detached 59be6a2

Merge pull request #120 from bnema/feat/incremental-session-snapshots

feat(snapshot): add incremental session persistence
commit
59be6a287795d122711ddb499804195a3bb314f5
author
bnema · · 2026-07-22 07:09 UTC
committer
GitHub · · 2026-07-22 07:09 UTC
merge of
2b23600 + ba65dc1 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
105 files · +13110 -4220
M go.mod +4 -1
@@ -2,7 +2,10 @@ module github.com/bnema/vev
22
33 go 1.26
44
5-require github.com/stretchr/testify v1.11.1
5+require (
6+ github.com/stretchr/testify v1.11.1
7+ golang.org/x/sys v0.47.0
8+)
69
710 require (
811 github.com/davecgh/go-spew v1.1.1 // indirect
M go.sum +2 -0 generated file: collapsed
A internal/adapters/snapshot/atomic.go +416 -0
@@ -0,0 +1,416 @@
1+package snapshot
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "io"
7+ "os"
8+ "path/filepath"
9+ "syscall"
10+
11+ "github.com/bnema/vev/pkg/safedir"
12+ "golang.org/x/sys/unix"
13+)
14+
15+func (r *Repository) ensureSession(key string) error {
16+ for _, directory := range []struct {
17+ path string
18+ phase string
19+ }{
20+ {r.dir, "repository"},
21+ {filepath.Join(r.dir, repositorySessionsDir), "sessions"},
22+ {r.sessionPath(key), "session"},
23+ {filepath.Join(r.sessionPath(key), repositoryObjectsDir), "objects"},
24+ {filepath.Join(r.sessionPath(key), repositoryGenerations), "generations"},
25+ } {
26+ if err := r.ensurePrivateDirectoryPhase(directory.path, directory.phase); err != nil {
27+ return fmt.Errorf("create snapshot repository directory: %w", err)
28+ }
29+ }
30+ return nil
31+}
32+
33+func (r *Repository) ensurePrivateDirectory(dir string) error {
34+ return r.ensurePrivateDirectoryPhase(dir, "snapshot directory")
35+}
36+
37+func (r *Repository) ensurePrivateDirectoryPhase(dir, phase string) error {
38+ // The configured root is the trust boundary. Once it is private, every
39+ // descendant is created through a pinned parent descriptor rather than via
40+ // a path that an attacker can replace between checks.
41+ if filepath.Clean(dir) == filepath.Clean(r.dir) {
42+ _, statErr := os.Lstat(dir)
43+ created := errors.Is(statErr, os.ErrNotExist)
44+ if statErr != nil && !created {
45+ return statErr
46+ }
47+ if err := safedir.EnsurePrivate(dir); err != nil {
48+ return err
49+ }
50+ if created {
51+ if hook := r.hooks.syncDirectory; hook != nil {
52+ if err := hook(filepath.Dir(dir)); err != nil {
53+ return fmt.Errorf("%s parent directory sync: %w", phase, err)
54+ }
55+ }
56+ if err := syncDirectory(filepath.Dir(dir)); err != nil {
57+ return fmt.Errorf("%s parent directory sync: %w", phase, err)
58+ }
59+ }
60+ return nil
61+ }
62+ if err := r.ensurePrivateDirectoryPhase(filepath.Dir(dir), "snapshot directory"); err != nil {
63+ return err
64+ }
65+ parent, name, err := r.repositoryParent(dir)
66+ if err != nil {
67+ return err
68+ }
69+ created := false
70+ mkdirErr := unix.Mkdirat(parent, name, 0o700)
71+ if mkdirErr == nil {
72+ created = true
73+ }
74+ parentCloseErr := r.closeDescriptor(parent, "close snapshot parent directory")
75+ if mkdirErr != nil && !errors.Is(mkdirErr, syscall.EEXIST) {
76+ err = mkdirErr
77+ joinCloseError(&err, "close snapshot parent directory", parentCloseErr)
78+ return err
79+ }
80+ if parentCloseErr != nil {
81+ return fmt.Errorf("close snapshot parent directory: %w", parentCloseErr)
82+ }
83+ opened, err := r.openDirectory(dir)
84+ if err != nil {
85+ return err
86+ }
87+ var st syscall.Stat_t
88+ statErr := syscall.Fstat(int(opened.Fd()), &st)
89+ closeErr := opened.Close()
90+ if statErr != nil {
91+ return statErr
92+ }
93+ if closeErr != nil {
94+ return closeErr
95+ }
96+ if int(st.Uid) != os.Geteuid() || st.Mode&0o777 != 0o700 {
97+ return fmt.Errorf("snapshot directory is not private")
98+ }
99+ if created {
100+ if err := r.syncDirectory(filepath.Dir(dir)); err != nil {
101+ return fmt.Errorf("%s parent directory sync: %w", phase, err)
102+ }
103+ }
104+ return nil
105+}
106+
107+// writeImmutable publishes data with link(2), whose EEXIST behavior prevents a
108+// raced target from being overwritten. A competing target is accepted only
109+// after verifier reads it through the same secure descriptor path as Load.
110+func (r *Repository) writeImmutable(path string, data []byte, verifier func([]byte) error) error {
111+ dir := filepath.Dir(path)
112+ phase := "object shard"
113+ if filepath.Base(dir) == repositoryGenerations {
114+ phase = "generation"
115+ }
116+ if err := r.ensurePrivateDirectoryPhase(dir, phase); err != nil {
117+ return err
118+ }
119+ return r.withAtomicTemp(dir, data, func(tempPath string) (bool, error) {
120+ if err := r.installImmutable(tempPath, path); err != nil {
121+ if !errors.Is(err, os.ErrExist) {
122+ return true, err
123+ }
124+ existing, readErr := r.readBounded(path)
125+ if readErr != nil {
126+ return true, readErr
127+ }
128+ return true, verifier(existing)
129+ }
130+ // link retains the temporary name, so cleanup must remove and sync it.
131+ return true, nil
132+ })
133+}
134+
135+// writeMutable is used only for HEAD, the sole authoritative pointer allowed
136+// to advance. The old or fully synced new HEAD is therefore always recoverable.
137+func (r *Repository) writeMutable(path string, data []byte) error {
138+ dir := filepath.Dir(path)
139+ if err := r.ensurePrivateDirectory(dir); err != nil {
140+ return err
141+ }
142+ return r.withAtomicTemp(dir, data, func(tempPath string) (bool, error) {
143+ if err := r.rename(tempPath, path); err != nil {
144+ return true, err
145+ }
146+ // rename consumed tempPath. Its directory sync persists both rename and
147+ // the absence of the temporary entry.
148+ if err := r.syncDirectory(dir); err != nil {
149+ return true, err
150+ }
151+ return false, nil
152+ })
153+}
154+
155+// withAtomicTemp prepares a private, durable temporary file, invokes publish,
156+// then closes and removes the temporary entry when publish reports that its
157+// operation did not consume it. Cleanup errors are joined after the operation
158+// error in the same order as the former immutable and mutable write paths.
159+func (r *Repository) withAtomicTemp(dir string, data []byte, publish func(string) (removeTemp bool, err error)) error {
160+ temp, err := r.createTemp(dir)
161+ if err != nil {
162+ return err
163+ }
164+ tempPath := temp.Name()
165+ closed := false
166+ cleanup := func(cause error) error {
167+ if !closed {
168+ if err := r.closeFile(temp); err != nil {
169+ cause = errors.Join(cause, fmt.Errorf("close snapshot temporary file: %w", err))
170+ }
171+ closed = true
172+ }
173+ if err := r.remove(tempPath); err != nil && !errors.Is(err, os.ErrNotExist) {
174+ cause = errors.Join(cause, err)
175+ } else if err == nil {
176+ cause = errors.Join(cause, r.syncDirectory(dir))
177+ }
178+ return cause
179+ }
180+ if err := temp.Chmod(0o600); err != nil {
181+ return cleanup(err)
182+ }
183+ if err := r.writeFile(temp, data); err != nil {
184+ return cleanup(err)
185+ }
186+ if err := r.syncFile(temp); err != nil {
187+ return cleanup(err)
188+ }
189+ if err := r.closeFile(temp); err != nil {
190+ closed = true
191+ return cleanup(fmt.Errorf("close snapshot temporary file: %w", err))
192+ }
193+ closed = true
194+ removeTemp, err := publish(tempPath)
195+ if !removeTemp {
196+ return err
197+ }
198+ return cleanup(err)
199+}
200+
201+func (r *Repository) createTemp(dir string) (*os.File, error) {
202+ if r.hooks.createTemp != nil {
203+ if err := r.hooks.createTemp(dir); err != nil {
204+ return nil, err
205+ }
206+ }
207+ return r.createTempAt(dir)
208+}
209+func (r *Repository) writeFile(f *os.File, data []byte) error {
210+ if r.hooks.writeTemp != nil {
211+ if err := r.hooks.writeTemp(f.Name()); err != nil {
212+ return err
213+ }
214+ }
215+ _, err := f.Write(data)
216+ return err
217+}
218+func (r *Repository) syncFile(f *os.File) error {
219+ if r.hooks.syncFile != nil {
220+ if err := r.hooks.syncFile(f.Name()); err != nil {
221+ return err
222+ }
223+ }
224+ return f.Sync()
225+}
226+func (r *Repository) closeFile(f *os.File) error {
227+ var injected error
228+ if r.hooks.closeFile != nil {
229+ injected = r.hooks.closeFile(f.Name())
230+ }
231+ return errors.Join(injected, f.Close())
232+}
233+
234+// closeDescriptor closes a raw descriptor while keeping its close boundary
235+// fault-injectable for tests. Callers add operation context before returning.
236+func (r *Repository) closeDescriptor(fd int, operation string) error {
237+ var injected error
238+ if r.hooks.closeDescriptor != nil {
239+ injected = r.hooks.closeDescriptor(operation)
240+ }
241+ return errors.Join(injected, syscall.Close(fd))
242+}
243+
244+func (r *Repository) closeRepositoryFile(f *os.File, operation string) error {
245+ var injected error
246+ if r.hooks.closeDescriptor != nil {
247+ injected = r.hooks.closeDescriptor(operation)
248+ }
249+ return errors.Join(injected, f.Close())
250+}
251+
252+// joinCloseError retains a primary operation failure and appends contextual
253+// close failures, including cleanup-only paths, without suppressing either.
254+func joinCloseError(primary *error, operation string, closeErr error) {
255+ if closeErr != nil {
256+ *primary = errors.Join(*primary, fmt.Errorf("%s: %w", operation, closeErr))
257+ }
258+}
259+
260+func (r *Repository) installImmutable(oldPath, newPath string) (err error) {
261+ if r.hooks.installImmutable != nil {
262+ if err := r.hooks.installImmutable(newPath); err != nil {
263+ return err
264+ }
265+ }
266+ oldFD, oldName, err := r.repositoryParent(oldPath)
267+ if err != nil {
268+ return err
269+ }
270+ defer func() {
271+ joinCloseError(&err, "close source snapshot parent directory", r.closeDescriptor(oldFD, "close source snapshot parent directory"))
272+ }()
273+ newFD, newName, err := r.repositoryParent(newPath)
274+ if err != nil {
275+ return err
276+ }
277+ defer func() {
278+ joinCloseError(&err, "close destination snapshot parent directory", r.closeDescriptor(newFD, "close destination snapshot parent directory"))
279+ }()
280+ return unix.Linkat(oldFD, oldName, newFD, newName, 0)
281+}
282+func (r *Repository) rename(oldPath, newPath string) (err error) {
283+ if r.hooks.rename != nil {
284+ if err := r.hooks.rename(newPath); err != nil {
285+ return err
286+ }
287+ }
288+ oldFD, oldName, err := r.repositoryParent(oldPath)
289+ if err != nil {
290+ return err
291+ }
292+ defer func() {
293+ joinCloseError(&err, "close source snapshot parent directory", r.closeDescriptor(oldFD, "close source snapshot parent directory"))
294+ }()
295+ newFD, newName, err := r.repositoryParent(newPath)
296+ if err != nil {
297+ return err
298+ }
299+ defer func() {
300+ joinCloseError(&err, "close destination snapshot parent directory", r.closeDescriptor(newFD, "close destination snapshot parent directory"))
301+ }()
302+ return unix.Renameat(oldFD, oldName, newFD, newName)
303+}
304+func (r *Repository) remove(path string) (err error) {
305+ if r.hooks.remove != nil {
306+ if err := r.hooks.remove(path); err != nil {
307+ return err
308+ }
309+ }
310+ fd, name, err := r.repositoryParent(path)
311+ if err != nil {
312+ return err
313+ }
314+ defer func() {
315+ joinCloseError(&err, "close snapshot parent directory", r.closeDescriptor(fd, "close snapshot parent directory"))
316+ }()
317+ unlinkErr := unix.Unlinkat(fd, name, 0)
318+ if unlinkErr == nil {
319+ return nil
320+ }
321+ var stat unix.Stat_t
322+ if err := unix.Fstatat(fd, name, &stat, unix.AT_SYMLINK_NOFOLLOW); err != nil || !directoryUnlinkRetry(unlinkErr, uint32(stat.Mode)) {
323+ return unlinkErr
324+ }
325+ return unix.Unlinkat(fd, name, unix.AT_REMOVEDIR)
326+}
327+func (r *Repository) syncDirectory(dir string) error {
328+ if r.hooks.syncDirectory != nil {
329+ if err := r.hooks.syncDirectory(dir); err != nil {
330+ return err
331+ }
332+ }
333+ return syncDirectory(dir)
334+}
335+func syncDirectory(dir string) error {
336+ f, err := os.Open(dir)
337+ if err != nil {
338+ return err
339+ }
340+ if err := f.Sync(); err != nil {
341+ _ = f.Close()
342+ return err
343+ }
344+ return f.Close()
345+}
346+
347+func (r *Repository) readOptionalBounded(path string) ([]byte, bool, error) {
348+ data, err := r.readBounded(path)
349+ if errors.Is(err, os.ErrNotExist) {
350+ return nil, false, nil
351+ }
352+ return data, err == nil, err
353+}
354+
355+// readBounded opens the final component with O_NOFOLLOW, validates the opened
356+// descriptor, then reads a hard bounded amount. It deliberately never trusts
357+// path metadata obtained before opening the descriptor.
358+func (r *Repository) readBounded(path string) ([]byte, error) {
359+ f, err := r.repositoryFD(path, syscall.O_RDONLY|syscall.O_NONBLOCK, 0)
360+ if err != nil {
361+ return nil, err
362+ }
363+ return readBoundedFile(f)
364+}
365+
366+// readBounded remains for focused descriptor-hardening tests. Repository code
367+// must use r.readBounded so intermediate components are pinned and no-follow.
368+func readBounded(path string) ([]byte, error) {
369+ fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_CLOEXEC|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
370+ if err != nil {
371+ return nil, err
372+ }
373+ f := os.NewFile(uintptr(fd), path)
374+ if f == nil {
375+ _ = syscall.Close(fd)
376+ return nil, fmt.Errorf("open snapshot file")
377+ }
378+ return readBoundedFile(f)
379+}
380+
381+func readBoundedFile(f *os.File) ([]byte, error) {
382+ fd := int(f.Fd())
383+ var stat syscall.Stat_t
384+ if err := syscall.Fstat(fd, &stat); err != nil {
385+ _ = f.Close()
386+ return nil, err
387+ }
388+ if stat.Mode&syscall.S_IFMT != syscall.S_IFREG {
389+ _ = f.Close()
390+ return nil, fmt.Errorf("not a regular file")
391+ }
392+ if int(stat.Uid) != os.Geteuid() {
393+ _ = f.Close()
394+ return nil, fmt.Errorf("snapshot file is not owned by effective uid")
395+ }
396+ if stat.Mode&0o077 != 0 {
397+ _ = f.Close()
398+ return nil, fmt.Errorf("snapshot file has unsafe permissions")
399+ }
400+ if stat.Size < 0 || stat.Size > int64(maxRepositoryRead) {
401+ _ = f.Close()
402+ return nil, fmt.Errorf("snapshot file too large")
403+ }
404+ data, readErr := io.ReadAll(io.LimitReader(f, int64(maxRepositoryRead)+1))
405+ closeErr := f.Close()
406+ if readErr != nil {
407+ return nil, readErr
408+ }
409+ if closeErr != nil {
410+ return nil, closeErr
411+ }
412+ if len(data) > maxRepositoryRead {
413+ return nil, fmt.Errorf("snapshot file too large")
414+ }
415+ return data, nil
416+}
A internal/adapters/snapshot/atomic_test.go +126 -0
@@ -0,0 +1,126 @@
1+package snapshot
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "syscall"
10+ "testing"
11+)
12+
13+func TestWriteImmutableUsesOneFinalDirectorySync(t *testing.T) {
14+ repo := NewRepository(privateDir(t))
15+ dir := filepath.Join(repo.dir, "immutable")
16+ if err := repo.ensurePrivateDirectory(dir); err != nil {
17+ t.Fatal(err)
18+ }
19+
20+ syncs := 0
21+ repo.hooks.syncDirectory = func(got string) error {
22+ if got == dir {
23+ syncs++
24+ }
25+ return nil
26+ }
27+ data := []byte("immutable")
28+ if err := repo.writeImmutable(filepath.Join(dir, "object"), data, func(existing []byte) error {
29+ if !bytes.Equal(existing, data) {
30+ return errors.New("unexpected immutable data")
31+ }
32+ return nil
33+ }); err != nil {
34+ t.Fatal(err)
35+ }
36+ if syncs != 1 {
37+ t.Fatalf("successful immutable directory syncs = %d, want 1", syncs)
38+ }
39+}
40+
41+func TestWithAtomicTempPreservesLifecycleOrder(t *testing.T) {
42+ repo := NewRepository(privateDir(t))
43+ dir := filepath.Join(repo.dir, "temporary")
44+ if err := repo.ensurePrivateDirectory(dir); err != nil {
45+ t.Fatal(err)
46+ }
47+
48+ var events []string
49+ repo.hooks.createTemp = func(string) error { events = append(events, "create"); return nil }
50+ repo.hooks.writeTemp = func(string) error { events = append(events, "write"); return nil }
51+ repo.hooks.syncFile = func(string) error { events = append(events, "sync file"); return nil }
52+ repo.hooks.closeFile = func(string) error { events = append(events, "close"); return nil }
53+ repo.hooks.remove = func(string) error { events = append(events, "remove"); return nil }
54+ repo.hooks.syncDirectory = func(string) error { events = append(events, "sync directory"); return nil }
55+
56+ if err := repo.withAtomicTemp(dir, []byte("data"), func(path string) (bool, error) {
57+ events = append(events, "publish")
58+ info, err := os.Stat(path)
59+ if err != nil {
60+ return true, err
61+ }
62+ if got := info.Mode().Perm(); got != 0o600 {
63+ t.Fatalf("temporary file mode = %o, want 600", got)
64+ }
65+ return true, nil
66+ }); err != nil {
67+ t.Fatal(err)
68+ }
69+ want := []string{"create", "write", "sync file", "close", "publish", "remove", "sync directory"}
70+ if len(events) != len(want) {
71+ t.Fatalf("lifecycle = %v, want %v", events, want)
72+ }
73+ for i := range want {
74+ if events[i] != want[i] {
75+ t.Fatalf("lifecycle = %v, want %v", events, want)
76+ }
77+ }
78+}
79+
80+func TestInstallImmutableJoinsPrimaryAndParentCloseErrors(t *testing.T) {
81+ repo := NewRepository(privateDir(t))
82+ dir := filepath.Join(repo.dir, "objects")
83+ if err := repo.ensurePrivateDirectory(dir); err != nil {
84+ t.Fatal(err)
85+ }
86+ oldPath := filepath.Join(dir, "old")
87+ newPath := filepath.Join(dir, "new")
88+ if err := os.WriteFile(oldPath, []byte("old"), 0o600); err != nil {
89+ t.Fatal(err)
90+ }
91+ if err := os.WriteFile(newPath, []byte("new"), 0o600); err != nil {
92+ t.Fatal(err)
93+ }
94+ closeCause := errors.New("injected source parent close failure")
95+ repo.hooks.closeDescriptor = func(operation string) error {
96+ if operation == "close source snapshot parent directory" {
97+ return closeCause
98+ }
99+ return nil
100+ }
101+
102+ err := repo.installImmutable(oldPath, newPath)
103+ if !errors.Is(err, syscall.EEXIST) || !errors.Is(err, closeCause) {
104+ t.Fatalf("installImmutable error = %v, want link and close failures", err)
105+ }
106+ if !strings.Contains(err.Error(), "close source snapshot parent directory") {
107+ t.Fatalf("installImmutable error = %v, want close operation context", err)
108+ }
109+}
110+
111+func TestWriteMutableDoesNotRemoveConsumedTemporaryFile(t *testing.T) {
112+ repo := NewRepository(privateDir(t))
113+ path := filepath.Join(repo.dir, "HEAD")
114+ repo.hooks.remove = func(string) error { return errors.New("remove must not run") }
115+
116+ if err := repo.writeMutable(path, []byte("head")); err != nil {
117+ t.Fatalf("writeMutable error = %v", err)
118+ }
119+ got, err := os.ReadFile(path)
120+ if err != nil {
121+ t.Fatal(err)
122+ }
123+ if !bytes.Equal(got, []byte("head")) {
124+ t.Fatalf("HEAD = %q, want %q", got, "head")
125+ }
126+}
A internal/adapters/snapshot/dirent_cookie_darwin.go +28 -0
@@ -0,0 +1,28 @@
1+//go:build darwin
2+
3+package snapshot
4+
5+import (
6+ "errors"
7+ "io"
8+ "syscall"
9+ "unsafe"
10+)
11+
12+// syscall.ReadDirent on Darwin implements Getdirentries by storing the number
13+// of returned entries in the descriptor seek offset. Dirent.Seekoff is not
14+// that cursor. A max-sized record buffer lets each call be drained before the
15+// descriptor-maintained count is saved.
16+const maintenanceDirentBufferSize = int(unsafe.Sizeof(syscall.Dirent{}))
17+
18+func directoryCookie(file maintenanceDirectory, _ *syscall.Dirent) (int64, error) {
19+ return file.Seek(0, io.SeekCurrent)
20+}
21+
22+func drainMaintenanceDirentBatch() bool { return true }
23+
24+// Darwin reports EPERM, rather than EISDIR, when unlinkat without
25+// AT_REMOVEDIR targets a directory. The mode check preserves EPERM for files.
26+func directoryUnlinkRetry(err error, mode uint32) bool {
27+ return errors.Is(err, syscall.EPERM) && mode&syscall.S_IFMT == syscall.S_IFDIR
28+}
A internal/adapters/snapshot/dirent_cookie_darwin_test.go +29 -0
@@ -0,0 +1,29 @@
1+//go:build darwin
2+
3+package snapshot
4+
5+import (
6+ "syscall"
7+ "testing"
8+)
9+
10+func TestDirectoryCookieUsesDescriptorEntryCount(t *testing.T) {
11+ file := fakeMaintenanceDirectory{seekOffset: 42}
12+ dirent := syscall.Dirent{Seekoff: 99}
13+ got, err := directoryCookie(file, &dirent)
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ if got != 42 {
18+ t.Fatalf("directoryCookie() = %d, want descriptor entry count 42", got)
19+ }
20+}
21+
22+func TestDirectoryUnlinkRetryRequiresDarwinDirectoryEPERM(t *testing.T) {
23+ if directoryUnlinkRetry(syscall.EPERM, syscall.S_IFREG) {
24+ t.Fatal("directoryUnlinkRetry(EPERM, regular file) = true, want false")
25+ }
26+ if !directoryUnlinkRetry(syscall.EPERM, syscall.S_IFDIR) {
27+ t.Fatal("directoryUnlinkRetry(EPERM, directory) = false, want true")
28+ }
29+}
A internal/adapters/snapshot/dirent_cookie_linux.go +19 -0
@@ -0,0 +1,19 @@
1+//go:build linux
2+
3+package snapshot
4+
5+import (
6+ "errors"
7+ "syscall"
8+)
9+
10+const maintenanceDirentBufferSize = 8192
11+
12+// directoryCookie is d_off, the resumable getdents(2) cookie for Linux.
13+func directoryCookie(_ maintenanceDirectory, dirent *syscall.Dirent) (int64, error) {
14+ return dirent.Off, nil
15+}
16+
17+func drainMaintenanceDirentBatch() bool { return false }
18+
19+func directoryUnlinkRetry(err error, _ uint32) bool { return errors.Is(err, syscall.EISDIR) }
A internal/adapters/snapshot/dirent_cookie_linux_test.go +28 -0
@@ -0,0 +1,28 @@
1+//go:build linux
2+
3+package snapshot
4+
5+import (
6+ "syscall"
7+ "testing"
8+)
9+
10+func TestDirectoryCookieUsesLinuxOffset(t *testing.T) {
11+ dirent := syscall.Dirent{Off: 42}
12+ got, err := directoryCookie(fakeMaintenanceDirectory{}, &dirent)
13+ if err != nil {
14+ t.Fatal(err)
15+ }
16+ if got != 42 {
17+ t.Fatalf("directoryCookie() = %d, want 42", got)
18+ }
19+}
20+
21+func TestDirectoryUnlinkRetryRequiresLinuxEISDIR(t *testing.T) {
22+ if directoryUnlinkRetry(syscall.EPERM, syscall.S_IFDIR) {
23+ t.Fatal("directoryUnlinkRetry(EPERM, directory) = true, want false")
24+ }
25+ if !directoryUnlinkRetry(syscall.EISDIR, syscall.S_IFDIR) {
26+ t.Fatal("directoryUnlinkRetry(EISDIR, directory) = false, want true")
27+ }
28+}
A internal/adapters/snapshot/fd_count_darwin_test.go +28 -0
@@ -0,0 +1,28 @@
1+//go:build darwin
2+
3+package snapshot
4+
5+import (
6+ "os"
7+ "testing"
8+)
9+
10+// macOS exposes the calling process descriptor table through /dev/fd.
11+// Readdirnames avoids os.ReadDir's fstatat calls for descriptors that can
12+// disappear while the directory is being enumerated.
13+func openDescriptorCount(t *testing.T) int {
14+ t.Helper()
15+ dir, err := os.Open("/dev/fd")
16+ if err != nil {
17+ t.Fatal(err)
18+ }
19+ names, readErr := dir.Readdirnames(-1)
20+ closeErr := dir.Close()
21+ if readErr != nil {
22+ t.Fatal(readErr)
23+ }
24+ if closeErr != nil {
25+ t.Fatal(closeErr)
26+ }
27+ return len(names)
28+}
A internal/adapters/snapshot/fd_count_linux_test.go +19 -0
@@ -0,0 +1,19 @@
1+//go:build linux
2+
3+package snapshot
4+
5+import (
6+ "os"
7+ "testing"
8+)
9+
10+// openDescriptorCount checks the actual Linux descriptor table, not a mocked
11+// lifecycle signal, so the maintenance leak assertion remains strict.
12+func openDescriptorCount(t *testing.T) int {
13+ t.Helper()
14+ entries, err := os.ReadDir("/proc/self/fd")
15+ if err != nil {
16+ t.Fatal(err)
17+ }
18+ return len(entries)
19+}
A internal/adapters/snapshot/legacy.go +399 -0
@@ -0,0 +1,399 @@
1+package snapshot
2+
3+import (
4+ "bytes"
5+ "context"
6+ "crypto/sha256"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "os"
11+ "path/filepath"
12+ "sort"
13+ "strings"
14+
15+ "github.com/bnema/vev/internal/ports"
16+)
17+
18+const (
19+ legacyDeleteMarkerPrefix = ".legacy-import-receipt-"
20+ maxLegacyDeleteMarkers = maxLegacySnapshotFiles
21+ maxLegacyDeleteNameBytes = 200
22+)
23+
24+type legacyDeleteMarker struct {
25+ name string
26+ digest [sha256.Size]byte
27+}
28+
29+type legacyDirectory interface {
30+ ReadDir(int) ([]os.DirEntry, error)
31+ Close() error
32+}
33+
34+func (r *Repository) openLegacyDirectory(path string) (legacyDirectory, error) {
35+ if r.hooks.openLegacyDirectory != nil {
36+ return r.hooks.openLegacyDirectory(path)
37+ }
38+ return r.openDirectory(path)
39+}
40+
41+// readLegacyImportCandidate reports whether an untrusted root entry can be
42+// safely imported. Legacy scanning skips entries that disappear or fail secure
43+// descriptor validation so one hostile .snap file does not block other imports.
44+func (r *Repository) readLegacyImportCandidate(path string) ([]byte, bool) {
45+ data, err := r.readBounded(path)
46+ return data, err == nil
47+}
48+
49+// LoadLegacy reads only pre-incremental root .snap files. It is deliberately
50+// isolated from repository reads so incremental directories are never traversed.
51+func (r *Repository) LoadLegacy(ctx context.Context) (out []ports.LegacySnapshot, err error) {
52+ if err := ctx.Err(); err != nil {
53+ return nil, err
54+ }
55+ // One budget covers marker recovery and import. Every directory entry costs
56+ // work, including unrelated attacker-created names, so neither phase can be
57+ // made unbounded by a huge legacy root.
58+ budget := maxDirectoryTraversalEntries
59+ // A marker is an already-authorized deletion, not an import candidate. Finish
60+ // it before exposing any legacy blobs so process restart cannot republish one.
61+ if err := r.cleanupPendingLegacyDeletes(ctx, &budget); err != nil {
62+ return nil, err
63+ }
64+ f, err := r.openLegacyDirectory(r.dir)
65+ if errors.Is(err, os.ErrNotExist) {
66+ return nil, nil
67+ }
68+ if err != nil {
69+ return nil, fmt.Errorf("read legacy snapshot directory: %w", safeFilesystemError(err))
70+ }
71+ defer func() {
72+ if closeErr := f.Close(); closeErr != nil {
73+ err = errors.Join(err, fmt.Errorf("close legacy snapshot directory: %w", safeFilesystemError(closeErr)))
74+ }
75+ }()
76+
77+ out = make([]ports.LegacySnapshot, 0, maxLegacySnapshotFiles)
78+ files := 0
79+ total := 0
80+ for {
81+ entries, readErr := f.ReadDir(maintenanceBatch)
82+ for _, entry := range entries {
83+ if err := ctx.Err(); err != nil {
84+ return nil, err
85+ }
86+ if budget == 0 {
87+ return nil, ErrDirectoryTraversalBudget
88+ }
89+ budget--
90+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".snap") {
91+ continue
92+ }
93+ files++
94+ if files > maxLegacySnapshotFiles {
95+ return nil, fmt.Errorf("legacy snapshot import has too many files (maximum %d)", maxLegacySnapshotFiles)
96+ }
97+ path := filepath.Join(r.dir, entry.Name())
98+ data, importable := r.readLegacyImportCandidate(path)
99+ if !importable {
100+ continue
101+ }
102+ if len(data) > maxLegacySnapshotBytes-total {
103+ return nil, fmt.Errorf("legacy snapshot import exceeds aggregate byte limit (%d bytes)", maxLegacySnapshotBytes)
104+ }
105+ total += len(data)
106+ name := strings.TrimSuffix(entry.Name(), ".snap")
107+ if strings.HasPrefix(name, "@") || !safeNameRE.MatchString(name) {
108+ name = ""
109+ }
110+ killed, tombstoneErr := r.tombstoned(name)
111+ if tombstoneErr != nil {
112+ return nil, fmt.Errorf("read killed session marker: %w", safeFilesystemError(tombstoneErr))
113+ }
114+ if killed {
115+ continue
116+ }
117+ out = append(out, ports.LegacySnapshot{Name: name, Data: data})
118+ }
119+ if errors.Is(readErr, io.EOF) {
120+ break
121+ }
122+ if readErr != nil {
123+ return nil, fmt.Errorf("read legacy snapshot directory: %w", safeFilesystemError(readErr))
124+ }
125+ }
126+ sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
127+ return out, nil
128+}
129+
130+// DeleteVerifiedLegacy records a hash-bound import receipt before deleting the
131+// source. LoadLegacy consumes surviving receipts after restart, but only when
132+// the source still has the exact bytes that were verified and published.
133+func (r *Repository) DeleteVerifiedLegacy(ctx context.Context, blob ports.LegacySnapshot) error {
134+ if err := ctx.Err(); err != nil {
135+ return err
136+ }
137+ filename := filenameForName(blob.Name)
138+ lock := r.lockSession("legacy-sync:" + filename)
139+ defer r.unlockSession(lock)
140+ if err := ctx.Err(); err != nil {
141+ return err
142+ }
143+ receipt := legacyDeleteMarker{name: blob.Name, digest: sha256.Sum256(blob.Data)}
144+ if err := r.authorizeLegacyDelete(receipt); err != nil {
145+ return err
146+ }
147+ return r.deleteAuthorizedLegacy(ctx, filename, receipt)
148+}
149+
150+// DeleteLegacy is for deliberate session purges, which do not represent a
151+// verified import and therefore must not create an import receipt.
152+func (r *Repository) DeleteLegacy(ctx context.Context, name string) error {
153+ if err := ctx.Err(); err != nil {
154+ return err
155+ }
156+ filename := filenameForName(name)
157+ lock := r.lockSession("legacy-sync:" + filename)
158+ defer r.unlockSession(lock)
159+ return r.deleteLegacyFile(ctx, filename)
160+}
161+
162+func legacyDeleteMarkerFilename(key string) string { return legacyDeleteMarkerPrefix + key }
163+
164+func (r *Repository) authorizeLegacyDelete(receipt legacyDeleteMarker) error {
165+ if err := r.ensurePrivateDirectory(r.dir); err != nil {
166+ return fmt.Errorf("create legacy import receipt directory: %w", safeFilesystemError(err))
167+ }
168+ path := filepath.Join(r.dir, legacyDeleteMarkerFilename(sessionKey(receipt.name)))
169+ data := append(receipt.digest[:], receipt.name...)
170+ if err := r.writeImmutable(path, data, func(data []byte) error {
171+ if len(data) != sha256.Size+len(receipt.name) || string(data[sha256.Size:]) != receipt.name || !bytes.Equal(data[:sha256.Size], receipt.digest[:]) {
172+ return fmt.Errorf("legacy import receipt identity mismatch")
173+ }
174+ return nil
175+ }); err != nil {
176+ return fmt.Errorf("write legacy import receipt: %w", safeFilesystemError(err))
177+ }
178+ return nil
179+}
180+
181+// deleteAuthorizedLegacy deletes only a source whose bytes match the durable
182+// receipt. A replaced source invalidates the receipt and remains importable.
183+func (r *Repository) deleteAuthorizedLegacy(ctx context.Context, filename string, receipt legacyDeleteMarker) error {
184+ if _, pending := r.pendingLegacySync.Load(filename); pending {
185+ if err := r.syncPendingLegacy(ctx, filename); err != nil {
186+ return err
187+ }
188+ return r.clearLegacyDeleteAuthorization(receipt.name)
189+ }
190+ path := filepath.Join(r.dir, filename)
191+ data, err := r.readBounded(path)
192+ if err != nil && !errors.Is(err, os.ErrNotExist) {
193+ return fmt.Errorf("read legacy snapshot for deletion: %w", safeFilesystemError(err))
194+ }
195+ if err == nil && sha256.Sum256(data) != receipt.digest {
196+ return r.clearLegacyDeleteAuthorization(receipt.name)
197+ }
198+ return r.deleteLegacyFile(ctx, filename, receipt.name)
199+}
200+
201+func (r *Repository) deleteLegacyFile(ctx context.Context, filename string, clearReceipt ...string) error {
202+ if _, pending := r.pendingLegacySync.Load(filename); pending {
203+ if err := r.syncPendingLegacy(ctx, filename); err != nil {
204+ return err
205+ }
206+ } else {
207+ path := filepath.Join(r.dir, filename)
208+ if err := r.remove(path); err != nil {
209+ if !errors.Is(err, os.ErrNotExist) {
210+ return fmt.Errorf("delete legacy snapshot: %w", safeFilesystemError(err))
211+ }
212+ // A missing repository has no deletion to make durable. This preserves
213+ // idempotence for explicit purges before the snapshot root exists.
214+ if _, statErr := os.Lstat(r.dir); errors.Is(statErr, os.ErrNotExist) {
215+ return nil
216+ } else if statErr != nil {
217+ return fmt.Errorf("stat legacy snapshot directory: %w", safeFilesystemError(statErr))
218+ }
219+ }
220+ r.pendingLegacySync.Store(filename, struct{}{})
221+ if err := r.syncPendingLegacy(ctx, filename); err != nil {
222+ return err
223+ }
224+ }
225+ if len(clearReceipt) != 0 {
226+ return r.clearLegacyDeleteAuthorization(clearReceipt[0])
227+ }
228+ return nil
229+}
230+
231+func (r *Repository) clearLegacyDeleteAuthorization(name string) error {
232+ path := filepath.Join(r.dir, legacyDeleteMarkerFilename(sessionKey(name)))
233+ if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
234+ return fmt.Errorf("clear legacy import receipt: %w", safeFilesystemError(err))
235+ }
236+ if err := r.syncDirectory(r.dir); err != nil {
237+ return fmt.Errorf("sync legacy import receipt directory: %w", safeFilesystemError(err))
238+ }
239+ return nil
240+}
241+
242+func (r *Repository) cleanupPendingLegacyDeletes(ctx context.Context, budget *int) error {
243+ markers, err := r.pendingLegacyDeleteMarkers(ctx, budget)
244+ if errors.Is(err, os.ErrNotExist) {
245+ return nil
246+ }
247+ if err != nil {
248+ return err
249+ }
250+ for _, marker := range markers {
251+ filename := filenameForName(marker.name)
252+ lock := r.lockSession("legacy-sync:" + filename)
253+ err := r.deleteAuthorizedLegacy(ctx, filename, marker)
254+ r.unlockSession(lock)
255+ if err != nil {
256+ return fmt.Errorf("retry authorized legacy deletion: %w", err)
257+ }
258+ }
259+ return nil
260+}
261+
262+func (r *Repository) pendingLegacyDeleteMarkers(ctx context.Context, budget *int) (markers []legacyDeleteMarker, err error) {
263+ f, err := r.openLegacyDirectory(r.dir)
264+ if err != nil {
265+ return nil, err
266+ }
267+ defer func() {
268+ if closeErr := f.Close(); closeErr != nil {
269+ err = errors.Join(err, fmt.Errorf("close legacy deletion marker directory: %w", safeFilesystemError(closeErr)))
270+ }
271+ }()
272+ markers = make([]legacyDeleteMarker, 0, maxLegacyDeleteMarkers)
273+ for {
274+ entries, readErr := f.ReadDir(maintenanceBatch)
275+ for _, entry := range entries {
276+ if err := ctx.Err(); err != nil {
277+ return nil, err
278+ }
279+ if *budget == 0 {
280+ return nil, ErrDirectoryTraversalBudget
281+ }
282+ *budget--
283+ if entry.IsDir() || !strings.HasPrefix(entry.Name(), legacyDeleteMarkerPrefix) {
284+ continue
285+ }
286+ if len(markers) == maxLegacyDeleteMarkers {
287+ return nil, fmt.Errorf("legacy deletion retry has too many markers (maximum %d)", maxLegacyDeleteMarkers)
288+ }
289+ key := strings.TrimPrefix(entry.Name(), legacyDeleteMarkerPrefix)
290+ if !canonicalSessionKey(key) {
291+ return nil, fmt.Errorf("invalid legacy deletion marker")
292+ }
293+ path := filepath.Join(r.dir, entry.Name())
294+ data, dataErr := r.readBounded(path)
295+ if dataErr != nil || len(data) < sha256.Size || len(data)-sha256.Size > maxLegacyDeleteNameBytes {
296+ return nil, fmt.Errorf("invalid legacy import receipt")
297+ }
298+ name := string(data[sha256.Size:])
299+ if sessionKey(name) != key {
300+ return nil, fmt.Errorf("invalid legacy import receipt")
301+ }
302+ marker := legacyDeleteMarker{name: name}
303+ copy(marker.digest[:], data[:sha256.Size])
304+ markers = append(markers, marker)
305+ }
306+ if errors.Is(readErr, io.EOF) {
307+ break
308+ }
309+ if readErr != nil {
310+ return nil, fmt.Errorf("read legacy deletion markers: %w", safeFilesystemError(readErr))
311+ }
312+ }
313+ return markers, nil
314+}
315+
316+// Tombstone durably marks name as logically killed before either snapshot
317+// source is deleted. Startup consults this marker before importing legacy or
318+// restoring incremental state, so a partial offline kill cannot resurrect it.
319+func (r *Repository) Tombstone(ctx context.Context, name string) error {
320+ if err := ctx.Err(); err != nil {
321+ return err
322+ }
323+ key := sessionKey(name)
324+ lock := r.lockSession(key)
325+ defer r.unlockSession(lock)
326+ if err := ctx.Err(); err != nil {
327+ return err
328+ }
329+ if err := r.ensurePrivateDirectory(r.dir); err != nil {
330+ return fmt.Errorf("create killed session marker directory: %w", safeFilesystemError(err))
331+ }
332+ path := filepath.Join(r.dir, tombstoneFilename(key))
333+ if err := r.writeImmutable(path, []byte(name), func(data []byte) error {
334+ if string(data) != name {
335+ return fmt.Errorf("killed session marker identity mismatch")
336+ }
337+ return nil
338+ }); err != nil {
339+ return fmt.Errorf("write killed session marker: %w", safeFilesystemError(err))
340+ }
341+ return nil
342+}
343+
344+// DeleteTombstone removes a marker only after both sources have been durably
345+// deleted. A missing marker is idempotent for offline-kill retries.
346+func (r *Repository) DeleteTombstone(ctx context.Context, name string) error {
347+ if err := ctx.Err(); err != nil {
348+ return err
349+ }
350+ key := sessionKey(name)
351+ lock := r.lockSession(key)
352+ defer r.unlockSession(lock)
353+ if err := ctx.Err(); err != nil {
354+ return err
355+ }
356+ if err := r.remove(filepath.Join(r.dir, tombstoneFilename(key))); errors.Is(err, os.ErrNotExist) {
357+ return nil
358+ } else if err != nil {
359+ return fmt.Errorf("delete killed session marker: %w", safeFilesystemError(err))
360+ }
361+ if err := r.syncDirectory(r.dir); err != nil {
362+ return fmt.Errorf("sync killed session marker directory: %w", safeFilesystemError(err))
363+ }
364+ return nil
365+}
366+
367+func tombstoneFilename(key string) string { return ".killed-" + key }
368+
369+func (r *Repository) tombstoned(name string) (bool, error) {
370+ if name == "" {
371+ return false, nil
372+ }
373+ path := filepath.Join(r.dir, tombstoneFilename(sessionKey(name)))
374+ if hook := r.hooks.beforeTombstoneCheck; hook != nil {
375+ hook(path)
376+ }
377+ data, err := r.readBounded(path)
378+ if errors.Is(err, os.ErrNotExist) {
379+ return false, nil
380+ }
381+ if err != nil {
382+ return false, err
383+ }
384+ if string(data) != name {
385+ return false, fmt.Errorf("killed session marker identity mismatch")
386+ }
387+ return true, nil
388+}
389+
390+func (r *Repository) syncPendingLegacy(ctx context.Context, filename string) error {
391+ if err := ctx.Err(); err != nil {
392+ return err
393+ }
394+ if err := r.syncDirectory(r.dir); err != nil {
395+ return fmt.Errorf("sync legacy snapshot directory: %w", safeFilesystemError(err))
396+ }
397+ r.pendingLegacySync.Delete(filename)
398+ return nil
399+}
A internal/adapters/snapshot/legacy_pending_delete_test.go +63 -0
@@ -0,0 +1,63 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/ports"
13+)
14+
15+func TestRepositoryLoadLegacyCompletesAuthorizedDeleteAfterRestart(t *testing.T) {
16+ repo := NewRepository(privateDir(t))
17+ require.NoError(t, os.Mkdir(repo.dir, 0o700))
18+ path := filepath.Join(repo.dir, filenameForName("named"))
19+ require.NoError(t, os.WriteFile(path, []byte("legacy"), 0o600))
20+
21+ injected := errors.New("unlink failed")
22+ repo.hooks.remove = func(path string) error {
23+ if filepath.Base(path) == filenameForName("named") {
24+ return injected
25+ }
26+ return os.Remove(path)
27+ }
28+ // The injected unlink fault is after receipt persistence, simulating a
29+ // crash boundary where publication was verified but source cleanup was not.
30+ require.ErrorIs(t, repo.DeleteVerifiedLegacy(context.Background(), ports.LegacySnapshot{Name: "named", Data: []byte("legacy")}), injected)
31+
32+ // A new repository has no in-memory retry state. The durable receipt must
33+ // fence import and complete deletion without another publication.
34+ repo = NewRepository(repo.dir)
35+ legacy, err := repo.LoadLegacy(context.Background())
36+ require.NoError(t, err)
37+ require.Empty(t, legacy)
38+ _, err = os.Stat(path)
39+ require.ErrorIs(t, err, os.ErrNotExist)
40+}
41+
42+func TestRepositoryReceiptDoesNotDeleteReplacedLegacySource(t *testing.T) {
43+ repo := NewRepository(privateDir(t))
44+ require.NoError(t, os.Mkdir(repo.dir, 0o700))
45+ path := filepath.Join(repo.dir, filenameForName("named"))
46+ require.NoError(t, os.WriteFile(path, []byte("verified"), 0o600))
47+
48+ injected := errors.New("unlink failed")
49+ repo.hooks.remove = func(path string) error {
50+ if filepath.Base(path) == filenameForName("named") {
51+ return injected
52+ }
53+ return os.Remove(path)
54+ }
55+ require.ErrorIs(t, repo.DeleteVerifiedLegacy(context.Background(), ports.LegacySnapshot{Name: "named", Data: []byte("verified")}), injected)
56+ require.NoError(t, os.WriteFile(path, []byte("replacement"), 0o600))
57+
58+ repo = NewRepository(repo.dir)
59+ legacy, err := repo.LoadLegacy(context.Background())
60+ require.NoError(t, err)
61+ require.Equal(t, []ports.LegacySnapshot{{Name: "named", Data: []byte("replacement")}}, legacy)
62+ require.FileExists(t, path)
63+}
A internal/adapters/snapshot/legacy_test.go +141 -0
@@ -0,0 +1,141 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+ "testing"
12+)
13+
14+func TestRepositoryLegacyReadsOnlyBoundedRootSnapshots(t *testing.T) {
15+ repo := NewRepository(privateDir(t))
16+ if err := os.MkdirAll(filepath.Join(repo.dir, repositorySessionsDir), 0o700); err != nil {
17+ t.Fatal(err)
18+ }
19+ if err := os.WriteFile(filepath.Join(repo.dir, "named.snap"), []byte("legacy"), 0o600); err != nil {
20+ t.Fatal(err)
21+ }
22+ if err := os.WriteFile(filepath.Join(repo.dir, filenameForName("unsafe/name")), []byte("hashed"), 0o600); err != nil {
23+ t.Fatal(err)
24+ }
25+ if err := os.WriteFile(filepath.Join(repo.dir, repositorySessionsDir, "ignored.snap"), []byte("incremental"), 0o600); err != nil {
26+ t.Fatal(err)
27+ }
28+
29+ got, err := repo.LoadLegacy(context.Background())
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ if len(got) != 2 || got[0].Name != "" || got[1].Name != "named" {
34+ t.Fatalf("LoadLegacy = %#v, want root safe and hashed snapshots", got)
35+ }
36+}
37+
38+func TestRepositoryLoadLegacyReportsDirectoryCloseError(t *testing.T) {
39+ for _, tt := range []struct {
40+ name string
41+ failOpen int
42+ operation string
43+ }{
44+ {name: "pending deletion markers", failOpen: 1, operation: "close legacy deletion marker directory"},
45+ {name: "legacy snapshots", failOpen: 2, operation: "close legacy snapshot directory"},
46+ } {
47+ t.Run(tt.name, func(t *testing.T) {
48+ closeErr := errors.New("injected directory close failure")
49+ repo := NewRepository(privateDir(t))
50+ openCalls := 0
51+ repo.hooks.openLegacyDirectory = func(string) (legacyDirectory, error) {
52+ openCalls++
53+ if openCalls == tt.failOpen {
54+ return fakeLegacyDirectory{closeErr: closeErr}, nil
55+ }
56+ return fakeLegacyDirectory{}, nil
57+ }
58+
59+ _, err := repo.LoadLegacy(context.Background())
60+ if !errors.Is(err, closeErr) {
61+ t.Fatalf("LoadLegacy error = %v, want close error", err)
62+ }
63+ if !strings.Contains(err.Error(), tt.operation) {
64+ t.Fatalf("LoadLegacy error = %v, want %q", err, tt.operation)
65+ }
66+ })
67+ }
68+}
69+
70+type fakeLegacyDirectory struct {
71+ closeErr error
72+}
73+
74+func (d fakeLegacyDirectory) ReadDir(int) ([]os.DirEntry, error) { return nil, io.EOF }
75+func (d fakeLegacyDirectory) Close() error { return d.closeErr }
76+
77+func TestRepositoryLegacyRejectsTooManyAndTooLargeAggregateSnapshots(t *testing.T) {
78+ t.Run("count", func(t *testing.T) {
79+ repo := NewRepository(privateDir(t))
80+ if err := os.Mkdir(repo.dir, 0o700); err != nil {
81+ t.Fatal(err)
82+ }
83+ for i := 0; i < maxLegacySnapshotFiles+1; i++ {
84+ if err := os.WriteFile(filepath.Join(repo.dir, fmt.Sprintf("%03d.snap", i)), []byte("legacy"), 0o600); err != nil {
85+ t.Fatal(err)
86+ }
87+ }
88+ if _, err := repo.LoadLegacy(context.Background()); err == nil || !strings.Contains(err.Error(), "too many") {
89+ t.Fatalf("LoadLegacy error = %v, want actionable count limit", err)
90+ }
91+ })
92+ t.Run("aggregate bytes", func(t *testing.T) {
93+ repo := NewRepository(privateDir(t))
94+ if err := os.Mkdir(repo.dir, 0o700); err != nil {
95+ t.Fatal(err)
96+ }
97+ for i, size := range []int{maxLegacySnapshotBytes / 2, maxLegacySnapshotBytes/2 + 1} {
98+ if err := os.WriteFile(filepath.Join(repo.dir, fmt.Sprintf("%d.snap", i)), make([]byte, size), 0o600); err != nil {
99+ t.Fatal(err)
100+ }
101+ }
102+ if _, err := repo.LoadLegacy(context.Background()); err == nil || !strings.Contains(err.Error(), "aggregate") {
103+ t.Fatalf("LoadLegacy error = %v, want actionable aggregate limit", err)
104+ }
105+ })
106+}
107+
108+func TestRepositoryDeleteLegacyMissingFileDoesNotRequireRoot(t *testing.T) {
109+ repo := NewRepository(privateDir(t))
110+ if err := repo.DeleteLegacy(context.Background(), "missing"); err != nil {
111+ t.Fatalf("DeleteLegacy missing file: %v", err)
112+ }
113+}
114+
115+func TestRepositoryDeleteLegacySurfacesRootSyncAndCanRetry(t *testing.T) {
116+ repo := NewRepository(privateDir(t))
117+ if err := os.Mkdir(repo.dir, 0o700); err != nil {
118+ t.Fatal(err)
119+ }
120+ if err := os.WriteFile(filepath.Join(repo.dir, filenameForName("named")), []byte("legacy"), 0o600); err != nil {
121+ t.Fatal(err)
122+ }
123+ injected := errors.New("root sync")
124+ syncCalls := 0
125+ repo.hooks.syncDirectory = func(string) error {
126+ syncCalls++
127+ if syncCalls == 1 {
128+ return injected
129+ }
130+ return nil
131+ }
132+ if err := repo.DeleteLegacy(context.Background(), "named"); !errors.Is(err, injected) {
133+ t.Fatalf("DeleteLegacy error = %v, want root sync error", err)
134+ }
135+ if err := repo.DeleteLegacy(context.Background(), "named"); err != nil {
136+ t.Fatalf("retry DeleteLegacy: %v", err)
137+ }
138+ if syncCalls != 2 {
139+ t.Fatalf("sync calls = %d, want retry to sync absent file", syncCalls)
140+ }
141+}
A internal/adapters/snapshot/load.go +285 -0
@@ -0,0 +1,285 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "crypto/sha256"
6+ "encoding/binary"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "os"
11+ "path/filepath"
12+ "sort"
13+
14+ "github.com/bnema/vev/internal/ports"
15+ codec "github.com/bnema/vev/internal/usecase/snapshot"
16+)
17+
18+// Directory scans are deliberately finite. Snapshot directories are attacker
19+// controlled state, so callers get a retryable error instead of retaining an
20+// arbitrary number of names or spending an arbitrary amount of work.
21+const (
22+ directoryTraversalBatch = 64
23+ maxDirectoryTraversalEntries = 4096
24+)
25+
26+var (
27+ ErrDirectoryTraversalBudget = errors.New("snapshot directory traversal budget exceeded")
28+ ErrInvalidHEAD = errors.New("invalid snapshot HEAD")
29+)
30+
31+func (r *Repository) List(ctx context.Context) ([]string, error) {
32+ if err := ctx.Err(); err != nil {
33+ return nil, err
34+ }
35+ budget := maxDirectoryTraversalEntries
36+ out := make([]string, 0)
37+ sessions := filepath.Join(r.dir, repositorySessionsDir)
38+ err := r.walkDirectory(ctx, sessions, &budget, func(entry os.DirEntry) error {
39+ if !entry.IsDir() || !canonicalSessionKey(entry.Name()) {
40+ return nil
41+ }
42+ generation, ok, err := r.loadNewestGeneration(ctx, entry.Name(), &budget)
43+ if err != nil || !ok {
44+ return err
45+ }
46+ out = append(out, generation.Name)
47+ return nil
48+ })
49+ if errors.Is(err, os.ErrNotExist) {
50+ return nil, nil
51+ }
52+ if err != nil {
53+ return nil, fmt.Errorf("read snapshot sessions: %w", err)
54+ }
55+ sort.Strings(out)
56+ return out, nil
57+}
58+
59+func (r *Repository) Load(ctx context.Context, name string) (ports.SnapshotGeneration, error) {
60+ if err := ctx.Err(); err != nil {
61+ return ports.SnapshotGeneration{}, err
62+ }
63+ key := sessionKey(name)
64+ lock := r.lockSession(key)
65+ defer r.unlockSession(lock)
66+ if err := ctx.Err(); err != nil {
67+ return ports.SnapshotGeneration{}, err
68+ }
69+ killed, err := r.tombstoned(name)
70+ if err != nil {
71+ return ports.SnapshotGeneration{}, fmt.Errorf("read killed session marker: %w", err)
72+ }
73+ if killed {
74+ return ports.SnapshotGeneration{}, fmt.Errorf("snapshot session %q is killed", name)
75+ }
76+ return r.load(ctx, name, key)
77+}
78+
79+func (r *Repository) load(ctx context.Context, name, key string) (ports.SnapshotGeneration, error) {
80+ preferred, preferredDigest, err := r.readHead(key)
81+ skipped := errors.Is(err, ErrInvalidHEAD)
82+ if err != nil && !errors.Is(err, os.ErrNotExist) && !errors.Is(err, ErrInvalidHEAD) {
83+ return ports.SnapshotGeneration{}, fmt.Errorf("read snapshot HEAD: %w", err)
84+ }
85+ if preferred != 0 {
86+ got, err := r.loadGeneration(ctx, name, key, preferred)
87+ if err == nil && sha256.Sum256(got.Manifest) == preferredDigest {
88+ return got, nil
89+ }
90+ skipped = true
91+ }
92+
93+ budget := maxDirectoryTraversalEntries
94+ candidates, err := r.generationCandidates(ctx, key, preferred, &budget)
95+ if err != nil {
96+ return ports.SnapshotGeneration{}, err
97+ }
98+ for _, generation := range candidates {
99+ got, err := r.loadGeneration(ctx, name, key, generation)
100+ if err != nil {
101+ skipped = true
102+ continue
103+ }
104+ got.Fallback = skipped || preferred != 0
105+ return got, nil
106+ }
107+ return ports.SnapshotGeneration{}, fmt.Errorf("no complete snapshot generation for %q", name)
108+}
109+
110+func (r *Repository) currentGeneration(ctx context.Context, name, key string) (uint64, []byte, error) {
111+ generation, digest, err := r.readHead(key)
112+ if err == nil {
113+ got, err := r.loadGeneration(ctx, name, key, generation)
114+ if err == nil && sha256.Sum256(got.Manifest) == digest {
115+ return generation, got.Manifest, nil
116+ }
117+ } else if !errors.Is(err, os.ErrNotExist) && !errors.Is(err, ErrInvalidHEAD) {
118+ return 0, nil, fmt.Errorf("read snapshot HEAD: %w", err)
119+ }
120+ budget := maxDirectoryTraversalEntries
121+ candidates, err := r.generationCandidates(ctx, key, 0, &budget)
122+ if err != nil {
123+ return 0, nil, err
124+ }
125+ for _, generation := range candidates {
126+ got, err := r.loadGeneration(ctx, name, key, generation)
127+ if err == nil {
128+ return generation, got.Manifest, nil
129+ }
130+ }
131+ return 0, nil, nil
132+}
133+
134+// loadNewestGeneration validates a single bounded candidate batch in strict
135+// descending generation order. It is used by List, whose name is discovered
136+// from each manifest rather than supplied by a caller.
137+func (r *Repository) loadNewestGeneration(ctx context.Context, key string, budget *int) (ports.SnapshotGeneration, bool, error) {
138+ candidates, err := r.generationCandidates(ctx, key, 0, budget)
139+ if err != nil {
140+ return ports.SnapshotGeneration{}, false, err
141+ }
142+ for _, generation := range candidates {
143+ data, err := r.readBounded(r.manifestPath(key, generation))
144+ if err != nil {
145+ continue
146+ }
147+ manifest, err := codec.UnmarshalManifest(data)
148+ if err != nil || sessionKey(manifest.Name) != key {
149+ continue
150+ }
151+ killed, err := r.tombstoned(manifest.Name)
152+ if err != nil {
153+ return ports.SnapshotGeneration{}, false, fmt.Errorf("read killed session marker: %w", err)
154+ }
155+ if killed {
156+ return ports.SnapshotGeneration{}, false, nil
157+ }
158+ got, err := r.loadGeneration(ctx, manifest.Name, key, generation)
159+ if err == nil {
160+ return got, true, nil
161+ }
162+ }
163+ return ports.SnapshotGeneration{}, false, nil
164+}
165+
166+// generationCandidates reads the generations directory once per load attempt.
167+// The shared traversal budget bounds both retained names and work; sorting the
168+// bounded batch preserves strict newest-to-oldest fallback without rescanning.
169+func (r *Repository) generationCandidates(ctx context.Context, key string, exclude uint64, budget *int) ([]uint64, error) {
170+ candidates := make([]uint64, 0)
171+ err := r.walkDirectory(ctx, filepath.Join(r.sessionPath(key), repositoryGenerations), budget, func(entry os.DirEntry) error {
172+ if entry.IsDir() {
173+ return nil
174+ }
175+ generation, ok := parseGenerationFilename(entry.Name())
176+ if ok && generation != exclude {
177+ candidates = append(candidates, generation)
178+ }
179+ return nil
180+ })
181+ if errors.Is(err, os.ErrNotExist) {
182+ return candidates, nil
183+ }
184+ if err != nil {
185+ return nil, err
186+ }
187+ sort.Slice(candidates, func(i, j int) bool { return candidates[i] > candidates[j] })
188+ return candidates, nil
189+}
190+
191+// walkDirectory is the common finite directory traversal primitive for reads.
192+// File.ReadDir maintains a cursor on one descriptor and returns at most one
193+// small batch, so no full directory enumeration is retained in memory.
194+func (r *Repository) walkDirectory(ctx context.Context, dir string, budget *int, visit func(os.DirEntry) error) (err error) {
195+ file, err := r.openDirectory(dir)
196+ if err != nil {
197+ return err
198+ }
199+ defer func() {
200+ if closeErr := file.Close(); err == nil && closeErr != nil {
201+ err = closeErr
202+ }
203+ }()
204+ for {
205+ if err := ctx.Err(); err != nil {
206+ return err
207+ }
208+ entries, readErr := file.ReadDir(directoryTraversalBatch)
209+ for _, entry := range entries {
210+ if *budget == 0 {
211+ return ErrDirectoryTraversalBudget
212+ }
213+ *budget--
214+ if err := visit(entry); err != nil {
215+ return err
216+ }
217+ }
218+ if errors.Is(readErr, io.EOF) {
219+ return nil
220+ }
221+ if readErr != nil {
222+ return readErr
223+ }
224+ }
225+}
226+
227+func (r *Repository) loadGeneration(ctx context.Context, name, key string, generation uint64) (ports.SnapshotGeneration, error) {
228+ data, err := r.readBounded(r.manifestPath(key, generation))
229+ if err != nil {
230+ return ports.SnapshotGeneration{}, err
231+ }
232+ manifest, err := codec.UnmarshalManifest(data)
233+ if err != nil || manifest.Name != name || manifest.Generation != generation {
234+ return ports.SnapshotGeneration{}, fmt.Errorf("invalid manifest")
235+ }
236+ refs := manifestRefs(manifest)
237+ if refs == nil || !withinGenerationBudget(len(data), refs) {
238+ return ports.SnapshotGeneration{}, fmt.Errorf("snapshot generation too large")
239+ }
240+ objects := make(map[ports.SnapshotDigest][]byte, len(refs))
241+ for digest, ref := range refs {
242+ if err := ctx.Err(); err != nil {
243+ return ports.SnapshotGeneration{}, err
244+ }
245+ object, err := r.readBounded(r.objectPath(key, digest))
246+ if err != nil {
247+ return ports.SnapshotGeneration{}, err
248+ }
249+ if sha256.Sum256(object) != digest || !validObject(object, ref) {
250+ return ports.SnapshotGeneration{}, fmt.Errorf("invalid object")
251+ }
252+ objects[digest] = object
253+ }
254+ return ports.SnapshotGeneration{Name: manifest.Name, Generation: generation, Manifest: data, Objects: objects}, nil
255+}
256+
257+func marshalHead(generation uint64, digest ports.SnapshotDigest) []byte {
258+ out := make([]byte, 4+8+sha256.Size)
259+ copy(out, "VEVH")
260+ binary.BigEndian.PutUint64(out[4:12], generation)
261+ copy(out[12:], digest[:])
262+ return out
263+}
264+func (r *Repository) readHead(key string) (uint64, ports.SnapshotDigest, error) {
265+ path := r.headPath(key)
266+ if hook := r.hooks.beforeHeadRead; hook != nil {
267+ if err := hook(path); err != nil {
268+ return 0, ports.SnapshotDigest{}, err
269+ }
270+ }
271+ data, err := r.readBounded(path)
272+ if err != nil {
273+ return 0, ports.SnapshotDigest{}, err
274+ }
275+ if len(data) != 4+8+sha256.Size || string(data[:4]) != "VEVH" {
276+ return 0, ports.SnapshotDigest{}, ErrInvalidHEAD
277+ }
278+ generation := binary.BigEndian.Uint64(data[4:12])
279+ var digest ports.SnapshotDigest
280+ copy(digest[:], data[12:])
281+ if generation == 0 {
282+ return 0, ports.SnapshotDigest{}, ErrInvalidHEAD
283+ }
284+ return generation, digest, nil
285+}
A internal/adapters/snapshot/load_bounds_test.go +112 -0
@@ -0,0 +1,112 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+)
11+
12+func TestRepositoryListStopsAtTraversalBudget(t *testing.T) {
13+ repo := NewRepository(privateDir(t))
14+ sessions := filepath.Join(repo.dir, repositorySessionsDir)
15+ if err := os.MkdirAll(sessions, 0o700); err != nil {
16+ t.Fatal(err)
17+ }
18+ for i := 0; i < maxDirectoryTraversalEntries+1; i++ {
19+ if err := os.Mkdir(filepath.Join(sessions, fmt.Sprintf("hostile-%05d", i)), 0o700); err != nil {
20+ t.Fatal(err)
21+ }
22+ }
23+
24+ _, err := repo.List(context.Background())
25+ if !errors.Is(err, ErrDirectoryTraversalBudget) {
26+ t.Fatalf("List error = %v, want directory traversal budget", err)
27+ }
28+}
29+
30+func TestRepositoryLoadStopsAtGenerationTraversalBudget(t *testing.T) {
31+ repo := NewRepository(privateDir(t))
32+ key := sessionKey("named")
33+ generations := filepath.Join(repo.sessionPath(key), repositoryGenerations)
34+ if err := os.MkdirAll(generations, 0o700); err != nil {
35+ t.Fatal(err)
36+ }
37+ for generation := uint64(1); generation <= uint64(maxDirectoryTraversalEntries+1); generation++ {
38+ if err := os.WriteFile(filepath.Join(generations, generationFilename(generation)), nil, 0o600); err != nil {
39+ t.Fatal(err)
40+ }
41+ }
42+
43+ _, err := repo.Load(context.Background(), "named")
44+ if !errors.Is(err, ErrDirectoryTraversalBudget) {
45+ t.Fatalf("Load error = %v, want directory traversal budget", err)
46+ }
47+}
48+
49+func TestRepositoryLoadFallsBackNewestToOldestWithoutGenerationEnumeration(t *testing.T) {
50+ repo := NewRepository(privateDir(t))
51+ for generation := uint64(1); generation <= 3; generation++ {
52+ if err := repo.Publish(context.Background(), repositoryPublication(t, "named", generation, []byte(fmt.Sprintf("state-%d", generation)))); err != nil {
53+ t.Fatal(err)
54+ }
55+ }
56+ key := sessionKey("named")
57+ publication := repositoryPublication(t, "named", 3, []byte("state-3"))
58+ if err := os.Remove(repo.objectPath(key, publication.Objects[0].Digest)); err != nil {
59+ t.Fatal(err)
60+ }
61+
62+ got, err := repo.Load(context.Background(), "named")
63+ if err != nil {
64+ t.Fatal(err)
65+ }
66+ if got.Generation != 2 || !got.Fallback {
67+ t.Fatalf("Load = generation %d fallback %v, want generation 2 fallback true", got.Generation, got.Fallback)
68+ }
69+}
70+
71+func TestRepositoryMaintainDoesNotCollectBeyondRetainedMetadataBudget(t *testing.T) {
72+ repo := NewRepository(privateDir(t))
73+ for generation := uint64(1); generation <= uint64(maxMaintenanceMarkedGenerations+1); generation++ {
74+ if err := repo.Publish(context.Background(), repositoryPublication(t, "named", generation, []byte(fmt.Sprintf("state-%d", generation)))); err != nil {
75+ t.Fatal(err)
76+ }
77+ }
78+ for pass := 0; pass < maxMaintenanceMarkedGenerations*3; pass++ {
79+ if err := repo.Maintain(context.Background()); err != nil {
80+ t.Fatal(err)
81+ }
82+ if repo.maintenanceSessions[sessionKey("named")] == nil {
83+ break
84+ }
85+ if pass == maxMaintenanceMarkedGenerations*3-1 {
86+ t.Fatal("maintenance did not complete after bounded object-shard traversal")
87+ }
88+ }
89+ if _, err := os.Lstat(repo.manifestPath(sessionKey("named"), 1)); err != nil {
90+ t.Fatalf("old manifest collected after mark budget overflow: %v", err)
91+ }
92+ if state := repo.maintenanceSessions[sessionKey("named")]; state != nil {
93+ t.Fatalf("overflow maintenance state retained = %#v, want reset", state)
94+ }
95+}
96+
97+func TestMaintenanceMarkStateIsCapped(t *testing.T) {
98+ references := &sessionMaintenance{marked: make(map[uint64]manifestMaintenance)}
99+ if !references.canRetainManifest(maxMaintenanceReferences) {
100+ t.Fatal("state rejected references at its documented ceiling")
101+ }
102+ if references.canRetainManifest(1) {
103+ t.Fatal("state accepted references beyond its documented ceiling")
104+ }
105+ generations := &sessionMaintenance{marked: make(map[uint64]manifestMaintenance)}
106+ for generation := uint64(1); generation <= maxMaintenanceMarkedGenerations; generation++ {
107+ generations.marked[generation] = manifestMaintenance{}
108+ }
109+ if generations.canRetainManifest(0) {
110+ t.Fatal("state accepted generations beyond its documented ceiling")
111+ }
112+}
A internal/adapters/snapshot/load_major_test.go +88 -0
@@ -0,0 +1,88 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "os"
6+ "syscall"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestRepositoryLoadPropagatesOperationalHeadErrors(t *testing.T) {
13+ repo := NewRepository(privateDir(t))
14+ require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))))
15+
16+ repo.hooks.beforeHeadRead = func(string) error { return os.ErrPermission }
17+
18+ _, err := repo.Load(context.Background(), "named")
19+ require.ErrorIs(t, err, os.ErrPermission)
20+}
21+
22+func TestRepositoryCurrentGenerationPropagatesOperationalHeadErrors(t *testing.T) {
23+ repo := NewRepository(privateDir(t))
24+ require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))))
25+
26+ repo.hooks.beforeHeadRead = func(string) error { return syscall.EIO }
27+
28+ _, _, err := repo.currentGeneration(context.Background(), "named", sessionKey("named"))
29+ require.ErrorIs(t, err, syscall.EIO)
30+}
31+
32+func TestRepositoryLoadFallsBackFromInvalidHead(t *testing.T) {
33+ repo := NewRepository(privateDir(t))
34+ require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))))
35+ require.NoError(t, os.WriteFile(repo.headPath(sessionKey("named")), []byte("invalid"), 0o600))
36+
37+ got, err := repo.Load(context.Background(), "named")
38+ require.NoError(t, err)
39+ require.Equal(t, uint64(1), got.Generation)
40+ require.True(t, got.Fallback)
41+}
42+
43+func TestRepositoryLoadFallsBackAcrossMultipleCorruptCandidates(t *testing.T) {
44+ repo := NewRepository(privateDir(t))
45+ for generation := uint64(1); generation <= 4; generation++ {
46+ require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", generation, []byte{byte(generation)})))
47+ }
48+ key := sessionKey("named")
49+ for _, generation := range []uint64{4, 3} {
50+ publication := repositoryPublication(t, "named", generation, []byte{byte(generation)})
51+ require.NoError(t, os.Remove(repo.objectPath(key, publication.Objects[0].Digest)))
52+ }
53+
54+ got, err := repo.Load(context.Background(), "named")
55+ require.NoError(t, err)
56+ require.Equal(t, uint64(2), got.Generation)
57+ require.True(t, got.Fallback)
58+}
59+
60+func TestRepositoryLoadHoldsSessionLockAcrossTombstoneCheck(t *testing.T) {
61+ repo := NewRepository(privateDir(t))
62+ require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))))
63+
64+ tombstoneCheckStarted := make(chan struct{})
65+ releaseTombstoneCheck := make(chan struct{})
66+ repo.hooks.beforeTombstoneCheck = func(string) {
67+ close(tombstoneCheckStarted)
68+ <-releaseTombstoneCheck
69+ }
70+ loadDone := make(chan error, 1)
71+ go func() {
72+ _, err := repo.Load(context.Background(), "named")
73+ loadDone <- err
74+ }()
75+ <-tombstoneCheckStarted
76+
77+ tombstoneDone := make(chan error, 1)
78+ go func() { tombstoneDone <- repo.Tombstone(context.Background(), "named") }()
79+ select {
80+ case err := <-tombstoneDone:
81+ t.Fatalf("Tombstone completed before Load released the session lock: %v", err)
82+ default:
83+ }
84+
85+ close(releaseTombstoneCheck)
86+ require.NoError(t, <-loadDone)
87+ require.NoError(t, <-tombstoneDone)
88+}
A internal/adapters/snapshot/maintenance.go +129 -0
@@ -0,0 +1,129 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+)
10+
11+const maintenanceBatch = 64
12+
13+// These caps bound metadata retained across resumable GC passes. If a session
14+// exceeds either cap, marking remains conservative and no destructive sweep or
15+// generation collection occurs in that cycle.
16+const (
17+ maxMaintenanceMarkedGenerations = 128
18+ maxMaintenanceReferences = 8192
19+)
20+
21+// Maintain reaps a bounded amount of stale state. Continuation handles are
22+// repository-owned, so a stable prefix cannot starve entries later in a large
23+// directory. Cancellation and errors discard those handles.
24+func (r *Repository) Maintain(ctx context.Context) (err error) {
25+ r.maintenanceMu.Lock()
26+ defer r.maintenanceMu.Unlock()
27+ if err := ctx.Err(); err != nil {
28+ r.resetMaintenance()
29+ return err
30+ }
31+ defer func() {
32+ if err != nil {
33+ r.resetMaintenance()
34+ }
35+ }()
36+ if r.maintenanceCursors == nil {
37+ r.maintenanceCursors = make(map[string]*maintenanceCursor)
38+ r.maintenanceSessions = make(map[string]*sessionMaintenance)
39+ }
40+
41+ budget := maintenanceBatch
42+ if _, err := r.removeTemps(ctx, r.dir, &budget, "root-temps"); err != nil || budget == 0 {
43+ return err
44+ }
45+ if r.maintenanceQuarantine != nil {
46+ changed, done, err := r.maintainQuarantine(ctx, &budget)
47+ if err != nil {
48+ return err
49+ }
50+ if changed {
51+ if err := r.syncDirectory(filepath.Join(r.dir, repositorySessionsDir)); err != nil {
52+ return fmt.Errorf("sync snapshot maintenance directory: %w", safeFilesystemError(err))
53+ }
54+ }
55+ if done {
56+ r.maintenanceQuarantine = nil
57+ }
58+ return nil
59+ }
60+ // Resume an incomplete session before discovering another one. This keeps
61+ // retained marks (and their session lock/epoch references) bounded even
62+ // when a hostile repository contains arbitrarily many partial sessions.
63+ for key := range r.maintenanceSessions {
64+ lock := r.lockSession(key)
65+ err = r.maintainSession(ctx, key, &budget)
66+ r.unlockSession(lock)
67+ return err
68+ }
69+ sessions := filepath.Join(r.dir, repositorySessionsDir)
70+ entries, _, err := r.readMaintenanceDir(sessions, maintenanceBatch, "sessions")
71+ if errors.Is(err, os.ErrNotExist) {
72+ return nil
73+ }
74+ if err != nil {
75+ return fmt.Errorf("read snapshot maintenance sessions: %w", safeFilesystemError(err))
76+ }
77+ for i, entry := range entries {
78+ if budget == 0 {
79+ r.requeueMaintenanceEntries(sessions, "sessions", entries[i:])
80+ return nil
81+ }
82+ if err := ctx.Err(); err != nil {
83+ return err
84+ }
85+ path := filepath.Join(sessions, entry.name)
86+ if isQuarantine(entry.name) {
87+ r.maintenanceQuarantine = &quarantineMaintenance{root: path, current: path}
88+ changed, done, err := r.maintainQuarantine(ctx, &budget)
89+ if err != nil && !errors.Is(err, os.ErrNotExist) {
90+ return fmt.Errorf("remove snapshot quarantine %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
91+ }
92+ if changed {
93+ if err := ctx.Err(); err != nil {
94+ return err
95+ }
96+ if err := r.syncDirectory(sessions); err != nil {
97+ return fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
98+ }
99+ }
100+ if done {
101+ r.maintenanceQuarantine = nil
102+ }
103+ if i+1 < len(entries) {
104+ r.requeueMaintenanceEntries(sessions, "sessions", entries[i+1:])
105+ }
106+ return nil
107+ }
108+ if !entry.isDir || !canonicalSessionKey(entry.name) {
109+ continue
110+ }
111+ lock := r.lockSession(entry.name)
112+ err = r.maintainSession(ctx, entry.name, &budget)
113+ r.unlockSession(lock)
114+ if err != nil {
115+ return err
116+ }
117+ if r.maintenanceSessions[entry.name] != nil {
118+ return nil
119+ }
120+ }
121+ return nil
122+}
123+
124+// maintenanceCursor is deliberately reopenable metadata, not an open file.
125+// On Linux, d_off is the seek cookie returned by getdents(2); seeking a newly
126+// opened descriptor to it resumes the directory stream. Go's File.ReadDir
127+// cannot be used here: its 8 KiB internal getdents buffer advances the kernel
128+// offset beyond a short ReadDir result (see os/dir_unix.go), so saving SeekCur
129+// after ReadDir would skip buffered entries.
A internal/adapters/snapshot/maintenance_cursor.go +285 -0
@@ -0,0 +1,285 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+ "syscall"
12+ "unsafe"
13+)
14+
15+// maxMaintenanceCursors bounds all retained directory seek cookies, including
16+// cursors for attacker-controlled object shards. Maintenance processes only
17+// one shard at a time, so normal operation stays well below this ceiling.
18+const maxMaintenanceCursors = 8
19+
20+var errMaintenanceCursorLimit = errors.New("snapshot maintenance cursor limit")
21+
22+type maintenanceCursor struct {
23+ offset int64
24+ pending []maintenanceDirEntry
25+ done bool
26+}
27+
28+type maintenanceDirEntry struct {
29+ name string
30+ isDir bool
31+}
32+
33+func (r *Repository) resetMaintenance() {
34+ for key := range r.maintenanceSessions {
35+ r.clearSessionMaintenance(key)
36+ }
37+ r.maintenanceCursors = nil
38+ r.maintenanceSessions = nil
39+ r.maintenanceQuarantine = nil
40+}
41+
42+func maintenanceCursorID(dir, purpose string) string { return purpose + "\x00" + dir }
43+
44+// readMaintenanceDir advances one named directory cursor. Its continuation is
45+// only a Linux seek cookie and at most one call's worth of pending names. The
46+// descriptor used to read a call is always closed before this method returns.
47+// Callers must return entries they could not process with
48+// requeueMaintenanceEntries before yielding for budget exhaustion.
49+func (r *Repository) readMaintenanceDir(dir string, n int, purpose string) ([]maintenanceDirEntry, bool, error) {
50+ id := maintenanceCursorID(dir, purpose)
51+ cursor := r.maintenanceCursors[id]
52+ if cursor != nil && len(cursor.pending) != 0 {
53+ count := min(n, len(cursor.pending))
54+ entries := cursor.pending[:count]
55+ cursor.pending = cursor.pending[count:]
56+ done := cursor.done && len(cursor.pending) == 0
57+ if done {
58+ delete(r.maintenanceCursors, id)
59+ }
60+ return entries, done, nil
61+ }
62+ if cursor == nil {
63+ if len(r.maintenanceCursors) >= maxMaintenanceCursors {
64+ return nil, false, errMaintenanceCursorLimit
65+ }
66+ cursor = &maintenanceCursor{}
67+ r.maintenanceCursors[id] = cursor
68+ }
69+
70+ entries, done, err := r.readMaintenanceDirent(dir, n, cursor)
71+ if err != nil {
72+ delete(r.maintenanceCursors, id)
73+ return nil, false, err
74+ }
75+ if done {
76+ delete(r.maintenanceCursors, id)
77+ }
78+ return entries, done, nil
79+}
80+
81+// maintenanceDirectory is the small syscall surface needed to resume a
82+// bounded getdents cursor. It permits deterministic fault injection without
83+// retaining directory descriptors between maintenance passes.
84+type maintenanceDirectory interface {
85+ Seek(int64, int) (int64, error)
86+ ReadDirent([]byte) (int, error)
87+ Close() error
88+}
89+
90+type osMaintenanceDirectory struct{ file *os.File }
91+
92+func (d osMaintenanceDirectory) Seek(offset int64, whence int) (int64, error) {
93+ return syscall.Seek(int(d.file.Fd()), offset, whence)
94+}
95+
96+func (d osMaintenanceDirectory) ReadDirent(buffer []byte) (int, error) {
97+ return syscall.ReadDirent(int(d.file.Fd()), buffer)
98+}
99+
100+func (d osMaintenanceDirectory) Close() error { return d.file.Close() }
101+
102+func (r *Repository) openMaintenanceDirectory(dir string) (maintenanceDirectory, error) {
103+ if open := r.hooks.openMaintenanceDirectory; open != nil {
104+ return open(dir)
105+ }
106+ file, err := r.openDirectory(dir)
107+ if err != nil {
108+ return nil, err
109+ }
110+ return osMaintenanceDirectory{file: file}, nil
111+}
112+
113+// maintenanceDirectoryError adds stable operation context and removes any
114+// filesystem path while retaining the underlying cause for errors.Is.
115+func maintenanceDirectoryError(operation string, err error) error {
116+ return fmt.Errorf("%s: %w", operation, safeFilesystemError(err))
117+}
118+
119+// readMaintenanceDirent saves a resumable directory cursor. Linux d_off lets
120+// a large getdents batch stop at limit. Darwin's syscall.ReadDirent instead
121+// stores an entry count in the descriptor offset, so every returned buffer
122+// must be drained before that descriptor-maintained count is saved.
123+func (r *Repository) readMaintenanceDirent(dir string, limit int, cursor *maintenanceCursor) (entries []maintenanceDirEntry, done bool, err error) {
124+ return r.readMaintenanceDirentWithDrain(dir, limit, cursor, drainMaintenanceDirentBatch(), directoryCookie)
125+}
126+
127+// readMaintenanceDirentWithDrain contains the shared traversal logic. The
128+// explicit parameters permit platform-independent verification of Darwin's
129+// buffer-draining semantics with deterministic fake directories.
130+func (r *Repository) readMaintenanceDirentWithDrain(dir string, limit int, cursor *maintenanceCursor, drainBuffer bool, cookie func(maintenanceDirectory, *syscall.Dirent) (int64, error)) (entries []maintenanceDirEntry, done bool, err error) {
131+ file, err := r.openMaintenanceDirectory(dir)
132+ if err != nil {
133+ return nil, false, maintenanceDirectoryError("open maintenance directory", err)
134+ }
135+ defer func() {
136+ if closeErr := file.Close(); closeErr != nil {
137+ closeErr = maintenanceDirectoryError("close maintenance directory", closeErr)
138+ if err != nil {
139+ err = errors.Join(err, closeErr)
140+ } else {
141+ entries = nil
142+ done = false
143+ err = closeErr
144+ }
145+ }
146+ }()
147+ if cursor.offset != 0 {
148+ if _, err := file.Seek(cursor.offset, io.SeekStart); err != nil {
149+ return nil, false, maintenanceDirectoryError("seek maintenance directory", err)
150+ }
151+ }
152+
153+ entries = make([]maintenanceDirEntry, 0, limit)
154+ buffer := make([]byte, maintenanceDirentBufferSize)
155+ for {
156+ count, err := file.ReadDirent(buffer)
157+ if err != nil {
158+ return nil, false, maintenanceDirectoryError("read maintenance directory", err)
159+ }
160+ if count < 0 || count > len(buffer) {
161+ return nil, false, syscall.EIO
162+ }
163+ if count == 0 {
164+ return entries, true, nil
165+ }
166+ for data := buffer[:count]; len(data) != 0 && (len(entries) < limit || drainBuffer); {
167+ record, nameBytes, reclen, decodeErr := decodeMaintenanceDirent(data)
168+ if decodeErr != nil {
169+ return nil, false, decodeErr
170+ }
171+ data = data[reclen:]
172+ // Advance past every raw record, including dot and disappeared entries.
173+ offset, cookieErr := cookie(file, record)
174+ if cookieErr != nil {
175+ return nil, false, maintenanceDirectoryError("tell maintenance directory", cookieErr)
176+ }
177+ cursor.offset = offset
178+ if end := strings.IndexByte(string(nameBytes), 0); end >= 0 {
179+ nameBytes = nameBytes[:end]
180+ }
181+ name := string(nameBytes)
182+ if name == "." || name == ".." || name == "" {
183+ continue
184+ }
185+ stat, err := r.stat(filepath.Join(dir, name))
186+ if errors.Is(err, os.ErrNotExist) {
187+ continue
188+ }
189+ if err != nil {
190+ // A final symlink is not traversed and is treated as a non-directory;
191+ // deletion remains dirfd-relative and therefore unlinks only the link.
192+ if errors.Is(err, syscall.ELOOP) {
193+ entries = append(entries, maintenanceDirEntry{name: name})
194+ continue
195+ }
196+ return nil, false, maintenanceDirectoryError("stat maintenance directory entry", err)
197+ }
198+ entries = append(entries, maintenanceDirEntry{name: name, isDir: stat.Mode&syscall.S_IFMT == syscall.S_IFDIR})
199+ }
200+ if len(entries) >= limit {
201+ if drainBuffer && len(entries) > limit {
202+ // Darwin has already advanced the descriptor past this entire
203+ // buffer, so retain only this final buffer's excess.
204+ cursor.pending = append(cursor.pending, entries[limit:]...)
205+ }
206+ return entries[:limit], false, nil
207+ }
208+ }
209+}
210+
211+// decodeMaintenanceDirent copies a bounded raw record into aligned storage
212+// before reading its fields. syscall.ReadDirent writes to a byte buffer, whose
213+// start and record boundaries are not guaranteed to satisfy syscall.Dirent's
214+// alignment requirements on Darwin.
215+func decodeMaintenanceDirent(data []byte) (*syscall.Dirent, []byte, int, error) {
216+ nameOffset := int(unsafe.Offsetof(syscall.Dirent{}.Name))
217+ if len(data) < nameOffset {
218+ return nil, nil, 0, syscall.EIO
219+ }
220+
221+ var record syscall.Dirent
222+ header := unsafe.Slice((*byte)(unsafe.Pointer(&record)), nameOffset)
223+ copy(header, data[:nameOffset])
224+ reclen := int(record.Reclen)
225+ if reclen < nameOffset || reclen > len(data) || reclen > int(unsafe.Sizeof(record)) {
226+ return nil, nil, 0, syscall.EIO
227+ }
228+
229+ recordBytes := unsafe.Slice((*byte)(unsafe.Pointer(&record)), reclen)
230+ copy(recordBytes, data[:reclen])
231+ name := unsafe.Slice((*byte)(unsafe.Pointer(&record.Name[0])), reclen-nameOffset)
232+ return &record, name, reclen, nil
233+}
234+
235+func (r *Repository) requeueMaintenanceEntries(dir, purpose string, entries []maintenanceDirEntry) {
236+ if len(entries) == 0 {
237+ return
238+ }
239+ id := maintenanceCursorID(dir, purpose)
240+ cursor := r.maintenanceCursors[id]
241+ if cursor == nil {
242+ cursor = &maintenanceCursor{done: true}
243+ r.maintenanceCursors[id] = cursor
244+ }
245+ cursor.pending = append(entries, cursor.pending...)
246+}
247+
248+func (r *Repository) removeTemps(ctx context.Context, dir string, budget *int, purpose string) (bool, error) {
249+ if *budget == 0 {
250+ return false, nil
251+ }
252+ entries, done, err := r.readMaintenanceDir(dir, maintenanceBatch, purpose)
253+ if errors.Is(err, os.ErrNotExist) {
254+ return true, nil
255+ }
256+ if err != nil {
257+ return false, fmt.Errorf("read snapshot maintenance directory: %w", safeFilesystemError(err))
258+ }
259+ for i, entry := range entries {
260+ if *budget == 0 {
261+ r.requeueMaintenanceEntries(dir, purpose, entries[i:])
262+ return false, nil
263+ }
264+ if err := ctx.Err(); err != nil {
265+ return false, err
266+ }
267+ if entry.isDir || !strings.HasPrefix(entry.name, ".tmp-") {
268+ continue
269+ }
270+ path := filepath.Join(dir, entry.name)
271+ if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
272+ return false, fmt.Errorf("remove snapshot maintenance file %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
273+ }
274+ if err := ctx.Err(); err != nil {
275+ return false, err
276+ }
277+ if err := r.syncDirectory(dir); err != nil {
278+ return false, fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
279+ }
280+ *budget--
281+ }
282+ return done, nil
283+}
284+
285+// maintenancePath avoids exposing the repository root while retaining a
A internal/adapters/snapshot/maintenance_delete.go +199 -0
@@ -0,0 +1,199 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+ "syscall"
12+)
13+
14+// Delete makes a session unavailable by durably moving it out of the canonical
15+// namespace. Maintain reaps the private quarantine later; Delete never restores
16+// a quarantined directory.
17+func (r *Repository) Delete(ctx context.Context, name string) error {
18+ if err := ctx.Err(); err != nil {
19+ return err
20+ }
21+ key := sessionKey(name)
22+ lock := r.lockSession(key)
23+ defer r.unlockSession(lock)
24+ if err := ctx.Err(); err != nil {
25+ return err
26+ }
27+
28+ canonical := r.sessionPath(key)
29+ sessions := filepath.Dir(canonical)
30+ // A prior rename may have succeeded while its parent sync failed. Complete
31+ // that durability boundary before considering a canonical directory: this
32+ // also leaves a newly recreated session untouched.
33+ pending, err := r.pendingQuarantine(sessions, key)
34+ if err != nil {
35+ return fmt.Errorf("stat deleting snapshot session %q: %w", key, safeFilesystemError(err))
36+ }
37+ if pending {
38+ if err := ctx.Err(); err != nil {
39+ return err
40+ }
41+ if err := r.syncDirectory(sessions); err != nil {
42+ return fmt.Errorf("sync deleted snapshot session directory %q: %w", key, safeFilesystemError(err))
43+ }
44+ return nil
45+ }
46+ if dir, err := r.openDirectory(canonical); errors.Is(err, os.ErrNotExist) {
47+ return nil
48+ } else if err != nil {
49+ return fmt.Errorf("stat snapshot session %q: %w", key, safeFilesystemError(err))
50+ } else if err := dir.Close(); err != nil {
51+ return fmt.Errorf("close snapshot session %q: %w", key, safeFilesystemError(err))
52+ }
53+ quarantine := filepath.Join(sessions, deletingSessionName(key))
54+ if err := ctx.Err(); err != nil {
55+ return err
56+ }
57+ // A renamed session can be replaced by a later publication. Invalidate any
58+ // mark made for the old namespace before that replacement becomes possible.
59+ r.invalidateStorageEpoch(key)
60+ if err := r.rename(canonical, quarantine); err != nil {
61+ return fmt.Errorf("quarantine snapshot session %q: %w", key, safeFilesystemError(err))
62+ }
63+ if err := ctx.Err(); err != nil {
64+ return err
65+ }
66+ if err := r.syncDirectory(sessions); err != nil {
67+ return fmt.Errorf("sync deleted snapshot session directory %q: %w", key, safeFilesystemError(err))
68+ }
69+ return nil
70+}
71+
72+// deletingSessionName is a deterministic pending-delete record. Its presence
73+// is checked directly rather than scanning an attacker-controlled sessions
74+// directory, and it prevents a retry from deleting a session republished after
75+// the initial rename reached disk but its directory sync failed.
76+func deletingSessionName(key string) string { return ".deleting-" + key }
77+
78+func (r *Repository) pendingQuarantine(dir, key string) (bool, error) {
79+ path := filepath.Join(dir, deletingSessionName(key))
80+ if hook := r.hooks.beforePendingQuarantineCheck; hook != nil {
81+ hook(path)
82+ }
83+ file, err := r.openDirectory(path)
84+ if errors.Is(err, os.ErrNotExist) {
85+ return false, nil
86+ }
87+ if err != nil {
88+ return false, err
89+ }
90+ return true, file.Close()
91+}
92+
93+// Maintain reaps a bounded amount of stale state. Continuation handles are
94+// repository-owned, so a stable prefix cannot starve entries later in a large
95+// directory. Cancellation and errors discard those handles: a restarted
96+// repository (and the next fresh cycle) always begins at a safe boundary.
97+func isQuarantine(name string) bool { return strings.HasPrefix(name, ".deleting-") }
98+
99+// quarantineMaintenance retains one root-to-current path, never an unbounded
100+// stack of directory handles or names. Parent paths are recovered after a
101+// removal, while descent resumes from current on the next budgeted step.
102+type quarantineMaintenance struct {
103+ root string
104+ current string
105+}
106+
107+// consumeQuarantineWork charges every filesystem operation that can be driven
108+// by quarantine contents. This makes deep and wide trees subject to exactly
109+// the same maintenanceBatch ceiling as removal work.
110+func (r *Repository) consumeQuarantineWork(budget *int, operation string) bool {
111+ if *budget == 0 {
112+ return false
113+ }
114+ *budget--
115+ if hook := r.hooks.beforeMaintenanceWork; hook != nil {
116+ hook(operation)
117+ }
118+ return true
119+}
120+
121+// maintainQuarantine makes one bounded depth-first traversal step at a time.
122+// It deliberately reopens a directory to select its first child: every
123+// successful child removal changes that prefix, so no directory cursor is
124+// retained at each hostile nesting level.
125+func (r *Repository) maintainQuarantine(ctx context.Context, budget *int) (changed, done bool, err error) {
126+ state := r.maintenanceQuarantine
127+ if state == nil {
128+ return false, true, nil
129+ }
130+ for *budget > 0 {
131+ if err := ctx.Err(); err != nil {
132+ return changed, false, err
133+ }
134+ if !r.consumeQuarantineWork(budget, "stat") {
135+ return changed, false, nil
136+ }
137+ stat, err := r.stat(state.current)
138+ if errors.Is(err, os.ErrNotExist) {
139+ if state.current == state.root {
140+ return changed, true, nil
141+ }
142+ state.current = filepath.Dir(state.current)
143+ continue
144+ }
145+ if err != nil && !errors.Is(err, syscall.ELOOP) {
146+ return changed, false, err
147+ }
148+ if err != nil || stat.Mode&syscall.S_IFMT != syscall.S_IFDIR {
149+ if !r.consumeQuarantineWork(budget, "remove") {
150+ return changed, false, nil
151+ }
152+ if err := r.remove(state.current); err != nil && !errors.Is(err, os.ErrNotExist) {
153+ return changed, false, err
154+ }
155+ changed = true
156+ state.current = filepath.Dir(state.current)
157+ continue
158+ }
159+
160+ if !r.consumeQuarantineWork(budget, "open") {
161+ return changed, false, nil
162+ }
163+ dir, err := r.openDirectory(state.current)
164+ if err != nil {
165+ return changed, false, err
166+ }
167+ if !r.consumeQuarantineWork(budget, "read") {
168+ _ = dir.Close()
169+ return changed, false, nil
170+ }
171+ entries, readErr := dir.ReadDir(1)
172+ closeErr := dir.Close()
173+ if readErr != nil && !errors.Is(readErr, io.EOF) {
174+ return changed, false, readErr
175+ }
176+ if closeErr != nil {
177+ return changed, false, closeErr
178+ }
179+ if len(entries) == 0 {
180+ if !r.consumeQuarantineWork(budget, "remove") {
181+ return changed, false, nil
182+ }
183+ if err := r.remove(state.current); err != nil && !errors.Is(err, os.ErrNotExist) {
184+ return changed, false, err
185+ }
186+ changed = true
187+ if state.current == state.root {
188+ return changed, true, nil
189+ }
190+ state.current = filepath.Dir(state.current)
191+ continue
192+ }
193+ if !r.consumeQuarantineWork(budget, "descend") {
194+ return changed, false, nil
195+ }
196+ state.current = filepath.Join(state.current, entries[0].Name())
197+ }
198+ return changed, false, nil
199+}
A internal/adapters/snapshot/maintenance_error.go +34 -0
@@ -0,0 +1,34 @@
1+package snapshot
2+
3+import (
4+ "errors"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+)
9+
10+func maintenancePath(root, path string) string {
11+ rel, err := filepath.Rel(root, path)
12+ if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
13+ return filepath.Base(path)
14+ }
15+ return rel
16+}
17+
18+type safePathError struct {
19+ op string
20+ cause error
21+}
22+
23+func (e safePathError) Error() string { return e.op + ": " + e.cause.Error() }
24+func (e safePathError) Unwrap() error { return e.cause }
25+
26+// safeFilesystemError preserves error matching without including a filesystem
27+// path from terminal-facing error text.
28+func safeFilesystemError(err error) error {
29+ var pathError *os.PathError
30+ if errors.As(err, &pathError) {
31+ return safePathError{op: pathError.Op, cause: pathError.Err}
32+ }
33+ return err
34+}
A internal/adapters/snapshot/maintenance_gc.go +414 -0
@@ -0,0 +1,414 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "crypto/sha256"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "sort"
11+ "strings"
12+
13+ "github.com/bnema/vev/internal/ports"
14+ codec "github.com/bnema/vev/internal/usecase/snapshot"
15+)
16+
17+type manifestMaintenance struct {
18+ refs map[ports.SnapshotDigest]codec.ObjectRef
19+ complete bool
20+}
21+
22+type sessionMaintenance struct {
23+ lock *sessionMutex
24+ token string
25+ epoch uint64
26+ marked map[uint64]manifestMaintenance
27+ referenceCount int
28+ uncertain bool
29+ conservative bool
30+ markDone bool
31+ manifestQueue []uint64
32+ objectTempShard string
33+ objectTempsDone bool
34+ sweepShard string
35+ sweepRootDone bool
36+}
37+
38+// canRetainManifest reserves bounded state before retaining a manifest's
39+// reference map. The caller must leave the map unretained when it returns
40+// false, and make the cycle conservative instead.
41+func (s *sessionMaintenance) canRetainManifest(referenceCount int) bool {
42+ if referenceCount < 0 || len(s.marked) >= maxMaintenanceMarkedGenerations {
43+ return false
44+ }
45+ if referenceCount > maxMaintenanceReferences-s.referenceCount {
46+ return false
47+ }
48+ s.referenceCount += referenceCount
49+ return true
50+}
51+
52+func (r *Repository) maintainSession(ctx context.Context, key string, budget *int) error {
53+ if err := ctx.Err(); err != nil {
54+ return err
55+ }
56+ state, err := r.sessionMaintenanceState(key)
57+ if err != nil {
58+ return err
59+ }
60+ if _, err := r.removeTemps(ctx, filepath.Join(r.sessionPath(key), repositoryGenerations), budget, "generation-temps:"+key); err != nil || *budget == 0 {
61+ return err
62+ }
63+ if !state.objectTempsDone {
64+ done, err := r.removeObjectTemps(ctx, key, state, budget)
65+ if err != nil || !done || *budget == 0 {
66+ return err
67+ }
68+ }
69+ if !state.markDone {
70+ if err := r.markSession(ctx, key, state); err != nil {
71+ return err
72+ }
73+ if !state.markDone {
74+ return nil
75+ }
76+ }
77+ if state.epoch != r.storageEpoch(key) {
78+ // A publication may have left an unpublished manifest after this mark.
79+ // Discard all mark and sweep cursors; the next pass starts from it.
80+ r.clearSessionMaintenance(key)
81+ return nil
82+ }
83+ if state.uncertain {
84+ // An unreadable, invalid, or unretained manifest may reference any
85+ // existing blob. Do not collect manifests or sweep in this cycle; retry
86+ // marking from a fresh directory pass.
87+ r.clearSessionMaintenance(key)
88+ return nil
89+ }
90+ if err := r.removeObsoleteManifests(ctx, key, state, budget); err != nil || *budget == 0 {
91+ return err
92+ }
93+ if err := r.sweepSession(ctx, key, state, budget); err != nil {
94+ return err
95+ }
96+ return nil
97+}
98+
99+func (r *Repository) sessionMaintenanceState(key string) (*sessionMaintenance, error) {
100+ token, conservative, err := r.maintenanceToken(key)
101+ if err != nil {
102+ return nil, err
103+ }
104+ state := r.maintenanceSessions[key]
105+ if state == nil || state.token != token || state.epoch != r.storageEpoch(key) {
106+ r.clearSessionMaintenance(key)
107+ state = &sessionMaintenance{
108+ lock: r.retainSessionState(key),
109+ token: token,
110+ epoch: r.storageEpoch(key),
111+ conservative: conservative,
112+ marked: make(map[uint64]manifestMaintenance),
113+ }
114+ r.maintenanceSessions[key] = state
115+ }
116+ return state, nil
117+}
118+
119+// maintenanceToken is the publication boundary. A missing or corrupt HEAD is
120+// itself stable maintenance state: we retain every classified reference until a
121+// valid publication changes that state, rather than restarting the mark pass.
122+func (r *Repository) maintenanceToken(key string) (string, bool, error) {
123+ data, exists, err := r.readOptionalBounded(r.headPath(key))
124+ if err != nil {
125+ return "", false, err
126+ }
127+ if !exists {
128+ return "missing", true, nil
129+ }
130+ sum := sha256.Sum256(data)
131+ if _, _, err := r.readHead(key); err != nil {
132+ return fmt.Sprintf("invalid:%x", sum), true, nil
133+ }
134+ return fmt.Sprintf("valid:%x", sum), false, nil
135+}
136+
137+func (r *Repository) clearSessionMaintenance(key string) {
138+ if state := r.maintenanceSessions[key]; state != nil && state.lock != nil {
139+ r.releaseSessionReference(state.lock)
140+ }
141+ delete(r.maintenanceSessions, key)
142+ prefix := "\x00" + filepath.Clean(r.sessionPath(key))
143+ for id := range r.maintenanceCursors {
144+ if strings.HasSuffix(id, prefix) || strings.Contains(id, ":"+key+":") || strings.Contains(id, ":"+key+"\x00") {
145+ delete(r.maintenanceCursors, id)
146+ }
147+ }
148+}
149+
150+func (r *Repository) markSession(ctx context.Context, key string, state *sessionMaintenance) error {
151+ dir := filepath.Join(r.sessionPath(key), repositoryGenerations)
152+ entries, done, err := r.readMaintenanceDir(dir, maintenanceBatch, "mark-generations:"+key)
153+ if errors.Is(err, os.ErrNotExist) {
154+ state.markDone = true
155+ return nil
156+ }
157+ if err != nil {
158+ return fmt.Errorf("read snapshot generations: %w", safeFilesystemError(err))
159+ }
160+ for _, entry := range entries {
161+ if err := ctx.Err(); err != nil {
162+ return err
163+ }
164+ if entry.isDir {
165+ continue
166+ }
167+ generation, ok := parseGenerationFilename(entry.name)
168+ if !ok {
169+ continue
170+ }
171+ data, err := r.readBounded(r.manifestPath(key, generation))
172+ if err != nil {
173+ state.uncertain = true
174+ continue
175+ }
176+ manifest, err := codec.UnmarshalManifest(data)
177+ if err != nil || manifest.Generation != generation || sessionKey(manifest.Name) != key {
178+ state.uncertain = true
179+ continue
180+ }
181+ refs, validRefs := maintenanceManifestRefs(manifest)
182+ if !validRefs || !withinGenerationBudget(len(data), refs) || !state.canRetainManifest(len(refs)) {
183+ state.uncertain = true
184+ continue
185+ }
186+ // Valid but incomplete generations are marked too. This protects blobs
187+ // written before a failed or in-flight publication reaches HEAD.
188+ _, loadErr := r.loadGeneration(ctx, manifest.Name, key, generation)
189+ state.marked[generation] = manifestMaintenance{refs: refs, complete: loadErr == nil}
190+ }
191+ if done {
192+ state.markDone = true
193+ if !state.conservative {
194+ state.manifestQueue = retainedManifestQueue(state.marked)
195+ }
196+ }
197+ return nil
198+}
199+
200+// maintenanceManifestRefs applies the GC reference ceiling while building its
201+// map, rather than first materializing a hostile manifest's entire reference
202+// set. Conflicting references are invalid just as they are for publication.
203+func maintenanceManifestRefs(manifest codec.Manifest) (map[ports.SnapshotDigest]codec.ObjectRef, bool) {
204+ refs := make(map[ports.SnapshotDigest]codec.ObjectRef)
205+ add := func(ref codec.ObjectRef) bool {
206+ if old, ok := refs[ref.Digest]; ok {
207+ return old == ref
208+ }
209+ if len(refs) == maxMaintenanceReferences {
210+ return false
211+ }
212+ refs[ref.Digest] = ref
213+ return true
214+ }
215+ for _, tab := range manifest.Tabs {
216+ for _, pane := range tab.Panes {
217+ for _, ref := range pane.Sealed {
218+ if !add(ref) {
219+ return nil, false
220+ }
221+ }
222+ if !add(pane.Tail) || !add(pane.Visible) {
223+ return nil, false
224+ }
225+ }
226+ }
227+ return refs, true
228+}
229+
230+// completeGenerationsDescending returns only complete generations in canonical
231+// newest-first order for both manifest and object retention.
232+func completeGenerationsDescending(marked map[uint64]manifestMaintenance) []uint64 {
233+ complete := make([]uint64, 0, len(marked))
234+ for generation, item := range marked {
235+ if item.complete {
236+ complete = append(complete, generation)
237+ }
238+ }
239+ sort.Slice(complete, func(i, j int) bool { return complete[i] > complete[j] })
240+ return complete
241+}
242+
243+func retainedManifestQueue(marked map[uint64]manifestMaintenance) []uint64 {
244+ complete := completeGenerationsDescending(marked)
245+ if len(complete) <= 2 {
246+ return nil
247+ }
248+ return complete[2:]
249+}
250+
251+func (r *Repository) removeObsoleteManifests(ctx context.Context, key string, state *sessionMaintenance, budget *int) error {
252+ for len(state.manifestQueue) != 0 && *budget > 0 {
253+ if err := ctx.Err(); err != nil {
254+ return err
255+ }
256+ generation := state.manifestQueue[0]
257+ path := r.manifestPath(key, generation)
258+ if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
259+ return fmt.Errorf("remove snapshot manifest %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
260+ }
261+ if err := ctx.Err(); err != nil {
262+ return err
263+ }
264+ if err := r.syncDirectory(filepath.Dir(path)); err != nil {
265+ return fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
266+ }
267+ state.manifestQueue = state.manifestQueue[1:]
268+ *budget--
269+ }
270+ return nil
271+}
272+
273+// removeObjectTemps performs at most one shard traversal or one root-entry
274+// discovery per Maintain call. Empty attacker-controlled shards consume no
275+// removal budget, so this fixed step ceiling prevents an unbounded scan of
276+// them while retaining only the root cursor and one active shard cursor.
277+func (r *Repository) removeObjectTemps(ctx context.Context, key string, state *sessionMaintenance, budget *int) (bool, error) {
278+ root := filepath.Join(r.sessionPath(key), repositoryObjectsDir)
279+ if *budget == 0 {
280+ return false, nil
281+ }
282+ if state.objectTempShard != "" {
283+ done, err := r.removeTemps(ctx, filepath.Join(root, state.objectTempShard), budget, "object-temps:"+key+":"+state.objectTempShard)
284+ if err != nil || !done {
285+ return false, err
286+ }
287+ state.objectTempShard = ""
288+ return state.objectTempsDone, nil
289+ }
290+ if state.objectTempsDone {
291+ return true, nil
292+ }
293+ entries, done, err := r.readMaintenanceDir(root, 1, "object-temps-shards:"+key)
294+ if errors.Is(err, os.ErrNotExist) {
295+ state.objectTempsDone = true
296+ return true, nil
297+ }
298+ if err != nil {
299+ return false, fmt.Errorf("read snapshot object shards: %w", safeFilesystemError(err))
300+ }
301+ state.objectTempsDone = done
302+ if len(entries) != 0 && entries[0].isDir {
303+ state.objectTempShard = entries[0].name
304+ }
305+ return state.objectTempsDone && state.objectTempShard == "", nil
306+}
307+
308+func (r *Repository) sweepSession(ctx context.Context, key string, state *sessionMaintenance, budget *int) error {
309+ // Every bounded sweep batch is tied to the mark's storage epoch. Do not use
310+ // stale references after any publication or replacement.
311+ if state.epoch != r.storageEpoch(key) {
312+ r.clearSessionMaintenance(key)
313+ return nil
314+ }
315+ referenced := retainedReferences(state.marked, state.conservative)
316+ root := filepath.Join(r.sessionPath(key), repositoryObjectsDir)
317+ if state.sweepShard != "" && *budget > 0 {
318+ shard := state.sweepShard
319+ done, err := r.sweepShard(ctx, filepath.Join(root, shard), referenced, budget, key, shard)
320+ if err != nil {
321+ return err
322+ }
323+ if !done {
324+ return nil
325+ }
326+ state.sweepShard = ""
327+ // Release this shard's cursor before discovering another shard.
328+ return nil
329+ }
330+ if !state.sweepRootDone {
331+ entries, done, err := r.readMaintenanceDir(root, 1, "sweep-shards:"+key)
332+ if errors.Is(err, os.ErrNotExist) {
333+ state.sweepRootDone = true
334+ } else if err != nil {
335+ return fmt.Errorf("read snapshot objects: %w", safeFilesystemError(err))
336+ } else {
337+ state.sweepRootDone = done
338+ if len(entries) != 0 && entries[0].isDir {
339+ state.sweepShard = entries[0].name
340+ }
341+ }
342+ }
343+ if state.sweepRootDone && state.sweepShard == "" {
344+ r.clearSessionMaintenance(key)
345+ }
346+ return nil
347+}
348+
349+func retainedReferences(marked map[uint64]manifestMaintenance, conservative bool) map[ports.SnapshotDigest]struct{} {
350+ if conservative {
351+ references := make(map[ports.SnapshotDigest]struct{})
352+ for _, item := range marked {
353+ for digest := range item.refs {
354+ references[digest] = struct{}{}
355+ }
356+ }
357+ return references
358+ }
359+ complete := completeGenerationsDescending(marked)
360+ keep := make(map[uint64]bool, 2)
361+ for _, generation := range complete {
362+ if len(keep) == 2 {
363+ break
364+ }
365+ keep[generation] = true
366+ }
367+ references := make(map[ports.SnapshotDigest]struct{})
368+ for generation, item := range marked {
369+ if !item.complete || keep[generation] {
370+ for digest := range item.refs {
371+ references[digest] = struct{}{}
372+ }
373+ }
374+ }
375+ return references
376+}
377+
378+func (r *Repository) sweepShard(ctx context.Context, dir string, referenced map[ports.SnapshotDigest]struct{}, budget *int, key, shard string) (bool, error) {
379+ entries, done, err := r.readMaintenanceDir(dir, maintenanceBatch, "sweep-objects:"+key+":"+shard)
380+ if errors.Is(err, os.ErrNotExist) {
381+ return true, nil
382+ }
383+ if err != nil {
384+ return false, fmt.Errorf("read snapshot object shard: %w", safeFilesystemError(err))
385+ }
386+ for i, object := range entries {
387+ if *budget == 0 {
388+ r.requeueMaintenanceEntries(dir, "sweep-objects:"+key+":"+shard, entries[i:])
389+ return false, nil
390+ }
391+ if err := ctx.Err(); err != nil {
392+ return false, err
393+ }
394+ digest, ok := parseObjectDigest(object.name)
395+ if !ok || object.isDir {
396+ continue
397+ }
398+ if _, used := referenced[digest]; used {
399+ continue
400+ }
401+ path := filepath.Join(dir, object.name)
402+ if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
403+ return false, fmt.Errorf("remove snapshot object %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
404+ }
405+ if err := ctx.Err(); err != nil {
406+ return false, err
407+ }
408+ if err := r.syncDirectory(dir); err != nil {
409+ return false, fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
410+ }
411+ *budget--
412+ }
413+ return done, nil
414+}
A internal/adapters/snapshot/maintenance_hardening_test.go +141 -0
@@ -0,0 +1,141 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+)
11+
12+func TestRepositoryMaintainBoundsCursorsAcrossHostileObjectShards(t *testing.T) {
13+ repo := NewRepository(privateDir(t))
14+ key := sessionKey("named")
15+ objects := filepath.Join(repo.sessionPath(key), repositoryObjectsDir)
16+ const shards = maintenanceBatch * 2
17+ for i := 0; i < shards; i++ {
18+ shard := filepath.Join(objects, fmt.Sprintf("%03d", i))
19+ if err := os.MkdirAll(shard, 0o700); err != nil {
20+ t.Fatal(err)
21+ }
22+ if err := os.WriteFile(filepath.Join(shard, ".tmp-stale"), []byte("stale"), 0o600); err != nil {
23+ t.Fatal(err)
24+ }
25+ // Force a resumable cursor in every attacker-controlled shard.
26+ for j := 0; j < maintenanceBatch+1; j++ {
27+ if err := os.WriteFile(filepath.Join(shard, fmt.Sprintf("live-%03d", j)), []byte("live"), 0o600); err != nil {
28+ t.Fatal(err)
29+ }
30+ }
31+ }
32+
33+ for pass := 0; pass < shards*4; pass++ {
34+ if err := repo.Maintain(context.Background()); err != nil {
35+ t.Fatal(err)
36+ }
37+ if got := len(repo.maintenanceCursors); got > maxMaintenanceCursors {
38+ t.Fatalf("retained maintenance cursors = %d, want at most %d", got, maxMaintenanceCursors)
39+ }
40+ }
41+ for i := 0; i < shards; i++ {
42+ _, err := os.Lstat(filepath.Join(objects, fmt.Sprintf("%03d", i), ".tmp-stale"))
43+ if !errors.Is(err, os.ErrNotExist) {
44+ t.Fatalf("stale temporary object in shard %d = %v, want removed", i, err)
45+ }
46+ }
47+}
48+
49+func TestRepositoryMaintainBoundsEmptyObjectTempShards(t *testing.T) {
50+ repo := NewRepository(privateDir(t))
51+ key := sessionKey("named")
52+ objects := filepath.Join(repo.sessionPath(key), repositoryObjectsDir)
53+ const shards = maintenanceBatch * 2
54+ for i := 0; i < shards; i++ {
55+ if err := os.MkdirAll(filepath.Join(objects, fmt.Sprintf("%03d", i)), 0o700); err != nil {
56+ t.Fatal(err)
57+ }
58+ }
59+
60+ openedShards := make(map[string]struct{}, shards)
61+ opensThisPass := 0
62+ tempPass := false
63+ repo.hooks.openMaintenanceDirectory = func(dir string) (maintenanceDirectory, error) {
64+ if tempPass && filepath.Dir(dir) == objects {
65+ opensThisPass++
66+ openedShards[dir] = struct{}{}
67+ }
68+ file, err := os.Open(dir)
69+ if err != nil {
70+ return nil, err
71+ }
72+ return osMaintenanceDirectory{file: file}, nil
73+ }
74+
75+ for pass := 0; pass < shards*3; pass++ {
76+ opensThisPass = 0
77+ tempPass = true
78+ if err := repo.Maintain(context.Background()); err != nil {
79+ t.Fatal(err)
80+ }
81+ tempPass = false
82+ if opensThisPass > 1 {
83+ t.Fatalf("empty object shards opened in maintenance pass %d = %d, want at most 1", pass, opensThisPass)
84+ }
85+ state := repo.maintenanceSessions[key]
86+ if state != nil && state.objectTempsDone {
87+ if got := len(openedShards); got != shards {
88+ t.Fatalf("empty object shards opened = %d, want %d", got, shards)
89+ }
90+ return
91+ }
92+ }
93+ t.Fatal("empty object temp shards were not eventually traversed")
94+}
95+
96+func TestRepositoryMaintainBoundsAndResumesDeepWideQuarantine(t *testing.T) {
97+ repo := NewRepository(privateDir(t))
98+ quarantine := filepath.Join(repo.dir, repositorySessionsDir, ".deleting-hostile")
99+ deep := quarantine
100+ for i := 0; i < maintenanceBatch*2; i++ {
101+ deep = filepath.Join(deep, "d")
102+ }
103+ leaf := filepath.Join(deep, "leaf")
104+ if err := os.MkdirAll(filepath.Dir(leaf), 0o700); err != nil {
105+ t.Fatal(err)
106+ }
107+ if err := os.WriteFile(leaf, []byte("stale"), 0o600); err != nil {
108+ t.Fatal(err)
109+ }
110+ for i := 0; i < maintenanceBatch*2; i++ {
111+ if err := os.WriteFile(filepath.Join(quarantine, fmt.Sprintf("wide-%03d", i)), []byte("stale"), 0o600); err != nil {
112+ t.Fatal(err)
113+ }
114+ }
115+
116+ work := 0
117+ repo.hooks.beforeMaintenanceWork = func(string) { work++ }
118+ if err := repo.Maintain(context.Background()); err != nil {
119+ t.Fatal(err)
120+ }
121+ if work == 0 || work > maintenanceBatch {
122+ t.Fatalf("quarantine work in one maintenance call = %d, want 1..%d", work, maintenanceBatch)
123+ }
124+ if _, err := os.Lstat(leaf); err != nil {
125+ t.Fatalf("deep quarantine leaf removed despite bounded traversal: %v", err)
126+ }
127+
128+ for pass := 0; pass < 1000; pass++ {
129+ work = 0
130+ if err := repo.Maintain(context.Background()); err != nil {
131+ t.Fatal(err)
132+ }
133+ if work > maintenanceBatch {
134+ t.Fatalf("quarantine work in pass %d = %d, want at most %d", pass, work, maintenanceBatch)
135+ }
136+ if _, err := os.Lstat(quarantine); errors.Is(err, os.ErrNotExist) {
137+ return
138+ }
139+ }
140+ t.Fatal("deep and wide quarantine was not eventually removed")
141+}
A internal/adapters/snapshot/maintenance_test.go +1010 -0
@@ -0,0 +1,1010 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "crypto/sha256"
6+ "encoding/binary"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "os"
11+ "path/filepath"
12+ "slices"
13+ "strings"
14+ "syscall"
15+ "testing"
16+ "unsafe"
17+
18+ "github.com/bnema/vev/internal/ports"
19+)
20+
21+func TestReadMaintenanceDirentPreservesCursorAndCloseFailuresWithoutPaths(t *testing.T) {
22+ privatePath := filepath.Join(t.TempDir(), "private-maintenance-directory")
23+ closeCause := errors.New("injected close failure")
24+ seekCause := errors.New("injected seek failure")
25+ readCause := errors.New("injected read failure")
26+
27+ for _, tc := range []struct {
28+ name string
29+ offset int64
30+ seekErr error
31+ readErr error
32+ wantCause []error
33+ wantOps []string
34+ }{
35+ {
36+ name: "close only",
37+ wantCause: []error{closeCause},
38+ wantOps: []string{"close maintenance directory"},
39+ },
40+ {
41+ name: "seek and close",
42+ offset: 1,
43+ seekErr: &os.PathError{Op: "seek", Path: privatePath, Err: seekCause},
44+ wantCause: []error{seekCause, closeCause},
45+ wantOps: []string{"seek maintenance directory", "close maintenance directory"},
46+ },
47+ {
48+ name: "read and close",
49+ readErr: &os.PathError{Op: "read", Path: privatePath, Err: readCause},
50+ wantCause: []error{readCause, closeCause},
51+ wantOps: []string{"read maintenance directory", "close maintenance directory"},
52+ },
53+ } {
54+ t.Run(tc.name, func(t *testing.T) {
55+ repo := NewRepository(privateDir(t))
56+ repo.hooks.openMaintenanceDirectory = func(string) (maintenanceDirectory, error) {
57+ return fakeMaintenanceDirectory{
58+ seekErr: tc.seekErr,
59+ readErr: tc.readErr,
60+ closeErr: &os.PathError{Op: "close", Path: privatePath, Err: closeCause},
61+ }, nil
62+ }
63+
64+ _, _, err := repo.readMaintenanceDirent(privatePath, 1, &maintenanceCursor{offset: tc.offset})
65+ if err == nil {
66+ t.Fatal("readMaintenanceDirent error = nil, want failure")
67+ }
68+ for _, cause := range tc.wantCause {
69+ if !errors.Is(err, cause) {
70+ t.Errorf("errors.Is(%v, %v) = false, want true", err, cause)
71+ }
72+ }
73+ for _, operation := range tc.wantOps {
74+ if !strings.Contains(err.Error(), operation) {
75+ t.Errorf("error = %q, want stable operation context %q", err, operation)
76+ }
77+ }
78+ if strings.Contains(err.Error(), privatePath) {
79+ t.Errorf("error leaks private path: %q", err)
80+ }
81+ })
82+ }
83+}
84+
85+func TestReadMaintenanceDirentRejectsRecordShorterThanNameOffset(t *testing.T) {
86+ repo := NewRepository(privateDir(t))
87+ nameOffset := int(unsafe.Offsetof(syscall.Dirent{}.Name))
88+ recordData := make([]byte, nameOffset)
89+ reclenOffset := unsafe.Offsetof(syscall.Dirent{}.Reclen)
90+ binary.NativeEndian.PutUint16(recordData[reclenOffset:], 1)
91+
92+ repo.hooks.openMaintenanceDirectory = func(string) (maintenanceDirectory, error) {
93+ return fakeMaintenanceDirectory{data: recordData}, nil
94+ }
95+ _, _, err := repo.readMaintenanceDirent("unused", 1, &maintenanceCursor{})
96+ if !errors.Is(err, syscall.EIO) {
97+ t.Fatalf("readMaintenanceDirent error = %v, want EIO", err)
98+ }
99+}
100+
101+func TestDecodeMaintenanceDirentBoundsRecords(t *testing.T) {
102+ nameOffset := int(unsafe.Offsetof(syscall.Dirent{}.Name))
103+ reclenOffset := unsafe.Offsetof(syscall.Dirent{}.Reclen)
104+ valid := make([]byte, nameOffset+len("entry")+1)
105+ binary.NativeEndian.PutUint16(valid[reclenOffset:], uint16(len(valid)))
106+ copy(valid[nameOffset:], "entry")
107+
108+ tests := []struct {
109+ name string
110+ data []byte
111+ wantName string
112+ wantErr bool
113+ }{
114+ {name: "truncated header", data: make([]byte, nameOffset-1), wantErr: true},
115+ {name: "short record", data: append([]byte(nil), valid...), wantErr: true},
116+ {name: "truncated record", data: valid[:nameOffset], wantErr: true},
117+ {name: "record exceeds aligned storage", data: make([]byte, int(unsafe.Sizeof(syscall.Dirent{}))+1), wantErr: true},
118+ {name: "valid", data: valid, wantName: "entry"},
119+ }
120+ binary.NativeEndian.PutUint16(tests[1].data[reclenOffset:], uint16(nameOffset-1))
121+ binary.NativeEndian.PutUint16(tests[2].data[reclenOffset:], uint16(len(valid)))
122+ binary.NativeEndian.PutUint16(tests[3].data[reclenOffset:], uint16(len(tests[3].data)))
123+
124+ for _, tc := range tests {
125+ t.Run(tc.name, func(t *testing.T) {
126+ record, name, reclen, err := decodeMaintenanceDirent(tc.data)
127+ if tc.wantErr {
128+ if !errors.Is(err, syscall.EIO) {
129+ t.Fatalf("decodeMaintenanceDirent() error = %v, want EIO", err)
130+ }
131+ return
132+ }
133+ if err != nil {
134+ t.Fatal(err)
135+ }
136+ if record == nil {
137+ t.Fatal("decodeMaintenanceDirent() record = nil")
138+ }
139+ if reclen != len(valid) {
140+ t.Fatalf("decodeMaintenanceDirent() reclen = %d, want %d", reclen, len(valid))
141+ }
142+ if got := string(name[:len(tc.wantName)]); got != tc.wantName {
143+ t.Fatalf("decodeMaintenanceDirent() name = %q, want %q", got, tc.wantName)
144+ }
145+ })
146+ }
147+}
148+
149+type fakeMaintenanceDirectory struct {
150+ data []byte
151+ seekOffset int64
152+ seekErr error
153+ readErr error
154+ closeErr error
155+}
156+
157+func (d fakeMaintenanceDirectory) Seek(int64, int) (int64, error) { return d.seekOffset, d.seekErr }
158+func (d fakeMaintenanceDirectory) ReadDirent(buffer []byte) (int, error) {
159+ if d.readErr != nil {
160+ return 0, d.readErr
161+ }
162+ return copy(buffer, d.data), nil
163+}
164+func (d fakeMaintenanceDirectory) Close() error { return d.closeErr }
165+
166+func TestReadMaintenanceDirentDrainsDarwinBatchesWithoutStarvation(t *testing.T) {
167+ dir := privateDir(t)
168+ if err := os.Mkdir(dir, 0o700); err != nil {
169+ t.Fatal(err)
170+ }
171+ batches := [][]string{
172+ {"one", "two"},
173+ {"three", "four", "five", "six"},
174+ }
175+ for _, batch := range batches {
176+ for _, name := range batch {
177+ if err := os.WriteFile(filepath.Join(dir, name), nil, 0o600); err != nil {
178+ t.Fatal(err)
179+ }
180+ }
181+ }
182+
183+ file := &fakeMultiBatchMaintenanceDirectory{batches: [][]byte{
184+ maintenanceDirentBatch(batches[0]...),
185+ maintenanceDirentBatch(batches[1]...),
186+ }}
187+ repo := NewRepository(dir)
188+ repo.hooks.openMaintenanceDirectory = func(string) (maintenanceDirectory, error) { return file, nil }
189+ cursor := &maintenanceCursor{}
190+
191+ entries, done, err := repo.readMaintenanceDirentWithDrain(dir, 4, cursor, true, maintenanceTestDirectoryCookie)
192+ if err != nil {
193+ t.Fatal(err)
194+ }
195+ if done {
196+ t.Fatal("readMaintenanceDirentWithDrain() done = true, want false before EOF")
197+ }
198+ if got, want := maintenanceEntryNames(entries), []string{"one", "two", "three", "four"}; !slices.Equal(got, want) {
199+ t.Fatalf("first entries = %v, want %v", got, want)
200+ }
201+ if got, want := maintenanceEntryNames(cursor.pending), []string{"five", "six"}; !slices.Equal(got, want) {
202+ t.Fatalf("pending entries = %v, want exactly final-buffer excess %v", got, want)
203+ }
204+ if got := file.reads; got != 2 {
205+ t.Fatalf("ReadDirent calls = %d, want 2 to fill the requested batch", got)
206+ }
207+
208+ id := maintenanceCursorID(dir, "test")
209+ repo.maintenanceCursors = map[string]*maintenanceCursor{id: cursor}
210+ entries, done, err = repo.readMaintenanceDir(dir, 4, "test")
211+ if err != nil {
212+ t.Fatal(err)
213+ }
214+ if done {
215+ t.Fatal("pending batch done = true, want false before EOF")
216+ }
217+ if got, want := maintenanceEntryNames(entries), []string{"five", "six"}; !slices.Equal(got, want) {
218+ t.Fatalf("pending entries = %v, want %v", got, want)
219+ }
220+ if got := file.reads; got != 2 {
221+ t.Fatalf("ReadDirent calls while delivering pending entries = %d, want 2", got)
222+ }
223+
224+ entries, done, err = repo.readMaintenanceDir(dir, 4, "test")
225+ if err != nil {
226+ t.Fatal(err)
227+ }
228+ if !done {
229+ t.Fatal("EOF batch done = false, want true")
230+ }
231+ if len(entries) != 0 {
232+ t.Fatalf("EOF entries = %v, want none", entries)
233+ }
234+ if _, ok := repo.maintenanceCursors[id]; ok {
235+ t.Fatal("EOF cursor retained after pending entries were delivered")
236+ }
237+}
238+
239+func maintenanceEntryNames(entries []maintenanceDirEntry) []string {
240+ names := make([]string, len(entries))
241+ for i, entry := range entries {
242+ names[i] = entry.name
243+ }
244+ return names
245+}
246+
247+func maintenanceDirentBatch(names ...string) []byte {
248+ nameOffset := int(unsafe.Offsetof(syscall.Dirent{}.Name))
249+ reclenOffset := unsafe.Offsetof(syscall.Dirent{}.Reclen)
250+ var data []byte
251+ for _, name := range names {
252+ record := make([]byte, nameOffset+len(name)+1)
253+ binary.NativeEndian.PutUint16(record[reclenOffset:], uint16(len(record)))
254+ copy(record[nameOffset:], name)
255+ data = append(data, record...)
256+ }
257+ return data
258+}
259+
260+type fakeMultiBatchMaintenanceDirectory struct {
261+ batches [][]byte
262+ index int
263+ reads int
264+}
265+
266+func (d *fakeMultiBatchMaintenanceDirectory) Seek(offset int64, whence int) (int64, error) {
267+ if whence == io.SeekCurrent {
268+ return int64(d.index), nil
269+ }
270+ d.index = int(offset)
271+ return offset, nil
272+}
273+
274+func (d *fakeMultiBatchMaintenanceDirectory) ReadDirent(buffer []byte) (int, error) {
275+ d.reads++
276+ if d.index == len(d.batches) {
277+ return 0, nil
278+ }
279+ batch := d.batches[d.index]
280+ d.index++
281+ return copy(buffer, batch), nil
282+}
283+
284+func (d *fakeMultiBatchMaintenanceDirectory) Close() error { return nil }
285+
286+func maintenanceTestDirectoryCookie(file maintenanceDirectory, _ *syscall.Dirent) (int64, error) {
287+ return file.Seek(0, io.SeekCurrent)
288+}
289+
290+func TestRepositoryDeleteQuarantinesCanonicalSessionBeforeCleanup(t *testing.T) {
291+ repo := NewRepository(privateDir(t))
292+ if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))); err != nil {
293+ t.Fatal(err)
294+ }
295+ if err := repo.Delete(context.Background(), "named"); err != nil {
296+ t.Fatal(err)
297+ }
298+
299+ canonical := repo.sessionPath(sessionKey("named"))
300+ if _, err := os.Lstat(canonical); !errors.Is(err, os.ErrNotExist) {
301+ t.Fatalf("canonical session after Delete = %v, want not exist", err)
302+ }
303+ entries, err := os.ReadDir(filepath.Join(repo.dir, repositorySessionsDir))
304+ if err != nil {
305+ t.Fatal(err)
306+ }
307+ if len(entries) != 1 || !isQuarantine(entries[0].Name()) || !entries[0].IsDir() {
308+ t.Fatalf("session entries after Delete = %#v, want one quarantine", entries)
309+ }
310+ if err := repo.Maintain(context.Background()); err != nil {
311+ t.Fatal(err)
312+ }
313+ entries, err = os.ReadDir(filepath.Join(repo.dir, repositorySessionsDir))
314+ if err != nil {
315+ t.Fatal(err)
316+ }
317+ if len(entries) != 0 {
318+ t.Fatalf("session entries after Maintain = %#v, want none", entries)
319+ }
320+}
321+
322+func TestRepositoryDeleteReturnsRootSyncFailureAfterQuarantine(t *testing.T) {
323+ repo := NewRepository(privateDir(t))
324+ if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))); err != nil {
325+ t.Fatal(err)
326+ }
327+ injected := errors.New("sessions sync")
328+ repo.hooks.syncDirectory = func(string) error { return injected }
329+ if err := repo.Delete(context.Background(), "named"); !errors.Is(err, injected) {
330+ t.Fatalf("Delete error = %v, want root sync failure", err)
331+ }
332+ if _, err := os.Lstat(repo.sessionPath(sessionKey("named"))); !errors.Is(err, os.ErrNotExist) {
333+ t.Fatalf("canonical session after failed sync = %v, want not exist", err)
334+ }
335+}
336+
337+func TestRepositoryDeleteWaitsForPublication(t *testing.T) {
338+ repo := NewRepository(privateDir(t))
339+ if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("first"))); err != nil {
340+ t.Fatal(err)
341+ }
342+ entered := make(chan struct{})
343+ release := make(chan struct{})
344+ repo.hooks.beforeHeadWrite = func(string) error {
345+ close(entered)
346+ <-release
347+ return nil
348+ }
349+ publishDone := make(chan error, 1)
350+ go func() {
351+ publishDone <- repo.Publish(context.Background(), repositoryPublication(t, "named", 2, []byte("second")))
352+ }()
353+ <-entered
354+ deleteReached := make(chan struct{})
355+ deleteRelease := make(chan struct{})
356+ repo.hooks.beforeSessionLock = waitAtSessionLock(deleteReached, deleteRelease)
357+ deleteDone := make(chan error, 1)
358+ go func() { deleteDone <- repo.Delete(context.Background(), "named") }()
359+ <-deleteReached
360+ // Delete is parked at the session-lock boundary while Publish owns it.
361+ close(deleteRelease)
362+ close(release)
363+ if err := <-publishDone; err != nil {
364+ t.Fatal(err)
365+ }
366+ if err := <-deleteDone; err != nil {
367+ t.Fatal(err)
368+ }
369+ if _, err := os.Lstat(repo.sessionPath(sessionKey("named"))); !errors.Is(err, os.ErrNotExist) {
370+ t.Fatalf("canonical session after concurrent Delete = %v, want not exist", err)
371+ }
372+}
373+
374+func TestRepositoryMaintainRetainsNewestTwoCompleteGenerations(t *testing.T) {
375+ repo := NewRepository(privateDir(t))
376+ for generation := uint64(1); generation <= 3; generation++ {
377+ if err := repo.Publish(context.Background(), repositoryPublication(t, "named", generation, []byte{byte(generation)})); err != nil {
378+ t.Fatal(err)
379+ }
380+ }
381+ key := sessionKey("named")
382+ for pass := 0; pass < maintenanceBatch; pass++ {
383+ if err := repo.Maintain(context.Background()); err != nil {
384+ t.Fatal(err)
385+ }
386+ _, err := os.Lstat(repo.manifestPath(key, 1))
387+ if errors.Is(err, os.ErrNotExist) {
388+ break
389+ }
390+ if err != nil {
391+ t.Fatalf("old manifest during Maintain = %v", err)
392+ }
393+ if pass == maintenanceBatch-1 {
394+ t.Fatal("old manifest was not eventually collected")
395+ }
396+ }
397+ for _, generation := range []uint64{2, 3} {
398+ if _, err := os.Lstat(repo.manifestPath(key, generation)); err != nil {
399+ t.Fatalf("retained manifest %d: %v", generation, err)
400+ }
401+ }
402+ if _, err := os.Lstat(repo.manifestPath(key, 1)); !errors.Is(err, os.ErrNotExist) {
403+ t.Fatalf("old manifest after Maintain = %v, want not exist", err)
404+ }
405+ for _, generation := range []uint64{2, 3} {
406+ if _, err := repo.Load(context.Background(), "named"); err != nil {
407+ t.Fatalf("Load after collecting generation %d: %v", generation, err)
408+ }
409+ }
410+}
411+
412+func TestRepositoryMaintainPreservesIncompleteManifestReferences(t *testing.T) {
413+ repo := NewRepository(privateDir(t))
414+ for generation := uint64(1); generation <= 3; generation++ {
415+ if err := repo.Publish(context.Background(), repositoryPublication(t, "named", generation, []byte{byte(generation)})); err != nil {
416+ t.Fatal(err)
417+ }
418+ }
419+ key := sessionKey("named")
420+ manifest := repositoryPublication(t, "named", 3, []byte{3})
421+ if err := os.Remove(repo.objectPath(key, manifest.Objects[1].Digest)); err != nil {
422+ t.Fatal(err)
423+ }
424+ if err := repo.Maintain(context.Background()); err != nil {
425+ t.Fatal(err)
426+ }
427+ if _, err := os.Lstat(repo.manifestPath(key, 3)); err != nil {
428+ t.Fatalf("incomplete manifest removed: %v", err)
429+ }
430+ if _, err := os.Lstat(repo.objectPath(key, manifest.Objects[0].Digest)); err != nil {
431+ t.Fatalf("object referenced by incomplete manifest removed: %v", err)
432+ }
433+}
434+
435+func TestRepositoryCancellationAfterSessionLockWaitPreventsMutations(t *testing.T) {
436+ t.Run("publish", func(t *testing.T) {
437+ repo := NewRepository(privateDir(t))
438+ key := sessionKey("named")
439+ lock := repo.lockSession(key)
440+ ctx, cancel := context.WithCancel(context.Background())
441+ reached := make(chan struct{})
442+ release := make(chan struct{})
443+ repo.hooks.beforeSessionLock = waitAtSessionLock(reached, release)
444+ done := make(chan error, 1)
445+ go func() { done <- repo.Publish(ctx, repositoryPublication(t, "named", 1, []byte("state"))) }()
446+ <-reached
447+ cancel()
448+ close(release)
449+ repo.unlockSession(lock)
450+ if err := <-done; !errors.Is(err, context.Canceled) {
451+ t.Fatalf("Publish error = %v, want canceled", err)
452+ }
453+ if _, err := os.Lstat(repo.dir); !errors.Is(err, os.ErrNotExist) {
454+ t.Fatalf("Publish mutated canceled repository: %v", err)
455+ }
456+ })
457+ t.Run("load and delete", func(t *testing.T) {
458+ repo := NewRepository(privateDir(t))
459+ if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))); err != nil {
460+ t.Fatal(err)
461+ }
462+ key := sessionKey("named")
463+ for _, operation := range []struct {
464+ name string
465+ run func(context.Context) error
466+ }{
467+ {"load", func(ctx context.Context) error { _, err := repo.Load(ctx, "named"); return err }},
468+ {"delete", func(ctx context.Context) error { return repo.Delete(ctx, "named") }},
469+ } {
470+ t.Run(operation.name, func(t *testing.T) {
471+ lock := repo.lockSession(key)
472+ ctx, cancel := context.WithCancel(context.Background())
473+ reached := make(chan struct{})
474+ release := make(chan struct{})
475+ repo.hooks.beforeSessionLock = waitAtSessionLock(reached, release)
476+ done := make(chan error, 1)
477+ go func() { done <- operation.run(ctx) }()
478+ <-reached
479+ cancel()
480+ close(release)
481+ repo.unlockSession(lock)
482+ if err := <-done; !errors.Is(err, context.Canceled) {
483+ t.Fatalf("%s error = %v, want canceled", operation.name, err)
484+ }
485+ repo.hooks.beforeSessionLock = nil
486+ if _, err := repo.Load(context.Background(), "named"); err != nil {
487+ t.Fatalf("%s mutated session: %v", operation.name, err)
488+ }
489+ })
490+ }
491+ })
492+ t.Run("maintain", func(t *testing.T) {
493+ repo := NewRepository(privateDir(t))
494+ if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))); err != nil {
495+ t.Fatal(err)
496+ }
497+ lock := repo.lockSession(sessionKey("named"))
498+ ctx, cancel := context.WithCancel(context.Background())
499+ reached := make(chan struct{})
500+ release := make(chan struct{})
501+ repo.hooks.beforeSessionLock = waitAtSessionLock(reached, release)
502+ done := make(chan error, 1)
503+ go func() { done <- repo.Maintain(ctx) }()
504+ <-reached
505+ cancel()
506+ close(release)
507+ repo.unlockSession(lock)
508+ if err := <-done; !errors.Is(err, context.Canceled) {
509+ t.Fatalf("Maintain error = %v, want canceled", err)
510+ }
511+ repo.hooks.beforeSessionLock = nil
512+ if _, err := repo.Load(context.Background(), "named"); err != nil {
513+ t.Fatalf("Maintain mutated session: %v", err)
514+ }
515+ })
516+}
517+
518+func waitAtSessionLock(reached chan<- struct{}, release <-chan struct{}) func(string) {
519+ return func(string) {
520+ close(reached)
521+ <-release
522+ }
523+}
524+
525+func TestRepositoryDeleteRetriesPendingQuarantineSyncWithoutDeletingRecreatedSession(t *testing.T) {
526+ repo := NewRepository(privateDir(t))
527+ if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("old"))); err != nil {
528+ t.Fatal(err)
529+ }
530+ injected := errors.New("sessions sync")
531+ repo.hooks.syncDirectory = func(string) error { return injected }
532+ if err := repo.Delete(context.Background(), "named"); !errors.Is(err, injected) {
533+ t.Fatalf("Delete error = %v, want sync failure", err)
534+ }
535+ repo.hooks.syncDirectory = nil
536+ if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("new"))); err != nil {
537+ t.Fatal(err)
538+ }
539+ if err := repo.Delete(context.Background(), "named"); err != nil {
540+ t.Fatalf("retry Delete: %v", err)
541+ }
542+ if _, err := repo.Load(context.Background(), "named"); err != nil {
543+ t.Fatalf("recreated session was deleted: %v", err)
544+ }
545+}
546+
547+func TestRepositoryMaintainResumesNestedQuarantineWithinBatch(t *testing.T) {
548+ repo := NewRepository(privateDir(t))
549+ quarantine := filepath.Join(repo.dir, repositorySessionsDir, ".deleting-named-test")
550+ for i := 0; i < maintenanceBatch+1; i++ {
551+ path := filepath.Join(quarantine, "nested", string(rune('a'+i)))
552+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
553+ t.Fatal(err)
554+ }
555+ if err := os.WriteFile(path, []byte("stale"), 0o600); err != nil {
556+ t.Fatal(err)
557+ }
558+ }
559+ if err := repo.Maintain(context.Background()); err != nil {
560+ t.Fatal(err)
561+ }
562+ if _, err := os.Lstat(quarantine); errors.Is(err, os.ErrNotExist) {
563+ t.Fatalf("quarantine after one bounded call = %v, want remaining tree", err)
564+ }
565+ for i := 0; i < 12; i++ {
566+ if err := repo.Maintain(context.Background()); err != nil {
567+ t.Fatal(err)
568+ }
569+ if _, err := os.Lstat(quarantine); errors.Is(err, os.ErrNotExist) {
570+ return
571+ }
572+ }
573+ if _, err := os.Lstat(quarantine); !errors.Is(err, os.ErrNotExist) {
574+ t.Fatalf("quarantine after resumed maintenance = %v, want removed", err)
575+ }
576+}
577+
578+func TestRepositoryMaintainClosesContinuationDirectories(t *testing.T) {
579+ repo := NewRepository(privateDir(t))
580+ sessions := filepath.Join(repo.dir, repositorySessionsDir)
581+ for i := 0; i < maintenanceBatch+1; i++ {
582+ generations := filepath.Join(sessions, fmt.Sprintf("named-%03d", i), repositoryGenerations)
583+ if err := os.MkdirAll(generations, 0o700); err != nil {
584+ t.Fatal(err)
585+ }
586+ for j := 0; j < maintenanceBatch+1; j++ {
587+ if err := os.WriteFile(filepath.Join(generations, fmt.Sprintf("unclassified-%03d", j)), []byte("state"), 0o600); err != nil {
588+ t.Fatal(err)
589+ }
590+ }
591+ }
592+ before := openDescriptorCount(t)
593+ if err := repo.Maintain(context.Background()); err != nil {
594+ t.Fatal(err)
595+ }
596+ if got := openDescriptorCount(t); got > before+4 {
597+ t.Fatalf("open descriptors after bounded maintenance = %d, want at most %d", got, before+4)
598+ }
599+}
600+
601+func TestRepositoryMaintainBoundsQueuedShardNames(t *testing.T) {
602+ repo := NewRepository(privateDir(t))
603+ key := sessionKey("named")
604+ root := filepath.Join(repo.sessionPath(key), repositoryObjectsDir)
605+ for i := 0; i < maintenanceBatch*2; i++ {
606+ if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("%02x", i)), 0o700); err != nil {
607+ t.Fatal(err)
608+ }
609+ }
610+ repo.maintenanceCursors = make(map[string]*maintenanceCursor)
611+ state := &sessionMaintenance{marked: make(map[uint64]manifestMaintenance), markDone: true}
612+ for i := 0; i < maintenanceBatch+2; i++ {
613+ budget := maintenanceBatch
614+ if err := repo.sweepSession(context.Background(), key, state, &budget); err != nil {
615+ t.Fatal(err)
616+ }
617+ if state.sweepShard != "" && len(repo.maintenanceCursors) > maxMaintenanceCursors {
618+ t.Fatalf("retained maintenance cursors = %d, want at most %d", len(repo.maintenanceCursors), maxMaintenanceCursors)
619+ }
620+ }
621+}
622+
623+func TestRepositoryMaintainResumesCursorAfterDirectoryMutation(t *testing.T) {
624+ repo := NewRepository(privateDir(t))
625+ if err := os.Mkdir(repo.dir, 0o700); err != nil {
626+ t.Fatal(err)
627+ }
628+ for i := 0; i < maintenanceBatch+1; i++ {
629+ if err := os.WriteFile(filepath.Join(repo.dir, fmt.Sprintf("live-%03d", i)), []byte("live"), 0o600); err != nil {
630+ t.Fatal(err)
631+ }
632+ }
633+ if err := repo.Maintain(context.Background()); err != nil {
634+ t.Fatal(err)
635+ }
636+ // Mutating entries ahead of the saved cookie must not reset traversal to
637+ // the prefix or lose a stale entry appended after that cookie.
638+ if err := os.Remove(filepath.Join(repo.dir, "live-000")); err != nil {
639+ t.Fatal(err)
640+ }
641+ stale := filepath.Join(repo.dir, ".tmp-appended")
642+ if err := os.WriteFile(stale, []byte("stale"), 0o600); err != nil {
643+ t.Fatal(err)
644+ }
645+ for i := 0; i < 4; i++ {
646+ if err := repo.Maintain(context.Background()); err != nil {
647+ t.Fatal(err)
648+ }
649+ if _, err := os.Lstat(stale); errors.Is(err, os.ErrNotExist) {
650+ return
651+ }
652+ }
653+ t.Fatal("stale entry appended after cursor mutation was not eventually removed")
654+}
655+
656+func TestRepositoryMaintainDoesNotStarveLaterTemporaryEntries(t *testing.T) {
657+ repo := NewRepository(privateDir(t))
658+ if err := os.Mkdir(repo.dir, 0o700); err != nil {
659+ t.Fatal(err)
660+ }
661+ // The live entries precede the stale entries in directory insertion order.
662+ // A fresh ReadDir from the start on every call would never reach the latter.
663+ for i := 0; i < maintenanceBatch+1; i++ {
664+ if err := os.WriteFile(filepath.Join(repo.dir, "live-"+string(rune('a'+i))), []byte("live"), 0o600); err != nil {
665+ t.Fatal(err)
666+ }
667+ }
668+ for i := 0; i < maintenanceBatch+1; i++ {
669+ if err := os.WriteFile(filepath.Join(repo.dir, ".tmp-later-"+string(rune('a'+i))), []byte("stale"), 0o600); err != nil {
670+ t.Fatal(err)
671+ }
672+ }
673+ for i := 0; i < 4; i++ {
674+ if err := repo.Maintain(context.Background()); err != nil {
675+ t.Fatal(err)
676+ }
677+ }
678+ entries, err := os.ReadDir(repo.dir)
679+ if err != nil {
680+ t.Fatal(err)
681+ }
682+ for _, entry := range entries {
683+ if strings.HasPrefix(entry.Name(), ".tmp-") {
684+ t.Fatalf("stale entry %q was starved", entry.Name())
685+ }
686+ }
687+}
688+
689+func TestRepositoryMaintainMarksLargeGenerationSetBeforeSweeping(t *testing.T) {
690+ repo := NewRepository(privateDir(t))
691+ var newest ports.SnapshotPublication
692+ for generation := uint64(1); generation <= maintenanceBatch+2; generation++ {
693+ publication := repositoryPublication(t, "named", generation, []byte(fmt.Sprintf("state-%d", generation)))
694+ if err := repo.Publish(context.Background(), publication); err != nil {
695+ t.Fatal(err)
696+ }
697+ newest = publication
698+ }
699+ key := sessionKey("named")
700+ stale := sha256.Sum256([]byte("stale object"))
701+ stalePath := repo.objectPath(key, stale)
702+ if err := os.MkdirAll(filepath.Dir(stalePath), 0o700); err != nil {
703+ t.Fatal(err)
704+ }
705+ if err := os.WriteFile(stalePath, []byte("stale object"), 0o600); err != nil {
706+ t.Fatal(err)
707+ }
708+
709+ // The first call has classified only one manifest batch. It must not sweep
710+ // even an otherwise stale object, nor a blob owned by a later manifest.
711+ if err := repo.Maintain(context.Background()); err != nil {
712+ t.Fatal(err)
713+ }
714+ if _, err := os.Lstat(stalePath); err != nil {
715+ t.Fatalf("stale object swept before complete mark pass: %v", err)
716+ }
717+ for _, object := range newest.Objects {
718+ if _, err := os.Lstat(repo.objectPath(key, object.Digest)); err != nil {
719+ t.Fatalf("object referenced by not-yet-classified manifest removed: %v", err)
720+ }
721+ }
722+
723+ for i := 0; i < 600; i++ {
724+ if err := repo.Maintain(context.Background()); err != nil {
725+ t.Fatal(err)
726+ }
727+ if _, err := os.Lstat(stalePath); errors.Is(err, os.ErrNotExist) {
728+ break
729+ }
730+ if i == 599 {
731+ t.Fatal("stale object was not eventually swept")
732+ }
733+ }
734+ for _, generation := range []uint64{maintenanceBatch + 1, maintenanceBatch + 2} {
735+ if _, err := os.Lstat(repo.manifestPath(key, generation)); err != nil {
736+ t.Fatalf("newest complete manifest %d removed: %v", generation, err)
737+ }
738+ }
739+}
740+
741+func TestRepositoryMaintainUsesFixedRemovalBatch(t *testing.T) {
742+ repo := NewRepository(privateDir(t))
743+ if err := os.Mkdir(repo.dir, 0o700); err != nil {
744+ t.Fatal(err)
745+ }
746+ for i := 0; i < maintenanceBatch+1; i++ {
747+ if err := os.WriteFile(filepath.Join(repo.dir, ".tmp-"+string(rune('a'+i))), []byte("stale"), 0o600); err != nil {
748+ t.Fatal(err)
749+ }
750+ }
751+ if err := repo.Maintain(context.Background()); err != nil {
752+ t.Fatal(err)
753+ }
754+ entries, err := os.ReadDir(repo.dir)
755+ if err != nil {
756+ t.Fatal(err)
757+ }
758+ if len(entries) != 1 {
759+ t.Fatalf("remaining stale entries = %d, want 1 after one batch", len(entries))
760+ }
761+}
762+
763+func TestRepositoryMaintainQueuesFetchedSessionsPastWorkBudget(t *testing.T) {
764+ repo := NewRepository(privateDir(t))
765+ if err := os.MkdirAll(filepath.Join(repo.dir, repositorySessionsDir), 0o700); err != nil {
766+ t.Fatal(err)
767+ }
768+ if err := os.WriteFile(filepath.Join(repo.dir, ".tmp-consume-budget"), []byte("stale"), 0o600); err != nil {
769+ t.Fatal(err)
770+ }
771+
772+ removed := make(map[string]int)
773+ for i := 0; i < maintenanceBatch+1; i++ {
774+ path := filepath.Join(repo.dir, repositorySessionsDir, fmt.Sprintf(".deleting-%03d", i))
775+ if err := os.Mkdir(path, 0o700); err != nil {
776+ t.Fatal(err)
777+ }
778+ }
779+ repo.hooks.remove = func(path string) error {
780+ if strings.HasPrefix(filepath.Base(path), ".deleting-") {
781+ removed[path]++
782+ }
783+ return nil
784+ }
785+
786+ sessions := filepath.Join(repo.dir, repositorySessionsDir)
787+ f, err := os.Open(sessions)
788+ if err != nil {
789+ t.Fatal(err)
790+ }
791+ fetched, err := f.ReadDir(maintenanceBatch)
792+ if err != nil {
793+ t.Fatal(err)
794+ }
795+ unread, err := f.ReadDir(1)
796+ if err != nil {
797+ t.Fatal(err)
798+ }
799+ if err := f.Close(); err != nil {
800+ t.Fatal(err)
801+ }
802+ queued := fetched[len(fetched)-1].Name()
803+ for i := 0; i < maintenanceBatch+4; i++ {
804+ if err := repo.Maintain(context.Background()); err != nil {
805+ t.Fatal(err)
806+ }
807+ if _, err := os.Lstat(filepath.Join(sessions, queued)); errors.Is(err, os.ErrNotExist) {
808+ break
809+ } else if i == maintenanceBatch+3 {
810+ t.Fatalf("fetched but unprocessed session %q = %v, want eventually removed", queued, err)
811+ }
812+ }
813+ if _, err := os.Lstat(filepath.Join(sessions, unread[0].Name())); err != nil {
814+ t.Fatalf("unread session %q = %v, want queued entry to run first", unread[0].Name(), err)
815+ }
816+
817+ for i := 0; i < maintenanceBatch+4; i++ {
818+ if err := repo.Maintain(context.Background()); err != nil {
819+ t.Fatal(err)
820+ }
821+ entries, err := os.ReadDir(sessions)
822+ if err != nil {
823+ t.Fatal(err)
824+ }
825+ if len(entries) == 0 {
826+ break
827+ }
828+ }
829+ entries, err := os.ReadDir(sessions)
830+ if err != nil {
831+ t.Fatal(err)
832+ }
833+ if len(entries) != 0 {
834+ t.Fatalf("queued sessions after resumed maintenance = %d, want none", len(entries))
835+ }
836+ if len(removed) != maintenanceBatch+1 {
837+ t.Fatalf("removed sessions = %d, want %d", len(removed), maintenanceBatch+1)
838+ }
839+ for path, calls := range removed {
840+ if calls != 1 {
841+ t.Fatalf("remove calls for %q = %d, want one", path, calls)
842+ }
843+ }
844+}
845+
846+func TestRepositoryMaintainClassifiesUnpublishedGenerationsBeforeSweep(t *testing.T) {
847+ for _, head := range []struct {
848+ name string
849+ set func(t *testing.T, repo *Repository, key string)
850+ }{
851+ {
852+ name: "missing",
853+ set: func(t *testing.T, repo *Repository, key string) {
854+ t.Helper()
855+ if err := os.Remove(repo.headPath(key)); err != nil {
856+ t.Fatal(err)
857+ }
858+ },
859+ },
860+ {
861+ name: "corrupt",
862+ set: func(t *testing.T, repo *Repository, key string) {
863+ t.Helper()
864+ if err := os.WriteFile(repo.headPath(key), []byte("corrupt"), 0o600); err != nil {
865+ t.Fatal(err)
866+ }
867+ },
868+ },
869+ } {
870+ t.Run(head.name, func(t *testing.T) {
871+ repo := NewRepository(privateDir(t))
872+ key := sessionKey("named")
873+ publications := make([]ports.SnapshotPublication, 0, maintenanceBatch+2)
874+ for generation := uint64(1); generation <= maintenanceBatch+2; generation++ {
875+ publication := repositoryPublication(t, "named", generation, []byte(fmt.Sprintf("state-%d", generation)))
876+ if err := repo.Publish(context.Background(), publication); err != nil {
877+ t.Fatal(err)
878+ }
879+ publications = append(publications, publication)
880+ }
881+ head.set(t, repo, key)
882+ stale := sha256.Sum256([]byte("unpublished stale object"))
883+ stalePath := repo.objectPath(key, stale)
884+ if err := os.MkdirAll(filepath.Dir(stalePath), 0o700); err != nil {
885+ t.Fatal(err)
886+ }
887+ if err := os.WriteFile(stalePath, []byte("unpublished stale object"), 0o600); err != nil {
888+ t.Fatal(err)
889+ }
890+
891+ if err := repo.Maintain(context.Background()); err != nil {
892+ t.Fatal(err)
893+ }
894+ if _, err := os.Lstat(stalePath); err != nil {
895+ t.Fatalf("stale object swept before full manifest classification: %v", err)
896+ }
897+ for _, publication := range publications {
898+ if _, err := os.Lstat(repo.objectPath(key, publication.Objects[0].Digest)); err != nil {
899+ t.Fatalf("object for unpublished generation %d removed early: %v", publication.Generation, err)
900+ }
901+ }
902+
903+ for i := 0; i < 600; i++ {
904+ if err := repo.Maintain(context.Background()); err != nil {
905+ t.Fatal(err)
906+ }
907+ if _, err := os.Lstat(stalePath); errors.Is(err, os.ErrNotExist) {
908+ break
909+ } else if i == 599 {
910+ t.Fatal("stale object was not swept after complete classification")
911+ }
912+ }
913+ for _, publication := range publications {
914+ if _, err := os.Lstat(repo.objectPath(key, publication.Objects[0].Digest)); err != nil {
915+ t.Fatalf("object for unpublished generation %d removed: %v", publication.Generation, err)
916+ }
917+ }
918+ })
919+ }
920+}
921+
922+func TestRepositoryMaintainRestartsMarkAfterFailedPublication(t *testing.T) {
923+ repo := NewRepository(privateDir(t))
924+ key := sessionKey("named")
925+ first := publicationWithTailShard(t, "named", 1, "ff")
926+ stalePaths := make([]string, 0, maintenanceBatch+1)
927+ for i := 0; i < maintenanceBatch+1; i++ {
928+ digest := digestInShard(t, "00", i)
929+ path := repo.objectPath(key, digest)
930+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
931+ t.Fatal(err)
932+ }
933+ if err := os.WriteFile(path, []byte("stale"), 0o600); err != nil {
934+ t.Fatal(err)
935+ }
936+ stalePaths = append(stalePaths, path)
937+ }
938+ if err := repo.Publish(context.Background(), first); err != nil {
939+ t.Fatal(err)
940+ }
941+
942+ // This completes marking but exhausts the sweep budget in the stale shard,
943+ // leaving the next sweep batch pending.
944+ if err := repo.Maintain(context.Background()); err != nil {
945+ t.Fatal(err)
946+ }
947+ staleRemaining := false
948+ for _, path := range stalePaths {
949+ if _, err := os.Lstat(path); err == nil {
950+ staleRemaining = true
951+ break
952+ }
953+ }
954+ if !staleRemaining {
955+ t.Fatal("sweep did not leave a pending stale object")
956+ }
957+
958+ second := publicationWithTailShard(t, "named", 2, "ff")
959+ injected := errors.New("before HEAD")
960+ repo.hooks.beforeHeadWrite = func(string) error { return injected }
961+ if err := repo.Publish(context.Background(), second); !errors.Is(err, injected) {
962+ t.Fatalf("Publish error = %v, want injected failure", err)
963+ }
964+ repo.hooks.beforeHeadWrite = nil
965+
966+ for i := 0; i < 20; i++ {
967+ if err := repo.Maintain(context.Background()); err != nil {
968+ t.Fatal(err)
969+ }
970+ for _, object := range second.Objects {
971+ if _, err := os.Lstat(repo.objectPath(key, object.Digest)); err != nil {
972+ t.Fatalf("object referenced by failed publication removed on pass %d: %v", i, err)
973+ }
974+ }
975+ staleRemaining = false
976+ for _, path := range stalePaths {
977+ if _, err := os.Lstat(path); err == nil {
978+ staleRemaining = true
979+ break
980+ }
981+ }
982+ }
983+ if staleRemaining {
984+ t.Fatal("stale object was not eventually swept")
985+ }
986+}
987+
988+func publicationWithTailShard(t *testing.T, name string, generation uint64, shard string) ports.SnapshotPublication {
989+ t.Helper()
990+ for i := 0; i < 4096; i++ {
991+ publication := repositoryPublication(t, name, generation, []byte(fmt.Sprintf("state-%d-%d", generation, i)))
992+ if fmt.Sprintf("%02x", publication.Objects[0].Digest[0]) == shard {
993+ return publication
994+ }
995+ }
996+ t.Fatalf("did not find publication tail in shard %q", shard)
997+ return ports.SnapshotPublication{}
998+}
999+
1000+func digestInShard(t *testing.T, shard string, n int) ports.SnapshotDigest {
1001+ t.Helper()
1002+ for i := 0; i < 4096; i++ {
1003+ digest := sha256.Sum256([]byte(fmt.Sprintf("stale-%d-%d", n, i)))
1004+ if fmt.Sprintf("%02x", digest[0]) == shard {
1005+ return digest
1006+ }
1007+ }
1008+ t.Fatalf("did not find stale digest in shard %q", shard)
1009+ return ports.SnapshotDigest{}
1010+}
A internal/adapters/snapshot/offline_kill_tombstone_test.go +53 -0
@@ -0,0 +1,53 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestRepositoryTombstoneBlocksIncrementalRestoreAndLegacyImport(t *testing.T) {
13+ repo := NewRepository(privateDir(t))
14+ require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))))
15+ require.NoError(t, os.WriteFile(filepath.Join(repo.dir, filenameForName("named")), []byte("legacy"), 0o600))
16+
17+ require.NoError(t, repo.Tombstone(context.Background(), "named"))
18+
19+ names, err := repo.List(context.Background())
20+ require.NoError(t, err)
21+ require.NotContains(t, names, "named")
22+ legacy, err := repo.LoadLegacy(context.Background())
23+ require.NoError(t, err)
24+ for _, snapshot := range legacy {
25+ require.NotEqual(t, "named", snapshot.Name)
26+ }
27+}
28+
29+func TestRepositoryDeleteLegacyRetriesDurabilityAfterTombstone(t *testing.T) {
30+ repo := NewRepository(privateDir(t))
31+ require.NoError(t, os.Mkdir(repo.dir, 0o700))
32+ require.NoError(t, os.WriteFile(filepath.Join(repo.dir, filenameForName("named")), []byte("legacy"), 0o600))
33+ require.NoError(t, repo.Tombstone(context.Background(), "named"))
34+
35+ calls := 0
36+ repo.hooks.syncDirectory = func(string) error {
37+ calls++
38+ if calls == 1 {
39+ return os.ErrPermission
40+ }
41+ return nil
42+ }
43+ require.ErrorIs(t, repo.DeleteLegacy(context.Background(), "named"), os.ErrPermission)
44+
45+ // A retry after process restart has no in-memory pending-sync state.
46+ repo = NewRepository(repo.dir)
47+ repo.hooks.syncDirectory = func(string) error {
48+ calls++
49+ return nil
50+ }
51+ require.NoError(t, repo.DeleteLegacy(context.Background(), "named"))
52+ require.Equal(t, 2, calls, "explicit purge retries only the pending source-directory sync")
53+}
A internal/adapters/snapshot/path.go +78 -0
@@ -0,0 +1,78 @@
1+package snapshot
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/hex"
6+ "fmt"
7+ "path/filepath"
8+ "regexp"
9+ "strconv"
10+ "strings"
11+
12+ "github.com/bnema/vev/internal/ports"
13+)
14+
15+var safeNameRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,200}$`)
16+
17+const maxSnapshotFileSize = 16 + (256 << 20)
18+
19+// filenameForName maps a session name to its deterministic legacy filename.
20+// It remains only for the one-way v3 import path.
21+func filenameForName(name string) string {
22+ if safeNameRE.MatchString(name) {
23+ return name + ".snap"
24+ }
25+ sum := sha256.Sum256([]byte(name))
26+ return "@" + hex.EncodeToString(sum[:])[:40] + ".snap"
27+}
28+
29+func (r *Repository) sessionPath(key string) string {
30+ return filepath.Join(r.dir, repositorySessionsDir, key)
31+}
32+func (r *Repository) objectPath(key string, digest ports.SnapshotDigest) string {
33+ hexDigest := hex.EncodeToString(digest[:])
34+ return filepath.Join(r.sessionPath(key), repositoryObjectsDir, hexDigest[:2], hexDigest)
35+}
36+func (r *Repository) manifestPath(key string, generation uint64) string {
37+ return filepath.Join(r.sessionPath(key), repositoryGenerations, generationFilename(generation))
38+}
39+func (r *Repository) headPath(key string) string {
40+ return filepath.Join(r.sessionPath(key), repositoryHead)
41+}
42+func sessionKey(name string) string {
43+ if safeNameRE.MatchString(name) && name != "." && name != ".." {
44+ return name
45+ }
46+ sum := sha256.Sum256([]byte(name))
47+ return "@" + hex.EncodeToString(sum[:])
48+}
49+func canonicalSessionKey(key string) bool {
50+ if safeNameRE.MatchString(key) && key != "." && key != ".." {
51+ return true
52+ }
53+ if len(key) != 65 || key[0] != '@' {
54+ return false
55+ }
56+ _, err := hex.DecodeString(key[1:])
57+ return err == nil && strings.ToLower(key[1:]) == key[1:]
58+}
59+func generationFilename(generation uint64) string {
60+ return fmt.Sprintf("%0*d.manifest", generationWidth, generation)
61+}
62+func parseGenerationFilename(name string) (uint64, bool) {
63+ if !strings.HasSuffix(name, ".manifest") || len(name) != generationWidth+len(".manifest") {
64+ return 0, false
65+ }
66+ n := strings.TrimSuffix(name, ".manifest")
67+ generation, err := strconv.ParseUint(n, 10, 64)
68+ return generation, err == nil && generationFilename(generation) == name && generation != 0
69+}
70+
71+func parseObjectDigest(name string) (ports.SnapshotDigest, bool) {
72+ var digest ports.SnapshotDigest
73+ if len(name) != hex.EncodedLen(len(digest)) || strings.ToLower(name) != name {
74+ return digest, false
75+ }
76+ _, err := hex.Decode(digest[:], []byte(name))
77+ return digest, err == nil
78+}
A internal/adapters/snapshot/path_security_test.go +85 -0
@@ -0,0 +1,85 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+)
12+
13+func TestRepositoryRejectsSymlinkedGenerationAndObjectShards(t *testing.T) {
14+ for _, target := range []string{repositoryGenerations, repositoryObjectsDir} {
15+ t.Run(target, func(t *testing.T) {
16+ repo := NewRepository(privateDir(t))
17+ publication := repositoryPublication(t, "named", 1, []byte("state"))
18+ if err := repo.Publish(context.Background(), publication); err != nil {
19+ t.Fatal(err)
20+ }
21+ key := sessionKey(publication.Name)
22+ inside := filepath.Join(repo.sessionPath(key), target)
23+ outside := t.TempDir()
24+ guard := filepath.Join(outside, "must-not-change")
25+ if err := os.WriteFile(guard, []byte("guard"), 0o600); err != nil {
26+ t.Fatal(err)
27+ }
28+ if err := os.Rename(inside, inside+"-real"); err != nil {
29+ t.Fatal(err)
30+ }
31+ if err := os.Symlink(outside, inside); err != nil {
32+ t.Fatal(err)
33+ }
34+ if _, err := repo.Load(context.Background(), publication.Name); err == nil {
35+ t.Fatal("Load succeeded through symlinked repository component")
36+ }
37+ // Maintenance must reject the replacement rather than following it.
38+ if err := repo.Maintain(context.Background()); err == nil {
39+ t.Fatal("Maintain succeeded through symlinked repository component")
40+ }
41+ got, err := os.ReadFile(guard)
42+ if err != nil || string(got) != "guard" {
43+ t.Fatalf("outside guard = %q, %v", got, err)
44+ }
45+ })
46+ }
47+}
48+
49+func TestStatReturnsInjectedCloseErrorWithContext(t *testing.T) {
50+ repo := NewRepository(privateDir(t))
51+ if err := repo.ensurePrivateDirectory(repo.dir); err != nil {
52+ t.Fatal(err)
53+ }
54+ path := filepath.Join(repo.dir, "state")
55+ if err := os.WriteFile(path, []byte("state"), 0o600); err != nil {
56+ t.Fatal(err)
57+ }
58+ closeCause := errors.New("injected stat close failure")
59+ repo.hooks.closeDescriptor = func(got string) error {
60+ if got == "close snapshot file" {
61+ return closeCause
62+ }
63+ return nil
64+ }
65+
66+ _, err := repo.stat(path)
67+ if !errors.Is(err, closeCause) || !strings.Contains(err.Error(), "close snapshot file") {
68+ t.Fatalf("stat error = %v, want contextual close failure", err)
69+ }
70+}
71+
72+func TestRepositoryLoadLegacyChargesUnrelatedRootEntries(t *testing.T) {
73+ repo := NewRepository(privateDir(t))
74+ if err := os.MkdirAll(repo.dir, 0o700); err != nil {
75+ t.Fatal(err)
76+ }
77+ for i := 0; i <= maxDirectoryTraversalEntries; i++ {
78+ if err := os.WriteFile(filepath.Join(repo.dir, fmt.Sprintf("unrelated-%05d", i)), nil, 0o600); err != nil {
79+ t.Fatal(err)
80+ }
81+ }
82+ if _, err := repo.LoadLegacy(context.Background()); !errors.Is(err, ErrDirectoryTraversalBudget) {
83+ t.Fatalf("LoadLegacy error = %v, want traversal budget error", err)
84+ }
85+}
A internal/adapters/snapshot/publish.go +331 -0
@@ -0,0 +1,331 @@
1+package snapshot
2+
3+import (
4+ "bytes"
5+ "context"
6+ "crypto/sha256"
7+ "fmt"
8+
9+ "github.com/bnema/vev/internal/ports"
10+ codec "github.com/bnema/vev/internal/usecase/snapshot"
11+)
12+
13+func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPublication) error {
14+ if err := ctx.Err(); err != nil {
15+ return err
16+ }
17+ key := sessionKey(publication.Name)
18+ lock := r.lockSession(key)
19+ defer r.unlockSession(lock)
20+ if err := ctx.Err(); err != nil {
21+ return err
22+ }
23+
24+ refs, err := validatePublicationManifest(publication)
25+ if err != nil {
26+ return err
27+ }
28+ if err := ctx.Err(); err != nil {
29+ return err
30+ }
31+ // A failed publication can still leave immutable blobs or a manifest behind.
32+ // Invalidate an in-progress GC mark before creating any publication storage.
33+ r.invalidateStorageEpoch(key)
34+ if err := r.ensureSession(key); err != nil {
35+ return err
36+ }
37+ // The common replay path needs neither a manifest decode nor a reference
38+ // map. Checking the immutable bytes directly also keeps its descriptor
39+ // reads beneath the pinned repository root.
40+ if generation, _, err := r.readHead(key); err == nil && generation == publication.Generation {
41+ if current, err := r.readBounded(r.manifestPath(key, generation)); err == nil && bytes.Equal(current, publication.Manifest) {
42+ return nil
43+ }
44+ }
45+
46+ current, currentManifest, currentRefs, err := r.currentPublication(ctx, publication.Name, key)
47+ if err != nil {
48+ return err
49+ }
50+ if publication.Generation < current || publication.Generation > current+1 {
51+ return fmt.Errorf("snapshot generation %d, current %d: immutable conflict", publication.Generation, current)
52+ }
53+ if publication.Generation == current {
54+ if !bytes.Equal(currentManifest, publication.Manifest) {
55+ return fmt.Errorf("snapshot generation %d: immutable conflict", publication.Generation)
56+ }
57+ return nil
58+ }
59+
60+ // Retained refs from the authoritative generation were verified when their
61+ // immutable files were installed. They are intentionally not re-read when a
62+ // producer supplies them again. Every other referenced object is checked
63+ // from disk once, or validated and installed from the supplied bytes.
64+ supplied, err := suppliedNecessaryObjects(refs, currentRefs, publication.Objects)
65+ if err != nil {
66+ return err
67+ }
68+ for digest, ref := range refs {
69+ if retainedObject(currentRefs, digest, ref) {
70+ continue
71+ }
72+ if err := ctx.Err(); err != nil {
73+ return err
74+ }
75+ path := r.objectPath(key, digest)
76+ exists, err := r.verifyObjectFile(path, digest, ref)
77+ if err != nil {
78+ return fmt.Errorf("verify object %x: %w", digest, err)
79+ }
80+ if exists {
81+ continue
82+ }
83+ objects := supplied[digest]
84+ if len(objects) == 0 {
85+ return fmt.Errorf("missing referenced object %x", digest)
86+ }
87+ object, err := r.validateSuppliedObject(objects, digest, ref)
88+ if err != nil {
89+ return err
90+ }
91+ if r.hooks.beforeBlobWrite != nil {
92+ if err := r.hooks.beforeBlobWrite(path); err != nil {
93+ return err
94+ }
95+ }
96+ if err := ctx.Err(); err != nil {
97+ return err
98+ }
99+ if err := r.writeImmutable(path, object.Data, func(existing []byte) error {
100+ if r.objectDigest(existing) != digest || !validObject(existing, ref) {
101+ return fmt.Errorf("existing immutable object is invalid")
102+ }
103+ return nil
104+ }); err != nil {
105+ return fmt.Errorf("write object: %w", err)
106+ }
107+ }
108+
109+ manifestPath := r.manifestPath(key, publication.Generation)
110+ existing, exists, err := r.readOptionalBounded(manifestPath)
111+ if err != nil {
112+ return err
113+ }
114+ if exists {
115+ if !bytes.Equal(existing, publication.Manifest) {
116+ return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation)
117+ }
118+ } else {
119+ if r.hooks.beforeManifestWrite != nil {
120+ if err := r.hooks.beforeManifestWrite(manifestPath); err != nil {
121+ return err
122+ }
123+ }
124+ if err := ctx.Err(); err != nil {
125+ return err
126+ }
127+ if err := r.writeImmutable(manifestPath, publication.Manifest, func(existing []byte) error {
128+ if !bytes.Equal(existing, publication.Manifest) {
129+ return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation)
130+ }
131+ return nil
132+ }); err != nil {
133+ return fmt.Errorf("write manifest: %w", err)
134+ }
135+ }
136+ if r.hooks.beforeHeadWrite != nil {
137+ if err := r.hooks.beforeHeadWrite(r.headPath(key)); err != nil {
138+ return err
139+ }
140+ }
141+ if err := ctx.Err(); err != nil {
142+ return err
143+ }
144+ if err := r.writeMutable(r.headPath(key), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest))); err != nil {
145+ return fmt.Errorf("write HEAD: %w", err)
146+ }
147+ return nil
148+}
149+
150+// currentPublication takes the normal HEAD path without loading every object
151+// in the current generation. A damaged HEAD or manifest still uses the full
152+// fallback scan, because only Load's object validation can establish a safe
153+// replacement authoritative generation in that recovery case.
154+func (r *Repository) currentPublication(ctx context.Context, name, key string) (uint64, []byte, map[ports.SnapshotDigest]codec.ObjectRef, error) {
155+ generation, digest, err := r.readHead(key)
156+ if err == nil {
157+ data, readErr := r.readBounded(r.manifestPath(key, generation))
158+ if readErr == nil && sha256.Sum256(data) == digest {
159+ refs, validateErr := validateManifest(data, name, generation)
160+ if validateErr == nil {
161+ return generation, data, refs, nil
162+ }
163+ }
164+ }
165+
166+ generation, data, err := r.currentGeneration(ctx, name, key)
167+ if err != nil || generation == 0 {
168+ return generation, data, nil, err
169+ }
170+ refs, err := validateManifest(data, name, generation)
171+ if err != nil {
172+ return 0, nil, nil, err
173+ }
174+ return generation, data, refs, nil
175+}
176+
177+func validatePublication(p ports.SnapshotPublication) (map[ports.SnapshotDigest]codec.ObjectRef, map[ports.SnapshotDigest]ports.SnapshotObject, error) {
178+ refs, err := validatePublicationManifest(p)
179+ if err != nil {
180+ return nil, nil, err
181+ }
182+ supplied, err := suppliedNecessaryObjects(refs, nil, p.Objects)
183+ if err != nil {
184+ return nil, nil, err
185+ }
186+ objects := make(map[ports.SnapshotDigest]ports.SnapshotObject, len(supplied))
187+ for digest, entries := range supplied {
188+ object, err := validateSuppliedObject(entries, digest, refs[digest], nil)
189+ if err != nil {
190+ return nil, nil, err
191+ }
192+ objects[digest] = object
193+ }
194+ return refs, objects, nil
195+}
196+
197+func validatePublicationManifest(p ports.SnapshotPublication) (map[ports.SnapshotDigest]codec.ObjectRef, error) {
198+ return validateManifest(p.Manifest, p.Name, p.Generation)
199+}
200+
201+func validateManifest(data []byte, name string, generation uint64) (map[ports.SnapshotDigest]codec.ObjectRef, error) {
202+ manifest, err := codec.UnmarshalManifest(data)
203+ if err != nil {
204+ return nil, fmt.Errorf("invalid manifest: %w", err)
205+ }
206+ if manifest.Name != name || manifest.Generation != generation || generation == 0 {
207+ return nil, fmt.Errorf("manifest name or generation does not match publication")
208+ }
209+ refs := manifestRefs(manifest)
210+ if refs == nil {
211+ return nil, fmt.Errorf("conflicting manifest references")
212+ }
213+ if !withinGenerationBudget(len(data), refs) {
214+ return nil, fmt.Errorf("snapshot generation too large")
215+ }
216+ return refs, nil
217+}
218+
219+// suppliedNecessaryObjects first rejects unreferenced input without inspecting
220+// its payload. Objects retained from currentRefs cannot affect publication and
221+// are left out so callers do not pay to hash or copy a full history again.
222+func suppliedNecessaryObjects(refs, currentRefs map[ports.SnapshotDigest]codec.ObjectRef, supplied []ports.SnapshotObject) (map[ports.SnapshotDigest][]ports.SnapshotObject, error) {
223+ out := make(map[ports.SnapshotDigest][]ports.SnapshotObject)
224+ for _, object := range supplied {
225+ if _, used := refs[object.Digest]; !used {
226+ return nil, fmt.Errorf("unreferenced object")
227+ }
228+ if retainedObject(currentRefs, object.Digest, refs[object.Digest]) {
229+ continue
230+ }
231+ out[object.Digest] = append(out[object.Digest], object)
232+ }
233+ return out, nil
234+}
235+
236+func retainedObject(currentRefs map[ports.SnapshotDigest]codec.ObjectRef, digest ports.SnapshotDigest, ref codec.ObjectRef) bool {
237+ currentRef, ok := currentRefs[digest]
238+ return ok && currentRef == ref
239+}
240+
241+func (r *Repository) validateSuppliedObject(objects []ports.SnapshotObject, digest ports.SnapshotDigest, ref codec.ObjectRef) (ports.SnapshotObject, error) {
242+ return validateSuppliedObject(objects, digest, ref, r)
243+}
244+
245+func validateSuppliedObject(objects []ports.SnapshotObject, digest ports.SnapshotDigest, ref codec.ObjectRef, repository *Repository) (ports.SnapshotObject, error) {
246+ object := objects[0]
247+ for _, candidate := range objects {
248+ actual := sha256.Sum256(candidate.Data)
249+ if repository != nil {
250+ actual = repository.objectDigest(candidate.Data)
251+ }
252+ if actual != candidate.Digest || candidate.Digest != digest {
253+ return ports.SnapshotObject{}, fmt.Errorf("object digest mismatch")
254+ }
255+ if !bytes.Equal(candidate.Data, object.Data) {
256+ return ports.SnapshotObject{}, fmt.Errorf("conflicting object")
257+ }
258+ }
259+ kind, payload, err := codec.PreflightObject(object.Data)
260+ if err != nil || kind != ref.Kind || len(object.Data) != int(ref.Size) || len(payload) == 0 {
261+ return ports.SnapshotObject{}, fmt.Errorf("invalid object envelope")
262+ }
263+ if repository != nil && repository.hooks.beforeObjectCopy != nil {
264+ repository.hooks.beforeObjectCopy(object.Data)
265+ }
266+ return ports.SnapshotObject{Digest: digest, Data: append([]byte(nil), object.Data...)}, nil
267+}
268+
269+func withinGenerationBudget(manifestSize int, refs map[ports.SnapshotDigest]codec.ObjectRef) bool {
270+ if manifestSize < 0 || manifestSize > maxRepositoryRead {
271+ return false
272+ }
273+ total := uint64(manifestSize)
274+ for _, ref := range refs {
275+ if uint64(ref.Size) > uint64(maxRepositoryRead)-total {
276+ return false
277+ }
278+ total += uint64(ref.Size)
279+ }
280+ return true
281+}
282+
283+func manifestRefs(manifest codec.Manifest) map[ports.SnapshotDigest]codec.ObjectRef {
284+ refs := make(map[ports.SnapshotDigest]codec.ObjectRef)
285+ add := func(ref codec.ObjectRef) bool {
286+ if old, ok := refs[ref.Digest]; ok && old != ref {
287+ return false
288+ }
289+ refs[ref.Digest] = ref
290+ return true
291+ }
292+ for _, tab := range manifest.Tabs {
293+ for _, pane := range tab.Panes {
294+ for _, ref := range pane.Sealed {
295+ if !add(ref) {
296+ return nil
297+ }
298+ }
299+ if !add(pane.Tail) || !add(pane.Visible) {
300+ return nil
301+ }
302+ }
303+ }
304+ return refs
305+}
306+
307+func validObject(data []byte, ref codec.ObjectRef) bool {
308+ kind, payload, err := codec.PreflightObject(data)
309+ return err == nil && kind == ref.Kind && len(payload) > 0 && len(data) == int(ref.Size)
310+}
311+
312+func (r *Repository) objectDigest(data []byte) ports.SnapshotDigest {
313+ if r.hooks.beforeObjectHash != nil {
314+ r.hooks.beforeObjectHash(data)
315+ }
316+ return sha256.Sum256(data)
317+}
318+
319+func (r *Repository) verifyObjectFile(path string, digest ports.SnapshotDigest, ref codec.ObjectRef) (bool, error) {
320+ if r.hooks.beforeObjectRead != nil {
321+ r.hooks.beforeObjectRead(path)
322+ }
323+ data, exists, err := r.readOptionalBounded(path)
324+ if err != nil || !exists {
325+ return exists, err
326+ }
327+ if r.objectDigest(data) != digest || !validObject(data, ref) {
328+ return true, fmt.Errorf("existing immutable object is invalid")
329+ }
330+ return true, nil
331+}
A internal/adapters/snapshot/publish_efficiency_test.go +198 -0
@@ -0,0 +1,198 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "crypto/sha256"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+
11+ "github.com/bnema/vev/internal/ports"
12+ codec "github.com/bnema/vev/internal/usecase/snapshot"
13+)
14+
15+func TestRepositoryPublishSkipsUnchangedTenThousandObjectHistory(t *testing.T) {
16+ const historyObjects = 10_000
17+
18+ repo := NewRepository(privateDir(t))
19+ first, second := largeIncrementalPublications(t, "named", historyObjects)
20+ seedCompletePublication(t, repo, first)
21+
22+ accounting := installFilesystemPublishAccounting(repo)
23+ if err := repo.Publish(context.Background(), second); err != nil {
24+ t.Fatal(err)
25+ }
26+ // Only the new mutable tail and visible objects need work. The 10k sealed
27+ // objects are retained history supplied again by the producer, not input
28+ // that Publish needs to read, hash, copy, or write.
29+ requireFilesystemPublishAccounting(t, accounting, filesystemPublishAccounting{
30+ objectReads: 2, objectHashes: 2, objectCopies: 2,
31+ tempCreates: 4, tempWrites: 4, fileSyncs: 4,
32+ objectWrites: 2, manifestWrites: 1, headWrites: 1,
33+ immutableInstalls: 3, mutableRenames: 1, tempRemoves: 3, directorySyncs: 4,
34+ })
35+
36+ // Replaying the now-current generation still parses the 10k references, but
37+ // must not allocate one object-sized buffer per retained history entry.
38+ allocations := testing.AllocsPerRun(3, func() {
39+ if err := repo.Publish(context.Background(), second); err != nil {
40+ panic(err)
41+ }
42+ })
43+ if allocations > 300 {
44+ t.Fatalf("unchanged 10k history Publish allocations = %.0f, want <= 300; retained objects were likely copied", allocations)
45+ }
46+
47+ got, err := repo.Load(context.Background(), second.Name)
48+ if err != nil {
49+ t.Fatal(err)
50+ }
51+ if got.Generation != second.Generation {
52+ t.Fatalf("published generation = %d, want %d", got.Generation, second.Generation)
53+ }
54+}
55+
56+type filesystemPublishAccounting struct {
57+ objectReads, objectHashes, objectCopies int
58+ tempCreates, tempWrites, fileSyncs int
59+ objectWrites, manifestWrites, headWrites int
60+ immutableInstalls, mutableRenames, tempRemoves, directorySyncs int
61+}
62+
63+func installFilesystemPublishAccounting(repo *Repository) *filesystemPublishAccounting {
64+ accounting := &filesystemPublishAccounting{}
65+ repo.hooks.beforeObjectRead = func(string) { accounting.objectReads++ }
66+ repo.hooks.beforeObjectHash = func([]byte) { accounting.objectHashes++ }
67+ repo.hooks.beforeObjectCopy = func([]byte) { accounting.objectCopies++ }
68+ repo.hooks.createTemp = func(string) error { accounting.tempCreates++; return nil }
69+ repo.hooks.writeTemp = func(string) error { accounting.tempWrites++; return nil }
70+ repo.hooks.syncFile = func(string) error { accounting.fileSyncs++; return nil }
71+ repo.hooks.beforeBlobWrite = func(string) error { accounting.objectWrites++; return nil }
72+ repo.hooks.beforeManifestWrite = func(string) error { accounting.manifestWrites++; return nil }
73+ repo.hooks.beforeHeadWrite = func(string) error { accounting.headWrites++; return nil }
74+ repo.hooks.installImmutable = func(string) error { accounting.immutableInstalls++; return nil }
75+ repo.hooks.rename = func(string) error { accounting.mutableRenames++; return nil }
76+ repo.hooks.remove = func(string) error { accounting.tempRemoves++; return nil }
77+ repo.hooks.syncDirectory = func(string) error { accounting.directorySyncs++; return nil }
78+ return accounting
79+}
80+
81+func requireFilesystemPublishAccounting(t *testing.T, got *filesystemPublishAccounting, want filesystemPublishAccounting) {
82+ t.Helper()
83+ if *got != want {
84+ t.Fatalf("filesystem Publish accounting = %+v, want %+v", *got, want)
85+ }
86+}
87+
88+func BenchmarkRepositoryPublishUnchangedTenThousandObjectHistory(b *testing.B) {
89+ first, second := largeIncrementalPublications(b, "named", 10_000)
90+ repo := NewRepository(filepath.Join(b.TempDir(), "vev"))
91+ seedCompletePublication(b, repo, first)
92+ accounting := installFilesystemPublishAccounting(repo)
93+
94+ b.ReportAllocs()
95+ b.ResetTimer()
96+ for b.Loop() {
97+ if err := repo.Publish(context.Background(), second); err != nil {
98+ b.Fatal(err)
99+ }
100+ }
101+ b.StopTimer()
102+ // The benchmark deliberately replays the current generation so it can run
103+ // repeatedly without fixture writes. Its bounds catch a regression that
104+ // reads, hashes, or copies all 10k retained objects on the fast path.
105+ if accounting.objectReads > 2 || accounting.objectHashes > 2 || accounting.objectCopies > 2 {
106+ b.Fatalf("unchanged 10k history per-run object work = %d/%d/%d, want <= 2/2/2", accounting.objectReads, accounting.objectHashes, accounting.objectCopies)
107+ }
108+}
109+
110+func TestRepositoryPublishVerifiesNecessaryExistingObjectOnce(t *testing.T) {
111+ repo := NewRepository(privateDir(t))
112+ first := repositoryPublication(t, "named", 1, []byte("first"))
113+ if err := repo.Publish(context.Background(), first); err != nil {
114+ t.Fatal(err)
115+ }
116+ second := repositoryPublication(t, "named", 2, []byte("second"))
117+ newTail := second.Objects[0]
118+ key := sessionKey(second.Name)
119+ if err := os.MkdirAll(filepath.Dir(repo.objectPath(key, newTail.Digest)), 0o700); err != nil {
120+ t.Fatal(err)
121+ }
122+ if err := os.WriteFile(repo.objectPath(key, newTail.Digest), newTail.Data, 0o600); err != nil {
123+ t.Fatal(err)
124+ }
125+
126+ var reads, hashes, copies int
127+ repo.hooks.beforeObjectRead = func(string) { reads++ }
128+ repo.hooks.beforeObjectHash = func([]byte) { hashes++ }
129+ repo.hooks.beforeObjectCopy = func([]byte) { copies++ }
130+ if err := repo.Publish(context.Background(), second); err != nil {
131+ t.Fatal(err)
132+ }
133+ // The existing new tail is checked from disk once. The unchanged visible
134+ // object is retained from generation one and needs no further work.
135+ if reads != 1 || hashes != 1 || copies != 0 {
136+ t.Fatalf("necessary object reads/hashes/copies = %d/%d/%d, want 1/1/0", reads, hashes, copies)
137+ }
138+}
139+
140+func largeIncrementalPublications(t testing.TB, name string, count int) (ports.SnapshotPublication, ports.SnapshotPublication) {
141+ t.Helper()
142+ sealed := make([]codec.ObjectRef, 0, count)
143+ objects := make([]ports.SnapshotObject, 0, count+2)
144+ for i := range count {
145+ object, err := codec.MarshalObject(codec.HistoryChunk, []byte(fmt.Sprintf("history-%d", i)))
146+ if err != nil {
147+ t.Fatal(err)
148+ }
149+ sealed = append(sealed, codec.ObjectRef{Kind: codec.HistoryChunk, Digest: object.Digest, Size: uint32(len(object.Data))})
150+ objects = append(objects, object)
151+ }
152+ makePublication := func(generation uint64, tailPayload, visiblePayload string) ports.SnapshotPublication {
153+ t.Helper()
154+ tail, err := codec.MarshalObject(codec.HistoryTail, []byte(tailPayload))
155+ if err != nil {
156+ t.Fatal(err)
157+ }
158+ visible, err := codec.MarshalObject(codec.Visible, []byte(visiblePayload))
159+ if err != nil {
160+ t.Fatal(err)
161+ }
162+ manifest, err := codec.MarshalManifest(codec.Manifest{Generation: generation, Name: name, Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Sealed: sealed, Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Visible: codec.ObjectRef{Kind: codec.Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}}}}}})
163+ if err != nil {
164+ t.Fatal(err)
165+ }
166+ return ports.SnapshotPublication{Name: name, Generation: generation, Manifest: manifest, Objects: append(append([]ports.SnapshotObject(nil), objects...), tail, visible)}
167+ }
168+ return makePublication(1, "tail-1", "visible-1"), makePublication(2, "tail-2", "visible-2")
169+}
170+
171+func seedCompletePublication(t testing.TB, repo *Repository, publication ports.SnapshotPublication) {
172+ t.Helper()
173+ key := sessionKey(publication.Name)
174+ if err := repo.ensureSession(key); err != nil {
175+ t.Fatal(err)
176+ }
177+ manifest, err := codec.UnmarshalManifest(publication.Manifest)
178+ if err != nil {
179+ t.Fatal(err)
180+ }
181+ for _, object := range publication.Objects {
182+ if err := os.MkdirAll(filepath.Dir(repo.objectPath(key, object.Digest)), 0o700); err != nil {
183+ t.Fatal(err)
184+ }
185+ if err := os.WriteFile(repo.objectPath(key, object.Digest), object.Data, 0o600); err != nil {
186+ t.Fatal(err)
187+ }
188+ }
189+ if err := os.WriteFile(repo.manifestPath(key, publication.Generation), publication.Manifest, 0o600); err != nil {
190+ t.Fatal(err)
191+ }
192+ if err := os.WriteFile(repo.headPath(key), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest)), 0o600); err != nil {
193+ t.Fatal(err)
194+ }
195+ if refs := manifestRefs(manifest); refs == nil {
196+ t.Fatal("invalid seeded manifest")
197+ }
198+}
A internal/adapters/snapshot/repository.go +178 -0
@@ -0,0 +1,178 @@
1+package snapshot
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/vev/internal/ports"
7+)
8+
9+const (
10+ repositorySessionsDir = "sessions"
11+ repositoryObjectsDir = "objects"
12+ repositoryGenerations = "generations"
13+ repositoryHead = "HEAD"
14+ generationWidth = 20
15+ maxRepositoryRead = maxSnapshotFileSize
16+ // Legacy import is deliberately smaller than the per-file parser ceiling:
17+ // importing old snapshots must not accumulate an unbounded startup payload.
18+ maxLegacySnapshotFiles = 64
19+ maxLegacySnapshotBytes = 8 << 20
20+)
21+
22+// Repository is the crash-safe, content-addressed session snapshot store.
23+// Store remains available separately for the one-way legacy bridge.
24+type Repository struct {
25+ dir string
26+ hooks repositoryHooks
27+
28+ // sessionStateMu owns both maps. A caller retains a reference before it
29+ // waits on a session mutex, so an idle entry can never be removed while a
30+ // waiter still holds its old mutex. Epoch entries share that lifetime.
31+ sessionStateMu sync.Mutex
32+ locks map[string]*sessionMutex
33+ storageEpochs map[string]uint64
34+ nextEpoch uint64
35+
36+ // maintenanceMu owns bounded continuation metadata: seek cookies and
37+ // pending directory entries. Directory descriptors are opened and closed
38+ // for each maintenance call.
39+ maintenanceMu sync.Mutex
40+ maintenanceCursors map[string]*maintenanceCursor
41+ maintenanceSessions map[string]*sessionMaintenance
42+ maintenanceQuarantine *quarantineMaintenance
43+
44+ // pendingLegacySync records an unlink whose root-directory sync failed.
45+ // It is keyed by the deterministic legacy filename and shares a per-file
46+ // lock with DeleteLegacy so a retry cannot acknowledge a recreated file.
47+ pendingLegacySync sync.Map // map[string]struct{}
48+}
49+
50+// repositoryHooks makes each persistence boundary fault-injectable. Hooks run
51+// immediately before their respective syscall and are intentionally package
52+// private so production callers cannot weaken repository guarantees.
53+type repositoryHooks struct {
54+ beforeBlobWrite func(string) error
55+ beforeManifestWrite func(string) error
56+ // Object hooks instrument the publication path in package tests. They keep
57+ // the steady-state cost of retained history observable without exposing a
58+ // production metrics surface.
59+ beforeObjectRead func(string)
60+ beforeObjectHash func([]byte)
61+ beforeObjectCopy func([]byte)
62+ beforeHeadRead func(string) error
63+ beforeHeadWrite func(string) error
64+ beforeTombstoneCheck func(string)
65+ beforePendingQuarantineCheck func(string)
66+ createTemp func(string) error
67+ writeTemp func(string) error
68+ syncFile func(string) error
69+ closeFile func(string) error
70+ closeDescriptor func(string) error
71+ installImmutable func(string) error
72+ rename func(string) error
73+ syncDirectory func(string) error
74+ remove func(string) error
75+ openMaintenanceDirectory func(string) (maintenanceDirectory, error)
76+ // beforeMaintenanceWork observes each budgeted quarantine filesystem step.
77+ // It is test-only instrumentation for hostile traversal bounds.
78+ beforeMaintenanceWork func(string)
79+ openLegacyDirectory func(string) (legacyDirectory, error)
80+ beforeSessionLock func(string)
81+}
82+
83+var _ ports.SnapshotRepository = (*Repository)(nil)
84+var _ ports.LegacySnapshotSource = (*Repository)(nil)
85+
86+// NewRepository creates a repository rooted at dir. It does not create files
87+// until the first publication, so merely constructing it is side-effect free.
88+func NewRepository(dir string) *Repository {
89+ return &Repository{
90+ dir: dir,
91+ locks: make(map[string]*sessionMutex),
92+ storageEpochs: make(map[string]uint64),
93+ }
94+}
95+
96+// invalidateStorageEpoch must be called with lockSession(key) held. The
97+// registry mutex makes accesses for different session keys race-free too.
98+func (r *Repository) invalidateStorageEpoch(key string) {
99+ r.sessionStateMu.Lock()
100+ r.nextEpoch++
101+ r.storageEpochs[key] = r.nextEpoch
102+ r.sessionStateMu.Unlock()
103+}
104+
105+// storageEpoch must be called with lockSession(key) held.
106+func (r *Repository) storageEpoch(key string) uint64 {
107+ r.sessionStateMu.Lock()
108+ epoch := r.storageEpochs[key]
109+ r.sessionStateMu.Unlock()
110+ return epoch
111+}
112+
113+// sessionMutex is retained by each waiter and each resumable maintenance
114+// state. That reference is what makes eviction safe: a new caller cannot lock
115+// a replacement mutex until all users of the prior mutex have left it.
116+type sessionMutex struct {
117+ key string
118+ mu sync.Mutex
119+ references int
120+}
121+
122+func (r *Repository) lockSession(key string) *sessionMutex {
123+ r.sessionStateMu.Lock()
124+ lock := r.locks[key]
125+ if lock == nil {
126+ lock = &sessionMutex{key: key}
127+ r.locks[key] = lock
128+ }
129+ lock.references++
130+ r.sessionStateMu.Unlock()
131+ if hook := r.hooks.beforeSessionLock; hook != nil {
132+ hook(key)
133+ }
134+ lock.mu.Lock()
135+ return lock
136+}
137+
138+func (r *Repository) unlockSession(lock *sessionMutex) {
139+ lock.mu.Unlock()
140+ r.releaseSessionReference(lock)
141+}
142+
143+func (r *Repository) retainSessionState(key string) *sessionMutex {
144+ r.sessionStateMu.Lock()
145+ lock := r.locks[key]
146+ // sessionMaintenanceState is called while the active session lock is held.
147+ if lock == nil {
148+ panic("snapshot: retain missing session lock")
149+ }
150+ lock.references++
151+ r.sessionStateMu.Unlock()
152+ return lock
153+}
154+
155+func (r *Repository) releaseSessionReference(lock *sessionMutex) {
156+ r.sessionStateMu.Lock()
157+ lock.references--
158+ if lock.references < 0 {
159+ r.sessionStateMu.Unlock()
160+ panic("snapshot: release of unretained session lock")
161+ }
162+ if lock.references == 0 {
163+ if r.locks[lock.key] != lock {
164+ r.sessionStateMu.Unlock()
165+ panic("snapshot: session lock registry mismatch")
166+ }
167+ delete(r.locks, lock.key)
168+ delete(r.storageEpochs, lock.key)
169+ }
170+ r.sessionStateMu.Unlock()
171+}
172+
173+// sessionStateCounts is test evidence that idle per-session state is evicted.
174+func (r *Repository) sessionStateCounts() (locks, epochs int) {
175+ r.sessionStateMu.Lock()
176+ defer r.sessionStateMu.Unlock()
177+ return len(r.locks), len(r.storageEpochs)
178+}
A internal/adapters/snapshot/repository_durability_test.go +183 -0
@@ -0,0 +1,183 @@
1+package snapshot
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+
11+ "github.com/bnema/vev/internal/ports"
12+ codec "github.com/bnema/vev/internal/usecase/snapshot"
13+)
14+
15+func TestRepositoryImmutableInstallDoesNotOverwriteRacedTarget(t *testing.T) {
16+ repo := NewRepository(privateDir(t))
17+ pub := repositoryPublication(t, "named", 1, []byte("state"))
18+ object := pub.Objects[0]
19+ target := repo.objectPath(sessionKey(pub.Name), object.Digest)
20+ repo.hooks.installImmutable = func(path string) error {
21+ if path == target {
22+ return os.WriteFile(path, []byte("attacker bytes"), 0o600)
23+ }
24+ return nil
25+ }
26+ if err := repo.Publish(context.Background(), pub); err == nil {
27+ t.Fatal("Publish succeeded after raced immutable target")
28+ }
29+ got, err := os.ReadFile(target)
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ if string(got) != "attacker bytes" {
34+ t.Fatalf("raced target = %q, want attacker bytes", got)
35+ }
36+}
37+
38+func prepareHeadStage(t *testing.T, repo *Repository, pub ports.SnapshotPublication) {
39+ t.Helper()
40+ key := sessionKey(pub.Name)
41+ prepareObjects(t, repo, pub)
42+ if err := repo.writeImmutable(repo.manifestPath(key, pub.Generation), pub.Manifest, func(data []byte) error {
43+ if !bytes.Equal(data, pub.Manifest) {
44+ return errors.New("invalid manifest")
45+ }
46+ return nil
47+ }); err != nil {
48+ t.Fatal(err)
49+ }
50+}
51+
52+func manifestReference(t *testing.T, data []byte, digest ports.SnapshotDigest) codec.ObjectRef {
53+ t.Helper()
54+ manifest, err := codec.UnmarshalManifest(data)
55+ if err != nil {
56+ t.Fatal(err)
57+ }
58+ ref, ok := manifestRefs(manifest)[digest]
59+ if !ok {
60+ t.Fatal("missing object reference")
61+ }
62+ return ref
63+}
64+
65+func TestRepositoryFaultsAtEveryPublicationBoundary(t *testing.T) {
66+ cases := []struct {
67+ location string
68+ operation string
69+ }{
70+ {"blob", "create"}, {"blob", "write"}, {"blob", "sync-file"}, {"blob", "close"}, {"blob", "install"}, {"blob", "sync-directory"},
71+ {"manifest", "create"}, {"manifest", "write"}, {"manifest", "sync-file"}, {"manifest", "close"}, {"manifest", "install"}, {"manifest", "sync-directory"},
72+ {"HEAD", "create"}, {"HEAD", "write"}, {"HEAD", "sync-file"}, {"HEAD", "close"}, {"HEAD", "rename"}, {"HEAD", "sync-directory"},
73+ }
74+ for _, tc := range cases {
75+ t.Run(tc.location+"/"+tc.operation, func(t *testing.T) {
76+ repo := NewRepository(privateDir(t))
77+ first := repositoryPublication(t, "named", 1, []byte("one"))
78+ if err := repo.Publish(context.Background(), first); err != nil {
79+ t.Fatal(err)
80+ }
81+ second := repositoryPublication(t, "named", 2, []byte("two"))
82+ key := sessionKey(second.Name)
83+ if err := repo.ensureSession(key); err != nil {
84+ t.Fatal(err)
85+ }
86+ preparePublicationLocation(t, repo, second, tc.location)
87+ injectBoundary(repo, tc.location, tc.operation, second)
88+ if err := repo.Publish(context.Background(), second); err == nil {
89+ t.Fatal("Publish succeeded with injected persistence failure")
90+ }
91+ repo.hooks = repositoryHooks{}
92+ got, err := repo.Load(context.Background(), "named")
93+ if err != nil {
94+ t.Fatal(err)
95+ }
96+ if got.Generation != 1 && got.Generation != 2 {
97+ t.Fatalf("authoritative generation = %d, want old or new", got.Generation)
98+ }
99+ })
100+ }
101+}
102+
103+// preparePublicationLocation advances setup only far enough to target the
104+// requested persistence location in the publication fault matrix.
105+func preparePublicationLocation(t *testing.T, repo *Repository, pub ports.SnapshotPublication, location string) {
106+ t.Helper()
107+ switch location {
108+ case "manifest":
109+ prepareObjects(t, repo, pub)
110+ case "HEAD":
111+ prepareHeadStage(t, repo, pub)
112+ }
113+}
114+
115+func injectBoundary(repo *Repository, location, operation string, pub ports.SnapshotPublication) {
116+ key := sessionKey(pub.Name)
117+ dir := filepath.Dir(repo.objectPath(key, pub.Objects[0].Digest))
118+ if location == "manifest" {
119+ dir = filepath.Dir(repo.manifestPath(key, pub.Generation))
120+ }
121+ if location == "HEAD" {
122+ dir = filepath.Dir(repo.headPath(key))
123+ }
124+ fail := errors.New("injected persistence failure")
125+ repo.hooks.createTemp = func(got string) error {
126+ if operation == "create" && got == dir {
127+ return fail
128+ }
129+ return nil
130+ }
131+ repo.hooks.writeTemp = func(path string) error {
132+ if operation == "write" && filepath.Dir(path) == dir {
133+ return fail
134+ }
135+ return nil
136+ }
137+ repo.hooks.syncFile = func(path string) error {
138+ if operation == "sync-file" && filepath.Dir(path) == dir {
139+ return fail
140+ }
141+ return nil
142+ }
143+ repo.hooks.closeFile = func(path string) error {
144+ if operation == "close" && filepath.Dir(path) == dir {
145+ return fail
146+ }
147+ return nil
148+ }
149+ repo.hooks.installImmutable = func(path string) error {
150+ if operation == "install" && filepath.Dir(path) == dir {
151+ return fail
152+ }
153+ return nil
154+ }
155+ repo.hooks.rename = func(path string) error {
156+ if operation == "rename" && location == "HEAD" && path == repo.headPath(key) {
157+ return fail
158+ }
159+ return nil
160+ }
161+ repo.hooks.syncDirectory = func(got string) error {
162+ if operation == "sync-directory" && got == dir {
163+ return fail
164+ }
165+ return nil
166+ }
167+}
168+
169+func prepareObjects(t *testing.T, repo *Repository, pub ports.SnapshotPublication) {
170+ t.Helper()
171+ key := sessionKey(pub.Name)
172+ for _, object := range pub.Objects {
173+ ref := manifestReference(t, pub.Manifest, object.Digest)
174+ if err := repo.writeImmutable(repo.objectPath(key, object.Digest), object.Data, func(data []byte) error {
175+ if !bytes.Equal(data, object.Data) || !validObject(data, ref) {
176+ return errors.New("invalid object")
177+ }
178+ return nil
179+ }); err != nil {
180+ t.Fatal(err)
181+ }
182+ }
183+}
A internal/adapters/snapshot/repository_fault_test.go +204 -0
@@ -0,0 +1,204 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+
11+ codec "github.com/bnema/vev/internal/usecase/snapshot"
12+)
13+
14+func TestRepositoryHeadFailureKeepsOldCompleteGeneration(t *testing.T) {
15+ dir := privateDir(t)
16+ repo := NewRepository(dir)
17+ first := repositoryPublication(t, "named", 1, []byte("one"))
18+ if err := repo.Publish(context.Background(), first); err != nil {
19+ t.Fatal(err)
20+ }
21+ second := repositoryPublication(t, "named", 2, []byte("two"))
22+ repo.hooks.beforeHeadWrite = func(string) error { return errors.New("injected HEAD failure") }
23+ if err := repo.Publish(context.Background(), second); err == nil {
24+ t.Fatal("Publish succeeded with injected HEAD failure")
25+ }
26+ repo.hooks.beforeHeadWrite = nil
27+ got, err := repo.Load(context.Background(), "named")
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ if got.Generation != 1 {
32+ t.Fatalf("generation after failed publish = %d, want 1", got.Generation)
33+ }
34+}
35+
36+func TestRepositoryCleanupFaultsSurfaceForImmutableAndMutablePublication(t *testing.T) {
37+ primary := errors.New("primary write failure")
38+ cleanup := errors.New("cleanup failure")
39+ cases := []struct {
40+ name string
41+ location string
42+ cleanup string
43+ }{
44+ {name: "immutable/close temporary", location: "manifest", cleanup: "close"},
45+ {name: "immutable/remove temporary", location: "manifest", cleanup: "remove"},
46+ {name: "immutable/sync after temporary removal", location: "manifest", cleanup: "sync-after-remove"},
47+ {name: "mutable/close temporary", location: "HEAD", cleanup: "close"},
48+ {name: "mutable/remove temporary", location: "HEAD", cleanup: "remove"},
49+ {name: "mutable/sync after temporary removal", location: "HEAD", cleanup: "sync-after-remove"},
50+ }
51+ for _, tc := range cases {
52+ t.Run(tc.name, func(t *testing.T) {
53+ repo := NewRepository(privateDir(t))
54+ first := repositoryPublication(t, "named", 1, []byte("one"))
55+ if err := repo.Publish(context.Background(), first); err != nil {
56+ t.Fatal(err)
57+ }
58+ second := repositoryPublication(t, "named", 2, []byte("two"))
59+ key := sessionKey(second.Name)
60+ if tc.location == "manifest" {
61+ prepareObjects(t, repo, second)
62+ } else {
63+ prepareHeadStage(t, repo, second)
64+ }
65+ dir := filepath.Dir(repo.manifestPath(key, second.Generation))
66+ phase := "write manifest"
67+ if tc.location == "HEAD" {
68+ dir = filepath.Dir(repo.headPath(key))
69+ phase = "write HEAD"
70+ }
71+ repo.hooks.writeTemp = func(path string) error {
72+ if filepath.Dir(path) == dir {
73+ return primary
74+ }
75+ return nil
76+ }
77+ repo.hooks.closeFile = func(path string) error {
78+ if tc.cleanup == "close" && filepath.Dir(path) == dir {
79+ return cleanup
80+ }
81+ return nil
82+ }
83+ repo.hooks.remove = func(string) error {
84+ if tc.cleanup == "remove" {
85+ return cleanup
86+ }
87+ return nil
88+ }
89+ repo.hooks.syncDirectory = func(path string) error {
90+ if tc.cleanup == "sync-after-remove" && path == dir {
91+ return cleanup
92+ }
93+ return nil
94+ }
95+
96+ err := repo.Publish(context.Background(), second)
97+ if !errors.Is(err, primary) || !errors.Is(err, cleanup) || !strings.Contains(err.Error(), phase) {
98+ t.Fatalf("Publish error = %v, want %q and both primary and cleanup failures", err, phase)
99+ }
100+ if tc.cleanup != "remove" {
101+ matches, globErr := filepath.Glob(filepath.Join(dir, ".tmp-*"))
102+ if globErr != nil || len(matches) != 0 {
103+ t.Fatalf("temporary files after successful cleanup = %v, glob error = %v", matches, globErr)
104+ }
105+ }
106+ repo.hooks = repositoryHooks{}
107+ assertRepositoryCompleteGeneration(t, repo, "named", 1, 2)
108+ })
109+ }
110+}
111+
112+func TestRepositoryNewDirectorySyncFaultsAreIndependent(t *testing.T) {
113+ cases := []struct {
114+ name string
115+ phase string
116+ occurrence int
117+ directory func(*Repository, string) string
118+ }{
119+ {name: "repository", phase: "repository", occurrence: 1, directory: func(_ *Repository, root string) string { return filepath.Dir(root) }},
120+ {name: "sessions", phase: "sessions", occurrence: 1, directory: func(repo *Repository, _ string) string { return repo.dir }},
121+ {name: "session", phase: "session", occurrence: 1, directory: func(repo *Repository, _ string) string { return filepath.Join(repo.dir, repositorySessionsDir) }},
122+ {name: "objects", phase: "objects", occurrence: 1, directory: func(repo *Repository, _ string) string { return repo.sessionPath(sessionKey("named")) }},
123+ {name: "generations", phase: "generations", occurrence: 2, directory: func(repo *Repository, _ string) string { return repo.sessionPath(sessionKey("named")) }},
124+ {name: "object shard", phase: "object shard", occurrence: 1, directory: func(repo *Repository, _ string) string {
125+ return filepath.Join(repo.sessionPath(sessionKey("named")), repositoryObjectsDir)
126+ }},
127+ }
128+ for _, tc := range cases {
129+ t.Run(tc.name, func(t *testing.T) {
130+ parent := privateDir(t)
131+ root := filepath.Join(parent, "repository")
132+ repo := NewRepository(root)
133+ wantDirectory := tc.directory(repo, root)
134+ calls := 0
135+ injected := errors.New("new directory sync failure")
136+ repo.hooks.syncDirectory = func(directory string) error {
137+ if directory == wantDirectory {
138+ calls++
139+ if calls == tc.occurrence {
140+ return injected
141+ }
142+ }
143+ return nil
144+ }
145+
146+ publication := repositoryPublication(t, "named", 1, []byte("one"))
147+ err := repo.Publish(context.Background(), publication)
148+ if !errors.Is(err, injected) || !strings.Contains(err.Error(), tc.phase) {
149+ t.Fatalf("Publish error = %v, want %q-labelled directory sync failure", err, tc.phase)
150+ }
151+ repo.hooks = repositoryHooks{}
152+ // A first-generation failure cannot have an older complete generation;
153+ // it must not expose an incomplete one as if it were loadable.
154+ if _, loadErr := repo.Load(context.Background(), "named"); loadErr == nil || !strings.Contains(loadErr.Error(), "no complete snapshot generation") {
155+ t.Fatalf("Load error after incomplete first generation = %v, want no complete generation", loadErr)
156+ }
157+ if err := repo.Publish(context.Background(), publication); err != nil {
158+ t.Fatal(err)
159+ }
160+ assertRepositoryCompleteGeneration(t, repo, "named", 1)
161+ })
162+ }
163+}
164+
165+func assertRepositoryCompleteGeneration(t *testing.T, repo *Repository, name string, generations ...uint64) {
166+ t.Helper()
167+ got, err := repo.Load(context.Background(), name)
168+ if err != nil {
169+ t.Fatal(err)
170+ }
171+ for _, generation := range generations {
172+ if got.Generation == generation && len(got.Manifest) != 0 && len(got.Objects) != 0 {
173+ return
174+ }
175+ }
176+ t.Fatalf("Load = incomplete or mixed generation %+v, want one of %v", got, generations)
177+}
178+
179+func TestRepositoryLoadFallsBackFromIncompleteNewestGeneration(t *testing.T) {
180+ dir := privateDir(t)
181+ repo := NewRepository(dir)
182+ first := repositoryPublication(t, "named", 1, []byte("one"))
183+ if err := repo.Publish(context.Background(), first); err != nil {
184+ t.Fatal(err)
185+ }
186+ second := repositoryPublication(t, "named", 2, []byte("two"))
187+ if err := repo.Publish(context.Background(), second); err != nil {
188+ t.Fatal(err)
189+ }
190+ manifest, err := codec.UnmarshalManifest(second.Manifest)
191+ if err != nil {
192+ t.Fatal(err)
193+ }
194+ if err := os.Remove(repo.objectPath(sessionKey("named"), manifest.Tabs[0].Panes[0].Tail.Digest)); err != nil {
195+ t.Fatal(err)
196+ }
197+ got, err := repo.Load(context.Background(), "named")
198+ if err != nil {
199+ t.Fatal(err)
200+ }
201+ if got.Generation != 1 || !got.Fallback {
202+ t.Fatalf("Load = generation %d fallback %v, want generation 1 fallback true", got.Generation, got.Fallback)
203+ }
204+}
A internal/adapters/snapshot/repository_hardening_test.go +83 -0
@@ -0,0 +1,83 @@
1+package snapshot
2+
3+import (
4+ "errors"
5+ "os"
6+ "path/filepath"
7+ "syscall"
8+ "testing"
9+
10+ codec "github.com/bnema/vev/internal/usecase/snapshot"
11+)
12+
13+func TestReadBoundedRejectsUnsafeFiles(t *testing.T) {
14+ dir := t.TempDir()
15+ regular := filepath.Join(dir, "regular")
16+ if err := os.WriteFile(regular, []byte("safe"), 0o600); err != nil {
17+ t.Fatal(err)
18+ }
19+ tooLarge := filepath.Join(dir, "large")
20+ f, err := os.Create(tooLarge)
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ if err := f.Truncate(maxRepositoryRead + 1); err != nil {
25+ t.Fatal(err)
26+ }
27+ if err := f.Close(); err != nil {
28+ t.Fatal(err)
29+ }
30+ unsafeMode := filepath.Join(dir, "mode")
31+ if err := os.WriteFile(unsafeMode, []byte("unsafe"), 0o644); err != nil {
32+ t.Fatal(err)
33+ }
34+ link := filepath.Join(dir, "link")
35+ if err := os.Symlink(regular, link); err != nil {
36+ t.Fatal(err)
37+ }
38+ fifo := filepath.Join(dir, "fifo")
39+ if err := syscall.Mkfifo(fifo, 0o600); err != nil {
40+ t.Fatal(err)
41+ }
42+
43+ for _, path := range []string{tooLarge, unsafeMode, link, fifo} {
44+ t.Run(filepath.Base(path), func(t *testing.T) {
45+ if _, err := readBounded(path); err == nil {
46+ t.Fatal("readBounded accepted unsafe file")
47+ }
48+ })
49+ }
50+
51+ wrongOwner := filepath.Join(dir, "owner")
52+ if os.Geteuid() == 0 {
53+ if err := os.WriteFile(wrongOwner, []byte("unsafe"), 0o600); err != nil {
54+ t.Fatal(err)
55+ }
56+ if err := os.Chown(wrongOwner, 1, -1); err != nil {
57+ t.Fatal(err)
58+ }
59+ if _, err := readBounded(wrongOwner); err == nil {
60+ t.Fatal("readBounded accepted wrong-owner file")
61+ }
62+ }
63+}
64+
65+func TestValidatePublicationRejectsUnloadableAggregate(t *testing.T) {
66+ pub := repositoryPublication(t, "named", 1, []byte("state"))
67+ manifest, err := codec.UnmarshalManifest(pub.Manifest)
68+ if err != nil {
69+ t.Fatal(err)
70+ }
71+ manifest.Tabs[0].Panes[0].Tail.Size = uint32(maxRepositoryRead)
72+ manifest.Tabs[0].Panes[0].Visible.Size = uint32(maxRepositoryRead)
73+ pub.Manifest, err = codec.MarshalManifest(manifest)
74+ if err != nil {
75+ t.Fatal(err)
76+ }
77+ pub.Objects = nil // validation permits omitted objects for immutable reuse.
78+ if _, _, err := validatePublication(pub); err == nil {
79+ t.Fatal("validatePublication accepted unloadable aggregate")
80+ } else if errors.Is(err, os.ErrNotExist) {
81+ t.Fatalf("validatePublication returned unrelated error: %v", err)
82+ }
83+}
A internal/adapters/snapshot/repository_maintenance_test.go +62 -0
@@ -0,0 +1,62 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+)
11+
12+func TestSafeFilesystemErrorDoesNotExposePath(t *testing.T) {
13+ cause := errors.New("permission denied")
14+ err := safeFilesystemError(&os.PathError{Op: "remove", Path: "\x1b[31munsafe", Err: cause})
15+ if !errors.Is(err, cause) || strings.Contains(err.Error(), "\x1b[31munsafe") {
16+ t.Fatalf("safe filesystem error = %q", err)
17+ }
18+}
19+
20+func TestRepositoryMaintenanceFilesystemErrorsHaveSafeContext(t *testing.T) {
21+ injected := errors.New("injected filesystem failure")
22+ cases := []struct {
23+ name string
24+ run func(*Repository) error
25+ want string
26+ }{
27+ {
28+ name: "delete sync",
29+ run: func(repo *Repository) error {
30+ publication := repositoryPublication(t, "named", 1, []byte("state"))
31+ if err := repo.Publish(context.Background(), publication); err != nil {
32+ t.Fatal(err)
33+ }
34+ repo.hooks.syncDirectory = func(string) error { return injected }
35+ return repo.Delete(context.Background(), "named")
36+ },
37+ want: `sync deleted snapshot session directory "named"`,
38+ },
39+ {
40+ name: "maintain remove",
41+ run: func(repo *Repository) error {
42+ if err := os.Mkdir(repo.dir, 0o700); err != nil {
43+ t.Fatal(err)
44+ }
45+ if err := os.WriteFile(filepath.Join(repo.dir, ".tmp-stale"), []byte("stale"), 0o600); err != nil {
46+ t.Fatal(err)
47+ }
48+ repo.hooks.remove = func(string) error { return injected }
49+ return repo.Maintain(context.Background())
50+ },
51+ want: `remove snapshot maintenance file ".tmp-stale"`,
52+ },
53+ }
54+ for _, tc := range cases {
55+ t.Run(tc.name, func(t *testing.T) {
56+ err := tc.run(NewRepository(privateDir(t)))
57+ if !errors.Is(err, injected) || !strings.Contains(err.Error(), tc.want) {
58+ t.Fatalf("error = %v, want context %q and injected error", err, tc.want)
59+ }
60+ })
61+ }
62+}
A internal/adapters/snapshot/repository_phase6_test.go +107 -0
@@ -0,0 +1,107 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "sync"
10+ "sync/atomic"
11+ "testing"
12+
13+ "github.com/bnema/vev/internal/ports"
14+)
15+
16+func TestRepositoryPublishDeleteManySessionsRaceFree(t *testing.T) {
17+ repo := NewRepository(privateDir(t))
18+ const sessions = 128
19+
20+ publications := make([]ports.SnapshotPublication, sessions)
21+ for i := range publications {
22+ name := fmt.Sprintf("named-%03d", i)
23+ publications[i] = repositoryPublication(t, name, 1, []byte(name))
24+ if err := repo.Publish(context.Background(), publications[i]); err != nil {
25+ t.Fatalf("initial Publish(%q): %v", name, err)
26+ }
27+ }
28+
29+ var wg sync.WaitGroup
30+ for _, publication := range publications {
31+ wg.Add(2)
32+ go func() {
33+ defer wg.Done()
34+ if err := repo.Publish(context.Background(), publication); err != nil {
35+ t.Errorf("Publish(%q): %v", publication.Name, err)
36+ }
37+ }()
38+ go func() {
39+ defer wg.Done()
40+ if err := repo.Delete(context.Background(), publication.Name); err != nil {
41+ t.Errorf("Delete(%q): %v", publication.Name, err)
42+ }
43+ }()
44+ }
45+ wg.Wait()
46+
47+ locks, epochs := repo.sessionStateCounts()
48+ if locks != 0 || epochs != 0 {
49+ t.Fatalf("retained session state = %d locks, %d epochs, want none", locks, epochs)
50+ }
51+}
52+
53+func TestRepositoryBoundsAndEvictsPartialMaintenanceSessionState(t *testing.T) {
54+ repo := NewRepository(privateDir(t))
55+ for _, key := range []string{"named-000", "named-001"} {
56+ generations := filepath.Join(repo.sessionPath(key), repositoryGenerations)
57+ for i := 0; i < maintenanceBatch+1; i++ {
58+ if err := os.MkdirAll(generations, 0o700); err != nil {
59+ t.Fatal(err)
60+ }
61+ if err := os.WriteFile(filepath.Join(generations, fmt.Sprintf("unclassified-%03d", i)), []byte("state"), 0o600); err != nil {
62+ t.Fatal(err)
63+ }
64+ }
65+ }
66+ if err := repo.Maintain(context.Background()); err != nil {
67+ t.Fatal(err)
68+ }
69+ locks, epochs := repo.sessionStateCounts()
70+ if locks != 1 || epochs != 0 {
71+ t.Fatalf("partial maintenance state = %d locks, %d epochs, want one lock and no epochs", locks, epochs)
72+ }
73+
74+ ctx, cancel := context.WithCancel(context.Background())
75+ cancel()
76+ if err := repo.Maintain(ctx); !errors.Is(err, context.Canceled) {
77+ t.Fatalf("canceled Maintain error = %v, want context canceled", err)
78+ }
79+ locks, epochs = repo.sessionStateCounts()
80+ if locks != 0 || epochs != 0 {
81+ t.Fatalf("session state after maintenance reset = %d locks, %d epochs, want none", locks, epochs)
82+ }
83+}
84+
85+func TestRepositoryDeleteUsesDeterministicPendingQuarantine(t *testing.T) {
86+ repo := NewRepository(privateDir(t))
87+ if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))); err != nil {
88+ t.Fatal(err)
89+ }
90+ sessions := filepath.Join(repo.dir, repositorySessionsDir)
91+ for i := 0; i < maxDirectoryTraversalEntries*2; i++ {
92+ if err := os.Mkdir(filepath.Join(sessions, fmt.Sprintf("unrelated-%05d", i)), 0o700); err != nil {
93+ t.Fatal(err)
94+ }
95+ }
96+ var checks atomic.Int32
97+ repo.hooks.beforePendingQuarantineCheck = func(string) { checks.Add(1) }
98+ if err := repo.Delete(context.Background(), "named"); err != nil {
99+ t.Fatal(err)
100+ }
101+ if got := checks.Load(); got != 1 {
102+ t.Fatalf("pending quarantine checks = %d, want one deterministic lookup", got)
103+ }
104+ if _, err := os.Lstat(filepath.Join(sessions, deletingSessionName(sessionKey("named")))); err != nil {
105+ t.Fatalf("deterministic quarantine missing: %v", err)
106+ }
107+}
A internal/adapters/snapshot/repository_test.go +71 -0
@@ -0,0 +1,71 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/ports"
8+ codec "github.com/bnema/vev/internal/usecase/snapshot"
9+)
10+
11+func TestRepositoryPublishesAndLoadsCompleteGeneration(t *testing.T) {
12+ t.Parallel()
13+ dir := privateDir(t)
14+ repo := NewRepository(dir)
15+ pub := repositoryPublication(t, "named", 1, []byte("state"))
16+ if err := repo.Publish(context.Background(), pub); err != nil {
17+ t.Fatalf("Publish: %v", err)
18+ }
19+ got, err := repo.Load(context.Background(), "named")
20+ if err != nil {
21+ t.Fatalf("Load: %v", err)
22+ }
23+ if got.Name != "named" || got.Generation != 1 || len(got.Objects) != 2 {
24+ t.Fatalf("Load = %#v", got)
25+ }
26+}
27+
28+func TestRepositoryDoesNotRewriteVerifiedImmutableBlob(t *testing.T) {
29+ t.Parallel()
30+ dir := privateDir(t)
31+ repo := NewRepository(dir)
32+ pub := repositoryPublication(t, "named", 1, []byte("state"))
33+ if err := repo.Publish(context.Background(), pub); err != nil {
34+ t.Fatal(err)
35+ }
36+ writes := 0
37+ repo.hooks.beforeBlobWrite = func(string) error { writes++; return nil }
38+ pub.Generation = 2
39+ manifest, err := codec.UnmarshalManifest(pub.Manifest)
40+ if err != nil {
41+ t.Fatal(err)
42+ }
43+ manifest.Generation = 2
44+ pub.Manifest, err = codec.MarshalManifest(manifest)
45+ if err != nil {
46+ t.Fatal(err)
47+ }
48+ if err := repo.Publish(context.Background(), pub); err != nil {
49+ t.Fatal(err)
50+ }
51+ if writes != 0 {
52+ t.Fatalf("blob writes = %d, want 0", writes)
53+ }
54+}
55+
56+func repositoryPublication(t *testing.T, name string, generation uint64, payload []byte) ports.SnapshotPublication {
57+ t.Helper()
58+ tail, err := codec.MarshalObject(codec.HistoryTail, payload)
59+ if err != nil {
60+ t.Fatal(err)
61+ }
62+ visible, err := codec.MarshalObject(codec.Visible, []byte("visible"))
63+ if err != nil {
64+ t.Fatal(err)
65+ }
66+ manifest, err := codec.MarshalManifest(codec.Manifest{Generation: generation, Name: name, Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Visible: codec.ObjectRef{Kind: codec.Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}}}}}})
67+ if err != nil {
68+ t.Fatal(err)
69+ }
70+ return ports.SnapshotPublication{Name: name, Generation: generation, Manifest: manifest, Objects: []ports.SnapshotObject{tail, visible}}
71+}
A internal/adapters/snapshot/safe_path.go +187 -0
@@ -0,0 +1,187 @@
1+package snapshot
2+
3+import (
4+ "crypto/rand"
5+ "encoding/hex"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+ "syscall"
12+
13+ "golang.org/x/sys/unix"
14+)
15+
16+// repositoryFD opens a repository-relative path one component at a time. Each
17+// directory is pinned by an O_NOFOLLOW descriptor before its child is opened,
18+// so a concurrent rename or symlink replacement cannot redirect an operation
19+// outside the repository.
20+func (r *Repository) repositoryFD(path string, finalFlags int, mode uint32) (*os.File, error) {
21+ rel, ok := r.repositoryRelative(path)
22+ if !ok {
23+ return nil, fmt.Errorf("snapshot path outside repository")
24+ }
25+ root, err := syscall.Open(r.dir, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0)
26+ if err != nil {
27+ return nil, err
28+ }
29+ if rel == "." {
30+ if finalFlags&syscall.O_DIRECTORY == 0 {
31+ err = fmt.Errorf("invalid snapshot root operation")
32+ joinCloseError(&err, "close snapshot root directory", r.closeDescriptor(root, "close snapshot root directory"))
33+ return nil, err
34+ }
35+ file := os.NewFile(uintptr(root), path)
36+ if file == nil {
37+ err = fmt.Errorf("open snapshot root")
38+ joinCloseError(&err, "close snapshot root directory", r.closeDescriptor(root, "close snapshot root directory"))
39+ return nil, err
40+ }
41+ return file, nil
42+ }
43+ parts := strings.Split(filepath.Clean(rel), string(filepath.Separator))
44+ fd := root
45+ for i, part := range parts {
46+ if part == "" || part == "." || part == ".." {
47+ err = fmt.Errorf("invalid snapshot path")
48+ joinCloseError(&err, "close snapshot path directory", r.closeDescriptor(fd, "close snapshot path directory"))
49+ return nil, err
50+ }
51+ flags := syscall.O_RDONLY | syscall.O_CLOEXEC | syscall.O_NOFOLLOW
52+ if i != len(parts)-1 {
53+ flags |= syscall.O_DIRECTORY
54+ } else {
55+ flags = finalFlags | syscall.O_CLOEXEC | syscall.O_NOFOLLOW
56+ }
57+ next, openErr := unix.Openat(fd, part, flags, mode)
58+ closeErr := r.closeDescriptor(fd, "close snapshot path parent directory")
59+ if openErr != nil {
60+ err = openErr
61+ joinCloseError(&err, "close snapshot path parent directory", closeErr)
62+ return nil, err
63+ }
64+ if closeErr != nil {
65+ err = fmt.Errorf("close snapshot path parent directory: %w", closeErr)
66+ joinCloseError(&err, "close snapshot path child directory", r.closeDescriptor(next, "close snapshot path child directory"))
67+ return nil, err
68+ }
69+ fd = next
70+ }
71+ file := os.NewFile(uintptr(fd), path)
72+ if file == nil {
73+ err = fmt.Errorf("open snapshot path")
74+ joinCloseError(&err, "close snapshot path descriptor", r.closeDescriptor(fd, "close snapshot path descriptor"))
75+ return nil, err
76+ }
77+ return file, nil
78+}
79+
80+func (r *Repository) repositoryParent(path string) (int, string, error) {
81+ rel, ok := r.repositoryRelative(path)
82+ if !ok || rel == "." {
83+ return -1, "", fmt.Errorf("snapshot path outside repository")
84+ }
85+ parts := strings.Split(filepath.Clean(rel), string(filepath.Separator))
86+ if len(parts) == 0 || parts[len(parts)-1] == "." || parts[len(parts)-1] == ".." {
87+ return -1, "", fmt.Errorf("invalid snapshot path")
88+ }
89+ fd, err := syscall.Open(r.dir, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0)
90+ if err != nil {
91+ return -1, "", err
92+ }
93+ for _, part := range parts[:len(parts)-1] {
94+ if part == "" || part == "." || part == ".." {
95+ err = fmt.Errorf("invalid snapshot path")
96+ joinCloseError(&err, "close snapshot parent directory", r.closeDescriptor(fd, "close snapshot parent directory"))
97+ return -1, "", err
98+ }
99+ next, openErr := unix.Openat(fd, part, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0)
100+ closeErr := r.closeDescriptor(fd, "close snapshot parent directory")
101+ if openErr != nil {
102+ err = openErr
103+ joinCloseError(&err, "close snapshot parent directory", closeErr)
104+ return -1, "", err
105+ }
106+ if closeErr != nil {
107+ err = fmt.Errorf("close snapshot parent directory: %w", closeErr)
108+ joinCloseError(&err, "close snapshot child directory", r.closeDescriptor(next, "close snapshot child directory"))
109+ return -1, "", err
110+ }
111+ fd = next
112+ }
113+ return fd, parts[len(parts)-1], nil
114+}
115+
116+func (r *Repository) repositoryRelative(path string) (string, bool) {
117+ if path == r.dir {
118+ return ".", true
119+ }
120+ prefix := r.dir + string(filepath.Separator)
121+ if !strings.HasPrefix(path, prefix) {
122+ return "", false
123+ }
124+ rel := strings.TrimPrefix(path, prefix)
125+ if rel == "" || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
126+ return "", false
127+ }
128+ return rel, true
129+}
130+
131+func (r *Repository) openDirectory(path string) (*os.File, error) {
132+ return r.repositoryFD(path, syscall.O_RDONLY|syscall.O_DIRECTORY, 0)
133+}
134+
135+func (r *Repository) stat(path string) (st syscall.Stat_t, err error) {
136+ file, err := r.repositoryFD(path, syscall.O_RDONLY|syscall.O_NONBLOCK, 0)
137+ if err != nil {
138+ return syscall.Stat_t{}, err
139+ }
140+ defer func() {
141+ joinCloseError(&err, "close snapshot file", r.closeRepositoryFile(file, "close snapshot file"))
142+ }()
143+ if err := syscall.Fstat(int(file.Fd()), &st); err != nil {
144+ return syscall.Stat_t{}, err
145+ }
146+ return st, nil
147+}
148+
149+// createTempAt creates an exclusively owned temporary file in an already
150+// validated directory. os.CreateTemp(path) would resolve that path again.
151+func (r *Repository) createTempAt(dir string) (temp *os.File, err error) {
152+ fd, err := r.openDirectory(dir)
153+ if err != nil {
154+ return nil, err
155+ }
156+ defer func() {
157+ if closeErr := r.closeRepositoryFile(fd, "close snapshot temporary directory"); closeErr != nil {
158+ if temp != nil {
159+ joinCloseError(&err, "close snapshot temporary file after directory close failure", r.closeRepositoryFile(temp, "close snapshot temporary file after directory close failure"))
160+ temp = nil
161+ }
162+ joinCloseError(&err, "close snapshot temporary directory", closeErr)
163+ }
164+ }()
165+ for range 100 {
166+ var random [8]byte
167+ if _, err := rand.Read(random[:]); err != nil {
168+ return nil, err
169+ }
170+ name := ".tmp-" + hex.EncodeToString(random[:])
171+ tempFD, err := unix.Openat(int(fd.Fd()), name, syscall.O_WRONLY|syscall.O_CREAT|syscall.O_EXCL|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0o600)
172+ if errors.Is(err, syscall.EEXIST) {
173+ continue
174+ }
175+ if err != nil {
176+ return nil, err
177+ }
178+ temp = os.NewFile(uintptr(tempFD), filepath.Join(dir, name))
179+ if temp == nil {
180+ err = fmt.Errorf("open snapshot temporary file")
181+ joinCloseError(&err, "close snapshot temporary descriptor", r.closeDescriptor(tempFD, "close snapshot temporary descriptor"))
182+ return nil, err
183+ }
184+ return temp, nil
185+ }
186+ return nil, fmt.Errorf("create snapshot temporary file: too many collisions")
187+}
D internal/adapters/snapshot/store.go +0 -168
@@ -1,168 +0,0 @@
1-package snapshot
2-
3-import (
4- "crypto/sha256"
5- "encoding/hex"
6- "errors"
7- "fmt"
8- "io"
9- "log/slog"
10- "os"
11- "path/filepath"
12- "regexp"
13- "strings"
14-
15- "github.com/bnema/vev/internal/ports"
16- "github.com/bnema/vev/pkg/safedir"
17-)
18-
19-var safeNameRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,200}$`)
20-
21-const maxSnapshotFileSize = 16 + (256 << 20)
22-
23-// Store persists raw session snapshot blobs on disk.
24-type Store struct {
25- dir string
26- log *slog.Logger
27-}
28-
29-var _ ports.SnapshotStore = (*Store)(nil)
30-
31-// NewStore creates a file-backed snapshot store rooted at dir.
32-func NewStore(dir string) *Store {
33- return &Store{dir: dir, log: slog.Default()}
34-}
35-
36-// Write atomically writes data for name. The previous snapshot remains in place
37-// until the final rename succeeds.
38-func (s *Store) Write(name string, data []byte) error {
39- if err := safedir.EnsurePrivate(s.dir); err != nil {
40- return fmt.Errorf("create snapshot dir: %w", err)
41- }
42-
43- tmp, err := os.CreateTemp(s.dir, ".tmp-*")
44- if err != nil {
45- return fmt.Errorf("create temp snapshot: %w", err)
46- }
47- tmpName := tmp.Name()
48- keepTmp := false
49- defer func() {
50- if !keepTmp {
51- _ = os.Remove(tmpName)
52- }
53- }()
54-
55- if err := tmp.Chmod(0o600); err != nil {
56- _ = tmp.Close()
57- return fmt.Errorf("chmod temp snapshot: %w", err)
58- }
59- if _, err := tmp.Write(data); err != nil {
60- _ = tmp.Close()
61- return fmt.Errorf("write temp snapshot: %w", err)
62- }
63- if err := tmp.Sync(); err != nil {
64- _ = tmp.Close()
65- return fmt.Errorf("sync temp snapshot: %w", err)
66- }
67- if err := tmp.Close(); err != nil {
68- return fmt.Errorf("close temp snapshot: %w", err)
69- }
70-
71- path := filepath.Join(s.dir, filenameForName(name))
72- if err := os.Rename(tmpName, path); err != nil {
73- return fmt.Errorf("rename snapshot: %w", err)
74- }
75- keepTmp = true
76- syncDir(s.dir)
77- return nil
78-}
79-
80-// Load returns all raw .snap blobs. It does not validate the blob format.
81-func (s *Store) Load() ([]ports.SnapshotBlob, error) {
82- entries, err := os.ReadDir(s.dir)
83- if errors.Is(err, os.ErrNotExist) {
84- return nil, nil
85- }
86- if err != nil {
87- return nil, fmt.Errorf("read snapshot dir: %w", err)
88- }
89-
90- blobs := make([]ports.SnapshotBlob, 0, len(entries))
91- for _, entry := range entries {
92- base := entry.Name()
93- path := filepath.Join(s.dir, base)
94- if strings.HasPrefix(base, ".tmp-") {
95- if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
96- s.log.Warn("remove stale snapshot temp", "path", path, "err", err)
97- }
98- continue
99- }
100- if entry.IsDir() || !strings.HasSuffix(base, ".snap") {
101- continue
102- }
103- data, err := readSnapshotFileBounded(path)
104- if err != nil {
105- s.log.Warn("read snapshot", "path", path, "err", err)
106- continue
107- }
108- name := strings.TrimSuffix(base, ".snap")
109- if strings.HasPrefix(name, "@") || !safeNameRE.MatchString(name) {
110- name = ""
111- }
112- blobs = append(blobs, ports.SnapshotBlob{Name: name, Data: data})
113- }
114- return blobs, nil
115-}
116-
117-// Delete removes the deterministic snapshot file for name. Missing files are ignored.
118-func (s *Store) Delete(name string) error {
119- err := os.Remove(filepath.Join(s.dir, filenameForName(name)))
120- if errors.Is(err, os.ErrNotExist) {
121- return nil
122- }
123- if err != nil {
124- return fmt.Errorf("delete snapshot: %w", err)
125- }
126- syncDir(s.dir)
127- return nil
128-}
129-
130-func readSnapshotFileBounded(path string) ([]byte, error) {
131- f, err := os.Open(path)
132- if err != nil {
133- return nil, err
134- }
135- defer func() { _ = f.Close() }()
136- info, err := f.Stat()
137- if err != nil {
138- return nil, err
139- }
140- if info.Size() > maxSnapshotFileSize {
141- return nil, fmt.Errorf("snapshot too large: %d bytes", info.Size())
142- }
143- data, err := io.ReadAll(io.LimitReader(f, maxSnapshotFileSize+1))
144- if err != nil {
145- return nil, err
146- }
147- if len(data) > maxSnapshotFileSize {
148- return nil, fmt.Errorf("snapshot too large: %d bytes", len(data))
149- }
150- return data, nil
151-}
152-
153-func filenameForName(name string) string {
154- if safeNameRE.MatchString(name) {
155- return name + ".snap"
156- }
157- sum := sha256.Sum256([]byte(name))
158- return "@" + hex.EncodeToString(sum[:])[:40] + ".snap"
159-}
160-
161-func syncDir(dir string) {
162- f, err := os.Open(dir)
163- if err != nil {
164- return
165- }
166- defer func() { _ = f.Close() }()
167- _ = f.Sync()
168-}
D internal/adapters/snapshot/store_test.go +0 -192
@@ -1,192 +0,0 @@
1-package snapshot
2-
3-import (
4- "crypto/sha256"
5- "encoding/hex"
6- "errors"
7- "os"
8- "path/filepath"
9- "runtime"
10- "sort"
11- "strings"
12- "testing"
13-)
14-
15-func privateDir(t *testing.T) string {
16- t.Helper()
17- return filepath.Join(t.TempDir(), "vev")
18-}
19-
20-func TestStoreWriteLoadRoundTripAndSupersede(t *testing.T) {
21- t.Parallel()
22- dir := privateDir(t)
23- store := NewStore(dir)
24-
25- if err := store.Write("alpha", []byte("first")); err != nil {
26- t.Fatalf("Write first: %v", err)
27- }
28- if err := store.Write("alpha", []byte("second")); err != nil {
29- t.Fatalf("Write second: %v", err)
30- }
31-
32- got := loadMap(t, store)
33- if string(got["alpha"]) != "second" {
34- t.Fatalf("loaded alpha = %q, want second", got["alpha"])
35- }
36- info, err := os.Stat(filepath.Join(dir, "alpha.snap"))
37- if err != nil {
38- t.Fatalf("stat snapshot: %v", err)
39- }
40- if gotMode := info.Mode().Perm(); gotMode != 0o600 {
41- t.Fatalf("snapshot mode = %v, want 0600", gotMode)
42- }
43- if dirInfo, err := os.Stat(dir); err != nil {
44- t.Fatalf("stat dir: %v", err)
45- } else if gotMode := dirInfo.Mode().Perm(); gotMode != 0o700 {
46- t.Fatalf("dir mode = %v, want 0700", gotMode)
47- }
48-}
49-
50-func TestStoreDelete(t *testing.T) {
51- t.Parallel()
52- dir := privateDir(t)
53- store := NewStore(dir)
54-
55- if err := store.Write("gone", []byte("data")); err != nil {
56- t.Fatalf("Write: %v", err)
57- }
58- if err := store.Delete("gone"); err != nil {
59- t.Fatalf("Delete existing: %v", err)
60- }
61- if err := store.Delete("gone"); err != nil {
62- t.Fatalf("Delete missing: %v", err)
63- }
64- if got := loadMap(t, store); len(got) != 0 {
65- t.Fatalf("Load after delete = %#v, want empty", got)
66- }
67-}
68-
69-func TestStoreUnsafeNameUsesDeterministicHash(t *testing.T) {
70- t.Parallel()
71- dir := privateDir(t)
72- store := NewStore(dir)
73- name := "../unsafe/session name"
74-
75- if err := store.Write(name, []byte("raw")); err != nil {
76- t.Fatalf("Write unsafe: %v", err)
77- }
78- sum := sha256.Sum256([]byte(name))
79- wantBase := "@" + hex.EncodeToString(sum[:])[:40] + ".snap"
80- if _, err := os.Stat(filepath.Join(dir, wantBase)); err != nil {
81- t.Fatalf("stat hashed snapshot %q: %v", wantBase, err)
82- }
83- loaded, err := store.Load()
84- if err != nil {
85- t.Fatalf("Load: %v", err)
86- }
87- if len(loaded) != 1 || loaded[0].Name != "" || string(loaded[0].Data) != "raw" {
88- t.Fatalf("Load unsafe = %#v, want one nameless raw blob", loaded)
89- }
90-}
91-
92-func TestStoreMissingDirAndStaleTmpCleanup(t *testing.T) {
93- t.Parallel()
94- dir := filepath.Join(t.TempDir(), "missing")
95- store := NewStore(dir)
96-
97- loaded, err := store.Load()
98- if err != nil {
99- t.Fatalf("Load missing dir: %v", err)
100- }
101- if len(loaded) != 0 {
102- t.Fatalf("Load missing dir len = %d, want 0", len(loaded))
103- }
104- if err := os.MkdirAll(dir, 0o700); err != nil {
105- t.Fatalf("MkdirAll: %v", err)
106- }
107- if err := os.WriteFile(filepath.Join(dir, ".tmp-stale"), []byte("tmp"), 0o600); err != nil {
108- t.Fatalf("write stale tmp: %v", err)
109- }
110- if err := os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("ignore"), 0o600); err != nil {
111- t.Fatalf("write ignored: %v", err)
112- }
113- if err := os.WriteFile(filepath.Join(dir, "beta.snap"), []byte("beta"), 0o600); err != nil {
114- t.Fatalf("write snap: %v", err)
115- }
116-
117- got := loadMap(t, store)
118- if string(got["beta"]) != "beta" || len(got) != 1 {
119- t.Fatalf("Load = %#v, want only beta", got)
120- }
121- if _, err := os.Stat(filepath.Join(dir, ".tmp-stale")); !errors.Is(err, os.ErrNotExist) {
122- t.Fatalf("stale tmp stat err = %v, want not exist", err)
123- }
124-}
125-
126-func TestStoreLoadRejectsOversizedFileBeforeRead(t *testing.T) {
127- t.Parallel()
128- dir := privateDir(t)
129- store := NewStore(dir)
130- if err := os.MkdirAll(dir, 0o700); err != nil {
131- t.Fatalf("MkdirAll: %v", err)
132- }
133- if err := os.WriteFile(filepath.Join(dir, "ok.snap"), []byte("ok"), 0o600); err != nil {
134- t.Fatalf("write ok: %v", err)
135- }
136- tooLarge := filepath.Join(dir, "huge.snap")
137- f, err := os.Create(tooLarge)
138- if err != nil {
139- t.Fatalf("create huge: %v", err)
140- }
141- if err := f.Truncate(maxSnapshotFileSize + 1); err != nil {
142- _ = f.Close()
143- t.Fatalf("truncate huge: %v", err)
144- }
145- if err := f.Close(); err != nil {
146- t.Fatalf("close huge: %v", err)
147- }
148-
149- got := loadMap(t, store)
150- if len(got) != 1 || string(got["ok"]) != "ok" {
151- t.Fatalf("Load = %#v, want only ok", got)
152- }
153-}
154-
155-func TestStoreLoadSkipsUnreadableFile(t *testing.T) {
156- if runtime.GOOS == "windows" || os.Geteuid() == 0 {
157- t.Skip("permission bits do not make file unreadable on this platform/user")
158- }
159- t.Parallel()
160- dir := privateDir(t)
161- store := NewStore(dir)
162- if err := os.MkdirAll(dir, 0o700); err != nil {
163- t.Fatalf("MkdirAll: %v", err)
164- }
165- if err := os.WriteFile(filepath.Join(dir, "ok.snap"), []byte("ok"), 0o600); err != nil {
166- t.Fatalf("write ok: %v", err)
167- }
168- bad := filepath.Join(dir, "bad.snap")
169- if err := os.WriteFile(bad, []byte("bad"), 0o000); err != nil {
170- t.Fatalf("write bad: %v", err)
171- }
172- t.Cleanup(func() { _ = os.Chmod(bad, 0o600) })
173-
174- got := loadMap(t, store)
175- if len(got) != 1 || string(got["ok"]) != "ok" {
176- t.Fatalf("Load = %#v, want only ok", got)
177- }
178-}
179-
180-func loadMap(t *testing.T, store *Store) map[string][]byte {
181- t.Helper()
182- loaded, err := store.Load()
183- if err != nil {
184- t.Fatalf("Load: %v", err)
185- }
186- sort.Slice(loaded, func(i, j int) bool { return strings.Compare(loaded[i].Name, loaded[j].Name) < 0 })
187- out := make(map[string][]byte, len(loaded))
188- for _, blob := range loaded {
189- out[blob.Name] = blob.Data
190- }
191- return out
192-}
A internal/adapters/snapshot/test_helpers_test.go +11 -0
@@ -0,0 +1,11 @@
1+package snapshot
2+
3+import (
4+ "path/filepath"
5+ "testing"
6+)
7+
8+func privateDir(t *testing.T) string {
9+ t.Helper()
10+ return filepath.Join(t.TempDir(), "vev")
11+}
A internal/app/offline_kill_retry_test.go +88 -0
@@ -0,0 +1,88 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/persist"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+type offlineSnapshotFake struct {
15+ calls []string
16+ deleteErr error
17+ deleteLegacyErr error
18+ tombstoneErr error
19+ deleteTombstoneErr error
20+}
21+
22+func (f *offlineSnapshotFake) Tombstone(context.Context, string) error {
23+ f.calls = append(f.calls, "tombstone")
24+ return f.tombstoneErr
25+}
26+
27+func (f *offlineSnapshotFake) Delete(context.Context, string) error {
28+ f.calls = append(f.calls, "incremental")
29+ return f.deleteErr
30+}
31+
32+func (f *offlineSnapshotFake) DeleteLegacy(context.Context, string) error {
33+ f.calls = append(f.calls, "legacy")
34+ return f.deleteLegacyErr
35+}
36+
37+func (f *offlineSnapshotFake) DeleteTombstone(context.Context, string) error {
38+ f.calls = append(f.calls, "delete tombstone")
39+ return f.deleteTombstoneErr
40+}
41+
42+func TestRunKillOfflineRetriesSnapshotDeletion(t *testing.T) {
43+ for _, tt := range []struct {
44+ name string
45+ incrementalErr error
46+ legacyErr error
47+ }{
48+ {name: "legacy failure", legacyErr: errors.New("legacy unlink failed")},
49+ {name: "incremental failure", incrementalErr: errors.New("incremental quarantine failed")},
50+ {name: "both failures", incrementalErr: errors.New("incremental quarantine failed"), legacyErr: errors.New("legacy unlink failed")},
51+ } {
52+ t.Run(tt.name, func(t *testing.T) {
53+ stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
54+ t.Setenv("XDG_STATE_HOME", stateRoot)
55+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
56+
57+ p, err := persist.Open(filepath.Join(stateRoot, "vev"))
58+ require.NoError(t, err)
59+ now := time.Now().UnixNano()
60+ require.NoError(t, p.Save(persist.Record{Name: "named", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}))
61+ require.NoError(t, p.Close())
62+
63+ fake := &offlineSnapshotFake{deleteErr: tt.incrementalErr, deleteLegacyErr: tt.legacyErr}
64+ originalFactory := newOfflineSnapshotRepository
65+ newOfflineSnapshotRepository = func(string) offlineSnapshotSource { return fake }
66+ t.Cleanup(func() { newOfflineSnapshotRepository = originalFactory })
67+
68+ err = runKill(context.Background(), "named", false, false)
69+ if tt.incrementalErr != nil {
70+ require.ErrorIs(t, err, tt.incrementalErr)
71+ }
72+ if tt.legacyErr != nil {
73+ require.ErrorIs(t, err, tt.legacyErr)
74+ }
75+ require.Equal(t, []string{"tombstone", "incremental", "legacy"}, fake.calls)
76+ records, err := persist.LoadReadOnly(filepath.Join(stateRoot, "vev"))
77+ require.NoError(t, err)
78+ require.Len(t, records, 1, "a failed source delete must retain retry identity")
79+
80+ fake.deleteErr, fake.deleteLegacyErr = nil, nil
81+ require.NoError(t, runKill(context.Background(), "named", false, false))
82+ require.Equal(t, []string{"tombstone", "incremental", "legacy", "tombstone", "incremental", "legacy", "delete tombstone"}, fake.calls)
83+ records, err = persist.LoadReadOnly(filepath.Join(stateRoot, "vev"))
84+ require.NoError(t, err)
85+ require.Empty(t, records)
86+ })
87+ }
88+}
M internal/app/run.go +44 -5
@@ -450,7 +450,8 @@ func runDaemon() (retErr error) {
450450 daemonOpts = append(daemonOpts, daemon.WithConfig(cfg))
451451 daemonOpts = append(daemonOpts, daemon.WithBarScriptCommandRunner(shellcmd.New()))
452452 daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome))
453- daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir())))
453+ snapshotRepository := snapshotadapter.NewRepository(snapshotDir())
454+ daemonOpts = append(daemonOpts, daemon.WithSnapshotRepository(snapshotRepository, snapshotRepository))
454455 daemonOpts = append(daemonOpts, daemon.WithNoticeStore(noticefile.New(platform.StateDir())))
455456 storePath := persist.StorePath(platform.StateDir())
456457 var storeErr error
@@ -1185,6 +1186,27 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
11851186 _ = tw.Flush()
11861187 }
11871188
1189+// offlineSnapshotSource owns the durable source and tombstone transitions for
1190+// a daemon-less kill. It is intentionally local to the composition root: the
1191+// daemon uses the repository through its narrower ports.
1192+type offlineSnapshotSource interface {
1193+ Tombstone(context.Context, string) error
1194+ Delete(context.Context, string) error
1195+ DeleteLegacy(context.Context, string) error
1196+ DeleteTombstone(context.Context, string) error
1197+}
1198+
1199+var newOfflineSnapshotRepository = func(dir string) offlineSnapshotSource {
1200+ return snapshotadapter.NewRepository(dir)
1201+}
1202+
1203+func phaseError(phase string, err error) error {
1204+ if err == nil {
1205+ return nil
1206+ }
1207+ return fmt.Errorf("vev: %s: %w", phase, err)
1208+}
1209+
11881210 // runKill asks the daemon to terminate a named session, every session, or the daemon.
11891211 func runKill(ctx context.Context, name string, all, daemon bool) error {
11901212 transport, err := realDial(ctx, ipc.SocketDir())
@@ -1214,17 +1236,34 @@ func runKill(ctx context.Context, name string, all, daemon bool) error {
12141236 if openErr != nil {
12151237 return fmt.Errorf("vev: opening stored sessions: %w", openErr)
12161238 }
1239+ snapshots := newOfflineSnapshotRepository(snapshotDir())
1240+ // The durable marker is the commit point for a logical kill. Keep it and
1241+ // the persisted record until both independently recoverable sources have
1242+ // crossed their deletion durability boundaries.
1243+ if tombstoneErr := snapshots.Tombstone(ctx, name); tombstoneErr != nil {
1244+ _ = p.Close()
1245+ return fmt.Errorf("vev: marking session killed: %w", tombstoneErr)
1246+ }
1247+ incrementalErr := snapshots.Delete(ctx, name)
1248+ legacyErr := snapshots.DeleteLegacy(ctx, name)
1249+ if incrementalErr != nil || legacyErr != nil {
1250+ _ = p.Close()
1251+ return errors.Join(
1252+ phaseError("deleting incremental session snapshot", incrementalErr),
1253+ phaseError("deleting legacy session snapshot", legacyErr),
1254+ )
1255+ }
12171256 if deleteErr := p.Delete(name); deleteErr != nil {
12181257 _ = p.Close()
12191258 return fmt.Errorf("vev: deleting stored session: %w", deleteErr)
12201259 }
1260+ if tombstoneErr := snapshots.DeleteTombstone(ctx, name); tombstoneErr != nil {
1261+ _ = p.Close()
1262+ return fmt.Errorf("vev: clearing killed session marker: %w", tombstoneErr)
1263+ }
12211264 if closeErr := p.Close(); closeErr != nil {
12221265 return fmt.Errorf("vev: closing stored sessions: %w", closeErr)
12231266 }
1224- snapshots := snapshotadapter.NewStore(snapshotDir())
1225- if deleteErr := snapshots.Delete(name); deleteErr != nil {
1226- return fmt.Errorf("vev: deleting session snapshot: %w", deleteErr)
1227- }
12281267 printKillSuccess(name, all, daemon)
12291268 return nil
12301269 }
M internal/app/run_test.go +5 -8
@@ -352,10 +352,7 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
352352 if err := p.Close(); err != nil {
353353 t.Fatalf("Close error = %v", err)
354354 }
355- snapshots := snapshot.NewStore(filepath.Join(stateRoot, "vev", "snapshots"))
356- if err := snapshots.Write("stored", []byte("snapshot bytes")); err != nil {
357- t.Fatalf("snapshot Write error = %v", err)
358- }
355+ snapshots := snapshot.NewRepository(filepath.Join(stateRoot, "vev", "snapshots"))
359356
360357 got := captureStdout(t, func() {
361358 if err := runKill(context.Background(), "stored", false, false); err != nil {
@@ -372,12 +369,12 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
372369 if len(records) != 0 {
373370 t.Fatalf("records after kill = %#v, want none", records)
374371 }
375- blobs, err := snapshots.Load()
372+ names, err := snapshots.List(context.Background())
376373 if err != nil {
377- t.Fatalf("snapshot Load error = %v", err)
374+ t.Fatalf("snapshot List error = %v", err)
378375 }
379- if len(blobs) != 0 {
380- t.Fatalf("snapshots after kill = %#v, want none", blobs)
376+ if len(names) != 0 {
377+ t.Fatalf("snapshots after kill = %#v, want none", names)
381378 }
382379 }
383380
M internal/ports/mocks/store_mock.go +514 -65
@@ -5,6 +5,8 @@
55 package portsmocks
66
77 import (
8+ "context"
9+
810 "github.com/bnema/vev/internal/ports"
911 mock "github.com/stretchr/testify/mock"
1012 )
@@ -334,13 +336,13 @@ func (_c *MockStore_Sync_Call) RunAndReturn(run func() error) *MockStore_Sync_Ca
334336 return _c
335337 }
336338
337-// NewMockSnapshotStore creates a new instance of MockSnapshotStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
339+// NewMockSnapshotRepository creates a new instance of MockSnapshotRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
338340 // The first argument is typically a *testing.T value.
339-func NewMockSnapshotStore(t interface {
341+func NewMockSnapshotRepository(t interface {
340342 mock.TestingT
341343 Cleanup(func())
342-}) *MockSnapshotStore {
343- mock := &MockSnapshotStore{}
344+}) *MockSnapshotRepository {
345+ mock := &MockSnapshotRepository{}
344346 mock.Mock.Test(t)
345347
346348 t.Cleanup(func() { mock.AssertExpectations(t) })
@@ -348,163 +350,350 @@ func NewMockSnapshotStore(t interface {
348350 return mock
349351 }
350352
351-// MockSnapshotStore is an autogenerated mock type for the SnapshotStore type
352-type MockSnapshotStore struct {
353+// MockSnapshotRepository is an autogenerated mock type for the SnapshotRepository type
354+type MockSnapshotRepository struct {
353355 mock.Mock
354356 }
355357
356-type MockSnapshotStore_Expecter struct {
358+type MockSnapshotRepository_Expecter struct {
357359 mock *mock.Mock
358360 }
359361
360-func (_m *MockSnapshotStore) EXPECT() *MockSnapshotStore_Expecter {
361- return &MockSnapshotStore_Expecter{mock: &_m.Mock}
362+func (_m *MockSnapshotRepository) EXPECT() *MockSnapshotRepository_Expecter {
363+ return &MockSnapshotRepository_Expecter{mock: &_m.Mock}
362364 }
363365
364-// Delete provides a mock function for the type MockSnapshotStore
365-func (_mock *MockSnapshotStore) Delete(name string) error {
366- ret := _mock.Called(name)
366+// Delete provides a mock function for the type MockSnapshotRepository
367+func (_mock *MockSnapshotRepository) Delete(context1 context.Context, s string) error {
368+ ret := _mock.Called(context1, s)
367369
368370 if len(ret) == 0 {
369371 panic("no return value specified for Delete")
370372 }
371373
372374 var r0 error
373- if returnFunc, ok := ret.Get(0).(func(string) error); ok {
374- r0 = returnFunc(name)
375+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
376+ r0 = returnFunc(context1, s)
375377 } else {
376378 r0 = ret.Error(0)
377379 }
378380 return r0
379381 }
380382
381-// MockSnapshotStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
382-type MockSnapshotStore_Delete_Call struct {
383+// MockSnapshotRepository_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
384+type MockSnapshotRepository_Delete_Call struct {
383385 *mock.Call
384386 }
385387
386388 // Delete is a helper method to define mock.On call
387-// - name string
388-func (_e *MockSnapshotStore_Expecter) Delete(name any) *MockSnapshotStore_Delete_Call {
389- return &MockSnapshotStore_Delete_Call{Call: _e.mock.On("Delete", name)}
389+// - context1 context.Context
390+// - s string
391+func (_e *MockSnapshotRepository_Expecter) Delete(context1 any, s any) *MockSnapshotRepository_Delete_Call {
392+ return &MockSnapshotRepository_Delete_Call{Call: _e.mock.On("Delete", context1, s)}
390393 }
391394
392-func (_c *MockSnapshotStore_Delete_Call) Run(run func(name string)) *MockSnapshotStore_Delete_Call {
395+func (_c *MockSnapshotRepository_Delete_Call) Run(run func(context1 context.Context, s string)) *MockSnapshotRepository_Delete_Call {
393396 _c.Call.Run(func(args mock.Arguments) {
394- var arg0 string
397+ var arg0 context.Context
395398 if args[0] != nil {
396- arg0 = args[0].(string)
399+ arg0 = args[0].(context.Context)
400+ }
401+ var arg1 string
402+ if args[1] != nil {
403+ arg1 = args[1].(string)
397404 }
398405 run(
399406 arg0,
407+ arg1,
400408 )
401409 })
402410 return _c
403411 }
404412
405-func (_c *MockSnapshotStore_Delete_Call) Return(err error) *MockSnapshotStore_Delete_Call {
413+func (_c *MockSnapshotRepository_Delete_Call) Return(err error) *MockSnapshotRepository_Delete_Call {
406414 _c.Call.Return(err)
407415 return _c
408416 }
409417
410-func (_c *MockSnapshotStore_Delete_Call) RunAndReturn(run func(name string) error) *MockSnapshotStore_Delete_Call {
418+func (_c *MockSnapshotRepository_Delete_Call) RunAndReturn(run func(context1 context.Context, s string) error) *MockSnapshotRepository_Delete_Call {
411419 _c.Call.Return(run)
412420 return _c
413421 }
414422
415-// Load provides a mock function for the type MockSnapshotStore
416-func (_mock *MockSnapshotStore) Load() ([]ports.SnapshotBlob, error) {
417- ret := _mock.Called()
423+// DeleteTombstone provides a mock function for the type MockSnapshotRepository
424+func (_mock *MockSnapshotRepository) DeleteTombstone(context1 context.Context, s string) error {
425+ ret := _mock.Called(context1, s)
418426
419427 if len(ret) == 0 {
420- panic("no return value specified for Load")
428+ panic("no return value specified for DeleteTombstone")
429+ }
430+
431+ var r0 error
432+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
433+ r0 = returnFunc(context1, s)
434+ } else {
435+ r0 = ret.Error(0)
436+ }
437+ return r0
438+}
439+
440+// MockSnapshotRepository_DeleteTombstone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteTombstone'
441+type MockSnapshotRepository_DeleteTombstone_Call struct {
442+ *mock.Call
443+}
444+
445+// DeleteTombstone is a helper method to define mock.On call
446+// - context1 context.Context
447+// - s string
448+func (_e *MockSnapshotRepository_Expecter) DeleteTombstone(context1 any, s any) *MockSnapshotRepository_DeleteTombstone_Call {
449+ return &MockSnapshotRepository_DeleteTombstone_Call{Call: _e.mock.On("DeleteTombstone", context1, s)}
450+}
451+
452+func (_c *MockSnapshotRepository_DeleteTombstone_Call) Run(run func(context1 context.Context, s string)) *MockSnapshotRepository_DeleteTombstone_Call {
453+ _c.Call.Run(func(args mock.Arguments) {
454+ var arg0 context.Context
455+ if args[0] != nil {
456+ arg0 = args[0].(context.Context)
457+ }
458+ var arg1 string
459+ if args[1] != nil {
460+ arg1 = args[1].(string)
461+ }
462+ run(
463+ arg0,
464+ arg1,
465+ )
466+ })
467+ return _c
468+}
469+
470+func (_c *MockSnapshotRepository_DeleteTombstone_Call) Return(err error) *MockSnapshotRepository_DeleteTombstone_Call {
471+ _c.Call.Return(err)
472+ return _c
473+}
474+
475+func (_c *MockSnapshotRepository_DeleteTombstone_Call) RunAndReturn(run func(context1 context.Context, s string) error) *MockSnapshotRepository_DeleteTombstone_Call {
476+ _c.Call.Return(run)
477+ return _c
478+}
479+
480+// List provides a mock function for the type MockSnapshotRepository
481+func (_mock *MockSnapshotRepository) List(context1 context.Context) ([]string, error) {
482+ ret := _mock.Called(context1)
483+
484+ if len(ret) == 0 {
485+ panic("no return value specified for List")
421486 }
422487
423- var r0 []ports.SnapshotBlob
488+ var r0 []string
424489 var r1 error
425- if returnFunc, ok := ret.Get(0).(func() ([]ports.SnapshotBlob, error)); ok {
426- return returnFunc()
490+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]string, error)); ok {
491+ return returnFunc(context1)
427492 }
428- if returnFunc, ok := ret.Get(0).(func() []ports.SnapshotBlob); ok {
429- r0 = returnFunc()
493+ if returnFunc, ok := ret.Get(0).(func(context.Context) []string); ok {
494+ r0 = returnFunc(context1)
430495 } else {
431496 if ret.Get(0) != nil {
432- r0 = ret.Get(0).([]ports.SnapshotBlob)
497+ r0 = ret.Get(0).([]string)
433498 }
434499 }
435- if returnFunc, ok := ret.Get(1).(func() error); ok {
436- r1 = returnFunc()
500+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
501+ r1 = returnFunc(context1)
437502 } else {
438503 r1 = ret.Error(1)
439504 }
440505 return r0, r1
441506 }
442507
443-// MockSnapshotStore_Load_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Load'
444-type MockSnapshotStore_Load_Call struct {
508+// MockSnapshotRepository_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
509+type MockSnapshotRepository_List_Call struct {
510+ *mock.Call
511+}
512+
513+// List is a helper method to define mock.On call
514+// - context1 context.Context
515+func (_e *MockSnapshotRepository_Expecter) List(context1 any) *MockSnapshotRepository_List_Call {
516+ return &MockSnapshotRepository_List_Call{Call: _e.mock.On("List", context1)}
517+}
518+
519+func (_c *MockSnapshotRepository_List_Call) Run(run func(context1 context.Context)) *MockSnapshotRepository_List_Call {
520+ _c.Call.Run(func(args mock.Arguments) {
521+ var arg0 context.Context
522+ if args[0] != nil {
523+ arg0 = args[0].(context.Context)
524+ }
525+ run(
526+ arg0,
527+ )
528+ })
529+ return _c
530+}
531+
532+func (_c *MockSnapshotRepository_List_Call) Return(strings []string, err error) *MockSnapshotRepository_List_Call {
533+ _c.Call.Return(strings, err)
534+ return _c
535+}
536+
537+func (_c *MockSnapshotRepository_List_Call) RunAndReturn(run func(context1 context.Context) ([]string, error)) *MockSnapshotRepository_List_Call {
538+ _c.Call.Return(run)
539+ return _c
540+}
541+
542+// Load provides a mock function for the type MockSnapshotRepository
543+func (_mock *MockSnapshotRepository) Load(context1 context.Context, s string) (ports.SnapshotGeneration, error) {
544+ ret := _mock.Called(context1, s)
545+
546+ if len(ret) == 0 {
547+ panic("no return value specified for Load")
548+ }
549+
550+ var r0 ports.SnapshotGeneration
551+ var r1 error
552+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (ports.SnapshotGeneration, error)); ok {
553+ return returnFunc(context1, s)
554+ }
555+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ports.SnapshotGeneration); ok {
556+ r0 = returnFunc(context1, s)
557+ } else {
558+ r0 = ret.Get(0).(ports.SnapshotGeneration)
559+ }
560+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
561+ r1 = returnFunc(context1, s)
562+ } else {
563+ r1 = ret.Error(1)
564+ }
565+ return r0, r1
566+}
567+
568+// MockSnapshotRepository_Load_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Load'
569+type MockSnapshotRepository_Load_Call struct {
445570 *mock.Call
446571 }
447572
448573 // Load is a helper method to define mock.On call
449-func (_e *MockSnapshotStore_Expecter) Load() *MockSnapshotStore_Load_Call {
450- return &MockSnapshotStore_Load_Call{Call: _e.mock.On("Load")}
574+// - context1 context.Context
575+// - s string
576+func (_e *MockSnapshotRepository_Expecter) Load(context1 any, s any) *MockSnapshotRepository_Load_Call {
577+ return &MockSnapshotRepository_Load_Call{Call: _e.mock.On("Load", context1, s)}
451578 }
452579
453-func (_c *MockSnapshotStore_Load_Call) Run(run func()) *MockSnapshotStore_Load_Call {
580+func (_c *MockSnapshotRepository_Load_Call) Run(run func(context1 context.Context, s string)) *MockSnapshotRepository_Load_Call {
454581 _c.Call.Run(func(args mock.Arguments) {
455- run()
582+ var arg0 context.Context
583+ if args[0] != nil {
584+ arg0 = args[0].(context.Context)
585+ }
586+ var arg1 string
587+ if args[1] != nil {
588+ arg1 = args[1].(string)
589+ }
590+ run(
591+ arg0,
592+ arg1,
593+ )
456594 })
457595 return _c
458596 }
459597
460-func (_c *MockSnapshotStore_Load_Call) Return(snapshotBlobs []ports.SnapshotBlob, err error) *MockSnapshotStore_Load_Call {
461- _c.Call.Return(snapshotBlobs, err)
598+func (_c *MockSnapshotRepository_Load_Call) Return(snapshotGeneration ports.SnapshotGeneration, err error) *MockSnapshotRepository_Load_Call {
599+ _c.Call.Return(snapshotGeneration, err)
462600 return _c
463601 }
464602
465-func (_c *MockSnapshotStore_Load_Call) RunAndReturn(run func() ([]ports.SnapshotBlob, error)) *MockSnapshotStore_Load_Call {
603+func (_c *MockSnapshotRepository_Load_Call) RunAndReturn(run func(context1 context.Context, s string) (ports.SnapshotGeneration, error)) *MockSnapshotRepository_Load_Call {
466604 _c.Call.Return(run)
467605 return _c
468606 }
469607
470-// Write provides a mock function for the type MockSnapshotStore
471-func (_mock *MockSnapshotStore) Write(name string, data []byte) error {
472- ret := _mock.Called(name, data)
608+// Maintain provides a mock function for the type MockSnapshotRepository
609+func (_mock *MockSnapshotRepository) Maintain(context1 context.Context) error {
610+ ret := _mock.Called(context1)
473611
474612 if len(ret) == 0 {
475- panic("no return value specified for Write")
613+ panic("no return value specified for Maintain")
476614 }
477615
478616 var r0 error
479- if returnFunc, ok := ret.Get(0).(func(string, []byte) error); ok {
480- r0 = returnFunc(name, data)
617+ if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
618+ r0 = returnFunc(context1)
481619 } else {
482620 r0 = ret.Error(0)
483621 }
484622 return r0
485623 }
486624
487-// MockSnapshotStore_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write'
488-type MockSnapshotStore_Write_Call struct {
625+// MockSnapshotRepository_Maintain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Maintain'
626+type MockSnapshotRepository_Maintain_Call struct {
489627 *mock.Call
490628 }
491629
492-// Write is a helper method to define mock.On call
493-// - name string
494-// - data []byte
495-func (_e *MockSnapshotStore_Expecter) Write(name any, data any) *MockSnapshotStore_Write_Call {
496- return &MockSnapshotStore_Write_Call{Call: _e.mock.On("Write", name, data)}
630+// Maintain is a helper method to define mock.On call
631+// - context1 context.Context
632+func (_e *MockSnapshotRepository_Expecter) Maintain(context1 any) *MockSnapshotRepository_Maintain_Call {
633+ return &MockSnapshotRepository_Maintain_Call{Call: _e.mock.On("Maintain", context1)}
497634 }
498635
499-func (_c *MockSnapshotStore_Write_Call) Run(run func(name string, data []byte)) *MockSnapshotStore_Write_Call {
636+func (_c *MockSnapshotRepository_Maintain_Call) Run(run func(context1 context.Context)) *MockSnapshotRepository_Maintain_Call {
500637 _c.Call.Run(func(args mock.Arguments) {
501- var arg0 string
638+ var arg0 context.Context
502639 if args[0] != nil {
503- arg0 = args[0].(string)
640+ arg0 = args[0].(context.Context)
504641 }
505- var arg1 []byte
642+ run(
643+ arg0,
644+ )
645+ })
646+ return _c
647+}
648+
649+func (_c *MockSnapshotRepository_Maintain_Call) Return(err error) *MockSnapshotRepository_Maintain_Call {
650+ _c.Call.Return(err)
651+ return _c
652+}
653+
654+func (_c *MockSnapshotRepository_Maintain_Call) RunAndReturn(run func(context1 context.Context) error) *MockSnapshotRepository_Maintain_Call {
655+ _c.Call.Return(run)
656+ return _c
657+}
658+
659+// Publish provides a mock function for the type MockSnapshotRepository
660+func (_mock *MockSnapshotRepository) Publish(context1 context.Context, snapshotPublication ports.SnapshotPublication) error {
661+ ret := _mock.Called(context1, snapshotPublication)
662+
663+ if len(ret) == 0 {
664+ panic("no return value specified for Publish")
665+ }
666+
667+ var r0 error
668+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.SnapshotPublication) error); ok {
669+ r0 = returnFunc(context1, snapshotPublication)
670+ } else {
671+ r0 = ret.Error(0)
672+ }
673+ return r0
674+}
675+
676+// MockSnapshotRepository_Publish_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Publish'
677+type MockSnapshotRepository_Publish_Call struct {
678+ *mock.Call
679+}
680+
681+// Publish is a helper method to define mock.On call
682+// - context1 context.Context
683+// - snapshotPublication ports.SnapshotPublication
684+func (_e *MockSnapshotRepository_Expecter) Publish(context1 any, snapshotPublication any) *MockSnapshotRepository_Publish_Call {
685+ return &MockSnapshotRepository_Publish_Call{Call: _e.mock.On("Publish", context1, snapshotPublication)}
686+}
687+
688+func (_c *MockSnapshotRepository_Publish_Call) Run(run func(context1 context.Context, snapshotPublication ports.SnapshotPublication)) *MockSnapshotRepository_Publish_Call {
689+ _c.Call.Run(func(args mock.Arguments) {
690+ var arg0 context.Context
691+ if args[0] != nil {
692+ arg0 = args[0].(context.Context)
693+ }
694+ var arg1 ports.SnapshotPublication
506695 if args[1] != nil {
507- arg1 = args[1].([]byte)
696+ arg1 = args[1].(ports.SnapshotPublication)
508697 }
509698 run(
510699 arg0,
@@ -514,12 +703,272 @@ func (_c *MockSnapshotStore_Write_Call) Run(run func(name string, data []byte))
514703 return _c
515704 }
516705
517-func (_c *MockSnapshotStore_Write_Call) Return(err error) *MockSnapshotStore_Write_Call {
706+func (_c *MockSnapshotRepository_Publish_Call) Return(err error) *MockSnapshotRepository_Publish_Call {
518707 _c.Call.Return(err)
519708 return _c
520709 }
521710
522-func (_c *MockSnapshotStore_Write_Call) RunAndReturn(run func(name string, data []byte) error) *MockSnapshotStore_Write_Call {
711+func (_c *MockSnapshotRepository_Publish_Call) RunAndReturn(run func(context1 context.Context, snapshotPublication ports.SnapshotPublication) error) *MockSnapshotRepository_Publish_Call {
712+ _c.Call.Return(run)
713+ return _c
714+}
715+
716+// Tombstone provides a mock function for the type MockSnapshotRepository
717+func (_mock *MockSnapshotRepository) Tombstone(context1 context.Context, s string) error {
718+ ret := _mock.Called(context1, s)
719+
720+ if len(ret) == 0 {
721+ panic("no return value specified for Tombstone")
722+ }
723+
724+ var r0 error
725+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
726+ r0 = returnFunc(context1, s)
727+ } else {
728+ r0 = ret.Error(0)
729+ }
730+ return r0
731+}
732+
733+// MockSnapshotRepository_Tombstone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Tombstone'
734+type MockSnapshotRepository_Tombstone_Call struct {
735+ *mock.Call
736+}
737+
738+// Tombstone is a helper method to define mock.On call
739+// - context1 context.Context
740+// - s string
741+func (_e *MockSnapshotRepository_Expecter) Tombstone(context1 any, s any) *MockSnapshotRepository_Tombstone_Call {
742+ return &MockSnapshotRepository_Tombstone_Call{Call: _e.mock.On("Tombstone", context1, s)}
743+}
744+
745+func (_c *MockSnapshotRepository_Tombstone_Call) Run(run func(context1 context.Context, s string)) *MockSnapshotRepository_Tombstone_Call {
746+ _c.Call.Run(func(args mock.Arguments) {
747+ var arg0 context.Context
748+ if args[0] != nil {
749+ arg0 = args[0].(context.Context)
750+ }
751+ var arg1 string
752+ if args[1] != nil {
753+ arg1 = args[1].(string)
754+ }
755+ run(
756+ arg0,
757+ arg1,
758+ )
759+ })
760+ return _c
761+}
762+
763+func (_c *MockSnapshotRepository_Tombstone_Call) Return(err error) *MockSnapshotRepository_Tombstone_Call {
764+ _c.Call.Return(err)
765+ return _c
766+}
767+
768+func (_c *MockSnapshotRepository_Tombstone_Call) RunAndReturn(run func(context1 context.Context, s string) error) *MockSnapshotRepository_Tombstone_Call {
769+ _c.Call.Return(run)
770+ return _c
771+}
772+
773+// NewMockLegacySnapshotSource creates a new instance of MockLegacySnapshotSource. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
774+// The first argument is typically a *testing.T value.
775+func NewMockLegacySnapshotSource(t interface {
776+ mock.TestingT
777+ Cleanup(func())
778+}) *MockLegacySnapshotSource {
779+ mock := &MockLegacySnapshotSource{}
780+ mock.Mock.Test(t)
781+
782+ t.Cleanup(func() { mock.AssertExpectations(t) })
783+
784+ return mock
785+}
786+
787+// MockLegacySnapshotSource is an autogenerated mock type for the LegacySnapshotSource type
788+type MockLegacySnapshotSource struct {
789+ mock.Mock
790+}
791+
792+type MockLegacySnapshotSource_Expecter struct {
793+ mock *mock.Mock
794+}
795+
796+func (_m *MockLegacySnapshotSource) EXPECT() *MockLegacySnapshotSource_Expecter {
797+ return &MockLegacySnapshotSource_Expecter{mock: &_m.Mock}
798+}
799+
800+// DeleteLegacy provides a mock function for the type MockLegacySnapshotSource
801+func (_mock *MockLegacySnapshotSource) DeleteLegacy(context1 context.Context, s string) error {
802+ ret := _mock.Called(context1, s)
803+
804+ if len(ret) == 0 {
805+ panic("no return value specified for DeleteLegacy")
806+ }
807+
808+ var r0 error
809+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
810+ r0 = returnFunc(context1, s)
811+ } else {
812+ r0 = ret.Error(0)
813+ }
814+ return r0
815+}
816+
817+// MockLegacySnapshotSource_DeleteLegacy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteLegacy'
818+type MockLegacySnapshotSource_DeleteLegacy_Call struct {
819+ *mock.Call
820+}
821+
822+// DeleteLegacy is a helper method to define mock.On call
823+// - context1 context.Context
824+// - s string
825+func (_e *MockLegacySnapshotSource_Expecter) DeleteLegacy(context1 any, s any) *MockLegacySnapshotSource_DeleteLegacy_Call {
826+ return &MockLegacySnapshotSource_DeleteLegacy_Call{Call: _e.mock.On("DeleteLegacy", context1, s)}
827+}
828+
829+func (_c *MockLegacySnapshotSource_DeleteLegacy_Call) Run(run func(context1 context.Context, s string)) *MockLegacySnapshotSource_DeleteLegacy_Call {
830+ _c.Call.Run(func(args mock.Arguments) {
831+ var arg0 context.Context
832+ if args[0] != nil {
833+ arg0 = args[0].(context.Context)
834+ }
835+ var arg1 string
836+ if args[1] != nil {
837+ arg1 = args[1].(string)
838+ }
839+ run(
840+ arg0,
841+ arg1,
842+ )
843+ })
844+ return _c
845+}
846+
847+func (_c *MockLegacySnapshotSource_DeleteLegacy_Call) Return(err error) *MockLegacySnapshotSource_DeleteLegacy_Call {
848+ _c.Call.Return(err)
849+ return _c
850+}
851+
852+func (_c *MockLegacySnapshotSource_DeleteLegacy_Call) RunAndReturn(run func(context1 context.Context, s string) error) *MockLegacySnapshotSource_DeleteLegacy_Call {
853+ _c.Call.Return(run)
854+ return _c
855+}
856+
857+// DeleteVerifiedLegacy provides a mock function for the type MockLegacySnapshotSource
858+func (_mock *MockLegacySnapshotSource) DeleteVerifiedLegacy(context1 context.Context, legacySnapshot ports.LegacySnapshot) error {
859+ ret := _mock.Called(context1, legacySnapshot)
860+
861+ if len(ret) == 0 {
862+ panic("no return value specified for DeleteVerifiedLegacy")
863+ }
864+
865+ var r0 error
866+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.LegacySnapshot) error); ok {
867+ r0 = returnFunc(context1, legacySnapshot)
868+ } else {
869+ r0 = ret.Error(0)
870+ }
871+ return r0
872+}
873+
874+// MockLegacySnapshotSource_DeleteVerifiedLegacy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteVerifiedLegacy'
875+type MockLegacySnapshotSource_DeleteVerifiedLegacy_Call struct {
876+ *mock.Call
877+}
878+
879+// DeleteVerifiedLegacy is a helper method to define mock.On call
880+// - context1 context.Context
881+// - legacySnapshot ports.LegacySnapshot
882+func (_e *MockLegacySnapshotSource_Expecter) DeleteVerifiedLegacy(context1 any, legacySnapshot any) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call {
883+ return &MockLegacySnapshotSource_DeleteVerifiedLegacy_Call{Call: _e.mock.On("DeleteVerifiedLegacy", context1, legacySnapshot)}
884+}
885+
886+func (_c *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call) Run(run func(context1 context.Context, legacySnapshot ports.LegacySnapshot)) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call {
887+ _c.Call.Run(func(args mock.Arguments) {
888+ var arg0 context.Context
889+ if args[0] != nil {
890+ arg0 = args[0].(context.Context)
891+ }
892+ var arg1 ports.LegacySnapshot
893+ if args[1] != nil {
894+ arg1 = args[1].(ports.LegacySnapshot)
895+ }
896+ run(
897+ arg0,
898+ arg1,
899+ )
900+ })
901+ return _c
902+}
903+
904+func (_c *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call) Return(err error) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call {
905+ _c.Call.Return(err)
906+ return _c
907+}
908+
909+func (_c *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call) RunAndReturn(run func(context1 context.Context, legacySnapshot ports.LegacySnapshot) error) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call {
910+ _c.Call.Return(run)
911+ return _c
912+}
913+
914+// LoadLegacy provides a mock function for the type MockLegacySnapshotSource
915+func (_mock *MockLegacySnapshotSource) LoadLegacy(context1 context.Context) ([]ports.LegacySnapshot, error) {
916+ ret := _mock.Called(context1)
917+
918+ if len(ret) == 0 {
919+ panic("no return value specified for LoadLegacy")
920+ }
921+
922+ var r0 []ports.LegacySnapshot
923+ var r1 error
924+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]ports.LegacySnapshot, error)); ok {
925+ return returnFunc(context1)
926+ }
927+ if returnFunc, ok := ret.Get(0).(func(context.Context) []ports.LegacySnapshot); ok {
928+ r0 = returnFunc(context1)
929+ } else {
930+ if ret.Get(0) != nil {
931+ r0 = ret.Get(0).([]ports.LegacySnapshot)
932+ }
933+ }
934+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
935+ r1 = returnFunc(context1)
936+ } else {
937+ r1 = ret.Error(1)
938+ }
939+ return r0, r1
940+}
941+
942+// MockLegacySnapshotSource_LoadLegacy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadLegacy'
943+type MockLegacySnapshotSource_LoadLegacy_Call struct {
944+ *mock.Call
945+}
946+
947+// LoadLegacy is a helper method to define mock.On call
948+// - context1 context.Context
949+func (_e *MockLegacySnapshotSource_Expecter) LoadLegacy(context1 any) *MockLegacySnapshotSource_LoadLegacy_Call {
950+ return &MockLegacySnapshotSource_LoadLegacy_Call{Call: _e.mock.On("LoadLegacy", context1)}
951+}
952+
953+func (_c *MockLegacySnapshotSource_LoadLegacy_Call) Run(run func(context1 context.Context)) *MockLegacySnapshotSource_LoadLegacy_Call {
954+ _c.Call.Run(func(args mock.Arguments) {
955+ var arg0 context.Context
956+ if args[0] != nil {
957+ arg0 = args[0].(context.Context)
958+ }
959+ run(
960+ arg0,
961+ )
962+ })
963+ return _c
964+}
965+
966+func (_c *MockLegacySnapshotSource_LoadLegacy_Call) Return(legacySnapshots []ports.LegacySnapshot, err error) *MockLegacySnapshotSource_LoadLegacy_Call {
967+ _c.Call.Return(legacySnapshots, err)
968+ return _c
969+}
970+
971+func (_c *MockLegacySnapshotSource_LoadLegacy_Call) RunAndReturn(run func(context1 context.Context) ([]ports.LegacySnapshot, error)) *MockLegacySnapshotSource_LoadLegacy_Call {
523972 _c.Call.Return(run)
524973 return _c
525974 }
D internal/ports/snapshot_test.go +0 -21
@@ -1,21 +0,0 @@
1-package ports
2-
3-import "testing"
4-
5-type snapshotStoreContract struct{}
6-
7-func (snapshotStoreContract) Write(name string, data []byte) error { return nil }
8-func (snapshotStoreContract) Load() ([]SnapshotBlob, error) { return nil, nil }
9-func (snapshotStoreContract) Delete(name string) error { return nil }
10-
11-func TestSnapshotStoreContract(t *testing.T) {
12- var _ SnapshotStore = snapshotStoreContract{}
13-
14- blob := SnapshotBlob{Name: "named-session", Data: []byte("snapshot-bytes")}
15- if blob.Name != "named-session" {
16- t.Fatalf("SnapshotBlob.Name = %q, want %q", blob.Name, "named-session")
17- }
18- if string(blob.Data) != "snapshot-bytes" {
19- t.Fatalf("SnapshotBlob.Data = %q, want %q", string(blob.Data), "snapshot-bytes")
20- }
21-}
M internal/ports/store.go +60 -7
@@ -1,5 +1,10 @@
11 package ports
22
3+import (
4+ "context"
5+ "crypto/sha256"
6+)
7+
38 // Store is a small byte-key/value persistence port.
49 //
510 // Implementations may buffer writes; Sync is the durability barrier.
@@ -17,15 +22,63 @@ type Store interface {
1722 Close() error
1823 }
1924
20-// SnapshotBlob is a durable named session snapshot payload.
21-type SnapshotBlob struct {
25+// SnapshotDigest is the SHA-256 content address of a complete snapshot object.
26+type SnapshotDigest [sha256.Size]byte
27+
28+type SnapshotObject struct {
29+ Digest SnapshotDigest
30+ Data []byte
31+}
32+
33+// SnapshotPublication atomically publishes a complete VEVM manifest and any
34+// newly reachable content-addressed VEVO objects for one named generation.
35+type SnapshotPublication struct {
36+ Name string
37+ Generation uint64
38+ Manifest []byte
39+ Objects []SnapshotObject
40+}
41+
42+// SnapshotGeneration is the caller-owned material needed to restore one
43+// named generation. Fallback indicates that the repository selected an older
44+// valid generation after the requested/current generation was unavailable.
45+type SnapshotGeneration struct {
46+ Name string
47+ Generation uint64
48+ Manifest []byte
49+ Objects map[SnapshotDigest][]byte
50+ Fallback bool
51+}
52+
53+// SnapshotRepository persists content-addressed incremental snapshot
54+// generations. All returned bytes are owned by the caller.
55+type SnapshotRepository interface {
56+ Publish(context.Context, SnapshotPublication) error
57+ List(context.Context) ([]string, error)
58+ Load(context.Context, string) (SnapshotGeneration, error)
59+ Delete(context.Context, string) error
60+ // Tombstone fences a named-session purge before either incremental or
61+ // legacy data is deleted. It must remain durable until DeleteTombstone.
62+ Tombstone(context.Context, string) error
63+ DeleteTombstone(context.Context, string) error
64+ Maintain(context.Context) error
65+}
66+
67+// LegacySnapshot is the pre-incremental named-session blob retained only for
68+// one-way migration. Data is caller-owned.
69+type LegacySnapshot struct {
2270 Name string
2371 Data []byte
2472 }
2573
26-// SnapshotStore persists encoded session snapshots by name.
27-type SnapshotStore interface {
28- Write(name string, data []byte) error
29- Load() ([]SnapshotBlob, error)
30- Delete(name string) error
74+// LegacySnapshotSource exposes the v3 bridge separately from the new write
75+// contract.
76+type LegacySnapshotSource interface {
77+ LoadLegacy(context.Context) ([]LegacySnapshot, error)
78+ // DeleteVerifiedLegacy removes precisely the legacy blob that was verified
79+ // after import. Implementations must persist its identity before unlinking.
80+ DeleteVerifiedLegacy(context.Context, LegacySnapshot) error
81+ // DeleteLegacy is reserved for explicit session purges, which intentionally
82+ // delete by name rather than as part of an import receipt.
83+ DeleteLegacy(context.Context, string) error
3184 }
M internal/usecase/copy/mode.go +22 -2
@@ -2,6 +2,7 @@ package copy
22
33 import (
44 "encoding/base64"
5+ "math"
56 "slices"
67 "strings"
78 "unicode"
@@ -27,12 +28,31 @@ func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot {
2728 return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height}
2829 }
2930 func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {
30- history := vt.NewHistory(vt.HistoryConfig{MaxRows: len(rows), ChunkRows: 256})
31+ history := vt.NewHistory(vt.HistoryConfig{
32+ MaxRows: len(rows),
33+ MaxCells: snapshotRowsCellBudget(rows),
34+ ChunkRows: 256,
35+ })
3136 for _, row := range rows {
32- history.Append(row)
37+ if err := history.Append(row); err != nil {
38+ panic("copy snapshot: configured history rejected supplied row")
39+ }
3340 }
3441 return Snapshot{history: history.SealAndView(), Width: width, Height: height}
3542 }
43+
44+// snapshotRowsCellBudget returns a capacity that retains every supplied row.
45+// Saturation is safe because a larger representable capacity does not exist.
46+func snapshotRowsCellBudget(rows [][]renderer.Cell) int {
47+ cells := 0
48+ for _, row := range rows {
49+ if len(row) > math.MaxInt-cells {
50+ return math.MaxInt
51+ }
52+ cells += len(row)
53+ }
54+ return cells
55+}
3656 func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height }
3757 func (s Snapshot) Row(i int) []renderer.Cell {
3858 if i < 0 {
M internal/usecase/copy/mode_test.go +17 -2
@@ -27,6 +27,21 @@ func modeFor(lines []string, height int) *Mode {
2727 return NewMode(NewDocument(NewSnapshotFromRows(rows, 16, height), domain.DefaultWordSeparators))
2828 }
2929
30+func TestNewSnapshotFromRowsPreservesWideRows(t *testing.T) {
31+ wide := make([]renderer.Cell, 321)
32+ for i := range wide {
33+ wide[i] = renderer.Cell{Rune: rune('a' + i%26)}
34+ }
35+ rows := [][]renderer.Cell{wide, row("tail")}
36+
37+ snapshot := NewSnapshotFromRows(rows, len(wide), len(rows))
38+
39+ require.Equal(t, len(rows), snapshot.history.Len())
40+ for i, want := range rows {
41+ require.Equal(t, want, snapshot.history.Row(i))
42+ }
43+}
44+
3045 func TestCopyModeKeyboardSelection(t *testing.T) {
3146 m := modeFor([]string{"alpha", "xy", "bravo", "charlie"}, 2)
3247 require.Equal(t, Pos{Row: 3}, m.Cursor())
@@ -175,9 +190,9 @@ func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) {
175190 const rows = 10_000
176191 history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows + 1, ChunkRows: 256})
177192 for range rows {
178- history.Append(row("unmatched"))
193+ require.NoError(t, history.Append(row("unmatched")))
179194 }
180- history.Append(row("target"))
195+ require.NoError(t, history.Append(row("target")))
181196 snapshot := NewSnapshot(history, renderer.NewFrame(16, 1))
182197 view := history.SealAndView()
183198 require.Same(t, view.Chunk(0), snapshot.history.Chunk(0))
M internal/usecase/daemon/copymode_test.go +9 -9
@@ -301,7 +301,7 @@ func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) {
301301 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
302302 pane := sess.tabs[0].focusedPane()
303303 installTestHistory(pane, vt.HistoryConfig{MaxRows: 4})
304- pane.history.Append(testRow("old alpha"))
304+ require.NoError(t, pane.history.Append(testRow("old alpha")))
305305 copy(pane.screen.Frame.Row(0), testRow("live alpha"))
306306
307307 d.enterCopyMode(sess, ac)
@@ -330,8 +330,8 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
330330 p, _ := newBlockingPTYWithWrites(t, writes)
331331 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
332332 installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
333- sess.tabs[0].focusedPane().history.Append(testRow("old1 "))
334- sess.tabs[0].focusedPane().history.Append(testRow("old2 "))
333+ require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old1 ")))
334+ require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old2 ")))
335335 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
336336
337337 d.enterCopyMode(sess, ac)
@@ -505,8 +505,8 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
505505 p, _ := newBlockingPTY(t)
506506 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
507507 installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
508- sess.tabs[0].focusedPane().history.Append(testRow("old1 "))
509- sess.tabs[0].focusedPane().history.Append(testRow("old2 "))
508+ require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old1 ")))
509+ require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old2 ")))
510510 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
511511
512512 d.enterCopyMode(sess, ac)
@@ -739,11 +739,11 @@ func TestCopyModeCapturesSourceAndRetainsItAcrossFocusMove(t *testing.T) {
739739 var want *pane
740740 if tc.useFloating {
741741 want = installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
742- want.history.Append(testRow("flt-old"))
742+ require.NoError(t, want.history.Append(testRow("flt-old")))
743743 want.screen.Write([]byte("flt-live"))
744744 } else {
745745 want = main
746- want.history.Append(testRow("one-old"))
746+ require.NoError(t, want.history.Append(testRow("one-old")))
747747 want.screen.Write([]byte("one-live"))
748748 }
749749
@@ -780,7 +780,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
780780 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
781781 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
782782 for i := range 30 {
783- fp.history.Append(testRow(fmt.Sprintf("old-%02d", i)))
783+ require.NoError(t, fp.history.Append(testRow(fmt.Sprintf("old-%02d", i))))
784784 }
785785 fp.screen.Write([]byte("live"))
786786 fp.mu.Lock()
@@ -806,7 +806,7 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) {
806806 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
807807 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
808808 for i := range 5 {
809- fp.history.Append(testRow(fmt.Sprintf("old-%d", i)))
809+ require.NoError(t, fp.history.Append(testRow(fmt.Sprintf("old-%d", i))))
810810 }
811811 fp.screen.Write([]byte("live"))
812812
M internal/usecase/daemon/daemon.go +156 -96
@@ -7,7 +7,6 @@ import (
77 "log/slog"
88 "math"
99 "os"
10- "slices"
1110 "sort"
1211 "sync"
1312 "sync/atomic"
@@ -17,7 +16,6 @@ import (
1716 "github.com/bnema/vev/internal/persist"
1817 "github.com/bnema/vev/internal/ports"
1918 "github.com/bnema/vev/internal/usecase/keys"
20- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
2119 )
2220
2321 // Scheduler debounce bounds. Idle updates use the minimum for low latency;
@@ -32,8 +30,13 @@ const debounceInterval = minDebounceInterval
3230 // ptyReadBufSize is the PTY reader's read buffer.
3331 const ptyReadBufSize = 32 * 1024
3432
35-// defaultScrollbackRows is the per-tab retained history size.
36-const defaultScrollbackRows = 10_000
33+// New and restored panes are bounded by both history rows and cells. The
34+// cell budget keeps a very wide terminal from retaining disproportionately
35+// more scrollback than the normal 160-column case.
36+const (
37+ defaultScrollbackRows = 10_000
38+ defaultScrollbackCells = 12_000 * 160
39+)
3740
3841 // detachNotifyTimeout bounds the best-effort Detached notification on the
3942 // detach/kill/shutdown paths: if a wedged client (full kernel send buffer)
@@ -101,22 +104,32 @@ type Daemon struct {
101104 beforeCopyModeRevalidate func()
102105 // beforeCopyMouseMap is a deterministic seam after an immutable copy-input
103106 // snapshot is captured and before its mapped position is applied.
104- beforeCopyMouseMap func()
105- ptys ports.PTYFactory
106- clock ports.Clock
107- log *slog.Logger
108- runtimeObserver ports.RuntimeObserver
109- baseEnv []string
110- shell string
111- shellArgs []string
112- shellOverride bool
113- persist *persist.Persister
114- persistEnabled bool
115- snaps ports.SnapshotStore
116- snapsEnabled bool
117- noticeStore ports.NoticeStore
118- snapshotMarshal func(snapcodec.Session) ([]byte, error)
119- snapshotJobs chan *snapshotCapture
107+ beforeCopyMouseMap func()
108+ ptys ports.PTYFactory
109+ clock ports.Clock
110+ log *slog.Logger
111+ runtimeObserver ports.RuntimeObserver
112+ baseEnv []string
113+ shell string
114+ shellArgs []string
115+ shellOverride bool
116+ persist *persist.Persister
117+ persistEnabled bool
118+ // snapshotRepository is the sole checkpoint contract. legacySnapshots is
119+ // read-only migration input and is never used for new writes.
120+ snapshotRepository ports.SnapshotRepository
121+ legacySnapshots ports.LegacySnapshotSource
122+ snapsEnabled bool
123+ // legacyImportPending holds verified legacy blobs whose source deletion
124+ // failed, so a later import retries only deletion rather than publication.
125+ legacyImportMu sync.Mutex
126+ legacyImportPending map[string][]byte
127+ noticeStore ports.NoticeStore
128+ snapshotJobs chan *snapshotCapture
129+ // snapshotWake wakes the repository scheduler when a session becomes dirty
130+ // or an attempt completes. It is never closed and producers only send
131+ // non-blockingly.
132+ snapshotWake chan struct{}
120133 snapshotWorkerMu sync.Mutex
121134 snapshotWorkerID uint64
122135 snapshotWorkerCtx context.Context
@@ -127,28 +140,33 @@ type Daemon struct {
127140 // snapshotFinalJobs coalesces terminal captures by session when the bounded
128141 // regular queue is full. It retains at most snapshotFinalQueueCapacity named
129142 // sessions, each with only its newest terminal state while the worker blocks.
130- snapshotFinalJobs map[*session]*snapshotCapture
131- snapshotFinalOrder []*session
132- snapshotWorkerClosing bool
133- snapshotWorkerInFlight *snapshotCapture
134- restoreDone chan struct{}
135- restoreOnce sync.Once
136- procCwd func(int) (string, error)
137- procComm func(int) (string, error)
138- procArgv func(int) ([]string, error)
139- procGroupArgv func(int, int) ([]string, error)
140- dirOrHome func(string) string
141- bindings atomic.Pointer[keys.Bindings]
142- codeOverrides atomic.Pointer[map[string]string]
143- restoreProcessAllowlist atomic.Pointer[map[string]struct{}]
144- floatingConfig atomic.Pointer[domain.FloatingConfig]
145- copyConfig atomic.Pointer[domain.CopyConfig]
146- paletteConfig atomic.Pointer[domain.PaletteConfig]
147- tabsConfig atomic.Pointer[domain.TabsConfig]
148- themeConfig atomic.Pointer[themeConfigSnapshot]
149- barScripts *barScriptState
150- notices *noticeCenter
151- resumeParkGrace time.Duration
143+ snapshotFinalJobs map[*session]*snapshotCapture
144+ snapshotFinalOrder []*session
145+ snapshotWorkerClosing bool
146+ snapshotWorkerInFlight *snapshotCapture
147+ // snapshotNoticeMu guards the active global persistence failure signature.
148+ // It is separate from snapshotWorkerMu so notice routing cannot block a
149+ // producer or a repository worker.
150+ snapshotNoticeMu sync.Mutex
151+ snapshotActiveFailureSignature string
152+ restoreDone chan struct{}
153+ restoreOnce sync.Once
154+ procCwd func(int) (string, error)
155+ procComm func(int) (string, error)
156+ procArgv func(int) ([]string, error)
157+ procGroupArgv func(int, int) ([]string, error)
158+ dirOrHome func(string) string
159+ bindings atomic.Pointer[keys.Bindings]
160+ codeOverrides atomic.Pointer[map[string]string]
161+ restoreProcessAllowlist atomic.Pointer[map[string]struct{}]
162+ floatingConfig atomic.Pointer[domain.FloatingConfig]
163+ copyConfig atomic.Pointer[domain.CopyConfig]
164+ paletteConfig atomic.Pointer[domain.PaletteConfig]
165+ tabsConfig atomic.Pointer[domain.TabsConfig]
166+ themeConfig atomic.Pointer[themeConfigSnapshot]
167+ barScripts *barScriptState
168+ notices *noticeCenter
169+ resumeParkGrace time.Duration
152170 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
153171 // (see clipboard.go); empty means use os.TempDir().
154172 tempDir string
@@ -194,15 +212,6 @@ type stoppedSession struct {
194212 purging bool
195213 }
196214
197-func (s stoppedSession) same(other stoppedSession) bool {
198- return s.name == other.name &&
199- s.cwd == other.cwd &&
200- s.createdAt == other.createdAt &&
201- s.lastUsedSeq == other.lastUsedSeq &&
202- s.purging == other.purging &&
203- slices.Equal(s.tabNames, other.tabNames)
204-}
205-
206215 type Option func(*Daemon)
207216
208217 // WithRuntimeObserver accepts only a composition-root serialized observer.
@@ -232,12 +241,13 @@ func WithStore(store ports.Store) Option {
232241 }
233242 }
234243
235-// WithSnapshotStore enables durable named session snapshots. A nil store keeps
236-// the daemon in no-op snapshot mode.
237-func WithSnapshotStore(store ports.SnapshotStore) Option {
244+// WithSnapshotRepository enables content-addressed incremental snapshots. The
245+// legacy source is read-only migration input and is never used for new writes.
246+func WithSnapshotRepository(repository ports.SnapshotRepository, legacy ports.LegacySnapshotSource) Option {
238247 return func(d *Daemon) {
239- d.snaps = store
240- d.snapsEnabled = store != nil
248+ d.snapshotRepository = repository
249+ d.legacySnapshots = legacy
250+ d.snapsEnabled = repository != nil
241251 }
242252 }
243253
@@ -343,23 +353,24 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
343353 shell = "/bin/sh"
344354 }
345355 d := &Daemon{
346- sessions: make(map[domain.SessionID]*session),
347- stopped: make(map[string]stoppedSession),
348- parked: make(map[uint64]*parkedAttachment),
349- ptys: ptys,
350- clock: clock,
351- log: log,
352- baseEnv: os.Environ(),
353- shell: shell,
354- persist: persist.New(nil),
355- dirOrHome: dirOrHome,
356- done: make(chan struct{}),
357- restoreDone: make(chan struct{}),
358- animWake: make(chan struct{}, 1),
359- snapshotMarshal: snapcodec.Marshal,
360- snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
361- notices: newNoticeCenter(),
362- resumeParkGrace: defaultResumeParkGrace,
356+ sessions: make(map[domain.SessionID]*session),
357+ stopped: make(map[string]stoppedSession),
358+ parked: make(map[uint64]*parkedAttachment),
359+ ptys: ptys,
360+ clock: clock,
361+ log: log,
362+ baseEnv: os.Environ(),
363+ shell: shell,
364+ persist: persist.New(nil),
365+ dirOrHome: dirOrHome,
366+ done: make(chan struct{}),
367+ restoreDone: make(chan struct{}),
368+ animWake: make(chan struct{}, 1),
369+ legacyImportPending: make(map[string][]byte),
370+ snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
371+ snapshotWake: make(chan struct{}, 1),
372+ notices: newNoticeCenter(),
373+ resumeParkGrace: defaultResumeParkGrace,
363374 barScripts: &barScriptState{
364375 cfg: barConfigFromDomain(domain.Defaults().Bar),
365376 outputs: make(map[domain.SessionID]barScriptOutputs),
@@ -443,7 +454,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
443454 if d.snapsEnabled {
444455 d.startSnapshotEncodeWorker()
445456 d.sessWg.Go(func() {
446- d.snapshotSaver(d.serveCtx)
457+ d.snapshotRepositorySaver(d.serveCtx)
447458 })
448459 d.sessWg.Go(func() {
449460 d.restoreSnapshots(d.serveCtx)
@@ -486,7 +497,15 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
486497 // force-close. Finally drain the conn handlers, run one defensive sweep in
487498 // case any ordering ever leaves a session behind, and join the session
488499 // goroutines (readers unblock via pty.Close, coordinators via ctx cancel).
489- d.shutdownAll(ports.ReasonServerShutdown)
500+ // One deadline owns every terminal checkpoint wait and the final worker
501+ // join. A repository is allowed to ignore cancellation, so spending a fresh
502+ // interval at either stage would make shutdown exceed its documented bound.
503+ var snapshotDeadline *snapshotShutdownDeadline
504+ if d.snapsEnabled {
505+ snapshotDeadline = newSnapshotShutdownDeadline(d.clock)
506+ defer snapshotDeadline.stop()
507+ }
508+ d.shutdownAllWithSnapshotDeadline(ports.ReasonServerShutdown, snapshotDeadline)
490509 d.waitNotifies()
491510 d.hardCancel()
492511 d.serveCancel()
@@ -495,7 +514,10 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
495514 // registry snapshot has been removed.
496515 d.connWg.Wait()
497516 d.attachmentCleanupWg.Wait()
498- d.stopSnapshotEncodeWorker()
517+ // Forced terminal checkpoints run before shutdown stops producers or the
518+ // worker. The same deadline bounds the worker's final drain, including a
519+ // repository call that ignores cancellation.
520+ d.stopSnapshotEncodeWorkerWithDeadline(snapshotDeadline)
499521 d.shutdownAll(ports.ReasonServerShutdown)
500522 d.sessWg.Wait()
501523 d.waitNotifies()
@@ -509,7 +531,11 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
509531 // closing under the same lock as the snapshot guarantees no session can be
510532 // inserted after the snapshot: route rejects once closing is set, and both run
511533 // under d.mu. killSession (which relocks) runs after the lock is released.
512-func (d *Daemon) shutdownAll(reason uint8) {
534+func (d *Daemon) shutdownAll(reason uint8) (checkpointIncomplete bool) {
535+ return d.shutdownAllWithSnapshotDeadline(reason, nil)
536+}
537+
538+func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) {
513539 d.mu.Lock()
514540 d.closing = true
515541 for token, parked := range d.parked {
@@ -521,13 +547,57 @@ func (d *Daemon) shutdownAll(reason uint8) {
521547 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))
522548 if empty {
523549 d.doneOnce.Do(func() { close(d.done) })
524- return
550+ return false
525551 }
526552 for _, s := range snapshot {
527- if err := d.killSession(s, reason, false); err != nil {
553+ if err := d.killSessionWithSnapshotDeadline(s, reason, false, deadline); err != nil {
554+ checkpointIncomplete = true
528555 d.log.Error("closing session with unpersisted terminal state", "err", err)
529556 }
530557 }
558+ return checkpointIncomplete
559+}
560+
561+// snapshotShutdownDeadline is a single-use shutdown budget shared by forced
562+// checkpoints and the final snapshot worker join. Its done channel is never
563+// closed by callers, so a detached worker can safely retain session state until
564+// an uncooperative repository call returns.
565+type snapshotShutdownDeadline struct {
566+ done chan struct{}
567+ stopCh chan struct{}
568+ finished chan struct{}
569+ stopOnce sync.Once
570+}
571+
572+func newSnapshotShutdownDeadline(clock ports.Clock) *snapshotShutdownDeadline {
573+ deadline := &snapshotShutdownDeadline{done: make(chan struct{}), stopCh: make(chan struct{}), finished: make(chan struct{})}
574+ timer := clock.NewTimer(snapshotFinalFlushTimeout)
575+ go func() {
576+ defer close(deadline.finished)
577+ select {
578+ case <-timer.C():
579+ close(deadline.done)
580+ case <-deadline.stopCh:
581+ timer.Stop()
582+ }
583+ }()
584+ return deadline
585+}
586+
587+func (d *snapshotShutdownDeadline) stop() {
588+ if d != nil {
589+ d.stopOnce.Do(func() {
590+ close(d.stopCh)
591+ <-d.finished
592+ })
593+ }
594+}
595+
596+func (d *snapshotShutdownDeadline) Done() <-chan struct{} {
597+ if d == nil {
598+ return nil
599+ }
600+ return d.done
531601 }
532602
533603 func (d *Daemon) sessionsSnapshotLocked() []*session {
@@ -620,25 +690,15 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
620690 d.mu.Lock()
621691 target := d.findByNameLocked(k.Name)
622692 if target == nil {
623- if stopped, ok := d.stopped[k.Name]; ok {
693+ if _, ok := d.stopped[k.Name]; ok {
624694 d.mu.Unlock()
625- if d.snapsEnabled && d.snaps != nil {
626- if err := d.snaps.Delete(k.Name); err != nil {
627- d.log.Warn("deleting stopped session snapshot failed", "err", err, "session", k.Name)
628- _ = tr.Send(frameError(ports.ErrInternal, "deleting stopped session snapshot failed"))
629- return
630- }
695+ // Stopped sessions follow the same tombstone-backed transaction as
696+ // live and offline purges. In particular, a retained legacy source
697+ // must never outlive metadata after an interrupted control kill.
698+ if err := d.retryStoppedPurge(k.Name); err != nil {
699+ d.log.Warn("deleting stopped session failed", "err", err, "session", k.Name)
700+ _ = tr.Send(frameError(ports.ErrInternal, "deleting stopped session failed"))
631701 }
632- if err := d.persist.Delete(k.Name); err != nil {
633- d.log.Warn("deleting persisted stopped session failed", "err", err, "session", k.Name)
634- _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted stopped session failed"))
635- return
636- }
637- d.mu.Lock()
638- if cur, ok := d.stopped[k.Name]; ok && cur.same(stopped) {
639- delete(d.stopped, k.Name)
640- }
641- d.mu.Unlock()
642702 return
643703 }
644704 }
M internal/usecase/daemon/input_test.go +2 -2
@@ -1237,7 +1237,7 @@ func TestMouseNormalScreenDragUsesPressOwnedDocumentAfterOutputEviction(t *testi
12371237
12381238 // Output evicts five lines after Press; the Document remains immutable.
12391239 for range 5 {
1240- sess.tabs[0].focusedPane().history.Append(testRow("evicted"))
1240+ require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("evicted")))
12411241 }
12421242
12431243 // Motion maps the next endpoint through the press-owned Document.
@@ -1764,7 +1764,7 @@ func TestActiveCopyMouseRejectsViewportChangeAfterMappingSnapshot(t *testing.T)
17641764 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
17651765 pane := sess.activeTab().focusedPane()
17661766 for range 8 {
1767- pane.history.Append(testRow("history"))
1767+ require.NoError(t, pane.history.Append(testRow("history")))
17681768 }
17691769 d.enterCopyMode(sess, ac)
17701770 mustOutputData(t, sends)
M internal/usecase/daemon/notice_persist_test.go +0 -90
@@ -1,17 +1,13 @@
11 package daemon
22
33 import (
4- "fmt"
5- "sync/atomic"
64 "testing"
75
8- "github.com/stretchr/testify/mock"
96 "github.com/stretchr/testify/require"
107
118 "github.com/bnema/vev/internal/domain"
129 "github.com/bnema/vev/internal/ports"
1310 portsmocks "github.com/bnema/vev/internal/ports/mocks"
14- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
1511 )
1612
1713 // TestKillSessionServerShutdownSaturatedAppendsNotice proves that when the
@@ -19,92 +15,6 @@ import (
1915 // no way to retain the session's terminal state and no attached client to
2016 // toast, because the daemon is dying — the failure is handed to the
2117 // NoticeStore instead of being silently lost.
22-func TestKillSessionServerShutdownSaturatedAppendsNotice(t *testing.T) {
23- store := discardSnapshotStore{}
24- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
25- WithSnapshotStore(store)(d)
26- startSnapshotEncodeWorker(t, d)
27-
28- entered := make(chan struct{})
29- release := make(chan struct{})
30- defer close(release)
31- var calls atomic.Int32
32- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
33- if calls.Add(1) == 1 {
34- close(entered)
35- <-release
36- }
37- return snapcodec.Marshal(s)
38- }
39-
40- active := newSnapshotTestSession(t, "active", false, "/work")
41- queued := newSnapshotTestSession(t, "queued", false, "/work")
42- markSnapshotDirty(active)
43- markSnapshotDirty(queued)
44- require.True(t, d.scheduleSnapshot(active))
45- <-entered // the worker is now parked inside marshal for active
46- require.True(t, d.scheduleSnapshot(queued), "one capture waits in the bounded queue")
47- for i := range snapshotFinalQueueCapacity {
48- s := newSnapshotTestSession(t, fmt.Sprintf("terminal-%d", i), false, "/work")
49- markSnapshotDirty(s)
50- require.True(t, d.scheduleFinalSnapshot(s), "fill the terminal-retention fallback to capacity")
51- }
52-
53- target := newSnapshotTestSession(t, "target", false, "/work")
54- targetPTY, ok := target.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
55- require.True(t, ok, "target pane PTY must be a MockPTY")
56- targetPTY.EXPECT().Close().Return(nil).Maybe()
57- d.mu.Lock()
58- d.sessions[target.id] = target
59- d.mu.Unlock()
60- markSnapshotDirty(target)
61-
62- notices := portsmocks.NewMockNoticeStore(t)
63- var appended domain.Notification
64- notices.EXPECT().Append(mock.Anything).RunAndReturn(func(n domain.Notification) error {
65- appended = n
66- return nil
67- }).Once()
68- d.noticeStore = notices
69-
70- err := d.killSession(target, ports.ReasonServerShutdown, false)
71- require.ErrorContains(t, err, "snapshot worker unavailable or saturated")
72-
73- d.mu.Lock()
74- _, stillPresent := d.sessions[target.id]
75- d.mu.Unlock()
76- require.False(t, stillPresent, "the session must be torn down even though its state could not be saved")
77-
78- require.Equal(t, domain.NoticeSnapshotWrite, appended.Code)
79- require.Equal(t, domain.NoticeError, appended.Severity)
80- require.Equal(t, "session target shut down without saving terminal state", appended.Message)
81- require.NotEmpty(t, appended.Details)
82-}
83-
84-// TestKillSessionServerShutdownNotSaturatedSkipsAppend proves the notice store
85-// is left untouched when the final snapshot is retained normally.
86-func TestKillSessionServerShutdownNotSaturatedSkipsAppend(t *testing.T) {
87- store := discardSnapshotStore{}
88- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
89- WithSnapshotStore(store)(d)
90- startSnapshotEncodeWorker(t, d)
91-
92- notices := portsmocks.NewMockNoticeStore(t) // no .EXPECT(): Append must not be called
93- d.noticeStore = notices
94-
95- target := newSnapshotTestSession(t, "target", false, "/work")
96- targetPTY, ok := target.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
97- require.True(t, ok, "target pane PTY must be a MockPTY")
98- targetPTY.EXPECT().Close().Return(nil).Maybe()
99- d.mu.Lock()
100- d.sessions[target.id] = target
101- d.mu.Unlock()
102- markSnapshotDirty(target)
103-
104- err := d.killSession(target, ports.ReasonServerShutdown, false)
105- require.NoError(t, err)
106-}
107-
10818 // TestStartupClaimRecordsAndToastsPendingNotices proves notices persisted by
10919 // a previous daemon are recorded to history and toasted to the first client
11020 // that attaches after this daemon starts before their claim is acknowledged.
M internal/usecase/daemon/notify.go +25 -0
@@ -61,6 +61,31 @@ func (nc *noticeCenter) record(n domain.Notification) domain.Notification {
6161 return n
6262 }
6363
64+// recordSnapshotFailure coalesces a repeated stable snapshot failure in the
65+// history. Details contains the signature rather than raw error text, so this
66+// comparison cannot be affected by volatile paths or snapshot content.
67+func (nc *noticeCenter) recordSnapshotFailure(n domain.Notification) (domain.Notification, bool) {
68+ if n.Count == 0 {
69+ n.Count = 1
70+ }
71+ nc.mu.Lock()
72+ defer nc.mu.Unlock()
73+ for i := len(nc.ring) - 1; i >= 0; i-- {
74+ previous := &nc.ring[i]
75+ if previous.Code != domain.NoticeSnapshotWrite || previous.SessionID != n.SessionID || previous.Details != n.Details {
76+ continue
77+ }
78+ previous.Count += n.Count
79+ previous.Time = n.Time
80+ return *previous, true
81+ }
82+ nc.ring = append(nc.ring, n)
83+ if len(nc.ring) > noticeHistoryCap {
84+ nc.ring = nc.ring[len(nc.ring)-noticeHistoryCap:]
85+ }
86+ return n, false
87+}
88+
6489 func (nc *noticeCenter) history() []domain.Notification {
6590 nc.mu.Lock()
6691 defer nc.mu.Unlock()
M internal/usecase/daemon/overlay_runtime_test.go +2 -2
@@ -199,7 +199,7 @@ func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) {
199199 ac := &attachedClient{}
200200 ac.initOverlays()
201201 history := newTestHistory(1)
202- history.Append(testRow("before"))
202+ require.NoError(t, history.Append(testRow("before")))
203203 document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1)), domain.DefaultWordSeparators)
204204
205205 ac.overlays.copyMu.Lock()
@@ -207,7 +207,7 @@ func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) {
207207 ac.overlays.copyDocument = document
208208 ac.overlays.copyMu.Unlock()
209209
210- history.Append(testRow("after"))
210+ require.NoError(t, history.Append(testRow("after")))
211211 renderSnap := ac.overlays.SnapshotForRender()
212212 defer renderSnap.Unlock()
213213 require.NotNil(t, renderSnap.copyMode)
M internal/usecase/daemon/pane.go +1 -1
@@ -47,7 +47,7 @@ func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {
4747 }
4848
4949 func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane {
50- screen := vt.NewScreenWithHistory(sz.Cols, sz.Rows, vt.HistoryConfig{MaxRows: defaultScrollbackRows})
50+ screen := vt.NewScreenWithHistory(sz.Cols, sz.Rows, vt.HistoryConfig{MaxRows: defaultScrollbackRows, MaxCells: defaultScrollbackCells})
5151 return &pane{
5252 id: id,
5353 stableID: stableID,
M internal/usecase/daemon/performance_benchmark_test.go +387 -77
@@ -1,9 +1,11 @@
11 package daemon
22
33 import (
4+ "context"
45 "fmt"
56 "io"
67 "sync"
8+ "sync/atomic"
79 "testing"
810 "time"
911
@@ -13,18 +15,138 @@ import (
1315 "github.com/bnema/vev/internal/ports"
1416 "github.com/bnema/vev/internal/usecase/layout"
1517 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
18+ "github.com/bnema/vev/pkg/renderer"
1619 )
1720
18-func TestCountingSnapshotStoreWriteCountsAndRetainsOpaquePayload(t *testing.T) {
19- store := &countingSnapshotStore{}
21+func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) {
22+ repository := &countingSnapshotRepository{}
2023 payload := []byte{0xff, 0x00, 0xfe}
24+ object := []byte{0x01, 0x02}
2125
22- require.NoError(t, store.Write("session", payload))
23- require.Equal(t, countingSnapshotMetrics{writes: 1, bytes: uint64(len(payload))}, store.metrics())
24- require.Equal(t, payload, store.lastPayload())
26+ require.NoError(t, repository.Publish(context.Background(), ports.SnapshotPublication{Name: "session", Manifest: payload, Objects: []ports.SnapshotObject{{Data: object}}}))
27+ require.Equal(t, countingSnapshotMetrics{writes: 1, manifestBytes: uint64(len(payload)), objectBytes: uint64(len(object)), suppliedObjectBytes: uint64(len(object)), headBytes: snapshotHeadBytes}, repository.metrics())
28+ require.Equal(t, payload, repository.lastPayload())
2529
2630 payload[0] = 0x01
27- require.Equal(t, payload, store.lastPayload(), "the counting sink must retain the payload slice header, not copy it")
31+ require.Equal(t, payload, repository.lastPayload(), "the counting sink must retain the payload slice header, not copy it")
32+}
33+
34+func TestCountingSnapshotRepositoryDoesNotRewriteKnownObjects(t *testing.T) {
35+ repository := &countingSnapshotRepository{}
36+ object, err := snapcodec.MarshalObject(snapcodec.HistoryChunk, []byte{0x01})
37+ require.NoError(t, err)
38+ publication := ports.SnapshotPublication{Name: "session", Manifest: []byte("manifest"), Objects: []ports.SnapshotObject{object}}
39+
40+ require.NoError(t, repository.Publish(context.Background(), publication))
41+ repository.reset()
42+ require.NoError(t, repository.Publish(context.Background(), publication))
43+
44+ require.Zero(t, repository.metrics().objectBytes, "filesystem repositories retain immutable content-addressed objects")
45+}
46+
47+func TestIncrementalSnapshotMetricsWriteNoUnchangedHistoryBlobsAndBoundCache(t *testing.T) {
48+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: 10_000})
49+
50+ publishBenchmarkSnapshot(t, fixture, 1)
51+ fixture.snapshots.reset()
52+ publishBenchmarkSnapshot(t, fixture, 2)
53+
54+ metrics := fixture.snapshots.metrics()
55+ require.Zero(t, metrics.historyBlobBytes)
56+ require.Zero(t, metrics.suppliedHistoryBytes)
57+ require.Zero(t, metrics.objectBytes)
58+ require.Positive(t, metrics.manifestBytes)
59+ require.Equal(t, uint64(snapshotHeadBytes), metrics.headBytes)
60+ require.LessOrEqual(t, benchmarkSnapshotCacheBytes(fixture.sess), snapshotChunkCacheLimit)
61+}
62+
63+func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testing.T) {
64+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: 10_000})
65+
66+ markSnapshotDirty(fixture.sess)
67+ require.True(t, fixture.d.scheduleSnapshot(fixture.sess))
68+ awaitSnapshotClean(t, fixture.sess)
69+
70+ var copies atomic.Uint64
71+ fixture.sess.snapshotMu.Lock()
72+ fixture.sess.snapshotChunkCache.copyObject = func(object ports.SnapshotObject) ports.SnapshotObject {
73+ copies.Add(1)
74+ return copySnapshotObject(object)
75+ }
76+ fixture.sess.snapshotMu.Unlock()
77+ fixture.snapshots.reset()
78+ fixture.activePane.mu.Lock()
79+ fixture.activePane.screen.Write([]byte("\x1b[1;1Hchanged"))
80+ frame := fixture.activePane.screen.PrimaryVisibleFrame()
81+ fixture.activePane.mu.Unlock()
82+ for i, r := range "changed" {
83+ require.Equalf(t, r, frame.At(i, 0).Rune, "fixture must overwrite at column %d", i)
84+ }
85+ markSnapshotDirty(fixture.sess)
86+ require.True(t, fixture.d.scheduleFinalSnapshot(fixture.sess))
87+ awaitSnapshotClean(t, fixture.sess)
88+
89+ metrics := fixture.snapshots.metrics()
90+ require.Equal(t, uint64(1), metrics.writes)
91+ require.Zero(t, metrics.suppliedHistoryBytes, "retained sealed history must not be resupplied")
92+ require.Zero(t, copies.Load(), "retained sealed history must not be deep-copied")
93+ require.Positive(t, metrics.suppliedObjectBytes, "changed tail and visible state must still be supplied")
94+ requireCompleteSnapshotManifest(t, fixture.snapshots, fixture.sess.name)
95+}
96+
97+func requireCompleteSnapshotManifest(t *testing.T, repository *countingSnapshotRepository, name string) {
98+ t.Helper()
99+ repository.mu.Lock()
100+ manifestBytes := append([]byte(nil), repository.last...)
101+ objects := repository.objects[name]
102+ repository.mu.Unlock()
103+ manifest, err := snapcodec.UnmarshalManifest(manifestBytes)
104+ require.NoError(t, err)
105+ for _, tab := range manifest.Tabs {
106+ for _, pane := range tab.Panes {
107+ for _, ref := range append(append([]snapcodec.ObjectRef(nil), pane.Sealed...), pane.Tail, pane.Visible) {
108+ _, ok := objects[ref.Digest]
109+ require.True(t, ok, "manifest reference %x is not retained", ref.Digest)
110+ }
111+ }
112+ }
113+}
114+
115+func TestIncrementalSnapshotMetricScenarios(t *testing.T) {
116+ for _, tt := range []struct {
117+ name string
118+ historyRows int
119+ mutate func(*performanceFixture, int)
120+ wantHistory bool
121+ }{
122+ {name: "tail-only", historyRows: 9_999, mutate: mutateBenchmarkTail},
123+ {name: "new-sealed-chunk", historyRows: 10_000, mutate: mutateBenchmarkSealedChunk, wantHistory: true},
124+ } {
125+ t.Run(tt.name, func(t *testing.T) {
126+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: tt.historyRows})
127+ publishBenchmarkSnapshot(t, fixture, 1)
128+ fixture.snapshots.reset()
129+ tt.mutate(fixture, 0)
130+ publishBenchmarkSnapshot(t, fixture, 2)
131+
132+ metrics := fixture.snapshots.metrics()
133+ if tt.wantHistory {
134+ require.Positive(t, metrics.historyBlobBytes)
135+ } else {
136+ require.Zero(t, metrics.historyBlobBytes)
137+ }
138+ require.LessOrEqual(t, benchmarkSnapshotCacheBytes(fixture.sess), snapshotChunkCacheLimit)
139+ })
140+ }
141+}
142+
143+func TestPerformanceFixtureCellLimitedHistory(t *testing.T) {
144+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 293, Rows: 40}, panes: 1, historyRows: 10_000})
145+ fixture.activePane.mu.Lock()
146+ rows, cells, capCells := fixture.activePane.history.Len(), fixture.activePane.history.Cells(), fixture.activePane.history.CellCap()
147+ fixture.activePane.mu.Unlock()
148+ require.Less(t, rows, 10_000)
149+ require.LessOrEqual(t, cells, capCells)
28150 }
29151
30152 func TestCountingOutputTransportCountsOpaquePayloadAndRejectsShortPayload(t *testing.T) {
@@ -63,7 +185,8 @@ func TestPerformanceFixtureCounters(t *testing.T) {
63185 fixture.captureSnapshot()
64186 metrics = fixture.metrics()
65187 require.Equal(t, uint64(1), metrics.snapshotWrites)
66- require.Positive(t, metrics.snapshotBytes)
188+ require.Positive(t, metrics.snapshotManifestBytes)
189+ require.Positive(t, metrics.snapshotObjectBytes)
67190
68191 fixture.enterCopySearch()
69192 require.Positive(t, fixture.searchMatches())
@@ -157,13 +280,13 @@ func TestPerformanceFixtureSnapshotCaptureRetainsSealedHistory(t *testing.T) {
157280 require.True(t, ok)
158281 require.Len(t, first.tabs, 1)
159282 require.Len(t, first.tabs[0].panes, 2)
160- require.Equal(t, 10_000, first.tabs[0].panes[0].history.Len())
161- require.Equal(t, 40, first.tabs[0].panes[0].history.ChunkCount())
283+ require.Equal(t, 10_000, first.tabs[0].panes[0].sealed.Len())
284+ require.Positive(t, first.tabs[0].panes[0].sealed.ChunkCount())
162285
163286 second, ok := fixture.d.captureSnapshotState(fixture.sess, 2)
164287 require.True(t, ok)
165288 secondPane := capturePaneByID(second, first.tabs[0].panes[0].id)
166- if secondPane == nil || first.tabs[0].panes[0].history.Chunk(0) != secondPane.history.Chunk(0) {
289+ if secondPane == nil || first.tabs[0].panes[0].sealed.Chunk(0) != secondPane.sealed.Chunk(0) {
167290 t.Fatal("unchanged sealed history chunk was not reused across captures")
168291 }
169292 }
@@ -216,14 +339,20 @@ func BenchmarkDaemonHistorySnapshotCapture(b *testing.B) {
216339 benchmarkDaemonSnapshotCapture(b)
217340 }
218341
219-// BenchmarkDaemonHistorySnapshotEncode measures v3 encoding from an already
220-// immutable capture, without queueing or persistence.
221-func BenchmarkDaemonHistorySnapshotEncode(b *testing.B) {
222- benchmarkDaemonSnapshotEncode(b)
342+// BenchmarkDaemonIncrementalSnapshotRepository measures the complete
343+// content-addressed publication path against a filesystem-equivalent byte
344+// counter. Each scenario reports persisted VEVO, VEVM, and VEVH bytes, plus
345+// allocation/time and the per-session encoded-history cache high-water mark.
346+func BenchmarkDaemonIncrementalSnapshotRepository(b *testing.B) {
347+ for _, scenario := range snapshotBenchmarkScenarios {
348+ b.Run(scenario.name, func(b *testing.B) {
349+ benchmarkIncrementalSnapshotRepository(b, scenario)
350+ })
351+ }
223352 }
224353
225354 // BenchmarkDaemonHistorySnapshotRestore measures terminal-state restoration
226-// from a validated v3 pane manifest without spawning PTY or session workers.
355+// from a validated incremental manifest without spawning PTY or session workers.
227356 func BenchmarkDaemonHistorySnapshotRestore(b *testing.B) {
228357 benchmarkDaemonSnapshotRestore(b)
229358 }
@@ -326,7 +455,7 @@ func benchmarkDaemonSnapshotCapture(b *testing.B) {
326455 b.Fatal("invalid snapshot capture fixture")
327456 }
328457 firstPane := first.tabs[0].panes[0]
329- firstChunk := firstPane.history.Chunk(0)
458+ firstChunk := firstPane.sealed.Chunk(0)
330459 captures := 0
331460 var last *snapshotCapture
332461 b.ReportAllocs()
@@ -340,7 +469,7 @@ func benchmarkDaemonSnapshotCapture(b *testing.B) {
340469 }
341470 b.StopTimer()
342471 lastPane := capturePaneByID(last, firstPane.id)
343- if captures != b.N || !validBenchmarkCapture(last) || lastPane == nil || lastPane.history.Chunk(0) != firstChunk {
472+ if captures != b.N || !validBenchmarkCapture(last) || lastPane == nil || lastPane.sealed.Chunk(0) != firstChunk {
344473 b.Fatal("snapshot capture did not preserve immutable sealed history")
345474 }
346475 b.ReportMetric(float64(capturePaneCount(last)), "capturepanes/op")
@@ -348,41 +477,159 @@ func benchmarkDaemonSnapshotCapture(b *testing.B) {
348477 }
349478 }
350479
351-func benchmarkDaemonSnapshotEncode(b *testing.B) {
352- for _, topology := range daemonSnapshotTopologies {
353- b.Run(topology.name, func(b *testing.B) {
354- fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000})
355- if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) {
356- b.Fatal("invalid daemon snapshot encode fixture")
357- }
358- capture, ok := fixture.d.captureSnapshotState(fixture.sess, 1)
359- if !ok || !validBenchmarkCapture(capture) {
360- b.Fatal("invalid snapshot encode fixture")
361- }
362- encoded, err := fixture.d.encodeSnapshotCapture(capture)
363- if err != nil {
364- b.Fatal(err)
365- }
366- if _, err := snapcodec.Unmarshal(encoded); err != nil {
367- b.Fatalf("invalid v3 snapshot encode fixture: %v", err)
368- }
369- var last []byte
370- b.ReportAllocs()
371- b.SetBytes(int64(len(encoded)))
372- b.ResetTimer()
373- for range b.N {
374- last, err = fixture.d.encodeSnapshotCapture(capture)
375- if err != nil {
376- b.Fatal(err)
377- }
378- }
379- b.StopTimer()
380- if _, err := snapcodec.Unmarshal(last); err != nil {
381- b.Fatalf("snapshot encoder produced invalid v3 data: %v", err)
382- }
383- b.ReportMetric(float64(len(last)), "snapshotbytes/op")
384- })
480+type snapshotBenchmarkScenario struct {
481+ name string
482+ size domain.Size
483+ historyRows int
484+ baseline bool
485+ mutate func(*performanceFixture, int)
486+ unchanged bool
487+ cellLimited bool
488+}
489+
490+var snapshotBenchmarkScenarios = []snapshotBenchmarkScenario{
491+ {name: "initial-10k-x-120", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000},
492+ {name: "unchanged", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, unchanged: true},
493+ {name: "visible-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, mutate: mutateBenchmarkVisible},
494+ // Leave one row below the retention cap so the mutation changes only the
495+ // mutable tail and cannot evict a sealed chunk.
496+ {name: "tail-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 9_999, baseline: true, mutate: mutateBenchmarkTail},
497+ {name: "new-sealed-chunk", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, mutate: mutateBenchmarkSealedChunk},
498+ {name: "cell-limited-293-columns", size: domain.Size{Cols: 293, Rows: 40}, historyRows: 10_000, cellLimited: true},
499+}
500+
501+func benchmarkIncrementalSnapshotRepository(b *testing.B, scenario snapshotBenchmarkScenario) {
502+ var (
503+ metrics countingSnapshotMetrics
504+ peakCacheBytes int
505+ )
506+ b.ReportAllocs()
507+ b.ResetTimer()
508+ for operation := 0; b.Loop(); operation++ {
509+ // A new fixture is deliberately outside the timed region. It gives every
510+ // iteration the same filesystem state: initial has no generation,
511+ // unchanged/visible/tail/sealed begin after one persisted generation.
512+ // Reusing one fixture would eventually turn a tail into a sealed chunk
513+ // and dilute initial-write bytes over later idempotent publications.
514+ b.StopTimer()
515+ fixture := newPerformanceFixture(b, performanceConfig{size: scenario.size, panes: 1, historyRows: scenario.historyRows})
516+ validateSnapshotBenchmarkFixture(b, fixture, scenario)
517+ generation := uint64(1)
518+ if scenario.baseline {
519+ publishBenchmarkSnapshot(b, fixture, generation)
520+ generation++
521+ fixture.snapshots.reset()
522+ }
523+ b.StartTimer()
524+ if scenario.mutate != nil {
525+ scenario.mutate(fixture, operation)
526+ }
527+ publishBenchmarkSnapshot(b, fixture, generation)
528+ b.StopTimer()
529+
530+ metrics = addCountingSnapshotMetrics(metrics, fixture.snapshots.metrics())
531+ peakCacheBytes = max(peakCacheBytes, benchmarkSnapshotCacheBytes(fixture.sess))
532+ // B.Loop requires the timer to be running at the next iteration.
533+ b.StartTimer()
534+ }
535+ b.StopTimer()
536+ if scenario.unchanged && metrics.historyBlobBytes != 0 {
537+ b.Fatalf("unchanged snapshot wrote %d history blob bytes", metrics.historyBlobBytes)
538+ }
539+ if peakCacheBytes > snapshotChunkCacheLimit {
540+ b.Fatalf("snapshot cache peak = %d, limit = %d", peakCacheBytes, snapshotChunkCacheLimit)
385541 }
542+ benchmarkReportSnapshotRepositoryMetrics(b, metrics, b.N, peakCacheBytes)
543+}
544+
545+func validateSnapshotBenchmarkFixture(b *testing.B, fixture *performanceFixture, scenario snapshotBenchmarkScenario) {
546+ b.Helper()
547+ if !fixture.hasHistoryTopology(1, 1, scenario.historyRows) && !scenario.cellLimited {
548+ b.Fatal("invalid incremental snapshot fixture")
549+ }
550+ if !scenario.cellLimited {
551+ return
552+ }
553+ fixture.activePane.mu.Lock()
554+ rows, cells, capCells := fixture.activePane.history.Len(), fixture.activePane.history.Cells(), fixture.activePane.history.CellCap()
555+ fixture.activePane.mu.Unlock()
556+ if rows >= scenario.historyRows || cells > capCells {
557+ b.Fatalf("293-column fixture did not enforce cell limit: rows=%d cells=%d cap=%d", rows, cells, capCells)
558+ }
559+}
560+
561+func addCountingSnapshotMetrics(a, b countingSnapshotMetrics) countingSnapshotMetrics {
562+ return countingSnapshotMetrics{
563+ writes: a.writes + b.writes,
564+ objectBytes: a.objectBytes + b.objectBytes,
565+ historyBlobBytes: a.historyBlobBytes + b.historyBlobBytes,
566+ suppliedObjectBytes: a.suppliedObjectBytes + b.suppliedObjectBytes,
567+ suppliedHistoryBytes: a.suppliedHistoryBytes + b.suppliedHistoryBytes,
568+ manifestBytes: a.manifestBytes + b.manifestBytes,
569+ headBytes: a.headBytes + b.headBytes,
570+ }
571+}
572+
573+func publishBenchmarkSnapshot(b testing.TB, fixture *performanceFixture, generation uint64) {
574+ b.Helper()
575+ capture, ok := fixture.d.captureSnapshotState(fixture.sess, generation)
576+ if !ok {
577+ b.Fatal("snapshot capture unexpectedly rejected benchmark fixture")
578+ }
579+ publication, err := fixture.d.incrementalPublication(capture)
580+ if err != nil {
581+ b.Fatal(err)
582+ }
583+ if err := fixture.snapshots.Publish(context.Background(), publication); err != nil {
584+ b.Fatal(err)
585+ }
586+ markSnapshotCaptureObjectsPublished(capture)
587+}
588+
589+func mutateBenchmarkVisible(fixture *performanceFixture, operation int) {
590+ fixture.activePane.mu.Lock()
591+ fixture.activePane.screen.Write([]byte(fmt.Sprintf("\x1b[1;1Hvisible-%08d", operation)))
592+ fixture.activePane.mu.Unlock()
593+}
594+
595+func mutateBenchmarkTail(fixture *performanceFixture, operation int) {
596+ fixture.activePane.mu.Lock()
597+ defer fixture.activePane.mu.Unlock()
598+ row := make([]renderer.Cell, fixture.activePane.screen.Frame.Width)
599+ for column := range row {
600+ row[column] = renderer.Cell{Rune: rune('a' + (operation+column)%26)}
601+ }
602+ if err := fixture.activePane.history.Append(row); err != nil {
603+ fixture.t.Fatal(err)
604+ }
605+}
606+
607+func mutateBenchmarkSealedChunk(fixture *performanceFixture, operation int) {
608+ for row := 0; row < 256; row++ {
609+ mutateBenchmarkTail(fixture, operation*256+row)
610+ }
611+}
612+
613+func benchmarkSnapshotCacheBytes(sess *session) int {
614+ sess.snapshotMu.Lock()
615+ defer sess.snapshotMu.Unlock()
616+ if sess.snapshotChunkCache == nil {
617+ return 0
618+ }
619+ return sess.snapshotChunkCache.used
620+}
621+
622+func benchmarkReportSnapshotRepositoryMetrics(b *testing.B, metrics countingSnapshotMetrics, operations int, peakCacheBytes int) {
623+ b.Helper()
624+ if operations == 0 {
625+ return
626+ }
627+ perOperation := float64(operations)
628+ b.ReportMetric(float64(metrics.objectBytes)/perOperation, "objectbytes/op")
629+ b.ReportMetric(float64(metrics.historyBlobBytes)/perOperation, "historyblobbytes/op")
630+ b.ReportMetric(float64(metrics.manifestBytes)/perOperation, "manifestbytes/op")
631+ b.ReportMetric(float64(metrics.headBytes)/perOperation, "headbytes/op")
632+ b.ReportMetric(float64(peakCacheBytes), "peakcachebytes")
386633 }
387634
388635 func benchmarkDaemonSnapshotRestore(b *testing.B) {
@@ -394,17 +641,17 @@ func benchmarkDaemonSnapshotRestore(b *testing.B) {
394641 if !ok || !validBenchmarkCapture(capture) {
395642 b.Fatal("invalid snapshot restore fixture")
396643 }
397- encoded, err := fixture.d.encodeSnapshotCapture(capture)
644+ publication, err := fixture.d.incrementalPublication(capture)
398645 if err != nil {
399646 b.Fatal(err)
400647 }
401- snapshot, err := snapcodec.Unmarshal(encoded)
648+ snapshot, err := sessionFromGeneration(snapshotGeneration(publication))
402649 if err != nil || len(snapshot.Tabs) != 1 || len(snapshot.Tabs[0].Panes) == 0 {
403- b.Fatalf("invalid v3 snapshot restore fixture: %v", err)
650+ b.Fatalf("invalid incremental snapshot restore fixture: %v", err)
404651 }
405652 paneSnapshot := snapshot.Tabs[0].Panes[0]
406653 b.ReportAllocs()
407- b.SetBytes(int64(len(encoded)))
654+ b.SetBytes(int64(snapshotPublicationBytes(publication)))
408655 b.ResetTimer()
409656 for range b.N {
410657 if err := restorePaneTerminal(fixture.activePane, paneSnapshot); err != nil {
@@ -422,7 +669,27 @@ func benchmarkDaemonSnapshotRestore(b *testing.B) {
422669 }
423670
424671 func validBenchmarkCapture(capture *snapshotCapture) bool {
425- return capture != nil && len(capture.tabs) > 0 && len(capture.tabs[0].panes) > 0 && capture.tabs[0].panes[0].history.Len() == 10_000 && capture.tabs[0].panes[0].history.ChunkCount() > 0
672+ return capture != nil && len(capture.tabs) > 0 && len(capture.tabs[0].panes) > 0 && capture.tabs[0].panes[0].sealed.Len() == 10_000 && capture.tabs[0].panes[0].sealed.ChunkCount() > 0
673+}
674+
675+func snapshotGeneration(publication ports.SnapshotPublication) ports.SnapshotGeneration {
676+ objects := make(map[ports.SnapshotDigest][]byte, len(publication.Objects))
677+ for _, object := range publication.Objects {
678+ objects[object.Digest] = object.Data
679+ }
680+ return ports.SnapshotGeneration{Name: publication.Name, Generation: publication.Generation, Manifest: publication.Manifest, Objects: objects}
681+}
682+
683+func snapshotObjectBytes(publication ports.SnapshotPublication) uint64 {
684+ var bytes uint64
685+ for _, object := range publication.Objects {
686+ bytes += uint64(len(object.Data))
687+ }
688+ return bytes
689+}
690+
691+func snapshotPublicationBytes(publication ports.SnapshotPublication) uint64 {
692+ return uint64(len(publication.Manifest)) + snapshotObjectBytes(publication)
426693 }
427694
428695 func capturePaneCount(capture *snapshotCapture) int {
@@ -473,7 +740,8 @@ func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations
473740 b.ReportMetric(float64(metrics.outputBytes)/perOperation, "outputbytes/op")
474741 b.ReportMetric(float64(metrics.outputPayloadBytes)/perOperation, "framepayloadbytes/op")
475742 b.ReportMetric(float64(metrics.snapshotWrites)/perOperation, "snapshotwrites/op")
476- b.ReportMetric(float64(metrics.snapshotBytes)/perOperation, "snapshotbytes/op")
743+ b.ReportMetric(float64(metrics.snapshotManifestBytes)/perOperation, "snapshotmanifestbytes/op")
744+ b.ReportMetric(float64(metrics.snapshotObjectBytes)/perOperation, "snapshotobjectbytes/op")
477745 b.ReportMetric(float64(metrics.coordinatorInvalidations)/perOperation, "coordinatorinvalidations/op")
478746 b.ReportMetric(float64(metrics.coordinatorWakes)/perOperation, "coordinatorwakes/op")
479747 b.ReportMetric(float64(metrics.coordinatorCoalesced)/perOperation, "coordinatorcoalesced/op")
@@ -587,7 +855,8 @@ type performanceMetrics struct {
587855 outputBytes uint64
588856 outputPayloadBytes uint64
589857 snapshotWrites uint64
590- snapshotBytes uint64
858+ snapshotManifestBytes uint64
859+ snapshotObjectBytes uint64
591860 coordinatorInvalidations uint64
592861 coordinatorWakes uint64
593862 coordinatorCoalesced uint64
@@ -674,7 +943,7 @@ type performanceFixture struct {
674943 sess *session
675944 ac *attachedClient
676945 output *countingOutputTransport
677- snaps *countingSnapshotStore
946+ snapshots *countingSnapshotRepository
678947 activePane *pane
679948 liveWrites [][]byte
680949 paints int
@@ -727,13 +996,13 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
727996 sess: sess,
728997 ac: ac,
729998 output: output,
730- snaps: &countingSnapshotStore{},
999+ snapshots: &countingSnapshotRepository{},
7311000 pty: pty,
7321001 clock: clock,
7331002 liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")},
7341003 resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size},
7351004 }
736- WithSnapshotStore(fixture.snaps)(d)
1005+ WithSnapshotRepository(fixture.snapshots, nil)(d)
7371006 d.startSnapshotEncodeWorker()
7381007 t.Cleanup(d.stopSnapshotEncodeWorker)
7391008 for tabIndex, tb := range sess.tabs {
@@ -778,7 +1047,7 @@ func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyR
7781047 for _, p := range tb.panes {
7791048 p.mu.Lock()
7801049 width := p.screen.Frame.Width
781- for row := 0; p.history.Len() < historyRows; row++ {
1050+ for row := 0; p.history.Len() < historyRows && row < historyRows+p.screen.Frame.Height; row++ {
7821051 p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n"))
7831052 }
7841053 p.mu.Unlock()
@@ -927,7 +1196,7 @@ func (f *performanceFixture) resized() bool {
9271196
9281197 func (f *performanceFixture) resetMetrics() {
9291198 f.output.reset()
930- f.snaps.reset()
1199+ f.snapshots.reset()
9311200 f.renderCaptures = 0
9321201 f.renderCompositions = 0
9331202 f.renderEmissions = 0
@@ -943,10 +1212,10 @@ func (f *performanceFixture) resetMetrics() {
9431212
9441213 func (f *performanceFixture) metrics() performanceMetrics {
9451214 output := f.output.metrics()
946- snapshots := f.snaps.metrics()
1215+ snapshots := f.snapshots.metrics()
9471216 metrics := performanceMetrics{
9481217 outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes,
949- snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes,
1218+ snapshotWrites: snapshots.writes, snapshotManifestBytes: snapshots.manifestBytes, snapshotObjectBytes: snapshots.objectBytes,
9501219 renderCaptures: f.renderCaptures, renderCompositions: f.renderCompositions, renderEmissions: f.renderEmissions,
9511220 resizeRequests: f.resizeRequests, resizeCommits: f.resizeCommits, resizeCommitNanos: f.resizeCommitNanos,
9521221 ptyFailures: f.ptyFailures, ptyRetries: f.ptyRetries,
@@ -1010,35 +1279,76 @@ func (t *countingOutputTransport) lastPayload() []byte {
10101279 return t.last
10111280 }
10121281
1013-type countingSnapshotMetrics struct{ writes, bytes uint64 }
1014-type countingSnapshotStore struct {
1282+// snapshotHeadBytes is the on-disk VEVH header (magic, generation, and
1283+// manifest digest) written by the filesystem repository for every generation.
1284+const snapshotHeadBytes = 4 + 8 + 32
1285+
1286+type countingSnapshotMetrics struct {
1287+ writes, objectBytes, historyBlobBytes, suppliedObjectBytes, suppliedHistoryBytes, manifestBytes, headBytes uint64
1288+}
1289+type countingSnapshotRepository struct {
10151290 mu sync.Mutex
10161291 countingSnapshotMetrics
1017- last []byte
1292+ objects map[string]map[ports.SnapshotDigest]struct{}
1293+ last []byte
10181294 }
10191295
1020-func (s *countingSnapshotStore) Write(_ string, data []byte) error {
1296+// Publish models the bytes written by the filesystem repository: immutable
1297+// VEVO objects are written once per session and each generation writes a VEVM
1298+// manifest plus the mutable VEVH pointer. It intentionally does not measure
1299+// directory metadata, temporary files, or fsyncs.
1300+func (s *countingSnapshotRepository) Publish(_ context.Context, publication ports.SnapshotPublication) error {
10211301 s.mu.Lock()
1302+ defer s.mu.Unlock()
1303+ if s.objects == nil {
1304+ s.objects = make(map[string]map[ports.SnapshotDigest]struct{})
1305+ }
1306+ objects := s.objects[publication.Name]
1307+ if objects == nil {
1308+ objects = make(map[ports.SnapshotDigest]struct{})
1309+ s.objects[publication.Name] = objects
1310+ }
10221311 s.writes++
1023- s.bytes += uint64(len(data))
1024- s.last = data
1025- s.mu.Unlock()
1312+ s.manifestBytes += uint64(len(publication.Manifest))
1313+ s.headBytes += snapshotHeadBytes
1314+ for _, object := range publication.Objects {
1315+ s.suppliedObjectBytes += uint64(len(object.Data))
1316+ kind, _, err := snapcodec.PreflightObject(object.Data)
1317+ if err == nil && kind == snapcodec.HistoryChunk {
1318+ s.suppliedHistoryBytes += uint64(len(object.Data))
1319+ }
1320+ if _, exists := objects[object.Digest]; exists {
1321+ continue
1322+ }
1323+ objects[object.Digest] = struct{}{}
1324+ s.objectBytes += uint64(len(object.Data))
1325+ if err == nil && kind == snapcodec.HistoryChunk {
1326+ s.historyBlobBytes += uint64(len(object.Data))
1327+ }
1328+ }
1329+ s.last = publication.Manifest
10261330 return nil
10271331 }
1028-func (*countingSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
1029-func (*countingSnapshotStore) Delete(string) error { return nil }
1030-func (s *countingSnapshotStore) reset() {
1332+func (*countingSnapshotRepository) List(context.Context) ([]string, error) { return nil, nil }
1333+func (*countingSnapshotRepository) Load(context.Context, string) (ports.SnapshotGeneration, error) {
1334+ return ports.SnapshotGeneration{}, nil
1335+}
1336+func (*countingSnapshotRepository) Delete(context.Context, string) error { return nil }
1337+func (*countingSnapshotRepository) Tombstone(context.Context, string) error { return nil }
1338+func (*countingSnapshotRepository) DeleteTombstone(context.Context, string) error { return nil }
1339+func (*countingSnapshotRepository) Maintain(context.Context) error { return nil }
1340+func (s *countingSnapshotRepository) reset() {
10311341 s.mu.Lock()
10321342 s.countingSnapshotMetrics = countingSnapshotMetrics{}
10331343 s.last = nil
10341344 s.mu.Unlock()
10351345 }
1036-func (s *countingSnapshotStore) metrics() countingSnapshotMetrics {
1346+func (s *countingSnapshotRepository) metrics() countingSnapshotMetrics {
10371347 s.mu.Lock()
10381348 defer s.mu.Unlock()
10391349 return s.countingSnapshotMetrics
10401350 }
1041-func (s *countingSnapshotStore) lastPayload() []byte {
1351+func (s *countingSnapshotRepository) lastPayload() []byte {
10421352 s.mu.Lock()
10431353 defer s.mu.Unlock()
10441354 return s.last
M internal/usecase/daemon/picker.go +3 -10
@@ -547,17 +547,10 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
547547
548548 func (d *Daemon) killPickerTarget(target picker.Target) error {
549549 if target.Stopped {
550- d.mu.Lock()
551- stopped, ok := d.stopped[target.Name]
552- if ok && !stopped.purging {
553- if err := d.persist.Delete(target.Name); err != nil {
554- d.mu.Unlock()
555- d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)
556- return domain.UserErr(domain.NoticePersistDelete, "couldn't delete stopped session", err)
557- }
558- delete(d.stopped, target.Name)
550+ if err := d.retryStoppedPurge(target.Name); err != nil {
551+ d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)
552+ return domain.UserErr(domain.NoticePersistDelete, "couldn't delete stopped session", err)
559553 }
560- d.mu.Unlock()
561554 return nil
562555 }
563556 d.mu.Lock()
M internal/usecase/daemon/picker_test.go +19 -8
@@ -35,11 +35,21 @@ func (failingDeleteStore) Range(func(k, v []byte) bool) {}
3535 func (failingDeleteStore) Sync() error { return nil }
3636 func (failingDeleteStore) Close() error { return nil }
3737
38-type refusingSnapshotDeleteStore struct{ err error }
38+type refusingSnapshotDeleteRepository struct{ err error }
3939
40-func (refusingSnapshotDeleteStore) Write(string, []byte) error { return nil }
41-func (refusingSnapshotDeleteStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
42-func (s refusingSnapshotDeleteStore) Delete(string) error { return s.err }
40+func (refusingSnapshotDeleteRepository) Publish(context.Context, ports.SnapshotPublication) error {
41+ return nil
42+}
43+func (refusingSnapshotDeleteRepository) List(context.Context) ([]string, error) { return nil, nil }
44+func (refusingSnapshotDeleteRepository) Load(context.Context, string) (ports.SnapshotGeneration, error) {
45+ return ports.SnapshotGeneration{}, nil
46+}
47+func (s refusingSnapshotDeleteRepository) Delete(context.Context, string) error { return s.err }
48+func (refusingSnapshotDeleteRepository) Tombstone(context.Context, string) error { return nil }
49+func (refusingSnapshotDeleteRepository) DeleteTombstone(context.Context, string) error {
50+ return nil
51+}
52+func (refusingSnapshotDeleteRepository) Maintain(context.Context) error { return nil }
4353
4454 func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab {
4555 tb := newTab(p, domain.Size{Cols: 80, Rows: 23})
@@ -796,7 +806,7 @@ func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testin
796806 }
797807
798808 func TestPickerPreviewContainsOnlyVisibleFrameRowsWithLargeScrollback(t *testing.T) {
799- withScrollback := newPickerPreviewTabWithHistoryRows(10_000)
809+ withScrollback := newPickerPreviewTabWithHistoryRows(t, 10_000)
800810 require.Equal(t, 10_000, withScrollback.focusedPane().screen.History().Len())
801811
802812 preview := snapshotPickerPreview(withScrollback)
@@ -820,11 +830,12 @@ func TestPickerPreviewContainsOnlyVisibleFrameRowsWithLargeScrollback(t *testing
820830 require.NotContains(t, got, "history-only-marker")
821831 }
822832
823-func newPickerPreviewTabWithHistoryRows(historyRows int) *tab {
833+func newPickerPreviewTabWithHistoryRows(t testing.TB, historyRows int) *tab {
834+ t.Helper()
824835 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
825836 p := tb.focusedPane()
826837 for range historyRows {
827- p.screen.History().Append(testRow("history-only-marker"))
838+ require.NoError(t, p.screen.History().Append(testRow("history-only-marker")))
828839 }
829840 p.screen.Write([]byte("NOW"))
830841 return tb
@@ -997,7 +1008,7 @@ func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t
9971008 d, from, ac, sends, releases := newRecentNavigationTestSessions(t)
9981009 defer releaseAll(releases)
9991010 cause := errors.New("snapshot delete refused")
1000- WithSnapshotStore(refusingSnapshotDeleteStore{err: cause})(d)
1011+ WithSnapshotRepository(refusingSnapshotDeleteRepository{err: cause}, nil)(d)
10011012 target := d.sessions[domain.SessionID("recent")]
10021013
10031014 d.enterPicker(from, ac)
M internal/usecase/daemon/render_test.go +1 -1
@@ -1289,7 +1289,7 @@ func TestPaintComposesCopyBodyAboveFloating(t *testing.T) {
12891289 defer releaseFloating()
12901290 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
12911291 fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3})
1292- fp.history.Append(testRow("flt-old"))
1292+ require.NoError(t, fp.history.Append(testRow("flt-old")))
12931293 fp.screen.Write([]byte("flt-live"))
12941294 installTestFloating(sess.activeTab(), fp, true)
12951295
M internal/usecase/daemon/session.go +268 -77
@@ -43,6 +43,7 @@ type session struct {
4343 client *attachedClient
4444 clipboardQueue []clipboardForward
4545 clipboardWorkerRunning bool
46+ renameInProgress bool
4647 cwd string
4748 terminal terminalEnv
4849 // env is the authoritative immutable environment snapshot for future PTY children.
@@ -52,14 +53,37 @@ type session struct {
5253 mruAt atomic.Uint64
5354 snapDirty atomic.Bool
5455 snapEligible atomic.Bool
55- // snapshotMu serializes the dirty generation with worker completion. It is
56- // intentionally independent from mu: persistence never holds session state
57- // locks while encoding or writing.
58- snapshotMu sync.Mutex
59- snapshotGeneration uint64
56+ // snapshotMu serializes mutation revisions and repository publication
57+ // generations with worker completion. It is intentionally independent from
58+ // mu: persistence never holds session state locks while encoding or writing.
59+ snapshotMu sync.Mutex
60+ snapshotGeneration uint64 // newest mutation revision
61+ snapshotPublishedGeneration uint64 // newest repository generation
62+ snapshotPublishedMutationRevision uint64
63+ snapshotNextEligibleAt time.Time
64+ // The coordinator state below is guarded by snapshotMu. A capture can be
65+ // queued globally or in flight, never more than one of each per session.
66+ // Quarantine cancels the session publication context before destructive
67+ // repository operations and publicationDone joins a started publication.
6068 snapshotPending bool
6169 snapshotPendingCaptures uint
62- snapshotChanged chan struct{}
70+ // snapshotForcedGeneration is the newest mutation revision a forced
71+ // checkpoint must publish. It survives an older routine capture so worker
72+ // completion can enqueue exactly one forced successor.
73+ snapshotForcedGeneration uint64
74+ snapshotQueuedCapture *snapshotCapture
75+ snapshotInFlightCapture *snapshotCapture
76+ snapshotPublicationDone chan struct{}
77+ snapshotPublicationContext context.Context
78+ snapshotPublicationCancel context.CancelFunc
79+ snapshotQuarantined bool
80+ snapshotQuarantineEpoch uint64
81+ // snapshotChunkCache retains encoded sealed history only for this named
82+ // session. snapshotMu owns it so cache touches never contend with pane
83+ // state or mutate bytes retained by queued/in-flight publications.
84+ snapshotChunkCache *snapshotChunkCache
85+ snapshotChanged chan struct{}
86+ snapshotWake chan struct{}
6387 // syncGen makes synchronized-output watchdog generations unique across all
6488 // panes in this session.
6589 syncGen atomic.Uint64
@@ -193,16 +217,20 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
193217 lastUsedSeq = stopped.lastUsedSeq
194218 }
195219 sess := &session{
196- id: id,
197- name: name,
198- ephemeral: ephemeral,
199- ctx: sctx,
200- cancel: cancel,
201- tabs: tabs,
202- cwd: cwd,
203- terminal: term,
204- env: env,
205- createdAt: createdAt,
220+ id: id,
221+ name: name,
222+ ephemeral: ephemeral,
223+ ctx: sctx,
224+ cancel: cancel,
225+ tabs: tabs,
226+ cwd: cwd,
227+ terminal: term,
228+ env: env,
229+ createdAt: createdAt,
230+ snapshotWake: d.snapshotWake,
231+ }
232+ if !ephemeral && name != "" {
233+ sess.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit)
206234 }
207235 if lastUsedSeq > 0 {
208236 sess.mruAt.Store(lastUsedSeq)
@@ -521,15 +549,27 @@ func (d *Daemon) renameSession(sess *session, name string) error {
521549 if err := domain.ValidateSessionName(name); err != nil {
522550 return err
523551 }
552+
553+ // Reserve the rename while state is inspected, then perform persistence and
554+ // repository I/O without daemon/session locks. The old identity is first
555+ // quarantined and durably purged; committing the new record before that
556+ // transaction completes could leave a restorable old legacy source.
524557 d.mu.Lock()
525- defer d.mu.Unlock()
526558 if taken := d.findByNameLocked(name); taken != nil && taken != sess {
559+ d.mu.Unlock()
527560 return errSessionNameInUse
528561 }
562+ sess.mu.Lock()
563+ if sess.renameInProgress {
564+ sess.mu.Unlock()
565+ d.mu.Unlock()
566+ return errors.New("session rename already in progress")
567+ }
529568 if stopped, ok := d.stopped[name]; ok && stopped.name != sess.name {
569+ sess.mu.Unlock()
570+ d.mu.Unlock()
530571 return errSessionNameInUse
531572 }
532- sess.mu.Lock()
533573 oldName := sess.name
534574 wasEphemeral := sess.ephemeral
535575 createdAt := sess.createdAt
@@ -538,45 +578,79 @@ func (d *Daemon) renameSession(sess *session, name string) error {
538578 createdAt, err = d.allocateLifecycleCreatedAtLocked()
539579 if err != nil {
540580 sess.mu.Unlock()
581+ d.mu.Unlock()
541582 return err
542583 }
543584 }
544585 lastUsedSeq := sess.mruAt.Load()
545- if wasEphemeral || oldName != name {
546- record := sess.persistRecordLocked(d.nowUnixNano())
547- record.Name = name
548- record.CreatedAt = createdAt
549- record.LastUsedSeq = lastUsedSeq
550- if err := d.persist.Save(record); err != nil {
551- sess.mu.Unlock()
552- return err
553- }
586+ record := sess.persistRecordLocked(d.nowUnixNano())
587+ record.Name = name
588+ record.CreatedAt = createdAt
589+ record.LastUsedSeq = lastUsedSeq
590+ sess.renameInProgress = true
591+ sess.mu.Unlock()
592+ d.mu.Unlock()
593+
594+ rollback := func(err error) error {
595+ sess.mu.Lock()
596+ sess.renameInProgress = false
597+ sess.mu.Unlock()
598+ return err
554599 }
600+ var quarantine snapshotCoordinatorQuarantine
555601 if !wasEphemeral && oldName != name {
556- if d.snapsEnabled && d.snaps != nil {
557- if err := d.snaps.Delete(oldName); err != nil {
558- if cleanupErr := d.persist.Delete(name); cleanupErr != nil {
559- d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name)
560- }
561- sess.mu.Unlock()
562- return err
563- }
602+ quarantine = quarantineSnapshotCoordinatorWithEpoch(sess)
603+ <-quarantine.done
604+ if err := d.beginSnapshotPurge(oldName); err != nil {
605+ return rollback(err)
564606 }
565- if err := d.persist.Delete(oldName); err != nil {
566- if cleanupErr := d.persist.Delete(name); cleanupErr != nil {
567- d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name)
568- }
569- sess.mu.Unlock()
570- return err
607+ // finishSnapshotPurge preserves the tombstone through both source
608+ // deletions and the old metadata delete, including a retained legacy
609+ // import whose verified source delete is still pending.
610+ if err := d.finishSnapshotPurge(oldName); err != nil {
611+ return rollback(err)
612+ }
613+ }
614+ // For a named rename this is the durable commit point, deliberately after
615+ // the old tombstone transaction. Ephemeral promotion has no old durable
616+ // identity and can create its first record directly.
617+ if wasEphemeral || oldName != name {
618+ if err := d.persist.Save(record); err != nil {
619+ return rollback(err)
571620 }
572621 }
622+
623+ d.mu.Lock()
624+ sess.mu.Lock()
625+ // The reservation prevents another rename. A closing session is allowed to
626+ // finish this short commit; killSession subsequently owns its teardown.
573627 delete(d.stopped, oldName)
574628 delete(d.stopped, name)
575629 sess.name = name
576630 sess.createdAt = createdAt
577631 sess.ephemeral = false
578- sess.snapEligible.Store(name != "")
632+ sess.renameInProgress = false
633+ // Keep the coordinator in quarantine until the new in-memory identity is
634+ // fully committed. The fresh coordinator below makes the new repository
635+ // start at generation one.
636+ if !wasEphemeral && oldName != name {
637+ sess.snapEligible.Store(false)
638+ } else {
639+ sess.snapEligible.Store(name != "")
640+ }
579641 sess.mu.Unlock()
642+ d.mu.Unlock()
643+ if !wasEphemeral && oldName != name {
644+ // A concurrent teardown may have quarantined after this rename did. In
645+ // that case its newer epoch owns the stopped coordinator.
646+ resumeSnapshotCoordinatorForNewIdentity(sess, quarantine)
647+ return nil
648+ }
649+ if wasEphemeral {
650+ sess.snapshotMu.Lock()
651+ sess.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit)
652+ sess.snapshotMu.Unlock()
653+ }
580654 markSnapshotDirty(sess)
581655 return nil
582656 }
@@ -717,36 +791,155 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
717791 // ptyReader drains child output into the VT screen and pokes the dirty channel
718792 // (non-blocking: a full channel already means a render is pending). On any read
719793
794+// beginSnapshotPurge commits a logical named-session kill before its live
795+// identity or persisted metadata is removed. It never runs under daemon or
796+// session locks.
797+func (d *Daemon) beginSnapshotPurge(name string) error {
798+ if d.snapshotRepository == nil || name == "" {
799+ return nil
800+ }
801+ return d.snapshotRepository.Tombstone(context.Background(), name)
802+}
803+
804+// finishSnapshotPurge deletes both independently durable snapshot sources,
805+// then metadata, and only then clears the tombstone. Any failure deliberately
806+// leaves the marker in place so startup cannot restore or import the name and
807+// a later live/offline kill can retry the idempotent source deletes.
808+func (d *Daemon) finishSnapshotPurge(name string) error {
809+ if d.snapshotRepository == nil {
810+ return d.persist.Delete(name)
811+ }
812+ incrementalErr := d.snapshotRepository.Delete(context.Background(), name)
813+ var legacyErr error
814+ if d.legacySnapshots != nil {
815+ legacyErr = d.legacySnapshots.DeleteLegacy(context.Background(), name)
816+ }
817+ if incrementalErr != nil || legacyErr != nil {
818+ return errors.Join(incrementalErr, legacyErr)
819+ }
820+ if err := d.persist.Delete(name); err != nil {
821+ return err
822+ }
823+ return d.snapshotRepository.DeleteTombstone(context.Background(), name)
824+}
825+
826+// retryStoppedPurge completes a previously closed session's durable purge.
827+// It keeps the stopped record hidden until all sources and metadata cross their
828+// deletion boundaries.
829+func (d *Daemon) retryStoppedPurge(name string) error {
830+ d.mu.Lock()
831+ stopped, ok := d.stopped[name]
832+ if !ok {
833+ d.mu.Unlock()
834+ return nil
835+ }
836+ // Without a repository there is no restore/import source to fence, so the
837+ // stopped record retains the metadata-only deletion behavior.
838+ if d.snapshotRepository == nil {
839+ d.mu.Unlock()
840+ if err := d.persist.Delete(name); err != nil {
841+ return err
842+ }
843+ d.mu.Lock()
844+ delete(d.stopped, name)
845+ d.mu.Unlock()
846+ return nil
847+ }
848+ stopped.purging = true
849+ d.stopped[name] = stopped
850+ d.mu.Unlock()
851+
852+ if err := d.beginSnapshotPurge(name); err != nil {
853+ return err
854+ }
855+ if err := d.finishSnapshotPurge(name); err != nil {
856+ return err
857+ }
858+ d.mu.Lock()
859+ if current, ok := d.stopped[name]; ok && current.purging {
860+ delete(d.stopped, name)
861+ }
862+ d.mu.Unlock()
863+ return nil
864+}
865+
720866 func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
867+ return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil)
868+}
869+
870+// killSessionWithSnapshotDeadline shares Serve's shutdown budget with its
871+// terminal checkpoint and coordinator join. A timed-out repository call keeps
872+// only immutable capture state; it never observes closed worker channels or
873+// session-owned cache that teardown has released.
874+func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, purge bool, deadline *snapshotShutdownDeadline) error {
721875 ringing := sess.anyAttention()
722876 sess.mu.Lock()
723877 isEphemeral := sess.ephemeral
878+ name := sess.name
724879 sess.mu.Unlock()
880+ // Commit the tombstone before changing live identity or metadata. A
881+ // repository delete must never race an older publication that could recreate
882+ // the just-quarantined name. Do not hold daemon or session locks while
883+ // tombstoning or joining: a repository implementation may block on I/O.
884+ if purge && !isEphemeral {
885+ if err := d.beginSnapshotPurge(name); err != nil {
886+ return err
887+ }
888+ if d.snapshotRepository != nil {
889+ <-quarantineSnapshotCoordinator(sess)
890+ }
891+ }
725892 if !purge && !isEphemeral && d.persistEnabled {
726893 d.refreshSessionCwd(sess)
727894 }
728- var snapshotDeleteErr, terminalSnapshotErr error
729- if d.snapsEnabled && !isEphemeral {
730- if purge {
895+ var terminalSnapshotErr error
896+ retainSnapshotRetry := false
897+ if d.snapsEnabled && !isEphemeral && !purge {
898+ markSnapshotDirty(sess)
899+ sess.snapshotMu.Lock()
900+ terminalGeneration := sess.snapshotGeneration
901+ sess.snapshotMu.Unlock()
902+ if !d.scheduleFinalSnapshot(sess) || !d.waitForSnapshotGenerationWithDeadline(sess, terminalGeneration, deadline) {
731903 sess.mu.Lock()
732904 name := sess.name
733905 sess.mu.Unlock()
734- if err := d.snaps.Delete(name); err != nil {
735- snapshotDeleteErr = err
736- d.log.Warn("deleting session snapshot failed", "err", err, "session", name)
906+ terminalSnapshotErr = fmt.Errorf("retain final snapshot for session %q: snapshot worker unavailable, saturated, or timed out", name)
907+ if reason != ports.ReasonServerShutdown {
908+ return terminalSnapshotErr
737909 }
910+ // Preserve any already-queued routine capture until the blocked
911+ // worker can safely discard it. This retains the dirty generation
912+ // and forced retry intent without adding another queue entry.
913+ retainSnapshotRetry = true
914+ d.persistShutdownSnapshotFailure(name, terminalSnapshotErr)
915+ }
916+ }
917+ if !isEphemeral {
918+ // Joining first preserves byte ownership for a publication that is still
919+ // encoding while this session is being torn down. Once Serve's shared
920+ // budget expires, the worker is cancelled by its one final join instead;
921+ // keep the cache reachable because an uncooperative writer may still be
922+ // encoding immutable state.
923+ var quarantineDone <-chan struct{}
924+ if retainSnapshotRetry {
925+ quarantineDone = quarantineSnapshotCoordinatorRetainingQueuedCapture(sess)
738926 } else {
739- markSnapshotDirty(sess)
740- if !d.scheduleFinalSnapshot(sess) {
741- sess.mu.Lock()
742- name := sess.name
743- sess.mu.Unlock()
744- terminalSnapshotErr = fmt.Errorf("retain final snapshot for session %q: snapshot worker unavailable or saturated", name)
745- if reason != ports.ReasonServerShutdown {
746- return terminalSnapshotErr
747- }
748- d.persistShutdownSnapshotFailure(name, terminalSnapshotErr)
927+ quarantineDone = quarantineSnapshotCoordinator(sess)
928+ }
929+ joined := true
930+ if deadline != nil {
931+ select {
932+ case <-quarantineDone:
933+ case <-deadline.Done():
934+ joined = false
749935 }
936+ } else {
937+ <-quarantineDone
938+ }
939+ if joined {
940+ sess.snapshotMu.Lock()
941+ sess.snapshotChunkCache = nil
942+ sess.snapshotMu.Unlock()
750943 }
751944 }
752945 d.mu.Lock()
@@ -778,25 +971,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
778971 d.mu.Unlock()
779972 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)
780973
781- purgeErr := snapshotDeleteErr
782- if !ephemeral && purge {
783- if err := d.persist.Delete(stoppedName); err != nil {
784- purgeErr = err
785- d.log.Warn("deleting persisted session failed", "err", err, "session", stoppedName)
786- d.mu.Lock()
787- if stopped, ok := d.stopped[stoppedName]; ok && stopped.purging {
788- stopped.purging = false
789- d.stopped[stoppedName] = stopped
790- }
791- d.mu.Unlock()
792- } else {
793- d.mu.Lock()
794- if stopped, ok := d.stopped[stoppedName]; ok && stopped.purging {
795- delete(d.stopped, stoppedName)
796- }
797- d.mu.Unlock()
798- }
799- }
974+ var purgeErr error
800975
801976 // A global route that selected this attachment must publish before terminal
802977 // teardown makes it stale.
@@ -840,6 +1015,22 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
8401015 d.log.Warn("removing clipboard temp file failed", "err", err, "path", path)
8411016 }
8421017 }
1018+ // The coordinator and all panes are now stopped, so no producer can publish
1019+ // another generation after this destructive source deletion. Keep the
1020+ // tombstone and hidden stopped record when any source or metadata operation
1021+ // fails; retryStoppedPurge (including a repeated live kill) resumes safely.
1022+ if !ephemeral && purge {
1023+ if err := d.finishSnapshotPurge(stoppedName); err != nil {
1024+ purgeErr = errors.Join(purgeErr, err)
1025+ d.log.Warn("finishing session snapshot purge failed", "err", err, "session", stoppedName)
1026+ } else {
1027+ d.mu.Lock()
1028+ if stopped, ok := d.stopped[stoppedName]; ok && stopped.purging {
1029+ delete(d.stopped, stoppedName)
1030+ }
1031+ d.mu.Unlock()
1032+ }
1033+ }
8431034 if empty {
8441035 d.doneOnce.Do(func() { close(d.done) })
8451036 } else if ringing {
A internal/usecase/daemon/session_purge_control_test.go +51 -0
@@ -0,0 +1,51 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/stretchr/testify/mock"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/ports"
11+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
12+)
13+
14+func TestHandleKillStoppedUsesDurableBothSourcePurge(t *testing.T) {
15+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
16+ repository := &retryablePurgeRepository{legacyErr: errors.New("legacy delete failed")}
17+ WithSnapshotRepository(repository, repository)(d)
18+ d.stopped["work"] = stoppedSession{name: "work"}
19+
20+ transport := portsmocks.NewMockTransport(t)
21+ transport.EXPECT().Send(mock.Anything).Return(nil).Once()
22+ transport.EXPECT().Close().Return(nil).Once()
23+ d.handleKill(transport, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})})
24+
25+ require.Equal(t, []string{"tombstone", "incremental", "legacy"}, repository.calls)
26+ require.True(t, repository.tombstoned["work"])
27+ require.True(t, d.stopped["work"].purging)
28+}
29+
30+func TestStoppedPurgeMetadataFailureRemainsFencedForRetry(t *testing.T) {
31+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
32+ repository := &retryablePurgeRepository{}
33+ WithSnapshotRepository(repository, repository)(d)
34+ store, state := newMockStore(t)
35+ WithStore(store)(d)
36+ metadataErr := errors.New("metadata delete failed")
37+ state.mu.Lock()
38+ state.deleteErr = func(string) error { return metadataErr }
39+ state.mu.Unlock()
40+ d.stopped["work"] = stoppedSession{name: "work"}
41+
42+ require.ErrorIs(t, d.retryStoppedPurge("work"), metadataErr)
43+ require.True(t, repository.tombstoned["work"])
44+ require.Equal(t, []string{"tombstone", "incremental", "legacy"}, repository.calls)
45+
46+ state.mu.Lock()
47+ state.deleteErr = nil
48+ state.mu.Unlock()
49+ require.NoError(t, d.retryStoppedPurge("work"))
50+ require.Equal(t, []string{"tombstone", "incremental", "legacy", "tombstone", "incremental", "legacy", "clear tombstone"}, repository.calls)
51+}
A internal/usecase/daemon/session_purge_retry_test.go +97 -0
@@ -0,0 +1,97 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
13+)
14+
15+type retryablePurgeRepository struct {
16+ incrementalErr error
17+ legacyErr error
18+ tombstoned map[string]bool
19+ calls []string
20+}
21+
22+func (r *retryablePurgeRepository) Publish(context.Context, ports.SnapshotPublication) error {
23+ return nil
24+}
25+func (r *retryablePurgeRepository) List(context.Context) ([]string, error) { return nil, nil }
26+func (r *retryablePurgeRepository) Load(context.Context, string) (ports.SnapshotGeneration, error) {
27+ return ports.SnapshotGeneration{}, errors.New("unused")
28+}
29+func (r *retryablePurgeRepository) Maintain(context.Context) error { return nil }
30+func (r *retryablePurgeRepository) Delete(_ context.Context, name string) error {
31+ r.calls = append(r.calls, "incremental")
32+ return r.incrementalErr
33+}
34+func (r *retryablePurgeRepository) LoadLegacy(context.Context) ([]ports.LegacySnapshot, error) {
35+ return nil, nil
36+}
37+func (r *retryablePurgeRepository) DeleteVerifiedLegacy(ctx context.Context, blob ports.LegacySnapshot) error {
38+ return r.DeleteLegacy(ctx, blob.Name)
39+}
40+func (r *retryablePurgeRepository) DeleteLegacy(_ context.Context, name string) error {
41+ r.calls = append(r.calls, "legacy")
42+ return r.legacyErr
43+}
44+func (r *retryablePurgeRepository) Tombstone(_ context.Context, name string) error {
45+ if r.tombstoned == nil {
46+ r.tombstoned = make(map[string]bool)
47+ }
48+ r.calls = append(r.calls, "tombstone")
49+ r.tombstoned[name] = true
50+ return nil
51+}
52+func (r *retryablePurgeRepository) DeleteTombstone(_ context.Context, name string) error {
53+ r.calls = append(r.calls, "clear tombstone")
54+ delete(r.tombstoned, name)
55+ return nil
56+}
57+
58+func TestLivePurgeRetainsTombstoneAcrossPartialSourceDeletion(t *testing.T) {
59+ for _, tt := range []struct {
60+ name string
61+ incremental error
62+ legacy error
63+ }{
64+ {name: "incremental failure", incremental: errors.New("incremental failed")},
65+ {name: "legacy failure", legacy: errors.New("legacy failed")},
66+ } {
67+ t.Run(tt.name, func(t *testing.T) {
68+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
69+ repository := &retryablePurgeRepository{incrementalErr: tt.incremental, legacyErr: tt.legacy}
70+ WithSnapshotRepository(repository, repository)(d)
71+ sess := newSnapshotTestSession(t, "work", false, "/work")
72+ pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
73+ require.True(t, ok, "snapshot test pane must use MockPTY")
74+ pty.EXPECT().Close().Return(nil).Once()
75+ d.sessions = map[domain.SessionID]*session{sess.id: sess}
76+
77+ require.Error(t, d.killSession(sess, ports.ReasonSessionKilled, true))
78+ require.True(t, repository.tombstoned["work"], "partial purge must fence startup restore/import")
79+ require.Equal(t, []string{"tombstone", "incremental", "legacy"}, repository.calls)
80+ d.mu.Lock()
81+ stopped, ok := d.stopped["work"]
82+ d.mu.Unlock()
83+ require.True(t, ok)
84+ require.True(t, stopped.purging)
85+
86+ repository.incrementalErr = nil
87+ repository.legacyErr = nil
88+ require.NoError(t, d.retryStoppedPurge("work"))
89+ require.False(t, repository.tombstoned["work"])
90+ require.Equal(t, []string{"tombstone", "incremental", "legacy", "tombstone", "incremental", "legacy", "clear tombstone"}, repository.calls)
91+ d.mu.Lock()
92+ _, ok = d.stopped["work"]
93+ d.mu.Unlock()
94+ require.False(t, ok, "metadata is removed only after both source deletes and tombstone clear")
95+ })
96+ }
97+}
A internal/usecase/daemon/session_rename_purge_test.go +45 -0
@@ -0,0 +1,45 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
11+)
12+
13+func TestRenamePurgesOldIncrementalAndLegacyBeforeCommittingNewIdentity(t *testing.T) {
14+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
15+ repository := &retryablePurgeRepository{}
16+ WithSnapshotRepository(repository, repository)(d)
17+ store, state := newMockStore(t)
18+ WithStore(store)(d)
19+ sess := newSnapshotTestSession(t, "old", false, "/work")
20+ d.sessions = map[domain.SessionID]*session{sess.id: sess}
21+ require.NoError(t, d.persist.Save(sess.persistRecordLocked(1)))
22+
23+ require.NoError(t, d.renameSession(sess, "new"))
24+ require.Equal(t, []string{"tombstone", "incremental", "legacy", "clear tombstone"}, repository.calls)
25+ require.False(t, state.has("old"))
26+ require.True(t, state.has("new"))
27+ require.False(t, repository.tombstoned["old"])
28+}
29+
30+func TestRenameLegacyFailureLeavesOldIdentityFenced(t *testing.T) {
31+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
32+ repository := &retryablePurgeRepository{legacyErr: errors.New("legacy delete failed")}
33+ WithSnapshotRepository(repository, repository)(d)
34+ store, state := newMockStore(t)
35+ WithStore(store)(d)
36+ sess := newSnapshotTestSession(t, "old", false, "/work")
37+ d.sessions = map[domain.SessionID]*session{sess.id: sess}
38+ require.NoError(t, d.persist.Save(sess.persistRecordLocked(1)))
39+
40+ require.Error(t, d.renameSession(sess, "new"))
41+ require.Equal(t, []string{"tombstone", "incremental", "legacy"}, repository.calls)
42+ require.True(t, repository.tombstoned["old"])
43+ require.True(t, state.has("old"))
44+ require.False(t, state.has("new"))
45+}
M internal/usecase/daemon/session_test.go +23 -9
@@ -674,12 +674,14 @@ func TestServeReturnsDespiteWedgedClientOnShutdown(t *testing.T) {
674674 }
675675
676676 type mockStoreState struct {
677- mu sync.Mutex
678- data map[string][]byte
679- sets int
680- dels []string
681- syncs int
682- closed bool
677+ mu sync.Mutex
678+ data map[string][]byte
679+ sets int
680+ dels []string
681+ deleteErr func(string) error
682+ deleteDone chan struct{}
683+ syncs int
684+ closed bool
683685 }
684686
685687 func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) {
@@ -701,9 +703,21 @@ func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) {
701703 }).Maybe()
702704 store.EXPECT().Delete(mock.Anything).RunAndReturn(func(k []byte) error {
703705 state.mu.Lock()
704- defer state.mu.Unlock()
705- state.dels = append(state.dels, string(k))
706- delete(state.data, string(k))
706+ key := string(k)
707+ state.dels = append(state.dels, key)
708+ if state.deleteErr != nil {
709+ if err := state.deleteErr(key); err != nil {
710+ state.mu.Unlock()
711+ return err
712+ }
713+ }
714+ delete(state.data, key)
715+ deleteDone := state.deleteDone
716+ state.deleteDone = nil
717+ state.mu.Unlock()
718+ if deleteDone != nil {
719+ close(deleteDone)
720+ }
707721 return nil
708722 }).Maybe()
709723 store.EXPECT().Range(mock.Anything).Run(func(fn func(k, v []byte) bool) {
M internal/usecase/daemon/snapshot.go +55 -780
@@ -2,12 +2,11 @@ package daemon
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
6- "math"
77 "sync"
88 "time"
99
10- "github.com/bnema/vev/internal/domain"
1110 "github.com/bnema/vev/internal/usecase/layout"
1211 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
1312 "github.com/bnema/vev/pkg/vt"
@@ -22,14 +21,29 @@ const snapshotQueueCapacity = 1
2221 // (and left dirty) rather than silently dropping a persistence acknowledgement.
2322 const snapshotFinalQueueCapacity = 32
2423
24+// snapshotAttemptKind distinguishes rate-limited routine publications from
25+// mandatory terminal checkpoints. Forced attempts never consume the routine
26+// completion window.
27+type snapshotAttemptKind uint8
28+
29+const (
30+ snapshotAttemptRoutine snapshotAttemptKind = iota
31+ snapshotAttemptForced
32+)
33+
2534 type snapshotCapture struct {
26- session *session
27- generation uint64
28- name string
29- createdAt uint64
30- active uint16
31- tabs []snapshotCaptureTab
32- finishOnce sync.Once
35+ session *session
36+ attemptKind snapshotAttemptKind
37+ generation uint64 // repository publication generation
38+ mutationRevision uint64
39+ name string
40+ createdAt uint64
41+ active uint16
42+ tabs []snapshotCaptureTab
43+ publicationContext context.Context
44+ sealedRefs map[*vt.HistoryChunk]snapcodec.ObjectRef // set by the single encoder worker
45+ coordinatorDiscarded bool // guarded by session.snapshotMu
46+ finishOnce sync.Once
3347 }
3448
3549 type snapshotCaptureTab struct {
@@ -43,779 +57,40 @@ type snapshotCaptureTab struct {
4357 }
4458
4559 type snapshotCapturePane struct {
46- id layout.PaneID
47- stableID string
48- cwd string
49- history vt.HistoryView
50- visible []byte
51- visibleErr error
52- process *snapcodec.Process
60+ id layout.PaneID
61+ stableID string
62+ cwd string
63+ // Sealed identities and the copied mutable tail are retained separately, so
64+ // capture never rotates a live mutable tail merely to persist it.
65+ sealed vt.HistorySnapshotView
66+ tail vt.HistoryView
67+ // visible is an owned copy taken while pane.mu is held. It must be
68+ // marshaled only after that lock has been released.
69+ visible vt.PrimaryVisibleSnapshot
70+ process *snapcodec.Process
71+}
72+
73+// snapshotFailureSignature classifies a persistence failure without retaining
74+// error text. Error strings often contain object content or filesystem paths,
75+// neither of which is stable enough for deduplication or suitable for a global
76+// notice history.
77+func snapshotFailureSignature(phase string, err error) string {
78+ if err == nil {
79+ return phase + ":none"
80+ }
81+ switch {
82+ case errors.Is(err, context.Canceled):
83+ return phase + ":canceled"
84+ case errors.Is(err, context.DeadlineExceeded):
85+ return phase + ":deadline"
86+ default:
87+ return fmt.Sprintf("%s:%T", phase, err)
88+ }
5389 }
5490
5591 const (
56- snapshotInterval = 30 * time.Second
92+ // Routine checkpoints are rate limited from completion; forced teardown
93+ // checkpoints use the same bounded worker but never consume this window.
94+ snapshotInterval = 2 * time.Minute
5795 snapshotFinalFlushTimeout = time.Second
5896 )
59-
60-func (d *Daemon) closeRestoreDone() {
61- d.restoreOnce.Do(func() { close(d.restoreDone) })
62-}
63-
64-func (d *Daemon) restoreSnapshots(ctx context.Context) {
65- if d == nil {
66- return
67- }
68- defer d.closeRestoreDone()
69- if ns := d.noticeStore; ns != nil {
70- claimed, err := ns.Claim()
71- if err != nil {
72- d.log.Warn("claiming pending notices failed", "err", err)
73- } else {
74- for _, n := range claimed {
75- d.notices.record(n)
76- d.notices.queueGlobal(n)
77- }
78- // Do not discard the claim until every notice has been recorded and
79- // queued. An Ack failure is safe: the claim is replayed at startup.
80- if err := ns.Ack(); err != nil {
81- d.log.Warn("acknowledging pending notices failed", "err", err)
82- }
83- }
84- }
85- if !d.snapsEnabled || d.snaps == nil {
86- return
87- }
88- blobs, err := d.snaps.Load()
89- if err != nil {
90- d.log.Warn("loading session snapshots failed", "err", err)
91- d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore,
92- "couldn't load saved sessions after restart", err)
93- return
94- }
95- for _, blob := range blobs {
96- select {
97- case <-ctx.Done():
98- return
99- default:
100- }
101- snap, err := snapcodec.Unmarshal(blob.Data)
102- if err != nil {
103- d.log.Warn("decoding session snapshot failed", "err", err, "session", blob.Name)
104- d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore,
105- "couldn't restore session "+blob.Name+" after restart", err)
106- continue
107- }
108- if err := d.restoreSession(ctx, snap); err != nil {
109- d.log.Warn("restoring session snapshot failed", "err", err, "session", snap.Name)
110- if ctx.Err() == nil {
111- d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore,
112- "couldn't restore session "+snap.Name+" after restart", err)
113- }
114- }
115- }
116-}
117-
118-func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) error {
119- if err := ctx.Err(); err != nil {
120- return err
121- }
122- if len(snap.Tabs) == 0 {
123- if snap.Active != 0 {
124- return fmt.Errorf("snapshot: active tab out of range")
125- }
126- } else if int(snap.Active) >= len(snap.Tabs) {
127- return fmt.Errorf("snapshot: active tab out of range")
128- }
129- if snap.Name == "" {
130- return fmt.Errorf("snapshot: empty session name")
131- }
132- if snap.CreatedAt > math.MaxInt64 {
133- return fmt.Errorf("snapshot: created_at overflows int64")
134- }
135- d.mu.Lock()
136- if d.closing || d.findByNameLocked(snap.Name) != nil {
137- d.mu.Unlock()
138- return nil
139- }
140- d.mu.Unlock()
141-
142- sctx, cancel := context.WithCancel(d.serveCtx)
143- opened := make([]*tab, 0, len(snap.Tabs))
144- // Snapshot restore runs before any client Hello is available. Start panes
145- // from the daemon environment captured at startup; the next attach replaces
146- // this snapshot for future panes from the client's capability.
147- restoreTerm := terminalEnv{}
148- restoreEnv := copyEnvironment(d.baseEnv)
149- closeOpened := func() {
150- for _, tb := range opened {
151- tb.closeAllPanes()
152- }
153- cancel()
154- }
155- allowlist := d.restoreProcessAllowlistSnapshot()
156- d.mu.Lock()
157- persisted := d.stopped[snap.Name]
158- d.mu.Unlock()
159- for tabIndex, tabSnap := range snap.Tabs {
160- if err := ctx.Err(); err != nil {
161- closeOpened()
162- return err
163- }
164- tbSize := domain.Size{Cols: int(tabSnap.Cols), Rows: int(tabSnap.Rows)}
165- if !tbSize.Valid() {
166- closeOpened()
167- return fmt.Errorf("snapshot: invalid tab size")
168- }
169- placements, ok := layout.Solve(tabSnap.Tree.Root, domain.Rect{Width: tbSize.Cols, Height: tbSize.Rows})
170- if !ok {
171- closeOpened()
172- return fmt.Errorf("snapshot: unsolvable layout")
173- }
174- placementByPane := make(map[layout.PaneID]domain.Rect, len(placements))
175- for _, pl := range placements {
176- placementByPane[pl.ID] = pl.Content
177- }
178- tabStableID := tabSnap.StableID
179- if tabStableID == "" {
180- var err error
181- tabStableID, err = newStableID("t")
182- if err != nil {
183- closeOpened()
184- return fmt.Errorf("snapshot: generating tab identity: %w", err)
185- }
186- }
187- tb := &tab{stableID: tabStableID, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize}
188- if tabIndex < len(persisted.tabNames) {
189- tb.name = persisted.tabNames[tabIndex]
190- }
191- if tb.nextPaneID <= 0 {
192- tb.nextPaneID = 1
193- }
194- tb.ctx, tb.cancel = context.WithCancel(sctx)
195- opened = append(opened, tb)
196- for _, paneSnap := range tabSnap.Panes {
197- restoreCommand := ""
198- if decision := planProcessRestore(paneSnap.Process, allowlist); decision.Restore {
199- restoreCommand = decision.Command
200- }
201- if err := ctx.Err(); err != nil {
202- closeOpened()
203- return err
204- }
205- contentRect, ok := placementByPane[paneSnap.ID]
206- if !ok {
207- closeOpened()
208- return fmt.Errorf("snapshot: missing pane placement")
209- }
210- contentSize := restorePTYSize(contentRect, tbSize)
211- paneStableID := paneSnap.StableID
212- if paneStableID == "" {
213- var err error
214- paneStableID, err = newStableID("p")
215- if err != nil {
216- closeOpened()
217- return fmt.Errorf("snapshot: generating pane identity: %w", err)
218- }
219- }
220- command, args := d.ptyCommand(restoreEnv)
221- pty, err := d.ptys.Open(ctx, command, args, childEnvFrom(restoreEnv, snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize)
222- if err != nil {
223- closeOpened()
224- return err
225- }
226- p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, contentSize)
227- p.ctx, p.cancel = context.WithCancel(tb.ctx)
228- p.rect = contentRect
229- if err := restorePaneTerminal(p, paneSnap); err != nil {
230- // p is not in tb.panes yet, so closeOpened cannot reach it.
231- p.cancel()
232- _ = pty.Close()
233- closeOpened()
234- return err
235- }
236- if restoreCommand != "" {
237- if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil {
238- d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID)
239- d.NotifyGlobal(domain.NoticeWarn, domain.NoticeAutoResume,
240- "couldn't restore the running program in session "+snap.Name, err)
241- }
242- }
243- tb.panes[paneSnap.ID] = p
244- }
245- }
246- if len(opened) == 0 {
247- cancel()
248- return nil
249- }
250- createdAt := int64(snap.CreatedAt)
251- sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), terminal: restoreTerm, env: restoreEnv, createdAt: createdAt}
252- sess.snapEligible.Store(true)
253- if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {
254- sess.cwd = snap.Tabs[0].Panes[0].Cwd
255- }
256- d.mu.Lock()
257- if stopped, ok := d.stopped[snap.Name]; ok {
258- sess.mruAt.Store(stopped.lastUsedSeq)
259- }
260- d.mu.Unlock()
261- record := sess.persistRecordLocked(createdAt)
262- if err := d.persist.Save(record); err != nil {
263- closeOpened()
264- return err
265- }
266- d.mu.Lock()
267- if d.closing || d.findByNameLocked(snap.Name) != nil || ctx.Err() != nil {
268- d.mu.Unlock()
269- closeOpened()
270- return nil
271- }
272- id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
273- d.nextID++
274- sess.id = id
275- delete(d.stopped, snap.Name)
276- d.sessions[id] = sess
277- d.mu.Unlock()
278- sess.snapDirty.Store(false)
279- for _, tb := range opened {
280- d.startTabGoroutines(sess, tb)
281- }
282- return nil
283-}
284-
285-func markSnapshotDirty(sess *session) {
286- if sess == nil || !sess.snapEligible.Load() {
287- return
288- }
289- sess.snapshotMu.Lock()
290- sess.snapshotGeneration++
291- sess.snapDirty.Store(true)
292- sess.signalSnapshotChangedLocked()
293- sess.snapshotMu.Unlock()
294-}
295-
296-// snapshotChangeLocked returns a channel closed by the next snapshot state
297-// transition. snapshotMu must be held.
298-func (sess *session) snapshotChangeLocked() chan struct{} {
299- if sess.snapshotChanged == nil {
300- sess.snapshotChanged = make(chan struct{})
301- }
302- return sess.snapshotChanged
303-}
304-
305-// signalSnapshotChangedLocked wakes waiters after a snapshot state transition.
306-// snapshotMu must be held.
307-func (sess *session) signalSnapshotChangedLocked() {
308- close(sess.snapshotChangeLocked())
309- sess.snapshotChanged = make(chan struct{})
310-}
311-
312-func restorePaneTerminal(p *pane, snap snapcodec.Pane) error {
313- if len(snap.Tail) == 0 {
314- return fmt.Errorf("snapshot history: missing tail blob")
315- }
316- if len(snap.Visible) == 0 {
317- return fmt.Errorf("snapshot visible: missing visible blob")
318- }
319- p.mu.Lock()
320- defer p.mu.Unlock()
321- screen, err := vt.NewScreenWithRestoredHistory(p.screen.Frame.Width, p.screen.Frame.Height, vt.HistoryConfig{MaxRows: defaultScrollbackRows}, snap.SealedChunks, snap.Tail)
322- if err != nil {
323- return fmt.Errorf("snapshot history: %w", err)
324- }
325- if err := screen.RestorePrimaryVisible(snap.Visible); err != nil {
326- return fmt.Errorf("snapshot visible: %w", err)
327- }
328- p.screen = screen
329- p.history = screen.History()
330- return nil
331-}
332-
333-func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size {
334- cols := contentRect.Width
335- if cols <= 0 {
336- cols = tabSize.Cols
337- }
338- if cols < layout.MinPaneCols {
339- cols = layout.MinPaneCols
340- }
341- rows := contentRect.Height
342- if rows <= 0 {
343- rows = min(max(tabSize.Rows, layout.MinPaneRows), 24)
344- }
345- if rows < layout.MinPaneRows {
346- rows = layout.MinPaneRows
347- }
348- return domain.Size{Cols: cols, Rows: rows}
349-}
350-
351-// scheduleSnapshot captures a session only once while an immutable capture is
352-// queued or being written. It never waits for the bounded worker queue.
353-func (d *Daemon) scheduleSnapshot(sess *session) bool {
354- return d.scheduleSnapshotWithFinalFallback(sess, false)
355-}
356-
357-// scheduleFinalSnapshot captures the terminal state even when an older capture
358-// is pending. A removed session cannot be retried by snapshotSaver, so the
359-// worker drains its retained terminal state before it stops.
360-func (d *Daemon) scheduleFinalSnapshot(sess *session) bool {
361- return d.scheduleSnapshotWithFinalFallback(sess, true)
362-}
363-
364-func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bool {
365- if d == nil || sess == nil || !d.snapsEnabled || d.snaps == nil || !sess.snapEligible.Load() {
366- return true
367- }
368- sess.snapshotMu.Lock()
369- if !sess.snapDirty.Load() || (!final && sess.snapshotPending) {
370- sess.snapshotMu.Unlock()
371- return true
372- }
373- generation := sess.snapshotGeneration
374- sess.snapshotPendingCaptures++
375- sess.snapshotPending = true
376- sess.signalSnapshotChangedLocked()
377- sess.snapshotMu.Unlock()
378-
379- capture, ok := d.captureSnapshotState(sess, generation)
380- if !ok {
381- d.finishSnapshotCapture(capture, false)
382- return false
383- }
384- if d.enqueueSnapshotCapture(capture) || (final && d.enqueueFinalSnapshotCapture(capture)) {
385- return true
386- }
387- // Coalesce under saturation or shutdown: the latest state stays dirty and
388- // will be captured on a later tick once an active worker has room.
389- d.finishSnapshotCapture(capture, false)
390- return false
391-}
392-
393-// enqueueSnapshotCapture serializes worker shutdown with the non-blocking
394-// queue send. The queue is deliberately never closed: producers can race
395-// shutdown without risking a send-on-closed panic.
396-func (d *Daemon) enqueueSnapshotCapture(capture *snapshotCapture) bool {
397- d.snapshotWorkerMu.Lock()
398- defer d.snapshotWorkerMu.Unlock()
399- if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
400- return false
401- }
402- select {
403- case d.snapshotJobs <- capture:
404- return true
405- default:
406- return false
407- }
408-}
409-
410-// enqueueFinalSnapshotCapture hands terminal captures to the existing worker
411-// without making session teardown wait behind its normal bounded queue. A
412-// blocked worker retains at most one terminal capture per session, replacing
413-// stale state with the newest immutable capture.
414-func (d *Daemon) enqueueFinalSnapshotCapture(capture *snapshotCapture) bool {
415- if capture == nil || capture.session == nil {
416- return false
417- }
418- d.snapshotWorkerMu.Lock()
419- if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
420- d.snapshotWorkerMu.Unlock()
421- return false
422- }
423- if d.snapshotFinalJobs == nil {
424- d.snapshotFinalJobs = make(map[*session]*snapshotCapture)
425- }
426- replaced, exists := d.snapshotFinalJobs[capture.session]
427- if !exists && len(d.snapshotFinalJobs) >= snapshotFinalQueueCapacity {
428- d.snapshotWorkerMu.Unlock()
429- d.log.Warn("terminal snapshot retention saturated; capture rejected", "session", capture.name, "capacity", snapshotFinalQueueCapacity)
430- return false
431- }
432- // Finish the stale capture before publishing its replacement. Otherwise a
433- // fast worker could persist the replacement and then have this stale failure
434- // mark the session dirty again.
435- d.finishSnapshotCapture(replaced, false)
436- if !exists {
437- d.snapshotFinalOrder = append(d.snapshotFinalOrder, capture.session)
438- }
439- d.snapshotFinalJobs[capture.session] = capture
440- select {
441- case d.snapshotWorkerFinalWake <- struct{}{}:
442- default:
443- }
444- d.snapshotWorkerMu.Unlock()
445- return true
446-}
447-
448-// captureSession rotates history tails and clones visible frames while holding
449-// each pane lock. The returned capture contains only immutable state; encoding
450-// and persistence are deliberately deferred to snapshotEncodeWorker.
451-func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapshotCapture, bool) {
452- sess.mu.Lock()
453- capture := &snapshotCapture{
454- session: sess,
455- generation: generation,
456- name: sess.name,
457- createdAt: uint64(sess.createdAt),
458- active: uint16(max(sess.active, 0)),
459- }
460- ephemeral := sess.ephemeral
461- fallbackCwd := sess.cwd
462- tabs := append([]*tab(nil), sess.tabs...)
463- sess.mu.Unlock()
464- if ephemeral || capture.name == "" {
465- return capture, false
466- }
467-
468- capture.tabs = make([]snapshotCaptureTab, 0, len(tabs))
469- for _, tb := range tabs {
470- tb.mu.Lock()
471- tabCapture := snapshotCaptureTab{
472- stableID: tb.stableID,
473- cols: uint16(max(tb.size.Cols, 0)),
474- rows: uint16(max(tb.size.Rows, 0)),
475- nextPaneID: uint64(max(tb.nextPaneID, 0)),
476- tree: tb.tree.Clone(),
477- }
478- if tb.tree != nil {
479- tabCapture.focus = tb.tree.Focus
480- }
481- panes := make([]*pane, 0, len(tb.panes))
482- for _, p := range tb.panes {
483- panes = append(panes, p)
484- }
485- tb.mu.Unlock()
486-
487- tabCapture.panes = make([]snapshotCapturePane, 0, len(panes))
488- for _, p := range panes {
489- p.mu.Lock()
490- pty := p.pty
491- pid := 0
492- if pty != nil {
493- pid = pty.Pid()
494- }
495- visible, visibleErr := p.screen.MarshalPrimaryVisible()
496- paneCapture := snapshotCapturePane{
497- id: p.id,
498- stableID: p.stableID,
499- history: p.history.SealAndView(),
500- visible: visible,
501- visibleErr: visibleErr,
502- }
503- p.mu.Unlock()
504- paneCapture.cwd = fallbackCwd
505- if d.procCwd != nil && pid > 0 {
506- if cwd, err := d.procCwd(pid); err == nil && cwd != "" {
507- paneCapture.cwd = cwd
508- }
509- }
510- paneCapture.process = d.capturePaneProcess(pty, pid)
511- tabCapture.panes = append(tabCapture.panes, paneCapture)
512- }
513- capture.tabs = append(capture.tabs, tabCapture)
514- }
515- return capture, true
516-}
517-
518-// captureSession remains the synchronous producer-facing trigger for callers
519-// such as teardown and benchmarks; the actual encoding and Write stay async.
520-func (d *Daemon) captureSession(sess *session) bool {
521- markSnapshotDirty(sess)
522- return d.scheduleSnapshot(sess)
523-}
524-
525-func (d *Daemon) encodeSnapshotCapture(capture *snapshotCapture) ([]byte, error) {
526- snap := snapcodec.Session{Name: capture.name, CreatedAt: capture.createdAt, Active: capture.active, Tabs: make([]snapcodec.Tab, 0, len(capture.tabs))}
527- for _, tabCapture := range capture.tabs {
528- tabSnap := snapcodec.Tab{
529- StableID: tabCapture.stableID, Cols: tabCapture.cols, Rows: tabCapture.rows,
530- NextPaneID: tabCapture.nextPaneID, Focus: tabCapture.focus, Tree: tabCapture.tree,
531- Panes: make([]snapcodec.Pane, 0, len(tabCapture.panes)),
532- }
533- paneIDs := make(map[layout.PaneID]struct{}, len(tabCapture.panes))
534- for _, paneCapture := range tabCapture.panes {
535- sealed, tail, historyErr := vt.MarshalSealedHistory(paneCapture.history)
536- if historyErr != nil || paneCapture.visibleErr != nil {
537- return nil, fmt.Errorf("terminal snapshot pane %s: history=%w visible=%w", paneCapture.id, historyErr, paneCapture.visibleErr)
538- }
539- tabSnap.Panes = append(tabSnap.Panes, snapcodec.Pane{ID: paneCapture.id, StableID: paneCapture.stableID, Cwd: paneCapture.cwd, SealedChunks: sealed, Tail: tail, Visible: paneCapture.visible, Process: paneCapture.process})
540- paneIDs[paneCapture.id] = struct{}{}
541- }
542- if tabSnap.Tree != nil {
543- pruneTreeToPanes(tabSnap.Tree.Root, paneIDs)
544- }
545- snap.Tabs = append(snap.Tabs, tabSnap)
546- }
547- return d.snapshotMarshal(snap)
548-}
549-
550-func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool) {
551- if capture == nil {
552- return
553- }
554- capture.finishOnce.Do(func() {
555- capture.session.snapshotMu.Lock()
556- if capture.session.snapshotPendingCaptures > 0 {
557- capture.session.snapshotPendingCaptures--
558- }
559- capture.session.snapshotPending = capture.session.snapshotPendingCaptures > 0
560- if succeeded && capture.session.snapshotGeneration == capture.generation {
561- capture.session.snapDirty.Store(false)
562- } else if !succeeded {
563- capture.session.snapDirty.Store(true)
564- }
565- capture.session.signalSnapshotChangedLocked()
566- capture.session.snapshotMu.Unlock()
567- })
568-}
569-
570-func (d *Daemon) startSnapshotEncodeWorker() {
571- if d == nil {
572- return
573- }
574- d.snapshotWorkerMu.Lock()
575- if d.snapshotWorkerCancel != nil {
576- d.snapshotWorkerMu.Unlock()
577- return
578- }
579- // Shutdown owns the worker lifetime so it can flush captures after Serve
580- // cancels its parent context. The worker is always stopped explicitly.
581- workerCtx, cancel := context.WithCancel(context.Background())
582- d.snapshotWorkerID++
583- workerID := d.snapshotWorkerID
584- d.snapshotWorkerCtx = workerCtx
585- d.snapshotWorkerCancel = cancel
586- d.snapshotWorkerDone = make(chan struct{})
587- d.snapshotWorkerFlush = make(chan struct{})
588- d.snapshotWorkerFinalWake = make(chan struct{}, 1)
589- d.snapshotFinalJobs = make(map[*session]*snapshotCapture)
590- d.snapshotFinalOrder = nil
591- d.snapshotWorkerClosing = false
592- d.snapshotWorkerInFlight = nil
593- done := d.snapshotWorkerDone
594- flush := d.snapshotWorkerFlush
595- finalWake := d.snapshotWorkerFinalWake
596- d.snapshotWorkerMu.Unlock()
597- go func() {
598- defer close(done)
599- write := func(capture *snapshotCapture) bool {
600- if workerCtx.Err() != nil || !d.setSnapshotWorkerInFlight(workerID, capture) {
601- d.finishSnapshotCapture(capture, false)
602- return false
603- }
604- data, err := d.encodeSnapshotCapture(capture)
605- if err == nil && workerCtx.Err() == nil {
606- err = d.snaps.Write(capture.name, data)
607- }
608- d.clearSnapshotWorkerInFlight(workerID, capture)
609- if err != nil && workerCtx.Err() == nil {
610- d.log.Warn("writing session snapshot failed", "err", err, "session", capture.name)
611- // Global, not session-scoped: by now the session may already be
612- // torn down, so a session notice would be dead-on-arrival. No lock
613- // is held here (clearSnapshotWorkerInFlight released its own).
614- d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotWrite,
615- "couldn't save session "+capture.name+"; recent state may be lost on restart", err)
616- }
617- d.finishSnapshotCapture(capture, err == nil && workerCtx.Err() == nil)
618- return workerCtx.Err() == nil
619- }
620- drainFinal := func() bool {
621- for {
622- capture := d.takeFinalSnapshotCapture()
623- if capture == nil {
624- return true
625- }
626- if !write(capture) {
627- return false
628- }
629- }
630- }
631- flushQueued := func() {
632- for {
633- select {
634- case capture := <-d.snapshotJobs:
635- if !write(capture) {
636- return
637- }
638- default:
639- _ = drainFinal()
640- return
641- }
642- }
643- }
644- for {
645- select {
646- case <-workerCtx.Done():
647- return
648- case <-flush:
649- flushQueued()
650- return
651- case <-finalWake:
652- if !drainFinal() {
653- return
654- }
655- case capture := <-d.snapshotJobs:
656- if !write(capture) {
657- return
658- }
659- }
660- }
661- }()
662-}
663-
664-func (d *Daemon) takeFinalSnapshotCapture() *snapshotCapture {
665- d.snapshotWorkerMu.Lock()
666- defer d.snapshotWorkerMu.Unlock()
667- for len(d.snapshotFinalOrder) > 0 {
668- sess := d.snapshotFinalOrder[0]
669- d.snapshotFinalOrder[0] = nil
670- d.snapshotFinalOrder = d.snapshotFinalOrder[1:]
671- capture := d.snapshotFinalJobs[sess]
672- delete(d.snapshotFinalJobs, sess)
673- if capture != nil {
674- return capture
675- }
676- }
677- return nil
678-}
679-
680-func (d *Daemon) setSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) bool {
681- d.snapshotWorkerMu.Lock()
682- defer d.snapshotWorkerMu.Unlock()
683- if d.snapshotWorkerID != workerID || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
684- return false
685- }
686- d.snapshotWorkerInFlight = capture
687- return true
688-}
689-
690-func (d *Daemon) clearSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) {
691- d.snapshotWorkerMu.Lock()
692- defer d.snapshotWorkerMu.Unlock()
693- if d.snapshotWorkerID == workerID && d.snapshotWorkerInFlight == capture {
694- d.snapshotWorkerInFlight = nil
695- }
696-}
697-
698-func (d *Daemon) stopSnapshotEncodeWorker() {
699- if d == nil {
700- return
701- }
702- d.snapshotWorkerMu.Lock()
703- cancel := d.snapshotWorkerCancel
704- if cancel == nil || d.snapshotWorkerClosing {
705- d.snapshotWorkerMu.Unlock()
706- return
707- }
708- // Stop accepting producers, then let the single worker drain its bounded
709- // queue. A non-context-aware store may still block indefinitely, so the
710- // injected clock bounds how long teardown waits before detaching it.
711- d.snapshotWorkerClosing = true
712- flush := d.snapshotWorkerFlush
713- done := d.snapshotWorkerDone
714- close(flush)
715- timer := d.clock.NewTimer(snapshotFinalFlushTimeout)
716- d.snapshotWorkerMu.Unlock()
717-
718- select {
719- case <-done:
720- timer.Stop()
721- d.finishStoppedSnapshotWorker(false)
722- case <-timer.C():
723- cancel()
724- d.finishStoppedSnapshotWorker(true)
725- }
726-}
727-
728-func (d *Daemon) finishStoppedSnapshotWorker(abandoned bool) {
729- d.snapshotWorkerMu.Lock()
730- inFlight := d.snapshotWorkerInFlight
731- d.snapshotWorkerCtx = nil
732- d.snapshotWorkerCancel = nil
733- d.snapshotWorkerDone = nil
734- d.snapshotWorkerFlush = nil
735- d.snapshotWorkerFinalWake = nil
736- d.snapshotWorkerClosing = false
737- d.snapshotWorkerInFlight = nil
738- queued := make([]*snapshotCapture, 0, len(d.snapshotJobs)+len(d.snapshotFinalJobs))
739- for _, capture := range d.snapshotFinalJobs {
740- queued = append(queued, capture)
741- }
742- d.snapshotFinalJobs = nil
743- d.snapshotFinalOrder = nil
744- for {
745- select {
746- case capture := <-d.snapshotJobs:
747- queued = append(queued, capture)
748- default:
749- d.snapshotWorkerMu.Unlock()
750- if abandoned {
751- d.finishSnapshotCapture(inFlight, false)
752- }
753- for _, capture := range queued {
754- d.finishSnapshotCapture(capture, false)
755- }
756- return
757- }
758- }
759-}
760-
761-func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process {
762- if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil {
763- return nil
764- }
765- pgid, err := pty.ForegroundPgid()
766- if err != nil || pgid <= 0 || pgid == shellPid {
767- return nil
768- }
769- argv, err := d.procGroupArgv(pgid, shellPid)
770- if err != nil || len(argv) == 0 || argv[0] == "" {
771- return nil
772- }
773- strategy := detectProcessStrategy(argv)
774- return &snapcodec.Process{
775- Argv: append([]string(nil), argv...),
776- Strategy: strategy,
777- Opts: snapcodec.ProcessOpts{
778- AgentSessionID: extractAgentSessionID(strategy, argv),
779- },
780- }
781-}
782-
783-func (d *Daemon) snapshotSaver(ctx context.Context) {
784- t := d.clock.NewTimer(snapshotInterval)
785- defer t.Stop()
786- for {
787- select {
788- case <-ctx.Done():
789- return
790- case <-t.C():
791- }
792-
793- d.mu.Lock()
794- sessions := d.sessionsSnapshotLocked()
795- d.mu.Unlock()
796- for _, sess := range sessions {
797- if sess.snapEligible.Load() && sess.snapDirty.Load() {
798- d.scheduleSnapshot(sess)
799- }
800- }
801- t.Reset(snapshotInterval)
802- }
803-}
804-
805-func pruneTreeToPanes(n *layout.Node, panes map[layout.PaneID]struct{}) bool {
806- if n == nil {
807- return false
808- }
809- if n.Kind == layout.Leaf {
810- _, ok := panes[n.Leaf]
811- return ok
812- }
813- kept := n.Children[:0]
814- for _, child := range n.Children {
815- if pruneTreeToPanes(child, panes) {
816- kept = append(kept, child)
817- }
818- }
819- n.Children = kept
820- return len(n.Children) > 0
821-}
M internal/usecase/daemon/snapshot_acceptance_test.go +59 -226
@@ -1,249 +1,82 @@
11 package daemon
22
33 import (
4+ "bytes"
45 "testing"
56
67 "github.com/stretchr/testify/require"
78
8- "github.com/bnema/vev/internal/domain"
9- portsmocks "github.com/bnema/vev/internal/ports/mocks"
10- scopy "github.com/bnema/vev/internal/usecase/copy"
11- "github.com/bnema/vev/internal/usecase/layout"
12- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
13- "github.com/bnema/vev/internal/usecase/visualsearch"
149 "github.com/bnema/vev/pkg/renderer"
1510 "github.com/bnema/vev/pkg/vt"
1611 )
1712
18-func TestSnapshotCaptureMarshalUnmarshalRestorePreservesTopologyAndTerminalState(t *testing.T) {
19- store := &channelSnapshotStore{writes: make(chan []byte, 1)}
20- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
21- WithSnapshotStore(store)(d)
22- startSnapshotEncodeWorker(t, d)
23-
24- sess := newSnapshotTestSession(t, "acceptance", false, "/work")
25- first := sess.tabs[0]
26- first.size = domain.Size{Cols: 80, Rows: 24}
27- first.stableID = "tab-one"
28- first.nextPaneID = 3
29- first.panes["pane-1"].stableID = "pane-one"
30- secondPane := newPaneWithStableID("pane-2", "pane-two", snapshotAcceptancePTY(t), domain.Size{Cols: 40, Rows: 24})
31- secondPane.history.Append([]renderer.Cell{{Rune: 't'}, {Rune: 'w'}, {Rune: 'o'}})
32- secondPane.screen.Write([]byte("two"))
33- first.panes["pane-2"] = secondPane
34- first.tree = &layout.Tree{Focus: "pane-2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
35- layout.NewLeaf("pane-1"),
36- layout.NewLeaf("pane-2"),
37- }}}
38-
39- second := newTab(snapshotAcceptancePTY(t), domain.Size{Cols: 90, Rows: 30})
40- second.stableID = "tab-two"
41- second.nextPaneID = 2
42- second.panes["pane-1"].stableID = "pane-three"
43- second.panes["pane-1"].history.Append([]renderer.Cell{{Rune: 't'}, {Rune: 'h'}, {Rune: 'r'}, {Rune: 'e'}, {Rune: 'e'}})
44- second.panes["pane-1"].screen.Write([]byte("three"))
45- sess.tabs = []*tab{first, second}
46- sess.active = 1
47-
48- require.True(t, d.captureSession(sess))
49- encoded := <-store.writes
50- captured, err := snapcodec.Unmarshal(encoded)
13+func TestSnapshotChunkCacheIsScopedToNamedSession(t *testing.T) {
14+ d := New(nil, nil, nil)
15+ a := newSnapshotTestSession(t, "a", false, "/a")
16+ b := newSnapshotTestSession(t, "b", false, "/b")
17+ a.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit)
18+ b.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit)
19+
20+ // Eleven maximum-size chunks exceed the per-session 16 MiB cache budget.
21+ // The cache keeps its warmed working set rather than admitting every
22+ // oldest-to-newest scan miss.
23+ aHistory := snapshotCacheHistory(t, 11, 'a')
24+ aCapture := snapshotCacheCapture(a, aHistory, 1)
25+ aFirst, err := d.incrementalPublication(aCapture)
5126 require.NoError(t, err)
52- assertCapturedSessionTopology(t, captured)
53-
54- restoreFactory := &restorePTYFactory{}
55- restoredDaemon := newTestDaemon(t, restoreFactory, stubClock{})
56- require.NoError(t, restoredDaemon.restoreSession(t.Context(), captured))
57- restoredDaemon.mu.Lock()
58- restored := restoredDaemon.findByNameLocked("acceptance")
59- restoredDaemon.mu.Unlock()
60- require.NotNil(t, restored)
61- require.Equal(t, 1, restored.active)
62- require.Len(t, restored.tabs, 2)
63-
64- for _, tt := range []struct {
65- name string
66- tab int
67- pane layout.PaneID
68- wantRow string
69- wantFocus layout.PaneID
70- }{
71- {name: "first tab focused second pane", tab: 0, pane: "pane-2", wantRow: "two", wantFocus: "pane-2"},
72- {name: "second tab focused only pane", tab: 1, pane: "pane-1", wantRow: "three", wantFocus: "pane-1"},
73- } {
74- t.Run(tt.name, func(t *testing.T) {
75- tab := restored.tabs[tt.tab]
76- tab.mu.Lock()
77- defer tab.mu.Unlock()
78- require.Equal(t, tt.wantFocus, tab.tree.Focus)
79- pane := tab.panes[tt.pane]
80- require.NotNil(t, pane)
81- pane.mu.Lock()
82- defer pane.mu.Unlock()
83- require.Equal(t, tt.wantRow, rowText(pane.history.View().Row(0)))
84- })
85- }
86-}
87-
88-func TestSnapshotCaptureRetainsImmutableValuesAfterTailMutationAndEviction(t *testing.T) {
89- store := &channelSnapshotStore{writes: make(chan []byte, 1)}
90- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
91- WithSnapshotStore(store)(d)
92- startSnapshotEncodeWorker(t, d)
93-
94- entered := make(chan struct{})
95- release := make(chan struct{})
96- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
97- close(entered)
98- <-release
99- return snapcodec.Marshal(s)
100- }
101- sess := newSnapshotTestSession(t, "frozen", false, "/work")
102- pane := sess.tabs[0].panes["pane-1"]
103- pane.screen = vt.NewScreenWithHistory(8, 3, vt.HistoryConfig{MaxRows: 2, ChunkRows: 1})
104- pane.history = pane.screen.History()
105- pane.history.Append([]renderer.Cell{{Rune: 'o'}, {Rune: 'l'}, {Rune: 'd'}})
106- pane.screen.Write([]byte("before"))
107-
108- require.True(t, d.captureSession(sess))
109- <-entered
110- pane.mu.Lock()
111- pane.history.Append([]renderer.Cell{{Rune: 'n'}, {Rune: 'e'}, {Rune: 'w'}})
112- pane.history.Append([]renderer.Cell{{Rune: 'e'}, {Rune: 'v'}, {Rune: 'i'}, {Rune: 'c'}, {Rune: 't'}})
113- pane.screen.Write([]byte("\rafter"))
114- pane.mu.Unlock()
115- close(release)
116-
117- captured, err := snapcodec.Unmarshal(<-store.writes)
27+ require.NotEmpty(t, aFirst.Objects)
28+ require.LessOrEqual(t, a.snapshotChunkCache.used, a.snapshotChunkCache.limit)
29+ require.LessOrEqual(t, a.snapshotChunkCache.limit, snapshotChunkCacheLimit)
30+ require.Contains(t, a.snapshotChunkCache.byPtr, aHistory.Chunk(0), "session A should retain its warmed entry")
31+
32+ bHistory := snapshotCacheHistory(t, 1, 'b')
33+ bCapture := snapshotCacheCapture(b, bHistory, 1)
34+ _, err = d.incrementalPublication(bCapture)
11835 require.NoError(t, err)
119- restoredHistory, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}, captured.Tabs[0].Panes[0].SealedChunks, captured.Tabs[0].Panes[0].Tail)
120- require.NoError(t, err)
121- require.Equal(t, "old", rowText(restoredHistory.View().Row(0)))
122- frame, err := vt.UnmarshalVisible(captured.Tabs[0].Panes[0].Visible)
123- require.NoError(t, err)
124- require.Contains(t, rowText(frame.Row(0)), "before")
125-}
36+ require.Contains(t, b.snapshotChunkCache.byPtr, bHistory.Chunk(0))
12637
127-func TestDaemonHistoryEvictionLeavesCopyAndSearchSnapshotsUsable(t *testing.T) {
128- sess := newSnapshotTestSession(t, "copy-lifetime", false, "/work")
129- pane := sess.tabs[0].panes["pane-1"]
130- pane.screen = vt.NewScreenWithHistory(8, 1, vt.HistoryConfig{MaxRows: 2, ChunkRows: 1})
131- pane.history = pane.screen.History()
132- for _, text := range []string{"first", "second"} {
133- pane.history.Append(cells(text))
134- }
135- pane.screen.Write([]byte("screen"))
136-
137- document := scopy.NewSnapshot(pane.history, pane.screen.Frame)
138- for _, text := range []string{"third", "fourth"} {
139- pane.history.Append(cells(text))
140- }
141-
142- for _, tt := range []struct {
143- name string
144- rows func() [][]renderer.Cell
145- want []string
146- }{
147- {name: "bounded live history is oldest first after eviction", rows: func() [][]renderer.Cell {
148- view := pane.history.View()
149- rows := make([][]renderer.Cell, view.Len())
150- for i := range rows {
151- rows[i] = view.Row(i)
152- }
153- return rows
154- }, want: []string{"third", "fourth"}},
155- {name: "captured document retains evicted rows", rows: func() [][]renderer.Cell {
156- rows := make([][]renderer.Cell, document.Len()-document.Height)
157- for i := range rows {
158- rows[i] = document.Row(i)
159- }
160- return rows
161- }, want: []string{"first", "second"}},
162- } {
163- t.Run(tt.name, func(t *testing.T) {
164- rows := tt.rows()
165- got := make([]string, len(rows))
166- for i, row := range rows {
167- got[i] = rowText(row)
168- }
169- require.Equal(t, tt.want, got)
170- })
171- }
172-
173- search := visualsearch.New(document)
174- for _, r := range "first" {
175- search.Insert(r)
176- }
177- match, ok := search.Selected()
178- require.True(t, ok)
179- require.Equal(t, "first", match.Text)
180-}
38+ inFlightBytes := append([]byte(nil), aFirst.Objects[0].Data...)
39+ aSecond := snapshotCacheCapture(a, snapshotCacheHistory(t, 1, 'z'), 2)
40+ _, err = d.incrementalPublication(aSecond)
41+ require.NoError(t, err)
42+ require.True(t, bytes.Equal(inFlightBytes, aFirst.Objects[0].Data), "cache pruning must not mutate queued publication bytes")
43+ require.LessOrEqual(t, a.snapshotChunkCache.used, snapshotChunkCacheLimit)
18144
182-func snapshotAcceptancePTY(t *testing.T) *portsmocks.MockPTY {
183- t.Helper()
184- pty := portsmocks.NewMockPTY(t)
185- pty.EXPECT().Pid().Return(0).Maybe()
186- pty.EXPECT().ForegroundPgid().Return(0, nil).Maybe()
187- return pty
45+ _, err = d.incrementalPublication(snapshotCacheCapture(b, bHistory, 2))
46+ require.NoError(t, err)
47+ require.Contains(t, b.snapshotChunkCache.byPtr, bHistory.Chunk(0), "session A eviction must not affect session B")
48+ require.LessOrEqual(t, b.snapshotChunkCache.used, snapshotChunkCacheLimit)
18849 }
18950
190-func assertCapturedSessionTopology(t *testing.T, got snapcodec.Session) {
51+func snapshotCacheHistory(t *testing.T, chunks int, first rune) vt.HistorySnapshotView {
19152 t.Helper()
192- require.Equal(t, "acceptance", got.Name)
193- require.Equal(t, uint16(1), got.Active)
194- require.Len(t, got.Tabs, 2)
195- for _, tt := range []struct {
196- name string
197- tab int
198- stableID string
199- focus layout.PaneID
200- nextPaneID uint64
201- paneIDs []layout.PaneID
202- wantSplit bool
203- }{
204- {name: "first split tab", tab: 0, stableID: "tab-one", focus: "pane-2", nextPaneID: 3, paneIDs: []layout.PaneID{"pane-1", "pane-2"}, wantSplit: true},
205- {name: "second leaf tab", tab: 1, stableID: "tab-two", focus: "pane-1", nextPaneID: 2, paneIDs: []layout.PaneID{"pane-1"}},
206- } {
207- t.Run(tt.name, func(t *testing.T) {
208- tab := got.Tabs[tt.tab]
209- require.Equal(t, tt.stableID, tab.StableID)
210- require.Equal(t, tt.focus, tab.Focus)
211- require.Equal(t, tt.nextPaneID, tab.NextPaneID)
212- require.NotNil(t, tab.Tree)
213- require.Equal(t, tt.focus, tab.Tree.Focus)
214- if tt.wantSplit {
215- require.Equal(t, layout.Split, tab.Tree.Root.Kind)
216- }
217- ids := make(map[layout.PaneID]struct{}, len(tab.Panes))
218- for _, pane := range tab.Panes {
219- ids[pane.ID] = struct{}{}
220- }
221- for _, id := range tt.paneIDs {
222- _, ok := ids[id]
223- require.True(t, ok, "missing pane %q", id)
224- }
225- _, focused := ids[tab.Focus]
226- require.True(t, focused, "focus %q is dangling", tab.Focus)
227- assertTreeReferencesCapturedPanes(t, tab.Tree.Root, ids)
228- })
53+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: chunks * 256, ChunkRows: 256})
54+ for row := 0; row < chunks*256; row++ {
55+ cells := make([]renderer.Cell, 160)
56+ for col := range cells {
57+ cells[col] = renderer.Cell{Rune: first + rune((row+col)%26)}
58+ }
59+ require.NoError(t, history.Append(cells))
22960 }
61+ return history.SnapshotView()
23062 }
23163
232-func assertTreeReferencesCapturedPanes(t *testing.T, node *layout.Node, panes map[layout.PaneID]struct{}) {
233- t.Helper()
234- if node == nil {
235- return
236- }
237- if node.Kind == layout.Leaf {
238- _, ok := panes[node.Leaf]
239- require.True(t, ok, "tree leaf %q is dangling", node.Leaf)
240- return
241- }
242- if node.Kind == layout.Stack && node.Expanded != "" {
243- _, ok := panes[node.Expanded]
244- require.True(t, ok, "stack expanded pane %q is dangling", node.Expanded)
245- }
246- for _, child := range node.Children {
247- assertTreeReferencesCapturedPanes(t, child, panes)
64+func snapshotCacheCapture(sess *session, history vt.HistorySnapshotView, generation uint64) *snapshotCapture {
65+ return &snapshotCapture{
66+ session: sess,
67+ name: sess.name,
68+ generation: generation,
69+ tabs: []snapshotCaptureTab{{
70+ stableID: "tab",
71+ cols: 1,
72+ rows: 1,
73+ panes: []snapshotCapturePane{{
74+ id: "pane",
75+ stableID: "pane",
76+ sealed: history,
77+ tail: history.Tail(),
78+ visible: vt.NewScreen(1, 1).PrimaryVisibleSnapshot(),
79+ }},
80+ }},
24881 }
24982 }
A internal/usecase/daemon/snapshot_capture.go +101 -0
@@ -0,0 +1,101 @@
1+package daemon
2+
3+import snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
4+
5+// captureSession rotates history tails and clones visible frames while holding
6+// each pane lock. The returned capture contains only immutable state; encoding
7+// and persistence are deliberately deferred to snapshotEncodeWorker.
8+func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapshotCapture, bool) {
9+ sess.mu.Lock()
10+ capture := &snapshotCapture{
11+ session: sess,
12+ generation: generation,
13+ name: sess.name,
14+ createdAt: uint64(sess.createdAt),
15+ active: uint16(max(sess.active, 0)),
16+ }
17+ ephemeral := sess.ephemeral
18+ fallbackCwd := sess.cwd
19+ tabs := append([]*tab(nil), sess.tabs...)
20+ sess.mu.Unlock()
21+ if ephemeral || capture.name == "" {
22+ return capture, false
23+ }
24+
25+ capture.tabs = make([]snapshotCaptureTab, 0, len(tabs))
26+ for _, tb := range tabs {
27+ tb.mu.Lock()
28+ tabCapture := snapshotCaptureTab{
29+ stableID: tb.stableID,
30+ cols: uint16(max(tb.size.Cols, 0)),
31+ rows: uint16(max(tb.size.Rows, 0)),
32+ nextPaneID: uint64(max(tb.nextPaneID, 0)),
33+ tree: tb.tree.Clone(),
34+ }
35+ if tb.tree != nil {
36+ tabCapture.focus = tb.tree.Focus
37+ }
38+ panes := make([]*pane, 0, len(tb.panes))
39+ for _, p := range tb.panes {
40+ panes = append(panes, p)
41+ }
42+ tb.mu.Unlock()
43+
44+ tabCapture.panes = make([]snapshotCapturePane, 0, len(panes))
45+ for _, p := range panes {
46+ p.mu.Lock()
47+ pty := p.pty
48+ pid := 0
49+ if pty != nil {
50+ pid = pty.Pid()
51+ }
52+ paneCapture := snapshotCapturePane{
53+ id: p.id,
54+ stableID: p.stableID,
55+ visible: p.screen.PrimaryVisibleSnapshot(),
56+ }
57+ paneCapture.sealed = p.history.SnapshotView()
58+ paneCapture.tail = paneCapture.sealed.Tail()
59+ p.mu.Unlock()
60+ paneCapture.cwd = fallbackCwd
61+ if d.procCwd != nil && pid > 0 {
62+ if cwd, err := d.procCwd(pid); err == nil && cwd != "" {
63+ paneCapture.cwd = cwd
64+ }
65+ }
66+ paneCapture.process = d.capturePaneProcess(pty, pid)
67+ tabCapture.panes = append(tabCapture.panes, paneCapture)
68+ }
69+ capture.tabs = append(capture.tabs, tabCapture)
70+ }
71+ return capture, true
72+}
73+
74+// captureSession remains the synchronous producer-facing trigger for callers
75+// such as teardown and benchmarks; the actual encoding and Write stay async.
76+func (d *Daemon) captureSession(sess *session) bool {
77+ markSnapshotDirty(sess)
78+ return d.scheduleSnapshot(sess)
79+}
80+
81+func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process {
82+ if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil {
83+ return nil
84+ }
85+ pgid, err := pty.ForegroundPgid()
86+ if err != nil || pgid <= 0 || pgid == shellPid {
87+ return nil
88+ }
89+ argv, err := d.procGroupArgv(pgid, shellPid)
90+ if err != nil || len(argv) == 0 || argv[0] == "" {
91+ return nil
92+ }
93+ strategy := detectProcessStrategy(argv)
94+ return &snapcodec.Process{
95+ Argv: append([]string(nil), argv...),
96+ Strategy: strategy,
97+ Opts: snapcodec.ProcessOpts{
98+ AgentSessionID: extractAgentSessionID(strategy, argv),
99+ },
100+ }
101+}
A internal/usecase/daemon/snapshot_coordinator.go +279 -0
@@ -0,0 +1,279 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "time"
6+)
7+
8+// snapshotCoordinatorQuarantine identifies one stop request. A later teardown
9+// can supersede it, in which case only that later owner may resume publication.
10+type snapshotCoordinatorQuarantine struct {
11+ done <-chan struct{}
12+ epoch uint64
13+}
14+
15+// quarantineSnapshotCoordinator prevents a deleted session from starting any
16+// further publication. The returned join channel is closed after an already
17+// started repository call returns; callers must wait without daemon or session
18+// locks before deleting repository state.
19+func quarantineSnapshotCoordinator(sess *session) <-chan struct{} {
20+ return quarantineSnapshotCoordinatorWithEpoch(sess).done
21+}
22+
23+// quarantineSnapshotCoordinatorRetainingQueuedCapture is used after a forced
24+// shutdown checkpoint times out. Its queued capture is logical retry state, not
25+// a repository operation: startSnapshotPublication rejects it while
26+// quarantined, and the worker eventually discards it after its blocked call
27+// returns.
28+func quarantineSnapshotCoordinatorRetainingQueuedCapture(sess *session) <-chan struct{} {
29+ return quarantineSnapshotCoordinatorWithOptions(sess, true).done
30+}
31+
32+func quarantineSnapshotCoordinatorWithEpoch(sess *session) snapshotCoordinatorQuarantine {
33+ return quarantineSnapshotCoordinatorWithOptions(sess, false)
34+}
35+
36+func quarantineSnapshotCoordinatorWithOptions(sess *session, retainQueuedCapture bool) snapshotCoordinatorQuarantine {
37+ if sess == nil {
38+ done := make(chan struct{})
39+ close(done)
40+ return snapshotCoordinatorQuarantine{done: done}
41+ }
42+ sess.snapshotMu.Lock()
43+ sess.snapshotQuarantineEpoch++
44+ epoch := sess.snapshotQuarantineEpoch
45+ sess.snapshotQuarantined = true
46+ sess.snapEligible.Store(false)
47+ if sess.snapshotPublicationCancel != nil {
48+ sess.snapshotPublicationCancel()
49+ }
50+ if sess.snapshotQueuedCapture != nil && !retainQueuedCapture {
51+ // A rename or purge must discard a capture under the old identity. The
52+ // global queue may still contain it, but startSnapshotPublication
53+ // compares identity and will discard it even if this session is renamed.
54+ sess.snapshotQueuedCapture.coordinatorDiscarded = true
55+ sess.snapshotQueuedCapture = nil
56+ if sess.snapshotPendingCaptures > 0 {
57+ sess.snapshotPendingCaptures--
58+ }
59+ sess.snapshotPending = sess.snapshotPendingCaptures > 0 || sess.snapshotInFlightCapture != nil
60+ }
61+ done := sess.snapshotPublicationDone
62+ if done == nil {
63+ done = make(chan struct{})
64+ close(done)
65+ }
66+ sess.signalSnapshotChangedLocked()
67+ sess.snapshotMu.Unlock()
68+ return snapshotCoordinatorQuarantine{done: done, epoch: epoch}
69+}
70+
71+// resumeSnapshotCoordinatorForNewIdentity starts a fresh repository lineage
72+// only after rename committed its new in-memory name. The epoch prevents a
73+// concurrent teardown from being undone by a late rename completion.
74+func resumeSnapshotCoordinatorForNewIdentity(sess *session, quarantine snapshotCoordinatorQuarantine) bool {
75+ if sess == nil {
76+ return false
77+ }
78+ sess.snapshotMu.Lock()
79+ if !sess.snapshotQuarantined || sess.snapshotQuarantineEpoch != quarantine.epoch {
80+ sess.snapshotMu.Unlock()
81+ return false
82+ }
83+ if sess.snapshotPublicationCancel != nil {
84+ sess.snapshotPublicationCancel()
85+ }
86+ sess.snapshotPublicationContext, sess.snapshotPublicationCancel = context.WithCancel(context.Background())
87+ sess.snapshotQuarantined = false
88+ // A repository name owns its own generation stream. Reset all scheduler
89+ // state while eligibility remains false, then atomically make generation 1
90+ // dirty and wake the scheduler.
91+ sess.snapshotGeneration = 1
92+ sess.snapshotPublishedGeneration = 0
93+ sess.snapshotPublishedMutationRevision = 0
94+ sess.snapshotForcedGeneration = 0
95+ sess.snapshotNextEligibleAt = time.Time{}
96+ sess.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit)
97+ sess.snapDirty.Store(true)
98+ sess.snapEligible.Store(true)
99+ sess.signalSnapshotChangedLocked()
100+ wake := sess.snapshotWake
101+ sess.snapshotMu.Unlock()
102+ if wake != nil {
103+ select {
104+ case wake <- struct{}{}:
105+ default:
106+ }
107+ }
108+ return true
109+}
110+
111+func markSnapshotDirty(sess *session) {
112+ if sess == nil || !sess.snapEligible.Load() {
113+ return
114+ }
115+ sess.snapshotMu.Lock()
116+ sess.snapshotGeneration++
117+ sess.snapDirty.Store(true)
118+ sess.signalSnapshotChangedLocked()
119+ wake := sess.snapshotWake
120+ sess.snapshotMu.Unlock()
121+ if wake != nil {
122+ select {
123+ case wake <- struct{}{}:
124+ default:
125+ }
126+ }
127+}
128+
129+// snapshotChangeLocked returns a channel closed by the next snapshot state
130+// transition. snapshotMu must be held.
131+func (sess *session) snapshotChangeLocked() chan struct{} {
132+ if sess.snapshotChanged == nil {
133+ sess.snapshotChanged = make(chan struct{})
134+ }
135+ return sess.snapshotChanged
136+}
137+
138+// signalSnapshotChangedLocked wakes waiters after a snapshot state transition.
139+// snapshotMu must be held.
140+func (sess *session) signalSnapshotChangedLocked() {
141+ close(sess.snapshotChangeLocked())
142+ sess.snapshotChanged = make(chan struct{})
143+}
144+
145+// waitForSnapshotGeneration waits only for the bounded terminal checkpoint
146+// deadline. It observes successful publication of the requested mutation
147+// revision, rather than dirty state, so a concurrent later mutation cannot
148+// turn a completed forced checkpoint into a false timeout.
149+func (d *Daemon) waitForSnapshotGeneration(sess *session, generation uint64) bool {
150+ if d == nil || sess == nil || generation == 0 {
151+ return true
152+ }
153+ deadline := newSnapshotShutdownDeadline(d.clock)
154+ defer deadline.stop()
155+ return d.waitForSnapshotGenerationWithDeadline(sess, generation, deadline)
156+}
157+
158+// waitForSnapshotGenerationWithDeadline waits for a forced checkpoint without
159+// allocating another shutdown interval. Serve shares one deadline across every
160+// session and the subsequent worker drain.
161+func (d *Daemon) waitForSnapshotGenerationWithDeadline(sess *session, generation uint64, deadline *snapshotShutdownDeadline) bool {
162+ if d == nil || sess == nil || generation == 0 {
163+ return true
164+ }
165+ if deadline == nil {
166+ return d.waitForSnapshotGeneration(sess, generation)
167+ }
168+ for {
169+ sess.snapshotMu.Lock()
170+ published := sess.snapshotPublishedMutationRevision >= generation
171+ changed := sess.snapshotChangeLocked()
172+ sess.snapshotMu.Unlock()
173+ if published {
174+ return true
175+ }
176+ select {
177+ case <-changed:
178+ case <-deadline.Done():
179+ return false
180+ }
181+ }
182+}
183+
184+// startSnapshotPublication moves a retained capture from the producer queue to
185+// the repository worker. Quarantine and this transition share snapshotMu, so a
186+// repository call cannot begin after destructive teardown has quarantined the
187+// session.
188+func startSnapshotPublication(capture *snapshotCapture) bool {
189+ if capture == nil || capture.session == nil {
190+ return false
191+ }
192+ sess := capture.session
193+ sess.snapshotMu.Lock()
194+ defer sess.snapshotMu.Unlock()
195+ if sess.snapshotQuarantined || sess.snapshotQueuedCapture != capture {
196+ return false
197+ }
198+ sess.snapshotQueuedCapture = nil
199+ sess.snapshotInFlightCapture = capture
200+ sess.snapshotPublicationDone = make(chan struct{})
201+ sess.snapshotPending = true
202+ sess.signalSnapshotChangedLocked()
203+ return true
204+}
205+
206+func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool) {
207+ if capture == nil {
208+ return
209+ }
210+ capture.finishOnce.Do(func() {
211+ var shouldScheduleForcedSuccessor bool
212+ capture.session.snapshotMu.Lock()
213+ if capture.session.snapshotPendingCaptures > 0 {
214+ capture.session.snapshotPendingCaptures--
215+ }
216+ if capture.session.snapshotQueuedCapture == capture {
217+ capture.session.snapshotQueuedCapture = nil
218+ }
219+ if capture.session.snapshotInFlightCapture == capture {
220+ capture.session.snapshotInFlightCapture = nil
221+ if capture.session.snapshotPublicationDone != nil {
222+ close(capture.session.snapshotPublicationDone)
223+ capture.session.snapshotPublicationDone = nil
224+ }
225+ }
226+ capture.session.snapshotPending = capture.session.snapshotPendingCaptures > 0 ||
227+ capture.session.snapshotQueuedCapture != nil || capture.session.snapshotInFlightCapture != nil
228+ if !capture.coordinatorDiscarded && capture.attemptKind == snapshotAttemptRoutine && d.snapshotRepository != nil {
229+ // The interval is measured from completion, including a failed
230+ // attempt. A forced teardown checkpoint deliberately leaves it alone.
231+ capture.session.snapshotNextEligibleAt = d.clock.Now().Add(snapshotInterval)
232+ }
233+ mutationRevision := capture.mutationRevision
234+ // Older unit fixtures created captures before mutation revisions were
235+ // recorded explicitly. Production captures always set it at scheduling;
236+ // retaining this fallback keeps those fixtures representative.
237+ if mutationRevision == 0 {
238+ mutationRevision = capture.generation
239+ }
240+ if succeeded {
241+ capture.session.snapshotPublishedGeneration = max(capture.session.snapshotPublishedGeneration, capture.generation)
242+ capture.session.snapshotPublishedMutationRevision = max(capture.session.snapshotPublishedMutationRevision, mutationRevision)
243+ }
244+ if succeeded && capture.session.snapshotGeneration == mutationRevision {
245+ capture.session.snapDirty.Store(false)
246+ } else if !succeeded {
247+ capture.session.snapDirty.Store(true)
248+ }
249+ if succeeded && capture.session.snapshotForcedGeneration != 0 &&
250+ mutationRevision >= capture.session.snapshotForcedGeneration {
251+ capture.session.snapshotForcedGeneration = 0
252+ }
253+ // A failed forced publication remains dirty for ordinary retry rather
254+ // than recursively creating forced jobs. A later forced request records
255+ // a newer intent and can create its one bounded successor.
256+ shouldScheduleForcedSuccessor = capture.session.snapshotForcedGeneration != 0 &&
257+ mutationRevision < capture.session.snapshotForcedGeneration &&
258+ capture.session.snapshotQueuedCapture == nil && capture.session.snapshotInFlightCapture == nil &&
259+ (succeeded || capture.attemptKind != snapshotAttemptForced)
260+ capture.session.signalSnapshotChangedLocked()
261+ wake := capture.session.snapshotWake
262+ capture.session.snapshotMu.Unlock()
263+ if wake != nil {
264+ select {
265+ case wake <- struct{}{}:
266+ default:
267+ }
268+ }
269+ if shouldScheduleForcedSuccessor {
270+ // This runs after publication completion and outside snapshotMu. It
271+ // bypasses routine eligibility while preserving the single queued and
272+ // single in-flight capture bound.
273+ _ = d.scheduleFinalSnapshot(capture.session)
274+ }
275+ if succeeded {
276+ d.clearSnapshotFailure()
277+ }
278+ })
279+}
A internal/usecase/daemon/snapshot_failure_notice_test.go +27 -0
@@ -0,0 +1,27 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestSnapshotFailureNoticeDeduplicatesStableCauseAndClearsOnSuccess(t *testing.T) {
11+ d, _, client, _ := newNoticeFixture(t, newNoticeClock())
12+ capture := &snapshotCapture{session: newSnapshotTestSession(t, "failed", false, "/work"), name: "failed"}
13+
14+ d.reportSnapshotFailure(capture, "publish", errors.New("first transient failure"))
15+ d.reportSnapshotFailure(capture, "publish", errors.New("same class, changed details"))
16+
17+ history := d.notices.history()
18+ require.Len(t, history, 1)
19+ require.EqualValues(t, 2, history[0].Count)
20+ require.Len(t, awaitToastCount(t, client, 1), 1)
21+
22+ d.finishSnapshotCapture(capture, true)
23+ d.reportSnapshotFailure(capture, "publish", errors.New("failure after success"))
24+ require.Len(t, awaitToastCount(t, client, 1), 1)
25+ require.Len(t, d.notices.history(), 1)
26+ require.EqualValues(t, 3, d.notices.history()[0].Count)
27+}
A internal/usecase/daemon/snapshot_failure_test.go +22 -0
@@ -0,0 +1,22 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "os"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestSnapshotFailureSignatureIsStableAndNilSafe(t *testing.T) {
12+ t.Parallel()
13+
14+ first := &os.PathError{Op: "write", Path: "/private/first", Err: os.ErrPermission}
15+ second := &os.PathError{Op: "write", Path: "/volatile/second", Err: os.ErrPermission}
16+
17+ require.Equal(t, snapshotFailureSignature("publish", first), snapshotFailureSignature("publish", second))
18+ require.NotContains(t, snapshotFailureSignature("publish", first), "/private/first")
19+ require.NotContains(t, snapshotFailureSignature("publish", nil), "%!w(<nil>)")
20+ require.NotEqual(t, snapshotFailureSignature("publish", first), snapshotFailureSignature("encode", first))
21+ require.NotEqual(t, snapshotFailureSignature("publish", first), snapshotFailureSignature("publish", errors.New("other")))
22+}
A internal/usecase/daemon/snapshot_generation_test.go +80 -0
@@ -0,0 +1,80 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/ports"
14+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
15+)
16+
17+func TestSnapshotMultipleMutationsPublishNextRepositoryGeneration(t *testing.T) {
18+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
19+ repository := portsmocks.NewMockSnapshotRepository(t)
20+ WithSnapshotRepository(repository, nil)(d)
21+ startSnapshotEncodeWorker(t, d)
22+
23+ publications := make(chan ports.SnapshotPublication, 1)
24+ repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {
25+ publications <- publication
26+ return nil
27+ }).Once()
28+
29+ sess := newSnapshotTestSession(t, "work", false, "/work")
30+ markSnapshotDirty(sess)
31+ markSnapshotDirty(sess)
32+ markSnapshotDirty(sess)
33+ require.True(t, d.scheduleSnapshot(sess))
34+
35+ select {
36+ case publication := <-publications:
37+ require.Equal(t, uint64(1), publication.Generation)
38+ case <-time.After(testWaitTimeout):
39+ t.Fatal("snapshot was not published")
40+ }
41+ awaitSnapshotClean(t, sess)
42+}
43+
44+func TestSnapshotRetryKeepsRepositoryGeneration(t *testing.T) {
45+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
46+ repository := portsmocks.NewMockSnapshotRepository(t)
47+ WithSnapshotRepository(repository, nil)(d)
48+ startSnapshotEncodeWorker(t, d)
49+
50+ publications := make(chan ports.SnapshotPublication, 2)
51+ var attempts atomic.Int32
52+ repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {
53+ publications <- publication
54+ if attempts.Add(1) == 1 {
55+ return errors.New("temporary repository failure")
56+ }
57+ return nil
58+ }).Twice()
59+
60+ sess := newSnapshotTestSession(t, "work", false, "/work")
61+ markSnapshotDirty(sess)
62+ require.True(t, d.scheduleSnapshot(sess))
63+ select {
64+ case publication := <-publications:
65+ require.Equal(t, uint64(1), publication.Generation)
66+ case <-time.After(testWaitTimeout):
67+ t.Fatal("first snapshot was not published")
68+ }
69+ awaitSnapshotIdle(t, sess)
70+ require.True(t, sess.snapDirty.Load())
71+
72+ require.True(t, d.scheduleFinalSnapshot(sess))
73+ select {
74+ case publication := <-publications:
75+ require.Equal(t, uint64(1), publication.Generation)
76+ case <-time.After(testWaitTimeout):
77+ t.Fatal("retry snapshot was not published")
78+ }
79+ awaitSnapshotClean(t, sess)
80+}
A internal/usecase/daemon/snapshot_import_test.go +215 -0
@@ -0,0 +1,215 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "errors"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/ports"
14+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
15+)
16+
17+type snapshotAcceptanceLegacySource struct {
18+ blobs []ports.LegacySnapshot
19+ loadCalls int
20+ deleteCalls []string
21+ deleteErr error
22+}
23+
24+func (s *snapshotAcceptanceLegacySource) LoadLegacy(ctx context.Context) ([]ports.LegacySnapshot, error) {
25+ if err := ctx.Err(); err != nil {
26+ return nil, err
27+ }
28+ s.loadCalls++
29+ out := make([]ports.LegacySnapshot, len(s.blobs))
30+ for i, blob := range s.blobs {
31+ out[i] = ports.LegacySnapshot{Name: blob.Name, Data: append([]byte(nil), blob.Data...)}
32+ }
33+ return out, nil
34+}
35+
36+func (s *snapshotAcceptanceLegacySource) DeleteVerifiedLegacy(ctx context.Context, blob ports.LegacySnapshot) error {
37+ return s.DeleteLegacy(ctx, blob.Name)
38+}
39+
40+func (s *snapshotAcceptanceLegacySource) DeleteLegacy(ctx context.Context, name string) error {
41+ if err := ctx.Err(); err != nil {
42+ return err
43+ }
44+ s.deleteCalls = append(s.deleteCalls, name)
45+ if s.deleteErr != nil {
46+ return s.deleteErr
47+ }
48+ for i, blob := range s.blobs {
49+ if blob.Name == name {
50+ s.blobs = append(s.blobs[:i], s.blobs[i+1:]...)
51+ break
52+ }
53+ }
54+ return nil
55+}
56+
57+func legacyAcceptanceSnapshot(t *testing.T) snapcodec.Session {
58+ t.Helper()
59+ data, err := os.ReadFile(filepath.Join("testdata", "legacy-v3-empty.b64"))
60+ require.NoError(t, err)
61+ encoded, err := base64.StdEncoding.DecodeString(string(data))
62+ require.NoError(t, err)
63+ snapshot, err := snapcodec.Unmarshal(encoded)
64+ require.NoError(t, err)
65+ return snapshot
66+}
67+
68+func legacyAcceptanceBlob(t *testing.T, snapshot snapcodec.Session) ports.LegacySnapshot {
69+ t.Helper()
70+ fixture := legacyAcceptanceSnapshot(t)
71+ require.Equal(t, fixture, snapshot, "legacy fixture must be the imported session")
72+ data, err := os.ReadFile(filepath.Join("testdata", "legacy-v3-empty.b64"))
73+ require.NoError(t, err)
74+ encoded, err := base64.StdEncoding.DecodeString(string(data))
75+ require.NoError(t, err)
76+ return ports.LegacySnapshot{Name: snapshot.Name, Data: encoded}
77+}
78+
79+func TestLegacyPublicationMarshalObjectErrorsIdentifyPaneAndStage(t *testing.T) {
80+ for _, tt := range []struct {
81+ name string
82+ mutate func(*snapcodec.Pane)
83+ stage string
84+ }{
85+ {name: "sealed chunk", mutate: func(pane *snapcodec.Pane) { pane.SealedChunks = [][]byte{nil} }, stage: "sealed chunk 0"},
86+ {name: "tail", mutate: func(pane *snapcodec.Pane) { pane.Tail = nil }, stage: "tail"},
87+ {name: "visible", mutate: func(pane *snapcodec.Pane) { pane.Visible = nil }, stage: "visible"},
88+ } {
89+ t.Run(tt.name, func(t *testing.T) {
90+ pane := snapcodec.Pane{ID: "pane-1", StableID: "pane-stable", Tail: []byte("tail"), Visible: []byte("visible")}
91+ tt.mutate(&pane)
92+ snapshot := snapcodec.Session{Name: "legacy", Tabs: []snapcodec.Tab{{StableID: "tab-1", Panes: []snapcodec.Pane{pane}}}}
93+
94+ _, err := legacyPublication(snapshot)
95+ require.Error(t, err)
96+ require.ErrorContains(t, err, `tab "tab-1"`)
97+ require.ErrorContains(t, err, `pane "pane-1"`)
98+ require.ErrorContains(t, err, tt.stage)
99+ require.ErrorContains(t, err, "marshal object")
100+ })
101+ }
102+}
103+
104+func TestImportLegacyPublishesCompleteGenerationBeforeDeletingSource(t *testing.T) {
105+ snapshot := legacyAcceptanceSnapshot(t)
106+ repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)}
107+ legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}}
108+ d := newTestDaemon(t, nil, stubClock{})
109+ WithSnapshotRepository(repository, legacy)(d)
110+ expected, verifyErr := legacyPublication(snapshot)
111+ require.NoError(t, verifyErr)
112+ _, verifyErr = snapcodec.Unmarshal(legacy.blobs[0].Data)
113+ require.NoError(t, verifyErr)
114+ require.NoError(t, verifyLegacyImportGeneration(acceptanceGeneration(t, snapshot, 1), expected))
115+
116+ d.importLegacySnapshots(context.Background())
117+
118+ require.Len(t, repository.publishes, 1)
119+ publication := repository.publishes[0]
120+ require.Equal(t, snapshot.Name, publication.Name)
121+ require.EqualValues(t, 1, publication.Generation)
122+ manifest, err := snapcodec.UnmarshalManifest(publication.Manifest)
123+ require.NoError(t, err)
124+ require.Equal(t, uint64(1), manifest.Generation)
125+ require.Empty(t, manifest.Tabs)
126+ for _, object := range publication.Objects {
127+ kind, _, err := snapcodec.UnmarshalObject(object.Data)
128+ require.NoError(t, err)
129+ require.Contains(t, []snapcodec.ObjectKind{snapcodec.HistoryChunk, snapcodec.HistoryTail, snapcodec.Visible}, kind)
130+ require.Equal(t, object.Digest, snapcodec.ManifestDigest(object.Data))
131+ }
132+ require.Equal(t, []string{snapshot.Name}, legacy.deleteCalls)
133+ require.Empty(t, legacy.blobs)
134+}
135+
136+func TestImportLegacyExistingIncrementalSkipsLegacy(t *testing.T) {
137+ snapshot := legacyAcceptanceSnapshot(t)
138+ repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: make(map[string]ports.SnapshotGeneration)}
139+ legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}}
140+ d := newTestDaemon(t, nil, stubClock{})
141+ WithSnapshotRepository(repository, legacy)(d)
142+
143+ d.importLegacySnapshots(context.Background())
144+
145+ require.Empty(t, repository.publishes)
146+ require.Empty(t, legacy.deleteCalls)
147+ require.Len(t, legacy.blobs, 1)
148+}
149+
150+func TestImportLegacyRetainsSourceUntilExactReloadVerification(t *testing.T) {
151+ snapshot := legacyAcceptanceSnapshot(t)
152+ for _, test := range []struct {
153+ name string
154+ publish error
155+ load error
156+ mutate func(ports.SnapshotGeneration, ports.SnapshotPublication) ports.SnapshotGeneration
157+ }{
158+ {"publish failure", errors.New("publish failed"), nil, nil},
159+ {"reload failure", nil, errors.New("reload failed"), nil},
160+ {"manifest metadata mismatch", nil, nil, func(g ports.SnapshotGeneration, _ ports.SnapshotPublication) ports.SnapshotGeneration {
161+ g.Generation++
162+ return g
163+ }},
164+ {"manifest bytes mismatch", nil, nil, func(g ports.SnapshotGeneration, _ ports.SnapshotPublication) ports.SnapshotGeneration {
165+ g.Manifest[len(g.Manifest)-1] ^= 1
166+ return g
167+ }},
168+ } {
169+ t.Run(test.name, func(t *testing.T) {
170+ repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration), publishErr: test.publish, loadErr: test.load, loadMutate: test.mutate}
171+ legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}}
172+ d := newTestDaemon(t, nil, stubClock{})
173+ WithSnapshotRepository(repository, legacy)(d)
174+ d.importLegacySnapshots(context.Background())
175+ require.Empty(t, legacy.deleteCalls)
176+ require.Len(t, legacy.blobs, 1)
177+ })
178+ }
179+}
180+
181+func TestImportLegacyDeleteFailureRetriesWithoutRepublishing(t *testing.T) {
182+ snapshot := legacyAcceptanceSnapshot(t)
183+ repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)}
184+ legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}, deleteErr: errors.New("sync failed")}
185+ d := newTestDaemon(t, nil, stubClock{})
186+ WithSnapshotRepository(repository, legacy)(d)
187+
188+ d.importLegacySnapshots(context.Background())
189+ legacy.deleteErr = nil
190+ d.importLegacySnapshots(context.Background())
191+
192+ require.Len(t, repository.publishes, 1, "a retry must only retry source deletion")
193+ require.Equal(t, []string{snapshot.Name, snapshot.Name}, legacy.deleteCalls)
194+ require.Empty(t, legacy.blobs)
195+}
196+
197+func TestImportLegacyCancellationAndPerSessionContinuation(t *testing.T) {
198+ bad := legacyAcceptanceSnapshot(t)
199+ good := legacyAcceptanceSnapshot(t)
200+ badBlob := legacyAcceptanceBlob(t, bad)
201+ badBlob.Data = []byte("not-a-v3-snapshot")
202+ repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)}
203+ legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{badBlob, legacyAcceptanceBlob(t, good)}}
204+ d := newTestDaemon(t, nil, stubClock{})
205+ WithSnapshotRepository(repository, legacy)(d)
206+
207+ d.importLegacySnapshots(context.Background())
208+ require.Len(t, repository.publishes, 1, "a corrupt session must not block later sessions")
209+ require.Equal(t, []string{good.Name}, legacy.deleteCalls)
210+
211+ cancelled, cancel := context.WithCancel(context.Background())
212+ cancel()
213+ d.importLegacySnapshots(cancelled)
214+ require.Equal(t, 1, legacy.loadCalls, "a canceled import must not touch the legacy source")
215+}
A internal/usecase/daemon/snapshot_incremental.go +248 -0
@@ -0,0 +1,248 @@
1+package daemon
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/vev/internal/ports"
7+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
8+ "github.com/bnema/vev/pkg/vt"
9+)
10+
11+// snapshotChunkCacheLimit is deliberately small: history chunks remain owned
12+// by VT and each named-session cache only retains their encoded VEVO form.
13+const snapshotChunkCacheLimit = 16 << 20
14+
15+type snapshotChunkCacheEntry struct {
16+ object ports.SnapshotObject
17+}
18+
19+// snapshotChunkCache is keyed by immutable HistoryChunk identity. Its owner
20+// must hold the named session's snapshotMu for every operation. Encoded bytes
21+// remain cache-owned; publications receive a copy only until a successful
22+// repository publication has made the immutable object available by reference.
23+type snapshotChunkCache struct {
24+ limit int
25+ used int
26+ byPtr map[*vt.HistoryChunk]snapshotChunkCacheEntry
27+ persisted map[*vt.HistoryChunk]snapcodec.ObjectRef
28+ // marshalChunk makes cache admission measurable without coupling the cache
29+ // policy test to VT's wire encoding implementation.
30+ marshalChunk func(*vt.HistoryChunk) ([]byte, error)
31+ copyObject func(ports.SnapshotObject) ports.SnapshotObject
32+}
33+
34+func newSnapshotChunkCache(limit int) *snapshotChunkCache {
35+ return &snapshotChunkCache{
36+ limit: limit,
37+ byPtr: make(map[*vt.HistoryChunk]snapshotChunkCacheEntry),
38+ persisted: make(map[*vt.HistoryChunk]snapcodec.ObjectRef),
39+ marshalChunk: vt.MarshalHistoryChunk,
40+ copyObject: copySnapshotObject,
41+ }
42+}
43+
44+// objectLocked returns a complete manifest reference and, when the repository
45+// does not already retain it, an independently owned object to supply. Caller
46+// holds the owning session's snapshotMu.
47+func (c *snapshotChunkCache) objectLocked(chunk *vt.HistoryChunk) (snapcodec.ObjectRef, *ports.SnapshotObject, error) {
48+ if chunk == nil {
49+ return snapcodec.ObjectRef{}, nil, fmt.Errorf("snapshot: nil history chunk")
50+ }
51+ if ref, ok := c.persisted[chunk]; ok {
52+ return ref, nil, nil
53+ }
54+ if entry, ok := c.byPtr[chunk]; ok {
55+ object := c.copyObject(entry.object)
56+ return objectRef(snapcodec.HistoryChunk, object), &object, nil
57+ }
58+
59+ payload, err := c.marshalChunk(chunk)
60+ if err != nil {
61+ return snapcodec.ObjectRef{}, nil, err
62+ }
63+ object, err := snapcodec.MarshalObject(snapcodec.HistoryChunk, payload)
64+ if err != nil {
65+ return snapcodec.ObjectRef{}, nil, err
66+ }
67+ ref := objectRef(snapcodec.HistoryChunk, object)
68+
69+ // An oldest-to-newest snapshot scan can exceed the cache. LRU would admit
70+ // every early miss and evict the later working set, causing the next scan to
71+ // encode every immutable chunk again. Once full, retain the warmed set and
72+ // return later misses uncached. Pruning makes space when a retained chunk is
73+ // no longer reachable by a queued or in-flight capture.
74+ if len(object.Data) > c.limit || c.used > c.limit-len(object.Data) {
75+ return ref, &object, nil
76+ }
77+ stored := c.copyObject(object)
78+ c.byPtr[chunk] = snapshotChunkCacheEntry{object: stored}
79+ c.used += len(stored.Data)
80+ return ref, &object, nil
81+}
82+
83+// pruneLocked releases cached encodings which no currently queued or in-flight
84+// capture can use. Caller holds the owning session's snapshotMu.
85+func (c *snapshotChunkCache) pruneLocked(referenced map[*vt.HistoryChunk]struct{}) {
86+ for chunk, entry := range c.byPtr {
87+ if _, keep := referenced[chunk]; !keep {
88+ delete(c.byPtr, chunk)
89+ c.used -= len(entry.object.Data)
90+ }
91+ }
92+ for chunk := range c.persisted {
93+ if _, keep := referenced[chunk]; !keep {
94+ delete(c.persisted, chunk)
95+ }
96+ }
97+}
98+
99+func copySnapshotObject(object ports.SnapshotObject) ports.SnapshotObject {
100+ object.Data = append([]byte(nil), object.Data...)
101+ return object
102+}
103+
104+// incrementalPublication converts one immutable capture into complete VEVO
105+// objects plus its VEVM manifest. Encoding runs after pane locks are released.
106+// Sealed history uses this capture's named-session pointer identity cache;
107+// tails and visible frames are intentionally not cached because they are copied
108+// mutable state.
109+func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.SnapshotPublication, error) {
110+ if capture == nil || capture.session == nil || capture.name == "" {
111+ return ports.SnapshotPublication{}, fmt.Errorf("snapshot: empty capture")
112+ }
113+ if err := prepareSnapshotChunkCache(capture); err != nil {
114+ return ports.SnapshotPublication{}, err
115+ }
116+ manifest := snapcodec.Manifest{Generation: capture.generation, Name: capture.name, CreatedAt: capture.createdAt, Active: capture.active, Tabs: make([]snapcodec.ManifestTab, 0, len(capture.tabs))}
117+ objects := make([]ports.SnapshotObject, 0)
118+ capture.sealedRefs = make(map[*vt.HistoryChunk]snapcodec.ObjectRef)
119+ for _, tab := range capture.tabs {
120+ outTab := snapcodec.ManifestTab{StableID: tab.stableID, Cols: tab.cols, Rows: tab.rows, NextPaneID: tab.nextPaneID, Focus: tab.focus, Tree: tab.tree, Panes: make([]snapcodec.ManifestPane, 0, len(tab.panes))}
121+ for _, pane := range tab.panes {
122+ // visible was copied under pane.mu. Marshal it here in the worker,
123+ // after all pane and session locks have been released.
124+ visible, err := pane.visible.Marshal()
125+ if err != nil {
126+ return ports.SnapshotPublication{}, fmt.Errorf("snapshot visible: %w", err)
127+ }
128+ outPane := snapcodec.ManifestPane{ID: pane.id, StableID: pane.stableID, Cwd: pane.cwd, Process: pane.process}
129+ for i := 0; i < pane.sealed.ChunkCount(); i++ {
130+ ref, object, err := snapshotChunkObject(capture, pane.sealed.Chunk(i))
131+ if err != nil {
132+ return ports.SnapshotPublication{}, fmt.Errorf("snapshot history chunk: %w", err)
133+ }
134+ outPane.Sealed = append(outPane.Sealed, ref)
135+ capture.sealedRefs[pane.sealed.Chunk(i)] = ref
136+ if object != nil {
137+ objects = append(objects, *object)
138+ }
139+ }
140+ tail, err := marshalSnapshotTail(pane.tail)
141+ if err != nil {
142+ return ports.SnapshotPublication{}, err
143+ }
144+ tailObject, err := snapcodec.MarshalObject(snapcodec.HistoryTail, tail)
145+ if err != nil {
146+ return ports.SnapshotPublication{}, err
147+ }
148+ visibleObject, err := snapcodec.MarshalObject(snapcodec.Visible, visible)
149+ if err != nil {
150+ return ports.SnapshotPublication{}, err
151+ }
152+ outPane.Tail = objectRef(snapcodec.HistoryTail, tailObject)
153+ outPane.Visible = objectRef(snapcodec.Visible, visibleObject)
154+ objects = append(objects, tailObject, visibleObject)
155+ outTab.Panes = append(outTab.Panes, outPane)
156+ }
157+ manifest.Tabs = append(manifest.Tabs, outTab)
158+ }
159+ encoded, err := snapcodec.MarshalManifest(manifest)
160+ if err != nil {
161+ return ports.SnapshotPublication{}, err
162+ }
163+ return ports.SnapshotPublication{Name: capture.name, Generation: capture.generation, Manifest: encoded, Objects: objects}, nil
164+}
165+
166+// marshalSnapshotTail selects the canonical empty-tail encoding before any
167+// general history encoding. An empty tail is represented by its dedicated
168+// canonical blob; a non-empty copied tail retains its rows.
169+func marshalSnapshotTail(tail vt.HistoryView) ([]byte, error) {
170+ if tail.Len() == 0 {
171+ return vt.MarshalEmptyHistoryTail()
172+ }
173+ return vt.MarshalHistory(tail)
174+}
175+
176+func prepareSnapshotChunkCache(capture *snapshotCapture) error {
177+ sess := capture.session
178+ sess.snapshotMu.Lock()
179+ defer sess.snapshotMu.Unlock()
180+ if sess.snapshotChunkCache == nil {
181+ if sess.name == "" || sess.ephemeral {
182+ return fmt.Errorf("snapshot: chunk cache unavailable")
183+ }
184+ sess.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit)
185+ }
186+ pruneSnapshotChunkCacheLocked(sess, capture)
187+ return nil
188+}
189+
190+func snapshotChunkObject(capture *snapshotCapture, chunk *vt.HistoryChunk) (snapcodec.ObjectRef, *ports.SnapshotObject, error) {
191+ sess := capture.session
192+ sess.snapshotMu.Lock()
193+ defer sess.snapshotMu.Unlock()
194+ return sess.snapshotChunkCache.objectLocked(chunk)
195+}
196+
197+// markSnapshotCaptureObjectsPublished records that the immutable sealed
198+// history referenced by capture is available in this session's repository
199+// lineage. It is called only after Publish succeeds.
200+func markSnapshotCaptureObjectsPublished(capture *snapshotCapture) {
201+ if capture == nil || capture.session == nil {
202+ return
203+ }
204+ sess := capture.session
205+ sess.snapshotMu.Lock()
206+ defer sess.snapshotMu.Unlock()
207+ cache := sess.snapshotChunkCache
208+ if cache == nil {
209+ return
210+ }
211+ for chunk, ref := range capture.sealedRefs {
212+ if chunk != nil {
213+ cache.persisted[chunk] = ref
214+ }
215+ }
216+}
217+
218+// pruneSnapshotChunkCacheLocked retains only chunks referenced by captures that
219+// can still be published. Caller holds sess.snapshotMu.
220+func pruneSnapshotChunkCacheLocked(sess *session, capture *snapshotCapture) {
221+ if sess.snapshotChunkCache == nil {
222+ return
223+ }
224+ referenced := make(map[*vt.HistoryChunk]struct{})
225+ collectSnapshotCaptureChunks(referenced, capture)
226+ collectSnapshotCaptureChunks(referenced, sess.snapshotQueuedCapture)
227+ collectSnapshotCaptureChunks(referenced, sess.snapshotInFlightCapture)
228+ sess.snapshotChunkCache.pruneLocked(referenced)
229+}
230+
231+func collectSnapshotCaptureChunks(referenced map[*vt.HistoryChunk]struct{}, capture *snapshotCapture) {
232+ if capture == nil {
233+ return
234+ }
235+ for _, tab := range capture.tabs {
236+ for _, pane := range tab.panes {
237+ for i := 0; i < pane.sealed.ChunkCount(); i++ {
238+ if chunk := pane.sealed.Chunk(i); chunk != nil {
239+ referenced[chunk] = struct{}{}
240+ }
241+ }
242+ }
243+ }
244+}
245+
246+func objectRef(kind snapcodec.ObjectKind, object ports.SnapshotObject) snapcodec.ObjectRef {
247+ return snapcodec.ObjectRef{Kind: kind, Digest: object.Digest, Size: uint32(len(object.Data))}
248+}
A internal/usecase/daemon/snapshot_incremental_test.go +143 -0
@@ -0,0 +1,143 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
10+ "github.com/bnema/vev/pkg/renderer"
11+ "github.com/bnema/vev/pkg/vt"
12+)
13+
14+func TestMarshalSnapshotTailSelectsCanonicalEncoder(t *testing.T) {
15+ for _, tt := range []struct {
16+ name string
17+ fill bool
18+ }{
19+ {name: "empty tail"},
20+ {name: "non-empty tail", fill: true},
21+ } {
22+ t.Run(tt.name, func(t *testing.T) {
23+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 2})
24+ if tt.fill {
25+ require.NoError(t, history.Append([]renderer.Cell{{Rune: 'x'}}))
26+ }
27+ tail := history.SnapshotView().Tail()
28+
29+ got, err := marshalSnapshotTail(tail)
30+ require.NoError(t, err)
31+ if tt.fill {
32+ want, err := vt.MarshalHistory(tail)
33+ require.NoError(t, err)
34+ require.Equal(t, want, got)
35+ return
36+ }
37+ want, err := vt.MarshalEmptyHistoryTail()
38+ require.NoError(t, err)
39+ require.Equal(t, want, got)
40+ })
41+ }
42+}
43+
44+func TestIncrementalPublicationEncodesAfterPaneUnlock(t *testing.T) {
45+ d := New(nil, nil, nil)
46+ sess := newSnapshotTestSession(t, "work", false, "/work")
47+ capture, ok := d.captureSnapshotState(sess, 1)
48+ if !ok {
49+ t.Fatal("capture rejected named session")
50+ }
51+
52+ pane := sess.tabs[0].panes["pane-1"]
53+ pane.mu.Lock()
54+ done := make(chan error, 1)
55+ go func() {
56+ _, err := d.incrementalPublication(capture)
57+ done <- err
58+ }()
59+ select {
60+ case err := <-done:
61+ if err != nil {
62+ t.Fatalf("encode capture: %v", err)
63+ }
64+ case <-time.After(testWaitTimeout):
65+ pane.mu.Unlock()
66+ t.Fatal("visible encoding waited for pane lock")
67+ }
68+ pane.mu.Unlock()
69+}
70+
71+func TestSnapshotChunkCacheResistsWarmedOverCapacityHistoryScans(t *testing.T) {
72+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 6, ChunkRows: 1})
73+ for i := range 6 {
74+ require.NoError(t, history.Append([]renderer.Cell{{Rune: rune('a' + i)}}))
75+ }
76+ view := history.SnapshotView()
77+ require.Equal(t, 6, view.ChunkCount())
78+
79+ firstPayload, err := vt.MarshalHistoryChunk(view.Chunk(0))
80+ require.NoError(t, err)
81+ firstObject, err := snapcodec.MarshalObject(snapcodec.HistoryChunk, firstPayload)
82+ require.NoError(t, err)
83+ cache := newSnapshotChunkCache(2 * len(firstObject.Data))
84+ encodes := make(map[*vt.HistoryChunk]int)
85+ cache.marshalChunk = func(chunk *vt.HistoryChunk) ([]byte, error) {
86+ encodes[chunk]++
87+ return vt.MarshalHistoryChunk(chunk)
88+ }
89+
90+ for pass := range 3 {
91+ for i := range view.ChunkCount() {
92+ _, _, err := cache.objectLocked(view.Chunk(i))
93+ require.NoError(t, err)
94+ }
95+ require.LessOrEqual(t, cache.used, cache.limit)
96+ if pass == 0 {
97+ require.Len(t, cache.byPtr, 2)
98+ }
99+ }
100+
101+ // The cache admits the first bounded working set then rejects later scan
102+ // misses. Consequently, each warmed retained chunk is encoded only once
103+ // even though the history is repeatedly scanned oldest-to-newest.
104+ require.Equal(t, 1, encodes[view.Chunk(0)])
105+ require.Equal(t, 1, encodes[view.Chunk(1)])
106+ require.Equal(t, 3, encodes[view.Chunk(2)])
107+ require.Equal(t, 3, encodes[view.Chunk(5)])
108+
109+ isolated := newSnapshotChunkCache(cache.limit)
110+ isolatedEncodes := 0
111+ isolated.marshalChunk = func(chunk *vt.HistoryChunk) ([]byte, error) {
112+ isolatedEncodes++
113+ return vt.MarshalHistoryChunk(chunk)
114+ }
115+ _, _, err = isolated.objectLocked(view.Chunk(0))
116+ require.NoError(t, err)
117+ require.Equal(t, 1, isolatedEncodes, "a session cache must not share encoded history with another session")
118+}
119+
120+func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) {
121+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1})
122+ require.NoError(t, history.Append([]renderer.Cell{renderer.BlankCell()}))
123+ view := history.SnapshotView()
124+ visible := vt.NewScreen(1, 1).PrimaryVisibleSnapshot()
125+ d := New(nil, nil, nil)
126+ sess := newSnapshotTestSession(t, "work", false, "/work")
127+ capture := &snapshotCapture{session: sess, name: "work", generation: 1, tabs: []snapshotCaptureTab{{stableID: "t", cols: 1, rows: 1, panes: []snapshotCapturePane{{id: "p", stableID: "p", sealed: view, tail: view.Tail(), visible: visible}}}}}
128+ first, err := d.incrementalPublication(capture)
129+ if err != nil {
130+ t.Fatal(err)
131+ }
132+ capture.generation = 2
133+ second, err := d.incrementalPublication(capture)
134+ if err != nil {
135+ t.Fatal(err)
136+ }
137+ if len(first.Objects) == 0 || len(second.Objects) == 0 {
138+ t.Fatal("publication omitted required objects")
139+ }
140+ if got, want := sess.snapshotChunkCache.used, sess.snapshotChunkCache.limit; got > want {
141+ t.Fatalf("cache bytes = %d, limit = %d", got, want)
142+ }
143+}
A internal/usecase/daemon/snapshot_legacy_import.go +44 -0
@@ -0,0 +1,44 @@
1+package daemon
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/vev/internal/ports"
7+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
8+)
9+
10+func legacyPublication(snapshot snapcodec.Session) (ports.SnapshotPublication, error) {
11+ manifest := snapcodec.Manifest{Generation: 1, Name: snapshot.Name, CreatedAt: snapshot.CreatedAt, Active: snapshot.Active, Tabs: make([]snapcodec.ManifestTab, 0, len(snapshot.Tabs))}
12+ objects := make([]ports.SnapshotObject, 0)
13+ for _, tab := range snapshot.Tabs {
14+ outTab := snapcodec.ManifestTab{StableID: tab.StableID, Cols: tab.Cols, Rows: tab.Rows, NextPaneID: tab.NextPaneID, Focus: tab.Focus, Tree: tab.Tree, Panes: make([]snapcodec.ManifestPane, 0, len(tab.Panes))}
15+ for _, pane := range tab.Panes {
16+ outPane := snapcodec.ManifestPane{ID: pane.ID, StableID: pane.StableID, Cwd: pane.Cwd, Process: pane.Process}
17+ for i, payload := range pane.SealedChunks {
18+ object, err := snapcodec.MarshalObject(snapcodec.HistoryChunk, payload)
19+ if err != nil {
20+ return ports.SnapshotPublication{}, fmt.Errorf("snapshot legacy import: tab %q pane %q sealed chunk %d: marshal object: %w", tab.StableID, pane.ID, i, err)
21+ }
22+ outPane.Sealed = append(outPane.Sealed, objectRef(snapcodec.HistoryChunk, object))
23+ objects = append(objects, object)
24+ }
25+ tail, err := snapcodec.MarshalObject(snapcodec.HistoryTail, pane.Tail)
26+ if err != nil {
27+ return ports.SnapshotPublication{}, fmt.Errorf("snapshot legacy import: tab %q pane %q tail: marshal object: %w", tab.StableID, pane.ID, err)
28+ }
29+ visible, err := snapcodec.MarshalObject(snapcodec.Visible, pane.Visible)
30+ if err != nil {
31+ return ports.SnapshotPublication{}, fmt.Errorf("snapshot legacy import: tab %q pane %q visible: marshal object: %w", tab.StableID, pane.ID, err)
32+ }
33+ outPane.Tail, outPane.Visible = objectRef(snapcodec.HistoryTail, tail), objectRef(snapcodec.Visible, visible)
34+ objects = append(objects, tail, visible)
35+ outTab.Panes = append(outTab.Panes, outPane)
36+ }
37+ manifest.Tabs = append(manifest.Tabs, outTab)
38+ }
39+ encoded, err := snapcodec.MarshalManifest(manifest)
40+ if err != nil {
41+ return ports.SnapshotPublication{}, fmt.Errorf("snapshot: marshal legacy import manifest: %w", err)
42+ }
43+ return ports.SnapshotPublication{Name: snapshot.Name, Generation: 1, Manifest: encoded, Objects: objects}, nil
44+}
D internal/usecase/daemon/snapshot_notice_test.go +0 -121
@@ -1,121 +0,0 @@
1-package daemon
2-
3-import (
4- "errors"
5- "fmt"
6- "sync/atomic"
7- "testing"
8-
9- "github.com/stretchr/testify/mock"
10- "github.com/stretchr/testify/require"
11-
12- "github.com/bnema/vev/internal/domain"
13- portsmocks "github.com/bnema/vev/internal/ports/mocks"
14- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
15-)
16-
17-// TestSnapshotWriteFailureNotifiesGlobally proves a failed snapshot write surfaces
18-// as a GLOBAL notice: the failing session is never registered (it stands in for a
19-// session already torn down by the time its async write fails), yet a client
20-// attached to a DIFFERENT, surviving session still receives the toast. This is the
21-// whole point of routing through NotifyGlobal instead of a session-scoped notice.
22-func TestSnapshotWriteFailureNotifiesGlobally(t *testing.T) {
23- // newNoticeFixture registers a survivor session "work"/"manual" owned by ac.
24- d, _, survivorClient, _ := newNoticeFixture(t, newNoticeClock())
25- store := portsmocks.NewMockSnapshotStore(t)
26- WithSnapshotStore(store)(d)
27- d.procCwd = func(int) (string, error) { return "/pane", nil }
28- startSnapshotEncodeWorker(t, d)
29-
30- // The failing session is deliberately NOT added to d.sessions.
31- failing := newSnapshotTestSession(t, "failing", false, "/fallback")
32-
33- store.EXPECT().Write("failing", mock.Anything).RunAndReturn(func(string, []byte) error {
34- return errors.New("disk full")
35- }).Once()
36-
37- require.True(t, d.captureSession(failing))
38-
39- // The write fails on the worker goroutine; awaitToastCount is the bounded,
40- // poll-only completion signal. No require runs on the worker goroutine.
41- toasts := awaitToastCount(t, survivorClient, 1)
42- require.Equal(t, domain.NoticeSnapshotWrite, toasts[0].Code)
43- require.Equal(t, "couldn't save session failing; recent state may be lost on restart", toasts[0].Message)
44- require.Equal(t, domain.NoticeError, toasts[0].Severity)
45- require.Equal(t, domain.SessionID(""), toasts[0].SessionID,
46- "snapshot write failures are global, never scoped to the (possibly gone) failing session")
47- require.Contains(t, toasts[0].Details, "disk full")
48-}
49-
50-// TestCloseLastTabSaturatedSnapshotSurfacesNotice proves that when closing the
51-// last tab of a named session is refused because the snapshot worker cannot
52-// retain the final state, the session survives for a retry and the still
53-// attached client is told why, instead of the close silently no-oping.
54-//
55-// The snapshot worker is saturated deterministically with the same fixture as
56-// the kill-session ordering tests: one capture blocked in marshal, one in the
57-// bounded queue, and the terminal-retention fallback filled to capacity. No
58-// assertion runs on the worker goroutine; the marshal gate is released only on
59-// cleanup, after every assertion has run on the test goroutine.
60-func TestCloseLastTabSaturatedSnapshotSurfacesNotice(t *testing.T) {
61- store := discardSnapshotStore{}
62- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
63- WithSnapshotStore(store)(d)
64- startSnapshotEncodeWorker(t, d)
65-
66- entered := make(chan struct{})
67- release := make(chan struct{})
68- // Release the sole blocked marshal on the test goroutine before the worker
69- // is stopped in cleanup, so teardown never waits on the gate.
70- defer close(release)
71- var calls atomic.Int32
72- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
73- if calls.Add(1) == 1 {
74- close(entered)
75- <-release
76- }
77- return snapcodec.Marshal(s)
78- }
79-
80- active := newSnapshotTestSession(t, "active", false, "/work")
81- queued := newSnapshotTestSession(t, "queued", false, "/work")
82- markSnapshotDirty(active)
83- markSnapshotDirty(queued)
84- require.True(t, d.scheduleSnapshot(active))
85- <-entered // the worker is now parked inside marshal for active
86- require.True(t, d.scheduleSnapshot(queued), "one capture waits in the bounded queue")
87- for i := range snapshotFinalQueueCapacity {
88- s := newSnapshotTestSession(t, fmt.Sprintf("terminal-%d", i), false, "/work")
89- markSnapshotDirty(s)
90- require.True(t, d.scheduleFinalSnapshot(s), "fill the terminal-retention fallback to capacity")
91- }
92-
93- // A single-tab named session whose client is still attached.
94- target := newSnapshotTestSession(t, "target", false, "/work")
95- tr, _ := newCapturingTransport(t)
96- ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
97- ac.initOverlays()
98- ac.setSession(target)
99- target.client = ac
100- d.mu.Lock()
101- d.sessions[target.id] = target
102- d.mu.Unlock()
103- require.Len(t, target.tabs, 1)
104-
105- d.closeTab(target, target.tabs[0], false)
106-
107- // The refused close retains the session for a retry.
108- d.mu.Lock()
109- require.Same(t, target, d.sessions[target.id], "a refused close of the last tab must retain the session")
110- d.mu.Unlock()
111-
112- // The refusal is recorded in history and shown on the still-attached client.
113- hist := d.notices.history()
114- require.NotEmpty(t, hist, "the refused close must be recorded as a notice")
115- require.Equal(t, domain.NoticeSnapshotSaturated, hist[0].Code)
116- require.Equal(t, target.id, hist[0].SessionID)
117-
118- toasts := awaitToastCount(t, ac, 1)
119- require.Equal(t, domain.NoticeSnapshotSaturated, toasts[0].Code)
120- require.Equal(t, "couldn't close tab: session state not yet saved; try again", toasts[0].Message)
121-}
A internal/usecase/daemon/snapshot_queue.go +144 -0
@@ -0,0 +1,144 @@
1+package daemon
2+
3+import "context"
4+
5+func (d *Daemon) scheduleSnapshot(sess *session) bool {
6+ return d.scheduleSnapshotWithFinalFallback(sess, false)
7+}
8+
9+// scheduleFinalSnapshot captures the terminal state even when an older capture
10+// is pending. A removed session cannot be retried by the repository scheduler, so the
11+// worker drains its retained terminal state before it stops.
12+func (d *Daemon) scheduleFinalSnapshot(sess *session) bool {
13+ return d.scheduleSnapshotWithFinalFallback(sess, true)
14+}
15+
16+func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bool {
17+ if d == nil || sess == nil || !d.snapsEnabled || d.snapshotRepository == nil || !sess.snapEligible.Load() {
18+ return true
19+ }
20+ kind := snapshotAttemptRoutine
21+ if final {
22+ kind = snapshotAttemptForced
23+ }
24+ sess.snapshotMu.Lock()
25+ if sess.snapshotQuarantined || !sess.snapDirty.Load() {
26+ available := !sess.snapshotQuarantined
27+ sess.snapshotMu.Unlock()
28+ return available
29+ }
30+ if final && sess.snapshotGeneration > sess.snapshotForcedGeneration {
31+ // Do not let an older queued or in-flight routine publication satisfy a
32+ // terminal checkpoint. The intent is retained until a publication at
33+ // this generation (or newer) succeeds.
34+ sess.snapshotForcedGeneration = sess.snapshotGeneration
35+ }
36+ if sess.snapshotPending ||
37+ (!final && !sess.snapshotNextEligibleAt.IsZero() && d.clock.Now().Before(sess.snapshotNextEligibleAt)) {
38+ sess.snapshotMu.Unlock()
39+ return true
40+ }
41+ if sess.snapshotPublicationContext == nil {
42+ sess.snapshotPublicationContext, sess.snapshotPublicationCancel = context.WithCancel(context.Background())
43+ }
44+ // A capture's repository generation advances only from the last successful
45+ // repository publication. Mutations are independently versioned, so several
46+ // changes coalesced before this checkpoint still publish the immediate next
47+ // repository generation and a failed attempt retries that same generation.
48+ generation := sess.snapshotPublishedGeneration + 1
49+ mutationRevision := sess.snapshotGeneration
50+ // A restored or test-created dirty session can predate mutation revision
51+ // tracking. Normalize that state into the first revision before capture.
52+ if mutationRevision == 0 {
53+ mutationRevision = 1
54+ sess.snapshotGeneration = mutationRevision
55+ }
56+ publicationContext := sess.snapshotPublicationContext
57+ sess.snapshotPendingCaptures++
58+ sess.snapshotPending = true
59+ sess.signalSnapshotChangedLocked()
60+ sess.snapshotMu.Unlock()
61+
62+ capture, ok := d.captureSnapshotState(sess, generation)
63+ if capture != nil {
64+ capture.mutationRevision = mutationRevision
65+ capture.attemptKind = kind
66+ capture.publicationContext = publicationContext
67+ }
68+ if !ok {
69+ d.finishSnapshotCapture(capture, false)
70+ return false
71+ }
72+ sess.snapshotMu.Lock()
73+ quarantined := sess.snapshotQuarantined
74+ if !quarantined {
75+ sess.snapshotQueuedCapture = capture
76+ }
77+ sess.snapshotMu.Unlock()
78+ if quarantined {
79+ d.finishSnapshotCapture(capture, false)
80+ return false
81+ }
82+ if d.enqueueSnapshotCapture(capture) || (final && d.enqueueFinalSnapshotCapture(capture)) {
83+ return true
84+ }
85+ // Coalesce under saturation or shutdown: the latest state stays dirty and
86+ // will be captured on a later tick once an active worker has room.
87+ d.finishSnapshotCapture(capture, false)
88+ return false
89+}
90+
91+// enqueueSnapshotCapture serializes worker shutdown with the non-blocking
92+// queue send. The queue is deliberately never closed: producers can race
93+// shutdown without risking a send-on-closed panic.
94+func (d *Daemon) enqueueSnapshotCapture(capture *snapshotCapture) bool {
95+ d.snapshotWorkerMu.Lock()
96+ defer d.snapshotWorkerMu.Unlock()
97+ if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
98+ return false
99+ }
100+ select {
101+ case d.snapshotJobs <- capture:
102+ return true
103+ default:
104+ return false
105+ }
106+}
107+
108+// enqueueFinalSnapshotCapture hands terminal captures to the existing worker
109+// without making session teardown wait behind its normal bounded queue. A
110+// blocked worker retains at most one terminal capture per session, replacing
111+// stale state with the newest immutable capture.
112+func (d *Daemon) enqueueFinalSnapshotCapture(capture *snapshotCapture) bool {
113+ if capture == nil || capture.session == nil {
114+ return false
115+ }
116+ d.snapshotWorkerMu.Lock()
117+ if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
118+ d.snapshotWorkerMu.Unlock()
119+ return false
120+ }
121+ if d.snapshotFinalJobs == nil {
122+ d.snapshotFinalJobs = make(map[*session]*snapshotCapture)
123+ }
124+ replaced, exists := d.snapshotFinalJobs[capture.session]
125+ if !exists && len(d.snapshotFinalJobs) >= snapshotFinalQueueCapacity {
126+ d.snapshotWorkerMu.Unlock()
127+ d.log.Warn("terminal snapshot retention saturated; capture rejected", "session", capture.name, "capacity", snapshotFinalQueueCapacity)
128+ return false
129+ }
130+ // Finish the stale capture before publishing its replacement. Otherwise a
131+ // fast worker could persist the replacement and then have this stale failure
132+ // mark the session dirty again.
133+ d.finishSnapshotCapture(replaced, false)
134+ if !exists {
135+ d.snapshotFinalOrder = append(d.snapshotFinalOrder, capture.session)
136+ }
137+ d.snapshotFinalJobs[capture.session] = capture
138+ select {
139+ case d.snapshotWorkerFinalWake <- struct{}{}:
140+ default:
141+ }
142+ d.snapshotWorkerMu.Unlock()
143+ return true
144+}
A internal/usecase/daemon/snapshot_restore.go +312 -0
@@ -0,0 +1,312 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "fmt"
6+ "math"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/usecase/layout"
10+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
11+ "github.com/bnema/vev/pkg/vt"
12+)
13+
14+func (d *Daemon) closeRestoreDone() {
15+ d.restoreOnce.Do(func() { close(d.restoreDone) })
16+}
17+
18+func (d *Daemon) restoreSnapshots(ctx context.Context) {
19+ if d == nil {
20+ return
21+ }
22+ defer d.closeRestoreDone()
23+ if ns := d.noticeStore; ns != nil {
24+ claimed, err := ns.Claim()
25+ if err != nil {
26+ d.log.Warn("claiming pending notices failed", "err", err)
27+ } else {
28+ for _, n := range claimed {
29+ d.notices.record(n)
30+ d.notices.queueGlobal(n)
31+ }
32+ // Do not discard the claim until every notice has been recorded and
33+ // queued. An Ack failure is safe: the claim is replayed at startup.
34+ if err := ns.Ack(); err != nil {
35+ d.log.Warn("acknowledging pending notices failed", "err", err)
36+ }
37+ }
38+ }
39+ if !d.snapsEnabled {
40+ return
41+ }
42+ if d.snapshotRepository != nil {
43+ d.restoreIncrementalSnapshots(ctx)
44+ }
45+
46+}
47+
48+func validateRestoreSessionSnapshot(snap snapcodec.Session) error {
49+ if len(snap.Tabs) == 0 {
50+ if snap.Active != 0 {
51+ return fmt.Errorf("snapshot: active tab out of range")
52+ }
53+ } else if int(snap.Active) >= len(snap.Tabs) {
54+ return fmt.Errorf("snapshot: active tab out of range")
55+ }
56+ if snap.Name == "" {
57+ return fmt.Errorf("snapshot: empty session name")
58+ }
59+ if snap.CreatedAt > math.MaxInt64 {
60+ return fmt.Errorf("snapshot: created_at overflows int64")
61+ }
62+ return nil
63+}
64+
65+// restoreSession owns the restored tabs until registration succeeds. Every
66+// unsuccessful path closes their PTYs and cancels their shared session context.
67+func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session, repositoryGeneration uint64) error {
68+ if err := ctx.Err(); err != nil {
69+ return err
70+ }
71+ if err := validateRestoreSessionSnapshot(snap); err != nil {
72+ return err
73+ }
74+ if d.restoredSessionAlreadyExists(snap.Name) {
75+ return nil
76+ }
77+
78+ parent := d.serveCtx
79+ if parent == nil {
80+ parent = context.Background()
81+ }
82+ sctx, cancel := context.WithCancel(parent)
83+ opened, err := d.restoreSnapshotTabs(ctx, sctx, snap)
84+ if err != nil {
85+ cancel()
86+ return err
87+ }
88+ if len(opened) == 0 {
89+ cancel()
90+ return nil
91+ }
92+ ownsOpened := true
93+ defer func() {
94+ if ownsOpened {
95+ closeRestoredTabs(opened)
96+ cancel()
97+ }
98+ }()
99+
100+ sess := d.newRestoredSession(snap, sctx, cancel, opened)
101+ // The loaded manifest is the repository head for this name. Future dirty
102+ // checkpoints must continue from it rather than reuse generation one.
103+ sess.snapshotPublishedGeneration = repositoryGeneration
104+ registered, err := d.persistAndRegisterRestoredSession(ctx, sess)
105+ if err != nil || !registered {
106+ return err
107+ }
108+ ownsOpened = false
109+ for _, tb := range opened {
110+ d.startTabGoroutines(sess, tb)
111+ }
112+ return nil
113+}
114+
115+func (d *Daemon) restoredSessionAlreadyExists(name string) bool {
116+ d.mu.Lock()
117+ defer d.mu.Unlock()
118+ return d.closing || d.findByNameLocked(name) != nil
119+}
120+
121+func (d *Daemon) restoreSnapshotTabs(ctx, sctx context.Context, snap snapcodec.Session) ([]*tab, error) {
122+ // Restore runs before client Hello. Future panes therefore inherit the
123+ // daemon's startup environment until an attach supplies terminal capability.
124+ restoreTerm := terminalEnv{}
125+ restoreEnv := copyEnvironment(d.baseEnv)
126+ allowlist := d.restoreProcessAllowlistSnapshot()
127+ stoppedTabNames := d.restoredSessionTabNames(snap.Name)
128+ opened := make([]*tab, 0, len(snap.Tabs))
129+ for index, tabSnap := range snap.Tabs {
130+ if err := ctx.Err(); err != nil {
131+ closeRestoredTabs(opened)
132+ return nil, err
133+ }
134+ tabName := ""
135+ if index < len(stoppedTabNames) {
136+ tabName = stoppedTabNames[index]
137+ }
138+ tb, err := d.restoreSnapshotTab(ctx, sctx, snap.Name, tabName, tabSnap, restoreEnv, restoreTerm, allowlist)
139+ if err != nil {
140+ closeRestoredTabs(opened)
141+ return nil, err
142+ }
143+ opened = append(opened, tb)
144+ }
145+ return opened, nil
146+}
147+
148+// closeRestoredTabs releases every PTY and pane context still owned by a
149+// failed restore. Registered sessions transfer this ownership to the daemon.
150+func closeRestoredTabs(tabs []*tab) {
151+ for _, tb := range tabs {
152+ tb.closeAllPanes()
153+ }
154+}
155+
156+func (d *Daemon) restoredSessionTabNames(name string) []string {
157+ d.mu.Lock()
158+ defer d.mu.Unlock()
159+ return append([]string(nil), d.stopped[name].tabNames...)
160+}
161+
162+func (d *Daemon) restoreSnapshotTab(ctx, sctx context.Context, sessionName, tabName string, tabSnap snapcodec.Tab, restoreEnv []string, restoreTerm terminalEnv, allowlist map[string]struct{}) (*tab, error) {
163+ tbSize := domain.Size{Cols: int(tabSnap.Cols), Rows: int(tabSnap.Rows)}
164+ if !tbSize.Valid() {
165+ return nil, fmt.Errorf("snapshot: invalid tab size")
166+ }
167+ placements, ok := layout.Solve(tabSnap.Tree.Root, domain.Rect{Width: tbSize.Cols, Height: tbSize.Rows})
168+ if !ok {
169+ return nil, fmt.Errorf("snapshot: unsolvable layout")
170+ }
171+ placementByPane := make(map[layout.PaneID]domain.Rect, len(placements))
172+ for _, placement := range placements {
173+ placementByPane[placement.ID] = placement.Content
174+ }
175+ tabStableID := tabSnap.StableID
176+ if tabStableID == "" {
177+ var err error
178+ tabStableID, err = newStableID("t")
179+ if err != nil {
180+ return nil, fmt.Errorf("snapshot: generating tab identity: %w", err)
181+ }
182+ }
183+ tb := &tab{stableID: tabStableID, name: tabName, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize}
184+ if tb.nextPaneID <= 0 {
185+ tb.nextPaneID = 1
186+ }
187+ tb.ctx, tb.cancel = context.WithCancel(sctx)
188+ for _, paneSnap := range tabSnap.Panes {
189+ if err := ctx.Err(); err != nil {
190+ tb.closeAllPanes()
191+ return nil, err
192+ }
193+ contentRect, ok := placementByPane[paneSnap.ID]
194+ if !ok {
195+ tb.closeAllPanes()
196+ return nil, fmt.Errorf("snapshot: missing pane placement")
197+ }
198+ p, err := d.restoreSnapshotPane(ctx, sessionName, tabStableID, paneSnap, contentRect, tbSize, restoreEnv, restoreTerm, allowlist, tb.ctx)
199+ if err != nil {
200+ tb.closeAllPanes()
201+ return nil, err
202+ }
203+ tb.panes[paneSnap.ID] = p
204+ }
205+ return tb, nil
206+}
207+
208+func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStableID string, paneSnap snapcodec.Pane, contentRect domain.Rect, tabSize domain.Size, restoreEnv []string, restoreTerm terminalEnv, allowlist map[string]struct{}, tabCtx context.Context) (*pane, error) {
209+ paneStableID := paneSnap.StableID
210+ if paneStableID == "" {
211+ var err error
212+ paneStableID, err = newStableID("p")
213+ if err != nil {
214+ return nil, fmt.Errorf("snapshot: generating pane identity: %w", err)
215+ }
216+ }
217+ command, args := d.ptyCommand(restoreEnv)
218+ pty, err := d.ptys.Open(ctx, command, args, childEnvFrom(restoreEnv, sessionName, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, restorePTYSize(contentRect, tabSize))
219+ if err != nil {
220+ return nil, err
221+ }
222+ p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, restorePTYSize(contentRect, tabSize))
223+ p.ctx, p.cancel = context.WithCancel(tabCtx)
224+ p.rect = contentRect
225+ if err := restorePaneTerminal(p, paneSnap); err != nil {
226+ p.cancel()
227+ _ = pty.Close()
228+ return nil, err
229+ }
230+ if decision := planProcessRestore(paneSnap.Process, allowlist); decision.Restore {
231+ if _, err := pty.Write([]byte(decision.Command + "\n")); err != nil {
232+ d.log.Warn("writing snapshot restore command failed", "err", err, "session", sessionName, "pane", paneSnap.ID)
233+ d.NotifyGlobal(domain.NoticeWarn, domain.NoticeAutoResume, "couldn't restore the running program in session "+sessionName, err)
234+ }
235+ }
236+ return p, nil
237+}
238+
239+func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab) *session {
240+ sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: tabs, active: int(snap.Active), terminal: terminalEnv{}, env: copyEnvironment(d.baseEnv), createdAt: int64(snap.CreatedAt), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)}
241+ sess.snapEligible.Store(true)
242+ if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {
243+ sess.cwd = snap.Tabs[0].Panes[0].Cwd
244+ }
245+ d.mu.Lock()
246+ if stopped, ok := d.stopped[snap.Name]; ok {
247+ sess.mruAt.Store(stopped.lastUsedSeq)
248+ }
249+ d.mu.Unlock()
250+ return sess
251+}
252+
253+// persistAndRegisterRestoredSession atomically transfers a successfully
254+// persisted session to the daemon. The caller retains cleanup on false/error.
255+func (d *Daemon) persistAndRegisterRestoredSession(ctx context.Context, sess *session) (bool, error) {
256+ if err := d.persist.Save(sess.persistRecordLocked(sess.createdAt)); err != nil {
257+ return false, err
258+ }
259+ d.mu.Lock()
260+ defer d.mu.Unlock()
261+ if d.closing || d.findByNameLocked(sess.name) != nil || ctx.Err() != nil {
262+ return false, nil
263+ }
264+ sess.id = domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
265+ d.nextID++
266+ delete(d.stopped, sess.name)
267+ d.sessions[sess.id] = sess
268+ sess.snapDirty.Store(false)
269+ return true, nil
270+}
271+
272+func restorePaneTerminal(p *pane, snap snapcodec.Pane) error {
273+ if len(snap.Tail) == 0 {
274+ return fmt.Errorf("snapshot history: missing tail blob")
275+ }
276+ if len(snap.Visible) == 0 {
277+ return fmt.Errorf("snapshot visible: missing visible blob")
278+ }
279+ p.mu.Lock()
280+ defer p.mu.Unlock()
281+ screen, err := vt.NewScreenWithRestoredHistory(p.screen.Frame.Width, p.screen.Frame.Height, vt.HistoryConfig{MaxRows: defaultScrollbackRows, MaxCells: defaultScrollbackCells}, snap.SealedChunks, snap.Tail)
282+ if err != nil {
283+ return fmt.Errorf("snapshot history: %w", err)
284+ }
285+ if err := screen.RestorePrimaryVisible(snap.Visible); err != nil {
286+ return fmt.Errorf("snapshot visible: %w", err)
287+ }
288+ p.screen = screen
289+ p.history = screen.History()
290+ return nil
291+}
292+
293+func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size {
294+ cols := contentRect.Width
295+ if cols <= 0 {
296+ cols = tabSize.Cols
297+ }
298+ if cols < layout.MinPaneCols {
299+ cols = layout.MinPaneCols
300+ }
301+ rows := contentRect.Height
302+ if rows <= 0 {
303+ rows = min(max(tabSize.Rows, layout.MinPaneRows), 24)
304+ }
305+ if rows < layout.MinPaneRows {
306+ rows = layout.MinPaneRows
307+ }
308+ return domain.Size{Cols: cols, Rows: rows}
309+}
310+
311+// scheduleSnapshot captures a session only once while an immutable capture is
312+// queued or being written. It never waits for the bounded worker queue.
A internal/usecase/daemon/snapshot_restore_incremental.go +215 -0
@@ -0,0 +1,215 @@
1+package daemon
2+
3+import (
4+ "bytes"
5+ "context"
6+ "crypto/sha256"
7+ "fmt"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
12+)
13+
14+// restoreIncrementalSnapshots imports v3 only before listing repository
15+// sessions, then restores each independently. A corrupt session therefore
16+// cannot prevent an unrelated saved session from starting.
17+func (d *Daemon) restoreIncrementalSnapshots(ctx context.Context) {
18+ d.importLegacySnapshots(ctx)
19+ names, err := d.snapshotRepository.List(ctx)
20+ if err != nil {
21+ d.log.Warn("listing session snapshots failed", "err", err)
22+ d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore, "couldn't load saved sessions after restart", err)
23+ return
24+ }
25+ for _, name := range names {
26+ if err := ctx.Err(); err != nil {
27+ return
28+ }
29+ generation, err := d.snapshotRepository.Load(ctx, name)
30+ if err != nil {
31+ d.snapshotRestoreFailure(name, err)
32+ continue
33+ }
34+ if generation.Fallback {
35+ d.NotifyGlobal(domain.NoticeWarn, domain.NoticeSnapshotRestore, "restored an older saved checkpoint for session "+name, nil)
36+ }
37+ snapshot, err := sessionFromGeneration(generation)
38+ if err != nil {
39+ d.snapshotRestoreFailure(name, err)
40+ continue
41+ }
42+ if err := d.restoreSession(ctx, snapshot, generation.Generation); err != nil {
43+ if ctx.Err() == nil {
44+ d.snapshotRestoreFailure(name, err)
45+ }
46+ }
47+ }
48+}
49+
50+func (d *Daemon) snapshotRestoreFailure(name string, err error) {
51+ d.log.Warn("restoring session snapshot failed", "err", err, "session", name)
52+ d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore, "couldn't restore session "+name+" after restart", err)
53+}
54+
55+func sessionFromGeneration(generation ports.SnapshotGeneration) (snapcodec.Session, error) {
56+ manifest, err := snapcodec.UnmarshalManifest(generation.Manifest)
57+ if err != nil {
58+ return snapcodec.Session{}, err
59+ }
60+ if manifest.Name != generation.Name || manifest.Generation != generation.Generation {
61+ return snapcodec.Session{}, fmt.Errorf("snapshot: generation identity mismatch")
62+ }
63+ result := snapcodec.Session{Name: manifest.Name, CreatedAt: manifest.CreatedAt, Active: manifest.Active, Tabs: make([]snapcodec.Tab, 0, len(manifest.Tabs))}
64+ for _, tab := range manifest.Tabs {
65+ outTab := snapcodec.Tab{StableID: tab.StableID, Cols: tab.Cols, Rows: tab.Rows, NextPaneID: tab.NextPaneID, Focus: tab.Focus, Tree: tab.Tree, Panes: make([]snapcodec.Pane, 0, len(tab.Panes))}
66+ for _, pane := range tab.Panes {
67+ outPane := snapcodec.Pane{ID: pane.ID, StableID: pane.StableID, Cwd: pane.Cwd, Process: pane.Process}
68+ for _, ref := range pane.Sealed {
69+ data, err := generationObject(generation, ref, snapcodec.HistoryChunk)
70+ if err != nil {
71+ return snapcodec.Session{}, err
72+ }
73+ outPane.SealedChunks = append(outPane.SealedChunks, data)
74+ }
75+ if outPane.Tail, err = generationObject(generation, pane.Tail, snapcodec.HistoryTail); err != nil {
76+ return snapcodec.Session{}, err
77+ }
78+ if outPane.Visible, err = generationObject(generation, pane.Visible, snapcodec.Visible); err != nil {
79+ return snapcodec.Session{}, err
80+ }
81+ outTab.Panes = append(outTab.Panes, outPane)
82+ }
83+ result.Tabs = append(result.Tabs, outTab)
84+ }
85+ return result, nil
86+}
87+
88+func generationObject(generation ports.SnapshotGeneration, ref snapcodec.ObjectRef, kind snapcodec.ObjectKind) ([]byte, error) {
89+ if ref.Kind != kind {
90+ return nil, fmt.Errorf("snapshot: object kind mismatch")
91+ }
92+ data, ok := generation.Objects[ref.Digest]
93+ if !ok || uint32(len(data)) != ref.Size || sha256.Sum256(data) != ref.Digest {
94+ return nil, fmt.Errorf("snapshot: missing object")
95+ }
96+ gotKind, payload, err := snapcodec.UnmarshalObject(data)
97+ if err != nil || gotKind != kind {
98+ return nil, fmt.Errorf("snapshot: invalid object")
99+ }
100+ return append([]byte(nil), payload...), nil
101+}
102+
103+// importLegacySnapshots is intentionally best effort per session. It deletes
104+// a legacy blob only after publication and a full repository reload verify the
105+// exact converted session, leaving failures retryable on the next startup.
106+func (d *Daemon) importLegacySnapshots(ctx context.Context) {
107+ if d.legacySnapshots == nil || d.snapshotRepository == nil || ctx.Err() != nil {
108+ return
109+ }
110+ existing, err := d.snapshotRepository.List(ctx)
111+ if err != nil {
112+ d.log.Warn("listing incremental snapshots before import failed", "err", err)
113+ return
114+ }
115+ present := make(map[string]struct{}, len(existing))
116+ for _, name := range existing {
117+ present[name] = struct{}{}
118+ }
119+ legacy, err := d.legacySnapshots.LoadLegacy(ctx)
120+ if err != nil {
121+ d.log.Warn("loading legacy snapshots failed", "err", err)
122+ return
123+ }
124+ for _, blob := range legacy {
125+ if ctx.Err() != nil {
126+ return
127+ }
128+ if _, ok := present[blob.Name]; ok {
129+ d.retryLegacyDelete(ctx, blob)
130+ continue
131+ }
132+ snapshot, err := snapcodec.Unmarshal(blob.Data)
133+ if err != nil || snapshot.Name != blob.Name {
134+ d.log.Warn("importing legacy snapshot failed", "session", blob.Name, "err", err)
135+ continue
136+ }
137+ publication, err := legacyPublication(snapshot)
138+ if err == nil {
139+ err = d.snapshotRepository.Publish(ctx, publication)
140+ }
141+ if err == nil {
142+ var generation ports.SnapshotGeneration
143+ generation, err = d.snapshotRepository.Load(ctx, snapshot.Name)
144+ if err == nil {
145+ err = verifyLegacyImportGeneration(generation, publication)
146+ }
147+ }
148+ if err != nil {
149+ d.log.Warn("importing legacy snapshot failed", "session", blob.Name, "err", err)
150+ continue
151+ }
152+ d.rememberLegacyDelete(blob)
153+ if err := d.legacySnapshots.DeleteVerifiedLegacy(ctx, blob); err != nil {
154+ d.log.Warn("deleting imported legacy snapshot failed", "session", blob.Name, "err", err)
155+ continue
156+ }
157+ d.forgetLegacyDelete(blob.Name)
158+ present[blob.Name] = struct{}{}
159+ }
160+}
161+
162+func verifyLegacyImportGeneration(generation ports.SnapshotGeneration, publication ports.SnapshotPublication) error {
163+ if generation.Name != publication.Name || generation.Generation != publication.Generation || !bytes.Equal(generation.Manifest, publication.Manifest) {
164+ return fmt.Errorf("snapshot: legacy import verification mismatch")
165+ }
166+ expected := make(map[ports.SnapshotDigest][]byte, len(publication.Objects))
167+ for _, object := range publication.Objects {
168+ if sha256.Sum256(object.Data) != object.Digest {
169+ return fmt.Errorf("snapshot: legacy import publication digest mismatch")
170+ }
171+ expected[object.Digest] = object.Data
172+ }
173+ if len(generation.Objects) != len(expected) {
174+ return fmt.Errorf("snapshot: legacy import verification mismatch")
175+ }
176+ for digest, expectedData := range expected {
177+ actual, ok := generation.Objects[digest]
178+ if !ok || !bytes.Equal(actual, expectedData) || sha256.Sum256(actual) != digest {
179+ return fmt.Errorf("snapshot: legacy import verification mismatch")
180+ }
181+ }
182+ return nil
183+}
184+
185+func (d *Daemon) rememberLegacyDelete(blob ports.LegacySnapshot) {
186+ d.legacyImportMu.Lock()
187+ if d.legacyImportPending == nil {
188+ d.legacyImportPending = make(map[string][]byte)
189+ }
190+ d.legacyImportPending[blob.Name] = append([]byte(nil), blob.Data...)
191+ d.legacyImportMu.Unlock()
192+}
193+
194+func (d *Daemon) forgetLegacyDelete(name string) {
195+ d.legacyImportMu.Lock()
196+ delete(d.legacyImportPending, name)
197+ d.legacyImportMu.Unlock()
198+}
199+
200+// retryLegacyDelete only acts on a blob which this daemon has already
201+// published and exactly reloaded. An unrelated pre-existing generation must
202+// continue to leave its legacy counterpart untouched.
203+func (d *Daemon) retryLegacyDelete(ctx context.Context, blob ports.LegacySnapshot) {
204+ d.legacyImportMu.Lock()
205+ pending, ok := d.legacyImportPending[blob.Name]
206+ d.legacyImportMu.Unlock()
207+ if !ok || !bytes.Equal(pending, blob.Data) {
208+ return
209+ }
210+ if err := d.legacySnapshots.DeleteVerifiedLegacy(ctx, blob); err != nil {
211+ d.log.Warn("deleting imported legacy snapshot failed", "session", blob.Name, "err", err)
212+ return
213+ }
214+ d.forgetLegacyDelete(blob.Name)
215+}
M internal/usecase/daemon/snapshot_restore_test.go +244 -730
@@ -2,794 +2,308 @@ package daemon
22
33 import (
44 "context"
5- "encoding/binary"
65 "errors"
7- "io"
86 "math"
97 "sync"
108 "testing"
11- "time"
129
1310 "github.com/stretchr/testify/require"
1411
1512 "github.com/bnema/vev/internal/domain"
1613 "github.com/bnema/vev/internal/ports"
17- scopy "github.com/bnema/vev/internal/usecase/copy"
1814 "github.com/bnema/vev/internal/usecase/layout"
1915 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
2016 "github.com/bnema/vev/pkg/renderer"
2117 "github.com/bnema/vev/pkg/vt"
2218 )
2319
24-func terminalPane(t *testing.T, id, stableID, cwd string, historyRows, visibleRows [][]renderer.Cell) snapcodec.Pane {
25- t.Helper()
26- h := vt.NewHistory(vt.HistoryConfig{MaxRows: defaultScrollbackRows})
27- for _, row := range historyRows {
28- h.Append(row)
29- }
30- sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView())
31- require.NoError(t, err)
32- frame := renderer.NewFrame(40, 24)
33- for y, row := range visibleRows {
34- copy(frame.Row(y), row)
35- }
36- visible, err := vt.MarshalVisible(frame)
37- require.NoError(t, err)
38- return snapcodec.Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible}
20+type snapshotAcceptanceRepository struct {
21+ mu sync.Mutex
22+ names []string
23+ generations map[string]ports.SnapshotGeneration
24+ publishErr error
25+ listErr error
26+ loadErr error
27+ publishes []ports.SnapshotPublication
28+ loadMutate func(ports.SnapshotGeneration, ports.SnapshotPublication) ports.SnapshotGeneration
3929 }
4030
41-func TestRestorePaneTerminalRejectsMissingOrMalformedCanonicalBlobs(t *testing.T) {
42- valid := terminalPane(t, "pane", "p", "/work", nil, nil)
43- tests := []struct {
44- name string
45- mutate func(*snapcodec.Pane)
46- }{
47- {name: "missing tail", mutate: func(p *snapcodec.Pane) { p.Tail = nil }},
48- {name: "missing visible", mutate: func(p *snapcodec.Pane) { p.Visible = nil }},
49- {name: "malformed tail", mutate: func(p *snapcodec.Pane) { p.Tail = []byte("bad") }},
50- {name: "malformed visible", mutate: func(p *snapcodec.Pane) { p.Visible = []byte("bad") }},
31+func (r *snapshotAcceptanceRepository) Publish(ctx context.Context, p ports.SnapshotPublication) error {
32+ if err := ctx.Err(); err != nil {
33+ return err
5134 }
52- for _, tt := range tests {
53- t.Run(tt.name, func(t *testing.T) {
54- snap := valid
55- tt.mutate(&snap)
56- p := newPane("pane", newRestorePTY(), domain.Size{Cols: 40, Rows: 24})
57- require.Error(t, restorePaneTerminal(p, snap))
58- })
35+ r.mu.Lock()
36+ defer r.mu.Unlock()
37+ r.publishes = append(r.publishes, p)
38+ if r.publishErr != nil {
39+ return r.publishErr
5940 }
60-}
61-
62-func TestRestoreSnapshotsNilDaemonDoesNotPanic(t *testing.T) {
63- var d *Daemon
64- require.NotPanics(t, func() { d.restoreSnapshots(t.Context()) })
65-}
66-
67-func TestRestoreSessionRejectsInvalidActiveTab(t *testing.T) {
68- factory := &restorePTYFactory{}
69- d := newTestDaemon(t, factory, stubClock{})
70-
71- err := d.restoreSession(context.Background(), snapcodec.Session{Name: "bad-active", Active: 1, Tabs: []snapcodec.Tab{{
72- Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/tmp")},
73- }}})
74-
75- require.Error(t, err)
76- require.Empty(t, factory.opens)
77-}
78-
79-func TestRestoreSessionClosesOpenedPTYWhenTerminalRestoreFails(t *testing.T) {
80- factory := &restorePTYFactory{}
81- d := newTestDaemon(t, factory, stubClock{})
82- pane := emptyTerminalPane(t, "pane-1", "/tmp")
83- pane.Tail = []byte("malformed")
84-
85- err := d.restoreSession(context.Background(), snapcodec.Session{Name: "bad", Tabs: []snapcodec.Tab{{
86- Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{pane},
87- }}})
88-
89- require.Error(t, err)
90- require.Len(t, factory.opens, 1)
91- require.Equal(t, 1, factory.opens[0].pty.closeCount())
92-}
93-
94-func TestRestoreSessionUsesDaemonFallbackEnvironmentAndShellBeforeAttach(t *testing.T) {
95- factory := &restorePTYFactory{}
96- d := newTestDaemon(t, factory, stubClock{})
97- d.baseEnv = []string{"ORDINARY=preserved", "SHELL=/usr/local/bin/daemon-shell", "TERM=stale", "RAW"}
98-
99- require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "work", Tabs: []snapcodec.Tab{{
100- StableID: "tab", Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{terminalPane(t, "pane-1", "pane", "/tmp", nil, nil)},
101- }}}))
102- initialOpens := factory.snapshotOpens()
103- require.Len(t, initialOpens, 1)
104- require.Equal(t, "/usr/local/bin/daemon-shell", initialOpens[0].command)
105- require.Equal(t, []string{
106- "ORDINARY=preserved", "SHELL=/usr/local/bin/daemon-shell", "RAW",
107- "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=tab,pane=pane",
108- }, initialOpens[0].env)
109-
110- d.baseEnv[0] = "ORDINARY=changed"
111- d.mu.Lock()
112- restored := d.findByNameLocked("work")
113- d.mu.Unlock()
114- require.NotNil(t, restored)
115- restored.mu.Lock()
116- require.Equal(t, []string{"ORDINARY=preserved", "SHELL=/usr/local/bin/daemon-shell", "TERM=stale", "RAW"}, restored.env)
117- restored.mu.Unlock()
118-
119- tr, _ := newCapturingTransport(t)
120- _, ac, err := d.route(ports.Hello{
121- Version: ports.ProtocolVersion,
122- Intent: ports.IntentAttach,
123- Name: "work",
124- Size: domain.Size{Cols: 80, Rows: 24},
125- Env: []string{"ORDINARY=attached", "SHELL=/bin/attached-shell"},
126- }, tr)
127- require.NoError(t, err)
128- require.NoError(t, d.createTab(restored, ac.size))
129-
130- restored.mu.Lock()
131- attachedTab := restored.tabs[1]
132- restored.mu.Unlock()
133- attachedTab.mu.Lock()
134- attachedPane := attachedTab.panes["pane-1"]
135- attachedTabStableID := attachedTab.stableID
136- attachedPaneStableID := attachedPane.stableID
137- attachedTab.mu.Unlock()
138- expectedVEV := "VEV=session=work,tab=" + attachedTabStableID + ",pane=" + attachedPaneStableID
139- var attachedOpens []restorePTYOpen
140- for _, open := range factory.snapshotOpens() {
141- if containsEnv(open.env, expectedVEV) {
142- attachedOpens = append(attachedOpens, open)
143- }
41+ objects := make(map[ports.SnapshotDigest][]byte, len(p.Objects))
42+ for _, object := range p.Objects {
43+ objects[object.Digest] = append([]byte(nil), object.Data...)
14444 }
145- require.Len(t, attachedOpens, 1)
146- require.Equal(t, "/bin/attached-shell", attachedOpens[0].command)
147- require.Equal(t, []string{
148- "ORDINARY=attached", "SHELL=/bin/attached-shell",
149- "TERM=xterm-256color", "TERM_PROGRAM=vev", expectedVEV,
150- }, attachedOpens[0].env)
151- require.NoError(t, d.killSession(restored, ports.ReasonSessionKilled, false))
152- d.sessWg.Wait()
153-}
154-
155-func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
156- store := &restoreSnapshotStore{}
157- store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
158- Name: "work",
159- CreatedAt: 99,
160- Active: 0,
161- Tabs: []snapcodec.Tab{{
162- StableID: "t_saved",
163- Cols: 80,
164- Rows: 24,
165- NextPaneID: 3,
166- Focus: "pane-2",
167- Tree: &layout.Tree{Focus: "pane-2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
168- layout.NewLeaf("pane-1"),
169- layout.NewLeaf("pane-2"),
170- }}},
171- Panes: []snapcodec.Pane{
172- terminalPane(t, "pane-1", "p_saved_1", "/one", [][]renderer.Cell{cells("old1")}, [][]renderer.Cell{cells("vis1")}),
173- terminalPane(t, "pane-2", "p_saved_2", "/two", [][]renderer.Cell{cells("old2")}, [][]renderer.Cell{cells("vis2")}),
174- },
175- }},
176- })}}
177- factory := &restorePTYFactory{}
178- d := newTestDaemon(t, factory, stubClock{})
179- persistStore, persistState := newMockStore(t)
180- WithStore(persistStore)(d)
181- WithSnapshotStore(store)(d)
182- d.stopped["work"] = stoppedSession{name: "work", createdAt: 99, tabNames: []string{"logs"}}
183-
184- d.restoreSnapshots(context.Background())
185-
186- require.Len(t, factory.opens, 2)
187- require.Equal(t, "/one", factory.opens[0].dir)
188- require.Equal(t, "/two", factory.opens[1].dir)
189- require.Equal(t, domain.Size{Cols: 40, Rows: 24}, factory.opens[0].size)
190-
191- d.mu.Lock()
192- restored := d.findByNameLocked("work")
193- d.mu.Unlock()
194- require.NotNil(t, restored)
195- require.False(t, restored.snapDirty.Load())
196- require.Equal(t, domain.SessionID("sess-0"), restored.id)
197- require.Equal(t, int64(99), restored.createdAt)
198- require.Equal(t, "/one", restored.cwd)
199-
200- restored.mu.Lock()
201- require.Len(t, restored.tabs, 1)
202- tb := restored.tabs[0]
203- restored.mu.Unlock()
204- require.Equal(t, "logs", tb.name)
205- require.Equal(t, []string{"logs"}, persistState.record(t, "work").TabNames)
206- tb.mu.Lock()
207- require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
208- require.Equal(t, 3, tb.nextPaneID)
209- p := tb.panes["pane-2"]
210- tb.mu.Unlock()
211- p.mu.Lock()
212- require.Same(t, p.screen.History(), p.history)
213- require.Equal(t, "old2", rowText(p.history.View().Row(0)))
214- require.Equal(t, 1, p.history.Len())
215- require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4]))
216- copySnap := scopy.NewSnapshot(p.history, p.screen.Frame)
217- require.Equal(t, "old2", rowText(copySnap.Row(0)[:4]))
218- require.Equal(t, "vis2", rowText(copySnap.Row(1)[:4]))
219- p.mu.Unlock()
220-
221- tb.mu.Lock()
222- tabStableID := tb.stableID
223- pane1StableID := tb.panes["pane-1"].stableID
224- pane2StableID := tb.panes["pane-2"].stableID
225- tb.mu.Unlock()
226- require.Equal(t, "t_saved", tabStableID)
227- require.Equal(t, "p_saved_1", pane1StableID)
228- require.Equal(t, "p_saved_2", pane2StableID)
229- require.Contains(t, factory.opens[0].env, "TERM=xterm-256color")
230- require.NotContains(t, factory.opens[0].env, "TERM=xterm-direct")
231- require.NotContains(t, factory.opens[0].env, "COLORTERM=truecolor")
232- require.Contains(t, factory.opens[0].env, "TERM_PROGRAM=vev")
233- require.Contains(t, factory.opens[0].env, "VEV=session=work,tab="+tabStableID+",pane="+pane1StableID)
234- require.Contains(t, factory.opens[1].env, "TERM=xterm-256color")
235- require.NotContains(t, factory.opens[1].env, "TERM=xterm-direct")
236- require.NotContains(t, factory.opens[1].env, "COLORTERM=truecolor")
237- require.Contains(t, factory.opens[1].env, "TERM_PROGRAM=vev")
238- require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID)
239-}
240-
241-func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testing.T) {
242- store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
243- Name: "work", Active: 0, Tabs: []snapcodec.Tab{
244- {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/one")}},
245- {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")}},
246- },
247- })}}}
248- factory := &restorePTYFactory{}
249- d := newTestDaemon(t, factory, stubClock{})
250- d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Command: "btop", Width: 80, Height: 80}})
251- WithSnapshotStore(store)(d)
252- d.restoreSnapshots(context.Background())
253- // Only the persisted normal panes may be restored. Floating runtimes are
254- // deliberately excluded and must wait for a Phase-5 activation path.
255- require.Len(t, factory.opens, 2)
256- d.mu.Lock()
257- restored := d.findByNameLocked("work")
258- d.mu.Unlock()
259- require.NotNil(t, restored)
260- restored.mu.Lock()
261- tabs := append([]*tab(nil), restored.tabs...)
262- restored.mu.Unlock()
263- require.Len(t, tabs, 2)
264- for _, tb := range tabs {
265- tb.mu.Lock()
266- require.Equal(t, floatingUninitialized, tb.floating.state)
267- require.Nil(t, tb.floating.pane)
268- tb.mu.Unlock()
45+ r.generations[p.Name] = ports.SnapshotGeneration{Name: p.Name, Generation: p.Generation, Manifest: append([]byte(nil), p.Manifest...), Objects: objects}
46+ found := false
47+ for _, name := range r.names {
48+ found = found || name == p.Name
26949 }
50+ if !found {
51+ r.names = append(r.names, p.Name)
52+ }
53+ return nil
27054 }
27155
272-func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore {
273- t.Helper()
274- pane := emptyTerminalPane(t, "pane-1", "/tmp")
275- pane.Process = proc
276- return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{
277- Name: name,
278- Tabs: []snapcodec.Tab{{
279- Cols: 80,
280- Rows: 24,
281- Tree: layout.NewTree("pane-1"),
282- Panes: []snapcodec.Pane{pane},
283- }},
284- })}}}
285-}
286-
287-func TestRestoreSnapshotsProcessRestoreCommands(t *testing.T) {
288- lessProc := &snapcodec.Process{Argv: []string{"less", "README.md"}, Strategy: processStrategyGeneric}
289- agentProc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}}
290- controlAgentProc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc\nmalicious"}}
291-
292- tests := []struct {
293- name string
294- store *restoreSnapshotStore
295- allow []string
296- wantWrites [][]string
297- }{
298- {
299- name: "allowlisted generic command",
300- store: processRestoreTestStore(t, "allowlisted", lessProc),
301- allow: []string{"less"},
302- wantWrites: [][]string{{"less README.md\n"}},
303- },
304- {
305- name: "denied generic command",
306- store: processRestoreTestStore(t, "denied", lessProc),
307- allow: []string{"vim"},
308- wantWrites: [][]string{{}},
309- },
310- {
311- name: "pane scoped command",
312- store: &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "scoped", Data: mustSnapshotBytes(t, snapcodec.Session{
313- Name: "scoped",
314- Tabs: []snapcodec.Tab{
315- {
316- Cols: 80,
317- Rows: 24,
318- Tree: layout.NewTree("pane-1"),
319- Panes: func() []snapcodec.Pane {
320- pane := emptyTerminalPane(t, "pane-1", "/one")
321- pane.Process = lessProc
322- return []snapcodec.Pane{pane}
323- }(),
324- },
325- {
326- Cols: 80,
327- Rows: 24,
328- Tree: layout.NewTree("pane-1"),
329- Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")},
330- },
331- },
332- })}}},
333- allow: []string{"less"},
334- wantWrites: [][]string{{"less README.md\n"}, {}},
335- },
336- {
337- name: "agent ID command",
338- store: processRestoreTestStore(t, "agent", agentProc),
339- allow: []string{"pi"},
340- wantWrites: [][]string{{"pi --resume abc123\n"}},
341- },
342- {
343- name: "agent ID with control byte rejected",
344- store: processRestoreTestStore(t, "agent-control", controlAgentProc),
345- allow: []string{"pi"},
346- wantWrites: [][]string{{}},
347- },
56+func (r *snapshotAcceptanceRepository) List(ctx context.Context) ([]string, error) {
57+ if err := ctx.Err(); err != nil {
58+ return nil, err
34859 }
349-
350- for _, tt := range tests {
351- t.Run(tt.name, func(t *testing.T) {
352- factory := &restorePTYFactory{}
353- d := newTestDaemon(t, factory, stubClock{})
354- WithSnapshotStore(tt.store)(d)
355- d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: tt.allow}})
356-
357- d.restoreSnapshots(context.Background())
358-
359- require.Len(t, factory.opens, len(tt.wantWrites))
360- for i, want := range tt.wantWrites {
361- if len(want) == 0 {
362- require.Empty(t, factory.opens[i].pty.writes)
363- continue
364- }
365- require.Equal(t, want, factory.opens[i].pty.writes)
366- }
367- })
60+ r.mu.Lock()
61+ defer r.mu.Unlock()
62+ if r.listErr != nil {
63+ return nil, r.listErr
36864 }
65+ return append([]string(nil), r.names...), nil
36966 }
37067
371-func TestRestoreSnapshotsProcessCommandWriteFailureIsNonFatal(t *testing.T) {
372- proc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}}
373- factory := &restorePTYFactory{writeErr: errors.New("boom")}
374- d := newTestDaemon(t, factory, stubClock{})
375- d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}})
376-
377- pane := emptyTerminalPane(t, "pane-1", "/tmp")
378- pane.Process = proc
379- require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "agent", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{pane}}}}))
380- require.Len(t, factory.opens, 1)
381- require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes)
382-}
383-
384-func TestRestoreSessionPassesRestoreContextToPTYOpen(t *testing.T) {
385- factory := &restorePTYFactory{}
386- d := newTestDaemon(t, factory, stubClock{})
387- ctx, cancel := context.WithCancel(context.Background())
388- defer cancel()
389-
390- require.NoError(t, d.restoreSession(ctx, snapcodec.Session{Name: "restore-context", Tabs: []snapcodec.Tab{{
391- Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/tmp")},
392- }}}))
393- require.Len(t, factory.opens, 1)
394- require.Same(t, ctx, factory.opens[0].ctx)
395-}
396-
397-func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) {
398- store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{
399- Name: "stacked",
400- Tabs: []snapcodec.Tab{{
401- Cols: 20,
402- Rows: 4,
403- Tree: &layout.Tree{Focus: "b", Root: &layout.Node{Kind: layout.Stack, Expanded: "b", Children: []*layout.Node{
404- layout.NewLeaf("a"), layout.NewLeaf("b"), layout.NewLeaf("c"),
405- }}},
406- Panes: []snapcodec.Pane{
407- emptyTerminalPane(t, "a", "/a"),
408- emptyTerminalPane(t, "b", "/b"),
409- emptyTerminalPane(t, "c", "/c"),
410- },
411- }},
412- })}}}
413- factory := &restorePTYFactory{}
414- d := newTestDaemon(t, factory, stubClock{})
415- WithSnapshotStore(store)(d)
416-
417- d.restoreSnapshots(context.Background())
418-
419- require.Len(t, factory.opens, 3)
420- for _, open := range factory.opens {
421- require.True(t, open.size.Valid(), "open size for %s was %#v", open.dir, open.size)
422- require.GreaterOrEqual(t, open.size.Cols, layout.MinPaneCols)
423- require.GreaterOrEqual(t, open.size.Rows, layout.MinPaneRows)
68+func (r *snapshotAcceptanceRepository) Load(ctx context.Context, name string) (ports.SnapshotGeneration, error) {
69+ if err := ctx.Err(); err != nil {
70+ return ports.SnapshotGeneration{}, err
42471 }
425- d.mu.Lock()
426- restored := d.findByNameLocked("stacked")
427- d.mu.Unlock()
428- require.NotNil(t, restored)
429- restored.mu.Lock()
430- tb := restored.tabs[0]
431- restored.mu.Unlock()
432- tb.mu.Lock()
433- require.Equal(t, layout.Stack, tb.tree.Root.Kind)
434- require.Equal(t, layout.PaneID("b"), tb.tree.Root.Expanded)
435- require.Len(t, tb.panes, 3)
436- tb.mu.Unlock()
437-}
438-
439-func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) {
440- valid := mustSnapshotBytes(t, snapcodec.Session{
441- Name: "live",
442- CreatedAt: 1,
443- Tabs: []snapcodec.Tab{{
444- Cols: 80,
445- Rows: 24,
446- Tree: layout.NewTree("pane-1"),
447- Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/ok")},
448- }},
449- })
450- badVersion := append([]byte(nil), valid...)
451- binary.BigEndian.PutUint16(badVersion[4:6], 1)
452- tests := []struct {
453- name string
454- blobs []ports.SnapshotBlob
455- liveName string
456- wantOpen int
457- }{
458- {name: "empty store"},
459- {name: "corrupt blob", blobs: []ports.SnapshotBlob{{Name: "bad", Data: []byte("not a snapshot")}}},
460- {name: "old v1 blob", blobs: []ports.SnapshotBlob{{Name: "old", Data: badVersion}}},
461- {name: "live name", blobs: []ports.SnapshotBlob{{Name: "live", Data: valid}}, liveName: "live"},
72+ r.mu.Lock()
73+ defer r.mu.Unlock()
74+ if r.loadErr != nil {
75+ return ports.SnapshotGeneration{}, r.loadErr
46276 }
463- for _, tt := range tests {
464- t.Run(tt.name, func(t *testing.T) {
465- factory := &restorePTYFactory{}
466- d := newTestDaemon(t, factory, stubClock{})
467- WithSnapshotStore(&restoreSnapshotStore{blobs: tt.blobs})(d)
468- if tt.liveName != "" {
469- d.sessions["existing"] = &session{id: "existing", name: tt.liveName}
470- }
471-
472- require.NotPanics(t, func() { d.restoreSnapshots(context.Background()) })
473- require.Len(t, factory.opens, tt.wantOpen)
474- })
77+ generation, ok := r.generations[name]
78+ if !ok {
79+ return ports.SnapshotGeneration{}, errors.New("missing generation")
47580 }
81+ generation = cloneAcceptanceGeneration(generation)
82+ if r.loadMutate != nil && len(r.publishes) > 0 {
83+ generation = r.loadMutate(generation, r.publishes[len(r.publishes)-1])
84+ }
85+ return cloneAcceptanceGeneration(generation), nil
47686 }
47787
478-func TestRestoreSnapshotsSkipsCreatedAtOverflow(t *testing.T) {
479- store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "overflow", Data: mustSnapshotBytes(t, snapcodec.Session{
480- Name: "overflow",
481- CreatedAt: uint64(math.MaxInt64) + 1,
482- Tabs: []snapcodec.Tab{{
483- Cols: 80,
484- Rows: 24,
485- Tree: layout.NewTree("pane-1"),
486- Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/bad")},
487- }},
488- })}}}
489- factory := &restorePTYFactory{}
490- d := newTestDaemon(t, factory, stubClock{})
491- WithSnapshotStore(store)(d)
492-
493- d.restoreSnapshots(context.Background())
494-
495- records, err := d.persist.LoadAll()
496- require.NoError(t, err)
497- require.Empty(t, records)
498- require.Len(t, factory.opens, 0)
499- d.mu.Lock()
500- restored := d.findByNameLocked("overflow")
501- d.mu.Unlock()
502- require.Nil(t, restored)
503-}
504-
505-// TestRestoreSnapshotsNotifiesGlobalOnUnrestorableSnapshots proves each
506-// unrestorable snapshot is recorded to history individually, while the
507-// pending-global queue dedups them by code into a single toast with the
508-// combined Count. Both blobs decode fine (valid snapcodec bytes, Marshal
509-// itself validates Active/tab range) but fail in restoreSession itself: an
510-// empty embedded session name is rejected deterministically, before any lock
511-// is taken or PTY is opened.
512-func TestRestoreSnapshotsNotifiesGlobalOnUnrestorableSnapshots(t *testing.T) {
513- badSnap := mustSnapshotBytes(t, snapcodec.Session{Name: ""})
514- store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{
515- {Name: "bad-1", Data: badSnap},
516- {Name: "bad-2", Data: badSnap},
517- }}
518- factory := &restorePTYFactory{}
519- d := newTestDaemon(t, factory, newNoticeClock())
520- WithSnapshotStore(store)(d)
521-
522- // Mirrors real startup (daemon.go's Serve): restoreSnapshots runs off the
523- // caller goroutine and signals completion by closing d.restoreDone.
524- d.sessWg.Go(func() { d.restoreSnapshots(d.serveCtx) })
88+func (*snapshotAcceptanceRepository) Delete(context.Context, string) error { return nil }
89+func (*snapshotAcceptanceRepository) Tombstone(context.Context, string) error { return nil }
90+func (*snapshotAcceptanceRepository) DeleteTombstone(context.Context, string) error { return nil }
91+func (*snapshotAcceptanceRepository) Maintain(context.Context) error { return nil }
52592
526- select {
527- case <-d.restoreDone:
528- case <-time.After(2 * time.Second):
529- t.Fatal("restoreSnapshots did not close restoreDone in time")
93+func TestSnapshotAcceptanceRepositoryLoadMutationDoesNotChangeStoredGeneration(t *testing.T) {
94+ digest := ports.SnapshotDigest{1}
95+ stored := ports.SnapshotGeneration{
96+ Name: "work",
97+ Manifest: []byte("manifest"),
98+ Objects: map[ports.SnapshotDigest][]byte{digest: []byte("object")},
53099 }
531-
532- require.Empty(t, factory.opens, "both snapshots must fail before any PTY is opened")
533- require.Len(t, d.notices.history(), 2, "each failure is recorded individually")
534- for _, n := range d.notices.history() {
535- require.Equal(t, domain.NoticeSnapshotRestore, n.Code)
536- require.Equal(t, domain.NoticeError, n.Severity)
100+ repository := &snapshotAcceptanceRepository{
101+ generations: map[string]ports.SnapshotGeneration{"work": stored},
102+ publishes: []ports.SnapshotPublication{{Name: "work"}},
103+ loadMutate: func(g ports.SnapshotGeneration, _ ports.SnapshotPublication) ports.SnapshotGeneration {
104+ g.Manifest[0] = 'M'
105+ g.Objects[digest][0] = 'O'
106+ delete(g.Objects, digest)
107+ return g
108+ },
537109 }
538110
539- tr, _ := newCapturingTransport(t)
540- sess, ac, err := d.route(ports.Hello{
541- Version: ports.ProtocolVersion,
542- Intent: ports.IntentEphemeral,
543- Size: domain.Size{Cols: 80, Rows: 24},
544- }, tr)
111+ _, err := repository.Load(context.Background(), "work")
545112 require.NoError(t, err)
546- d.firstPaint(sess, ac, ac.size)
547-
548- toasts := awaitToastCount(t, ac, 1)
549- require.Equal(t, domain.NoticeSnapshotRestore, toasts[0].Code)
550- require.Equal(t, 2, toasts[0].Count, "two same-code failures dedup into one toast counted twice")
551- require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue")
113+ require.Equal(t, []byte("manifest"), repository.generations["work"].Manifest)
114+ require.Equal(t, []byte("object"), repository.generations["work"].Objects[digest])
552115 }
553116
554-func TestRestoreSnapshotsDoesNotNotifyForCancelledRestore(t *testing.T) {
555- ctx, cancel := context.WithCancel(context.Background())
556- factory := &restorePTYFactory{onOpen: cancel}
557- store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "cancelled", Data: mustSnapshotBytes(t, snapcodec.Session{
558- Name: "cancelled",
559- Tabs: []snapcodec.Tab{{
560- Cols: 80, Rows: 24,
561- Tree: &layout.Tree{Focus: "one", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
562- layout.NewLeaf("one"), layout.NewLeaf("two"),
563- }}},
564- Panes: []snapcodec.Pane{
565- emptyTerminalPane(t, "one", "/one"),
566- emptyTerminalPane(t, "two", "/two"),
567- },
568- }},
569- })}}}
570- d := newTestDaemon(t, factory, stubClock{})
571- WithSnapshotStore(store)(d)
572-
573- d.restoreSnapshots(ctx)
574-
575- require.Len(t, factory.snapshotOpens(), 1, "cancellation stops restore before the next pane")
576- require.Empty(t, d.notices.history(), "shutdown cancellation is not a user-visible restore failure")
577-}
578-
579-func TestRestoreSnapshotsNotifiesGlobalOnResumeCommandWriteFailure(t *testing.T) {
580- proc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}}
581- store := processRestoreTestStore(t, "agent-session", proc)
582- factory := &restorePTYFactory{writeErr: errors.New("boom")}
583- d := newTestDaemon(t, factory, newNoticeClock())
584- WithSnapshotStore(store)(d)
585- d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}})
586-
587- // Mirrors real startup (daemon.go's Serve): restoreSnapshots runs off the
588- // caller goroutine and signals completion by closing d.restoreDone.
589- d.sessWg.Go(func() { d.restoreSnapshots(d.serveCtx) })
590-
591- select {
592- case <-d.restoreDone:
593- case <-time.After(2 * time.Second):
594- t.Fatal("restoreSnapshots did not close restoreDone in time")
117+func cloneAcceptanceGeneration(g ports.SnapshotGeneration) ports.SnapshotGeneration {
118+ out := g
119+ out.Manifest = append([]byte(nil), g.Manifest...)
120+ out.Objects = make(map[ports.SnapshotDigest][]byte, len(g.Objects))
121+ for digest, data := range g.Objects {
122+ out.Objects[digest] = append([]byte(nil), data...)
595123 }
596-
597- require.Len(t, factory.opens, 1, "the pane still opens even though the resume write fails")
598- require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes)
599-
600- history := d.notices.history()
601- require.Len(t, history, 1)
602- require.Equal(t, domain.NoticeAutoResume, history[0].Code)
603- require.Equal(t, domain.NoticeWarn, history[0].Severity)
604- require.Equal(t, "couldn't restore the running program in session agent-session", history[0].Message)
605-
606- tr, _ := newCapturingTransport(t)
607- sess, ac, err := d.route(ports.Hello{
608- Version: ports.ProtocolVersion,
609- Intent: ports.IntentEphemeral,
610- Size: domain.Size{Cols: 80, Rows: 24},
611- }, tr)
612- require.NoError(t, err)
613- d.firstPaint(sess, ac, ac.size)
614-
615- toasts := awaitToastCount(t, ac, 1)
616- require.Equal(t, domain.NoticeAutoResume, toasts[0].Code)
617- require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue")
124+ return out
618125 }
619126
620-func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) {
621- releaseLoad := make(chan struct{})
622- loaded := make(chan struct{})
623- store := &restoreSnapshotStore{
624- loadFn: func() ([]ports.SnapshotBlob, error) {
625- close(loaded)
626- <-releaseLoad
627- return []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
628- Name: "work",
629- Tabs: []snapcodec.Tab{{
630- Cols: 80,
631- Rows: 24,
632- Tree: layout.NewTree("pane-1"),
633- Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/restored")},
634- }},
635- })}}, nil
636- },
127+func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session {
128+ t.Helper()
129+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 8 * 293, ChunkRows: 2})
130+ for _, ch := range []rune{'a', 'b', 'c', 'd', 'e'} {
131+ row := make([]renderer.Cell, 293)
132+ for i := range row {
133+ row[i] = renderer.Cell{Rune: ch}
134+ }
135+ require.NoError(t, history.Append(row))
637136 }
638- factory := &restorePTYFactory{}
639- d := newTestDaemon(t, factory, stubClock{})
640- WithSnapshotStore(store)(d)
641-
642- go d.restoreSnapshots(context.Background())
643- <-loaded
644-
645- routed := make(chan *session, 1)
646- go func() {
647- tr, _ := newCapturingTransport(t)
648- sess, _, err := d.route(ports.Hello{Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 26}}, tr)
137+ view := history.SnapshotView()
138+ sealed := make([][]byte, view.ChunkCount())
139+ for i := range sealed {
140+ var err error
141+ sealed[i], err = vt.MarshalHistoryChunk(view.Chunk(i))
649142 require.NoError(t, err)
650- routed <- sess
651- }()
652-
653- select {
654- case <-routed:
655- t.Fatal("named attach routed before restore barrier closed")
656- case <-time.After(25 * time.Millisecond):
657- }
658- close(releaseLoad)
659- select {
660- case sess := <-routed:
661- require.Equal(t, "work", sess.name)
662- require.Len(t, factory.opens, 1)
663- require.Equal(t, "/restored", factory.opens[0].dir)
664- case <-time.After(time.Second):
665- t.Fatal("timed out waiting for named attach after restore")
666143 }
667-}
668-
669-func TestNamedRouteRestoreBarrierReturnsShutdown(t *testing.T) {
670- d := newTestDaemon(t, &restorePTYFactory{}, stubClock{})
671- WithSnapshotStore(&restoreSnapshotStore{})(d)
672- d.serveCancel()
673-
674- tr, _ := newCapturingTransport(t)
675- sess, ac, err := d.route(ports.Hello{Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}}, tr)
676-
677- require.Nil(t, sess)
678- require.Nil(t, ac)
679- var perr *protoErr
680- require.ErrorAs(t, err, &perr)
681- require.Equal(t, ports.ErrServerShutdown, perr.code)
682-}
683-
684-func emptyTerminalPane(t *testing.T, id, cwd string) snapcodec.Pane {
685- t.Helper()
686- return terminalPane(t, id, "", cwd, nil, nil)
687-}
144+ tail, err := vt.MarshalHistoryTail(view)
145+ require.NoError(t, err)
688146
689-func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte {
690- t.Helper()
691- b, err := snapcodec.Marshal(s)
147+ screen := vt.NewScreen(293, 4)
148+ screen.Write([]byte("primary-visible"))
149+ screen.Write([]byte("\x1b[?1049h"))
150+ screen.Write([]byte("alternate-active"))
151+ visible, err := screen.PrimaryVisibleSnapshot().Marshal()
692152 require.NoError(t, err)
693- return b
694-}
695153
696-func cells(s string) []renderer.Cell {
697- out := make([]renderer.Cell, 0, len(s))
698- for _, r := range s {
699- out = append(out, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
154+ paneID := layout.PaneID("pane-7")
155+ return snapcodec.Session{
156+ Name: name, CreatedAt: 77, Active: 0,
157+ Tabs: []snapcodec.Tab{{
158+ StableID: "tab-stable", Cols: 293, Rows: 4, NextPaneID: 8, Focus: paneID,
159+ Tree: &layout.Tree{Root: layout.NewLeaf(paneID), Focus: paneID},
160+ Panes: []snapcodec.Pane{{
161+ ID: paneID, StableID: "pane-stable", Cwd: "/snapshot/cwd", SealedChunks: sealed, Tail: tail, Visible: visible,
162+ Process: &snapcodec.Process{Argv: []string{"sleep", "99"}, Strategy: "argv"},
163+ }},
164+ }},
700165 }
701- return out
702-}
703-
704-type restoreSnapshotStore struct {
705- blobs []ports.SnapshotBlob
706- loadFn func() ([]ports.SnapshotBlob, error)
707166 }
708167
709-func (s *restoreSnapshotStore) Write(string, []byte) error { return nil }
710-func (s *restoreSnapshotStore) Delete(string) error { return nil }
711-func (s *restoreSnapshotStore) Load() ([]ports.SnapshotBlob, error) {
712- if s.loadFn != nil {
713- return s.loadFn()
168+func acceptanceGeneration(t *testing.T, snapshot snapcodec.Session, generation uint64) ports.SnapshotGeneration {
169+ t.Helper()
170+ publication, err := legacyPublication(snapshot)
171+ require.NoError(t, err)
172+ manifest, err := snapcodec.UnmarshalManifest(publication.Manifest)
173+ require.NoError(t, err)
174+ manifest.Generation = generation
175+ encoded, err := snapcodec.MarshalManifest(manifest)
176+ require.NoError(t, err)
177+ objects := make(map[ports.SnapshotDigest][]byte, len(publication.Objects))
178+ for _, object := range publication.Objects {
179+ objects[object.Digest] = append([]byte(nil), object.Data...)
714180 }
715- return s.blobs, nil
716-}
717-
718-type restorePTYFactory struct {
719- mu sync.Mutex
720- opens []restorePTYOpen
721- writeErr error
722- onOpen func()
723-}
724-
725-type restorePTYOpen struct {
726- ctx context.Context
727- command string
728- dir string
729- size domain.Size
730- env []string
731- pty *restorePTY
181+ return ports.SnapshotGeneration{Name: snapshot.Name, Generation: generation, Manifest: encoded, Objects: objects}
732182 }
733183
734-func (f *restorePTYFactory) Open(ctx context.Context, command string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
735- f.mu.Lock()
736- pty := newRestorePTY()
737- pty.writeErr = f.writeErr
738- f.opens = append(f.opens, restorePTYOpen{ctx: ctx, command: command, dir: dir, size: sz, env: append([]string(nil), env...), pty: pty})
739- onOpen := f.onOpen
740- f.mu.Unlock()
741- if onOpen != nil {
742- onOpen()
184+func TestValidateRestoreSessionSnapshot(t *testing.T) {
185+ valid := restoreAcceptanceSession(t, "restored")
186+ for _, test := range []struct {
187+ name string
188+ snap snapcodec.Session
189+ }{
190+ {"valid", valid},
191+ {"empty name", func() snapcodec.Session { snap := valid; snap.Name = ""; return snap }()},
192+ {"active without tabs", func() snapcodec.Session { snap := valid; snap.Tabs = nil; snap.Active = 1; return snap }()},
193+ {"active beyond tabs", func() snapcodec.Session { snap := valid; snap.Active = 1; return snap }()},
194+ {"created at overflow", func() snapcodec.Session { snap := valid; snap.CreatedAt = math.MaxInt64 + 1; return snap }()},
195+ } {
196+ t.Run(test.name, func(t *testing.T) {
197+ err := validateRestoreSessionSnapshot(test.snap)
198+ if test.name == "valid" {
199+ require.NoError(t, err)
200+ return
201+ }
202+ require.Error(t, err)
203+ })
743204 }
744- return pty, nil
745205 }
746206
747-func (f *restorePTYFactory) snapshotOpens() []restorePTYOpen {
748- f.mu.Lock()
749- defer f.mu.Unlock()
750- opens := make([]restorePTYOpen, len(f.opens))
751- copy(opens, f.opens)
752- for i := range opens {
753- opens[i].env = append([]string(nil), opens[i].env...)
754- }
755- return opens
756-}
207+func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
208+ snapshot := restoreAcceptanceSession(t, "restored")
209+ generation := acceptanceGeneration(t, snapshot, 9)
210+ repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: generation}}
211+ pty, release := newBlockingPTY(t)
212+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
213+ store, _ := newMockStore(t)
214+ WithStore(store)(d)
215+ WithSnapshotRepository(repository, nil)(d)
216+ t.Cleanup(func() { release(); d.sessWg.Wait() })
757217
758-type restorePTY struct {
759- mu sync.Mutex
760- writes []string
761- closes int
762- writeErr error
763- done chan struct{}
764- once sync.Once
765-}
218+ d.restoreIncrementalSnapshots(context.Background())
766219
767-func newRestorePTY() *restorePTY { return &restorePTY{done: make(chan struct{})} }
768-func (p *restorePTY) Read([]byte) (int, error) {
769- <-p.done
770- return 0, io.EOF
771-}
772-func (p *restorePTY) Write(b []byte) (int, error) {
773- p.mu.Lock()
774- defer p.mu.Unlock()
775- p.writes = append(p.writes, string(b))
776- if p.writeErr != nil {
777- return 0, p.writeErr
220+ d.mu.Lock()
221+ restored := d.findByNameLocked(snapshot.Name)
222+ d.mu.Unlock()
223+ require.NotNil(t, restored)
224+ require.False(t, restored.snapDirty.Load(), "a loaded generation must begin clean")
225+ require.Equal(t, uint64(9), restored.snapshotPublishedGeneration, "the loaded manifest is the repository generation head")
226+ require.Equal(t, 0, restored.active)
227+ require.Equal(t, "/snapshot/cwd", restored.cwd)
228+ require.Len(t, restored.tabs, 1)
229+ tab := restored.tabs[0]
230+ require.Equal(t, 293, tab.size.Cols)
231+ require.Equal(t, layout.PaneID("pane-7"), tab.tree.Focus)
232+ pane := tab.panes[layout.PaneID("pane-7")]
233+ pane.mu.Lock()
234+ require.Equal(t, "primary-visible", string([]rune{pane.screen.Frame.Row(0)[0].Rune, pane.screen.Frame.Row(0)[1].Rune, pane.screen.Frame.Row(0)[2].Rune, pane.screen.Frame.Row(0)[3].Rune, pane.screen.Frame.Row(0)[4].Rune, pane.screen.Frame.Row(0)[5].Rune, pane.screen.Frame.Row(0)[6].Rune, pane.screen.Frame.Row(0)[7].Rune, pane.screen.Frame.Row(0)[8].Rune, pane.screen.Frame.Row(0)[9].Rune, pane.screen.Frame.Row(0)[10].Rune, pane.screen.Frame.Row(0)[11].Rune, pane.screen.Frame.Row(0)[12].Rune, pane.screen.Frame.Row(0)[13].Rune, pane.screen.Frame.Row(0)[14].Rune}))
235+ require.Equal(t, 5, pane.history.Len())
236+ require.Equal(t, 5*293, pane.history.Cells())
237+ require.Equal(t, defaultScrollbackRows, pane.history.Cap())
238+ require.Equal(t, defaultScrollbackCells, pane.history.CellCap())
239+ pane.mu.Unlock()
240+
241+ startSnapshotEncodeWorker(t, d)
242+ markSnapshotDirty(restored)
243+ require.True(t, d.scheduleSnapshot(restored))
244+ awaitSnapshotClean(t, restored)
245+ require.Len(t, repository.publishes, 1)
246+ require.Equal(t, uint64(10), repository.publishes[0].Generation, "a restored session must continue the concrete repository generation stream")
247+}
248+
249+func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) {
250+ snapshot := restoreAcceptanceSession(t, "fallback")
251+ valid := acceptanceGeneration(t, snapshot, 3)
252+ valid.Fallback = true
253+ pty, release := newBlockingPTY(t)
254+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
255+ store, _ := newMockStore(t)
256+ WithStore(store)(d)
257+ repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: valid}}
258+ WithSnapshotRepository(repository, nil)(d)
259+ t.Cleanup(func() { release(); d.sessWg.Wait() })
260+ d.restoreIncrementalSnapshots(context.Background())
261+ require.Len(t, d.notices.history(), 1)
262+ require.Equal(t, domain.NoticeWarn, d.notices.history()[0].Severity)
263+
264+ for _, mutate := range []struct {
265+ name string
266+ fn func(*ports.SnapshotGeneration)
267+ }{
268+ {"missing", func(g *ports.SnapshotGeneration) {
269+ for digest := range g.Objects {
270+ delete(g.Objects, digest)
271+ break
272+ }
273+ }},
274+ {"wrong kind", func(g *ports.SnapshotGeneration) {
275+ for digest, data := range g.Objects {
276+ kind, payload, err := snapcodec.UnmarshalObject(data)
277+ require.NoError(t, err)
278+ if kind == snapcodec.HistoryTail {
279+ replacement, err := snapcodec.MarshalObject(snapcodec.Visible, payload)
280+ require.NoError(t, err)
281+ delete(g.Objects, digest)
282+ g.Objects[digest] = replacement.Data
283+ break
284+ }
285+ }
286+ }},
287+ {"wrong digest", func(g *ports.SnapshotGeneration) {
288+ for digest, data := range g.Objects {
289+ kind, payload, err := snapcodec.UnmarshalObject(data)
290+ require.NoError(t, err)
291+ payload = append([]byte(nil), payload...)
292+ payload[len(payload)-1] ^= 1
293+ replacement, err := snapcodec.MarshalObject(kind, payload)
294+ require.NoError(t, err)
295+ require.Equal(t, len(data), len(replacement.Data))
296+ g.Objects[digest] = replacement.Data
297+ break
298+ }
299+ }},
300+ } {
301+ t.Run(mutate.name, func(t *testing.T) {
302+ generation := cloneAcceptanceGeneration(valid)
303+ generation.Fallback = false
304+ mutate.fn(&generation)
305+ _, err := sessionFromGeneration(generation)
306+ require.Error(t, err)
307+ })
778308 }
779- return len(b), nil
780-}
781-func (p *restorePTY) Close() error {
782- p.mu.Lock()
783- p.closes++
784- p.mu.Unlock()
785- p.once.Do(func() { close(p.done) })
786- return nil
787-}
788-func (p *restorePTY) closeCount() int {
789- p.mu.Lock()
790- defer p.mu.Unlock()
791- return p.closes
792309 }
793-func (p *restorePTY) Resize(domain.Size) error { return nil }
794-func (p *restorePTY) Pid() int { return 0 }
795-func (p *restorePTY) ForegroundPgid() (int, error) { return 0, nil }
A internal/usecase/daemon/snapshot_scheduler.go +68 -0
@@ -0,0 +1,68 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "time"
6+)
7+
8+func (d *Daemon) snapshotRepositorySaver(ctx context.Context) {
9+ if d == nil {
10+ return
11+ }
12+ timer := d.clock.NewTimer(24 * time.Hour)
13+ defer timer.Stop()
14+ for {
15+ delay := d.scheduleEligibleRepositorySnapshots()
16+ timer.Reset(delay)
17+ select {
18+ case <-ctx.Done():
19+ return
20+ case <-d.snapshotWake:
21+ case <-timer.C():
22+ }
23+ }
24+}
25+
26+func (d *Daemon) scheduleEligibleRepositorySnapshots() time.Duration {
27+ d.mu.Lock()
28+ sessions := d.sessionsSnapshotLocked()
29+ d.mu.Unlock()
30+ now := d.clock.Now()
31+ for _, sess := range sessions {
32+ if !sess.snapEligible.Load() || !sess.snapDirty.Load() {
33+ continue
34+ }
35+ sess.snapshotMu.Lock()
36+ due := !sess.snapshotPending && (sess.snapshotNextEligibleAt.IsZero() || !now.Before(sess.snapshotNextEligibleAt))
37+ sess.snapshotMu.Unlock()
38+ if due {
39+ d.scheduleSnapshot(sess)
40+ }
41+ }
42+
43+ // Scheduling can synchronously fail or mark a capture pending, so inspect
44+ // the final state to select the actual earliest timer deadline.
45+ var next time.Time
46+ for _, sess := range sessions {
47+ if !sess.snapEligible.Load() || !sess.snapDirty.Load() {
48+ continue
49+ }
50+ sess.snapshotMu.Lock()
51+ eligibleAt := sess.snapshotNextEligibleAt
52+ pending := sess.snapshotPending
53+ sess.snapshotMu.Unlock()
54+ if pending || eligibleAt.IsZero() {
55+ continue
56+ }
57+ if next.IsZero() || eligibleAt.Before(next) {
58+ next = eligibleAt
59+ }
60+ }
61+ if next.IsZero() {
62+ return 24 * time.Hour
63+ }
64+ if delay := next.Sub(now); delay > 0 {
65+ return delay
66+ }
67+ return 0
68+}
A internal/usecase/daemon/snapshot_scheduler_test.go +981 -0
@@ -0,0 +1,981 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "sync"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/ports"
17+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
18+)
19+
20+func TestSnapshotCoordinatorQuarantineCancelsPublicationBeforeDelete(t *testing.T) {
21+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
22+ repository := portsmocks.NewMockSnapshotRepository(t)
23+ WithSnapshotRepository(repository, nil)(d)
24+ startSnapshotEncodeWorker(t, d)
25+
26+ started := make(chan struct{})
27+ repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.SnapshotPublication) error {
28+ close(started)
29+ <-ctx.Done()
30+ return ctx.Err()
31+ }).Once()
32+ sess := newSnapshotTestSession(t, "work", false, "/work")
33+ markSnapshotDirty(sess)
34+ require.True(t, d.scheduleSnapshot(sess))
35+ select {
36+ case <-started:
37+ case <-time.After(testWaitTimeout):
38+ t.Fatal("publication did not start")
39+ }
40+
41+ done := quarantineSnapshotCoordinator(sess)
42+ select {
43+ case <-done:
44+ case <-time.After(testWaitTimeout):
45+ t.Fatal("quarantine did not join canceled publication")
46+ }
47+}
48+
49+func TestSnapshotCoordinatorQuarantineJoinsInFlightPublication(t *testing.T) {
50+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
51+ repository := portsmocks.NewMockSnapshotRepository(t)
52+ WithSnapshotRepository(repository, nil)(d)
53+ startSnapshotEncodeWorker(t, d)
54+
55+ started := make(chan struct{})
56+ release := make(chan struct{})
57+ repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.SnapshotPublication) error {
58+ close(started)
59+ <-release // deliberately ignore cancellation
60+ return nil
61+ }).Once()
62+ sess := newSnapshotTestSession(t, "work", false, "/work")
63+ markSnapshotDirty(sess)
64+ require.True(t, d.scheduleSnapshot(sess))
65+ select {
66+ case <-started:
67+ case <-time.After(testWaitTimeout):
68+ t.Fatal("publication did not start")
69+ }
70+
71+ done := quarantineSnapshotCoordinator(sess)
72+ select {
73+ case <-done:
74+ t.Fatal("quarantine joined before publication returned")
75+ default:
76+ }
77+ close(release)
78+ select {
79+ case <-done:
80+ case <-time.After(testWaitTimeout):
81+ t.Fatal("quarantine did not join publication")
82+ }
83+}
84+
85+func TestRenameKeepsSnapshotCoordinatorQuarantinedUntilNewIdentityCommits(t *testing.T) {
86+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
87+ store, state := newMockStore(t)
88+ WithStore(store)(d)
89+ repository := portsmocks.NewMockSnapshotRepository(t)
90+ WithSnapshotRepository(repository, nil)(d)
91+ startSnapshotEncodeWorker(t, d)
92+
93+ deleteEntered := make(chan struct{})
94+ allowDelete := make(chan struct{})
95+ deleteCompleted := make(chan struct{})
96+ state.deleteDone = deleteCompleted
97+ published := make(chan ports.SnapshotPublication, 2)
98+ repository.EXPECT().Tombstone(mock.Anything, "work").Return(nil).Once()
99+ repository.EXPECT().Delete(mock.Anything, "work").RunAndReturn(func(context.Context, string) error {
100+ close(deleteEntered)
101+ <-allowDelete
102+ return nil
103+ }).Once()
104+ repository.EXPECT().DeleteTombstone(mock.Anything, "work").Return(nil).Once()
105+ repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {
106+ published <- publication
107+ return nil
108+ }).Once()
109+
110+ pty, release := newBlockingPTY(t)
111+ defer release()
112+ d.ptys = newFactory(t, pty)
113+ sess, err := d.createSessionLocked("work", false, "/work", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
114+ require.NoError(t, err)
115+ markSnapshotDirty(sess)
116+
117+ renameDone := make(chan error, 1)
118+ go func() { renameDone <- d.renameSession(sess, "renamed") }()
119+ select {
120+ case <-deleteEntered:
121+ case <-time.After(testWaitTimeout):
122+ t.Fatal("rename did not begin deleting the old snapshot identity")
123+ }
124+
125+ // Hold the daemon identity commit after Delete has returned. Scheduler work
126+ // during this window must not capture or publish the old name.
127+ d.mu.Lock()
128+ close(allowDelete)
129+ select {
130+ case <-deleteCompleted:
131+ case <-time.After(testWaitTimeout):
132+ t.Fatal("old snapshot identity was not deleted")
133+ }
134+ require.False(t, state.has("work"))
135+ sess.snapshotMu.Lock()
136+ quarantined := sess.snapshotQuarantined
137+ sess.snapshotMu.Unlock()
138+ require.True(t, quarantined, "old Delete returned before the coordinator resumed")
139+
140+ schedulerCtx, cancelScheduler := context.WithCancel(context.Background())
141+ defer cancelScheduler()
142+ go d.snapshotRepositorySaver(schedulerCtx)
143+ select {
144+ case d.snapshotWake <- struct{}{}:
145+ default:
146+ }
147+ require.True(t, d.scheduleSnapshot(sess), "old work may be coalesced while the coordinator is quarantined")
148+ d.mu.Unlock()
149+
150+ require.NoError(t, <-renameDone)
151+ select {
152+ case publication := <-published:
153+ require.Equal(t, "renamed", publication.Name)
154+ require.Equal(t, uint64(1), publication.Generation)
155+ case <-time.After(testWaitTimeout):
156+ t.Fatal("new identity was not published")
157+ }
158+}
159+
160+func TestRenameRollbackAfterOldDeleteLeavesOldCoordinatorStopped(t *testing.T) {
161+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
162+ store, state := newMockStore(t)
163+ WithStore(store)(d)
164+ repository := portsmocks.NewMockSnapshotRepository(t)
165+ WithSnapshotRepository(repository, nil)(d)
166+ startSnapshotEncodeWorker(t, d)
167+
168+ deleteOldRecordErr := errors.New("old record delete failed")
169+ state.mu.Lock()
170+ state.deleteErr = func(key string) error {
171+ if key == "work" {
172+ return deleteOldRecordErr
173+ }
174+ return nil
175+ }
176+ state.mu.Unlock()
177+ repository.EXPECT().Tombstone(mock.Anything, "work").Return(nil).Once()
178+ repository.EXPECT().Delete(mock.Anything, "work").Return(nil).Once()
179+ // No Publish expectation is installed: any publication is an unexpected mock
180+ // call. The saver handshake below joins the scheduler before this test ends.
181+
182+ pty, release := newBlockingPTY(t)
183+ defer release()
184+ d.ptys = newFactory(t, pty)
185+ sess, err := d.createSessionLocked("work", false, "/work", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
186+ require.NoError(t, err)
187+ markSnapshotDirty(sess)
188+
189+ require.ErrorIs(t, d.renameSession(sess, "renamed"), deleteOldRecordErr)
190+ sess.mu.Lock()
191+ require.Equal(t, "work", sess.name)
192+ require.False(t, sess.ephemeral)
193+ require.False(t, sess.renameInProgress)
194+ sess.mu.Unlock()
195+ require.True(t, state.has("work"))
196+ require.False(t, state.has("renamed"))
197+ sess.snapshotMu.Lock()
198+ quarantined := sess.snapshotQuarantined
199+ sess.snapshotMu.Unlock()
200+ require.True(t, quarantined)
201+
202+ // Drain stale producer notifications, then use the timer-reset handshake to
203+ // prove the saver consumed this wake and completed its scheduling pass. Stop
204+ // and join it before asserting: no publisher can start after this point.
205+ for {
206+ select {
207+ case <-d.snapshotWake:
208+ default:
209+ goto wakeDrained
210+ }
211+ }
212+
213+wakeDrained:
214+ clock := newDeterministicSnapshotClock(time.Unix(100, 0))
215+ d.clock = clock
216+ schedulerCtx, cancelScheduler := context.WithCancel(context.Background())
217+ schedulerDone := make(chan struct{})
218+ go func() {
219+ d.snapshotRepositorySaver(schedulerCtx)
220+ close(schedulerDone)
221+ }()
222+ timer := clock.timer()
223+ require.Equal(t, 24*time.Hour, <-timer.resets)
224+ select {
225+ case d.snapshotWake <- struct{}{}:
226+ default:
227+ t.Fatal("snapshot wake unexpectedly remained full")
228+ }
229+ require.Equal(t, 24*time.Hour, <-timer.resets, "scheduler did not process the rollback wake")
230+ cancelScheduler()
231+ select {
232+ case <-schedulerDone:
233+ case <-time.After(testWaitTimeout):
234+ t.Fatal("snapshot scheduler did not stop")
235+ }
236+ // The deterministic scheduler clock has no independently firing deadline;
237+ // restore the daemon's normal test clock before its session cleanup runs.
238+ d.clock = stubClock{}
239+}
240+
241+func TestForcedSnapshotSchedulesSuccessorAfterRoutineInFlight(t *testing.T) {
242+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
243+ repository := portsmocks.NewMockSnapshotRepository(t)
244+ WithSnapshotRepository(repository, nil)(d)
245+ startSnapshotEncodeWorker(t, d)
246+
247+ started := make(chan struct{})
248+ release := make(chan struct{})
249+ publications := make(chan ports.SnapshotPublication, 2)
250+ repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {
251+ publications <- publication
252+ if publication.Generation == 1 {
253+ close(started)
254+ <-release
255+ }
256+ return nil
257+ }).Twice()
258+
259+ sess := newSnapshotTestSession(t, "work", false, "/work")
260+ markSnapshotDirty(sess)
261+ require.True(t, d.scheduleSnapshot(sess))
262+ select {
263+ case <-started:
264+ case <-time.After(testWaitTimeout):
265+ t.Fatal("routine publication did not start")
266+ }
267+
268+ // The terminal state changed after the routine capture, so its publication
269+ // cannot satisfy the forced checkpoint.
270+ sess.tabs[0].panes["pane-1"].screen.Write([]byte(" terminal"))
271+ markSnapshotDirty(sess)
272+ require.True(t, d.scheduleFinalSnapshot(sess))
273+ close(release)
274+
275+ awaitSnapshotClean(t, sess)
276+ require.Equal(t, uint64(1), (<-publications).Generation)
277+ require.Equal(t, uint64(2), (<-publications).Generation)
278+}
279+
280+func TestForcedSnapshotSchedulesSuccessorForRoutineCaptureQueuedBehindWorker(t *testing.T) {
281+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
282+ repository := portsmocks.NewMockSnapshotRepository(t)
283+ WithSnapshotRepository(repository, nil)(d)
284+ startSnapshotEncodeWorker(t, d)
285+
286+ blockerStarted := make(chan struct{})
287+ releaseBlocker := make(chan struct{})
288+ workPublications := make(chan ports.SnapshotPublication, 2)
289+ repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {
290+ if publication.Name == "blocker" {
291+ close(blockerStarted)
292+ <-releaseBlocker
293+ return nil
294+ }
295+ workPublications <- publication
296+ return nil
297+ }).Times(3)
298+
299+ blocker := newSnapshotTestSession(t, "blocker", false, "/work")
300+ markSnapshotDirty(blocker)
301+ require.True(t, d.scheduleSnapshot(blocker))
302+ select {
303+ case <-blockerStarted:
304+ case <-time.After(testWaitTimeout):
305+ t.Fatal("blocking publication did not start")
306+ }
307+
308+ sess := newSnapshotTestSession(t, "work", false, "/work")
309+ markSnapshotDirty(sess)
310+ require.True(t, d.scheduleSnapshot(sess), "routine capture should occupy the bounded queue")
311+ sess.tabs[0].panes["pane-1"].screen.Write([]byte(" terminal"))
312+ markSnapshotDirty(sess)
313+ require.True(t, d.scheduleFinalSnapshot(sess))
314+ close(releaseBlocker)
315+
316+ awaitSnapshotClean(t, sess)
317+ require.Equal(t, uint64(1), (<-workPublications).Generation)
318+ require.Equal(t, uint64(2), (<-workPublications).Generation)
319+}
320+
321+func TestMultipleForcedSnapshotsCoalesceToOneSuccessor(t *testing.T) {
322+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
323+ repository := portsmocks.NewMockSnapshotRepository(t)
324+ WithSnapshotRepository(repository, nil)(d)
325+ startSnapshotEncodeWorker(t, d)
326+
327+ started := make(chan struct{})
328+ release := make(chan struct{})
329+ publications := make(chan ports.SnapshotPublication, 2)
330+ repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {
331+ publications <- publication
332+ if publication.Generation == 1 {
333+ close(started)
334+ <-release
335+ }
336+ return nil
337+ }).Twice()
338+
339+ sess := newSnapshotTestSession(t, "work", false, "/work")
340+ markSnapshotDirty(sess)
341+ require.True(t, d.scheduleSnapshot(sess))
342+ select {
343+ case <-started:
344+ case <-time.After(testWaitTimeout):
345+ t.Fatal("routine publication did not start")
346+ }
347+ markSnapshotDirty(sess)
348+ require.True(t, d.scheduleFinalSnapshot(sess))
349+ markSnapshotDirty(sess)
350+ require.True(t, d.scheduleFinalSnapshot(sess))
351+ close(release)
352+
353+ awaitSnapshotClean(t, sess)
354+ require.Equal(t, uint64(1), (<-publications).Generation)
355+ require.Equal(t, uint64(2), (<-publications).Generation)
356+ sess.snapshotMu.Lock()
357+ require.False(t, sess.snapshotPending)
358+ require.Nil(t, sess.snapshotQueuedCapture)
359+ require.Nil(t, sess.snapshotInFlightCapture)
360+ sess.snapshotMu.Unlock()
361+}
362+
363+func TestForcedSnapshotShutdownTimeoutRetainsRetryableStateAndNotice(t *testing.T) {
364+ clock := newSnapshotDeadlineClock()
365+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
366+ repository := portsmocks.NewMockSnapshotRepository(t)
367+ WithSnapshotRepository(repository, nil)(d)
368+ notices := portsmocks.NewMockNoticeStore(t)
369+ WithNoticeStore(notices)(d)
370+ startSnapshotEncodeWorker(t, d)
371+
372+ blockerStarted := make(chan struct{})
373+ releaseBlocker := make(chan struct{})
374+ var releaseBlockerOnce sync.Once
375+ release := func() { releaseBlockerOnce.Do(func() { close(releaseBlocker) }) }
376+ // Register this after newTestDaemon's worker cleanup so the uncooperative
377+ // repository call is always released before cleanup asks the worker to join.
378+ t.Cleanup(release)
379+ repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {
380+ require.Equal(t, "blocker", publication.Name)
381+ close(blockerStarted)
382+ <-releaseBlocker
383+ return nil
384+ }).Once()
385+ notices.EXPECT().Append(mock.MatchedBy(func(notification domain.Notification) bool {
386+ return notification.Code == domain.NoticeSnapshotWrite && notification.Severity == domain.NoticeError
387+ })).Return(nil).Once()
388+
389+ blocker := newSnapshotTestSession(t, "blocker", false, "/work")
390+ markSnapshotDirty(blocker)
391+ require.True(t, d.scheduleSnapshot(blocker))
392+ select {
393+ case <-blockerStarted:
394+ case <-time.After(testWaitTimeout):
395+ t.Fatal("blocking publication did not start")
396+ }
397+
398+ sess := newSnapshotTestSession(t, "work", false, "/work")
399+ pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
400+ require.True(t, ok, "snapshot test pane must use MockPTY")
401+ pty.EXPECT().Close().Return(nil).Maybe()
402+ d.sessions[sess.id] = sess
403+ markSnapshotDirty(sess)
404+ require.True(t, d.scheduleSnapshot(sess), "routine capture should occupy the bounded queue")
405+
406+ killed := make(chan error, 1)
407+ go func() { killed <- d.killSession(sess, ports.ReasonServerShutdown, false) }()
408+ timer := clock.nextTimer(t)
409+ timer.fire()
410+ require.Error(t, <-killed)
411+
412+ func() {
413+ sess.snapshotMu.Lock()
414+ defer sess.snapshotMu.Unlock()
415+ require.True(t, sess.snapDirty.Load())
416+ require.Greater(t, sess.snapshotForcedGeneration, sess.snapshotPublishedGeneration)
417+ require.True(t, sess.snapshotPending)
418+ require.Equal(t, uint(1), sess.snapshotPendingCaptures)
419+ require.NotNil(t, sess.snapshotQueuedCapture, "the one routine capture remains retryable")
420+ require.Nil(t, sess.snapshotInFlightCapture)
421+ }()
422+ d.snapshotWorkerMu.Lock()
423+ require.LessOrEqual(t, len(d.snapshotJobs), snapshotQueueCapacity)
424+ d.snapshotWorkerMu.Unlock()
425+
426+ // The retained capture is not published after quarantine, but releasing the
427+ // unrelated blocked call lets the worker discard it and lets cleanup join.
428+ release()
429+ for {
430+ sess.snapshotMu.Lock()
431+ complete := !sess.snapshotPending && sess.snapshotQueuedCapture == nil
432+ changed := sess.snapshotChangeLocked()
433+ sess.snapshotMu.Unlock()
434+ if complete {
435+ break
436+ }
437+ select {
438+ case <-changed:
439+ case <-time.After(testWaitTimeout):
440+ t.Fatal("quarantined capture was not discarded")
441+ }
442+ }
443+}
444+
445+func TestServeShutdownCheckpointsBeforeStoppingSnapshotWorker(t *testing.T) {
446+ p, releasePTY := newBlockingPTY(t)
447+ defer releasePTY()
448+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
449+ repository := portsmocks.NewMockSnapshotRepository(t)
450+ WithSnapshotRepository(repository, nil)(d)
451+ repository.EXPECT().List(mock.Anything).Return(nil, nil).Maybe()
452+
453+ published := make(chan ports.SnapshotPublication, 2)
454+ allowTerminalPublication := make(chan struct{})
455+ repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {
456+ published <- publication
457+ if publication.Generation == 2 {
458+ <-allowTerminalPublication
459+ }
460+ return nil
461+ }).Twice()
462+
463+ tr, sends, _ := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}))
464+ l := serveSnapshotListener(t, tr)
465+ ctx, cancel := context.WithCancel(context.Background())
466+ defer cancel()
467+ served := make(chan error, 1)
468+ go func() { served <- d.Serve(ctx, l) }()
469+
470+ awaitFrame(t, sends, ports.MsgWelcome)
471+ markSnapshotDirty(firstSession(d))
472+ d.snapshotWake <- struct{}{}
473+ first := <-published
474+ require.Equal(t, uint64(1), first.Generation)
475+
476+ cancel()
477+ terminal := <-published
478+ require.Equal(t, uint64(2), terminal.Generation, "shutdown must publish a forced terminal checkpoint")
479+ close(allowTerminalPublication)
480+
481+ select {
482+ case err := <-served:
483+ require.NoError(t, err)
484+ case <-time.After(testWaitTimeout):
485+ t.Fatal("Serve did not return after the terminal checkpoint completed")
486+ }
487+}
488+
489+func TestServeShutdownDeadlineDoesNotWaitTwiceForUncooperativeSnapshotRepository(t *testing.T) {
490+ p, releasePTY := newBlockingPTY(t)
491+ defer releasePTY()
492+ clock := newServeShutdownClock()
493+ d := newTestDaemon(t, newFactory(t, p), clock)
494+ repository := portsmocks.NewMockSnapshotRepository(t)
495+ WithSnapshotRepository(repository, nil)(d)
496+ repository.EXPECT().List(mock.Anything).Return(nil, nil).Maybe()
497+ noticeStore := portsmocks.NewMockNoticeStore(t)
498+ WithNoticeStore(noticeStore)(d)
499+ noticeStore.EXPECT().Claim().Return(nil, nil).Maybe()
500+ noticeStore.EXPECT().Ack().Return(nil).Maybe()
501+ noticeStore.EXPECT().Append(mock.MatchedBy(func(n domain.Notification) bool {
502+ return n.Code == domain.NoticeSnapshotWrite && n.Severity == domain.NoticeError
503+ })).Return(nil).Once()
504+
505+ firstPublicationCompleted := make(chan struct{})
506+ terminalPublicationStarted := make(chan struct{})
507+ releaseTerminalPublication := make(chan struct{})
508+ var lastValid atomic.Uint64
509+ repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {
510+ if publication.Generation == 1 {
511+ lastValid.Store(publication.Generation)
512+ close(firstPublicationCompleted)
513+ return nil
514+ }
515+ close(terminalPublicationStarted)
516+ <-releaseTerminalPublication // Deliberately ignores the worker cancellation context.
517+ return nil
518+ }).Twice()
519+
520+ tr, sends, _ := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}))
521+ l := serveSnapshotListener(t, tr)
522+ ctx, cancel := context.WithCancel(context.Background())
523+ defer cancel()
524+ served := make(chan error, 1)
525+ go func() { served <- d.Serve(ctx, l) }()
526+
527+ awaitFrame(t, sends, ports.MsgWelcome)
528+ markSnapshotDirty(firstSession(d))
529+ d.snapshotWake <- struct{}{}
530+ select {
531+ case <-firstPublicationCompleted:
532+ case <-time.After(testWaitTimeout):
533+ t.Fatal("initial checkpoint did not complete")
534+ }
535+
536+ cancel()
537+ select {
538+ case <-terminalPublicationStarted:
539+ case <-time.After(testWaitTimeout):
540+ t.Fatal("shutdown terminal checkpoint did not reach the worker")
541+ }
542+ d.snapshotWorkerMu.Lock()
543+ workerDone := d.snapshotWorkerDone
544+ d.snapshotWorkerMu.Unlock()
545+ require.NotNil(t, workerDone)
546+ defer func() {
547+ close(releaseTerminalPublication)
548+ select {
549+ case <-workerDone:
550+ case <-time.After(testWaitTimeout):
551+ t.Error("snapshot worker leaked after its repository call was released")
552+ }
553+ }()
554+ clock.nextFinalTimer(t).fire()
555+
556+ select {
557+ case err := <-served:
558+ require.NoError(t, err)
559+ case <-time.After(testWaitTimeout):
560+ t.Fatal("Serve waited beyond the terminal checkpoint deadline")
561+ }
562+ require.LessOrEqual(t, clock.finalBudget.Load(), int64(snapshotFinalFlushTimeout), "shutdown must consume at most one snapshot deadline")
563+ require.Equal(t, uint64(1), lastValid.Load(), "the last valid checkpoint remains available after the terminal attempt times out")
564+}
565+
566+type serveShutdownClock struct {
567+ finalTimers chan *snapshotDeadlineTimer
568+ finalBudget atomic.Int64
569+}
570+
571+func newServeShutdownClock() *serveShutdownClock {
572+ return &serveShutdownClock{finalTimers: make(chan *snapshotDeadlineTimer, 2)}
573+}
574+
575+func (*serveShutdownClock) Now() time.Time { return time.Unix(100, 0) }
576+func (c *serveShutdownClock) NewTimer(delay time.Duration) ports.Timer {
577+ if delay != snapshotFinalFlushTimeout {
578+ return stubTimer{}
579+ }
580+ timer := &snapshotDeadlineTimer{ch: make(chan time.Time, 1), onFire: func() {
581+ c.finalBudget.Add(int64(delay))
582+ }}
583+ c.finalTimers <- timer
584+ return timer
585+}
586+
587+func (c *serveShutdownClock) nextFinalTimer(t *testing.T) *snapshotDeadlineTimer {
588+ t.Helper()
589+ select {
590+ case timer := <-c.finalTimers:
591+ return timer
592+ case <-time.After(testWaitTimeout):
593+ t.Fatal("shutdown checkpoint deadline timer was not created")
594+ return nil
595+ }
596+}
597+
598+func serveSnapshotListener(t *testing.T, tr ports.Transport) *portsmocks.MockListener {
599+ t.Helper()
600+ l := portsmocks.NewMockListener(t)
601+ connections := make(chan ports.Transport, 1)
602+ connections <- tr
603+ closed := make(chan struct{})
604+ var once sync.Once
605+ l.EXPECT().Accept().RunAndReturn(func() (ports.Transport, error) {
606+ select {
607+ case conn := <-connections:
608+ return conn, nil
609+ case <-closed:
610+ return nil, io.EOF
611+ }
612+ }).Maybe()
613+ l.EXPECT().Close().RunAndReturn(func() error {
614+ once.Do(func() { close(closed) })
615+ return nil
616+ }).Maybe()
617+ l.EXPECT().Addr().Return("mock").Maybe()
618+ return l
619+}
620+
621+func TestRoutineSnapshotEligibilityStartsAtCompletionAndForcedDoesNotMoveIt(t *testing.T) {
622+ clock := &snapshotSchedulerClock{now: time.Unix(100, 0)}
623+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
624+ repository := portsmocks.NewMockSnapshotRepository(t)
625+ WithSnapshotRepository(repository, nil)(d)
626+ startSnapshotEncodeWorker(t, d)
627+
628+ var publishes atomic.Int32
629+ repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _ ports.SnapshotPublication) error {
630+ publishes.Add(1)
631+ return nil
632+ }).Times(2)
633+
634+ sess := newSnapshotTestSession(t, "work", false, "/work")
635+ markSnapshotDirty(sess)
636+ require.True(t, d.scheduleSnapshot(sess))
637+ awaitSnapshotClean(t, sess)
638+
639+ sess.snapshotMu.Lock()
640+ eligibleAt := sess.snapshotNextEligibleAt
641+ sess.snapshotMu.Unlock()
642+ require.Equal(t, time.Unix(100, 0).Add(snapshotInterval), eligibleAt)
643+
644+ markSnapshotDirty(sess)
645+ require.True(t, d.scheduleSnapshot(sess))
646+ require.Equal(t, int32(1), publishes.Load(), "rate-limited routine work must not publish")
647+
648+ require.True(t, d.scheduleFinalSnapshot(sess))
649+ awaitSnapshotClean(t, sess)
650+ sess.snapshotMu.Lock()
651+ require.Equal(t, eligibleAt, sess.snapshotNextEligibleAt)
652+ sess.snapshotMu.Unlock()
653+}
654+
655+type snapshotSchedulerClock struct {
656+ mu sync.Mutex
657+ now time.Time
658+}
659+
660+func (c *snapshotSchedulerClock) Now() time.Time {
661+ c.mu.Lock()
662+ defer c.mu.Unlock()
663+ return c.now
664+}
665+
666+func (*snapshotSchedulerClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
667+
668+type snapshotDeadlineClock struct {
669+ timers chan *snapshotDeadlineTimer
670+}
671+
672+func newSnapshotDeadlineClock() *snapshotDeadlineClock {
673+ return &snapshotDeadlineClock{timers: make(chan *snapshotDeadlineTimer, 4)}
674+}
675+
676+func (*snapshotDeadlineClock) Now() time.Time { return time.Unix(100, 0) }
677+func (c *snapshotDeadlineClock) NewTimer(time.Duration) ports.Timer {
678+ timer := &snapshotDeadlineTimer{ch: make(chan time.Time, 1)}
679+ c.timers <- timer
680+ return timer
681+}
682+
683+func (c *snapshotDeadlineClock) nextTimer(t *testing.T) *snapshotDeadlineTimer {
684+ t.Helper()
685+ select {
686+ case timer := <-c.timers:
687+ return timer
688+ case <-time.After(testWaitTimeout):
689+ t.Fatal("snapshot deadline timer was not created")
690+ return nil
691+ }
692+}
693+
694+type snapshotDeadlineTimer struct {
695+ ch chan time.Time
696+ onFire func()
697+}
698+
699+func (t *snapshotDeadlineTimer) C() <-chan time.Time { return t.ch }
700+func (*snapshotDeadlineTimer) Reset(time.Duration) bool { return true }
701+func (*snapshotDeadlineTimer) Stop() bool { return true }
702+func (t *snapshotDeadlineTimer) fire() {
703+ if t.onFire != nil {
704+ t.onFire()
705+ }
706+ t.ch <- time.Unix(101, 0)
707+}
708+
709+// deterministicSnapshotClock records timer resets and advances only when a
710+// test explicitly fires its timer. It deliberately has no wall-clock behavior.
711+type deterministicSnapshotClock struct {
712+ mu sync.Mutex
713+ now time.Time
714+ timers chan *deterministicSnapshotTimer
715+}
716+
717+func newDeterministicSnapshotClock(now time.Time) *deterministicSnapshotClock {
718+ return &deterministicSnapshotClock{now: now, timers: make(chan *deterministicSnapshotTimer, 1)}
719+}
720+
721+func (c *deterministicSnapshotClock) Now() time.Time {
722+ c.mu.Lock()
723+ defer c.mu.Unlock()
724+ return c.now
725+}
726+
727+func (c *deterministicSnapshotClock) advance(now time.Time) {
728+ c.mu.Lock()
729+ c.now = now
730+ c.mu.Unlock()
731+}
732+
733+func (c *deterministicSnapshotClock) NewTimer(time.Duration) ports.Timer {
734+ timer := &deterministicSnapshotTimer{clock: c, ch: make(chan time.Time, 1), resets: make(chan time.Duration, 16)}
735+ c.timers <- timer
736+ return timer
737+}
738+
739+func (c *deterministicSnapshotClock) timer() *deterministicSnapshotTimer { return <-c.timers }
740+
741+type deterministicSnapshotTimer struct {
742+ clock *deterministicSnapshotClock
743+ ch chan time.Time
744+ resets chan time.Duration
745+}
746+
747+func (t *deterministicSnapshotTimer) C() <-chan time.Time { return t.ch }
748+func (t *deterministicSnapshotTimer) Reset(delay time.Duration) bool {
749+ t.resets <- delay
750+ return true
751+}
752+func (*deterministicSnapshotTimer) Stop() bool { return true }
753+func (t *deterministicSnapshotTimer) fire() { t.ch <- t.clock.Now() }
754+
755+func awaitSnapshotCleanSignal(sess *session) {
756+ for {
757+ sess.snapshotMu.Lock()
758+ clean := !sess.snapDirty.Load()
759+ changed := sess.snapshotChangeLocked()
760+ sess.snapshotMu.Unlock()
761+ if clean {
762+ return
763+ }
764+ <-changed
765+ }
766+}
767+
768+func TestSnapshotCompletionSchedulesRoutineRetriesFromCompletion(t *testing.T) {
769+ now := time.Unix(100, 0)
770+ clock := newDeterministicSnapshotClock(now)
771+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
772+ repository := portsmocks.NewMockSnapshotRepository(t)
773+ WithSnapshotRepository(repository, nil)(d)
774+
775+ for _, tt := range []struct {
776+ name string
777+ kind snapshotAttemptKind
778+ succeeded bool
779+ existingEligible time.Time
780+ wantDirty bool
781+ wantEligible time.Time
782+ }{
783+ {name: "routine success", kind: snapshotAttemptRoutine, succeeded: true, wantEligible: now.Add(snapshotInterval)},
784+ {name: "routine failure", kind: snapshotAttemptRoutine, wantDirty: true, wantEligible: now.Add(snapshotInterval)},
785+ {name: "forced completion preserves routine eligibility", kind: snapshotAttemptForced, succeeded: true, existingEligible: now.Add(time.Minute), wantEligible: now.Add(time.Minute)},
786+ } {
787+ t.Run(tt.name, func(t *testing.T) {
788+ sess := newSnapshotTestSession(t, "work", false, "/work")
789+ sess.snapshotGeneration = 1
790+ sess.snapshotNextEligibleAt = tt.existingEligible
791+ sess.snapDirty.Store(true)
792+ d.finishSnapshotCapture(&snapshotCapture{session: sess, generation: 1, attemptKind: tt.kind}, tt.succeeded)
793+
794+ require.Equal(t, tt.wantDirty, sess.snapDirty.Load())
795+ sess.snapshotMu.Lock()
796+ require.Equal(t, tt.wantEligible, sess.snapshotNextEligibleAt)
797+ sess.snapshotMu.Unlock()
798+ })
799+ }
800+}
801+
802+func TestSnapshotSchedulerImmediateEligibilityAndStaleCapturesRemainDirty(t *testing.T) {
803+ now := time.Unix(100, 0)
804+ clock := newDeterministicSnapshotClock(now)
805+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
806+ WithSnapshotRepository(portsmocks.NewMockSnapshotRepository(t), nil)(d)
807+
808+ t.Run("no prior attempt is immediately eligible", func(t *testing.T) {
809+ published := make(chan ports.SnapshotPublication, 1)
810+ repository := portsmocks.NewMockSnapshotRepository(t)
811+ WithSnapshotRepository(repository, nil)(d)
812+ startSnapshotEncodeWorker(t, d)
813+ repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {
814+ published <- publication
815+ return nil
816+ }).Once()
817+
818+ sess := newSnapshotTestSession(t, "immediate", false, "/work")
819+ sess.snapshotWake = d.snapshotWake
820+ sess.snapDirty.Store(true)
821+ d.sessions[sess.id] = sess
822+
823+ d.scheduleEligibleRepositorySnapshots()
824+ select {
825+ case publication := <-published:
826+ require.Equal(t, "immediate", publication.Name)
827+ case <-time.After(testWaitTimeout):
828+ t.Fatal("immediately eligible snapshot was not published")
829+ }
830+ awaitSnapshotClean(t, sess)
831+ })
832+
833+ t.Run("queued and in-flight stale successes cannot clear newer generation", func(t *testing.T) {
834+ for _, state := range []struct {
835+ name string
836+ set func(*session, *snapshotCapture)
837+ }{
838+ {name: "queued", set: func(sess *session, capture *snapshotCapture) { sess.snapshotQueuedCapture = capture }},
839+ {name: "in flight", set: func(sess *session, capture *snapshotCapture) { sess.snapshotInFlightCapture = capture }},
840+ } {
841+ t.Run(state.name, func(t *testing.T) {
842+ sess := newSnapshotTestSession(t, state.name, false, "/work")
843+ sess.snapshotGeneration = 1
844+ sess.snapDirty.Store(true)
845+ capture := &snapshotCapture{session: sess, generation: 1, attemptKind: snapshotAttemptRoutine}
846+ sess.snapshotPendingCaptures = 1
847+ sess.snapshotPending = true
848+ state.set(sess, capture)
849+
850+ markSnapshotDirty(sess)
851+ d.finishSnapshotCapture(capture, true)
852+
853+ require.True(t, sess.snapDirty.Load())
854+ require.Equal(t, uint64(1), sess.snapshotPublishedGeneration)
855+ require.Equal(t, now.Add(snapshotInterval), sess.snapshotNextEligibleAt)
856+ })
857+ }
858+ })
859+}
860+
861+func TestSnapshotWorkerQueueIsBoundedAndForcedCapturesCoalesce(t *testing.T) {
862+ now := time.Unix(100, 0)
863+ clock := newDeterministicSnapshotClock(now)
864+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
865+ repository := portsmocks.NewMockSnapshotRepository(t)
866+ WithSnapshotRepository(repository, nil)(d)
867+ startSnapshotEncodeWorker(t, d)
868+
869+ started := make(chan struct{})
870+ release := make(chan struct{})
871+ publications := make(chan ports.SnapshotPublication, 3)
872+ var blockFirst sync.Once
873+ repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {
874+ publications <- publication
875+ if publication.Name == "in-flight" {
876+ blockFirst.Do(func() {
877+ close(started)
878+ <-release
879+ })
880+ }
881+ return nil
882+ }).Times(3)
883+
884+ inFlight := newSnapshotTestSession(t, "in-flight", false, "/work")
885+ markSnapshotDirty(inFlight)
886+ require.True(t, d.scheduleSnapshot(inFlight))
887+ <-started
888+
889+ queued := newSnapshotTestSession(t, "queued", false, "/work")
890+ markSnapshotDirty(queued)
891+ require.True(t, d.scheduleSnapshot(queued))
892+
893+ d.snapshotWorkerMu.Lock()
894+ require.NotNil(t, d.snapshotWorkerInFlight)
895+ require.Len(t, d.snapshotJobs, snapshotQueueCapacity)
896+ d.snapshotWorkerMu.Unlock()
897+
898+ saturated := newSnapshotTestSession(t, "saturated", false, "/work")
899+ markSnapshotDirty(saturated)
900+ require.False(t, d.scheduleSnapshot(saturated), "a saturated queue must not block the producer")
901+ require.True(t, saturated.snapDirty.Load(), "the rejected capture remains retryable")
902+ saturated.snapshotMu.Lock()
903+ require.Equal(t, now.Add(snapshotInterval), saturated.snapshotNextEligibleAt)
904+ saturated.snapshotMu.Unlock()
905+
906+ // A forced request bypasses the routine rate limit and repeated requests
907+ // coalesce to one successor after the in-flight capture completes.
908+ markSnapshotDirty(inFlight)
909+ require.True(t, d.scheduleFinalSnapshot(inFlight))
910+ markSnapshotDirty(inFlight)
911+ require.True(t, d.scheduleFinalSnapshot(inFlight))
912+ close(release)
913+
914+ gotGenerations := []uint64{(<-publications).Generation, (<-publications).Generation, (<-publications).Generation}
915+ require.ElementsMatch(t, []uint64{1, 1, 2}, gotGenerations, "one forced successor bypasses the routine limit while queued work remains bounded")
916+ awaitSnapshotCleanSignal(inFlight)
917+}
918+
919+func TestSnapshotRepositorySaverUsesEarliestDeadlineAndRecomputes(t *testing.T) {
920+ now := time.Unix(100, 0)
921+ clock := newDeterministicSnapshotClock(now)
922+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
923+ repository := portsmocks.NewMockSnapshotRepository(t)
924+ WithSnapshotRepository(repository, nil)(d)
925+ startSnapshotEncodeWorker(t, d)
926+
927+ published := make(chan ports.SnapshotPublication, 1)
928+ repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {
929+ published <- publication
930+ return nil
931+ }).Once()
932+
933+ later := newSnapshotTestSession(t, "later", false, "/work")
934+ later.snapshotWake = d.snapshotWake
935+ later.snapDirty.Store(true)
936+ later.snapshotNextEligibleAt = now.Add(10 * time.Minute)
937+ d.sessions[later.id] = later
938+
939+ ctx, cancel := context.WithCancel(context.Background())
940+ done := make(chan struct{})
941+ go func() {
942+ d.snapshotRepositorySaver(ctx)
943+ close(done)
944+ }()
945+ timer := clock.timer()
946+ require.Equal(t, 10*time.Minute, <-timer.resets)
947+
948+ earlier := newSnapshotTestSession(t, "earlier", false, "/work")
949+ earlier.snapshotWake = d.snapshotWake
950+ earlier.snapDirty.Store(true)
951+ earlier.snapshotNextEligibleAt = now.Add(5 * time.Minute)
952+ d.sessions[earlier.id] = earlier
953+ d.snapshotWake <- struct{}{}
954+ require.Equal(t, 5*time.Minute, <-timer.resets)
955+
956+ clock.advance(now.Add(5 * time.Minute))
957+ timer.fire()
958+ // The saver first recomputes immediately after queueing the due capture.
959+ require.Equal(t, 5*time.Minute, <-timer.resets)
960+ publication := <-published
961+ require.Equal(t, "earlier", publication.Name, "timer fires exactly at the earliest eligibility")
962+ for {
963+ earlier.snapshotMu.Lock()
964+ clean := !earlier.snapDirty.Load()
965+ changed := earlier.snapshotChangeLocked()
966+ earlier.snapshotMu.Unlock()
967+ if clean {
968+ break
969+ }
970+ <-changed
971+ }
972+ // The later dirty session is still the next deadline after completion.
973+ require.Equal(t, 5*time.Minute, <-timer.resets)
974+
975+ delete(d.sessions, later.id)
976+ d.snapshotWake <- struct{}{}
977+ require.Equal(t, 24*time.Hour, <-timer.resets)
978+
979+ cancel()
980+ <-done
981+}
M internal/usecase/daemon/snapshot_test.go +11 -1143
@@ -3,10 +3,6 @@ package daemon
33 import (
44 "context"
55 "errors"
6- "fmt"
7- "io"
8- "sync"
9- "sync/atomic"
106 "testing"
117 "time"
128
@@ -16,1012 +12,26 @@ import (
1612 "github.com/bnema/vev/internal/domain"
1713 "github.com/bnema/vev/internal/ports"
1814 portsmocks "github.com/bnema/vev/internal/ports/mocks"
19- "github.com/bnema/vev/internal/usecase/layout"
20- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
2115 "github.com/bnema/vev/pkg/renderer"
22- "github.com/bnema/vev/pkg/vt"
2316 )
2417
25-func TestSnapshotEncodeWorkerDefersMarshalUntilAfterCapture(t *testing.T) {
26- store := &channelSnapshotStore{writes: make(chan []byte, 1)}
18+// TestSnapshotWorkerPublishesContentAddressedCapture verifies that all new
19+// checkpoints use the repository publication contract.
20+func TestSnapshotWorkerPublishesContentAddressedCapture(t *testing.T) {
2721 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
28- WithSnapshotStore(store)(d)
29- t.Cleanup(d.stopSnapshotEncodeWorker)
30- d.startSnapshotEncodeWorker()
31-
32- entered := make(chan struct{})
33- release := make(chan struct{})
34- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
35- close(entered)
36- <-release
37- return snapcodec.Marshal(s)
38- }
39- sess := newSnapshotTestSession(t, "work", false, "/work")
40- markSnapshotDirty(sess)
41- require.True(t, d.scheduleSnapshot(sess))
42- <-entered
43-
44- paneLocked := make(chan struct{})
45- releasePane := make(chan struct{})
46- go func() {
47- p := sess.tabs[0].panes["pane-1"]
48- p.mu.Lock()
49- close(paneLocked)
50- <-releasePane
51- p.mu.Unlock()
52- }()
53- <-paneLocked
54- close(releasePane)
55- close(release)
56- <-store.writes
57-}
58-
59-func TestSnapshotEncodeWorkerDoesNotBlockPTYOrCoordinatorRender(t *testing.T) {
60- exerciseBlockedSnapshotWorker(t, true)
61-}
62-
63-func TestSnapshotStoreWorkerDoesNotBlockPTYOrCoordinatorRender(t *testing.T) {
64- exerciseBlockedSnapshotWorker(t, false)
65-}
66-
67-func exerciseBlockedSnapshotWorker(t *testing.T, blockEncode bool) {
68- t.Helper()
69- store := newGatedSnapshotStore()
70- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
71- WithSnapshotStore(store)(d)
72- d.startSnapshotEncodeWorker()
73- t.Cleanup(func() {
74- store.unblock()
75- d.stopSnapshotEncodeWorker()
76- })
77- if blockEncode {
78- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
79- store.enter()
80- <-store.release
81- return snapcodec.Marshal(s)
82- }
83- }
84-
85- sess := newSnapshotTestSession(t, "live", false, "/live")
86- d.sessions[sess.id] = sess
87- sess.tabs[0].size = domain.Size{Cols: 80, Rows: 24}
88- p := sess.tabs[0].panes["pane-1"]
89- tr, sends, _ := newConn(t, ports.Frame{})
90- ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 8, Rows: 3}}
91- ac.setSession(sess)
92- sess.mu.Lock()
93- sess.client = ac
94- sess.mu.Unlock()
95- rendered := make(chan struct{}, 1)
96- rc := newRenderCoordinator(renderCoordinatorOptions{
97- clock: stubClock{},
98- onInvalidate: func(renderInvalidation) {
99- d.paint(sess, ac, true, nil)
100- rendered <- struct{}{}
101- },
102- })
103- sess.installRenderCoordinator(rc)
104- rc.attach(ac)
105- require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac)))
106- t.Cleanup(func() { rc.beginSessionTeardown().finish(); rc.waitForTimerWorkers() })
107-
108- markSnapshotDirty(sess)
109- require.True(t, d.scheduleSnapshot(sess))
110- <-store.entered
111-
112- paneLocked := make(chan struct{})
113- releasePane := make(chan struct{})
114- go func() {
115- p.mu.Lock()
116- close(paneLocked)
117- <-releasePane
118- p.mu.Unlock()
119- }()
120- <-paneLocked
121- close(releasePane)
122-
123- reader := portsmocks.NewMockPTY(t)
124- var reads atomic.Int32
125- reader.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
126- if reads.Add(1) == 1 {
127- return copy(buf, []byte("ok")), nil
128- }
129- return 0, io.EOF
130- }).Twice()
131- reader.EXPECT().Write(mock.Anything).Return(0, nil).Maybe()
132- p.mu.Lock()
133- p.pty = reader
134- p.onExit = func() {}
135- p.mu.Unlock()
136- d.sessWg.Add(1)
137- go d.ptyReader(sess, sess.tabs[0], p)
138- d.sessWg.Wait()
139- <-rendered
140- _ = awaitFrame(t, sends, ports.MsgOutput)
141- p.mu.Lock()
142- require.Contains(t, screenLineText(p.screen, 0), "ok")
143- p.mu.Unlock()
144-
145- store.unblock()
146- awaitSnapshotIdle(t, sess)
147- require.True(t, sess.snapDirty.Load(), "PTY output published while blocked must remain retryable")
148-}
149-
150-func TestFinalSnapshotFlushPersistsNewestNamedSessionBeforeWorkerTeardown(t *testing.T) {
151- clock := newFinalFlushClock()
152- store := &channelSnapshotStore{writes: make(chan []byte, 1)}
153- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
154- WithSnapshotStore(store)(d)
155- d.startSnapshotEncodeWorker()
156-
157- entered := make(chan struct{})
158- release := make(chan struct{})
159- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
160- close(entered)
161- <-release
162- return snapcodec.Marshal(s)
163- }
164- sess := newSnapshotTestSession(t, "final", false, "/work")
165- pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
166- require.True(t, ok)
167- pty.EXPECT().Close().Return(nil).Once()
168- sess.tabs[0].panes["pane-1"].screen.Write([]byte("\rnewest"))
169- d.sessions[sess.id] = sess
170-
171- require.NoError(t, d.killSession(sess, 0, false))
172- <-entered
173- stopped := make(chan struct{})
174- go func() {
175- d.stopSnapshotEncodeWorker()
176- close(stopped)
177- }()
178- close(release)
179- <-stopped
180- awaitSnapshotIdle(t, sess)
181-
182- select {
183- case data := <-store.writes:
184- snap, err := snapcodec.Unmarshal(data)
185- require.NoError(t, err)
186- frame, err := vt.UnmarshalVisible(snap.Tabs[0].Panes[0].Visible)
187- require.NoError(t, err)
188- require.Contains(t, rowText(frame.Row(0)), "newest")
189- default:
190- t.Fatal("final snapshot was dropped during worker teardown")
191- }
192-}
193-
194-func TestFinalSnapshotSupersedesPendingCaptureBeforeWorkerTeardown(t *testing.T) {
195- clock := newFinalFlushClock()
196- store := &channelSnapshotStore{writes: make(chan []byte, 2)}
197- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
198- WithSnapshotStore(store)(d)
199- d.startSnapshotEncodeWorker()
200-
201- entered := make(chan struct{})
202- release := make(chan struct{})
203- var calls atomic.Int32
204- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
205- if calls.Add(1) == 1 {
206- close(entered)
207- <-release
208- }
209- return snapcodec.Marshal(s)
210- }
211- sess := newSnapshotTestSession(t, "pending-final", false, "/work")
212- markSnapshotDirty(sess)
213- require.True(t, d.scheduleSnapshot(sess))
214- <-entered
215-
216- pane := sess.tabs[0].panes["pane-1"]
217- pane.mu.Lock()
218- pane.screen.Write([]byte("\rterminal"))
219- pane.mu.Unlock()
220- markSnapshotDirty(sess)
221- require.True(t, d.scheduleFinalSnapshot(sess))
222-
223- stopped := make(chan struct{})
224- go func() {
225- d.stopSnapshotEncodeWorker()
226- close(stopped)
227- }()
228- close(release)
229- <-stopped
230-
231- require.Len(t, store.writes, 2, "teardown must flush the newer terminal capture")
232- var latest snapcodec.Session
233- for range 2 {
234- snap, err := snapcodec.Unmarshal(<-store.writes)
235- require.NoError(t, err)
236- latest = snap
237- }
238- frame, err := vt.UnmarshalVisible(latest.Tabs[0].Panes[0].Visible)
239- require.NoError(t, err)
240- require.Contains(t, rowText(frame.Row(0)), "terminal")
241-}
242-
243-func TestFinalSnapshotFallbackCoalescesRepeatedTerminalCaptures(t *testing.T) {
244- store := discardSnapshotStore{}
245- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
246- WithSnapshotStore(store)(d)
247- d.startSnapshotEncodeWorker()
248-
249- entered := make(chan struct{})
250- release := make(chan struct{})
251- var releaseOnce sync.Once
252- releaseWorker := func() { releaseOnce.Do(func() { close(release) }) }
253- defer func() {
254- releaseWorker()
255- d.stopSnapshotEncodeWorker()
256- }()
257- var calls atomic.Int32
258- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
259- if calls.Add(1) == 1 {
260- close(entered)
261- <-release
262- }
263- return snapcodec.Marshal(s)
264- }
265- sess := newSnapshotTestSession(t, "coalesced-final", false, "/work")
266- markSnapshotDirty(sess)
267- require.True(t, d.scheduleSnapshot(sess))
268- <-entered
269-
270- for generation := uint64(1); generation <= 32; generation++ {
271- capture, ok := d.captureSnapshotState(sess, generation)
272- require.True(t, ok)
273- require.True(t, d.enqueueFinalSnapshotCapture(capture))
274- }
275-
276- d.snapshotWorkerMu.Lock()
277- captures := make([]*snapshotCapture, 0, len(d.snapshotFinalJobs))
278- for _, capture := range d.snapshotFinalJobs {
279- captures = append(captures, capture)
280- }
281- d.snapshotWorkerMu.Unlock()
282- require.Len(t, captures, 1, "blocked workers retain only the latest terminal capture per session")
283- require.Equal(t, uint64(32), captures[0].generation)
284- releaseWorker()
285-}
286-
287-func TestFinalSnapshotFallbackBoundsDistinctTerminalSessions(t *testing.T) {
288- store := discardSnapshotStore{}
289- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
290- WithSnapshotStore(store)(d)
291- d.startSnapshotEncodeWorker()
292-
293- entered := make(chan struct{})
294- release := make(chan struct{})
295- var calls atomic.Int32
296- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
297- if calls.Add(1) == 1 {
298- close(entered)
299- <-release
300- }
301- return snapcodec.Marshal(s)
302- }
303- defer func() {
304- close(release)
305- d.stopSnapshotEncodeWorker()
306- }()
307-
308- active := newSnapshotTestSession(t, "active", false, "/work")
309- queued := newSnapshotTestSession(t, "queued", false, "/work")
310- markSnapshotDirty(active)
311- markSnapshotDirty(queued)
312- require.True(t, d.scheduleSnapshot(active))
313- <-entered
314- require.True(t, d.scheduleSnapshot(queued), "fill the regular bounded queue before terminal retirement")
315-
316- var superseded *session
317- for i := range snapshotFinalQueueCapacity {
318- sess := newSnapshotTestSession(t, fmt.Sprintf("terminal-%d", i), false, "/work")
319- markSnapshotDirty(sess)
320- require.True(t, d.scheduleFinalSnapshot(sess))
321- if i == 0 {
322- superseded = sess
323- }
324- }
325- markSnapshotDirty(superseded)
326- require.True(t, d.scheduleFinalSnapshot(superseded), "a newer generation for a retained session must supersede at capacity")
327-
328- rejected := newSnapshotTestSession(t, "terminal-rejected", false, "/work")
329- d.mu.Lock()
330- d.sessions[rejected.id] = rejected
331- d.mu.Unlock()
332- markSnapshotDirty(rejected)
333- err := d.killSession(rejected, ports.ReasonSessionKilled, false)
334- require.ErrorContains(t, err, "snapshot worker unavailable or saturated")
335- d.mu.Lock()
336- require.Same(t, rejected, d.sessions[rejected.id], "rejected terminal state must remain registered for retry")
337- d.mu.Unlock()
338-
339- d.snapshotWorkerMu.Lock()
340- retained := len(d.snapshotFinalJobs)
341- owners := len(d.snapshotFinalOrder)
342- supersedingCapture := d.snapshotFinalJobs[superseded]
343- d.snapshotWorkerMu.Unlock()
344- require.Equal(t, snapshotFinalQueueCapacity, retained, "distinct terminal captures must have a global bound")
345- require.Equal(t, snapshotFinalQueueCapacity, owners, "retention order must not retain extra session owners")
346- require.NotNil(t, supersedingCapture)
347- require.Equal(t, uint64(2), supersedingCapture.generation, "retained session must keep its newest terminal generation")
348- require.NotNil(t, rejected)
349- require.True(t, rejected.snapEligible.Load())
350- require.True(t, rejected.snapDirty.Load(), "a rejected terminal capture must remain retryable")
351- awaitSnapshotIdle(t, rejected)
352-}
353-
354-func TestStopSnapshotEncodeWorkerDoesNotWaitForUncancellableWrite(t *testing.T) {
355- store := newNeverReturningSnapshotStore()
356- clock := newFinalFlushClock()
357- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
358- WithSnapshotStore(store)(d)
359- d.startSnapshotEncodeWorker()
360-
361- sess := newSnapshotTestSession(t, "stuck-write", false, "/work")
362- require.True(t, d.captureSession(sess))
363- <-store.entered
364-
365- stopped := make(chan struct{})
366- go func() {
367- d.stopSnapshotEncodeWorker()
368- close(stopped)
369- }()
370- timer := <-clock.timers
371- timer.ch <- time.Time{}
372- <-stopped
373- awaitSnapshotIdle(t, sess)
374- require.True(t, sess.snapDirty.Load(), "an abandoned write must remain retryable")
375- require.NotPanics(t, func() { require.False(t, d.scheduleSnapshot(sess)) })
376-}
377-
378-func TestRejectedSnapshotCaptureLeavesSessionRetryable(t *testing.T) {
379- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
380- WithSnapshotStore(&channelSnapshotStore{writes: make(chan []byte, 1)})(d)
381- sess := newSnapshotTestSession(t, "rejected", false, "/work")
382- sess.name = ""
383- sess.snapEligible.Store(true)
384-
385- markSnapshotDirty(sess)
386- require.False(t, d.scheduleSnapshot(sess))
387- awaitSnapshotIdle(t, sess)
388- require.True(t, sess.snapDirty.Load())
389-}
390-
391-func TestStoppedSnapshotWorkerLeavesCaptureRetryable(t *testing.T) {
392- store := &channelSnapshotStore{writes: make(chan []byte, 1)}
393- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
394- WithSnapshotStore(store)(d)
395- d.startSnapshotEncodeWorker()
396- d.stopSnapshotEncodeWorker()
397-
398- sess := newSnapshotTestSession(t, "stopped", false, "/work")
399- markSnapshotDirty(sess)
400- require.False(t, d.scheduleSnapshot(sess), "a stopped worker must not accept a capture")
401- awaitSnapshotIdle(t, sess)
402- require.True(t, sess.snapDirty.Load(), "an unsubmitted capture must remain retryable")
403-}
404-
405-func TestSnapshotQueueSaturationLeavesCaptureRetryable(t *testing.T) {
406- store := &channelSnapshotStore{writes: make(chan []byte, 3)}
407- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
408- WithSnapshotStore(store)(d)
409- startSnapshotEncodeWorker(t, d)
410- entered := make(chan struct{})
411- release := make(chan struct{})
412- var calls atomic.Int32
413- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
414- if calls.Add(1) == 1 {
415- close(entered)
416- <-release
417- }
418- return snapcodec.Marshal(s)
419- }
420- first := newSnapshotTestSession(t, "first", false, "/work")
421- second := newSnapshotTestSession(t, "second", false, "/work")
422- third := newSnapshotTestSession(t, "third", false, "/work")
423- markSnapshotDirty(first)
424- markSnapshotDirty(second)
425- markSnapshotDirty(third)
426- require.True(t, d.scheduleSnapshot(first))
427- <-entered
428- require.True(t, d.scheduleSnapshot(second), "one capture may wait in the bounded queue")
429- require.False(t, d.scheduleSnapshot(third), "full queue must not block the producer")
430- require.True(t, third.snapDirty.Load())
431-
432- close(release)
433- awaitSnapshotClean(t, first)
434- awaitSnapshotClean(t, second)
435- require.True(t, d.scheduleSnapshot(third))
436- <-store.writes
437- awaitSnapshotClean(t, third)
438-}
439-
440-func TestNamedFinalSnapshotSurvivesSaturatedQueue(t *testing.T) {
441- for _, tc := range []struct {
442- name string
443- stop func(t *testing.T, d *Daemon, sess *session)
444- }{
445- {
446- name: "kill session",
447- stop: func(t *testing.T, d *Daemon, sess *session) {
448- t.Helper()
449- require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
450- },
451- },
452- {
453- name: "shutdown all",
454- stop: func(t *testing.T, d *Daemon, _ *session) {
455- t.Helper()
456- d.shutdownAll(ports.ReasonServerShutdown)
457- },
458- },
459- } {
460- t.Run(tc.name, func(t *testing.T) {
461- store := &channelSnapshotStore{writes: make(chan []byte, 3)}
462- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
463- WithSnapshotStore(store)(d)
464- startSnapshotEncodeWorker(t, d)
465-
466- entered := make(chan struct{})
467- release := make(chan struct{})
468- var calls atomic.Int32
469- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
470- if calls.Add(1) == 1 {
471- close(entered)
472- <-release
473- }
474- return snapcodec.Marshal(s)
475- }
476- first := newSnapshotTestSession(t, "first", false, "/work")
477- second := newSnapshotTestSession(t, "second", false, "/work")
478- final := newSnapshotTestSession(t, "final", false, "/work")
479- finalPTY, ok := final.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
480- require.True(t, ok)
481- finalPTY.EXPECT().Close().Return(nil).Once()
482- markSnapshotDirty(first)
483- markSnapshotDirty(second)
484- require.True(t, d.scheduleSnapshot(first))
485- <-entered
486- require.True(t, d.scheduleSnapshot(second))
487- d.sessions[final.id] = final
488-
489- tc.stop(t, d, final)
490- require.NotContains(t, d.sessions, final.id, "final capture must survive session removal")
491- close(release)
492-
493- written := make(map[string]bool, 3)
494- deadline := time.NewTimer(time.Second)
495- defer deadline.Stop()
496- for len(written) < 3 {
497- select {
498- case data := <-store.writes:
499- snap, err := snapcodec.Unmarshal(data)
500- require.NoError(t, err)
501- written[snap.Name] = true
502- case <-deadline.C:
503- t.Fatalf("final snapshot was dropped: wrote %v", written)
504- }
505- }
506- require.True(t, written[final.name])
507- })
508- }
509-}
510-
511-func TestSnapshotCompletionSignalsWaiters(t *testing.T) {
512- store := &channelSnapshotStore{writes: make(chan []byte, 1)}
513- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
514- WithSnapshotStore(store)(d)
515- startSnapshotEncodeWorker(t, d)
516-
517- entered := make(chan struct{})
518- release := make(chan struct{})
519- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
520- close(entered)
521- <-release
522- return snapcodec.Marshal(s)
523- }
524- sess := newSnapshotTestSession(t, "completion", false, "/work")
525- require.True(t, d.captureSession(sess))
526- <-entered
527-
528- sess.snapshotMu.Lock()
529- changed := sess.snapshotChangeLocked()
530- sess.snapshotMu.Unlock()
531- close(release)
532- <-changed
533- awaitSnapshotClean(t, sess)
534-}
535-
536-func TestSnapshotSuccessDoesNotClearNewerGeneration(t *testing.T) {
537- store := &channelSnapshotStore{writes: make(chan []byte, 2)}
538- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
539- WithSnapshotStore(store)(d)
22+ repository := portsmocks.NewMockSnapshotRepository(t)
23+ WithSnapshotRepository(repository, nil)(d)
54024 startSnapshotEncodeWorker(t, d)
541- entered := make(chan struct{})
542- release := make(chan struct{})
543- var calls atomic.Int32
544- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
545- if calls.Add(1) == 1 {
546- close(entered)
547- <-release
548- }
549- return snapcodec.Marshal(s)
550- }
551- sess := newSnapshotTestSession(t, "generation", false, "/work")
55225
553- markSnapshotDirty(sess)
554- require.True(t, d.scheduleSnapshot(sess))
555- <-entered
556- markSnapshotDirty(sess)
557- close(release)
558- <-store.writes
559- awaitSnapshotIdle(t, sess)
560- require.True(t, sess.snapDirty.Load(), "a stale successful capture must not clear newer state")
561-
562- require.True(t, d.scheduleSnapshot(sess))
563- <-store.writes
564- awaitSnapshotClean(t, sess)
565-}
566-
567-func TestSnapshotEncodeFailureLeavesSessionDirtyAndRetries(t *testing.T) {
568- store := &channelSnapshotStore{writes: make(chan []byte, 1)}
569- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
570- WithSnapshotStore(store)(d)
571- startSnapshotEncodeWorker(t, d)
572- sess := newSnapshotTestSession(t, "retry-encode", false, "/work")
573- failed := make(chan struct{}, 1)
574- var calls atomic.Int32
575- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
576- if calls.Add(1) == 1 {
577- failed <- struct{}{}
578- return nil, errors.New("encode failed")
579- }
580- return snapcodec.Marshal(s)
581- }
582-
583- require.True(t, d.captureSession(sess))
584- <-failed
585- awaitSnapshotIdle(t, sess)
586- require.True(t, sess.snapDirty.Load())
587- require.True(t, d.scheduleSnapshot(sess))
588- <-store.writes
589- awaitSnapshotClean(t, sess)
590-}
591-
592-func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) {
593- clock := newManualSnapshotClock()
594- store := portsmocks.NewMockSnapshotStore(t)
595- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
596- WithSnapshotStore(store)(d)
597- d.procCwd = func(int) (string, error) { return "/pane", nil }
598- startSnapshotEncodeWorker(t, d)
599-
600- dirty := newSnapshotTestSession(t, "dirty", false, "/fallback")
601- clean := newSnapshotTestSession(t, "clean", false, "/fallback")
602- ephemeral := newSnapshotTestSession(t, "0", true, "/fallback")
603- dirty.snapDirty.Store(true)
604- ephemeral.snapDirty.Store(true)
605- d.sessions[dirty.id] = dirty
606- d.sessions[clean.id] = clean
607- d.sessions[ephemeral.id] = ephemeral
608-
609- wrote := make(chan struct{}, 1)
610- store.EXPECT().Write("dirty", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
611- snap, err := snapcodec.Unmarshal(data)
612- require.NoError(t, err)
613- require.Equal(t, "dirty", snap.Name)
614- wrote <- struct{}{}
615- return nil
616- }).Once()
617-
618- ctx, cancel := context.WithCancel(t.Context())
619- defer cancel()
620- go d.snapshotSaver(ctx)
621- clock.fireNext(t)
622- select {
623- case <-wrote:
624- case <-time.After(time.Second):
625- t.Fatal("timed out waiting for dirty snapshot write")
626- }
627- awaitSnapshotClean(t, dirty)
628-
629- clock.fireNext(t)
630- select {
631- case <-wrote:
632- t.Fatal("clean second interval wrote unexpectedly")
633- case <-time.After(25 * time.Millisecond):
634- }
635-}
636-
637-func TestSnapshotCaptureExcludesFloatingRuntime(t *testing.T) {
638- store := portsmocks.NewMockSnapshotStore(t)
639- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
640- WithSnapshotStore(store)(d)
641- startSnapshotEncodeWorker(t, d)
642- sess := newSnapshotTestSession(t, "work", false, "/fallback")
643- tb := sess.tabs[0]
644- floatingPTY, _ := newBlockingPTY(t)
645- floating := newPane(layout.PaneID("floating"), floatingPTY, domain.Size{Cols: 20, Rows: 8})
646- tb.mu.Lock()
647- generation := tb.beginFloatingWarmLocked(false)
648- require.True(t, tb.installFloatingLocked(floating, generation))
649- tb.mu.Unlock()
650-
651- store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
652- snap, err := snapcodec.Unmarshal(data)
653- require.NoError(t, err)
654- require.Len(t, snap.Tabs, 1)
655- require.Len(t, snap.Tabs[0].Panes, 1, "floating runtime must not be persisted")
656- require.Equal(t, layout.PaneID("pane-1"), snap.Tabs[0].Panes[0].ID)
657- return nil
658- }).Once()
659- require.True(t, d.captureSession(sess))
660- awaitSnapshotClean(t, sess)
661-}
662-
663-func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) {
664- store := portsmocks.NewMockSnapshotStore(t)
665- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
666- WithSnapshotStore(store)(d)
667- startSnapshotEncodeWorker(t, d)
668- d.procGroupArgv = func(pgid int, shellPid int) ([]string, error) {
669- require.Equal(t, 4321, pgid)
670- require.Equal(t, 1234, shellPid)
671- return []string{"claude", "--session-id", "agent-123"}, nil
672- }
673-
674- sess := newSnapshotTestSession(t, "work", false, "/fallback")
675- pty := snapshotProcessPTY(t, 4321)
676- sess.tabs[0].panes["pane-1"].pty = pty
677-
678- store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
679- snap, err := snapcodec.Unmarshal(data)
680- require.NoError(t, err)
681- proc := snap.Tabs[0].Panes[0].Process
682- require.NotNil(t, proc)
683- require.Equal(t, []string{"claude", "--session-id", "agent-123"}, proc.Argv)
684- require.Equal(t, processStrategyClaude, proc.Strategy)
685- require.Equal(t, "agent-123", proc.Opts.AgentSessionID)
686- return nil
687- }).Once()
688-
689- require.True(t, d.captureSession(sess))
690- awaitSnapshotClean(t, sess)
691-}
692-
693-func TestSnapshotCaptureProcessFailureDoesNotBlockSnapshot(t *testing.T) {
694- store := portsmocks.NewMockSnapshotStore(t)
695- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
696- WithSnapshotStore(store)(d)
697- startSnapshotEncodeWorker(t, d)
698- d.procGroupArgv = func(int, int) ([]string, error) { return nil, errors.New("inspect failed") }
699-
700- sess := newSnapshotTestSession(t, "work", false, "/fallback")
701- pty := snapshotProcessPTY(t, 4321)
702- sess.tabs[0].panes["pane-1"].pty = pty
703-
704- store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
705- snap, err := snapcodec.Unmarshal(data)
706- require.NoError(t, err)
707- require.Nil(t, snap.Tabs[0].Panes[0].Process)
708- return nil
709- }).Once()
710-
711- require.True(t, d.captureSession(sess))
712- awaitSnapshotClean(t, sess)
713-}
714-
715-func TestSnapshotCaptureSkipsBareShellProcess(t *testing.T) {
716- store := portsmocks.NewMockSnapshotStore(t)
717- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
718- WithSnapshotStore(store)(d)
719- startSnapshotEncodeWorker(t, d)
720- d.procGroupArgv = func(int, int) ([]string, error) {
721- t.Fatal("bare shell should not inspect argv")
722- return nil, nil
723- }
724-
725- sess := newSnapshotTestSession(t, "work", false, "/fallback")
726- pty := snapshotProcessPTY(t, 1234)
727- sess.tabs[0].panes["pane-1"].pty = pty
728-
729- store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
730- snap, err := snapcodec.Unmarshal(data)
731- require.NoError(t, err)
732- require.Nil(t, snap.Tabs[0].Panes[0].Process)
733- return nil
734- }).Once()
26+ repository.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(p ports.SnapshotPublication) bool {
27+ return p.Name == "work" && p.Generation == 1 && len(p.Manifest) > 0 && len(p.Objects) > 0
28+ })).Return(nil).Once()
73529
30+ sess := newSnapshotTestSession(t, "work", false, "/work")
73631 require.True(t, d.captureSession(sess))
73732 awaitSnapshotClean(t, sess)
73833 }
73934
740-func TestKillSessionSnapshotsNamedSessionBeforeClosingPanes(t *testing.T) {
741- store := newGatedSnapshotStore()
742- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
743- WithSnapshotStore(store)(d)
744- d.startSnapshotEncodeWorker()
745- t.Cleanup(func() {
746- store.unblock()
747- d.stopSnapshotEncodeWorker()
748- })
749- d.procCwd = func(int) (string, error) { return "/live", nil }
750-
751- sess := newSnapshotTestSession(t, "work", false, "/fallback")
752- d.sessions[sess.id] = sess
753-
754- var closed atomic.Bool
755- mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
756- require.True(t, ok)
757- mockPTY.EXPECT().Close().RunAndReturn(func() error {
758- closed.Store(true)
759- return nil
760- }).Once()
761-
762- require.NoError(t, d.killSession(sess, 0, false))
763- // closeAllPanes runs synchronously inside killSession, which has already
764- // returned here. The store's write gate is still held below, so
765- // persistence cannot have completed yet: this proves pane close did not
766- // wait on it, with zero timing dependence on the encode worker.
767- require.True(t, closed.Load(), "snapshot persistence must not delay pane close")
768-
769- store.unblock()
770- awaitSnapshotClean(t, sess)
771-
772- writes, name, data := store.recorded()
773- require.Equal(t, 1, writes)
774- require.Equal(t, "work", name)
775- snap, err := snapcodec.Unmarshal(data)
776- require.NoError(t, err)
777- require.Equal(t, "work", snap.Name)
778- require.Equal(t, "/live", snap.Tabs[0].Panes[0].Cwd)
779-}
780-
781-func TestPTYReaderSynchronizedUpdateFlushMarksSnapshotDirty(t *testing.T) {
782- d := newTestDaemon(t, nil, stubClock{})
783- p := portsmocks.NewMockPTY(t)
784- chunks := [][]byte{[]byte("\x1b[?2026hhello\x1b[?2026l")}
785- p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
786- if len(chunks) == 0 {
787- return 0, io.EOF
788- }
789- n := copy(buf, chunks[0])
790- chunks = chunks[1:]
791- return n, nil
792- })
793- p.EXPECT().Close().Return(nil).Maybe()
794-
795- sctx, cancel := context.WithCancel(context.Background())
796- defer cancel()
797- tb := newTestTabWithContext(p, sctx, cancel)
798- sess := &session{id: "sync", name: "sync", tabs: []*tab{tb}, ctx: sctx, cancel: cancel}
799- sess.snapEligible.Store(true)
800- d.sessions[sess.id] = sess
801- d.sessWg.Add(1)
802- d.ptyReader(sess, tb, tb.focusedPane())
803-
804- require.True(t, sess.snapDirty.Load())
805-}
806-
807-func TestPTYReaderSameReadSynchronizedOutputUsesUrgentCoordinatorDeadline(t *testing.T) {
808- d := newTestDaemon(t, nil, stubClock{})
809- p := chunkReadPTY(t, []byte("\x1b[?2026hcomplete batch\x1b[?2026l"))
810- sctx, cancel := context.WithCancel(context.Background())
811- defer cancel()
812- tb := newTestTabWithContext(p, sctx, cancel)
813- sess := &session{id: "sync-urgent", name: "sync-urgent", tabs: []*tab{tb}, client: &attachedClient{}, ctx: sctx, cancel: cancel}
814- invs := make(chan renderInvalidation, 1)
815- clock := newCoordinatorMockClock(t, 2)
816- sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{
817- clock: clock.clock,
818- onInvalidate: func(inv renderInvalidation) { invs <- inv },
819- }))
820- d.sessions[sess.id] = sess
821- d.sessWg.Add(1)
822- d.ptyReader(sess, tb, tb.focusedPane())
823-
824- inv := awaitInvalidation(t, invs)
825- require.Equal(t, invalidateUrgent, inv.class, "a complete same-read synchronized batch must flush urgently")
826- timer := awaitCoordinatorScheduledTimer(t, clock)
827- require.Equal(t, urgentRenderDeadline, timer.duration)
828-}
829-
830-func TestSnapshotSaverKeepsDirtyWhenWriteFails(t *testing.T) {
831- clock := newManualSnapshotClock()
832- store := portsmocks.NewMockSnapshotStore(t)
833- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
834- WithSnapshotStore(store)(d)
835- startSnapshotEncodeWorker(t, d)
836-
837- sess := newSnapshotTestSession(t, "retry", false, "/fallback")
838- sess.snapDirty.Store(true)
839- d.sessions[sess.id] = sess
840-
841- failed := make(chan struct{}, 1)
842- store.EXPECT().Write("retry", mock.Anything).RunAndReturn(func(string, []byte) error {
843- failed <- struct{}{}
844- return errors.New("boom")
845- }).Once()
846- wrote := make(chan struct{}, 1)
847- store.EXPECT().Write("retry", mock.Anything).RunAndReturn(func(string, []byte) error {
848- wrote <- struct{}{}
849- return nil
850- }).Once()
851-
852- ctx, cancel := context.WithCancel(t.Context())
853- defer cancel()
854- go d.snapshotSaver(ctx)
855- clock.fireNext(t)
856- <-failed
857- awaitSnapshotIdle(t, sess)
858- require.True(t, sess.snapDirty.Load())
859- clock.fireNext(t)
860- select {
861- case <-wrote:
862- case <-time.After(time.Second):
863- t.Fatal("timed out waiting for retry snapshot write")
864- }
865- awaitSnapshotClean(t, sess)
866- require.False(t, sess.snapDirty.Load())
867-}
868-
869-func TestKillSessionPurgeDeletesSnapshot(t *testing.T) {
870- store := portsmocks.NewMockSnapshotStore(t)
871- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
872- WithSnapshotStore(store)(d)
873-
874- sess := newSnapshotTestSession(t, "work", false, "/fallback")
875- d.sessions[sess.id] = sess
876-
877- store.EXPECT().Delete("work").Return(nil).Once()
878- mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
879- require.True(t, ok)
880- mockPTY.EXPECT().Close().Return(nil).Once()
881-
882- require.NoError(t, d.killSession(sess, 0, true))
883-}
884-
885-func TestKillSessionPurgeReturnsSnapshotDeleteError(t *testing.T) {
886- store := portsmocks.NewMockSnapshotStore(t)
887- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
888- WithSnapshotStore(store)(d)
889-
890- sess := newSnapshotTestSession(t, "work", false, "/fallback")
891- d.sessions[sess.id] = sess
892-
893- store.EXPECT().Delete("work").Return(errors.New("delete failed")).Once()
894- mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
895- require.True(t, ok)
896- mockPTY.EXPECT().Close().Return(nil).Once()
897-
898- err := d.killSession(sess, 0, true)
899- require.ErrorContains(t, err, "delete failed")
900- require.NotContains(t, d.sessions, sess.id)
901- require.NotContains(t, d.stopped, "work")
902-}
903-
904-func TestRefreshSessionCwdMarksSnapshotDirty(t *testing.T) {
905- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
906- d.procCwd = func(int) (string, error) { return "/new", nil }
907- sess := newSnapshotTestSession(t, "work", false, "/old")
908- d.sessions[sess.id] = sess
909-
910- d.refreshSessionCwd(sess)
911- require.Equal(t, "/new", sess.cwd)
912- require.True(t, sess.snapDirty.Load())
913-}
914-
915-func TestStoppedSessionKillPurgesSnapshot(t *testing.T) {
916- store := portsmocks.NewMockSnapshotStore(t)
917- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
918- WithSnapshotStore(store)(d)
919- d.stopped["work"] = stoppedSession{name: "work", cwd: "/work", createdAt: 1}
920-
921- store.EXPECT().Delete("work").Return(nil).Once()
922- tr, _, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})})
923-
924- d.handleKill(tr, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})})
925-
926- require.NotContains(t, d.stopped, "work")
927-}
928-
929-func TestStoppedSessionKillSnapshotDeleteFailureKeepsMetadata(t *testing.T) {
930- store := portsmocks.NewMockSnapshotStore(t)
931- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
932- WithSnapshotStore(store)(d)
933- d.stopped["work"] = stoppedSession{name: "work", cwd: "/work", createdAt: 1}
934-
935- store.EXPECT().Delete("work").Return(errors.New("delete failed")).Once()
936- tr, sends, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})})
937-
938- d.handleKill(tr, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})})
939-
940- require.Contains(t, d.stopped, "work")
941- frame := awaitFrame(t, sends, ports.MsgError)
942- msg, err := ports.UnmarshalErrorMsg(frame.Payload)
943- require.NoError(t, err)
944- require.Equal(t, ports.ErrInternal, msg.Code)
945-}
946-
947-func TestRenameSessionDeletesOldSnapshotAndMarksDirty(t *testing.T) {
948- store := portsmocks.NewMockSnapshotStore(t)
949- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
950- WithSnapshotStore(store)(d)
951- sess := newSnapshotTestSession(t, "old", false, "/fallback")
952- d.sessions[sess.id] = sess
953-
954- store.EXPECT().Delete("old").Return(nil).Once()
955- require.NoError(t, d.renameSession(sess, "new"))
956-
957- require.True(t, sess.snapDirty.Load())
958- require.Nil(t, d.findByNameLocked("old"))
959-}
960-
961-func TestSnapshotSaverCapturesLayoutOnlyDirtySave(t *testing.T) {
962- clock := newManualSnapshotClock()
963- store := portsmocks.NewMockSnapshotStore(t)
964- factory := portsmocks.NewMockPTYFactory(t)
965- d := newTestDaemon(t, factory, clock)
966- WithSnapshotStore(store)(d)
967- startSnapshotEncodeWorker(t, d)
968-
969- sess := newSnapshotTestSession(t, "work", false, "/work")
970- tctx, tcancel := context.WithCancel(context.Background())
971- t.Cleanup(tcancel)
972- sess.ctx = tctx
973- sess.cancel = tcancel
974- sess.tabs[0].ctx = tctx
975- sess.tabs[0].cancel = tcancel
976- for _, p := range sess.tabs[0].panes {
977- p.ctx = tctx
978- p.cancel = tcancel
979- }
980- sess.tabs[0].size = domain.Size{Cols: 41, Rows: 10}
981- d.sessions[sess.id] = sess
982- newPTY := portsmocks.NewMockPTY(t)
983- newPTY.EXPECT().Pid().Return(2345).Maybe()
984- newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
985- newPTY.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe()
986- newPTY.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
987- newPTY.EXPECT().ForegroundPgid().Return(0, nil).Maybe()
988- factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()
989-
990- require.NoError(t, d.splitPane(sess, nil, layout.Right))
991- require.True(t, sess.snapDirty.Load())
992-
993- wrote := make(chan struct{}, 1)
994- store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
995- snap, err := snapcodec.Unmarshal(data)
996- require.NoError(t, err)
997- require.Len(t, snap.Tabs, 1)
998- require.Len(t, snap.Tabs[0].Panes, 2)
999- wrote <- struct{}{}
1000- return nil
1001- }).Once()
1002-
1003- ctx, cancel := context.WithCancel(t.Context())
1004- defer cancel()
1005- go d.snapshotSaver(ctx)
1006- clock.fireNext(t)
1007- select {
1008- case <-wrote:
1009- case <-time.After(time.Second):
1010- t.Fatal("timed out waiting for layout-only dirty snapshot write")
1011- }
1012-}
1013-
1014-func snapshotProcessPTY(t *testing.T, pgid int) *portsmocks.MockPTY {
1015- t.Helper()
1016- pty := portsmocks.NewMockPTY(t)
1017- pty.EXPECT().Pid().Return(1234).Maybe()
1018- pty.EXPECT().Read(mock.Anything).Return(0, errors.New("unused")).Maybe()
1019- pty.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe()
1020- pty.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
1021- pty.EXPECT().ForegroundPgid().Return(pgid, nil).Once()
1022- return pty
1023-}
1024-
102535 func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd string) *session {
102636 t.Helper()
102737 pty := portsmocks.NewMockPTY(t)
@@ -1034,18 +44,8 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin
103444 tb := newTab(pty, domain.Size{Cols: 8, Rows: 3})
103545 p := tb.panes["pane-1"]
103646 p.screen.Write([]byte("hello"))
1037- p.history.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})
1038- sess := &session{
1039- id: domain.SessionID("sess-" + name),
1040- name: name,
1041- ephemeral: ephemeral,
1042- ctx: context.Background(),
1043- cancel: func() {},
1044- tabs: []*tab{tb},
1045- active: 0,
1046- cwd: cwd,
1047- createdAt: 42,
1048- }
47+ require.NoError(t, p.history.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}}))
48+ sess := &session{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, active: 0, cwd: cwd, createdAt: 42}
104949 sess.snapEligible.Store(!ephemeral && name != "")
105050 return sess
105151 }
@@ -1095,135 +95,3 @@ func startSnapshotEncodeWorker(t *testing.T, d *Daemon) {
109595 d.startSnapshotEncodeWorker()
109696 t.Cleanup(d.stopSnapshotEncodeWorker)
109797 }
1098-
1099-type discardSnapshotStore struct{}
1100-
1101-func (discardSnapshotStore) Write(string, []byte) error { return nil }
1102-func (discardSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
1103-func (discardSnapshotStore) Delete(string) error { return nil }
1104-
1105-type gatedSnapshotStore struct {
1106- entered chan struct{}
1107- release chan struct{}
1108- once sync.Once
1109- releaseOnce sync.Once
1110-
1111- mu sync.Mutex
1112- writes int
1113- name string
1114- data []byte
1115-}
1116-
1117-func newGatedSnapshotStore() *gatedSnapshotStore {
1118- return &gatedSnapshotStore{entered: make(chan struct{}), release: make(chan struct{})}
1119-}
1120-
1121-func (s *gatedSnapshotStore) enter() { s.once.Do(func() { close(s.entered) }) }
1122-func (s *gatedSnapshotStore) unblock() { s.releaseOnce.Do(func() { close(s.release) }) }
1123-
1124-func (s *gatedSnapshotStore) Write(name string, data []byte) error {
1125- s.enter()
1126- <-s.release
1127- s.mu.Lock()
1128- s.writes++
1129- s.name = name
1130- s.data = append([]byte(nil), data...)
1131- s.mu.Unlock()
1132- return nil
1133-}
1134-func (*gatedSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
1135-func (*gatedSnapshotStore) Delete(string) error { return nil }
1136-
1137-// recorded returns the write count and the most recently written name/data,
1138-// captured under the store's own lock so it is safe to call from the test
1139-// goroutine after the write has happened-before via awaitSnapshotClean.
1140-func (s *gatedSnapshotStore) recorded() (int, string, []byte) {
1141- s.mu.Lock()
1142- defer s.mu.Unlock()
1143- return s.writes, s.name, s.data
1144-}
1145-
1146-type neverReturningSnapshotStore struct {
1147- entered chan struct{}
1148- once sync.Once
1149- block chan struct{}
1150-}
1151-
1152-func newNeverReturningSnapshotStore() *neverReturningSnapshotStore {
1153- return &neverReturningSnapshotStore{entered: make(chan struct{}), block: make(chan struct{})}
1154-}
1155-
1156-func (s *neverReturningSnapshotStore) Write(string, []byte) error {
1157- s.once.Do(func() { close(s.entered) })
1158- <-s.block
1159- return nil
1160-}
1161-func (*neverReturningSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
1162-func (*neverReturningSnapshotStore) Delete(string) error { return nil }
1163-
1164-type channelSnapshotStore struct {
1165- writes chan []byte
1166-}
1167-
1168-func (s *channelSnapshotStore) Write(_ string, data []byte) error {
1169- s.writes <- append([]byte(nil), data...)
1170- return nil
1171-}
1172-func (*channelSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
1173-func (*channelSnapshotStore) Delete(string) error { return nil }
1174-
1175-type manualSnapshotClock struct {
1176- mu sync.Mutex
1177- timers []*manualSnapshotTimer
1178-}
1179-
1180-func newManualSnapshotClock() *manualSnapshotClock { return &manualSnapshotClock{} }
1181-
1182-func (c *manualSnapshotClock) Now() time.Time { return time.Unix(0, 0) }
1183-
1184-func (c *manualSnapshotClock) NewTimer(time.Duration) ports.Timer {
1185- t := &manualSnapshotTimer{ch: make(chan time.Time, 1)}
1186- c.mu.Lock()
1187- c.timers = append(c.timers, t)
1188- c.mu.Unlock()
1189- return t
1190-}
1191-
1192-func (c *manualSnapshotClock) fireNext(t *testing.T) {
1193- t.Helper()
1194- deadline := time.After(time.Second)
1195- for {
1196- c.mu.Lock()
1197- if len(c.timers) > 0 {
1198- timer := c.timers[0]
1199- c.mu.Unlock()
1200- timer.ch <- time.Unix(0, 0)
1201- return
1202- }
1203- c.mu.Unlock()
1204- select {
1205- case <-deadline:
1206- t.Fatal("timed out waiting for snapshot timer")
1207- case <-time.After(time.Millisecond):
1208- }
1209- }
1210-}
1211-
1212-type finalFlushClock struct{ timers chan *manualSnapshotTimer }
1213-
1214-func newFinalFlushClock() *finalFlushClock {
1215- return &finalFlushClock{timers: make(chan *manualSnapshotTimer, 1)}
1216-}
1217-
1218-func (c *finalFlushClock) Now() time.Time { return time.Unix(0, 0) }
1219-func (c *finalFlushClock) NewTimer(time.Duration) ports.Timer {
1220- t := &manualSnapshotTimer{ch: make(chan time.Time, 1)}
1221- c.timers <- t
1222- return t
1223-}
1224-
1225-type manualSnapshotTimer struct{ ch chan time.Time }
1226-
1227-func (t *manualSnapshotTimer) C() <-chan time.Time { return t.ch }
1228-func (t *manualSnapshotTimer) Reset(time.Duration) bool { return true }
1229-func (t *manualSnapshotTimer) Stop() bool { return true }
A internal/usecase/daemon/snapshot_worker.go +260 -0
@@ -0,0 +1,260 @@
1+package daemon
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+func (d *Daemon) reportSnapshotFailure(capture *snapshotCapture, phase string, cause error) {
10+ if d == nil || capture == nil || capture.session == nil || cause == nil {
11+ return
12+ }
13+ signature := snapshotFailureSignature(phase, cause)
14+ d.snapshotNoticeMu.Lock()
15+ changed := d.snapshotActiveFailureSignature != signature
16+ d.snapshotActiveFailureSignature = signature
17+ d.snapshotNoticeMu.Unlock()
18+
19+ n := domain.Notification{
20+ Code: domain.NoticeSnapshotWrite,
21+ Severity: domain.NoticeError,
22+ Message: "couldn't save session state; recent state may be lost on restart",
23+ // The history is intentionally diagnostic only at the error-class level.
24+ // Raw causes can include user paths and terminal content.
25+ Details: signature,
26+ Time: d.clock.Now(),
27+ }
28+ n, _ = d.notices.recordSnapshotFailure(n)
29+ d.log.Warn("writing session snapshot failed", "phase", phase, "class", signature, "session", capture.name)
30+ if changed {
31+ d.deliverGlobal(n)
32+ }
33+}
34+
35+func (d *Daemon) clearSnapshotFailure() {
36+ if d == nil {
37+ return
38+ }
39+ d.snapshotNoticeMu.Lock()
40+ d.snapshotActiveFailureSignature = ""
41+ d.snapshotNoticeMu.Unlock()
42+}
43+
44+func (d *Daemon) startSnapshotEncodeWorker() {
45+ if d == nil {
46+ return
47+ }
48+ d.snapshotWorkerMu.Lock()
49+ if d.snapshotWorkerCancel != nil {
50+ d.snapshotWorkerMu.Unlock()
51+ return
52+ }
53+ // Shutdown owns the worker lifetime so it can flush captures after Serve
54+ // cancels its parent context. The worker is always stopped explicitly.
55+ workerCtx, cancel := context.WithCancel(context.Background())
56+ d.snapshotWorkerID++
57+ workerID := d.snapshotWorkerID
58+ d.snapshotWorkerCtx = workerCtx
59+ d.snapshotWorkerCancel = cancel
60+ d.snapshotWorkerDone = make(chan struct{})
61+ d.snapshotWorkerFlush = make(chan struct{})
62+ d.snapshotWorkerFinalWake = make(chan struct{}, 1)
63+ d.snapshotFinalJobs = make(map[*session]*snapshotCapture)
64+ d.snapshotFinalOrder = nil
65+ d.snapshotWorkerClosing = false
66+ d.snapshotWorkerInFlight = nil
67+ done := d.snapshotWorkerDone
68+ flush := d.snapshotWorkerFlush
69+ finalWake := d.snapshotWorkerFinalWake
70+ d.snapshotWorkerMu.Unlock()
71+ go d.runSnapshotEncodeWorker(workerCtx, workerID, done, flush, finalWake)
72+}
73+
74+// runSnapshotEncodeWorker owns the worker state machine. Each event either
75+// continues processing, drains the terminal queues for shutdown, or stops;
76+// publication details are kept out of the select loop.
77+func (d *Daemon) runSnapshotEncodeWorker(workerCtx context.Context, workerID uint64, done chan<- struct{}, flush, finalWake <-chan struct{}) {
78+ defer close(done)
79+ for {
80+ select {
81+ case <-workerCtx.Done():
82+ return
83+ case <-flush:
84+ d.flushSnapshotCaptures(workerCtx, workerID)
85+ return
86+ case <-finalWake:
87+ if !d.drainFinalSnapshotCaptures(workerCtx, workerID) {
88+ return
89+ }
90+ case capture := <-d.snapshotJobs:
91+ if !d.publishSnapshotCapture(workerCtx, workerID, capture) {
92+ return
93+ }
94+ }
95+ }
96+}
97+
98+func (d *Daemon) flushSnapshotCaptures(workerCtx context.Context, workerID uint64) {
99+ for {
100+ select {
101+ case capture := <-d.snapshotJobs:
102+ if !d.publishSnapshotCapture(workerCtx, workerID, capture) {
103+ return
104+ }
105+ default:
106+ _ = d.drainFinalSnapshotCaptures(workerCtx, workerID)
107+ return
108+ }
109+ }
110+}
111+
112+func (d *Daemon) drainFinalSnapshotCaptures(workerCtx context.Context, workerID uint64) bool {
113+ for capture := d.takeFinalSnapshotCapture(); capture != nil; capture = d.takeFinalSnapshotCapture() {
114+ if !d.publishSnapshotCapture(workerCtx, workerID, capture) {
115+ return false
116+ }
117+ }
118+ return true
119+}
120+
121+func (d *Daemon) publishSnapshotCapture(workerCtx context.Context, workerID uint64, capture *snapshotCapture) bool {
122+ if workerCtx.Err() != nil || !startSnapshotPublication(capture) || !d.setSnapshotWorkerInFlight(workerID, capture) {
123+ d.finishSnapshotCapture(capture, false)
124+ return workerCtx.Err() == nil
125+ }
126+ publication, err := d.incrementalPublication(capture)
127+ publicationContext := capture.publicationContext
128+ if publicationContext == nil {
129+ publicationContext = workerCtx
130+ }
131+ if err == nil && workerCtx.Err() == nil {
132+ if err = publicationContext.Err(); err == nil {
133+ err = d.snapshotRepository.Publish(publicationContext, publication)
134+ }
135+ }
136+ d.clearSnapshotWorkerInFlight(workerID, capture)
137+ if err == nil && workerCtx.Err() == nil {
138+ markSnapshotCaptureObjectsPublished(capture)
139+ }
140+ if err != nil && workerCtx.Err() == nil && publicationContext.Err() == nil {
141+ // Global, not session-scoped: by now the session may already be torn
142+ // down, so a session notice would be dead-on-arrival. No lock is held
143+ // here (clearSnapshotWorkerInFlight released its own).
144+ d.reportSnapshotFailure(capture, "publish", err)
145+ }
146+ d.finishSnapshotCapture(capture, err == nil && workerCtx.Err() == nil)
147+ return workerCtx.Err() == nil
148+}
149+
150+func (d *Daemon) takeFinalSnapshotCapture() *snapshotCapture {
151+ d.snapshotWorkerMu.Lock()
152+ defer d.snapshotWorkerMu.Unlock()
153+ for len(d.snapshotFinalOrder) > 0 {
154+ sess := d.snapshotFinalOrder[0]
155+ d.snapshotFinalOrder[0] = nil
156+ d.snapshotFinalOrder = d.snapshotFinalOrder[1:]
157+ capture := d.snapshotFinalJobs[sess]
158+ delete(d.snapshotFinalJobs, sess)
159+ if capture != nil {
160+ return capture
161+ }
162+ }
163+ return nil
164+}
165+
166+func (d *Daemon) setSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) bool {
167+ d.snapshotWorkerMu.Lock()
168+ defer d.snapshotWorkerMu.Unlock()
169+ if d.snapshotWorkerID != workerID || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
170+ return false
171+ }
172+ d.snapshotWorkerInFlight = capture
173+ return true
174+}
175+
176+func (d *Daemon) clearSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) {
177+ d.snapshotWorkerMu.Lock()
178+ defer d.snapshotWorkerMu.Unlock()
179+ if d.snapshotWorkerID == workerID && d.snapshotWorkerInFlight == capture {
180+ d.snapshotWorkerInFlight = nil
181+ }
182+}
183+
184+func (d *Daemon) stopSnapshotEncodeWorker() {
185+ if d == nil {
186+ return
187+ }
188+ deadline := newSnapshotShutdownDeadline(d.clock)
189+ defer deadline.stop()
190+ d.stopSnapshotEncodeWorkerWithDeadline(deadline)
191+}
192+
193+// stopSnapshotEncodeWorkerWithDeadline performs the one deadline-aware join
194+// for a Serve shutdown. Queues are never closed, and detached writers only
195+// retain immutable captures plus contexts/channels that remain live, so an
196+// uncooperative repository call cannot race teardown or block process exit.
197+func (d *Daemon) stopSnapshotEncodeWorkerWithDeadline(deadline *snapshotShutdownDeadline) {
198+ if d == nil {
199+ return
200+ }
201+ d.snapshotWorkerMu.Lock()
202+ cancel := d.snapshotWorkerCancel
203+ if cancel == nil || d.snapshotWorkerClosing {
204+ d.snapshotWorkerMu.Unlock()
205+ return
206+ }
207+ // Stop accepting producers, then let the single worker drain its bounded
208+ // queue. A non-context-aware store may still block indefinitely, so the
209+ // shared shutdown deadline bounds the one and only join.
210+ d.snapshotWorkerClosing = true
211+ flush := d.snapshotWorkerFlush
212+ done := d.snapshotWorkerDone
213+ close(flush)
214+ d.snapshotWorkerMu.Unlock()
215+
216+ if deadline == nil {
217+ deadline = newSnapshotShutdownDeadline(d.clock)
218+ defer deadline.stop()
219+ }
220+ select {
221+ case <-done:
222+ d.finishStoppedSnapshotWorker(false)
223+ case <-deadline.Done():
224+ cancel()
225+ d.finishStoppedSnapshotWorker(true)
226+ }
227+}
228+
229+func (d *Daemon) finishStoppedSnapshotWorker(abandoned bool) {
230+ d.snapshotWorkerMu.Lock()
231+ inFlight := d.snapshotWorkerInFlight
232+ d.snapshotWorkerCtx = nil
233+ d.snapshotWorkerCancel = nil
234+ d.snapshotWorkerDone = nil
235+ d.snapshotWorkerFlush = nil
236+ d.snapshotWorkerFinalWake = nil
237+ d.snapshotWorkerClosing = false
238+ d.snapshotWorkerInFlight = nil
239+ queued := make([]*snapshotCapture, 0, len(d.snapshotJobs)+len(d.snapshotFinalJobs))
240+ for _, capture := range d.snapshotFinalJobs {
241+ queued = append(queued, capture)
242+ }
243+ d.snapshotFinalJobs = nil
244+ d.snapshotFinalOrder = nil
245+ for {
246+ select {
247+ case capture := <-d.snapshotJobs:
248+ queued = append(queued, capture)
249+ default:
250+ d.snapshotWorkerMu.Unlock()
251+ if abandoned {
252+ d.finishSnapshotCapture(inFlight, false)
253+ }
254+ for _, capture := range queued {
255+ d.finishSnapshotCapture(capture, false)
256+ }
257+ return
258+ }
259+ }
260+}
A internal/usecase/daemon/testdata/legacy-v3-empty.b64 +1 -0
@@ -0,0 +1,1 @@
1+VkVWUwADAAAAAAAc4u6s7AAObGVnYWN5LWZpeHR1cmUAAAAAAAAAAAAAAAA=
D internal/usecase/snapshot/benchmark_test.go +0 -122
@@ -1,122 +0,0 @@
1-package snapshot
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/vev/internal/usecase/layout"
7- "github.com/bnema/vev/pkg/renderer"
8- "github.com/bnema/vev/pkg/vt"
9-)
10-
11-var (
12- benchmarkV3BytesSink []byte
13- benchmarkV3SessionSink Session
14-)
15-
16-func BenchmarkSnapshotV3Marshal10KRows(b *testing.B) {
17- session, encoded := benchmarkV3Session(b)
18- b.ReportAllocs()
19- b.SetBytes(int64(len(encoded)))
20- b.ResetTimer()
21- for b.Loop() {
22- var err error
23- benchmarkV3BytesSink, err = Marshal(session)
24- if err != nil {
25- b.Fatal(err)
26- }
27- }
28- b.StopTimer()
29- if _, err := Unmarshal(benchmarkV3BytesSink); err != nil {
30- b.Fatalf("marshal produced an invalid v3 snapshot: %v", err)
31- }
32-}
33-
34-func BenchmarkSnapshotV3Unmarshal10KRows(b *testing.B) {
35- _, encoded := benchmarkV3Session(b)
36- b.ReportAllocs()
37- b.SetBytes(int64(len(encoded)))
38- b.ResetTimer()
39- for b.Loop() {
40- var err error
41- benchmarkV3SessionSink, err = Unmarshal(encoded)
42- if err != nil {
43- b.Fatal(err)
44- }
45- }
46- b.StopTimer()
47- if len(benchmarkV3SessionSink.Tabs) != 1 || len(benchmarkV3SessionSink.Tabs[0].Panes) != 1 || len(benchmarkV3SessionSink.Tabs[0].Panes[0].SealedChunks) != 40 {
48- b.Fatal("unmarshal lost canonical 10k-row snapshot data")
49- }
50-}
51-
52-func benchmarkV3Session(t testing.TB) (Session, []byte) {
53- t.Helper()
54- const (
55- cols = 120
56- rows = 40
57- historyRows = 10_000
58- chunkRows = 256
59- )
60- history := vt.NewHistory(vt.HistoryConfig{MaxRows: historyRows, ChunkRows: chunkRows})
61- for row := range historyRows {
62- cells := make([]renderer.Cell, cols)
63- for col := range cells {
64- cells[col] = renderer.Cell{Rune: rune('a' + (row+col)%26)}
65- }
66- history.Append(cells)
67- }
68- sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView())
69- if err != nil {
70- t.Fatal(err)
71- }
72- visibleFrame := renderer.NewFrame(cols, rows)
73- for y := range rows {
74- for x := range cols {
75- visibleFrame.Set(x, y, renderer.Cell{Rune: rune('A' + (x+y)%26)})
76- }
77- }
78- visible, err := vt.MarshalVisible(visibleFrame)
79- if err != nil {
80- t.Fatal(err)
81- }
82- session := Session{
83- Name: "benchmark",
84- CreatedAt: 1,
85- Tabs: []Tab{{
86- StableID: "tab-1",
87- Cols: cols,
88- Rows: rows,
89- NextPaneID: 2,
90- Focus: "pane-1",
91- Tree: layout.NewTree("pane-1"),
92- Panes: []Pane{{
93- ID: "pane-1",
94- StableID: "pane-1",
95- Cwd: "/benchmark",
96- SealedChunks: sealed,
97- Tail: tail,
98- Visible: visible,
99- }},
100- }},
101- }
102- encoded, err := Marshal(session)
103- if err != nil {
104- t.Fatal(err)
105- }
106- decoded, err := Unmarshal(encoded)
107- if err != nil {
108- t.Fatal(err)
109- }
110- paneCount := 0
111- decodedChunks := 0
112- if len(decoded.Tabs) == 1 {
113- paneCount = len(decoded.Tabs[0].Panes)
114- if paneCount == 1 {
115- decodedChunks = len(decoded.Tabs[0].Panes[0].SealedChunks)
116- }
117- }
118- if len(sealed) != (historyRows+chunkRows-1)/chunkRows || paneCount != 1 || decodedChunks != len(sealed) {
119- t.Fatalf("invalid v3 benchmark fixture: chunks=%d tabs=%d panes=%d decoded chunks=%d", len(sealed), len(decoded.Tabs), paneCount, decodedChunks)
120- }
121- return session, encoded
122-}
M internal/usecase/snapshot/codec.go +63 -165
@@ -24,7 +24,7 @@ const (
2424 magic = "VEVS"
2525 version = uint16(3)
2626 maxDecodedBodySize = 256 << 20
27- maxSnapshotBlobs = 1 << 16
27+ maxSnapshotObjects = 1 << 16
2828 maxSnapshotBytes = 256 << 20
2929 maxSnapshotRows = 1 << 20
3030 maxSnapshotCells = 16 << 20
@@ -34,24 +34,6 @@ const (
3434 maxSnapshotDecodedAllocation = 256 << 20
3535 )
3636
37-// Marshal encodes the strict v3, flags-zero durable envelope.
38-func Marshal(s Session) ([]byte, error) {
39- var w payloadWriter
40- if err := writeSession(&w, s); err != nil {
41- return nil, err
42- }
43- if len(w.b) > maxDecodedBodySize || len(w.b) > math.MaxUint32 {
44- return nil, fmt.Errorf("%w: body too large", ErrInvalidData)
45- }
46- out := make([]byte, 16+len(w.b))
47- copy(out[:4], magic)
48- binary.BigEndian.PutUint16(out[4:6], version)
49- binary.BigEndian.PutUint32(out[8:12], uint32(len(w.b)))
50- binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(w.b))
51- copy(out[16:], w.b)
52- return out, nil
53-}
54-
5537 // Unmarshal validates the complete manifest and all VT blobs without
5638 // allocations first, then decodes the already preflighted data.
5739 func Unmarshal(b []byte) (Session, error) {
@@ -146,6 +128,61 @@ func (w *payloadWriter) putStrings(ss []string) error {
146128 return nil
147129 }
148130
131+func writeNode(w *payloadWriter, n *layout.Node) error {
132+ if n == nil {
133+ w.putUint8(manifestNodeNil)
134+ return nil
135+ }
136+ switch n.Kind {
137+ case layout.Leaf:
138+ w.putUint8(manifestNodeLeaf)
139+ return w.putString(string(n.Leaf))
140+ case layout.Split:
141+ w.putUint8(manifestNodeSplit)
142+ w.putUint8(uint8(n.Dir))
143+ return writeChildren(w, n.Children)
144+ case layout.Stack:
145+ w.putUint8(manifestNodeStack)
146+ if err := w.putString(string(n.Expanded)); err != nil {
147+ return err
148+ }
149+ return writeChildren(w, n.Children)
150+ default:
151+ return fmt.Errorf("%w: unknown node kind", ErrInvalidData)
152+ }
153+}
154+
155+func writeChildren(w *payloadWriter, children []*layout.Node) error {
156+ if len(children) > math.MaxUint16 {
157+ return fmt.Errorf("%w: too many children", ErrInvalidData)
158+ }
159+ w.putUint16(uint16(len(children)))
160+ for _, c := range children {
161+ if err := writeNode(w, c); err != nil {
162+ return err
163+ }
164+ }
165+ return nil
166+}
167+
168+func writeProcess(w *payloadWriter, p *Process) error {
169+ if p == nil {
170+ w.putUint8(processAbsent)
171+ return nil
172+ }
173+ if len(p.Argv) == 0 {
174+ return fmt.Errorf("%w: process argv empty", ErrInvalidData)
175+ }
176+ w.putUint8(processPresent)
177+ if err := w.putStrings(p.Argv); err != nil {
178+ return err
179+ }
180+ if err := w.putString(p.Strategy); err != nil {
181+ return err
182+ }
183+ return w.putString(p.Opts.AgentSessionID)
184+}
185+
149186 type payloadReader struct{ b []byte }
150187
151188 func (r *payloadReader) getUint8() (uint8, error) {
@@ -226,27 +263,6 @@ func (r *payloadReader) done() error {
226263 return nil
227264 }
228265
229-func writeSession(w *payloadWriter, s Session) error {
230- if len(s.Tabs) > math.MaxUint16 {
231- return fmt.Errorf("%w: too many tabs", ErrInvalidData)
232- }
233- if err := validateActive(s.Active, len(s.Tabs)); err != nil {
234- return err
235- }
236- if err := w.putString(s.Name); err != nil {
237- return err
238- }
239- w.putUint64(s.CreatedAt)
240- w.putUint16(s.Active)
241- w.putUint16(uint16(len(s.Tabs)))
242- for _, t := range s.Tabs {
243- if err := writeTab(w, t); err != nil {
244- return err
245- }
246- }
247- return nil
248-}
249-
250266 func readSession(r *payloadReader) (Session, error) {
251267 name, err := r.getString()
252268 if err != nil {
@@ -278,35 +294,6 @@ func readSession(r *payloadReader) (Session, error) {
278294 return s, nil
279295 }
280296
281-func writeTab(w *payloadWriter, t Tab) error {
282- if err := w.putString(t.StableID); err != nil {
283- return err
284- }
285- w.putUint16(t.Cols)
286- w.putUint16(t.Rows)
287- w.putUint64(t.NextPaneID)
288- if err := w.putString(string(t.Focus)); err != nil {
289- return err
290- }
291- var root *layout.Node
292- if t.Tree != nil {
293- root = t.Tree.Root
294- }
295- if err := writeNode(w, root); err != nil {
296- return err
297- }
298- if len(t.Panes) > math.MaxUint16 {
299- return fmt.Errorf("%w: too many panes", ErrInvalidData)
300- }
301- w.putUint16(uint16(len(t.Panes)))
302- for _, p := range t.Panes {
303- if err := writePane(w, p); err != nil {
304- return err
305- }
306- }
307- return nil
308-}
309-
310297 func readTab(r *payloadReader) (Tab, error) {
311298 stableID, err := r.getString()
312299 if err != nil {
@@ -347,56 +334,19 @@ func readTab(r *payloadReader) (Tab, error) {
347334 return t, nil
348335 }
349336
350-func writeNode(w *payloadWriter, n *layout.Node) error {
351- if n == nil {
352- w.putUint8(3)
353- return nil
354- }
355- switch n.Kind {
356- case layout.Leaf:
357- w.putUint8(0)
358- return w.putString(string(n.Leaf))
359- case layout.Split:
360- w.putUint8(1)
361- w.putUint8(uint8(n.Dir))
362- return writeChildren(w, n.Children)
363- case layout.Stack:
364- w.putUint8(2)
365- if err := w.putString(string(n.Expanded)); err != nil {
366- return err
367- }
368- return writeChildren(w, n.Children)
369- default:
370- return fmt.Errorf("%w: unknown node kind", ErrInvalidData)
371- }
372-}
373-
374-func writeChildren(w *payloadWriter, children []*layout.Node) error {
375- if len(children) > math.MaxUint16 {
376- return fmt.Errorf("%w: too many children", ErrInvalidData)
377- }
378- w.putUint16(uint16(len(children)))
379- for _, c := range children {
380- if err := writeNode(w, c); err != nil {
381- return err
382- }
383- }
384- return nil
385-}
386-
387337 func readNode(r *payloadReader) (*layout.Node, error) {
388338 kind, err := r.getUint8()
389339 if err != nil {
390340 return nil, err
391341 }
392342 switch kind {
393- case 0:
343+ case manifestNodeLeaf:
394344 leaf, err := r.getString()
395345 if err != nil {
396346 return nil, err
397347 }
398348 return layout.NewLeaf(layout.PaneID(leaf)), nil
399- case 1:
349+ case manifestNodeSplit:
400350 dir, err := r.getUint8()
401351 if err != nil {
402352 return nil, err
@@ -406,7 +356,7 @@ func readNode(r *payloadReader) (*layout.Node, error) {
406356 return nil, err
407357 }
408358 return &layout.Node{Kind: layout.Split, Dir: layout.SplitDir(dir), Children: children}, nil
409- case 2:
359+ case manifestNodeStack:
410360 exp, err := r.getString()
411361 if err != nil {
412362 return nil, err
@@ -416,7 +366,7 @@ func readNode(r *payloadReader) (*layout.Node, error) {
416366 return nil, err
417367 }
418368 return &layout.Node{Kind: layout.Stack, Expanded: layout.PaneID(exp), Children: children}, nil
419- case 3:
369+ case manifestNodeNil:
420370 return nil, nil
421371 default:
422372 return nil, fmt.Errorf("%w: unknown node kind", ErrInvalidData)
@@ -439,40 +389,6 @@ func readChildren(r *payloadReader) ([]*layout.Node, error) {
439389 return out, nil
440390 }
441391
442-func writePane(w *payloadWriter, p Pane) error {
443- if err := w.putString(string(p.ID)); err != nil {
444- return err
445- }
446- if err := w.putString(p.StableID); err != nil {
447- return err
448- }
449- if err := w.putString(p.Cwd); err != nil {
450- return err
451- }
452- if len(p.SealedChunks) > maxSnapshotBlobs {
453- return fmt.Errorf("%w: too many sealed chunks", ErrInvalidData)
454- }
455- w.putUint32(uint32(len(p.SealedChunks)))
456- for _, blob := range p.SealedChunks {
457- if len(blob) == 0 || len(blob) > math.MaxUint32 {
458- return fmt.Errorf("%w: sealed chunk length", ErrInvalidData)
459- }
460- w.putUint32(uint32(len(blob)))
461- w.b = append(w.b, blob...)
462- }
463- if len(p.Tail) == 0 || len(p.Tail) > math.MaxUint32 {
464- return fmt.Errorf("%w: tail length", ErrInvalidData)
465- }
466- w.putUint32(uint32(len(p.Tail)))
467- w.b = append(w.b, p.Tail...)
468- if len(p.Visible) == 0 || len(p.Visible) > math.MaxUint32 {
469- return fmt.Errorf("%w: visible length", ErrInvalidData)
470- }
471- w.putUint32(uint32(len(p.Visible)))
472- w.b = append(w.b, p.Visible...)
473- return writeProcess(w, p.Process)
474-}
475-
476392 func readPane(r *payloadReader) (Pane, error) {
477393 id, err := r.getString()
478394 if err != nil {
@@ -490,7 +406,7 @@ func readPane(r *payloadReader) (Pane, error) {
490406 if err != nil {
491407 return Pane{}, err
492408 }
493- if n > maxSnapshotBlobs || uint64(n) > uint64(len(r.b))/4 {
409+ if n > maxSnapshotObjects || uint64(n) > uint64(len(r.b))/4 {
494410 return Pane{}, fmt.Errorf("%w: sealed chunks", ErrInvalidData)
495411 }
496412 sealed := make([][]byte, n)
@@ -514,33 +430,15 @@ func readPane(r *payloadReader) (Pane, error) {
514430 return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: proc}, nil
515431 }
516432
517-func writeProcess(w *payloadWriter, p *Process) error {
518- if p == nil {
519- w.putUint8(0)
520- return nil
521- }
522- if len(p.Argv) == 0 {
523- return fmt.Errorf("%w: process argv empty", ErrInvalidData)
524- }
525- w.putUint8(1)
526- if err := w.putStrings(p.Argv); err != nil {
527- return err
528- }
529- if err := w.putString(p.Strategy); err != nil {
530- return err
531- }
532- return w.putString(p.Opts.AgentSessionID)
533-}
534-
535433 func readProcess(r *payloadReader) (*Process, error) {
536434 present, err := r.getUint8()
537435 if err != nil {
538436 return nil, err
539437 }
540- if present == 0 {
438+ if present == processAbsent {
541439 return nil, nil
542440 }
543- if present != 1 {
441+ if present != processPresent {
544442 return nil, fmt.Errorf("%w: process presence", ErrInvalidData)
545443 }
546444 argv, err := r.getStrings()
A internal/usecase/snapshot/codec_encoder_test.go +113 -0
@@ -0,0 +1,113 @@
1+package snapshot
2+
3+import (
4+ "encoding/binary"
5+ "fmt"
6+ "hash/crc32"
7+ "math"
8+
9+ "github.com/bnema/vev/internal/usecase/layout"
10+)
11+
12+// marshalTest is a test-only v3 encoder. Production retains only the decoder
13+// required by the one-time legacy import.
14+func marshalTest(s Session) ([]byte, error) {
15+ var w payloadWriter
16+ if err := writeSession(&w, s); err != nil {
17+ return nil, err
18+ }
19+ if len(w.b) > maxDecodedBodySize || len(w.b) > math.MaxUint32 {
20+ return nil, fmt.Errorf("%w: body too large", ErrInvalidData)
21+ }
22+ out := make([]byte, 16+len(w.b))
23+ copy(out[:4], magic)
24+ binary.BigEndian.PutUint16(out[4:6], version)
25+ binary.BigEndian.PutUint32(out[8:12], uint32(len(w.b)))
26+ binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(w.b))
27+ copy(out[16:], w.b)
28+ return out, nil
29+}
30+
31+func writeSession(w *payloadWriter, s Session) error {
32+ if len(s.Tabs) > math.MaxUint16 {
33+ return fmt.Errorf("%w: too many tabs", ErrInvalidData)
34+ }
35+ if err := validateActive(s.Active, len(s.Tabs)); err != nil {
36+ return err
37+ }
38+ if err := w.putString(s.Name); err != nil {
39+ return err
40+ }
41+ w.putUint64(s.CreatedAt)
42+ w.putUint16(s.Active)
43+ w.putUint16(uint16(len(s.Tabs)))
44+ for _, t := range s.Tabs {
45+ if err := writeTab(w, t); err != nil {
46+ return err
47+ }
48+ }
49+ return nil
50+}
51+
52+func writeTab(w *payloadWriter, t Tab) error {
53+ if err := w.putString(t.StableID); err != nil {
54+ return err
55+ }
56+ w.putUint16(t.Cols)
57+ w.putUint16(t.Rows)
58+ w.putUint64(t.NextPaneID)
59+ if err := w.putString(string(t.Focus)); err != nil {
60+ return err
61+ }
62+ var root *layout.Node
63+ if t.Tree != nil {
64+ root = t.Tree.Root
65+ }
66+ if err := writeNode(w, root); err != nil {
67+ return err
68+ }
69+ if len(t.Panes) > math.MaxUint16 {
70+ return fmt.Errorf("%w: too many panes", ErrInvalidData)
71+ }
72+ w.putUint16(uint16(len(t.Panes)))
73+ for _, p := range t.Panes {
74+ if err := writePane(w, p); err != nil {
75+ return err
76+ }
77+ }
78+ return nil
79+}
80+
81+func writePane(w *payloadWriter, p Pane) error {
82+ if err := w.putString(string(p.ID)); err != nil {
83+ return err
84+ }
85+ if err := w.putString(p.StableID); err != nil {
86+ return err
87+ }
88+ if err := w.putString(p.Cwd); err != nil {
89+ return err
90+ }
91+ if len(p.SealedChunks) > maxSnapshotObjects {
92+ return fmt.Errorf("%w: too many sealed chunks", ErrInvalidData)
93+ }
94+ w.putUint32(uint32(len(p.SealedChunks)))
95+ for _, blob := range p.SealedChunks {
96+ if len(blob) == 0 || len(blob) > math.MaxUint32 {
97+ return fmt.Errorf("%w: sealed chunk length", ErrInvalidData)
98+ }
99+ w.putUint32(uint32(len(blob)))
100+ w.b = append(w.b, blob...)
101+ }
102+ if len(p.Tail) == 0 || len(p.Tail) > math.MaxUint32 {
103+ return fmt.Errorf("%w: tail length", ErrInvalidData)
104+ }
105+ w.putUint32(uint32(len(p.Tail)))
106+ w.b = append(w.b, p.Tail...)
107+ if len(p.Visible) == 0 || len(p.Visible) > math.MaxUint32 {
108+ return fmt.Errorf("%w: visible length", ErrInvalidData)
109+ }
110+ w.putUint32(uint32(len(p.Visible)))
111+ w.b = append(w.b, p.Visible...)
112+ return writeProcess(w, p.Process)
113+}
M internal/usecase/snapshot/codec_test.go +40 -19
@@ -32,7 +32,7 @@ func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) {
3232 {ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible},
3333 {ID: "p2", SealedChunks: sealed2, Tail: tail2, Visible: visible2},
3434 }}}}
35- encoded, err := Marshal(want)
35+ encoded, err := marshalTest(want)
3636 if err != nil {
3737 t.Fatal(err)
3838 }
@@ -86,13 +86,13 @@ func TestActiveTabReferenceMustBeExact(t *testing.T) {
8686 {name: "nonempty tabs active past end", s: Session{Active: 1, Tabs: []Tab{validTab}}},
8787 } {
8888 t.Run("marshal "+tc.name, func(t *testing.T) {
89- _, err := Marshal(tc.s)
89+ _, err := marshalTest(tc.s)
9090 if tc.valid {
9191 requireNoError(t, err)
9292 return
9393 }
9494 if !errors.Is(err, ErrInvalidData) {
95- t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData)
95+ t.Fatalf("marshalTest() error = %v, want %v", err, ErrInvalidData)
9696 }
9797 })
9898 }
@@ -153,7 +153,7 @@ func TestUnmarshalRejectsHostileV3ManifestDeclarationsBeforeAllocation(t *testin
153153 func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) {
154154 sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}})
155155 visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
156- encoded, err := Marshal(Session{Name: "p", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}})
156+ encoded, err := marshalTest(Session{Name: "p", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}})
157157 if err != nil {
158158 t.Fatal(err)
159159 }
@@ -174,7 +174,7 @@ func TestUnmarshalRejectsEmptyAndDuplicatePaneIDsDuringPreflight(t *testing.T) {
174174 {name: "duplicate", panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}, {ID: "p", Tail: tail, Visible: visible}}},
175175 } {
176176 t.Run(tc.name, func(t *testing.T) {
177- data, err := Marshal(Session{Name: "s", Tabs: []Tab{{Panes: tc.panes}}})
177+ data, err := marshalTest(Session{Name: "s", Tabs: []Tab{{Panes: tc.panes}}})
178178 requireNoError(t, err)
179179 reject(t, data, ErrInvalidData)
180180 })
@@ -184,7 +184,7 @@ func TestUnmarshalRejectsEmptyAndDuplicatePaneIDsDuringPreflight(t *testing.T) {
184184 func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) {
185185 sealed, tail := historyBlobs(t, nil)
186186 visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
187- encoded, err := Marshal(Session{Name: "p", Tabs: []Tab{{Focus: "missing", Tree: layout.NewTree("missing"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}})
187+ encoded, err := marshalTest(Session{Name: "p", Tabs: []Tab{{Focus: "missing", Tree: layout.NewTree("missing"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}})
188188 if err != nil {
189189 t.Fatal(err)
190190 }
@@ -193,8 +193,8 @@ func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) {
193193 }
194194 }
195195
196-func TestUnmarshalRejectsNonCanonicalBlobRolesDuringPreflight(t *testing.T) {
197- sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}})
196+func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) {
197+ sealed, emptyTail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}})
198198 if len(sealed) == 0 {
199199 t.Fatal("expected a sealed chunk")
200200 }
@@ -204,16 +204,37 @@ func TestUnmarshalRejectsNonCanonicalBlobRolesDuringPreflight(t *testing.T) {
204204 multiChunk = append(multiChunk, sealed[0][9:]...)
205205 visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
206206 emptyVisible := visibleBlob(t, nil)
207+
208+ // Incremental captures retain one copied mutable tail chunk instead of
209+ // rotating it into the sealed history set. That one chunk is canonical and
210+ // must remain a valid v3 import payload.
211+ mutableHistory := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2})
212+ for _, row := range [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}} {
213+ requireNoError(t, mutableHistory.Append(row))
214+ }
215+ mutableTail, err := vt.MarshalHistoryTail(mutableHistory.SnapshotView())
216+ requireNoError(t, err)
217+ fullHistory, err := vt.MarshalHistory(mutableHistory.SealAndView())
218+ requireNoError(t, err)
219+ valid, err := marshalTest(Session{Name: "s", Tabs: []Tab{{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: sealed[:1], Tail: mutableTail, Visible: visible}}}}})
220+ requireNoError(t, err)
221+ roundTrip, err := Unmarshal(valid)
222+ requireNoError(t, err)
223+ if got := roundTrip.Tabs[0].Panes[0].Tail; !bytes.Equal(got, mutableTail) {
224+ t.Fatal("mutable tail did not round trip")
225+ }
226+
207227 for _, tc := range []struct {
208228 name string
209229 tab Tab
210230 }{
211- {name: "sealed multi-chunk", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: [][]byte{multiChunk}, Tail: tail, Visible: visible}}}},
212- {name: "nonempty tail", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: sealed[0], Visible: visible}}}},
213- {name: "empty visible geometry", tab: Tab{Panes: []Pane{{ID: "p", Tail: tail, Visible: emptyVisible}}}},
231+ {name: "sealed multi-chunk", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: [][]byte{multiChunk}, Tail: emptyTail, Visible: visible}}}},
232+ {name: "tail has multiple chunks", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: multiChunk, Visible: visible}}}},
233+ {name: "noncanonical full history tail", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: fullHistory, Visible: visible}}}},
234+ {name: "empty visible geometry", tab: Tab{Panes: []Pane{{ID: "p", Tail: emptyTail, Visible: emptyVisible}}}},
214235 } {
215236 t.Run(tc.name, func(t *testing.T) {
216- data, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}})
237+ data, err := marshalTest(Session{Name: "s", Tabs: []Tab{tc.tab}})
217238 requireNoError(t, err)
218239 reject(t, data, ErrInvalidData)
219240 })
@@ -238,7 +259,7 @@ func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) {
238259 {name: "nil root", tab: Tab{Tree: &layout.Tree{Root: nil}}},
239260 } {
240261 t.Run(tc.name, func(t *testing.T) {
241- data, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}})
262+ data, err := marshalTest(Session{Name: "s", Tabs: []Tab{tc.tab}})
242263 requireNoError(t, err)
243264 got, err := Unmarshal(data)
244265 requireNoError(t, err)
@@ -265,7 +286,7 @@ func TestRoundTripSessionMetadataAndProcess(t *testing.T) {
265286 },
266287 {StableID: "t2", Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", SealedChunks: sealed, Tail: tail, Visible: visible}}},
267288 }}
268- data, err := Marshal(want)
289+ data, err := marshalTest(want)
269290 requireNoError(t, err)
270291 got, err := Unmarshal(data)
271292 requireNoError(t, err)
@@ -279,11 +300,11 @@ func TestRoundTripSessionMetadataAndProcess(t *testing.T) {
279300 }
280301
281302 func TestMarshalRejectsProcessWithoutArgv(t *testing.T) {
282- _, err := Marshal(Session{Name: "s", Tabs: []Tab{
303+ _, err := marshalTest(Session{Name: "s", Tabs: []Tab{
283304 {Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Process: &Process{Strategy: "generic"}}}},
284305 }})
285306 if !errors.Is(err, ErrInvalidData) {
286- t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData)
307+ t.Fatalf("marshalTest() error = %v, want %v", err, ErrInvalidData)
287308 }
288309 }
289310
@@ -322,7 +343,7 @@ func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) {
322343 func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) {
323344 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 256, ChunkRows: 256})
324345 for range 256 {
325- history.Append([]renderer.Cell{{Rune: 'x'}})
346+ requireNoError(t, history.Append([]renderer.Cell{{Rune: 'x'}}))
326347 }
327348 sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView())
328349 requireNoError(t, err)
@@ -333,7 +354,7 @@ func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) {
333354 for i := range tabs {
334355 tabs[i] = Tab{Panes: []Pane{{ID: layout.PaneID(strconv.Itoa(i)), SealedChunks: sealed, Tail: tail, Visible: visible}}}
335356 }
336- data, err := Marshal(Session{Name: "over-budget", Tabs: tabs})
357+ data, err := marshalTest(Session{Name: "over-budget", Tabs: tabs})
337358 requireNoError(t, err)
338359
339360 allocs := testing.AllocsPerRun(3, func() {
@@ -385,7 +406,7 @@ func historyBlobs(t *testing.T, rows [][]renderer.Cell) ([][]byte, []byte) {
385406 t.Helper()
386407 h := vt.NewHistory(vt.HistoryConfig{MaxRows: 128, ChunkRows: 2})
387408 for _, row := range rows {
388- h.Append(row)
409+ requireNoError(t, h.Append(row))
389410 }
390411 sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView())
391412 if err != nil {
A internal/usecase/snapshot/head_test.go +44 -0
@@ -0,0 +1,44 @@
1+package snapshot
2+
3+import (
4+ "errors"
5+ "testing"
6+)
7+
8+func TestHeadCodecIsDeterministicAndStrict(t *testing.T) {
9+ head := Head{Generation: 7, ManifestDigest: SnapshotDigest{1}}
10+ encoded, err := MarshalHead(head)
11+ if err != nil {
12+ t.Fatal(err)
13+ }
14+ encodedAgain, err := MarshalHead(head)
15+ if err != nil {
16+ t.Fatal(err)
17+ }
18+ if string(encoded) != string(encodedAgain) {
19+ t.Fatal("head encoding is not deterministic")
20+ }
21+ got, err := UnmarshalHead(encoded)
22+ if err != nil || got != head {
23+ t.Fatalf("head round trip = %#v, %v", got, err)
24+ }
25+ for n := range len(encoded) {
26+ if _, err := UnmarshalHead(encoded[:n]); err == nil {
27+ t.Fatalf("prefix %d accepted", n)
28+ }
29+ }
30+ if _, err := UnmarshalHead(append(encoded, 0)); !errors.Is(err, ErrTrailingBytes) {
31+ t.Fatalf("trailing data error = %v", err)
32+ }
33+}
34+
35+func TestHeadCodecRejectsInvalidDeclarations(t *testing.T) {
36+ for _, head := range []Head{
37+ {ManifestDigest: SnapshotDigest{1}},
38+ {Generation: 1},
39+ } {
40+ if _, err := MarshalHead(head); !errors.Is(err, ErrInvalidData) {
41+ t.Fatalf("MarshalHead(%#v) error = %v, want invalid data", head, err)
42+ }
43+ }
44+}
A internal/usecase/snapshot/manifest.go +515 -0
@@ -0,0 +1,515 @@
1+package snapshot
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/binary"
6+ "fmt"
7+ "hash/crc32"
8+ "math"
9+
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/usecase/layout"
12+)
13+
14+const (
15+ manifestMagic = "VEVM"
16+ headMagic = "VEVH"
17+ objectMagic = "VEVO"
18+ ManifestVersion = uint16(1)
19+ manifestHeaderSize = 16
20+ objectEnvelopeBodyPrefixSize = 1 + 4 // object kind and payload length
21+ minObjectEnvelopeSize = manifestHeaderSize + objectEnvelopeBodyPrefixSize + 1
22+ objectRefDigestSize = sha256.Size
23+ objectRefSizeFieldSize = 4
24+
25+ manifestNodeLeaf uint8 = 0
26+ manifestNodeSplit uint8 = 1
27+ manifestNodeStack uint8 = 2
28+ manifestNodeNil uint8 = 3
29+
30+ processAbsent uint8 = 0
31+ processPresent uint8 = 1
32+ // maxDecodedBodySize includes the object kind and payload-length prefix.
33+ maxObjectEnvelopeSize = manifestHeaderSize + maxDecodedBodySize
34+ maxObjectPayloadSize = maxDecodedBodySize - objectEnvelopeBodyPrefixSize
35+)
36+
37+// SnapshotDigest is the shared content-address type used by the repository.
38+type SnapshotDigest = ports.SnapshotDigest
39+
40+func ManifestDigest(encoded []byte) SnapshotDigest { return sha256.Sum256(encoded) }
41+
42+type ObjectKind uint8
43+
44+const (
45+ HistoryChunk ObjectKind = iota + 1
46+ HistoryTail
47+ Visible
48+)
49+
50+type ObjectRef struct {
51+ Kind ObjectKind
52+ Digest SnapshotDigest
53+ Size uint32
54+}
55+
56+// Head is the repository-owned generation pointer. It is deliberately not a
57+// VEVM payload; adapters produce and atomically manage HEAD later.
58+type Head struct {
59+ Generation uint64
60+ ManifestDigest SnapshotDigest
61+}
62+
63+// MarshalHead encodes the deterministic, strict VEVH generation pointer.
64+func MarshalHead(h Head) ([]byte, error) {
65+ if h.Generation == 0 || isZeroDigestBytes(h.ManifestDigest[:]) {
66+ return nil, fmt.Errorf("%w: head", ErrInvalidData)
67+ }
68+ var w payloadWriter
69+ w.putUint64(h.Generation)
70+ w.b = append(w.b, h.ManifestDigest[:]...)
71+ return marshalManifestEnvelope(headMagic, w.b)
72+}
73+
74+// UnmarshalHead decodes a complete VEVH generation pointer.
75+func UnmarshalHead(encoded []byte) (Head, error) {
76+ body, err := unmarshalManifestEnvelope(encoded, headMagic)
77+ if err != nil {
78+ return Head{}, err
79+ }
80+ if len(body) < 8+sha256.Size {
81+ return Head{}, ErrShortPayload
82+ }
83+ if len(body) != 8+sha256.Size {
84+ return Head{}, ErrTrailingBytes
85+ }
86+ head := Head{Generation: binary.BigEndian.Uint64(body[:8])}
87+ copy(head.ManifestDigest[:], body[8:])
88+ if head.Generation == 0 || isZeroDigestBytes(head.ManifestDigest[:]) {
89+ return Head{}, fmt.Errorf("%w: head", ErrInvalidData)
90+ }
91+ return head, nil
92+}
93+
94+// Manifest is the complete VEVM payload carried directly in a publication.
95+type Manifest struct {
96+ Generation uint64
97+ Name string
98+ CreatedAt uint64
99+ Active uint16
100+ Tabs []ManifestTab
101+}
102+
103+type ManifestTab struct {
104+ StableID string
105+ Cols uint16
106+ Rows uint16
107+ NextPaneID uint64
108+ Focus layout.PaneID
109+ Tree *layout.Tree
110+ Panes []ManifestPane
111+}
112+
113+type ManifestPane struct {
114+ ID layout.PaneID
115+ StableID string
116+ Cwd string
117+ Sealed []ObjectRef
118+ Tail ObjectRef
119+ Visible ObjectRef
120+ Process *Process
121+}
122+
123+func MarshalManifest(m Manifest) ([]byte, error) {
124+ if err := validateManifest(m); err != nil {
125+ return nil, err
126+ }
127+ var w payloadWriter
128+ w.putUint64(m.Generation)
129+ if err := w.putString(m.Name); err != nil {
130+ return nil, err
131+ }
132+ w.putUint64(m.CreatedAt)
133+ w.putUint16(m.Active)
134+ if len(m.Tabs) > math.MaxUint16 {
135+ return nil, fmt.Errorf("%w: too many tabs", ErrInvalidData)
136+ }
137+ w.putUint16(uint16(len(m.Tabs)))
138+ for _, tab := range m.Tabs {
139+ if err := writeManifestTab(&w, tab); err != nil {
140+ return nil, err
141+ }
142+ }
143+ return marshalManifestEnvelope(manifestMagic, w.b)
144+}
145+
146+func UnmarshalManifest(encoded []byte) (Manifest, error) {
147+ body, err := unmarshalManifestEnvelope(encoded, manifestMagic)
148+ if err != nil {
149+ return Manifest{}, err
150+ }
151+ if err := preflightManifest(body); err != nil {
152+ return Manifest{}, err
153+ }
154+ return decodeManifest(body)
155+}
156+
157+// decodeManifest decodes an already preflighted payload. Keeping this separate
158+// allows the preflight grammar to be differentially tested against decoding.
159+func decodeManifest(body []byte) (Manifest, error) {
160+ r := payloadReader{b: body}
161+ generation, err := r.getUint64()
162+ if err != nil {
163+ return Manifest{}, err
164+ }
165+ name, err := r.getString()
166+ if err != nil {
167+ return Manifest{}, err
168+ }
169+ createdAt, err := r.getUint64()
170+ if err != nil {
171+ return Manifest{}, err
172+ }
173+ active, err := r.getUint16()
174+ if err != nil {
175+ return Manifest{}, err
176+ }
177+ n, err := r.getUint16()
178+ if err != nil {
179+ return Manifest{}, err
180+ }
181+ m := Manifest{Generation: generation, Name: name, CreatedAt: createdAt, Active: active, Tabs: make([]ManifestTab, n)}
182+ for i := range m.Tabs {
183+ if m.Tabs[i], err = readManifestTab(&r); err != nil {
184+ return Manifest{}, err
185+ }
186+ }
187+ if err := r.done(); err != nil {
188+ return Manifest{}, err
189+ }
190+ if err := validateManifest(m); err != nil {
191+ return Manifest{}, err
192+ }
193+ return m, nil
194+}
195+
196+// MarshalObject frames payload as VEVO and returns the bytes together with the
197+// SHA-256 address of those complete bytes.
198+func MarshalObject(kind ObjectKind, payload []byte) (ports.SnapshotObject, error) {
199+ if !validObjectKind(kind) || len(payload) == 0 || len(payload) > maxObjectPayloadSize {
200+ return ports.SnapshotObject{}, fmt.Errorf("%w: object", ErrInvalidData)
201+ }
202+ var w payloadWriter
203+ w.putUint8(uint8(kind))
204+ w.putUint32(uint32(len(payload)))
205+ w.b = append(w.b, payload...)
206+ data, err := marshalManifestEnvelope(objectMagic, w.b)
207+ if err != nil {
208+ return ports.SnapshotObject{}, err
209+ }
210+ return ports.SnapshotObject{Digest: sha256.Sum256(data), Data: data}, nil
211+}
212+
213+func UnmarshalObject(encoded []byte) (ObjectKind, []byte, error) {
214+ kind, payload, err := PreflightObject(encoded)
215+ if err != nil {
216+ return 0, nil, err
217+ }
218+ return kind, append([]byte(nil), payload...), nil
219+}
220+
221+// PreflightObject verifies VEVO framing and declared payload size without
222+// copying; the returned payload aliases encoded and is only valid while it is.
223+func PreflightObject(encoded []byte) (ObjectKind, []byte, error) {
224+ body, err := unmarshalManifestEnvelope(encoded, objectMagic)
225+ if err != nil {
226+ return 0, nil, err
227+ }
228+ if len(body) < 5 {
229+ return 0, nil, ErrShortPayload
230+ }
231+ kind := ObjectKind(body[0])
232+ if !validObjectKind(kind) {
233+ return 0, nil, fmt.Errorf("%w: object kind", ErrInvalidData)
234+ }
235+ n := binary.BigEndian.Uint32(body[1:5])
236+ if n == 0 || n > maxObjectPayloadSize {
237+ return 0, nil, fmt.Errorf("%w: object length", ErrInvalidData)
238+ }
239+ if uint64(n) > uint64(len(body)-5) {
240+ return 0, nil, ErrShortPayload
241+ }
242+ if uint64(n) != uint64(len(body)-5) {
243+ return 0, nil, ErrTrailingBytes
244+ }
245+ return kind, body[5:], nil
246+}
247+
248+func marshalManifestEnvelope(magic string, body []byte) ([]byte, error) {
249+ if len(body) > maxDecodedBodySize || len(body) > math.MaxUint32 {
250+ return nil, fmt.Errorf("%w: body too large", ErrInvalidData)
251+ }
252+ out := make([]byte, manifestHeaderSize+len(body))
253+ copy(out[:4], magic)
254+ binary.BigEndian.PutUint16(out[4:6], ManifestVersion)
255+ binary.BigEndian.PutUint32(out[8:12], uint32(len(body)))
256+ binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))
257+ copy(out[16:], body)
258+ return out, nil
259+}
260+
261+func unmarshalManifestEnvelope(encoded []byte, expectedMagic string) ([]byte, error) {
262+ if len(encoded) < manifestHeaderSize {
263+ return nil, ErrShortPayload
264+ }
265+ if string(encoded[:4]) != expectedMagic {
266+ return nil, ErrBadMagic
267+ }
268+ if binary.BigEndian.Uint16(encoded[4:6]) != ManifestVersion {
269+ return nil, ErrBadVersion
270+ }
271+ if binary.BigEndian.Uint16(encoded[6:8]) != 0 {
272+ return nil, fmt.Errorf("%w: flags", ErrInvalidData)
273+ }
274+ n := binary.BigEndian.Uint32(encoded[8:12])
275+ if n > maxDecodedBodySize {
276+ return nil, fmt.Errorf("%w: body too large", ErrInvalidData)
277+ }
278+ if uint64(len(encoded)) < uint64(manifestHeaderSize)+uint64(n) {
279+ return nil, ErrShortPayload
280+ }
281+ if uint64(len(encoded)) != uint64(manifestHeaderSize)+uint64(n) {
282+ return nil, ErrTrailingBytes
283+ }
284+ body := encoded[manifestHeaderSize:]
285+ if crc32.ChecksumIEEE(body) != binary.BigEndian.Uint32(encoded[12:16]) {
286+ return nil, ErrBadCRC
287+ }
288+ return body, nil
289+}
290+
291+func writeManifestTab(w *payloadWriter, tab ManifestTab) error {
292+ if err := w.putString(tab.StableID); err != nil {
293+ return err
294+ }
295+ w.putUint16(tab.Cols)
296+ w.putUint16(tab.Rows)
297+ w.putUint64(tab.NextPaneID)
298+ if err := w.putString(string(tab.Focus)); err != nil {
299+ return err
300+ }
301+ var root *layout.Node
302+ if tab.Tree != nil {
303+ root = tab.Tree.Root
304+ }
305+ if err := writeNode(w, root); err != nil {
306+ return err
307+ }
308+ if len(tab.Panes) > math.MaxUint16 {
309+ return fmt.Errorf("%w: too many panes", ErrInvalidData)
310+ }
311+ w.putUint16(uint16(len(tab.Panes)))
312+ for _, pane := range tab.Panes {
313+ if err := writeManifestPane(w, pane); err != nil {
314+ return err
315+ }
316+ }
317+ return nil
318+}
319+func writeManifestPane(w *payloadWriter, pane ManifestPane) error {
320+ if err := w.putString(string(pane.ID)); err != nil {
321+ return err
322+ }
323+ if err := w.putString(pane.StableID); err != nil {
324+ return err
325+ }
326+ if err := w.putString(pane.Cwd); err != nil {
327+ return err
328+ }
329+ if len(pane.Sealed) > math.MaxUint16 {
330+ return fmt.Errorf("%w: too many sealed references", ErrInvalidData)
331+ }
332+ w.putUint16(uint16(len(pane.Sealed)))
333+ for _, ref := range pane.Sealed {
334+ writeObjectRef(w, ref)
335+ }
336+ writeObjectRef(w, pane.Tail)
337+ writeObjectRef(w, pane.Visible)
338+ return writeProcess(w, pane.Process)
339+}
340+func writeObjectRef(w *payloadWriter, ref ObjectRef) {
341+ w.putUint8(uint8(ref.Kind))
342+ w.b = append(w.b, ref.Digest[:]...)
343+ w.putUint32(ref.Size)
344+}
345+
346+func readManifestTab(r *payloadReader) (ManifestTab, error) {
347+ stableID, err := r.getString()
348+ if err != nil {
349+ return ManifestTab{}, err
350+ }
351+ cols, err := r.getUint16()
352+ if err != nil {
353+ return ManifestTab{}, err
354+ }
355+ rows, err := r.getUint16()
356+ if err != nil {
357+ return ManifestTab{}, err
358+ }
359+ next, err := r.getUint64()
360+ if err != nil {
361+ return ManifestTab{}, err
362+ }
363+ focus, err := r.getString()
364+ if err != nil {
365+ return ManifestTab{}, err
366+ }
367+ root, err := readNode(r)
368+ if err != nil {
369+ return ManifestTab{}, err
370+ }
371+ n, err := r.getUint16()
372+ if err != nil {
373+ return ManifestTab{}, err
374+ }
375+ tab := ManifestTab{StableID: stableID, Cols: cols, Rows: rows, NextPaneID: next, Focus: layout.PaneID(focus), Tree: &layout.Tree{Root: root, Focus: layout.PaneID(focus)}, Panes: make([]ManifestPane, n)}
376+ for i := range tab.Panes {
377+ if tab.Panes[i], err = readManifestPane(r); err != nil {
378+ return ManifestTab{}, err
379+ }
380+ }
381+ return tab, nil
382+}
383+func readManifestPane(r *payloadReader) (ManifestPane, error) {
384+ id, err := r.getString()
385+ if err != nil {
386+ return ManifestPane{}, err
387+ }
388+ stableID, err := r.getString()
389+ if err != nil {
390+ return ManifestPane{}, err
391+ }
392+ cwd, err := r.getString()
393+ if err != nil {
394+ return ManifestPane{}, err
395+ }
396+ n, err := r.getUint16()
397+ if err != nil {
398+ return ManifestPane{}, err
399+ }
400+ pane := ManifestPane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, Sealed: make([]ObjectRef, n)}
401+ for i := range pane.Sealed {
402+ if pane.Sealed[i], err = readObjectRef(r); err != nil {
403+ return ManifestPane{}, err
404+ }
405+ }
406+ if pane.Tail, err = readObjectRef(r); err != nil {
407+ return ManifestPane{}, err
408+ }
409+ if pane.Visible, err = readObjectRef(r); err != nil {
410+ return ManifestPane{}, err
411+ }
412+ pane.Process, err = readProcess(r)
413+ return pane, err
414+}
415+func readObjectRef(r *payloadReader) (ObjectRef, error) {
416+ kind, digestBytes, size, err := parseObjectRef(r)
417+ if err != nil {
418+ return ObjectRef{}, err
419+ }
420+ var digest SnapshotDigest
421+ copy(digest[:], digestBytes)
422+ return ObjectRef{Kind: kind, Digest: digest, Size: size}, nil
423+}
424+
425+// parseObjectRef advances r over an encoded object reference without copying
426+// its digest. Callers that retain the digest must copy it before reusing input.
427+func parseObjectRef(r *payloadReader) (ObjectKind, []byte, uint32, error) {
428+ kind, err := r.getUint8()
429+ if err != nil {
430+ return 0, nil, 0, err
431+ }
432+ if len(r.b) < objectRefDigestSize+objectRefSizeFieldSize {
433+ return 0, nil, 0, ErrShortPayload
434+ }
435+ digest := r.b[:objectRefDigestSize]
436+ r.b = r.b[objectRefDigestSize:]
437+ size, err := r.getUint32()
438+ if err != nil {
439+ return 0, nil, 0, err
440+ }
441+ return ObjectKind(kind), digest, size, nil
442+}
443+
444+func validateManifest(m Manifest) error {
445+ if m.Name == "" {
446+ return fmt.Errorf("%w: manifest name", ErrInvalidData)
447+ }
448+ if err := validateActive(m.Active, len(m.Tabs)); err != nil {
449+ return err
450+ }
451+ for _, tab := range m.Tabs {
452+ if !safeDimensions(tab.Cols, tab.Rows) {
453+ return fmt.Errorf("%w: dimensions", ErrInvalidData)
454+ }
455+ ids := make(map[layout.PaneID]struct{}, len(tab.Panes))
456+ for _, pane := range tab.Panes {
457+ if pane.ID == "" {
458+ return fmt.Errorf("%w: empty pane ID", ErrInvalidData)
459+ }
460+ if _, exists := ids[pane.ID]; exists {
461+ return fmt.Errorf("%w: duplicate pane ID", ErrInvalidData)
462+ }
463+ if err := validatePaneRefs(pane); err != nil {
464+ return err
465+ }
466+ ids[pane.ID] = struct{}{}
467+ }
468+ if _, ok := ids[tab.Focus]; tab.Focus != "" && !ok {
469+ return ErrUnknownPane
470+ }
471+ if err := validateTree(tab.Tree, ids); err != nil {
472+ return err
473+ }
474+ }
475+ return nil
476+}
477+func safeDimensions(cols, rows uint16) bool {
478+ return cols != 0 && rows != 0 && uint64(cols)*uint64(rows) <= maxSnapshotCells
479+}
480+func validatePaneRefs(p ManifestPane) error {
481+ for _, ref := range p.Sealed {
482+ if err := validateObjectRef(ref, HistoryChunk); err != nil {
483+ return err
484+ }
485+ }
486+ if err := validateObjectRef(p.Tail, HistoryTail); err != nil {
487+ return err
488+ }
489+ return validateObjectRef(p.Visible, Visible)
490+}
491+func validateObjectRef(ref ObjectRef, want ObjectKind) error {
492+ return validateObjectRefFields(ref.Kind, ref.Digest[:], ref.Size, want)
493+}
494+
495+func validateObjectRefFields(kind ObjectKind, digest []byte, size uint32, want ObjectKind) error {
496+ if kind != want || !validObjectEnvelopeSize(size) || isZeroDigestBytes(digest) {
497+ return fmt.Errorf("%w: object reference", ErrInvalidData)
498+ }
499+ return nil
500+}
501+func validObjectEnvelopeSize(size uint32) bool {
502+ return size >= minObjectEnvelopeSize && size <= maxObjectEnvelopeSize
503+}
504+
505+func validObjectKind(kind ObjectKind) bool {
506+ return kind == HistoryChunk || kind == HistoryTail || kind == Visible
507+}
508+func isZeroDigestBytes(d []byte) bool {
509+ for _, b := range d {
510+ if b != 0 {
511+ return false
512+ }
513+ }
514+ return true
515+}
A internal/usecase/snapshot/manifest_preflight.go +271 -0
@@ -0,0 +1,271 @@
1+package snapshot
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/vev/internal/usecase/layout"
7+)
8+
9+// preflightManifest rejects hostile declarations before UnmarshalManifest
10+// allocates tab, pane, string, or reference slices.
11+func preflightManifest(body []byte) error {
12+ r := payloadReader{b: body}
13+ budget := manifestPreflightBudget{}
14+ if _, err := r.getUint64(); err != nil {
15+ return err
16+ }
17+ name, err := preflightManifestStringBytes(&r, &budget)
18+ if err != nil {
19+ return err
20+ }
21+ if len(name) == 0 {
22+ return fmt.Errorf("%w: manifest name", ErrInvalidData)
23+ }
24+ if _, err := r.getUint64(); err != nil {
25+ return err
26+ }
27+ active, err := r.getUint16()
28+ if err != nil {
29+ return err
30+ }
31+ tabs, err := r.getUint16()
32+ if err != nil {
33+ return err
34+ }
35+ if err := validateActive(active, int(tabs)); err != nil {
36+ return err
37+ }
38+ if uint64(tabs) > uint64(len(r.b))/2 || !budget.add(uint64(tabs)*128) {
39+ return fmt.Errorf("%w: tab allocation", ErrInvalidData)
40+ }
41+ for range tabs {
42+ if err := preflightManifestTab(&r, &budget); err != nil {
43+ return err
44+ }
45+ }
46+ return r.done()
47+}
48+
49+type manifestPreflightBudget struct{ allocation uint64 }
50+
51+func (b *manifestPreflightBudget) add(n uint64) bool {
52+ if n > maxSnapshotDecodedAllocation-b.allocation {
53+ return false
54+ }
55+ b.allocation += n
56+ return true
57+}
58+
59+func preflightManifestStringBytes(r *payloadReader, budget *manifestPreflightBudget) ([]byte, error) {
60+ n, err := r.getUint16()
61+ if err != nil {
62+ return nil, err
63+ }
64+ if uint64(n) > uint64(len(r.b)) {
65+ return nil, ErrShortPayload
66+ }
67+ if !budget.add(uint64(n) + 16) {
68+ return nil, fmt.Errorf("%w: string allocation", ErrInvalidData)
69+ }
70+ b := r.b[:n]
71+ r.b = r.b[n:]
72+ return b, nil
73+}
74+
75+// skipManifestString accounts for the decoded string without materializing it.
76+func skipManifestString(r *payloadReader, budget *manifestPreflightBudget) error {
77+ _, err := preflightManifestStringBytes(r, budget)
78+ return err
79+}
80+
81+func preflightManifestString(r *payloadReader, budget *manifestPreflightBudget) (string, error) {
82+ b, err := preflightManifestStringBytes(r, budget)
83+ if err != nil {
84+ return "", err
85+ }
86+ return string(b), nil
87+}
88+
89+func preflightManifestTab(r *payloadReader, budget *manifestPreflightBudget) error {
90+ if err := skipManifestString(r, budget); err != nil {
91+ return err
92+ }
93+ cols, err := r.getUint16()
94+ if err != nil {
95+ return err
96+ }
97+ rows, err := r.getUint16()
98+ if err != nil {
99+ return err
100+ }
101+ if !safeDimensions(cols, rows) {
102+ return fmt.Errorf("%w: dimensions", ErrInvalidData)
103+ }
104+ if _, err := r.getUint64(); err != nil {
105+ return err
106+ }
107+ focus, err := preflightManifestString(r, budget)
108+ if err != nil {
109+ return err
110+ }
111+ references := make([]layout.PaneID, 0, 8)
112+ if err := preflightManifestNode(r, 0, true, budget, &references); err != nil {
113+ return err
114+ }
115+ panes, err := r.getUint16()
116+ if err != nil {
117+ return err
118+ }
119+ if uint64(panes) > uint64(len(r.b))/2 || !budget.add(uint64(panes)*256) {
120+ return fmt.Errorf("%w: pane allocation", ErrInvalidData)
121+ }
122+ ids := make(map[layout.PaneID]struct{}, panes)
123+ for range panes {
124+ id, err := preflightManifestPane(r, budget)
125+ if err != nil {
126+ return err
127+ }
128+ if id == "" {
129+ return fmt.Errorf("%w: empty pane ID", ErrInvalidData)
130+ }
131+ if _, duplicate := ids[id]; duplicate {
132+ return fmt.Errorf("%w: duplicate pane ID", ErrInvalidData)
133+ }
134+ ids[id] = struct{}{}
135+ }
136+ if focus != "" {
137+ references = append(references, layout.PaneID(focus))
138+ }
139+ for _, id := range references {
140+ if _, ok := ids[id]; !ok {
141+ return ErrUnknownPane
142+ }
143+ }
144+ return nil
145+}
146+
147+func preflightManifestNode(r *payloadReader, depth int, root bool, budget *manifestPreflightBudget, refs *[]layout.PaneID) error {
148+ if depth > 64 || !budget.add(128) {
149+ return fmt.Errorf("%w: tree resource declaration", ErrInvalidData)
150+ }
151+ kind, err := r.getUint8()
152+ if err != nil {
153+ return err
154+ }
155+ switch kind {
156+ case manifestNodeLeaf:
157+ leaf, err := preflightManifestString(r, budget)
158+ if err != nil {
159+ return err
160+ }
161+ *refs = append(*refs, layout.PaneID(leaf))
162+ return nil
163+ case manifestNodeSplit:
164+ dir, err := r.getUint8()
165+ if err != nil {
166+ return err
167+ }
168+ if layout.SplitDir(dir) != layout.Horizontal && layout.SplitDir(dir) != layout.Vertical {
169+ return fmt.Errorf("%w: split dir", ErrInvalidData)
170+ }
171+ case manifestNodeStack:
172+ expanded, err := preflightManifestString(r, budget)
173+ if err != nil {
174+ return err
175+ }
176+ if expanded != "" {
177+ *refs = append(*refs, layout.PaneID(expanded))
178+ }
179+ case manifestNodeNil:
180+ if !root {
181+ return fmt.Errorf("%w: nil child node", ErrInvalidData)
182+ }
183+ return nil
184+ default:
185+ return fmt.Errorf("%w: node kind", ErrInvalidData)
186+ }
187+ n, err := r.getUint16()
188+ if err != nil {
189+ return err
190+ }
191+ if uint64(n) > uint64(len(r.b)) {
192+ return ErrShortPayload
193+ }
194+ for range n {
195+ if err := preflightManifestNode(r, depth+1, false, budget, refs); err != nil {
196+ return err
197+ }
198+ }
199+ return nil
200+}
201+
202+func preflightManifestPane(r *payloadReader, budget *manifestPreflightBudget) (layout.PaneID, error) {
203+ id, err := preflightManifestString(r, budget)
204+ if err != nil {
205+ return "", err
206+ }
207+ if err := skipManifestString(r, budget); err != nil {
208+ return "", err
209+ }
210+ if err := skipManifestString(r, budget); err != nil {
211+ return "", err
212+ }
213+ sealed, err := r.getUint16()
214+ if err != nil {
215+ return "", err
216+ }
217+ if uint64(sealed) > uint64(len(r.b))/(objectRefDigestSize+objectRefSizeFieldSize+1) || !budget.add(uint64(sealed)*48) {
218+ return "", fmt.Errorf("%w: sealed allocation", ErrInvalidData)
219+ }
220+ for range sealed {
221+ if err := preflightObjectRef(r, HistoryChunk); err != nil {
222+ return "", err
223+ }
224+ }
225+ if err := preflightObjectRef(r, HistoryTail); err != nil {
226+ return "", err
227+ }
228+ if err := preflightObjectRef(r, Visible); err != nil {
229+ return "", err
230+ }
231+ if err := preflightManifestProcess(r, budget); err != nil {
232+ return "", err
233+ }
234+ return layout.PaneID(id), nil
235+}
236+
237+func preflightObjectRef(r *payloadReader, want ObjectKind) error {
238+ kind, digest, size, err := parseObjectRef(r)
239+ if err != nil {
240+ return err
241+ }
242+ return validateObjectRefFields(kind, digest, size, want)
243+}
244+func preflightManifestProcess(r *payloadReader, budget *manifestPreflightBudget) error {
245+ present, err := r.getUint8()
246+ if err != nil {
247+ return err
248+ }
249+ if present == processAbsent {
250+ return nil
251+ }
252+ if present != processPresent {
253+ return fmt.Errorf("%w: process presence", ErrInvalidData)
254+ }
255+ n, err := r.getUint16()
256+ if err != nil {
257+ return err
258+ }
259+ if n == 0 || !budget.add(uint64(n)*16) {
260+ return fmt.Errorf("%w: process argv", ErrInvalidData)
261+ }
262+ for range n {
263+ if err := skipManifestString(r, budget); err != nil {
264+ return err
265+ }
266+ }
267+ if err := skipManifestString(r, budget); err != nil {
268+ return err
269+ }
270+ return skipManifestString(r, budget)
271+}
A internal/usecase/snapshot/manifest_preflight_test.go +173 -0
@@ -0,0 +1,173 @@
1+package snapshot
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "hash/crc32"
7+ "testing"
8+
9+ "github.com/bnema/vev/internal/usecase/layout"
10+)
11+
12+func TestManifestGrammarRoundTrip(t *testing.T) {
13+ for _, tc := range []struct {
14+ name string
15+ tree *layout.Tree
16+ proc *Process
17+ }{
18+ {"split without process", &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("one"), layout.NewLeaf("two")}}}, nil},
19+ {"stack with process argv", &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Expanded: "one", Children: []*layout.Node{layout.NewLeaf("one"), layout.NewLeaf("two")}}}, &Process{Argv: []string{"sh", "-c", "echo"}, Strategy: "restore", Opts: ProcessOpts{AgentSessionID: "agent"}}},
20+ } {
21+ t.Run(tc.name, func(t *testing.T) {
22+ encoded, err := MarshalManifest(testManifest(tc.tree, tc.proc))
23+ if err != nil {
24+ t.Fatal(err)
25+ }
26+ got, err := UnmarshalManifest(encoded)
27+ if err != nil {
28+ t.Fatal(err)
29+ }
30+ if got.Tabs[0].Tree.Root.Kind != tc.tree.Root.Kind || (got.Tabs[0].Panes[0].Process == nil) != (tc.proc == nil) {
31+ t.Fatalf("decoded tree/process = %#v/%#v", got.Tabs[0].Tree, got.Tabs[0].Panes[0].Process)
32+ }
33+ })
34+ }
35+}
36+
37+func TestManifestEnvelopePreflightParity(t *testing.T) {
38+ encoded, err := MarshalManifest(testManifest(layout.NewTree("one"), nil))
39+ if err != nil {
40+ t.Fatal(err)
41+ }
42+ mutations := []struct {
43+ name string
44+ edit func([]byte) []byte
45+ }{
46+ {"valid", func(b []byte) []byte { return b }},
47+ {"magic", func(b []byte) []byte { b[0] ^= 1; return b }},
48+ {"version", func(b []byte) []byte { binary.BigEndian.PutUint16(b[4:6], ManifestVersion+1); return b }},
49+ {"flags", func(b []byte) []byte { b[7] = 1; return b }},
50+ {"body length", func(b []byte) []byte { binary.BigEndian.PutUint32(b[8:12], uint32(len(b))); return b }},
51+ {"crc", func(b []byte) []byte { b[12] ^= 1; return b }},
52+ {"trailing", func(b []byte) []byte { return append(b, 0) }},
53+ {"body name", func(b []byte) []byte { b[26] ^= 1; binary.BigEndian.PutUint32(b[12:16], checksum(b[16:])); return b }},
54+ {"body generation", func(b []byte) []byte { b[16] ^= 1; binary.BigEndian.PutUint32(b[12:16], checksum(b[16:])); return b }},
55+ {"body node kind", func(b []byte) []byte { b[65] = 99; binary.BigEndian.PutUint32(b[12:16], checksum(b[16:])); return b }},
56+ }
57+ for _, tc := range mutations {
58+ t.Run(tc.name, func(t *testing.T) {
59+ candidate := tc.edit(append([]byte(nil), encoded...))
60+ _, decodeErr := UnmarshalManifest(candidate)
61+ preflightErr := preflightManifestEnvelope(candidate)
62+ uncheckedErr := decodeManifestEnvelope(candidate)
63+ if (decodeErr == nil) != (preflightErr == nil) || (preflightErr == nil) != (uncheckedErr == nil) {
64+ t.Fatalf("preflight error = %v, unchecked decode error = %v, decode error = %v", preflightErr, uncheckedErr, decodeErr)
65+ }
66+ })
67+ }
68+}
69+
70+func TestManifestObjectRefKinds(t *testing.T) {
71+ for _, tc := range []struct {
72+ name string
73+ got ObjectKind
74+ want ObjectKind
75+ valid bool
76+ }{
77+ {"chunk", HistoryChunk, HistoryChunk, true},
78+ {"tail", HistoryTail, HistoryTail, true},
79+ {"visible", Visible, Visible, true},
80+ {"invalid", ObjectKind(99), Visible, false},
81+ {"wrong role", HistoryTail, Visible, false},
82+ } {
83+ t.Run(tc.name, func(t *testing.T) {
84+ var w payloadWriter
85+ writeObjectRef(&w, ObjectRef{Kind: tc.got, Digest: SnapshotDigest{1}, Size: minObjectEnvelopeSize})
86+ err := preflightObjectRef(&payloadReader{b: w.b}, tc.want)
87+ if (err == nil) != tc.valid {
88+ t.Fatalf("preflightObjectRef() error = %v, valid=%v", err, tc.valid)
89+ }
90+ })
91+ }
92+}
93+
94+func TestManifestPreflightHostileDeclarations(t *testing.T) {
95+ t.Run("tab count", func(t *testing.T) {
96+ var w payloadWriter
97+ w.putUint64(1)
98+ if err := w.putString("name"); err != nil {
99+ t.Fatalf("putString(name): %v", err)
100+ }
101+ w.putUint64(1)
102+ w.putUint16(0)
103+ w.putUint16(^uint16(0))
104+ if err := preflightManifest(w.b); !errors.Is(err, ErrInvalidData) {
105+ t.Fatalf("preflightManifest() error = %v", err)
106+ }
107+ })
108+ for _, tc := range []struct {
109+ name string
110+ fn func(*payloadReader, *manifestPreflightBudget) error
111+ body []byte
112+ }{
113+ {"string length", func(r *payloadReader, b *manifestPreflightBudget) error { return skipManifestString(r, b) }, []byte{0, 1}},
114+ {"tree children", func(r *payloadReader, b *manifestPreflightBudget) error {
115+ return preflightManifestNode(r, 0, true, b, nil)
116+ }, []byte{manifestNodeSplit, byte(layout.Horizontal), 0xff, 0xff}},
117+ {"nil child", func(r *payloadReader, b *manifestPreflightBudget) error {
118+ return preflightManifestNode(r, 1, false, b, nil)
119+ }, []byte{manifestNodeNil}},
120+ {"process declaration", func(r *payloadReader, b *manifestPreflightBudget) error { return preflightManifestProcess(r, b) }, []byte{processPresent, 0, 1, 0, 2, 'x'}},
121+ } {
122+ t.Run(tc.name, func(t *testing.T) {
123+ err := tc.fn(&payloadReader{b: tc.body}, &manifestPreflightBudget{})
124+ if err == nil {
125+ t.Fatal("hostile declaration accepted")
126+ }
127+ })
128+ }
129+}
130+
131+func TestSkipManifestStringDoesNotAllocate(t *testing.T) {
132+ raw := make([]byte, 2+1024)
133+ binary.BigEndian.PutUint16(raw, 1024)
134+ if allocations := testing.AllocsPerRun(100, func() {
135+ r := payloadReader{b: raw}
136+ if err := skipManifestString(&r, &manifestPreflightBudget{}); err != nil {
137+ t.Fatal(err)
138+ }
139+ }); allocations != 0 {
140+ t.Fatalf("skipManifestString allocations = %v, want 0", allocations)
141+ }
142+}
143+
144+func preflightManifestEnvelope(encoded []byte) error {
145+ body, err := unmarshalManifestEnvelope(encoded, manifestMagic)
146+ if err != nil {
147+ return err
148+ }
149+ return preflightManifest(body)
150+}
151+
152+func decodeManifestEnvelope(encoded []byte) error {
153+ body, err := unmarshalManifestEnvelope(encoded, manifestMagic)
154+ if err != nil {
155+ return err
156+ }
157+ _, err = decodeManifest(body)
158+ return err
159+}
160+
161+func checksum(body []byte) uint32 {
162+ return crc32.ChecksumIEEE(body)
163+}
164+
165+func testManifest(tree *layout.Tree, process *Process) Manifest {
166+ return Manifest{Name: "named", Tabs: []ManifestTab{{
167+ StableID: "tab", Cols: 80, Rows: 24, Focus: "one", Tree: tree,
168+ Panes: []ManifestPane{
169+ {ID: "one", StableID: "one", Cwd: "/one", Sealed: []ObjectRef{{Kind: HistoryChunk, Digest: SnapshotDigest{1}, Size: minObjectEnvelopeSize}}, Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize}, Visible: ObjectRef{Kind: Visible, Digest: SnapshotDigest{3}, Size: minObjectEnvelopeSize}, Process: process},
170+ {ID: "two", StableID: "two", Cwd: "/two", Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{4}, Size: minObjectEnvelopeSize}, Visible: ObjectRef{Kind: Visible, Digest: SnapshotDigest{5}, Size: minObjectEnvelopeSize}},
171+ },
172+ }}}
173+}
A internal/usecase/snapshot/manifest_test.go +183 -0
@@ -0,0 +1,183 @@
1+package snapshot
2+
3+import (
4+ "bytes"
5+ "crypto/sha256"
6+ "errors"
7+ "math"
8+ "testing"
9+
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/usecase/layout"
12+)
13+
14+func TestManifestCodecUsesGenerationAndOrderedTypedReferences(t *testing.T) {
15+ chunk, err := MarshalObject(HistoryChunk, []byte("history"))
16+ if err != nil {
17+ t.Fatal(err)
18+ }
19+ tail, err := MarshalObject(HistoryTail, []byte("tail"))
20+ if err != nil {
21+ t.Fatal(err)
22+ }
23+ visible, err := MarshalObject(Visible, []byte("visible"))
24+ if err != nil {
25+ t.Fatal(err)
26+ }
27+ manifest := Manifest{Generation: 7, Name: "named", CreatedAt: 9, Active: 0, Tabs: []ManifestTab{{
28+ StableID: "tab", Cols: 80, Rows: 24, NextPaneID: 2, Focus: "pane", Tree: layout.NewTree("pane"),
29+ Panes: []ManifestPane{{ID: "pane", StableID: "pane-stable", Cwd: "/tmp",
30+ Sealed: []ObjectRef{{Kind: HistoryChunk, Digest: chunk.Digest, Size: uint32(len(chunk.Data))}},
31+ Tail: ObjectRef{Kind: HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))},
32+ Visible: ObjectRef{Kind: Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))},
33+ }},
34+ }}}
35+ encoded, err := MarshalManifest(manifest)
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ got, err := UnmarshalManifest(encoded)
40+ if err != nil {
41+ t.Fatal(err)
42+ }
43+ if got.Generation != manifest.Generation || got.Tabs[0].Panes[0].Tail != manifest.Tabs[0].Panes[0].Tail {
44+ t.Fatalf("round trip = %#v", got)
45+ }
46+ for n := range len(encoded) {
47+ if _, err := UnmarshalManifest(encoded[:n]); err == nil {
48+ t.Fatalf("prefix %d accepted", n)
49+ }
50+ }
51+ if _, err := UnmarshalManifest(append(encoded, 0)); !errors.Is(err, ErrTrailingBytes) {
52+ t.Fatalf("trailing data error = %v", err)
53+ }
54+
55+ head := Head{Generation: 7, ManifestDigest: sha256.Sum256(encoded)}
56+ if head.Generation != manifest.Generation || head.ManifestDigest != ManifestDigest(encoded) {
57+ t.Fatalf("head = %#v", head)
58+ }
59+}
60+
61+func TestObjectCodecReturnsContentAddressedObjectAndPayload(t *testing.T) {
62+ object, err := MarshalObject(Visible, []byte("canonical-vt"))
63+ if err != nil {
64+ t.Fatal(err)
65+ }
66+ if object.Digest != sha256.Sum256(object.Data) {
67+ t.Fatalf("digest does not address VEVO bytes")
68+ }
69+ kind, payload, err := UnmarshalObject(object.Data)
70+ if err != nil || kind != Visible || !bytes.Equal(payload, []byte("canonical-vt")) {
71+ t.Fatalf("round trip = %v %q %v", kind, payload, err)
72+ }
73+ kind, payload, err = PreflightObject(object.Data)
74+ if err != nil || kind != Visible || !bytes.Equal(payload, []byte("canonical-vt")) {
75+ t.Fatalf("preflight = %v %q %v", kind, payload, err)
76+ }
77+ for n := range len(object.Data) {
78+ if _, _, err := UnmarshalObject(object.Data[:n]); err == nil {
79+ t.Fatalf("prefix %d accepted", n)
80+ }
81+ }
82+ if _, _, err := UnmarshalObject(append(object.Data, 0)); !errors.Is(err, ErrTrailingBytes) {
83+ t.Fatalf("trailing data error = %v", err)
84+ }
85+}
86+
87+func TestManifestDimensionBoundaries(t *testing.T) {
88+ ref := ObjectRef{Kind: HistoryTail, Digest: ports.SnapshotDigest{1}, Size: minObjectEnvelopeSize}
89+ visible := ObjectRef{Kind: Visible, Digest: ports.SnapshotDigest{2}, Size: minObjectEnvelopeSize}
90+ for _, tc := range []struct {
91+ name string
92+ cols, rows uint16
93+ wantErr bool
94+ }{
95+ {"max cols safe rows", math.MaxUint16, 1, false},
96+ {"max rows safe cols", 1, math.MaxUint16, false},
97+ {"hostile product", math.MaxUint16, math.MaxUint16, true},
98+ } {
99+ t.Run(tc.name, func(t *testing.T) {
100+ m := Manifest{Name: "safe", Tabs: []ManifestTab{{Cols: tc.cols, Rows: tc.rows, Panes: []ManifestPane{{ID: "p", Tail: ref, Visible: visible}}}}}
101+ _, err := MarshalManifest(m)
102+ if (err != nil) != tc.wantErr {
103+ t.Fatalf("MarshalManifest() error = %v, want error=%v", err, tc.wantErr)
104+ }
105+ })
106+ }
107+}
108+
109+func TestManifestRejectsImpossibleObjectEnvelopeSize(t *testing.T) {
110+ manifest := Manifest{Name: "named", Tabs: []ManifestTab{{Cols: 1, Rows: 1, Panes: []ManifestPane{{
111+ ID: "pane",
112+ Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{1}, Size: 1},
113+ Visible: ObjectRef{Kind: Visible, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize},
114+ }}}}}
115+ if _, err := MarshalManifest(manifest); !errors.Is(err, ErrInvalidData) {
116+ t.Fatalf("MarshalManifest() error = %v, want invalid data", err)
117+ }
118+}
119+
120+func TestObjectRefEnvelopeSizeBoundaries(t *testing.T) {
121+ const (
122+ minimum = manifestHeaderSize + 1 + 4 + 1
123+ maximum = manifestHeaderSize + maxDecodedBodySize
124+ )
125+ if minObjectEnvelopeSize != minimum || maxObjectEnvelopeSize != maximum {
126+ t.Fatalf("object envelope range = [%d, %d], want [%d, %d]", minObjectEnvelopeSize, maxObjectEnvelopeSize, minimum, maximum)
127+ }
128+ object, err := MarshalObject(HistoryTail, []byte{0})
129+ if err != nil {
130+ t.Fatalf("MarshalObject() error = %v", err)
131+ }
132+ if len(object.Data) != minimum {
133+ t.Fatalf("MarshalObject() size = %d, want %d", len(object.Data), minimum)
134+ }
135+
136+ for _, tc := range []struct {
137+ name string
138+ size uint32
139+ valid bool
140+ }{
141+ {name: "one below minimum", size: minimum - 1},
142+ {name: "minimum", size: minimum, valid: true},
143+ {name: "maximum declaration", size: maximum, valid: true},
144+ {name: "one above maximum", size: maximum + 1},
145+ } {
146+ t.Run(tc.name, func(t *testing.T) {
147+ manifest := manifestWithTailSize(tc.size)
148+ encoded, err := MarshalManifest(manifest)
149+ if tc.valid {
150+ if err != nil {
151+ t.Fatalf("MarshalManifest() error = %v", err)
152+ }
153+ if _, err := UnmarshalManifest(encoded); err != nil {
154+ t.Fatalf("UnmarshalManifest() error = %v", err)
155+ }
156+ } else if !errors.Is(err, ErrInvalidData) {
157+ t.Fatalf("MarshalManifest() error = %v, want invalid data", err)
158+ }
159+
160+ var w payloadWriter
161+ writeObjectRef(&w, ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{1}, Size: tc.size})
162+ err = preflightObjectRef(&payloadReader{b: w.b}, HistoryTail)
163+ if tc.valid && err != nil {
164+ t.Fatalf("preflightObjectRef() error = %v", err)
165+ }
166+ if !tc.valid && !errors.Is(err, ErrInvalidData) {
167+ t.Fatalf("preflightObjectRef() error = %v, want invalid data", err)
168+ }
169+ })
170+ }
171+}
172+
173+func manifestWithTailSize(size uint32) Manifest {
174+ return Manifest{Name: "named", Tabs: []ManifestTab{{
175+ Cols: 1,
176+ Rows: 1,
177+ Panes: []ManifestPane{{
178+ ID: "pane",
179+ Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{1}, Size: size},
180+ Visible: ObjectRef{Kind: Visible, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize},
181+ }},
182+ }}}
183+}
M internal/usecase/snapshot/preflight.go +6 -3
@@ -198,7 +198,7 @@ func preflightPaneStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uin
198198 if err != nil {
199199 return err
200200 }
201- if n > maxSnapshotBlobs || uint64(n) > uint64(len(r.b))/4 {
201+ if n > maxSnapshotObjects || uint64(n) > uint64(len(r.b))/4 {
202202 return fmt.Errorf("%w: sealed chunks", ErrInvalidData)
203203 }
204204 for range n {
@@ -214,7 +214,10 @@ func preflightPaneStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uin
214214 if err != nil {
215215 return err
216216 }
217- if tail.Chunks != 0 || tail.Rows != 0 || tail.Cells != 0 {
217+ // A capture keeps the live mutable tail separate from immutable sealed
218+ // chunks. It may therefore contain one partial chunk (or the canonical
219+ // empty tail), but never a sequence of sealed chunks.
220+ if tail.Chunks > 1 {
218221 return fmt.Errorf("%w: tail blob role", ErrInvalidData)
219222 }
220223 visible, err := preflightBlob(r, totals, blobs, false, budget)
@@ -235,7 +238,7 @@ func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, hist
235238 if n == 0 || uint64(n) > uint64(len(r.b)) || uint64(n) > maxSnapshotBytes {
236239 return vt.DecodeStats{}, fmt.Errorf("%w: VT blob length", ErrInvalidData)
237240 }
238- if *blobs >= maxSnapshotBlobs {
241+ if *blobs >= maxSnapshotObjects {
239242 return vt.DecodeStats{}, fmt.Errorf("%w: too many VT blobs", ErrInvalidData)
240243 }
241244 blob := r.b[:n]
M internal/usecase/snapshot/preflight_acceptance_test.go +1 -1
@@ -33,7 +33,7 @@ func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T
3333 },
3434 } {
3535 t.Run(tt.name, func(t *testing.T) {
36- encoded, err := Marshal(Session{Name: "hostile", Tabs: []Tab{tt.tab}})
36+ encoded, err := marshalTest(Session{Name: "hostile", Tabs: []Tab{tt.tab}})
3737 if err != nil {
3838 t.Fatalf("Marshal: %v", err)
3939 }
M pkg/vt/benchmark_test.go +25 -5
@@ -8,9 +8,10 @@ import (
88 )
99
1010 var (
11- benchmarkHistoryViewSink HistoryView
12- benchmarkHistoryBlobSink []byte
13- benchmarkHistoryBlobsSink [][]byte
11+ benchmarkHistoryViewSink HistoryView
12+ benchmarkHistorySnapshotSink HistorySnapshotView
13+ benchmarkHistoryBlobSink []byte
14+ benchmarkHistoryBlobsSink [][]byte
1415 )
1516
1617 // TestHistoryViewPartialTailAllocationBounded guards the capture property used
@@ -75,6 +76,25 @@ func BenchmarkHistoryView10KRowsPartialTail(b *testing.B) {
7576 b.ReportMetric(float64(10_000%256), "tailrows/view")
7677 }
7778
79+func BenchmarkHistorySnapshotView10KRowsPartialTail(b *testing.B) {
80+ history := benchmarkHistory(b, 10_000, 120, 256)
81+ if got, want := len(history.tail), 16; got != want {
82+ b.Fatalf("partial tail rows = %d, want %d", got, want)
83+ }
84+ sealed := history.View().Chunk(0)
85+
86+ b.ReportAllocs()
87+ b.ResetTimer()
88+ for b.Loop() {
89+ benchmarkHistorySnapshotSink = history.SnapshotView()
90+ }
91+ b.StopTimer()
92+ if got := benchmarkHistorySnapshotSink; got.Chunk(0) != sealed || got.Tail().Len() != 16 {
93+ b.Fatal("snapshot did not preserve chunk identity and tail rows")
94+ }
95+ b.ReportMetric(float64(benchmarkHistorySnapshotSink.ChunkCount()), "sealedchunks/view")
96+}
97+
7898 func BenchmarkMarshalHistoryChunk256x120(b *testing.B) {
7999 history := benchmarkHistory(b, 256, 120, 256)
80100 chunk := history.SealAndView().Chunk(0)
@@ -155,7 +175,7 @@ func benchmarkHistory(t testing.TB, rows, width, chunkRows int) *History {
155175 for col := range cells {
156176 cells[col] = renderer.Cell{Rune: rune('a' + (row+col)%26)}
157177 }
158- history.Append(cells)
178+ requireHistoryAppend(t, history, cells)
159179 }
160180 return history
161181 }
@@ -173,7 +193,7 @@ func benchmarkScreenResizeReflow(b *testing.B, historyRows int) {
173193 s := NewScreenWithHistory(120, 40, HistoryConfig{MaxRows: historyRows + 500})
174194 if historyRows > 0 {
175195 for range historyRows {
176- s.history.Append(make([]renderer.Cell, 120))
196+ requireHistoryAppend(b, s.history, make([]renderer.Cell, 120))
177197 }
178198 }
179199 s.Write(bytes.Repeat([]byte("x"), 120*39))
M pkg/vt/history.go +165 -17
@@ -1,10 +1,20 @@
11 package vt
22
3-import "github.com/bnema/vev/pkg/renderer"
3+import (
4+ "errors"
5+ "math"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+// ErrHistoryRowTooWide is returned when a row cannot fit within the configured
11+// cell budget. The history is not modified when this error is returned.
12+var ErrHistoryRowTooWide = errors.New("history row exceeds cell capacity")
413
514 // HistoryConfig controls the bounded terminal history retained by a Screen.
615 type HistoryConfig struct {
716 MaxRows int
17+ MaxCells int
818 ChunkRows int
919 }
1020
@@ -13,10 +23,12 @@ type HistoryConfig struct {
1323 // appends.
1424 type History struct {
1525 maxRows int
26+ maxCells int
1627 chunkRows int
1728 chunks []*HistoryChunk
1829 tail [][]renderer.Cell
1930 rows int
31+ cells int
2032 }
2133
2234 // HistoryChunk is an immutable group of history rows. Its identity is stable
@@ -30,6 +42,17 @@ type HistoryChunk struct {
3042 type HistoryView struct {
3143 chunks []*HistoryChunk
3244 rows int
45+ cells int
46+}
47+
48+// HistorySnapshotView captures sealed history chunks and the mutable tail
49+// independently. Sealed chunks are shared by identity; Tail is owned by the
50+// view and can be serialized without rotating the live tail into a chunk.
51+type HistorySnapshotView struct {
52+ chunks []*HistoryChunk
53+ tail [][]renderer.Cell
54+ rows int
55+ cells int
3356 }
3457
3558 func NewHistory(config HistoryConfig) *History {
@@ -42,20 +65,39 @@ func NewHistory(config HistoryConfig) *History {
4265 }
4366 chunkRows = min(chunkRows, 256)
4467 chunkRows = min(chunkRows, config.MaxRows)
45- return &History{maxRows: config.MaxRows, chunkRows: chunkRows}
68+ maxCells := config.MaxCells
69+ if maxCells <= 0 {
70+ maxCells = defaultHistoryMaxCells(config.MaxRows)
71+ }
72+ return &History{maxRows: config.MaxRows, maxCells: maxCells, chunkRows: chunkRows}
73+}
74+
75+func defaultHistoryMaxCells(maxRows int) int {
76+ if maxRows > math.MaxInt/160 {
77+ return math.MaxInt
78+ }
79+ return maxRows * 160
4680 }
4781
4882 // Append records a copy of row. Once a chunk is full it is sealed forever.
49-func (h *History) Append(row []renderer.Cell) {
83+// Rows wider than the total cell capacity are rejected without mutation.
84+func (h *History) Append(row []renderer.Cell) error {
5085 if h == nil || h.maxRows == 0 {
51- return
86+ return nil
5287 }
88+ if len(row) > h.maxCells {
89+ return ErrHistoryRowTooWide
90+ }
91+ // Make room before adding so Cells cannot overflow even when the default
92+ // capacity is MaxInt.
93+ h.evictFor(len(row))
5394 h.tail = append(h.tail, append([]renderer.Cell(nil), row...))
5495 h.rows++
96+ h.cells += len(row)
5597 if len(h.tail) == h.chunkRows {
5698 h.sealTail()
5799 }
58- h.evict()
100+ return nil
59101 }
60102
61103 func (h *History) sealTail() {
@@ -66,13 +108,53 @@ func (h *History) sealTail() {
66108 h.tail = nil
67109 }
68110
111+// normalizeTail seals complete chunks so the mutable tail remains shorter than
112+// chunkRows. Restored snapshots may have been written with a different chunk
113+// size than the current history configuration.
114+func (h *History) normalizeTail() {
115+ for h.chunkRows > 0 && len(h.tail) >= h.chunkRows {
116+ h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail[:h.chunkRows]})
117+ h.tail = h.tail[h.chunkRows:]
118+ }
119+}
120+
69121 func (h *History) evict() {
70- for h.rows > h.maxRows && len(h.chunks) > 0 {
71- evicted := h.chunks[0]
72- h.rows -= len(evicted.rows)
73- copy(h.chunks, h.chunks[1:])
74- h.chunks[len(h.chunks)-1] = nil
75- h.chunks = h.chunks[:len(h.chunks)-1]
122+ h.evictUntil(0, 0)
123+}
124+
125+// evictFor discards oldest rows until another row using cellCount cells can fit.
126+func (h *History) evictFor(cellCount int) {
127+ h.evictUntil(1, cellCount)
128+}
129+
130+// evictUntil makes room for rowCount rows and cellCount cells without
131+// overflowing either accounting total.
132+func (h *History) evictUntil(rowCount, cellCount int) {
133+ for h.rows > h.maxRows-rowCount || h.cells > h.maxCells-cellCount {
134+ if len(h.chunks) > 0 {
135+ chunk := h.chunks[0]
136+ row := chunk.rows[0]
137+ h.rows--
138+ h.cells -= len(row)
139+ if len(chunk.rows) == 1 {
140+ copy(h.chunks, h.chunks[1:])
141+ h.chunks[len(h.chunks)-1] = nil
142+ h.chunks = h.chunks[:len(h.chunks)-1]
143+ } else {
144+ // Preserve cell storage while replacing only the chunk wrapper: a
145+ // retained view may still refer to the original immutable chunk.
146+ h.chunks[0] = &HistoryChunk{rows: chunk.rows[1:]}
147+ }
148+ continue
149+ }
150+ if len(h.tail) == 0 {
151+ return
152+ }
153+ row := h.tail[0]
154+ h.rows--
155+ h.cells -= len(row)
156+ h.tail[0] = nil
157+ h.tail = h.tail[1:]
76158 }
77159 }
78160
@@ -93,13 +175,34 @@ func (h *History) View() HistoryView {
93175 }
94176 chunks := append([]*HistoryChunk(nil), h.chunks...)
95177 if len(h.tail) > 0 {
96- rows := make([][]renderer.Cell, len(h.tail))
97- for i, row := range h.tail {
98- rows[i] = append([]renderer.Cell(nil), row...)
99- }
100- chunks = append(chunks, &HistoryChunk{rows: rows})
178+ chunks = append(chunks, &HistoryChunk{rows: cloneHistoryRows(h.tail)})
179+ }
180+ return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells}
181+}
182+
183+// SnapshotView captures history for persistence without sealing the mutable
184+// tail. Sealed chunks are shared by identity and the tail is deeply copied.
185+func (h *History) SnapshotView() HistorySnapshotView {
186+ if h == nil || h.rows == 0 {
187+ return HistorySnapshotView{}
188+ }
189+ return HistorySnapshotView{
190+ chunks: append([]*HistoryChunk(nil), h.chunks...),
191+ tail: cloneHistoryRows(h.tail),
192+ rows: h.rows,
193+ cells: h.cells,
101194 }
102- return HistoryView{chunks: chunks, rows: h.rows}
195+}
196+
197+func cloneHistoryRows(rows [][]renderer.Cell) [][]renderer.Cell {
198+ if len(rows) == 0 {
199+ return nil
200+ }
201+ copyRows := make([][]renderer.Cell, len(rows))
202+ for i, row := range rows {
203+ copyRows[i] = append([]renderer.Cell(nil), row...)
204+ }
205+ return copyRows
103206 }
104207
105208 // Len returns the currently retained history row count.
@@ -110,6 +213,14 @@ func (h *History) Len() int {
110213 return h.rows
111214 }
112215
216+// Cells returns the currently retained history cell count.
217+func (h *History) Cells() int {
218+ if h == nil {
219+ return 0
220+ }
221+ return h.cells
222+}
223+
113224 // Cap returns the configured bounded row capacity.
114225 func (h *History) Cap() int {
115226 if h == nil {
@@ -118,7 +229,16 @@ func (h *History) Cap() int {
118229 return h.maxRows
119230 }
120231
232+// CellCap returns the configured bounded cell capacity.
233+func (h *History) CellCap() int {
234+ if h == nil {
235+ return 0
236+ }
237+ return h.maxCells
238+}
239+
121240 func (v HistoryView) Len() int { return v.rows }
241+func (v HistoryView) Cells() int { return v.cells }
122242 func (v HistoryView) ChunkCount() int { return len(v.chunks) }
123243
124244 // Chunk returns the immutable chunk at i, or nil when i is out of range.
@@ -161,3 +281,31 @@ func (v HistoryView) BorrowedRow(i int) []renderer.Cell {
161281 }
162282 return nil
163283 }
284+
285+func (v HistorySnapshotView) Len() int { return v.rows }
286+func (v HistorySnapshotView) Cells() int { return v.cells }
287+func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) }
288+
289+// Chunk returns a sealed immutable chunk at i, or nil when i is out of range.
290+func (v HistorySnapshotView) Chunk(i int) *HistoryChunk {
291+ if i < 0 || i >= len(v.chunks) {
292+ return nil
293+ }
294+ return v.chunks[i]
295+}
296+
297+// Tail returns an immutable view of the copied mutable tail.
298+func (v HistorySnapshotView) Tail() HistoryView {
299+ if len(v.tail) == 0 {
300+ return HistoryView{}
301+ }
302+ return HistoryView{chunks: []*HistoryChunk{{rows: v.tail}}, rows: len(v.tail), cells: historyRowsCells(v.tail)}
303+}
304+
305+func historyRowsCells(rows [][]renderer.Cell) int {
306+ cells := 0
307+ for _, row := range rows {
308+ cells += len(row)
309+ }
310+ return cells
311+}
M pkg/vt/history_acceptance_test.go +1 -1
@@ -62,7 +62,7 @@ func TestSnapshotCodecsPreserveEveryTerminalCellAcrossSealedTailAndVisible(t *te
6262 makeRow('D', rgb), makeRow('E', indexed), makeRow('F', rgb), makeRow('G', indexed),
6363 }
6464 for _, row := range want {
65- history.Append(row)
65+ requireHistoryAppend(t, history, row)
6666 }
6767 if got, wantChunks := history.View().ChunkCount(), 4; got != wantChunks {
6868 t.Fatalf("chunk count = %d, want three sealed chunks plus copied tail %d", got, wantChunks)
M pkg/vt/history_codec.go +6 -6
@@ -46,7 +46,7 @@ func MarshalHistory(view HistoryView) ([]byte, error) {
4646 for _, row := range chunk.rows {
4747 cellCount := uint64(len(row))
4848 cellBytes, ok := historyCellByteCount(cellCount)
49- if cellCount > maxHistoryRowCells || !ok ||
49+ if !ok ||
5050 !addHistoryDecodeBudget(&stats.cells, cellCount, maxHistoryCells) ||
5151 !addHistoryDecodeBudget(&stats.styles, cellCount, maxHistoryDecodeStyles) ||
5252 !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) {
@@ -193,11 +193,11 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
193193 for range rowCount {
194194 cellCount, ok := p.uint32()
195195 cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount))
196- if !ok || uint64(cellCount) > maxHistoryRowCells || !validCellBytes ||
197- uint64(cellCount) > uint64(len(p.data))/historyCellBytes ||
196+ if !ok || !validCellBytes ||
198197 !addHistoryDecodeBudget(&stats.cells, uint64(cellCount), maxHistoryCells) ||
199198 !addHistoryDecodeBudget(&stats.styles, uint64(cellCount), maxHistoryDecodeStyles) ||
200- !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) {
199+ !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) ||
200+ uint64(cellCount) > uint64(len(p.data))/historyCellBytes {
201201 return HistoryView{}, historyDecodeStats{}, false
202202 }
203203
@@ -205,7 +205,7 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
205205 if populate {
206206 row = make([]renderer.Cell, cellCount)
207207 }
208- for i := uint32(0); i < cellCount; i++ {
208+ for i := range cellCount {
209209 cell, ok := p.cell()
210210 if !ok || !validHistoryCell(cell) {
211211 return HistoryView{}, historyDecodeStats{}, false
@@ -225,7 +225,7 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
225225 if len(p.data) != 0 {
226226 return HistoryView{}, historyDecodeStats{}, false
227227 }
228- return HistoryView{chunks: chunks, rows: int(stats.rows)}, stats, true
228+ return HistoryView{chunks: chunks, rows: int(stats.rows), cells: int(stats.cells)}, stats, true
229229 }
230230
231231 func historyCellByteCount(cellCount uint64) (uint64, bool) {
M pkg/vt/history_codec_test.go +54 -12
@@ -2,6 +2,8 @@ package vt
22
33 import (
44 "encoding/binary"
5+ "fmt"
6+ "math"
57 "testing"
68
79 "github.com/bnema/vev/pkg/renderer"
@@ -55,7 +57,7 @@ func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) {
5557 t.Run(tt.name, func(t *testing.T) {
5658 history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2})
5759 for _, row := range tt.rows {
58- history.Append(row)
60+ requireHistoryAppend(t, history, row)
5961 }
6062
6163 encoded, err := MarshalHistory(history.View())
@@ -73,8 +75,8 @@ func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) {
7375
7476 func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) {
7577 history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 2})
76- history.Append(historyRow("AAAA"))
77- history.Append(historyRow("BBBB"))
78+ requireHistoryAppend(t, history, historyRow("AAAA"))
79+ requireHistoryAppend(t, history, historyRow("BBBB"))
7880 encoded, err := MarshalHistory(history.View())
7981 if err != nil {
8082 t.Fatalf("marshal history: %v", err)
@@ -123,10 +125,7 @@ func TestChunkCodecSupportsRepresentativeHistory(t *testing.T) {
123125 }
124126
125127 func TestChunkCodecRejectsDimensionsBeyondSupportedLimits(t *testing.T) {
126- const (
127- supportedRows = 12_000
128- supportedWidth = 160
129- )
128+ const supportedRows = 12_000
130129 tests := []struct {
131130 name string
132131 view HistoryView
@@ -137,11 +136,6 @@ func TestChunkCodecRejectsDimensionsBeyondSupportedLimits(t *testing.T) {
137136 view: historyViewWithDimensions(supportedRows+1, 0),
138137 data: historyPayloadWithDimensions(supportedRows+1, 0),
139138 },
140- {
141- name: "too many cells in a row",
142- view: historyViewWithDimensions(1, supportedWidth+1),
143- data: historyPayloadWithDimensions(1, supportedWidth+1),
144- },
145139 }
146140
147141 for _, tt := range tests {
@@ -156,6 +150,41 @@ func TestChunkCodecRejectsDimensionsBeyondSupportedLimits(t *testing.T) {
156150 }
157151 }
158152
153+func TestChunkCodecRejectsAggregateCellDeclarationBeforePayloadAllocation(t *testing.T) {
154+ data := aggregateCellLimitDeclaration()
155+ if len(data) >= 1024 {
156+ t.Fatalf("aggregate declaration length = %d, want compact fixture", len(data))
157+ }
158+ if _, err := PreflightHistoryBlob(data); err == nil {
159+ t.Fatal("preflight accepted aggregate cell declaration")
160+ }
161+ if _, err := UnmarshalHistory(data); err == nil {
162+ t.Fatal("unmarshal accepted aggregate cell declaration")
163+ }
164+}
165+
166+func TestChunkCodecAcceptsWideRowsWithinAggregateBudget(t *testing.T) {
167+ for _, width := range []int{161, 293, math.MaxUint16} {
168+ t.Run(fmt.Sprintf("width_%d", width), func(t *testing.T) {
169+ view := historyViewWithDimensions(1, width)
170+ encoded, err := MarshalHistory(view)
171+ if err != nil {
172+ t.Fatalf("marshal width %d: %v", width, err)
173+ }
174+ decoded, err := UnmarshalHistory(encoded)
175+ if err != nil {
176+ t.Fatalf("unmarshal width %d: %v", width, err)
177+ }
178+ if got := len(decoded.Row(0)); got != width {
179+ t.Fatalf("decoded width = %d, want %d", got, width)
180+ }
181+ if stats, err := PreflightHistoryBlob(encoded); err != nil || stats.Cells != uint64(width) {
182+ t.Fatalf("preflight stats = %+v, err = %v", stats, err)
183+ }
184+ })
185+ }
186+}
187+
159188 func TestChunkCodecRejectsHostileChunkAndRowDeclarations(t *testing.T) {
160189 tests := []struct {
161190 name string
@@ -213,6 +242,18 @@ func historyPayloadWithDimensions(rowCount, width int) []byte {
213242 return data
214243 }
215244
245+// aggregateCellLimitDeclaration declares one row, which is within the row
246+// budget, but its cells exceed the aggregate budget. It deliberately omits
247+// cell payload bytes: the aggregate limit must reject it before allocation.
248+func aggregateCellLimitDeclaration() []byte {
249+ data := make([]byte, 9, 21)
250+ copy(data, historyMagic)
251+ data[4] = historyVersion
252+ binary.BigEndian.PutUint32(data[5:], 1)
253+ data = binary.BigEndian.AppendUint32(data, 1)
254+ return binary.BigEndian.AppendUint32(data, uint32(maxHistoryCells+1))
255+}
256+
216257 func hostileHistoryDeclarations(chunkCount, rowCount int) []byte {
217258 data := make([]byte, 9, 9+chunkCount*(4+rowCount*4))
218259 copy(data, historyMagic)
@@ -248,6 +289,7 @@ func TestHistoryPreflightMatchesUnmarshalMalformedInput(t *testing.T) {
248289 {name: "invalid rune", data: invalidRune},
249290 {name: "invalid underline style", data: invalidUnderlineStyle},
250291 {name: "aggregate row budget", data: hostileHistoryDeclarations(47, maxHistoryChunkRows)},
292+ {name: "aggregate cell budget", data: aggregateCellLimitDeclaration()},
251293 {name: "zero row count", data: []byte{'V', 'T', 'H', '1', historyVersion, 0, 0, 0, 1, 0, 0, 0, 0}},
252294 }
253295 for _, tt := range tests {
M pkg/vt/history_test.go +107 -9
@@ -1,11 +1,20 @@
11 package vt
22
33 import (
4+ "errors"
5+ "math"
46 "testing"
57
68 "github.com/bnema/vev/pkg/renderer"
79 )
810
11+func requireHistoryAppend(t testing.TB, history *History, row []renderer.Cell) {
12+ t.Helper()
13+ if err := history.Append(row); err != nil {
14+ t.Fatalf("append history row: %v", err)
15+ }
16+}
17+
918 func TestHistoryOrdersSealedChunksAndEvictsAtCapacity(t *testing.T) {
1019 tests := []struct {
1120 name string
@@ -22,11 +31,11 @@ func TestHistoryOrdersSealedChunksAndEvictsAtCapacity(t *testing.T) {
2231 want: []string{"cccc", "dddd", "eeee", "ffff"},
2332 },
2433 {
25- name: "nonmultiple capacity evicts a whole oldest immutable chunk",
34+ name: "nonmultiple capacity evicts exactly the oldest row",
2635 maxRows: 5,
2736 chunkRows: 2,
2837 rows: []string{"0000", "1111", "2222", "3333", "4444", "5555"},
29- want: []string{"2222", "3333", "4444", "5555"},
38+ want: []string{"1111", "2222", "3333", "4444", "5555"},
3039 },
3140 {
3241 name: "rows preserve append order across chunk boundaries",
@@ -41,7 +50,7 @@ func TestHistoryOrdersSealedChunksAndEvictsAtCapacity(t *testing.T) {
4150 t.Run(tt.name, func(t *testing.T) {
4251 history := NewHistory(HistoryConfig{MaxRows: tt.maxRows, ChunkRows: tt.chunkRows})
4352 for _, text := range tt.rows {
44- history.Append(historyRow(text))
53+ requireHistoryAppend(t, history, historyRow(text))
4554 }
4655
4756 view := history.View()
@@ -70,15 +79,15 @@ func TestHistoryTailRotationAndStableViews(t *testing.T) {
7079 t.Run(tt.name, func(t *testing.T) {
7180 history := NewHistory(HistoryConfig{MaxRows: 6, ChunkRows: 2})
7281 first := historyRow("AAAA")
73- history.Append(first)
74- history.Append(historyRow("BBBB"))
82+ requireHistoryAppend(t, history, first)
83+ requireHistoryAppend(t, history, historyRow("BBBB"))
7584 view := history.View()
7685 if got, want := view.ChunkCount(), 1; got != want {
7786 t.Fatalf("sealed chunk count = %d, want %d", got, want)
7887 }
7988
8089 first[0].Rune = 'X'
81- history.Append(historyRow("CCCC"))
90+ requireHistoryAppend(t, history, historyRow("CCCC"))
8291
8392 if got, want := historyViewTexts(view), []string{"AAAA", "BBBB"}; !equalStrings(got, want) {
8493 t.Fatalf("stable view rows = %#v, want %#v", got, want)
@@ -100,11 +109,11 @@ func TestHistoryReusesSealedChunkIdentityAcrossViews(t *testing.T) {
100109 for _, tt := range tests {
101110 t.Run(tt.name, func(t *testing.T) {
102111 history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2})
103- history.Append(historyRow("AAAA"))
104- history.Append(historyRow("BBBB"))
112+ requireHistoryAppend(t, history, historyRow("AAAA"))
113+ requireHistoryAppend(t, history, historyRow("BBBB"))
105114 before := history.View()
106115
107- history.Append(historyRow("CCCC"))
116+ requireHistoryAppend(t, history, historyRow("CCCC"))
108117 after := history.View()
109118
110119 if before.ChunkCount() == 0 || after.ChunkCount() == 0 {
@@ -192,6 +201,95 @@ func TestHistoryRecordsOnlyTopEdgeScrollEvictions(t *testing.T) {
192201 }
193202 }
194203
204+func TestHistoryBoundsRowsAndCellsWithExactRowEviction(t *testing.T) {
205+ history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 5, ChunkRows: 2})
206+ for _, text := range []string{"aa", "bbb", "c", "dd"} {
207+ if err := history.Append(historyRow(text)); err != nil {
208+ t.Fatalf("append %q: %v", text, err)
209+ }
210+ }
211+
212+ view := history.View()
213+ if got, want := historyViewTexts(view), []string{"c", "dd"}; !equalStrings(got, want) {
214+ t.Fatalf("retained rows = %#v, want %#v", got, want)
215+ }
216+ if got, want := history.Len(), 2; got != want {
217+ t.Fatalf("retained rows = %d, want %d", got, want)
218+ }
219+ if got, want := history.Cells(), 3; got != want {
220+ t.Fatalf("retained cells = %d, want %d", got, want)
221+ }
222+ if got, want := view.Cells(), 3; got != want {
223+ t.Fatalf("view cells = %d, want %d", got, want)
224+ }
225+}
226+
227+func TestHistoryAppendIsNoOpForNilAndZeroValue(t *testing.T) {
228+ tests := []struct {
229+ name string
230+ history *History
231+ }{
232+ {name: "nil history"},
233+ {name: "zero-value history", history: &History{}},
234+ }
235+
236+ for _, tt := range tests {
237+ t.Run(tt.name, func(t *testing.T) {
238+ if err := tt.history.Append(historyRow("row")); err != nil {
239+ t.Fatalf("append error = %v, want nil", err)
240+ }
241+ if got := tt.history.Len(); got != 0 {
242+ t.Fatalf("retained rows = %d, want 0", got)
243+ }
244+ })
245+ }
246+}
247+
248+func TestHistoryRejectsRowWiderThanCellBudgetWithoutMutation(t *testing.T) {
249+ history := NewHistory(HistoryConfig{MaxRows: 2, MaxCells: 2, ChunkRows: 2})
250+ if err := history.Append(historyRow("ok")); err != nil {
251+ t.Fatalf("append retained row: %v", err)
252+ }
253+ before := history.View()
254+
255+ err := history.Append(historyRow("wide"))
256+ if !errors.Is(err, ErrHistoryRowTooWide) {
257+ t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooWide", err)
258+ }
259+ if got, want := historyViewTexts(history.View()), historyViewTexts(before); !equalStrings(got, want) {
260+ t.Fatalf("history mutated after rejected append: got %#v, want %#v", got, want)
261+ }
262+ if got, want := history.Cells(), 2; got != want {
263+ t.Fatalf("retained cells after rejected append = %d, want %d", got, want)
264+ }
265+}
266+
267+func TestScreenDropsOversizedHistoryRowsWithoutInterruptingScroll(t *testing.T) {
268+ screen := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 2, MaxCells: 3})
269+ var events []string
270+ screen.OnLineEvicted = func(row []renderer.Cell) { events = append(events, rowText(row)) }
271+ copy(screen.Frame.Row(0), historyRow("AAAA"))
272+ copy(screen.Frame.Row(1), historyRow("BBBB"))
273+ screen.scrollUpRegion(0, 1, 1)
274+
275+ if got := screen.History().Len(); got != 0 {
276+ t.Fatalf("oversized scrollback rows = %d, want 0", got)
277+ }
278+ if got, want := events, []string{"AAAA"}; !equalStrings(got, want) {
279+ t.Fatalf("scroll callbacks = %#v, want %#v", got, want)
280+ }
281+ if got := lineText(screen, 0); got != "BBBB" {
282+ t.Fatalf("screen did not scroll after history drop: row 0 = %q", got)
283+ }
284+}
285+
286+func TestHistoryDefaultCellBudgetDoesNotOverflow(t *testing.T) {
287+ history := NewHistory(HistoryConfig{MaxRows: math.MaxInt, ChunkRows: 1})
288+ if got, want := history.CellCap(), math.MaxInt; got != want {
289+ t.Fatalf("default cell cap = %d, want %d", got, want)
290+ }
291+}
292+
195293 func historyRow(text string) []renderer.Cell {
196294 row := make([]renderer.Cell, len(text))
197295 for i, r := range text {
A pkg/vt/primary_visible_snapshot_test.go +29 -0
@@ -0,0 +1,29 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestPrimaryVisibleSnapshotOwnsSavedPrimaryAcrossLiveMutations(t *testing.T) {
10+ s := NewScreen(8, 2)
11+ s.Write([]byte("primary"))
12+ s.Write([]byte("\x1b[?1049h"))
13+
14+ captured := s.PrimaryVisibleSnapshot()
15+ mutated := make(chan struct{})
16+ go func() {
17+ s.Write([]byte("alternate"))
18+ s.Resize(3, 3)
19+ s.Write([]byte("\x1b[?1049lmutated"))
20+ close(mutated)
21+ }()
22+
23+ blob, err := captured.Marshal()
24+ <-mutated
25+ require.NoError(t, err)
26+ restored := NewScreen(1, 1)
27+ require.NoError(t, restored.RestorePrimaryVisible(blob))
28+ require.Equal(t, "primary ", rowString(restored.Frame.Row(0)))
29+}
M pkg/vt/screen_scroll.go +11 -2
@@ -1,6 +1,10 @@
11 package vt
22
3-import "github.com/bnema/vev/pkg/renderer"
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
48
59 func (s *Screen) index() {
610 if s.Frame.Height == 0 {
@@ -88,7 +92,12 @@ func (s *Screen) emitLineEvicted(top, n int) {
8892
8993 func (s *Screen) recordEvicted(row []renderer.Cell) {
9094 if s.history != nil {
91- s.history.Append(row)
95+ err := s.history.Append(row)
96+ if err != nil && !errors.Is(err, ErrHistoryRowTooWide) {
97+ panic(err)
98+ }
99+ // A nil error records the row; ErrHistoryRowTooWide explicitly leaves it
100+ // unrecorded. Both cases continue to the eviction observer below.
92101 }
93102 if s.OnLineEvicted != nil {
94103 s.OnLineEvicted(append([]renderer.Cell(nil), row...))
M pkg/vt/snapshot.go +46 -5
@@ -54,6 +54,12 @@ func MarshalHistoryChunk(chunk *HistoryChunk) ([]byte, error) {
5454 // MarshalEmptyHistoryTail returns the mandatory canonical empty tail blob.
5555 func MarshalEmptyHistoryTail() ([]byte, error) { return MarshalHistory(HistoryView{}) }
5656
57+// MarshalHistoryTail encodes the copied mutable tail of a snapshot view as one
58+// canonical history blob. It does not seal or otherwise mutate live history.
59+func MarshalHistoryTail(view HistorySnapshotView) ([]byte, error) {
60+ return MarshalHistory(view.Tail())
61+}
62+
5763 // MarshalSealedHistory serializes a SealAndView result as oldest-first,
5864 // self-contained sealed blobs plus a mandatory empty canonical tail blob.
5965 func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) {
@@ -84,18 +90,23 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist
8490 if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 {
8591 return nil, fmt.Errorf("restore sealed history: %w", errInvalidHistory)
8692 }
93+ h.evictUntil(len(view.chunks[0].rows), view.Cells())
8794 h.chunks = append(h.chunks, view.chunks[0])
8895 h.rows += len(view.chunks[0].rows)
96+ h.cells += view.Cells()
8997 }
9098 view, err := UnmarshalHistory(tail)
9199 if err != nil || len(view.chunks) > 1 {
92100 return nil, fmt.Errorf("restore history tail: %w", errInvalidHistory)
93101 }
94102 if len(view.chunks) == 1 {
103+ h.evictUntil(len(view.chunks[0].rows), view.Cells())
95104 h.tail = view.chunks[0].rows
96105 h.rows += len(h.tail)
106+ h.cells += view.Cells()
97107 }
98108 h.evict()
109+ h.normalizeTail()
99110 return h, nil
100111 }
101112
@@ -130,17 +141,47 @@ func MarshalVisible(frame renderer.Frame) ([]byte, error) {
130141 return marshalVisible(frame, boundaries)
131142 }
132143
133-// MarshalPrimaryVisible preserves the primary buffer's logical-line boundaries
134-// alongside its cells so a restored viewport can subsequently reflow.
135-func (s *Screen) MarshalPrimaryVisible() ([]byte, error) {
144+// PrimaryVisibleSnapshot is an owned copy of the primary buffer's visible
145+// state. Capture it while the screen owner is synchronized, then Marshal it
146+// after releasing that lock.
147+type PrimaryVisibleSnapshot struct {
148+ frame renderer.Frame
149+ boundaries []lineBoundary
150+}
151+
152+// PrimaryVisibleSnapshot copies the saved primary frame and its logical-line
153+// boundaries. When the alternate screen is active, the saved primary buffer is
154+// copied rather than the currently displayed alternate buffer.
155+func (s *Screen) PrimaryVisibleSnapshot() PrimaryVisibleSnapshot {
136156 b := s.buffer
137157 if s.alternate != nil {
138158 b = s.alternate.buffer
139159 }
140160 if b == nil {
141- return MarshalVisible(s.PrimaryVisibleFrame())
161+ return PrimaryVisibleSnapshot{frame: s.PrimaryVisibleFrame()}
162+ }
163+ return PrimaryVisibleSnapshot{
164+ frame: b.frame.Clone(),
165+ boundaries: append([]lineBoundary(nil), b.boundaries...),
142166 }
143- return marshalVisible(b.frame, b.boundaries)
167+}
168+
169+// Marshal encodes an owned primary visible snapshot without accessing live VT
170+// state.
171+func (v PrimaryVisibleSnapshot) Marshal() ([]byte, error) {
172+ if len(v.boundaries) == 0 && v.frame.Height > 0 {
173+ return MarshalVisible(v.frame)
174+ }
175+ return marshalVisible(v.frame, v.boundaries)
176+}
177+
178+// MarshalPrimaryVisible preserves the primary buffer's logical-line boundaries
179+// alongside its cells so a restored viewport can subsequently reflow.
180+//
181+// Deprecated: callers holding a pane lock should use PrimaryVisibleSnapshot
182+// and marshal the result after unlocking.
183+func (s *Screen) MarshalPrimaryVisible() ([]byte, error) {
184+ return s.PrimaryVisibleSnapshot().Marshal()
144185 }
145186
146187 func marshalVisible(frame renderer.Frame, boundaries []lineBoundary) ([]byte, error) {
M pkg/vt/snapshot_test.go +79 -0
@@ -4,9 +4,88 @@ import (
44 "strings"
55 "testing"
66
7+ "github.com/bnema/vev/pkg/renderer"
78 "github.com/stretchr/testify/require"
89 )
910
11+func TestHistorySnapshotViewDoesNotSealTailAndCopiesIt(t *testing.T) {
12+ history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2})
13+ if err := history.Append(historyRow("AAAA")); err != nil {
14+ t.Fatal(err)
15+ }
16+ if err := history.Append(historyRow("BBBB")); err != nil {
17+ t.Fatal(err)
18+ }
19+ if err := history.Append(historyRow("CCCC")); err != nil {
20+ t.Fatal(err)
21+ }
22+ sealed := history.View().Chunk(0)
23+
24+ view := history.SnapshotView()
25+ if got, want := view.ChunkCount(), 1; got != want {
26+ t.Fatalf("sealed chunks = %d, want %d", got, want)
27+ }
28+ if view.Chunk(0) != sealed {
29+ t.Fatal("snapshot copied an immutable sealed chunk")
30+ }
31+ if got, want := len(history.tail), 1; got != want {
32+ t.Fatalf("live tail rows = %d, want %d; snapshot sealed it", got, want)
33+ }
34+ history.tail[0][0].Rune = 'X'
35+ if got, want := rowText(view.Tail().Row(0)), "CCCC"; got != want {
36+ t.Fatalf("snapshot tail = %q, want %q", got, want)
37+ }
38+ if got, want := view.Len(), 3; got != want {
39+ t.Fatalf("snapshot rows = %d, want %d", got, want)
40+ }
41+ if got, want := view.Cells(), 12; got != want {
42+ t.Fatalf("snapshot cells = %d, want %d", got, want)
43+ }
44+
45+ tail, err := MarshalHistoryTail(view)
46+ require.NoError(t, err)
47+ decoded, err := UnmarshalHistory(tail)
48+ require.NoError(t, err)
49+ require.Equal(t, []string{"CCCC"}, historyViewTexts(decoded))
50+}
51+
52+func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) {
53+ fullTail, err := MarshalHistory(HistoryView{
54+ chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("AAAA"), historyRow("BBBB")}}},
55+ rows: 2,
56+ })
57+ require.NoError(t, err)
58+
59+ restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 4, ChunkRows: 2}, nil, fullTail)
60+ require.NoError(t, err)
61+ require.NoError(t, restored.Append(historyRow("CCCC")))
62+
63+ require.Equal(t, []string{"AAAA", "BBBB", "CCCC"}, historyViewTexts(restored.View()))
64+ require.Len(t, restored.chunks, 1, "a full restored tail must become sealed")
65+ require.Len(t, restored.tail, 1, "the appended row must be the mutable tail")
66+}
67+
68+func TestHistoryFromBlobsRestoresCellAccountingAndEvictsToBothBudgets(t *testing.T) {
69+ history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 10, ChunkRows: 2})
70+ for _, text := range []string{"a", "b", "cc"} {
71+ require.NoError(t, history.Append(historyRow(text)))
72+ }
73+ view := history.SnapshotView()
74+ sealed := make([][]byte, view.ChunkCount())
75+ for i := range sealed {
76+ var err error
77+ sealed[i], err = MarshalHistoryChunk(view.Chunk(i))
78+ require.NoError(t, err)
79+ }
80+ tail, err := MarshalHistoryTail(view)
81+ require.NoError(t, err)
82+
83+ restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 4, MaxCells: 2, ChunkRows: 2}, sealed, tail)
84+ require.NoError(t, err)
85+ require.Equal(t, []string{"cc"}, historyViewTexts(restored.View()))
86+ require.Equal(t, 2, restored.Cells())
87+}
88+
1089 func TestPrimaryVisibleSnapshotRetainsReflowBoundaries(t *testing.T) {
1190 s := NewScreen(5, 3)
1291 s.Write([]byte("abcdefgh"))

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 2

Tags 13

↑↓ moveenter openesc close