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
2 2
3 go 1.263 go 1.26
4 4
5-require github.com/stretchr/testify v1.11.15+require (
6+ github.com/stretchr/testify v1.11.1
7+ golang.org/x/sys v0.47.0
8+)
6 9
7 require (10 require (
8 github.com/davecgh/go-spew v1.1.1 // indirect11 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) {
450 daemonOpts = append(daemonOpts, daemon.WithConfig(cfg))450 daemonOpts = append(daemonOpts, daemon.WithConfig(cfg))
451 daemonOpts = append(daemonOpts, daemon.WithBarScriptCommandRunner(shellcmd.New()))451 daemonOpts = append(daemonOpts, daemon.WithBarScriptCommandRunner(shellcmd.New()))
452 daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome))452 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))
454 daemonOpts = append(daemonOpts, daemon.WithNoticeStore(noticefile.New(platform.StateDir())))455 daemonOpts = append(daemonOpts, daemon.WithNoticeStore(noticefile.New(platform.StateDir())))
455 storePath := persist.StorePath(platform.StateDir())456 storePath := persist.StorePath(platform.StateDir())
456 var storeErr error457 var storeErr error
@@ -1185,6 +1186,27 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
1185 _ = tw.Flush()1186 _ = tw.Flush()
1186 }1187 }
1187 1188
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+
1188 // runKill asks the daemon to terminate a named session, every session, or the daemon.1210 // runKill asks the daemon to terminate a named session, every session, or the daemon.
1189 func runKill(ctx context.Context, name string, all, daemon bool) error {1211 func runKill(ctx context.Context, name string, all, daemon bool) error {
1190 transport, err := realDial(ctx, ipc.SocketDir())1212 transport, err := realDial(ctx, ipc.SocketDir())
@@ -1214,17 +1236,34 @@ func runKill(ctx context.Context, name string, all, daemon bool) error {
1214 if openErr != nil {1236 if openErr != nil {
1215 return fmt.Errorf("vev: opening stored sessions: %w", openErr)1237 return fmt.Errorf("vev: opening stored sessions: %w", openErr)
1216 }1238 }
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+ }
1217 if deleteErr := p.Delete(name); deleteErr != nil {1256 if deleteErr := p.Delete(name); deleteErr != nil {
1218 _ = p.Close()1257 _ = p.Close()
1219 return fmt.Errorf("vev: deleting stored session: %w", deleteErr)1258 return fmt.Errorf("vev: deleting stored session: %w", deleteErr)
1220 }1259 }
1260+ if tombstoneErr := snapshots.DeleteTombstone(ctx, name); tombstoneErr != nil {
1261+ _ = p.Close()
1262+ return fmt.Errorf("vev: clearing killed session marker: %w", tombstoneErr)
1263+ }
1221 if closeErr := p.Close(); closeErr != nil {1264 if closeErr := p.Close(); closeErr != nil {
1222 return fmt.Errorf("vev: closing stored sessions: %w", closeErr)1265 return fmt.Errorf("vev: closing stored sessions: %w", closeErr)
1223 }1266 }
1224- snapshots := snapshotadapter.NewStore(snapshotDir())
1225- if deleteErr := snapshots.Delete(name); deleteErr != nil {
1226- return fmt.Errorf("vev: deleting session snapshot: %w", deleteErr)
1227- }
1228 printKillSuccess(name, all, daemon)1267 printKillSuccess(name, all, daemon)
1229 return nil1268 return nil
1230 }1269 }
M internal/app/run_test.go +5 -8
@@ -352,10 +352,7 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
352 if err := p.Close(); err != nil {352 if err := p.Close(); err != nil {
353 t.Fatalf("Close error = %v", err)353 t.Fatalf("Close error = %v", err)
354 }354 }
355- snapshots := snapshot.NewStore(filepath.Join(stateRoot, "vev", "snapshots"))355+ snapshots := snapshot.NewRepository(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- }
359 356
360 got := captureStdout(t, func() {357 got := captureStdout(t, func() {
361 if err := runKill(context.Background(), "stored", false, false); err != nil {358 if err := runKill(context.Background(), "stored", false, false); err != nil {
@@ -372,12 +369,12 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
372 if len(records) != 0 {369 if len(records) != 0 {
373 t.Fatalf("records after kill = %#v, want none", records)370 t.Fatalf("records after kill = %#v, want none", records)
374 }371 }
375- blobs, err := snapshots.Load()372+ names, err := snapshots.List(context.Background())
376 if err != nil {373 if err != nil {
377- t.Fatalf("snapshot Load error = %v", err)374+ t.Fatalf("snapshot List error = %v", err)
378 }375 }
379- if len(blobs) != 0 {376+ if len(names) != 0 {
380- t.Fatalf("snapshots after kill = %#v, want none", blobs)377+ t.Fatalf("snapshots after kill = %#v, want none", names)
381 }378 }
382 }379 }
383 380
M internal/ports/mocks/store_mock.go +514 -65
@@ -5,6 +5,8 @@
5 package portsmocks5 package portsmocks
6 6
7 import (7 import (
8+ "context"
9+
8 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
9 mock "github.com/stretchr/testify/mock"11 mock "github.com/stretchr/testify/mock"
10 )12 )
@@ -334,13 +336,13 @@ func (_c *MockStore_Sync_Call) RunAndReturn(run func() error) *MockStore_Sync_Ca
334 return _c336 return _c
335 }337 }
336 338
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.
338 // The first argument is typically a *testing.T value.340 // The first argument is typically a *testing.T value.
339-func NewMockSnapshotStore(t interface {341+func NewMockSnapshotRepository(t interface {
340 mock.TestingT342 mock.TestingT
341 Cleanup(func())343 Cleanup(func())
342-}) *MockSnapshotStore {344+}) *MockSnapshotRepository {
343- mock := &MockSnapshotStore{}345+ mock := &MockSnapshotRepository{}
344 mock.Mock.Test(t)346 mock.Mock.Test(t)
345 347
346 t.Cleanup(func() { mock.AssertExpectations(t) })348 t.Cleanup(func() { mock.AssertExpectations(t) })
@@ -348,163 +350,350 @@ func NewMockSnapshotStore(t interface {
348 return mock350 return mock
349 }351 }
350 352
351-// MockSnapshotStore is an autogenerated mock type for the SnapshotStore type353+// MockSnapshotRepository is an autogenerated mock type for the SnapshotRepository type
352-type MockSnapshotStore struct {354+type MockSnapshotRepository struct {
353 mock.Mock355 mock.Mock
354 }356 }
355 357
356-type MockSnapshotStore_Expecter struct {358+type MockSnapshotRepository_Expecter struct {
357 mock *mock.Mock359 mock *mock.Mock
358 }360 }
359 361
360-func (_m *MockSnapshotStore) EXPECT() *MockSnapshotStore_Expecter {362+func (_m *MockSnapshotRepository) EXPECT() *MockSnapshotRepository_Expecter {
361- return &MockSnapshotStore_Expecter{mock: &_m.Mock}363+ return &MockSnapshotRepository_Expecter{mock: &_m.Mock}
362 }364 }
363 365
364-// Delete provides a mock function for the type MockSnapshotStore366+// Delete provides a mock function for the type MockSnapshotRepository
365-func (_mock *MockSnapshotStore) Delete(name string) error {367+func (_mock *MockSnapshotRepository) Delete(context1 context.Context, s string) error {
366- ret := _mock.Called(name)368+ ret := _mock.Called(context1, s)
367 369
368 if len(ret) == 0 {370 if len(ret) == 0 {
369 panic("no return value specified for Delete")371 panic("no return value specified for Delete")
370 }372 }
371 373
372 var r0 error374 var r0 error
373- if returnFunc, ok := ret.Get(0).(func(string) error); ok {375+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
374- r0 = returnFunc(name)376+ r0 = returnFunc(context1, s)
375 } else {377 } else {
376 r0 = ret.Error(0)378 r0 = ret.Error(0)
377 }379 }
378 return r0380 return r0
379 }381 }
380 382
381-// MockSnapshotStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'383+// MockSnapshotRepository_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
382-type MockSnapshotStore_Delete_Call struct {384+type MockSnapshotRepository_Delete_Call struct {
383 *mock.Call385 *mock.Call
384 }386 }
385 387
386 // Delete is a helper method to define mock.On call388 // Delete is a helper method to define mock.On call
387-// - name string389+// - context1 context.Context
388-func (_e *MockSnapshotStore_Expecter) Delete(name any) *MockSnapshotStore_Delete_Call {390+// - s string
389- return &MockSnapshotStore_Delete_Call{Call: _e.mock.On("Delete", name)}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)}
390 }393 }
391 394
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 {
393 _c.Call.Run(func(args mock.Arguments) {396 _c.Call.Run(func(args mock.Arguments) {
394- var arg0 string397+ var arg0 context.Context
395 if args[0] != nil {398 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)
397 }404 }
398 run(405 run(
399 arg0,406 arg0,
407+ arg1,
400 )408 )
401 })409 })
402 return _c410 return _c
403 }411 }
404 412
405-func (_c *MockSnapshotStore_Delete_Call) Return(err error) *MockSnapshotStore_Delete_Call {413+func (_c *MockSnapshotRepository_Delete_Call) Return(err error) *MockSnapshotRepository_Delete_Call {
406 _c.Call.Return(err)414 _c.Call.Return(err)
407 return _c415 return _c
408 }416 }
409 417
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 {
411 _c.Call.Return(run)419 _c.Call.Return(run)
412 return _c420 return _c
413 }421 }
414 422
415-// Load provides a mock function for the type MockSnapshotStore423+// DeleteTombstone provides a mock function for the type MockSnapshotRepository
416-func (_mock *MockSnapshotStore) Load() ([]ports.SnapshotBlob, error) {424+func (_mock *MockSnapshotRepository) DeleteTombstone(context1 context.Context, s string) error {
417- ret := _mock.Called()425+ ret := _mock.Called(context1, s)
418 426
419 if len(ret) == 0 {427 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")
421 }486 }
422 487
423- var r0 []ports.SnapshotBlob488+ var r0 []string
424 var r1 error489 var r1 error
425- if returnFunc, ok := ret.Get(0).(func() ([]ports.SnapshotBlob, error)); ok {490+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]string, error)); ok {
426- return returnFunc()491+ return returnFunc(context1)
427 }492 }
428- if returnFunc, ok := ret.Get(0).(func() []ports.SnapshotBlob); ok {493+ if returnFunc, ok := ret.Get(0).(func(context.Context) []string); ok {
429- r0 = returnFunc()494+ r0 = returnFunc(context1)
430 } else {495 } else {
431 if ret.Get(0) != nil {496 if ret.Get(0) != nil {
432- r0 = ret.Get(0).([]ports.SnapshotBlob)497+ r0 = ret.Get(0).([]string)
433 }498 }
434 }499 }
435- if returnFunc, ok := ret.Get(1).(func() error); ok {500+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
436- r1 = returnFunc()501+ r1 = returnFunc(context1)
437 } else {502 } else {
438 r1 = ret.Error(1)503 r1 = ret.Error(1)
439 }504 }
440 return r0, r1505 return r0, r1
441 }506 }
442 507
443-// MockSnapshotStore_Load_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Load'508+// MockSnapshotRepository_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
444-type MockSnapshotStore_Load_Call struct {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 {
445 *mock.Call570 *mock.Call
446 }571 }
447 572
448 // Load is a helper method to define mock.On call573 // Load is a helper method to define mock.On call
449-func (_e *MockSnapshotStore_Expecter) Load() *MockSnapshotStore_Load_Call {574+// - context1 context.Context
450- return &MockSnapshotStore_Load_Call{Call: _e.mock.On("Load")}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)}
451 }578 }
452 579
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 {
454 _c.Call.Run(func(args mock.Arguments) {581 _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+ )
456 })594 })
457 return _c595 return _c
458 }596 }
459 597
460-func (_c *MockSnapshotStore_Load_Call) Return(snapshotBlobs []ports.SnapshotBlob, err error) *MockSnapshotStore_Load_Call {598+func (_c *MockSnapshotRepository_Load_Call) Return(snapshotGeneration ports.SnapshotGeneration, err error) *MockSnapshotRepository_Load_Call {
461- _c.Call.Return(snapshotBlobs, err)599+ _c.Call.Return(snapshotGeneration, err)
462 return _c600 return _c
463 }601 }
464 602
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 {
466 _c.Call.Return(run)604 _c.Call.Return(run)
467 return _c605 return _c
468 }606 }
469 607
470-// Write provides a mock function for the type MockSnapshotStore608+// Maintain provides a mock function for the type MockSnapshotRepository
471-func (_mock *MockSnapshotStore) Write(name string, data []byte) error {609+func (_mock *MockSnapshotRepository) Maintain(context1 context.Context) error {
472- ret := _mock.Called(name, data)610+ ret := _mock.Called(context1)
473 611
474 if len(ret) == 0 {612 if len(ret) == 0 {
475- panic("no return value specified for Write")613+ panic("no return value specified for Maintain")
476 }614 }
477 615
478 var r0 error616 var r0 error
479- if returnFunc, ok := ret.Get(0).(func(string, []byte) error); ok {617+ if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
480- r0 = returnFunc(name, data)618+ r0 = returnFunc(context1)
481 } else {619 } else {
482 r0 = ret.Error(0)620 r0 = ret.Error(0)
483 }621 }
484 return r0622 return r0
485 }623 }
486 624
487-// MockSnapshotStore_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write'625+// MockSnapshotRepository_Maintain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Maintain'
488-type MockSnapshotStore_Write_Call struct {626+type MockSnapshotRepository_Maintain_Call struct {
489 *mock.Call627 *mock.Call
490 }628 }
491 629
492-// Write is a helper method to define mock.On call630+// Maintain is a helper method to define mock.On call
493-// - name string631+// - context1 context.Context
494-// - data []byte632+func (_e *MockSnapshotRepository_Expecter) Maintain(context1 any) *MockSnapshotRepository_Maintain_Call {
495-func (_e *MockSnapshotStore_Expecter) Write(name any, data any) *MockSnapshotStore_Write_Call {633+ return &MockSnapshotRepository_Maintain_Call{Call: _e.mock.On("Maintain", context1)}
496- return &MockSnapshotStore_Write_Call{Call: _e.mock.On("Write", name, data)}
497 }634 }
498 635
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 {
500 _c.Call.Run(func(args mock.Arguments) {637 _c.Call.Run(func(args mock.Arguments) {
501- var arg0 string638+ var arg0 context.Context
502 if args[0] != nil {639 if args[0] != nil {
503- arg0 = args[0].(string)640+ arg0 = args[0].(context.Context)
504 }641 }
505- var arg1 []byte642+ 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
506 if args[1] != nil {695 if args[1] != nil {
507- arg1 = args[1].([]byte)696+ arg1 = args[1].(ports.SnapshotPublication)
508 }697 }
509 run(698 run(
510 arg0,699 arg0,
@@ -514,12 +703,272 @@ func (_c *MockSnapshotStore_Write_Call) Run(run func(name string, data []byte))
514 return _c703 return _c
515 }704 }
516 705
517-func (_c *MockSnapshotStore_Write_Call) Return(err error) *MockSnapshotStore_Write_Call {706+func (_c *MockSnapshotRepository_Publish_Call) Return(err error) *MockSnapshotRepository_Publish_Call {
518 _c.Call.Return(err)707 _c.Call.Return(err)
519 return _c708 return _c
520 }709 }
521 710
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 {
523 _c.Call.Return(run)972 _c.Call.Return(run)
524 return _c973 return _c
525 }974 }
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 @@
1 package ports1 package ports
2 2
3+import (
4+ "context"
5+ "crypto/sha256"
6+)
7+
3 // Store is a small byte-key/value persistence port.8 // Store is a small byte-key/value persistence port.
4 //9 //
5 // Implementations may buffer writes; Sync is the durability barrier.10 // Implementations may buffer writes; Sync is the durability barrier.
@@ -17,15 +22,63 @@ type Store interface {
17 Close() error22 Close() error
18 }23 }
19 24
20-// SnapshotBlob is a durable named session snapshot payload.25+// SnapshotDigest is the SHA-256 content address of a complete snapshot object.
21-type SnapshotBlob struct {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 {
22 Name string70 Name string
23 Data []byte71 Data []byte
24 }72 }
25 73
26-// SnapshotStore persists encoded session snapshots by name.74+// LegacySnapshotSource exposes the v3 bridge separately from the new write
27-type SnapshotStore interface {75+// contract.
28- Write(name string, data []byte) error76+type LegacySnapshotSource interface {
29- Load() ([]SnapshotBlob, error)77+ LoadLegacy(context.Context) ([]LegacySnapshot, error)
30- Delete(name string) error78+ // 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
31 }84 }
M internal/usecase/copy/mode.go +22 -2
@@ -2,6 +2,7 @@ package copy
2 2
3 import (3 import (
4 "encoding/base64"4 "encoding/base64"
5+ "math"
5 "slices"6 "slices"
6 "strings"7 "strings"
7 "unicode"8 "unicode"
@@ -27,12 +28,31 @@ func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot {
27 return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height}28 return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height}
28 }29 }
29 func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {30 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+ })
31 for _, row := range rows {36 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+ }
33 }40 }
34 return Snapshot{history: history.SealAndView(), Width: width, Height: height}41 return Snapshot{history: history.SealAndView(), Width: width, Height: height}
35 }42 }
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+}
36 func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height }56 func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height }
37 func (s Snapshot) Row(i int) []renderer.Cell {57 func (s Snapshot) Row(i int) []renderer.Cell {
38 if i < 0 {58 if i < 0 {
M internal/usecase/copy/mode_test.go +17 -2
@@ -27,6 +27,21 @@ func modeFor(lines []string, height int) *Mode {
27 return NewMode(NewDocument(NewSnapshotFromRows(rows, 16, height), domain.DefaultWordSeparators))27 return NewMode(NewDocument(NewSnapshotFromRows(rows, 16, height), domain.DefaultWordSeparators))
28 }28 }
29 29
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+
30 func TestCopyModeKeyboardSelection(t *testing.T) {45 func TestCopyModeKeyboardSelection(t *testing.T) {
31 m := modeFor([]string{"alpha", "xy", "bravo", "charlie"}, 2)46 m := modeFor([]string{"alpha", "xy", "bravo", "charlie"}, 2)
32 require.Equal(t, Pos{Row: 3}, m.Cursor())47 require.Equal(t, Pos{Row: 3}, m.Cursor())
@@ -175,9 +190,9 @@ func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) {
175 const rows = 10_000190 const rows = 10_000
176 history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows + 1, ChunkRows: 256})191 history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows + 1, ChunkRows: 256})
177 for range rows {192 for range rows {
178- history.Append(row("unmatched"))193+ require.NoError(t, history.Append(row("unmatched")))
179 }194 }
180- history.Append(row("target"))195+ require.NoError(t, history.Append(row("target")))
181 snapshot := NewSnapshot(history, renderer.NewFrame(16, 1))196 snapshot := NewSnapshot(history, renderer.NewFrame(16, 1))
182 view := history.SealAndView()197 view := history.SealAndView()
183 require.Same(t, view.Chunk(0), snapshot.history.Chunk(0))198 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) {
301 d, sess, ac, sends := newManualSessionWithPTYs(t, p)301 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
302 pane := sess.tabs[0].focusedPane()302 pane := sess.tabs[0].focusedPane()
303 installTestHistory(pane, vt.HistoryConfig{MaxRows: 4})303 installTestHistory(pane, vt.HistoryConfig{MaxRows: 4})
304- pane.history.Append(testRow("old alpha"))304+ require.NoError(t, pane.history.Append(testRow("old alpha")))
305 copy(pane.screen.Frame.Row(0), testRow("live alpha"))305 copy(pane.screen.Frame.Row(0), testRow("live alpha"))
306 306
307 d.enterCopyMode(sess, ac)307 d.enterCopyMode(sess, ac)
@@ -330,8 +330,8 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
330 p, _ := newBlockingPTYWithWrites(t, writes)330 p, _ := newBlockingPTYWithWrites(t, writes)
331 d, sess, ac, sends := newManualSessionWithPTYs(t, p)331 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
332 installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})332 installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
333- sess.tabs[0].focusedPane().history.Append(testRow("old1 "))333+ require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old1 ")))
334- sess.tabs[0].focusedPane().history.Append(testRow("old2 "))334+ require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old2 ")))
335 sess.tabs[0].focusedPane().screen.Write([]byte("live"))335 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
336 336
337 d.enterCopyMode(sess, ac)337 d.enterCopyMode(sess, ac)
@@ -505,8 +505,8 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
505 p, _ := newBlockingPTY(t)505 p, _ := newBlockingPTY(t)
506 d, sess, ac, sends := newManualSessionWithPTYs(t, p)506 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
507 installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})507 installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
508- sess.tabs[0].focusedPane().history.Append(testRow("old1 "))508+ require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old1 ")))
509- sess.tabs[0].focusedPane().history.Append(testRow("old2 "))509+ require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old2 ")))
510 sess.tabs[0].focusedPane().screen.Write([]byte("live"))510 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
511 511
512 d.enterCopyMode(sess, ac)512 d.enterCopyMode(sess, ac)
@@ -739,11 +739,11 @@ func TestCopyModeCapturesSourceAndRetainsItAcrossFocusMove(t *testing.T) {
739 var want *pane739 var want *pane
740 if tc.useFloating {740 if tc.useFloating {
741 want = installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})741 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")))
743 want.screen.Write([]byte("flt-live"))743 want.screen.Write([]byte("flt-live"))
744 } else {744 } else {
745 want = main745 want = main
746- want.history.Append(testRow("one-old"))746+ require.NoError(t, want.history.Append(testRow("one-old")))
747 want.screen.Write([]byte("one-live"))747 want.screen.Write([]byte("one-live"))
748 }748 }
749 749
@@ -780,7 +780,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
780 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)780 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
781 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})781 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
782 for i := range 30 {782 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))))
784 }784 }
785 fp.screen.Write([]byte("live"))785 fp.screen.Write([]byte("live"))
786 fp.mu.Lock()786 fp.mu.Lock()
@@ -806,7 +806,7 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) {
806 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)806 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
807 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})807 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
808 for i := range 5 {808 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))))
810 }810 }
811 fp.screen.Write([]byte("live"))811 fp.screen.Write([]byte("live"))
812 812
M internal/usecase/daemon/daemon.go +156 -96
@@ -7,7 +7,6 @@ import (
7 "log/slog"7 "log/slog"
8 "math"8 "math"
9 "os"9 "os"
10- "slices"
11 "sort"10 "sort"
12 "sync"11 "sync"
13 "sync/atomic"12 "sync/atomic"
@@ -17,7 +16,6 @@ import (
17 "github.com/bnema/vev/internal/persist"16 "github.com/bnema/vev/internal/persist"
18 "github.com/bnema/vev/internal/ports"17 "github.com/bnema/vev/internal/ports"
19 "github.com/bnema/vev/internal/usecase/keys"18 "github.com/bnema/vev/internal/usecase/keys"
20- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
21 )19 )
22 20
23 // Scheduler debounce bounds. Idle updates use the minimum for low latency;21 // Scheduler debounce bounds. Idle updates use the minimum for low latency;
@@ -32,8 +30,13 @@ const debounceInterval = minDebounceInterval
32 // ptyReadBufSize is the PTY reader's read buffer.30 // ptyReadBufSize is the PTY reader's read buffer.
33 const ptyReadBufSize = 32 * 102431 const ptyReadBufSize = 32 * 1024
34 32
35-// defaultScrollbackRows is the per-tab retained history size.33+// New and restored panes are bounded by both history rows and cells. The
36-const defaultScrollbackRows = 10_00034+// 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+)
37 40
38 // detachNotifyTimeout bounds the best-effort Detached notification on the41 // detachNotifyTimeout bounds the best-effort Detached notification on the
39 // detach/kill/shutdown paths: if a wedged client (full kernel send buffer)42 // detach/kill/shutdown paths: if a wedged client (full kernel send buffer)
@@ -101,22 +104,32 @@ type Daemon struct {
101 beforeCopyModeRevalidate func()104 beforeCopyModeRevalidate func()
102 // beforeCopyMouseMap is a deterministic seam after an immutable copy-input105 // beforeCopyMouseMap is a deterministic seam after an immutable copy-input
103 // snapshot is captured and before its mapped position is applied.106 // snapshot is captured and before its mapped position is applied.
104- beforeCopyMouseMap func()107+ beforeCopyMouseMap func()
105- ptys ports.PTYFactory108+ ptys ports.PTYFactory
106- clock ports.Clock109+ clock ports.Clock
107- log *slog.Logger110+ log *slog.Logger
108- runtimeObserver ports.RuntimeObserver111+ runtimeObserver ports.RuntimeObserver
109- baseEnv []string112+ baseEnv []string
110- shell string113+ shell string
111- shellArgs []string114+ shellArgs []string
112- shellOverride bool115+ shellOverride bool
113- persist *persist.Persister116+ persist *persist.Persister
114- persistEnabled bool117+ persistEnabled bool
115- snaps ports.SnapshotStore118+ // snapshotRepository is the sole checkpoint contract. legacySnapshots is
116- snapsEnabled bool119+ // read-only migration input and is never used for new writes.
117- noticeStore ports.NoticeStore120+ snapshotRepository ports.SnapshotRepository
118- snapshotMarshal func(snapcodec.Session) ([]byte, error)121+ legacySnapshots ports.LegacySnapshotSource
119- snapshotJobs chan *snapshotCapture122+ 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{}
120 snapshotWorkerMu sync.Mutex133 snapshotWorkerMu sync.Mutex
121 snapshotWorkerID uint64134 snapshotWorkerID uint64
122 snapshotWorkerCtx context.Context135 snapshotWorkerCtx context.Context
@@ -127,28 +140,33 @@ type Daemon struct {
127 // snapshotFinalJobs coalesces terminal captures by session when the bounded140 // snapshotFinalJobs coalesces terminal captures by session when the bounded
128 // regular queue is full. It retains at most snapshotFinalQueueCapacity named141 // regular queue is full. It retains at most snapshotFinalQueueCapacity named
129 // sessions, each with only its newest terminal state while the worker blocks.142 // sessions, each with only its newest terminal state while the worker blocks.
130- snapshotFinalJobs map[*session]*snapshotCapture143+ snapshotFinalJobs map[*session]*snapshotCapture
131- snapshotFinalOrder []*session144+ snapshotFinalOrder []*session
132- snapshotWorkerClosing bool145+ snapshotWorkerClosing bool
133- snapshotWorkerInFlight *snapshotCapture146+ snapshotWorkerInFlight *snapshotCapture
134- restoreDone chan struct{}147+ // snapshotNoticeMu guards the active global persistence failure signature.
135- restoreOnce sync.Once148+ // It is separate from snapshotWorkerMu so notice routing cannot block a
136- procCwd func(int) (string, error)149+ // producer or a repository worker.
137- procComm func(int) (string, error)150+ snapshotNoticeMu sync.Mutex
138- procArgv func(int) ([]string, error)151+ snapshotActiveFailureSignature string
139- procGroupArgv func(int, int) ([]string, error)152+ restoreDone chan struct{}
140- dirOrHome func(string) string153+ restoreOnce sync.Once
141- bindings atomic.Pointer[keys.Bindings]154+ procCwd func(int) (string, error)
142- codeOverrides atomic.Pointer[map[string]string]155+ procComm func(int) (string, error)
143- restoreProcessAllowlist atomic.Pointer[map[string]struct{}]156+ procArgv func(int) ([]string, error)
144- floatingConfig atomic.Pointer[domain.FloatingConfig]157+ procGroupArgv func(int, int) ([]string, error)
145- copyConfig atomic.Pointer[domain.CopyConfig]158+ dirOrHome func(string) string
146- paletteConfig atomic.Pointer[domain.PaletteConfig]159+ bindings atomic.Pointer[keys.Bindings]
147- tabsConfig atomic.Pointer[domain.TabsConfig]160+ codeOverrides atomic.Pointer[map[string]string]
148- themeConfig atomic.Pointer[themeConfigSnapshot]161+ restoreProcessAllowlist atomic.Pointer[map[string]struct{}]
149- barScripts *barScriptState162+ floatingConfig atomic.Pointer[domain.FloatingConfig]
150- notices *noticeCenter163+ copyConfig atomic.Pointer[domain.CopyConfig]
151- resumeParkGrace time.Duration164+ 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
152 // tempDir overrides os.TempDir() for clipboard-image-transfer writes170 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
153 // (see clipboard.go); empty means use os.TempDir().171 // (see clipboard.go); empty means use os.TempDir().
154 tempDir string172 tempDir string
@@ -194,15 +212,6 @@ type stoppedSession struct {
194 purging bool212 purging bool
195 }213 }
196 214
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-
206 type Option func(*Daemon)215 type Option func(*Daemon)
207 216
208 // WithRuntimeObserver accepts only a composition-root serialized observer.217 // WithRuntimeObserver accepts only a composition-root serialized observer.
@@ -232,12 +241,13 @@ func WithStore(store ports.Store) Option {
232 }241 }
233 }242 }
234 243
235-// WithSnapshotStore enables durable named session snapshots. A nil store keeps244+// WithSnapshotRepository enables content-addressed incremental snapshots. The
236-// the daemon in no-op snapshot mode.245+// legacy source is read-only migration input and is never used for new writes.
237-func WithSnapshotStore(store ports.SnapshotStore) Option {246+func WithSnapshotRepository(repository ports.SnapshotRepository, legacy ports.LegacySnapshotSource) Option {
238 return func(d *Daemon) {247 return func(d *Daemon) {
239- d.snaps = store248+ d.snapshotRepository = repository
240- d.snapsEnabled = store != nil249+ d.legacySnapshots = legacy
250+ d.snapsEnabled = repository != nil
241 }251 }
242 }252 }
243 253
@@ -343,23 +353,24 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
343 shell = "/bin/sh"353 shell = "/bin/sh"
344 }354 }
345 d := &Daemon{355 d := &Daemon{
346- sessions: make(map[domain.SessionID]*session),356+ sessions: make(map[domain.SessionID]*session),
347- stopped: make(map[string]stoppedSession),357+ stopped: make(map[string]stoppedSession),
348- parked: make(map[uint64]*parkedAttachment),358+ parked: make(map[uint64]*parkedAttachment),
349- ptys: ptys,359+ ptys: ptys,
350- clock: clock,360+ clock: clock,
351- log: log,361+ log: log,
352- baseEnv: os.Environ(),362+ baseEnv: os.Environ(),
353- shell: shell,363+ shell: shell,
354- persist: persist.New(nil),364+ persist: persist.New(nil),
355- dirOrHome: dirOrHome,365+ dirOrHome: dirOrHome,
356- done: make(chan struct{}),366+ done: make(chan struct{}),
357- restoreDone: make(chan struct{}),367+ restoreDone: make(chan struct{}),
358- animWake: make(chan struct{}, 1),368+ animWake: make(chan struct{}, 1),
359- snapshotMarshal: snapcodec.Marshal,369+ legacyImportPending: make(map[string][]byte),
360- snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),370+ snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
361- notices: newNoticeCenter(),371+ snapshotWake: make(chan struct{}, 1),
362- resumeParkGrace: defaultResumeParkGrace,372+ notices: newNoticeCenter(),
373+ resumeParkGrace: defaultResumeParkGrace,
363 barScripts: &barScriptState{374 barScripts: &barScriptState{
364 cfg: barConfigFromDomain(domain.Defaults().Bar),375 cfg: barConfigFromDomain(domain.Defaults().Bar),
365 outputs: make(map[domain.SessionID]barScriptOutputs),376 outputs: make(map[domain.SessionID]barScriptOutputs),
@@ -443,7 +454,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
443 if d.snapsEnabled {454 if d.snapsEnabled {
444 d.startSnapshotEncodeWorker()455 d.startSnapshotEncodeWorker()
445 d.sessWg.Go(func() {456 d.sessWg.Go(func() {
446- d.snapshotSaver(d.serveCtx)457+ d.snapshotRepositorySaver(d.serveCtx)
447 })458 })
448 d.sessWg.Go(func() {459 d.sessWg.Go(func() {
449 d.restoreSnapshots(d.serveCtx)460 d.restoreSnapshots(d.serveCtx)
@@ -486,7 +497,15 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
486 // force-close. Finally drain the conn handlers, run one defensive sweep in497 // force-close. Finally drain the conn handlers, run one defensive sweep in
487 // case any ordering ever leaves a session behind, and join the session498 // case any ordering ever leaves a session behind, and join the session
488 // goroutines (readers unblock via pty.Close, coordinators via ctx cancel).499 // 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)
490 d.waitNotifies()509 d.waitNotifies()
491 d.hardCancel()510 d.hardCancel()
492 d.serveCancel()511 d.serveCancel()
@@ -495,7 +514,10 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
495 // registry snapshot has been removed.514 // registry snapshot has been removed.
496 d.connWg.Wait()515 d.connWg.Wait()
497 d.attachmentCleanupWg.Wait()516 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)
499 d.shutdownAll(ports.ReasonServerShutdown)521 d.shutdownAll(ports.ReasonServerShutdown)
500 d.sessWg.Wait()522 d.sessWg.Wait()
501 d.waitNotifies()523 d.waitNotifies()
@@ -509,7 +531,11 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
509 // closing under the same lock as the snapshot guarantees no session can be531 // closing under the same lock as the snapshot guarantees no session can be
510 // inserted after the snapshot: route rejects once closing is set, and both run532 // inserted after the snapshot: route rejects once closing is set, and both run
511 // under d.mu. killSession (which relocks) runs after the lock is released.533 // 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) {
513 d.mu.Lock()539 d.mu.Lock()
514 d.closing = true540 d.closing = true
515 for token, parked := range d.parked {541 for token, parked := range d.parked {
@@ -521,13 +547,57 @@ func (d *Daemon) shutdownAll(reason uint8) {
521 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))547 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))
522 if empty {548 if empty {
523 d.doneOnce.Do(func() { close(d.done) })549 d.doneOnce.Do(func() { close(d.done) })
524- return550+ return false
525 }551 }
526 for _, s := range snapshot {552 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
528 d.log.Error("closing session with unpersisted terminal state", "err", err)555 d.log.Error("closing session with unpersisted terminal state", "err", err)
529 }556 }
530 }557 }
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
531 }601 }
532 602
533 func (d *Daemon) sessionsSnapshotLocked() []*session {603 func (d *Daemon) sessionsSnapshotLocked() []*session {
@@ -620,25 +690,15 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
620 d.mu.Lock()690 d.mu.Lock()
621 target := d.findByNameLocked(k.Name)691 target := d.findByNameLocked(k.Name)
622 if target == nil {692 if target == nil {
623- if stopped, ok := d.stopped[k.Name]; ok {693+ if _, ok := d.stopped[k.Name]; ok {
624 d.mu.Unlock()694 d.mu.Unlock()
625- if d.snapsEnabled && d.snaps != nil {695+ // Stopped sessions follow the same tombstone-backed transaction as
626- if err := d.snaps.Delete(k.Name); err != nil {696+ // live and offline purges. In particular, a retained legacy source
627- d.log.Warn("deleting stopped session snapshot failed", "err", err, "session", k.Name)697+ // must never outlive metadata after an interrupted control kill.
628- _ = tr.Send(frameError(ports.ErrInternal, "deleting stopped session snapshot failed"))698+ if err := d.retryStoppedPurge(k.Name); err != nil {
629- return699+ d.log.Warn("deleting stopped session failed", "err", err, "session", k.Name)
630- }700+ _ = tr.Send(frameError(ports.ErrInternal, "deleting stopped session failed"))
631 }701 }
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()
642 return702 return
643 }703 }
644 }704 }
M internal/usecase/daemon/input_test.go +2 -2
@@ -1237,7 +1237,7 @@ func TestMouseNormalScreenDragUsesPressOwnedDocumentAfterOutputEviction(t *testi
1237 1237
1238 // Output evicts five lines after Press; the Document remains immutable.1238 // Output evicts five lines after Press; the Document remains immutable.
1239 for range 5 {1239 for range 5 {
1240- sess.tabs[0].focusedPane().history.Append(testRow("evicted"))1240+ require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("evicted")))
1241 }1241 }
1242 1242
1243 // Motion maps the next endpoint through the press-owned Document.1243 // Motion maps the next endpoint through the press-owned Document.
@@ -1764,7 +1764,7 @@ func TestActiveCopyMouseRejectsViewportChangeAfterMappingSnapshot(t *testing.T)
1764 d, sess, ac, sends := newManualSessionWithPTYs(t, p)1764 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1765 pane := sess.activeTab().focusedPane()1765 pane := sess.activeTab().focusedPane()
1766 for range 8 {1766 for range 8 {
1767- pane.history.Append(testRow("history"))1767+ require.NoError(t, pane.history.Append(testRow("history")))
1768 }1768 }
1769 d.enterCopyMode(sess, ac)1769 d.enterCopyMode(sess, ac)
1770 mustOutputData(t, sends)1770 mustOutputData(t, sends)
M internal/usecase/daemon/notice_persist_test.go +0 -90
@@ -1,17 +1,13 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4- "fmt"
5- "sync/atomic"
6 "testing"4 "testing"
7 5
8- "github.com/stretchr/testify/mock"
9 "github.com/stretchr/testify/require"6 "github.com/stretchr/testify/require"
10 7
11 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
13 portsmocks "github.com/bnema/vev/internal/ports/mocks"10 portsmocks "github.com/bnema/vev/internal/ports/mocks"
14- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
15 )11 )
16 12
17 // TestKillSessionServerShutdownSaturatedAppendsNotice proves that when the13 // TestKillSessionServerShutdownSaturatedAppendsNotice proves that when the
@@ -19,92 +15,6 @@ import (
19 // no way to retain the session's terminal state and no attached client to15 // no way to retain the session's terminal state and no attached client to
20 // toast, because the daemon is dying — the failure is handed to the16 // toast, because the daemon is dying — the failure is handed to the
21 // NoticeStore instead of being silently lost.17 // 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-
108 // TestStartupClaimRecordsAndToastsPendingNotices proves notices persisted by18 // TestStartupClaimRecordsAndToastsPendingNotices proves notices persisted by
109 // a previous daemon are recorded to history and toasted to the first client19 // a previous daemon are recorded to history and toasted to the first client
110 // that attaches after this daemon starts before their claim is acknowledged.20 // 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 {
61 return n61 return n
62 }62 }
63 63
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+
64 func (nc *noticeCenter) history() []domain.Notification {89 func (nc *noticeCenter) history() []domain.Notification {
65 nc.mu.Lock()90 nc.mu.Lock()
66 defer nc.mu.Unlock()91 defer nc.mu.Unlock()
M internal/usecase/daemon/overlay_runtime_test.go +2 -2
@@ -199,7 +199,7 @@ func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) {
199 ac := &attachedClient{}199 ac := &attachedClient{}
200 ac.initOverlays()200 ac.initOverlays()
201 history := newTestHistory(1)201 history := newTestHistory(1)
202- history.Append(testRow("before"))202+ require.NoError(t, history.Append(testRow("before")))
203 document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1)), domain.DefaultWordSeparators)203 document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1)), domain.DefaultWordSeparators)
204 204
205 ac.overlays.copyMu.Lock()205 ac.overlays.copyMu.Lock()
@@ -207,7 +207,7 @@ func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) {
207 ac.overlays.copyDocument = document207 ac.overlays.copyDocument = document
208 ac.overlays.copyMu.Unlock()208 ac.overlays.copyMu.Unlock()
209 209
210- history.Append(testRow("after"))210+ require.NoError(t, history.Append(testRow("after")))
211 renderSnap := ac.overlays.SnapshotForRender()211 renderSnap := ac.overlays.SnapshotForRender()
212 defer renderSnap.Unlock()212 defer renderSnap.Unlock()
213 require.NotNil(t, renderSnap.copyMode)213 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 {
47 }47 }
48 48
49 func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane {49 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})
51 return &pane{51 return &pane{
52 id: id,52 id: id,
53 stableID: stableID,53 stableID: stableID,
M internal/usecase/daemon/performance_benchmark_test.go +387 -77
@@ -1,9 +1,11 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "context"
4 "fmt"5 "fmt"
5 "io"6 "io"
6 "sync"7 "sync"
8+ "sync/atomic"
7 "testing"9 "testing"
8 "time"10 "time"
9 11
@@ -13,18 +15,138 @@ import (
13 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
14 "github.com/bnema/vev/internal/usecase/layout"16 "github.com/bnema/vev/internal/usecase/layout"
15 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"17 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
18+ "github.com/bnema/vev/pkg/renderer"
16 )19 )
17 20
18-func TestCountingSnapshotStoreWriteCountsAndRetainsOpaquePayload(t *testing.T) {21+func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) {
19- store := &countingSnapshotStore{}22+ repository := &countingSnapshotRepository{}
20 payload := []byte{0xff, 0x00, 0xfe}23 payload := []byte{0xff, 0x00, 0xfe}
24+ object := []byte{0x01, 0x02}
21 25
22- require.NoError(t, store.Write("session", payload))26+ require.NoError(t, repository.Publish(context.Background(), ports.SnapshotPublication{Name: "session", Manifest: payload, Objects: []ports.SnapshotObject{{Data: object}}}))
23- require.Equal(t, countingSnapshotMetrics{writes: 1, bytes: uint64(len(payload))}, store.metrics())27+ require.Equal(t, countingSnapshotMetrics{writes: 1, manifestBytes: uint64(len(payload)), objectBytes: uint64(len(object)), suppliedObjectBytes: uint64(len(object)), headBytes: snapshotHeadBytes}, repository.metrics())
24- require.Equal(t, payload, store.lastPayload())28+ require.Equal(t, payload, repository.lastPayload())
25 29
26 payload[0] = 0x0130 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)
28 }150 }
29 151
30 func TestCountingOutputTransportCountsOpaquePayloadAndRejectsShortPayload(t *testing.T) {152 func TestCountingOutputTransportCountsOpaquePayloadAndRejectsShortPayload(t *testing.T) {
@@ -63,7 +185,8 @@ func TestPerformanceFixtureCounters(t *testing.T) {
63 fixture.captureSnapshot()185 fixture.captureSnapshot()
64 metrics = fixture.metrics()186 metrics = fixture.metrics()
65 require.Equal(t, uint64(1), metrics.snapshotWrites)187 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)
67 190
68 fixture.enterCopySearch()191 fixture.enterCopySearch()
69 require.Positive(t, fixture.searchMatches())192 require.Positive(t, fixture.searchMatches())
@@ -157,13 +280,13 @@ func TestPerformanceFixtureSnapshotCaptureRetainsSealedHistory(t *testing.T) {
157 require.True(t, ok)280 require.True(t, ok)
158 require.Len(t, first.tabs, 1)281 require.Len(t, first.tabs, 1)
159 require.Len(t, first.tabs[0].panes, 2)282 require.Len(t, first.tabs[0].panes, 2)
160- require.Equal(t, 10_000, first.tabs[0].panes[0].history.Len())283+ require.Equal(t, 10_000, first.tabs[0].panes[0].sealed.Len())
161- require.Equal(t, 40, first.tabs[0].panes[0].history.ChunkCount())284+ require.Positive(t, first.tabs[0].panes[0].sealed.ChunkCount())
162 285
163 second, ok := fixture.d.captureSnapshotState(fixture.sess, 2)286 second, ok := fixture.d.captureSnapshotState(fixture.sess, 2)
164 require.True(t, ok)287 require.True(t, ok)
165 secondPane := capturePaneByID(second, first.tabs[0].panes[0].id)288 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) {
167 t.Fatal("unchanged sealed history chunk was not reused across captures")290 t.Fatal("unchanged sealed history chunk was not reused across captures")
168 }291 }
169 }292 }
@@ -216,14 +339,20 @@ func BenchmarkDaemonHistorySnapshotCapture(b *testing.B) {
216 benchmarkDaemonSnapshotCapture(b)339 benchmarkDaemonSnapshotCapture(b)
217 }340 }
218 341
219-// BenchmarkDaemonHistorySnapshotEncode measures v3 encoding from an already342+// BenchmarkDaemonIncrementalSnapshotRepository measures the complete
220-// immutable capture, without queueing or persistence.343+// content-addressed publication path against a filesystem-equivalent byte
221-func BenchmarkDaemonHistorySnapshotEncode(b *testing.B) {344+// counter. Each scenario reports persisted VEVO, VEVM, and VEVH bytes, plus
222- benchmarkDaemonSnapshotEncode(b)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+ }
223 }352 }
224 353
225 // BenchmarkDaemonHistorySnapshotRestore measures terminal-state restoration354 // 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.
227 func BenchmarkDaemonHistorySnapshotRestore(b *testing.B) {356 func BenchmarkDaemonHistorySnapshotRestore(b *testing.B) {
228 benchmarkDaemonSnapshotRestore(b)357 benchmarkDaemonSnapshotRestore(b)
229 }358 }
@@ -326,7 +455,7 @@ func benchmarkDaemonSnapshotCapture(b *testing.B) {
326 b.Fatal("invalid snapshot capture fixture")455 b.Fatal("invalid snapshot capture fixture")
327 }456 }
328 firstPane := first.tabs[0].panes[0]457 firstPane := first.tabs[0].panes[0]
329- firstChunk := firstPane.history.Chunk(0)458+ firstChunk := firstPane.sealed.Chunk(0)
330 captures := 0459 captures := 0
331 var last *snapshotCapture460 var last *snapshotCapture
332 b.ReportAllocs()461 b.ReportAllocs()
@@ -340,7 +469,7 @@ func benchmarkDaemonSnapshotCapture(b *testing.B) {
340 }469 }
341 b.StopTimer()470 b.StopTimer()
342 lastPane := capturePaneByID(last, firstPane.id)471 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 {
344 b.Fatal("snapshot capture did not preserve immutable sealed history")473 b.Fatal("snapshot capture did not preserve immutable sealed history")
345 }474 }
346 b.ReportMetric(float64(capturePaneCount(last)), "capturepanes/op")475 b.ReportMetric(float64(capturePaneCount(last)), "capturepanes/op")
@@ -348,41 +477,159 @@ func benchmarkDaemonSnapshotCapture(b *testing.B) {
348 }477 }
349 }478 }
350 479
351-func benchmarkDaemonSnapshotEncode(b *testing.B) {480+type snapshotBenchmarkScenario struct {
352- for _, topology := range daemonSnapshotTopologies {481+ name string
353- b.Run(topology.name, func(b *testing.B) {482+ size domain.Size
354- fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000})483+ historyRows int
355- if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) {484+ baseline bool
356- b.Fatal("invalid daemon snapshot encode fixture")485+ mutate func(*performanceFixture, int)
357- }486+ unchanged bool
358- capture, ok := fixture.d.captureSnapshotState(fixture.sess, 1)487+ cellLimited bool
359- if !ok || !validBenchmarkCapture(capture) {488+}
360- b.Fatal("invalid snapshot encode fixture")489+
361- }490+var snapshotBenchmarkScenarios = []snapshotBenchmarkScenario{
362- encoded, err := fixture.d.encodeSnapshotCapture(capture)491+ {name: "initial-10k-x-120", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000},
363- if err != nil {492+ {name: "unchanged", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, unchanged: true},
364- b.Fatal(err)493+ {name: "visible-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, mutate: mutateBenchmarkVisible},
365- }494+ // Leave one row below the retention cap so the mutation changes only the
366- if _, err := snapcodec.Unmarshal(encoded); err != nil {495+ // mutable tail and cannot evict a sealed chunk.
367- b.Fatalf("invalid v3 snapshot encode fixture: %v", err)496+ {name: "tail-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 9_999, baseline: true, mutate: mutateBenchmarkTail},
368- }497+ {name: "new-sealed-chunk", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, mutate: mutateBenchmarkSealedChunk},
369- var last []byte498+ {name: "cell-limited-293-columns", size: domain.Size{Cols: 293, Rows: 40}, historyRows: 10_000, cellLimited: true},
370- b.ReportAllocs()499+}
371- b.SetBytes(int64(len(encoded)))500+
372- b.ResetTimer()501+func benchmarkIncrementalSnapshotRepository(b *testing.B, scenario snapshotBenchmarkScenario) {
373- for range b.N {502+ var (
374- last, err = fixture.d.encodeSnapshotCapture(capture)503+ metrics countingSnapshotMetrics
375- if err != nil {504+ peakCacheBytes int
376- b.Fatal(err)505+ )
377- }506+ b.ReportAllocs()
378- }507+ b.ResetTimer()
379- b.StopTimer()508+ for operation := 0; b.Loop(); operation++ {
380- if _, err := snapcodec.Unmarshal(last); err != nil {509+ // A new fixture is deliberately outside the timed region. It gives every
381- b.Fatalf("snapshot encoder produced invalid v3 data: %v", err)510+ // iteration the same filesystem state: initial has no generation,
382- }511+ // unchanged/visible/tail/sealed begin after one persisted generation.
383- b.ReportMetric(float64(len(last)), "snapshotbytes/op")512+ // Reusing one fixture would eventually turn a tail into a sealed chunk
384- })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)
385 }541 }
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")
386 }633 }
387 634
388 func benchmarkDaemonSnapshotRestore(b *testing.B) {635 func benchmarkDaemonSnapshotRestore(b *testing.B) {
@@ -394,17 +641,17 @@ func benchmarkDaemonSnapshotRestore(b *testing.B) {
394 if !ok || !validBenchmarkCapture(capture) {641 if !ok || !validBenchmarkCapture(capture) {
395 b.Fatal("invalid snapshot restore fixture")642 b.Fatal("invalid snapshot restore fixture")
396 }643 }
397- encoded, err := fixture.d.encodeSnapshotCapture(capture)644+ publication, err := fixture.d.incrementalPublication(capture)
398 if err != nil {645 if err != nil {
399 b.Fatal(err)646 b.Fatal(err)
400 }647 }
401- snapshot, err := snapcodec.Unmarshal(encoded)648+ snapshot, err := sessionFromGeneration(snapshotGeneration(publication))
402 if err != nil || len(snapshot.Tabs) != 1 || len(snapshot.Tabs[0].Panes) == 0 {649 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)
404 }651 }
405 paneSnapshot := snapshot.Tabs[0].Panes[0]652 paneSnapshot := snapshot.Tabs[0].Panes[0]
406 b.ReportAllocs()653 b.ReportAllocs()
407- b.SetBytes(int64(len(encoded)))654+ b.SetBytes(int64(snapshotPublicationBytes(publication)))
408 b.ResetTimer()655 b.ResetTimer()
409 for range b.N {656 for range b.N {
410 if err := restorePaneTerminal(fixture.activePane, paneSnapshot); err != nil {657 if err := restorePaneTerminal(fixture.activePane, paneSnapshot); err != nil {
@@ -422,7 +669,27 @@ func benchmarkDaemonSnapshotRestore(b *testing.B) {
422 }669 }
423 670
424 func validBenchmarkCapture(capture *snapshotCapture) bool {671 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() > 0672+ 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)
426 }693 }
427 694
428 func capturePaneCount(capture *snapshotCapture) int {695 func capturePaneCount(capture *snapshotCapture) int {
@@ -473,7 +740,8 @@ func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations
473 b.ReportMetric(float64(metrics.outputBytes)/perOperation, "outputbytes/op")740 b.ReportMetric(float64(metrics.outputBytes)/perOperation, "outputbytes/op")
474 b.ReportMetric(float64(metrics.outputPayloadBytes)/perOperation, "framepayloadbytes/op")741 b.ReportMetric(float64(metrics.outputPayloadBytes)/perOperation, "framepayloadbytes/op")
475 b.ReportMetric(float64(metrics.snapshotWrites)/perOperation, "snapshotwrites/op")742 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")
477 b.ReportMetric(float64(metrics.coordinatorInvalidations)/perOperation, "coordinatorinvalidations/op")745 b.ReportMetric(float64(metrics.coordinatorInvalidations)/perOperation, "coordinatorinvalidations/op")
478 b.ReportMetric(float64(metrics.coordinatorWakes)/perOperation, "coordinatorwakes/op")746 b.ReportMetric(float64(metrics.coordinatorWakes)/perOperation, "coordinatorwakes/op")
479 b.ReportMetric(float64(metrics.coordinatorCoalesced)/perOperation, "coordinatorcoalesced/op")747 b.ReportMetric(float64(metrics.coordinatorCoalesced)/perOperation, "coordinatorcoalesced/op")
@@ -587,7 +855,8 @@ type performanceMetrics struct {
587 outputBytes uint64855 outputBytes uint64
588 outputPayloadBytes uint64856 outputPayloadBytes uint64
589 snapshotWrites uint64857 snapshotWrites uint64
590- snapshotBytes uint64858+ snapshotManifestBytes uint64
859+ snapshotObjectBytes uint64
591 coordinatorInvalidations uint64860 coordinatorInvalidations uint64
592 coordinatorWakes uint64861 coordinatorWakes uint64
593 coordinatorCoalesced uint64862 coordinatorCoalesced uint64
@@ -674,7 +943,7 @@ type performanceFixture struct {
674 sess *session943 sess *session
675 ac *attachedClient944 ac *attachedClient
676 output *countingOutputTransport945 output *countingOutputTransport
677- snaps *countingSnapshotStore946+ snapshots *countingSnapshotRepository
678 activePane *pane947 activePane *pane
679 liveWrites [][]byte948 liveWrites [][]byte
680 paints int949 paints int
@@ -727,13 +996,13 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
727 sess: sess,996 sess: sess,
728 ac: ac,997 ac: ac,
729 output: output,998 output: output,
730- snaps: &countingSnapshotStore{},999+ snapshots: &countingSnapshotRepository{},
731 pty: pty,1000 pty: pty,
732 clock: clock,1001 clock: clock,
733 liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")},1002 liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")},
734 resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size},1003 resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size},
735 }1004 }
736- WithSnapshotStore(fixture.snaps)(d)1005+ WithSnapshotRepository(fixture.snapshots, nil)(d)
737 d.startSnapshotEncodeWorker()1006 d.startSnapshotEncodeWorker()
738 t.Cleanup(d.stopSnapshotEncodeWorker)1007 t.Cleanup(d.stopSnapshotEncodeWorker)
739 for tabIndex, tb := range sess.tabs {1008 for tabIndex, tb := range sess.tabs {
@@ -778,7 +1047,7 @@ func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyR
778 for _, p := range tb.panes {1047 for _, p := range tb.panes {
779 p.mu.Lock()1048 p.mu.Lock()
780 width := p.screen.Frame.Width1049 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++ {
782 p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n"))1051 p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n"))
783 }1052 }
784 p.mu.Unlock()1053 p.mu.Unlock()
@@ -927,7 +1196,7 @@ func (f *performanceFixture) resized() bool {
927 1196
928 func (f *performanceFixture) resetMetrics() {1197 func (f *performanceFixture) resetMetrics() {
929 f.output.reset()1198 f.output.reset()
930- f.snaps.reset()1199+ f.snapshots.reset()
931 f.renderCaptures = 01200 f.renderCaptures = 0
932 f.renderCompositions = 01201 f.renderCompositions = 0
933 f.renderEmissions = 01202 f.renderEmissions = 0
@@ -943,10 +1212,10 @@ func (f *performanceFixture) resetMetrics() {
943 1212
944 func (f *performanceFixture) metrics() performanceMetrics {1213 func (f *performanceFixture) metrics() performanceMetrics {
945 output := f.output.metrics()1214 output := f.output.metrics()
946- snapshots := f.snaps.metrics()1215+ snapshots := f.snapshots.metrics()
947 metrics := performanceMetrics{1216 metrics := performanceMetrics{
948 outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes,1217 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,
950 renderCaptures: f.renderCaptures, renderCompositions: f.renderCompositions, renderEmissions: f.renderEmissions,1219 renderCaptures: f.renderCaptures, renderCompositions: f.renderCompositions, renderEmissions: f.renderEmissions,
951 resizeRequests: f.resizeRequests, resizeCommits: f.resizeCommits, resizeCommitNanos: f.resizeCommitNanos,1220 resizeRequests: f.resizeRequests, resizeCommits: f.resizeCommits, resizeCommitNanos: f.resizeCommitNanos,
952 ptyFailures: f.ptyFailures, ptyRetries: f.ptyRetries,1221 ptyFailures: f.ptyFailures, ptyRetries: f.ptyRetries,
@@ -1010,35 +1279,76 @@ func (t *countingOutputTransport) lastPayload() []byte {
1010 return t.last1279 return t.last
1011 }1280 }
1012 1281
1013-type countingSnapshotMetrics struct{ writes, bytes uint64 }1282+// snapshotHeadBytes is the on-disk VEVH header (magic, generation, and
1014-type countingSnapshotStore struct {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 {
1015 mu sync.Mutex1290 mu sync.Mutex
1016 countingSnapshotMetrics1291 countingSnapshotMetrics
1017- last []byte1292+ objects map[string]map[ports.SnapshotDigest]struct{}
1293+ last []byte
1018 }1294 }
1019 1295
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 {
1021 s.mu.Lock()1301 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+ }
1022 s.writes++1311 s.writes++
1023- s.bytes += uint64(len(data))1312+ s.manifestBytes += uint64(len(publication.Manifest))
1024- s.last = data1313+ s.headBytes += snapshotHeadBytes
1025- s.mu.Unlock()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
1026 return nil1330 return nil
1027 }1331 }
1028-func (*countingSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }1332+func (*countingSnapshotRepository) List(context.Context) ([]string, error) { return nil, nil }
1029-func (*countingSnapshotStore) Delete(string) error { return nil }1333+func (*countingSnapshotRepository) Load(context.Context, string) (ports.SnapshotGeneration, error) {
1030-func (s *countingSnapshotStore) reset() {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() {
1031 s.mu.Lock()1341 s.mu.Lock()
1032 s.countingSnapshotMetrics = countingSnapshotMetrics{}1342 s.countingSnapshotMetrics = countingSnapshotMetrics{}
1033 s.last = nil1343 s.last = nil
1034 s.mu.Unlock()1344 s.mu.Unlock()
1035 }1345 }
1036-func (s *countingSnapshotStore) metrics() countingSnapshotMetrics {1346+func (s *countingSnapshotRepository) metrics() countingSnapshotMetrics {
1037 s.mu.Lock()1347 s.mu.Lock()
1038 defer s.mu.Unlock()1348 defer s.mu.Unlock()
1039 return s.countingSnapshotMetrics1349 return s.countingSnapshotMetrics
1040 }1350 }
1041-func (s *countingSnapshotStore) lastPayload() []byte {1351+func (s *countingSnapshotRepository) lastPayload() []byte {
1042 s.mu.Lock()1352 s.mu.Lock()
1043 defer s.mu.Unlock()1353 defer s.mu.Unlock()
1044 return s.last1354 return s.last
M internal/usecase/daemon/picker.go +3 -10
@@ -547,17 +547,10 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
547 547
548 func (d *Daemon) killPickerTarget(target picker.Target) error {548 func (d *Daemon) killPickerTarget(target picker.Target) error {
549 if target.Stopped {549 if target.Stopped {
550- d.mu.Lock()550+ if err := d.retryStoppedPurge(target.Name); err != nil {
551- stopped, ok := d.stopped[target.Name]551+ d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)
552- if ok && !stopped.purging {552+ return domain.UserErr(domain.NoticePersistDelete, "couldn't delete stopped session", err)
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)
559 }553 }
560- d.mu.Unlock()
561 return nil554 return nil
562 }555 }
563 d.mu.Lock()556 d.mu.Lock()
M internal/usecase/daemon/picker_test.go +19 -8
@@ -35,11 +35,21 @@ func (failingDeleteStore) Range(func(k, v []byte) bool) {}
35 func (failingDeleteStore) Sync() error { return nil }35 func (failingDeleteStore) Sync() error { return nil }
36 func (failingDeleteStore) Close() error { return nil }36 func (failingDeleteStore) Close() error { return nil }
37 37
38-type refusingSnapshotDeleteStore struct{ err error }38+type refusingSnapshotDeleteRepository struct{ err error }
39 39
40-func (refusingSnapshotDeleteStore) Write(string, []byte) error { return nil }40+func (refusingSnapshotDeleteRepository) Publish(context.Context, ports.SnapshotPublication) error {
41-func (refusingSnapshotDeleteStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }41+ return nil
42-func (s refusingSnapshotDeleteStore) Delete(string) error { return s.err }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 }
43 53
44 func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab {54 func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab {
45 tb := newTab(p, domain.Size{Cols: 80, Rows: 23})55 tb := newTab(p, domain.Size{Cols: 80, Rows: 23})
@@ -796,7 +806,7 @@ func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testin
796 }806 }
797 807
798 func TestPickerPreviewContainsOnlyVisibleFrameRowsWithLargeScrollback(t *testing.T) {808 func TestPickerPreviewContainsOnlyVisibleFrameRowsWithLargeScrollback(t *testing.T) {
799- withScrollback := newPickerPreviewTabWithHistoryRows(10_000)809+ withScrollback := newPickerPreviewTabWithHistoryRows(t, 10_000)
800 require.Equal(t, 10_000, withScrollback.focusedPane().screen.History().Len())810 require.Equal(t, 10_000, withScrollback.focusedPane().screen.History().Len())
801 811
802 preview := snapshotPickerPreview(withScrollback)812 preview := snapshotPickerPreview(withScrollback)
@@ -820,11 +830,12 @@ func TestPickerPreviewContainsOnlyVisibleFrameRowsWithLargeScrollback(t *testing
820 require.NotContains(t, got, "history-only-marker")830 require.NotContains(t, got, "history-only-marker")
821 }831 }
822 832
823-func newPickerPreviewTabWithHistoryRows(historyRows int) *tab {833+func newPickerPreviewTabWithHistoryRows(t testing.TB, historyRows int) *tab {
834+ t.Helper()
824 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})835 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
825 p := tb.focusedPane()836 p := tb.focusedPane()
826 for range historyRows {837 for range historyRows {
827- p.screen.History().Append(testRow("history-only-marker"))838+ require.NoError(t, p.screen.History().Append(testRow("history-only-marker")))
828 }839 }
829 p.screen.Write([]byte("NOW"))840 p.screen.Write([]byte("NOW"))
830 return tb841 return tb
@@ -997,7 +1008,7 @@ func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t
997 d, from, ac, sends, releases := newRecentNavigationTestSessions(t)1008 d, from, ac, sends, releases := newRecentNavigationTestSessions(t)
998 defer releaseAll(releases)1009 defer releaseAll(releases)
999 cause := errors.New("snapshot delete refused")1010 cause := errors.New("snapshot delete refused")
1000- WithSnapshotStore(refusingSnapshotDeleteStore{err: cause})(d)1011+ WithSnapshotRepository(refusingSnapshotDeleteRepository{err: cause}, nil)(d)
1001 target := d.sessions[domain.SessionID("recent")]1012 target := d.sessions[domain.SessionID("recent")]
1002 1013
1003 d.enterPicker(from, ac)1014 d.enterPicker(from, ac)
M internal/usecase/daemon/render_test.go +1 -1
@@ -1289,7 +1289,7 @@ func TestPaintComposesCopyBodyAboveFloating(t *testing.T) {
1289 defer releaseFloating()1289 defer releaseFloating()
1290 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)1290 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
1291 fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3})1291 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")))
1293 fp.screen.Write([]byte("flt-live"))1293 fp.screen.Write([]byte("flt-live"))
1294 installTestFloating(sess.activeTab(), fp, true)1294 installTestFloating(sess.activeTab(), fp, true)
1295 1295
M internal/usecase/daemon/session.go +268 -77
@@ -43,6 +43,7 @@ type session struct {
43 client *attachedClient43 client *attachedClient
44 clipboardQueue []clipboardForward44 clipboardQueue []clipboardForward
45 clipboardWorkerRunning bool45 clipboardWorkerRunning bool
46+ renameInProgress bool
46 cwd string47 cwd string
47 terminal terminalEnv48 terminal terminalEnv
48 // env is the authoritative immutable environment snapshot for future PTY children.49 // env is the authoritative immutable environment snapshot for future PTY children.
@@ -52,14 +53,37 @@ type session struct {
52 mruAt atomic.Uint6453 mruAt atomic.Uint64
53 snapDirty atomic.Bool54 snapDirty atomic.Bool
54 snapEligible atomic.Bool55 snapEligible atomic.Bool
55- // snapshotMu serializes the dirty generation with worker completion. It is56+ // snapshotMu serializes mutation revisions and repository publication
56- // intentionally independent from mu: persistence never holds session state57+ // generations with worker completion. It is intentionally independent from
57- // locks while encoding or writing.58+ // mu: persistence never holds session state locks while encoding or writing.
58- snapshotMu sync.Mutex59+ snapshotMu sync.Mutex
59- snapshotGeneration uint6460+ 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.
60 snapshotPending bool68 snapshotPending bool
61 snapshotPendingCaptures uint69 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{}
63 // syncGen makes synchronized-output watchdog generations unique across all87 // syncGen makes synchronized-output watchdog generations unique across all
64 // panes in this session.88 // panes in this session.
65 syncGen atomic.Uint6489 syncGen atomic.Uint64
@@ -193,16 +217,20 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
193 lastUsedSeq = stopped.lastUsedSeq217 lastUsedSeq = stopped.lastUsedSeq
194 }218 }
195 sess := &session{219 sess := &session{
196- id: id,220+ id: id,
197- name: name,221+ name: name,
198- ephemeral: ephemeral,222+ ephemeral: ephemeral,
199- ctx: sctx,223+ ctx: sctx,
200- cancel: cancel,224+ cancel: cancel,
201- tabs: tabs,225+ tabs: tabs,
202- cwd: cwd,226+ cwd: cwd,
203- terminal: term,227+ terminal: term,
204- env: env,228+ env: env,
205- createdAt: createdAt,229+ createdAt: createdAt,
230+ snapshotWake: d.snapshotWake,
231+ }
232+ if !ephemeral && name != "" {
233+ sess.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit)
206 }234 }
207 if lastUsedSeq > 0 {235 if lastUsedSeq > 0 {
208 sess.mruAt.Store(lastUsedSeq)236 sess.mruAt.Store(lastUsedSeq)
@@ -521,15 +549,27 @@ func (d *Daemon) renameSession(sess *session, name string) error {
521 if err := domain.ValidateSessionName(name); err != nil {549 if err := domain.ValidateSessionName(name); err != nil {
522 return err550 return err
523 }551 }
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.
524 d.mu.Lock()557 d.mu.Lock()
525- defer d.mu.Unlock()
526 if taken := d.findByNameLocked(name); taken != nil && taken != sess {558 if taken := d.findByNameLocked(name); taken != nil && taken != sess {
559+ d.mu.Unlock()
527 return errSessionNameInUse560 return errSessionNameInUse
528 }561 }
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+ }
529 if stopped, ok := d.stopped[name]; ok && stopped.name != sess.name {568 if stopped, ok := d.stopped[name]; ok && stopped.name != sess.name {
569+ sess.mu.Unlock()
570+ d.mu.Unlock()
530 return errSessionNameInUse571 return errSessionNameInUse
531 }572 }
532- sess.mu.Lock()
533 oldName := sess.name573 oldName := sess.name
534 wasEphemeral := sess.ephemeral574 wasEphemeral := sess.ephemeral
535 createdAt := sess.createdAt575 createdAt := sess.createdAt
@@ -538,45 +578,79 @@ func (d *Daemon) renameSession(sess *session, name string) error {
538 createdAt, err = d.allocateLifecycleCreatedAtLocked()578 createdAt, err = d.allocateLifecycleCreatedAtLocked()
539 if err != nil {579 if err != nil {
540 sess.mu.Unlock()580 sess.mu.Unlock()
581+ d.mu.Unlock()
541 return err582 return err
542 }583 }
543 }584 }
544 lastUsedSeq := sess.mruAt.Load()585 lastUsedSeq := sess.mruAt.Load()
545- if wasEphemeral || oldName != name {586+ record := sess.persistRecordLocked(d.nowUnixNano())
546- record := sess.persistRecordLocked(d.nowUnixNano())587+ record.Name = name
547- record.Name = name588+ record.CreatedAt = createdAt
548- record.CreatedAt = createdAt589+ record.LastUsedSeq = lastUsedSeq
549- record.LastUsedSeq = lastUsedSeq590+ sess.renameInProgress = true
550- if err := d.persist.Save(record); err != nil {591+ sess.mu.Unlock()
551- sess.mu.Unlock()592+ d.mu.Unlock()
552- return err593+
553- }594+ rollback := func(err error) error {
595+ sess.mu.Lock()
596+ sess.renameInProgress = false
597+ sess.mu.Unlock()
598+ return err
554 }599 }
600+ var quarantine snapshotCoordinatorQuarantine
555 if !wasEphemeral && oldName != name {601 if !wasEphemeral && oldName != name {
556- if d.snapsEnabled && d.snaps != nil {602+ quarantine = quarantineSnapshotCoordinatorWithEpoch(sess)
557- if err := d.snaps.Delete(oldName); err != nil {603+ <-quarantine.done
558- if cleanupErr := d.persist.Delete(name); cleanupErr != nil {604+ if err := d.beginSnapshotPurge(oldName); err != nil {
559- d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name)605+ return rollback(err)
560- }
561- sess.mu.Unlock()
562- return err
563- }
564 }606 }
565- if err := d.persist.Delete(oldName); err != nil {607+ // finishSnapshotPurge preserves the tombstone through both source
566- if cleanupErr := d.persist.Delete(name); cleanupErr != nil {608+ // deletions and the old metadata delete, including a retained legacy
567- d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name)609+ // import whose verified source delete is still pending.
568- }610+ if err := d.finishSnapshotPurge(oldName); err != nil {
569- sess.mu.Unlock()611+ return rollback(err)
570- return err612+ }
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)
571 }620 }
572 }621 }
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.
573 delete(d.stopped, oldName)627 delete(d.stopped, oldName)
574 delete(d.stopped, name)628 delete(d.stopped, name)
575 sess.name = name629 sess.name = name
576 sess.createdAt = createdAt630 sess.createdAt = createdAt
577 sess.ephemeral = false631 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+ }
579 sess.mu.Unlock()641 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+ }
580 markSnapshotDirty(sess)654 markSnapshotDirty(sess)
581 return nil655 return nil
582 }656 }
@@ -717,36 +791,155 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
717 // ptyReader drains child output into the VT screen and pokes the dirty channel791 // ptyReader drains child output into the VT screen and pokes the dirty channel
718 // (non-blocking: a full channel already means a render is pending). On any read792 // (non-blocking: a full channel already means a render is pending). On any read
719 793
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+
720 func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {866 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 {
721 ringing := sess.anyAttention()875 ringing := sess.anyAttention()
722 sess.mu.Lock()876 sess.mu.Lock()
723 isEphemeral := sess.ephemeral877 isEphemeral := sess.ephemeral
878+ name := sess.name
724 sess.mu.Unlock()879 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+ }
725 if !purge && !isEphemeral && d.persistEnabled {892 if !purge && !isEphemeral && d.persistEnabled {
726 d.refreshSessionCwd(sess)893 d.refreshSessionCwd(sess)
727 }894 }
728- var snapshotDeleteErr, terminalSnapshotErr error895+ var terminalSnapshotErr error
729- if d.snapsEnabled && !isEphemeral {896+ retainSnapshotRetry := false
730- if purge {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) {
731 sess.mu.Lock()903 sess.mu.Lock()
732 name := sess.name904 name := sess.name
733 sess.mu.Unlock()905 sess.mu.Unlock()
734- if err := d.snaps.Delete(name); err != nil {906+ terminalSnapshotErr = fmt.Errorf("retain final snapshot for session %q: snapshot worker unavailable, saturated, or timed out", name)
735- snapshotDeleteErr = err907+ if reason != ports.ReasonServerShutdown {
736- d.log.Warn("deleting session snapshot failed", "err", err, "session", name)908+ return terminalSnapshotErr
737 }909 }
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)
738 } else {926 } else {
739- markSnapshotDirty(sess)927+ quarantineDone = quarantineSnapshotCoordinator(sess)
740- if !d.scheduleFinalSnapshot(sess) {928+ }
741- sess.mu.Lock()929+ joined := true
742- name := sess.name930+ if deadline != nil {
743- sess.mu.Unlock()931+ select {
744- terminalSnapshotErr = fmt.Errorf("retain final snapshot for session %q: snapshot worker unavailable or saturated", name)932+ case <-quarantineDone:
745- if reason != ports.ReasonServerShutdown {933+ case <-deadline.Done():
746- return terminalSnapshotErr934+ joined = false
747- }
748- d.persistShutdownSnapshotFailure(name, terminalSnapshotErr)
749 }935 }
936+ } else {
937+ <-quarantineDone
938+ }
939+ if joined {
940+ sess.snapshotMu.Lock()
941+ sess.snapshotChunkCache = nil
942+ sess.snapshotMu.Unlock()
750 }943 }
751 }944 }
752 d.mu.Lock()945 d.mu.Lock()
@@ -778,25 +971,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
778 d.mu.Unlock()971 d.mu.Unlock()
779 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)972 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)
780 973
781- purgeErr := snapshotDeleteErr974+ var purgeErr error
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- }
800 975
801 // A global route that selected this attachment must publish before terminal976 // A global route that selected this attachment must publish before terminal
802 // teardown makes it stale.977 // teardown makes it stale.
@@ -840,6 +1015,22 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
840 d.log.Warn("removing clipboard temp file failed", "err", err, "path", path)1015 d.log.Warn("removing clipboard temp file failed", "err", err, "path", path)
841 }1016 }
842 }1017 }
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+ }
843 if empty {1034 if empty {
844 d.doneOnce.Do(func() { close(d.done) })1035 d.doneOnce.Do(func() { close(d.done) })
845 } else if ringing {1036 } 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) {
674 }674 }
675 675
676 type mockStoreState struct {676 type mockStoreState struct {
677- mu sync.Mutex677+ mu sync.Mutex
678- data map[string][]byte678+ data map[string][]byte
679- sets int679+ sets int
680- dels []string680+ dels []string
681- syncs int681+ deleteErr func(string) error
682- closed bool682+ deleteDone chan struct{}
683+ syncs int
684+ closed bool
683 }685 }
684 686
685 func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) {687 func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) {
@@ -701,9 +703,21 @@ func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) {
701 }).Maybe()703 }).Maybe()
702 store.EXPECT().Delete(mock.Anything).RunAndReturn(func(k []byte) error {704 store.EXPECT().Delete(mock.Anything).RunAndReturn(func(k []byte) error {
703 state.mu.Lock()705 state.mu.Lock()
704- defer state.mu.Unlock()706+ key := string(k)
705- state.dels = append(state.dels, string(k))707+ state.dels = append(state.dels, key)
706- delete(state.data, string(k))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+ }
707 return nil721 return nil
708 }).Maybe()722 }).Maybe()
709 store.EXPECT().Range(mock.Anything).Run(func(fn func(k, v []byte) bool) {723 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
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "fmt"6 "fmt"
6- "math"
7 "sync"7 "sync"
8 "time"8 "time"
9 9
10- "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/usecase/layout"10 "github.com/bnema/vev/internal/usecase/layout"
12 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"11 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
13 "github.com/bnema/vev/pkg/vt"12 "github.com/bnema/vev/pkg/vt"
@@ -22,14 +21,29 @@ const snapshotQueueCapacity = 1
22 // (and left dirty) rather than silently dropping a persistence acknowledgement.21 // (and left dirty) rather than silently dropping a persistence acknowledgement.
23 const snapshotFinalQueueCapacity = 3222 const snapshotFinalQueueCapacity = 32
24 23
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+
25 type snapshotCapture struct {34 type snapshotCapture struct {
26- session *session35+ session *session
27- generation uint6436+ attemptKind snapshotAttemptKind
28- name string37+ generation uint64 // repository publication generation
29- createdAt uint6438+ mutationRevision uint64
30- active uint1639+ name string
31- tabs []snapshotCaptureTab40+ createdAt uint64
32- finishOnce sync.Once41+ 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
33 }47 }
34 48
35 type snapshotCaptureTab struct {49 type snapshotCaptureTab struct {
@@ -43,779 +57,40 @@ type snapshotCaptureTab struct {
43 }57 }
44 58
45 type snapshotCapturePane struct {59 type snapshotCapturePane struct {
46- id layout.PaneID60+ id layout.PaneID
47- stableID string61+ stableID string
48- cwd string62+ cwd string
49- history vt.HistoryView63+ // Sealed identities and the copied mutable tail are retained separately, so
50- visible []byte64+ // capture never rotates a live mutable tail merely to persist it.
51- visibleErr error65+ sealed vt.HistorySnapshotView
52- process *snapcodec.Process66+ 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+ }
53 }89 }
54 90
55 const (91 const (
56- snapshotInterval = 30 * time.Second92+ // 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
57 snapshotFinalFlushTimeout = time.Second95 snapshotFinalFlushTimeout = time.Second
58 )96 )
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 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "bytes"
4 "testing"5 "testing"
5 6
6 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
7 8
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"
14 "github.com/bnema/vev/pkg/renderer"9 "github.com/bnema/vev/pkg/renderer"
15 "github.com/bnema/vev/pkg/vt"10 "github.com/bnema/vev/pkg/vt"
16 )11 )
17 12
18-func TestSnapshotCaptureMarshalUnmarshalRestorePreservesTopologyAndTerminalState(t *testing.T) {13+func TestSnapshotChunkCacheIsScopedToNamedSession(t *testing.T) {
19- store := &channelSnapshotStore{writes: make(chan []byte, 1)}14+ d := New(nil, nil, nil)
20- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})15+ a := newSnapshotTestSession(t, "a", false, "/a")
21- WithSnapshotStore(store)(d)16+ b := newSnapshotTestSession(t, "b", false, "/b")
22- startSnapshotEncodeWorker(t, d)17+ a.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit)
23-18+ b.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit)
24- sess := newSnapshotTestSession(t, "acceptance", false, "/work")19+
25- first := sess.tabs[0]20+ // Eleven maximum-size chunks exceed the per-session 16 MiB cache budget.
26- first.size = domain.Size{Cols: 80, Rows: 24}21+ // The cache keeps its warmed working set rather than admitting every
27- first.stableID = "tab-one"22+ // oldest-to-newest scan miss.
28- first.nextPaneID = 323+ aHistory := snapshotCacheHistory(t, 11, 'a')
29- first.panes["pane-1"].stableID = "pane-one"24+ aCapture := snapshotCacheCapture(a, aHistory, 1)
30- secondPane := newPaneWithStableID("pane-2", "pane-two", snapshotAcceptancePTY(t), domain.Size{Cols: 40, Rows: 24})25+ aFirst, err := d.incrementalPublication(aCapture)
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)
51 require.NoError(t, err)26 require.NoError(t, err)
52- assertCapturedSessionTopology(t, captured)27+ require.NotEmpty(t, aFirst.Objects)
53-28+ require.LessOrEqual(t, a.snapshotChunkCache.used, a.snapshotChunkCache.limit)
54- restoreFactory := &restorePTYFactory{}29+ require.LessOrEqual(t, a.snapshotChunkCache.limit, snapshotChunkCacheLimit)
55- restoredDaemon := newTestDaemon(t, restoreFactory, stubClock{})30+ require.Contains(t, a.snapshotChunkCache.byPtr, aHistory.Chunk(0), "session A should retain its warmed entry")
56- require.NoError(t, restoredDaemon.restoreSession(t.Context(), captured))31+
57- restoredDaemon.mu.Lock()32+ bHistory := snapshotCacheHistory(t, 1, 'b')
58- restored := restoredDaemon.findByNameLocked("acceptance")33+ bCapture := snapshotCacheCapture(b, bHistory, 1)
59- restoredDaemon.mu.Unlock()34+ _, err = d.incrementalPublication(bCapture)
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)
118 require.NoError(t, err)35 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)36+ require.Contains(t, b.snapshotChunkCache.byPtr, bHistory.Chunk(0))
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-}
126 37
127-func TestDaemonHistoryEvictionLeavesCopyAndSearchSnapshotsUsable(t *testing.T) {38+ inFlightBytes := append([]byte(nil), aFirst.Objects[0].Data...)
128- sess := newSnapshotTestSession(t, "copy-lifetime", false, "/work")39+ aSecond := snapshotCacheCapture(a, snapshotCacheHistory(t, 1, 'z'), 2)
129- pane := sess.tabs[0].panes["pane-1"]40+ _, err = d.incrementalPublication(aSecond)
130- pane.screen = vt.NewScreenWithHistory(8, 1, vt.HistoryConfig{MaxRows: 2, ChunkRows: 1})41+ require.NoError(t, err)
131- pane.history = pane.screen.History()42+ require.True(t, bytes.Equal(inFlightBytes, aFirst.Objects[0].Data), "cache pruning must not mutate queued publication bytes")
132- for _, text := range []string{"first", "second"} {43+ require.LessOrEqual(t, a.snapshotChunkCache.used, snapshotChunkCacheLimit)
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-}
181 44
182-func snapshotAcceptancePTY(t *testing.T) *portsmocks.MockPTY {45+ _, err = d.incrementalPublication(snapshotCacheCapture(b, bHistory, 2))
183- t.Helper()46+ require.NoError(t, err)
184- pty := portsmocks.NewMockPTY(t)47+ require.Contains(t, b.snapshotChunkCache.byPtr, bHistory.Chunk(0), "session A eviction must not affect session B")
185- pty.EXPECT().Pid().Return(0).Maybe()48+ require.LessOrEqual(t, b.snapshotChunkCache.used, snapshotChunkCacheLimit)
186- pty.EXPECT().ForegroundPgid().Return(0, nil).Maybe()
187- return pty
188 }49 }
189 50
190-func assertCapturedSessionTopology(t *testing.T, got snapcodec.Session) {51+func snapshotCacheHistory(t *testing.T, chunks int, first rune) vt.HistorySnapshotView {
191 t.Helper()52 t.Helper()
192- require.Equal(t, "acceptance", got.Name)53+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: chunks * 256, ChunkRows: 256})
193- require.Equal(t, uint16(1), got.Active)54+ for row := 0; row < chunks*256; row++ {
194- require.Len(t, got.Tabs, 2)55+ cells := make([]renderer.Cell, 160)
195- for _, tt := range []struct {56+ for col := range cells {
196- name string57+ cells[col] = renderer.Cell{Rune: first + rune((row+col)%26)}
197- tab int58+ }
198- stableID string59+ require.NoError(t, history.Append(cells))
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- })
229 }60 }
61+ return history.SnapshotView()
230 }62 }
231 63
232-func assertTreeReferencesCapturedPanes(t *testing.T, node *layout.Node, panes map[layout.PaneID]struct{}) {64+func snapshotCacheCapture(sess *session, history vt.HistorySnapshotView, generation uint64) *snapshotCapture {
233- t.Helper()65+ return &snapshotCapture{
234- if node == nil {66+ session: sess,
235- return67+ name: sess.name,
236- }68+ generation: generation,
237- if node.Kind == layout.Leaf {69+ tabs: []snapshotCaptureTab{{
238- _, ok := panes[node.Leaf]70+ stableID: "tab",
239- require.True(t, ok, "tree leaf %q is dangling", node.Leaf)71+ cols: 1,
240- return72+ rows: 1,
241- }73+ panes: []snapshotCapturePane{{
242- if node.Kind == layout.Stack && node.Expanded != "" {74+ id: "pane",
243- _, ok := panes[node.Expanded]75+ stableID: "pane",
244- require.True(t, ok, "stack expanded pane %q is dangling", node.Expanded)76+ sealed: history,
245- }77+ tail: history.Tail(),
246- for _, child := range node.Children {78+ visible: vt.NewScreen(1, 1).PrimaryVisibleSnapshot(),
247- assertTreeReferencesCapturedPanes(t, child, panes)79+ }},
80+ }},
248 }81 }
249 }82 }
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
2 2
3 import (3 import (
4 "context"4 "context"
5- "encoding/binary"
6 "errors"5 "errors"
7- "io"
8 "math"6 "math"
9 "sync"7 "sync"
10 "testing"8 "testing"
11- "time"
12 9
13 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
14 11
15 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
16 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
17- scopy "github.com/bnema/vev/internal/usecase/copy"
18 "github.com/bnema/vev/internal/usecase/layout"14 "github.com/bnema/vev/internal/usecase/layout"
19 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"15 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
20 "github.com/bnema/vev/pkg/renderer"16 "github.com/bnema/vev/pkg/renderer"
21 "github.com/bnema/vev/pkg/vt"17 "github.com/bnema/vev/pkg/vt"
22 )18 )
23 19
24-func terminalPane(t *testing.T, id, stableID, cwd string, historyRows, visibleRows [][]renderer.Cell) snapcodec.Pane {20+type snapshotAcceptanceRepository struct {
25- t.Helper()21+ mu sync.Mutex
26- h := vt.NewHistory(vt.HistoryConfig{MaxRows: defaultScrollbackRows})22+ names []string
27- for _, row := range historyRows {23+ generations map[string]ports.SnapshotGeneration
28- h.Append(row)24+ publishErr error
29- }25+ listErr error
30- sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView())26+ loadErr error
31- require.NoError(t, err)27+ publishes []ports.SnapshotPublication
32- frame := renderer.NewFrame(40, 24)28+ loadMutate func(ports.SnapshotGeneration, ports.SnapshotPublication) ports.SnapshotGeneration
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}
39 }29 }
40 30
41-func TestRestorePaneTerminalRejectsMissingOrMalformedCanonicalBlobs(t *testing.T) {31+func (r *snapshotAcceptanceRepository) Publish(ctx context.Context, p ports.SnapshotPublication) error {
42- valid := terminalPane(t, "pane", "p", "/work", nil, nil)32+ if err := ctx.Err(); err != nil {
43- tests := []struct {33+ return err
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") }},
51 }34 }
52- for _, tt := range tests {35+ r.mu.Lock()
53- t.Run(tt.name, func(t *testing.T) {36+ defer r.mu.Unlock()
54- snap := valid37+ r.publishes = append(r.publishes, p)
55- tt.mutate(&snap)38+ if r.publishErr != nil {
56- p := newPane("pane", newRestorePTY(), domain.Size{Cols: 40, Rows: 24})39+ return r.publishErr
57- require.Error(t, restorePaneTerminal(p, snap))
58- })
59 }40 }
60-}41+ objects := make(map[ports.SnapshotDigest][]byte, len(p.Objects))
61-42+ for _, object := range p.Objects {
62-func TestRestoreSnapshotsNilDaemonDoesNotPanic(t *testing.T) {43+ objects[object.Digest] = append([]byte(nil), object.Data...)
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- }
144 }44 }
145- require.Len(t, attachedOpens, 1)45+ r.generations[p.Name] = ports.SnapshotGeneration{Name: p.Name, Generation: p.Generation, Manifest: append([]byte(nil), p.Manifest...), Objects: objects}
146- require.Equal(t, "/bin/attached-shell", attachedOpens[0].command)46+ found := false
147- require.Equal(t, []string{47+ for _, name := range r.names {
148- "ORDINARY=attached", "SHELL=/bin/attached-shell",48+ found = found || name == p.Name
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()
269 }49 }
50+ if !found {
51+ r.names = append(r.names, p.Name)
52+ }
53+ return nil
270 }54 }
271 55
272-func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore {56+func (r *snapshotAcceptanceRepository) List(ctx context.Context) ([]string, error) {
273- t.Helper()57+ if err := ctx.Err(); err != nil {
274- pane := emptyTerminalPane(t, "pane-1", "/tmp")58+ return nil, err
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- },
348 }59 }
349-60+ r.mu.Lock()
350- for _, tt := range tests {61+ defer r.mu.Unlock()
351- t.Run(tt.name, func(t *testing.T) {62+ if r.listErr != nil {
352- factory := &restorePTYFactory{}63+ return nil, r.listErr
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- })
368 }64 }
65+ return append([]string(nil), r.names...), nil
369 }66 }
370 67
371-func TestRestoreSnapshotsProcessCommandWriteFailureIsNonFatal(t *testing.T) {68+func (r *snapshotAcceptanceRepository) Load(ctx context.Context, name string) (ports.SnapshotGeneration, error) {
372- proc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}}69+ if err := ctx.Err(); err != nil {
373- factory := &restorePTYFactory{writeErr: errors.New("boom")}70+ return ports.SnapshotGeneration{}, err
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)
424 }71 }
425- d.mu.Lock()72+ r.mu.Lock()
426- restored := d.findByNameLocked("stacked")73+ defer r.mu.Unlock()
427- d.mu.Unlock()74+ if r.loadErr != nil {
428- require.NotNil(t, restored)75+ return ports.SnapshotGeneration{}, r.loadErr
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"},
462 }76 }
463- for _, tt := range tests {77+ generation, ok := r.generations[name]
464- t.Run(tt.name, func(t *testing.T) {78+ if !ok {
465- factory := &restorePTYFactory{}79+ return ports.SnapshotGeneration{}, errors.New("missing generation")
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- })
475 }80 }
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
476 }86 }
477 87
478-func TestRestoreSnapshotsSkipsCreatedAtOverflow(t *testing.T) {88+func (*snapshotAcceptanceRepository) Delete(context.Context, string) error { return nil }
479- store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "overflow", Data: mustSnapshotBytes(t, snapcodec.Session{89+func (*snapshotAcceptanceRepository) Tombstone(context.Context, string) error { return nil }
480- Name: "overflow",90+func (*snapshotAcceptanceRepository) DeleteTombstone(context.Context, string) error { return nil }
481- CreatedAt: uint64(math.MaxInt64) + 1,91+func (*snapshotAcceptanceRepository) Maintain(context.Context) error { return nil }
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) })
525 92
526- select {93+func TestSnapshotAcceptanceRepositoryLoadMutationDoesNotChangeStoredGeneration(t *testing.T) {
527- case <-d.restoreDone:94+ digest := ports.SnapshotDigest{1}
528- case <-time.After(2 * time.Second):95+ stored := ports.SnapshotGeneration{
529- t.Fatal("restoreSnapshots did not close restoreDone in time")96+ Name: "work",
97+ Manifest: []byte("manifest"),
98+ Objects: map[ports.SnapshotDigest][]byte{digest: []byte("object")},
530 }99 }
531-100+ repository := &snapshotAcceptanceRepository{
532- require.Empty(t, factory.opens, "both snapshots must fail before any PTY is opened")101+ generations: map[string]ports.SnapshotGeneration{"work": stored},
533- require.Len(t, d.notices.history(), 2, "each failure is recorded individually")102+ publishes: []ports.SnapshotPublication{{Name: "work"}},
534- for _, n := range d.notices.history() {103+ loadMutate: func(g ports.SnapshotGeneration, _ ports.SnapshotPublication) ports.SnapshotGeneration {
535- require.Equal(t, domain.NoticeSnapshotRestore, n.Code)104+ g.Manifest[0] = 'M'
536- require.Equal(t, domain.NoticeError, n.Severity)105+ g.Objects[digest][0] = 'O'
106+ delete(g.Objects, digest)
107+ return g
108+ },
537 }109 }
538 110
539- tr, _ := newCapturingTransport(t)111+ _, err := repository.Load(context.Background(), "work")
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)
545 require.NoError(t, err)112 require.NoError(t, err)
546- d.firstPaint(sess, ac, ac.size)113+ require.Equal(t, []byte("manifest"), repository.generations["work"].Manifest)
547-114+ require.Equal(t, []byte("object"), repository.generations["work"].Objects[digest])
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")
552 }115 }
553 116
554-func TestRestoreSnapshotsDoesNotNotifyForCancelledRestore(t *testing.T) {117+func cloneAcceptanceGeneration(g ports.SnapshotGeneration) ports.SnapshotGeneration {
555- ctx, cancel := context.WithCancel(context.Background())118+ out := g
556- factory := &restorePTYFactory{onOpen: cancel}119+ out.Manifest = append([]byte(nil), g.Manifest...)
557- store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "cancelled", Data: mustSnapshotBytes(t, snapcodec.Session{120+ out.Objects = make(map[ports.SnapshotDigest][]byte, len(g.Objects))
558- Name: "cancelled",121+ for digest, data := range g.Objects {
559- Tabs: []snapcodec.Tab{{122+ out.Objects[digest] = append([]byte(nil), data...)
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")
595 }123 }
596-124+ return out
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")
618 }125 }
619 126
620-func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) {127+func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session {
621- releaseLoad := make(chan struct{})128+ t.Helper()
622- loaded := make(chan struct{})129+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 8 * 293, ChunkRows: 2})
623- store := &restoreSnapshotStore{130+ for _, ch := range []rune{'a', 'b', 'c', 'd', 'e'} {
624- loadFn: func() ([]ports.SnapshotBlob, error) {131+ row := make([]renderer.Cell, 293)
625- close(loaded)132+ for i := range row {
626- <-releaseLoad133+ row[i] = renderer.Cell{Rune: ch}
627- return []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{134+ }
628- Name: "work",135+ require.NoError(t, history.Append(row))
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- },
637 }136 }
638- factory := &restorePTYFactory{}137+ view := history.SnapshotView()
639- d := newTestDaemon(t, factory, stubClock{})138+ sealed := make([][]byte, view.ChunkCount())
640- WithSnapshotStore(store)(d)139+ for i := range sealed {
641-140+ var err error
642- go d.restoreSnapshots(context.Background())141+ sealed[i], err = vt.MarshalHistoryChunk(view.Chunk(i))
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)
649 require.NoError(t, err)142 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")
666 }143 }
667-}144+ tail, err := vt.MarshalHistoryTail(view)
668-145+ require.NoError(t, err)
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-}
688 146
689-func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte {147+ screen := vt.NewScreen(293, 4)
690- t.Helper()148+ screen.Write([]byte("primary-visible"))
691- b, err := snapcodec.Marshal(s)149+ screen.Write([]byte("\x1b[?1049h"))
150+ screen.Write([]byte("alternate-active"))
151+ visible, err := screen.PrimaryVisibleSnapshot().Marshal()
692 require.NoError(t, err)152 require.NoError(t, err)
693- return b
694-}
695 153
696-func cells(s string) []renderer.Cell {154+ paneID := layout.PaneID("pane-7")
697- out := make([]renderer.Cell, 0, len(s))155+ return snapcodec.Session{
698- for _, r := range s {156+ Name: name, CreatedAt: 77, Active: 0,
699- out = append(out, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})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+ }},
700 }165 }
701- return out
702-}
703-
704-type restoreSnapshotStore struct {
705- blobs []ports.SnapshotBlob
706- loadFn func() ([]ports.SnapshotBlob, error)
707 }166 }
708 167
709-func (s *restoreSnapshotStore) Write(string, []byte) error { return nil }168+func acceptanceGeneration(t *testing.T, snapshot snapcodec.Session, generation uint64) ports.SnapshotGeneration {
710-func (s *restoreSnapshotStore) Delete(string) error { return nil }169+ t.Helper()
711-func (s *restoreSnapshotStore) Load() ([]ports.SnapshotBlob, error) {170+ publication, err := legacyPublication(snapshot)
712- if s.loadFn != nil {171+ require.NoError(t, err)
713- return s.loadFn()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...)
714 }180 }
715- return s.blobs, nil181+ return ports.SnapshotGeneration{Name: snapshot.Name, Generation: generation, Manifest: encoded, Objects: objects}
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
732 }182 }
733 183
734-func (f *restorePTYFactory) Open(ctx context.Context, command string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {184+func TestValidateRestoreSessionSnapshot(t *testing.T) {
735- f.mu.Lock()185+ valid := restoreAcceptanceSession(t, "restored")
736- pty := newRestorePTY()186+ for _, test := range []struct {
737- pty.writeErr = f.writeErr187+ name string
738- f.opens = append(f.opens, restorePTYOpen{ctx: ctx, command: command, dir: dir, size: sz, env: append([]string(nil), env...), pty: pty})188+ snap snapcodec.Session
739- onOpen := f.onOpen189+ }{
740- f.mu.Unlock()190+ {"valid", valid},
741- if onOpen != nil {191+ {"empty name", func() snapcodec.Session { snap := valid; snap.Name = ""; return snap }()},
742- onOpen()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+ })
743 }204 }
744- return pty, nil
745 }205 }
746 206
747-func (f *restorePTYFactory) snapshotOpens() []restorePTYOpen {207+func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
748- f.mu.Lock()208+ snapshot := restoreAcceptanceSession(t, "restored")
749- defer f.mu.Unlock()209+ generation := acceptanceGeneration(t, snapshot, 9)
750- opens := make([]restorePTYOpen, len(f.opens))210+ repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: generation}}
751- copy(opens, f.opens)211+ pty, release := newBlockingPTY(t)
752- for i := range opens {212+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
753- opens[i].env = append([]string(nil), opens[i].env...)213+ store, _ := newMockStore(t)
754- }214+ WithStore(store)(d)
755- return opens215+ WithSnapshotRepository(repository, nil)(d)
756-}216+ t.Cleanup(func() { release(); d.sessWg.Wait() })
757 217
758-type restorePTY struct {218+ d.restoreIncrementalSnapshots(context.Background())
759- mu sync.Mutex
760- writes []string
761- closes int
762- writeErr error
763- done chan struct{}
764- once sync.Once
765-}
766 219
767-func newRestorePTY() *restorePTY { return &restorePTY{done: make(chan struct{})} }220+ d.mu.Lock()
768-func (p *restorePTY) Read([]byte) (int, error) {221+ restored := d.findByNameLocked(snapshot.Name)
769- <-p.done222+ d.mu.Unlock()
770- return 0, io.EOF223+ require.NotNil(t, restored)
771-}224+ require.False(t, restored.snapDirty.Load(), "a loaded generation must begin clean")
772-func (p *restorePTY) Write(b []byte) (int, error) {225+ require.Equal(t, uint64(9), restored.snapshotPublishedGeneration, "the loaded manifest is the repository generation head")
773- p.mu.Lock()226+ require.Equal(t, 0, restored.active)
774- defer p.mu.Unlock()227+ require.Equal(t, "/snapshot/cwd", restored.cwd)
775- p.writes = append(p.writes, string(b))228+ require.Len(t, restored.tabs, 1)
776- if p.writeErr != nil {229+ tab := restored.tabs[0]
777- return 0, p.writeErr230+ 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+ })
778 }308 }
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
792 }309 }
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
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6- "fmt"
7- "io"
8- "sync"
9- "sync/atomic"
10 "testing"6 "testing"
11 "time"7 "time"
12 8
@@ -16,1012 +12,26 @@ import (
16 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
17 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
18 portsmocks "github.com/bnema/vev/internal/ports/mocks"14 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"
21 "github.com/bnema/vev/pkg/renderer"15 "github.com/bnema/vev/pkg/renderer"
22- "github.com/bnema/vev/pkg/vt"
23 )16 )
24 17
25-func TestSnapshotEncodeWorkerDefersMarshalUntilAfterCapture(t *testing.T) {18+// TestSnapshotWorkerPublishesContentAddressedCapture verifies that all new
26- store := &channelSnapshotStore{writes: make(chan []byte, 1)}19+// checkpoints use the repository publication contract.
20+func TestSnapshotWorkerPublishesContentAddressedCapture(t *testing.T) {
27 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})21 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
28- WithSnapshotStore(store)(d)22+ repository := portsmocks.NewMockSnapshotRepository(t)
29- t.Cleanup(d.stopSnapshotEncodeWorker)23+ WithSnapshotRepository(repository, nil)(d)
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)
540 startSnapshotEncodeWorker(t, d)24 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")
552 25
553- markSnapshotDirty(sess)26+ repository.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(p ports.SnapshotPublication) bool {
554- require.True(t, d.scheduleSnapshot(sess))27+ return p.Name == "work" && p.Generation == 1 && len(p.Manifest) > 0 && len(p.Objects) > 0
555- <-entered28+ })).Return(nil).Once()
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()
735 29
30+ sess := newSnapshotTestSession(t, "work", false, "/work")
736 require.True(t, d.captureSession(sess))31 require.True(t, d.captureSession(sess))
737 awaitSnapshotClean(t, sess)32 awaitSnapshotClean(t, sess)
738 }33 }
739 34
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-
1025 func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd string) *session {35 func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd string) *session {
1026 t.Helper()36 t.Helper()
1027 pty := portsmocks.NewMockPTY(t)37 pty := portsmocks.NewMockPTY(t)
@@ -1034,18 +44,8 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin
1034 tb := newTab(pty, domain.Size{Cols: 8, Rows: 3})44 tb := newTab(pty, domain.Size{Cols: 8, Rows: 3})
1035 p := tb.panes["pane-1"]45 p := tb.panes["pane-1"]
1036 p.screen.Write([]byte("hello"))46 p.screen.Write([]byte("hello"))
1037- p.history.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})47+ require.NoError(t, p.history.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}}))
1038- sess := &session{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}
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- }
1049 sess.snapEligible.Store(!ephemeral && name != "")49 sess.snapEligible.Store(!ephemeral && name != "")
1050 return sess50 return sess
1051 }51 }
@@ -1095,135 +95,3 @@ func startSnapshotEncodeWorker(t *testing.T, d *Daemon) {
1095 d.startSnapshotEncodeWorker()95 d.startSnapshotEncodeWorker()
1096 t.Cleanup(d.stopSnapshotEncodeWorker)96 t.Cleanup(d.stopSnapshotEncodeWorker)
1097 }97 }
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 (
24 magic = "VEVS"24 magic = "VEVS"
25 version = uint16(3)25 version = uint16(3)
26 maxDecodedBodySize = 256 << 2026 maxDecodedBodySize = 256 << 20
27- maxSnapshotBlobs = 1 << 1627+ maxSnapshotObjects = 1 << 16
28 maxSnapshotBytes = 256 << 2028 maxSnapshotBytes = 256 << 20
29 maxSnapshotRows = 1 << 2029 maxSnapshotRows = 1 << 20
30 maxSnapshotCells = 16 << 2030 maxSnapshotCells = 16 << 20
@@ -34,24 +34,6 @@ const (
34 maxSnapshotDecodedAllocation = 256 << 2034 maxSnapshotDecodedAllocation = 256 << 20
35 )35 )
36 36
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-
55 // Unmarshal validates the complete manifest and all VT blobs without37 // Unmarshal validates the complete manifest and all VT blobs without
56 // allocations first, then decodes the already preflighted data.38 // allocations first, then decodes the already preflighted data.
57 func Unmarshal(b []byte) (Session, error) {39 func Unmarshal(b []byte) (Session, error) {
@@ -146,6 +128,61 @@ func (w *payloadWriter) putStrings(ss []string) error {
146 return nil128 return nil
147 }129 }
148 130
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+
149 type payloadReader struct{ b []byte }186 type payloadReader struct{ b []byte }
150 187
151 func (r *payloadReader) getUint8() (uint8, error) {188 func (r *payloadReader) getUint8() (uint8, error) {
@@ -226,27 +263,6 @@ func (r *payloadReader) done() error {
226 return nil263 return nil
227 }264 }
228 265
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-
250 func readSession(r *payloadReader) (Session, error) {266 func readSession(r *payloadReader) (Session, error) {
251 name, err := r.getString()267 name, err := r.getString()
252 if err != nil {268 if err != nil {
@@ -278,35 +294,6 @@ func readSession(r *payloadReader) (Session, error) {
278 return s, nil294 return s, nil
279 }295 }
280 296
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-
310 func readTab(r *payloadReader) (Tab, error) {297 func readTab(r *payloadReader) (Tab, error) {
311 stableID, err := r.getString()298 stableID, err := r.getString()
312 if err != nil {299 if err != nil {
@@ -347,56 +334,19 @@ func readTab(r *payloadReader) (Tab, error) {
347 return t, nil334 return t, nil
348 }335 }
349 336
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-
387 func readNode(r *payloadReader) (*layout.Node, error) {337 func readNode(r *payloadReader) (*layout.Node, error) {
388 kind, err := r.getUint8()338 kind, err := r.getUint8()
389 if err != nil {339 if err != nil {
390 return nil, err340 return nil, err
391 }341 }
392 switch kind {342 switch kind {
393- case 0:343+ case manifestNodeLeaf:
394 leaf, err := r.getString()344 leaf, err := r.getString()
395 if err != nil {345 if err != nil {
396 return nil, err346 return nil, err
397 }347 }
398 return layout.NewLeaf(layout.PaneID(leaf)), nil348 return layout.NewLeaf(layout.PaneID(leaf)), nil
399- case 1:349+ case manifestNodeSplit:
400 dir, err := r.getUint8()350 dir, err := r.getUint8()
401 if err != nil {351 if err != nil {
402 return nil, err352 return nil, err
@@ -406,7 +356,7 @@ func readNode(r *payloadReader) (*layout.Node, error) {
406 return nil, err356 return nil, err
407 }357 }
408 return &layout.Node{Kind: layout.Split, Dir: layout.SplitDir(dir), Children: children}, nil358 return &layout.Node{Kind: layout.Split, Dir: layout.SplitDir(dir), Children: children}, nil
409- case 2:359+ case manifestNodeStack:
410 exp, err := r.getString()360 exp, err := r.getString()
411 if err != nil {361 if err != nil {
412 return nil, err362 return nil, err
@@ -416,7 +366,7 @@ func readNode(r *payloadReader) (*layout.Node, error) {
416 return nil, err366 return nil, err
417 }367 }
418 return &layout.Node{Kind: layout.Stack, Expanded: layout.PaneID(exp), Children: children}, nil368 return &layout.Node{Kind: layout.Stack, Expanded: layout.PaneID(exp), Children: children}, nil
419- case 3:369+ case manifestNodeNil:
420 return nil, nil370 return nil, nil
421 default:371 default:
422 return nil, fmt.Errorf("%w: unknown node kind", ErrInvalidData)372 return nil, fmt.Errorf("%w: unknown node kind", ErrInvalidData)
@@ -439,40 +389,6 @@ func readChildren(r *payloadReader) ([]*layout.Node, error) {
439 return out, nil389 return out, nil
440 }390 }
441 391
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-
476 func readPane(r *payloadReader) (Pane, error) {392 func readPane(r *payloadReader) (Pane, error) {
477 id, err := r.getString()393 id, err := r.getString()
478 if err != nil {394 if err != nil {
@@ -490,7 +406,7 @@ func readPane(r *payloadReader) (Pane, error) {
490 if err != nil {406 if err != nil {
491 return Pane{}, err407 return Pane{}, err
492 }408 }
493- if n > maxSnapshotBlobs || uint64(n) > uint64(len(r.b))/4 {409+ if n > maxSnapshotObjects || uint64(n) > uint64(len(r.b))/4 {
494 return Pane{}, fmt.Errorf("%w: sealed chunks", ErrInvalidData)410 return Pane{}, fmt.Errorf("%w: sealed chunks", ErrInvalidData)
495 }411 }
496 sealed := make([][]byte, n)412 sealed := make([][]byte, n)
@@ -514,33 +430,15 @@ func readPane(r *payloadReader) (Pane, error) {
514 return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: proc}, nil430 return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: proc}, nil
515 }431 }
516 432
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-
535 func readProcess(r *payloadReader) (*Process, error) {433 func readProcess(r *payloadReader) (*Process, error) {
536 present, err := r.getUint8()434 present, err := r.getUint8()
537 if err != nil {435 if err != nil {
538 return nil, err436 return nil, err
539 }437 }
540- if present == 0 {438+ if present == processAbsent {
541 return nil, nil439 return nil, nil
542 }440 }
543- if present != 1 {441+ if present != processPresent {
544 return nil, fmt.Errorf("%w: process presence", ErrInvalidData)442 return nil, fmt.Errorf("%w: process presence", ErrInvalidData)
545 }443 }
546 argv, err := r.getStrings()444 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) {
32 {ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible},32 {ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible},
33 {ID: "p2", SealedChunks: sealed2, Tail: tail2, Visible: visible2},33 {ID: "p2", SealedChunks: sealed2, Tail: tail2, Visible: visible2},
34 }}}}34 }}}}
35- encoded, err := Marshal(want)35+ encoded, err := marshalTest(want)
36 if err != nil {36 if err != nil {
37 t.Fatal(err)37 t.Fatal(err)
38 }38 }
@@ -86,13 +86,13 @@ func TestActiveTabReferenceMustBeExact(t *testing.T) {
86 {name: "nonempty tabs active past end", s: Session{Active: 1, Tabs: []Tab{validTab}}},86 {name: "nonempty tabs active past end", s: Session{Active: 1, Tabs: []Tab{validTab}}},
87 } {87 } {
88 t.Run("marshal "+tc.name, func(t *testing.T) {88 t.Run("marshal "+tc.name, func(t *testing.T) {
89- _, err := Marshal(tc.s)89+ _, err := marshalTest(tc.s)
90 if tc.valid {90 if tc.valid {
91 requireNoError(t, err)91 requireNoError(t, err)
92 return92 return
93 }93 }
94 if !errors.Is(err, ErrInvalidData) {94 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)
96 }96 }
97 })97 })
98 }98 }
@@ -153,7 +153,7 @@ func TestUnmarshalRejectsHostileV3ManifestDeclarationsBeforeAllocation(t *testin
153 func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) {153 func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) {
154 sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}})154 sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}})
155 visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})155 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}}}}})
157 if err != nil {157 if err != nil {
158 t.Fatal(err)158 t.Fatal(err)
159 }159 }
@@ -174,7 +174,7 @@ func TestUnmarshalRejectsEmptyAndDuplicatePaneIDsDuringPreflight(t *testing.T) {
174 {name: "duplicate", panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}, {ID: "p", Tail: tail, Visible: visible}}},174 {name: "duplicate", panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}, {ID: "p", Tail: tail, Visible: visible}}},
175 } {175 } {
176 t.Run(tc.name, func(t *testing.T) {176 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}}})
178 requireNoError(t, err)178 requireNoError(t, err)
179 reject(t, data, ErrInvalidData)179 reject(t, data, ErrInvalidData)
180 })180 })
@@ -184,7 +184,7 @@ func TestUnmarshalRejectsEmptyAndDuplicatePaneIDsDuringPreflight(t *testing.T) {
184 func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) {184 func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) {
185 sealed, tail := historyBlobs(t, nil)185 sealed, tail := historyBlobs(t, nil)
186 visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})186 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}}}}})
188 if err != nil {188 if err != nil {
189 t.Fatal(err)189 t.Fatal(err)
190 }190 }
@@ -193,8 +193,8 @@ func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) {
193 }193 }
194 }194 }
195 195
196-func TestUnmarshalRejectsNonCanonicalBlobRolesDuringPreflight(t *testing.T) {196+func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) {
197- sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}})197+ sealed, emptyTail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}})
198 if len(sealed) == 0 {198 if len(sealed) == 0 {
199 t.Fatal("expected a sealed chunk")199 t.Fatal("expected a sealed chunk")
200 }200 }
@@ -204,16 +204,37 @@ func TestUnmarshalRejectsNonCanonicalBlobRolesDuringPreflight(t *testing.T) {
204 multiChunk = append(multiChunk, sealed[0][9:]...)204 multiChunk = append(multiChunk, sealed[0][9:]...)
205 visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})205 visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
206 emptyVisible := visibleBlob(t, nil)206 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+
207 for _, tc := range []struct {227 for _, tc := range []struct {
208 name string228 name string
209 tab Tab229 tab Tab
210 }{230 }{
211- {name: "sealed multi-chunk", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: [][]byte{multiChunk}, Tail: tail, Visible: visible}}}},231+ {name: "sealed multi-chunk", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: [][]byte{multiChunk}, Tail: emptyTail, Visible: visible}}}},
212- {name: "nonempty tail", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: sealed[0], Visible: visible}}}},232+ {name: "tail has multiple chunks", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: multiChunk, Visible: visible}}}},
213- {name: "empty visible geometry", tab: Tab{Panes: []Pane{{ID: "p", Tail: tail, Visible: emptyVisible}}}},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}}}},
214 } {235 } {
215 t.Run(tc.name, func(t *testing.T) {236 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}})
217 requireNoError(t, err)238 requireNoError(t, err)
218 reject(t, data, ErrInvalidData)239 reject(t, data, ErrInvalidData)
219 })240 })
@@ -238,7 +259,7 @@ func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) {
238 {name: "nil root", tab: Tab{Tree: &layout.Tree{Root: nil}}},259 {name: "nil root", tab: Tab{Tree: &layout.Tree{Root: nil}}},
239 } {260 } {
240 t.Run(tc.name, func(t *testing.T) {261 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}})
242 requireNoError(t, err)263 requireNoError(t, err)
243 got, err := Unmarshal(data)264 got, err := Unmarshal(data)
244 requireNoError(t, err)265 requireNoError(t, err)
@@ -265,7 +286,7 @@ func TestRoundTripSessionMetadataAndProcess(t *testing.T) {
265 },286 },
266 {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}}},287 {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}}},
267 }}288 }}
268- data, err := Marshal(want)289+ data, err := marshalTest(want)
269 requireNoError(t, err)290 requireNoError(t, err)
270 got, err := Unmarshal(data)291 got, err := Unmarshal(data)
271 requireNoError(t, err)292 requireNoError(t, err)
@@ -279,11 +300,11 @@ func TestRoundTripSessionMetadataAndProcess(t *testing.T) {
279 }300 }
280 301
281 func TestMarshalRejectsProcessWithoutArgv(t *testing.T) {302 func TestMarshalRejectsProcessWithoutArgv(t *testing.T) {
282- _, err := Marshal(Session{Name: "s", Tabs: []Tab{303+ _, err := marshalTest(Session{Name: "s", Tabs: []Tab{
283 {Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Process: &Process{Strategy: "generic"}}}},304 {Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Process: &Process{Strategy: "generic"}}}},
284 }})305 }})
285 if !errors.Is(err, ErrInvalidData) {306 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)
287 }308 }
288 }309 }
289 310
@@ -322,7 +343,7 @@ func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) {
322 func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) {343 func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) {
323 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 256, ChunkRows: 256})344 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 256, ChunkRows: 256})
324 for range 256 {345 for range 256 {
325- history.Append([]renderer.Cell{{Rune: 'x'}})346+ requireNoError(t, history.Append([]renderer.Cell{{Rune: 'x'}}))
326 }347 }
327 sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView())348 sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView())
328 requireNoError(t, err)349 requireNoError(t, err)
@@ -333,7 +354,7 @@ func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) {
333 for i := range tabs {354 for i := range tabs {
334 tabs[i] = Tab{Panes: []Pane{{ID: layout.PaneID(strconv.Itoa(i)), SealedChunks: sealed, Tail: tail, Visible: visible}}}355 tabs[i] = Tab{Panes: []Pane{{ID: layout.PaneID(strconv.Itoa(i)), SealedChunks: sealed, Tail: tail, Visible: visible}}}
335 }356 }
336- data, err := Marshal(Session{Name: "over-budget", Tabs: tabs})357+ data, err := marshalTest(Session{Name: "over-budget", Tabs: tabs})
337 requireNoError(t, err)358 requireNoError(t, err)
338 359
339 allocs := testing.AllocsPerRun(3, func() {360 allocs := testing.AllocsPerRun(3, func() {
@@ -385,7 +406,7 @@ func historyBlobs(t *testing.T, rows [][]renderer.Cell) ([][]byte, []byte) {
385 t.Helper()406 t.Helper()
386 h := vt.NewHistory(vt.HistoryConfig{MaxRows: 128, ChunkRows: 2})407 h := vt.NewHistory(vt.HistoryConfig{MaxRows: 128, ChunkRows: 2})
387 for _, row := range rows {408 for _, row := range rows {
388- h.Append(row)409+ requireNoError(t, h.Append(row))
389 }410 }
390 sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView())411 sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView())
391 if err != nil {412 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
198 if err != nil {198 if err != nil {
199 return err199 return err
200 }200 }
201- if n > maxSnapshotBlobs || uint64(n) > uint64(len(r.b))/4 {201+ if n > maxSnapshotObjects || uint64(n) > uint64(len(r.b))/4 {
202 return fmt.Errorf("%w: sealed chunks", ErrInvalidData)202 return fmt.Errorf("%w: sealed chunks", ErrInvalidData)
203 }203 }
204 for range n {204 for range n {
@@ -214,7 +214,10 @@ func preflightPaneStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uin
214 if err != nil {214 if err != nil {
215 return err215 return err
216 }216 }
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 {
218 return fmt.Errorf("%w: tail blob role", ErrInvalidData)221 return fmt.Errorf("%w: tail blob role", ErrInvalidData)
219 }222 }
220 visible, err := preflightBlob(r, totals, blobs, false, budget)223 visible, err := preflightBlob(r, totals, blobs, false, budget)
@@ -235,7 +238,7 @@ func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, hist
235 if n == 0 || uint64(n) > uint64(len(r.b)) || uint64(n) > maxSnapshotBytes {238 if n == 0 || uint64(n) > uint64(len(r.b)) || uint64(n) > maxSnapshotBytes {
236 return vt.DecodeStats{}, fmt.Errorf("%w: VT blob length", ErrInvalidData)239 return vt.DecodeStats{}, fmt.Errorf("%w: VT blob length", ErrInvalidData)
237 }240 }
238- if *blobs >= maxSnapshotBlobs {241+ if *blobs >= maxSnapshotObjects {
239 return vt.DecodeStats{}, fmt.Errorf("%w: too many VT blobs", ErrInvalidData)242 return vt.DecodeStats{}, fmt.Errorf("%w: too many VT blobs", ErrInvalidData)
240 }243 }
241 blob := r.b[:n]244 blob := r.b[:n]
M internal/usecase/snapshot/preflight_acceptance_test.go +1 -1
@@ -33,7 +33,7 @@ func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T
33 },33 },
34 } {34 } {
35 t.Run(tt.name, func(t *testing.T) {35 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}})
37 if err != nil {37 if err != nil {
38 t.Fatalf("Marshal: %v", err)38 t.Fatalf("Marshal: %v", err)
39 }39 }
M pkg/vt/benchmark_test.go +25 -5
@@ -8,9 +8,10 @@ import (
8 )8 )
9 9
10 var (10 var (
11- benchmarkHistoryViewSink HistoryView11+ benchmarkHistoryViewSink HistoryView
12- benchmarkHistoryBlobSink []byte12+ benchmarkHistorySnapshotSink HistorySnapshotView
13- benchmarkHistoryBlobsSink [][]byte13+ benchmarkHistoryBlobSink []byte
14+ benchmarkHistoryBlobsSink [][]byte
14 )15 )
15 16
16 // TestHistoryViewPartialTailAllocationBounded guards the capture property used17 // TestHistoryViewPartialTailAllocationBounded guards the capture property used
@@ -75,6 +76,25 @@ func BenchmarkHistoryView10KRowsPartialTail(b *testing.B) {
75 b.ReportMetric(float64(10_000%256), "tailrows/view")76 b.ReportMetric(float64(10_000%256), "tailrows/view")
76 }77 }
77 78
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+
78 func BenchmarkMarshalHistoryChunk256x120(b *testing.B) {98 func BenchmarkMarshalHistoryChunk256x120(b *testing.B) {
79 history := benchmarkHistory(b, 256, 120, 256)99 history := benchmarkHistory(b, 256, 120, 256)
80 chunk := history.SealAndView().Chunk(0)100 chunk := history.SealAndView().Chunk(0)
@@ -155,7 +175,7 @@ func benchmarkHistory(t testing.TB, rows, width, chunkRows int) *History {
155 for col := range cells {175 for col := range cells {
156 cells[col] = renderer.Cell{Rune: rune('a' + (row+col)%26)}176 cells[col] = renderer.Cell{Rune: rune('a' + (row+col)%26)}
157 }177 }
158- history.Append(cells)178+ requireHistoryAppend(t, history, cells)
159 }179 }
160 return history180 return history
161 }181 }
@@ -173,7 +193,7 @@ func benchmarkScreenResizeReflow(b *testing.B, historyRows int) {
173 s := NewScreenWithHistory(120, 40, HistoryConfig{MaxRows: historyRows + 500})193 s := NewScreenWithHistory(120, 40, HistoryConfig{MaxRows: historyRows + 500})
174 if historyRows > 0 {194 if historyRows > 0 {
175 for range historyRows {195 for range historyRows {
176- s.history.Append(make([]renderer.Cell, 120))196+ requireHistoryAppend(b, s.history, make([]renderer.Cell, 120))
177 }197 }
178 }198 }
179 s.Write(bytes.Repeat([]byte("x"), 120*39))199 s.Write(bytes.Repeat([]byte("x"), 120*39))
M pkg/vt/history.go +165 -17
@@ -1,10 +1,20 @@
1 package vt1 package vt
2 2
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")
4 13
5 // HistoryConfig controls the bounded terminal history retained by a Screen.14 // HistoryConfig controls the bounded terminal history retained by a Screen.
6 type HistoryConfig struct {15 type HistoryConfig struct {
7 MaxRows int16 MaxRows int
17+ MaxCells int
8 ChunkRows int18 ChunkRows int
9 }19 }
10 20
@@ -13,10 +23,12 @@ type HistoryConfig struct {
13 // appends.23 // appends.
14 type History struct {24 type History struct {
15 maxRows int25 maxRows int
26+ maxCells int
16 chunkRows int27 chunkRows int
17 chunks []*HistoryChunk28 chunks []*HistoryChunk
18 tail [][]renderer.Cell29 tail [][]renderer.Cell
19 rows int30 rows int
31+ cells int
20 }32 }
21 33
22 // HistoryChunk is an immutable group of history rows. Its identity is stable34 // HistoryChunk is an immutable group of history rows. Its identity is stable
@@ -30,6 +42,17 @@ type HistoryChunk struct {
30 type HistoryView struct {42 type HistoryView struct {
31 chunks []*HistoryChunk43 chunks []*HistoryChunk
32 rows int44 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
33 }56 }
34 57
35 func NewHistory(config HistoryConfig) *History {58 func NewHistory(config HistoryConfig) *History {
@@ -42,20 +65,39 @@ func NewHistory(config HistoryConfig) *History {
42 }65 }
43 chunkRows = min(chunkRows, 256)66 chunkRows = min(chunkRows, 256)
44 chunkRows = min(chunkRows, config.MaxRows)67 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
46 }80 }
47 81
48 // Append records a copy of row. Once a chunk is full it is sealed forever.82 // 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 {
50 if h == nil || h.maxRows == 0 {85 if h == nil || h.maxRows == 0 {
51- return86+ return nil
52 }87 }
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))
53 h.tail = append(h.tail, append([]renderer.Cell(nil), row...))94 h.tail = append(h.tail, append([]renderer.Cell(nil), row...))
54 h.rows++95 h.rows++
96+ h.cells += len(row)
55 if len(h.tail) == h.chunkRows {97 if len(h.tail) == h.chunkRows {
56 h.sealTail()98 h.sealTail()
57 }99 }
58- h.evict()100+ return nil
59 }101 }
60 102
61 func (h *History) sealTail() {103 func (h *History) sealTail() {
@@ -66,13 +108,53 @@ func (h *History) sealTail() {
66 h.tail = nil108 h.tail = nil
67 }109 }
68 110
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+
69 func (h *History) evict() {121 func (h *History) evict() {
70- for h.rows > h.maxRows && len(h.chunks) > 0 {122+ h.evictUntil(0, 0)
71- evicted := h.chunks[0]123+}
72- h.rows -= len(evicted.rows)124+
73- copy(h.chunks, h.chunks[1:])125+// evictFor discards oldest rows until another row using cellCount cells can fit.
74- h.chunks[len(h.chunks)-1] = nil126+func (h *History) evictFor(cellCount int) {
75- h.chunks = h.chunks[:len(h.chunks)-1]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:]
76 }158 }
77 }159 }
78 160
@@ -93,13 +175,34 @@ func (h *History) View() HistoryView {
93 }175 }
94 chunks := append([]*HistoryChunk(nil), h.chunks...)176 chunks := append([]*HistoryChunk(nil), h.chunks...)
95 if len(h.tail) > 0 {177 if len(h.tail) > 0 {
96- rows := make([][]renderer.Cell, len(h.tail))178+ chunks = append(chunks, &HistoryChunk{rows: cloneHistoryRows(h.tail)})
97- for i, row := range h.tail {179+ }
98- rows[i] = append([]renderer.Cell(nil), row...)180+ return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells}
99- }181+}
100- chunks = append(chunks, &HistoryChunk{rows: rows})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,
101 }194 }
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
103 }206 }
104 207
105 // Len returns the currently retained history row count.208 // Len returns the currently retained history row count.
@@ -110,6 +213,14 @@ func (h *History) Len() int {
110 return h.rows213 return h.rows
111 }214 }
112 215
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+
113 // Cap returns the configured bounded row capacity.224 // Cap returns the configured bounded row capacity.
114 func (h *History) Cap() int {225 func (h *History) Cap() int {
115 if h == nil {226 if h == nil {
@@ -118,7 +229,16 @@ func (h *History) Cap() int {
118 return h.maxRows229 return h.maxRows
119 }230 }
120 231
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+
121 func (v HistoryView) Len() int { return v.rows }240 func (v HistoryView) Len() int { return v.rows }
241+func (v HistoryView) Cells() int { return v.cells }
122 func (v HistoryView) ChunkCount() int { return len(v.chunks) }242 func (v HistoryView) ChunkCount() int { return len(v.chunks) }
123 243
124 // Chunk returns the immutable chunk at i, or nil when i is out of range.244 // 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 {
161 }281 }
162 return nil282 return nil
163 }283 }
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
62 makeRow('D', rgb), makeRow('E', indexed), makeRow('F', rgb), makeRow('G', indexed),62 makeRow('D', rgb), makeRow('E', indexed), makeRow('F', rgb), makeRow('G', indexed),
63 }63 }
64 for _, row := range want {64 for _, row := range want {
65- history.Append(row)65+ requireHistoryAppend(t, history, row)
66 }66 }
67 if got, wantChunks := history.View().ChunkCount(), 4; got != wantChunks {67 if got, wantChunks := history.View().ChunkCount(), 4; got != wantChunks {
68 t.Fatalf("chunk count = %d, want three sealed chunks plus copied tail %d", got, wantChunks)68 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) {
46 for _, row := range chunk.rows {46 for _, row := range chunk.rows {
47 cellCount := uint64(len(row))47 cellCount := uint64(len(row))
48 cellBytes, ok := historyCellByteCount(cellCount)48 cellBytes, ok := historyCellByteCount(cellCount)
49- if cellCount > maxHistoryRowCells || !ok ||49+ if !ok ||
50 !addHistoryDecodeBudget(&stats.cells, cellCount, maxHistoryCells) ||50 !addHistoryDecodeBudget(&stats.cells, cellCount, maxHistoryCells) ||
51 !addHistoryDecodeBudget(&stats.styles, cellCount, maxHistoryDecodeStyles) ||51 !addHistoryDecodeBudget(&stats.styles, cellCount, maxHistoryDecodeStyles) ||
52 !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) {52 !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) {
@@ -193,11 +193,11 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
193 for range rowCount {193 for range rowCount {
194 cellCount, ok := p.uint32()194 cellCount, ok := p.uint32()
195 cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount))195 cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount))
196- if !ok || uint64(cellCount) > maxHistoryRowCells || !validCellBytes ||196+ if !ok || !validCellBytes ||
197- uint64(cellCount) > uint64(len(p.data))/historyCellBytes ||
198 !addHistoryDecodeBudget(&stats.cells, uint64(cellCount), maxHistoryCells) ||197 !addHistoryDecodeBudget(&stats.cells, uint64(cellCount), maxHistoryCells) ||
199 !addHistoryDecodeBudget(&stats.styles, uint64(cellCount), maxHistoryDecodeStyles) ||198 !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 {
201 return HistoryView{}, historyDecodeStats{}, false201 return HistoryView{}, historyDecodeStats{}, false
202 }202 }
203 203
@@ -205,7 +205,7 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
205 if populate {205 if populate {
206 row = make([]renderer.Cell, cellCount)206 row = make([]renderer.Cell, cellCount)
207 }207 }
208- for i := uint32(0); i < cellCount; i++ {208+ for i := range cellCount {
209 cell, ok := p.cell()209 cell, ok := p.cell()
210 if !ok || !validHistoryCell(cell) {210 if !ok || !validHistoryCell(cell) {
211 return HistoryView{}, historyDecodeStats{}, false211 return HistoryView{}, historyDecodeStats{}, false
@@ -225,7 +225,7 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
225 if len(p.data) != 0 {225 if len(p.data) != 0 {
226 return HistoryView{}, historyDecodeStats{}, false226 return HistoryView{}, historyDecodeStats{}, false
227 }227 }
228- return HistoryView{chunks: chunks, rows: int(stats.rows)}, stats, true228+ return HistoryView{chunks: chunks, rows: int(stats.rows), cells: int(stats.cells)}, stats, true
229 }229 }
230 230
231 func historyCellByteCount(cellCount uint64) (uint64, bool) {231 func historyCellByteCount(cellCount uint64) (uint64, bool) {
M pkg/vt/history_codec_test.go +54 -12
@@ -2,6 +2,8 @@ package vt
2 2
3 import (3 import (
4 "encoding/binary"4 "encoding/binary"
5+ "fmt"
6+ "math"
5 "testing"7 "testing"
6 8
7 "github.com/bnema/vev/pkg/renderer"9 "github.com/bnema/vev/pkg/renderer"
@@ -55,7 +57,7 @@ func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) {
55 t.Run(tt.name, func(t *testing.T) {57 t.Run(tt.name, func(t *testing.T) {
56 history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2})58 history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2})
57 for _, row := range tt.rows {59 for _, row := range tt.rows {
58- history.Append(row)60+ requireHistoryAppend(t, history, row)
59 }61 }
60 62
61 encoded, err := MarshalHistory(history.View())63 encoded, err := MarshalHistory(history.View())
@@ -73,8 +75,8 @@ func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) {
73 75
74 func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) {76 func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) {
75 history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 2})77 history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 2})
76- history.Append(historyRow("AAAA"))78+ requireHistoryAppend(t, history, historyRow("AAAA"))
77- history.Append(historyRow("BBBB"))79+ requireHistoryAppend(t, history, historyRow("BBBB"))
78 encoded, err := MarshalHistory(history.View())80 encoded, err := MarshalHistory(history.View())
79 if err != nil {81 if err != nil {
80 t.Fatalf("marshal history: %v", err)82 t.Fatalf("marshal history: %v", err)
@@ -123,10 +125,7 @@ func TestChunkCodecSupportsRepresentativeHistory(t *testing.T) {
123 }125 }
124 126
125 func TestChunkCodecRejectsDimensionsBeyondSupportedLimits(t *testing.T) {127 func TestChunkCodecRejectsDimensionsBeyondSupportedLimits(t *testing.T) {
126- const (128+ const supportedRows = 12_000
127- supportedRows = 12_000
128- supportedWidth = 160
129- )
130 tests := []struct {129 tests := []struct {
131 name string130 name string
132 view HistoryView131 view HistoryView
@@ -137,11 +136,6 @@ func TestChunkCodecRejectsDimensionsBeyondSupportedLimits(t *testing.T) {
137 view: historyViewWithDimensions(supportedRows+1, 0),136 view: historyViewWithDimensions(supportedRows+1, 0),
138 data: historyPayloadWithDimensions(supportedRows+1, 0),137 data: historyPayloadWithDimensions(supportedRows+1, 0),
139 },138 },
140- {
141- name: "too many cells in a row",
142- view: historyViewWithDimensions(1, supportedWidth+1),
143- data: historyPayloadWithDimensions(1, supportedWidth+1),
144- },
145 }139 }
146 140
147 for _, tt := range tests {141 for _, tt := range tests {
@@ -156,6 +150,41 @@ func TestChunkCodecRejectsDimensionsBeyondSupportedLimits(t *testing.T) {
156 }150 }
157 }151 }
158 152
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+
159 func TestChunkCodecRejectsHostileChunkAndRowDeclarations(t *testing.T) {188 func TestChunkCodecRejectsHostileChunkAndRowDeclarations(t *testing.T) {
160 tests := []struct {189 tests := []struct {
161 name string190 name string
@@ -213,6 +242,18 @@ func historyPayloadWithDimensions(rowCount, width int) []byte {
213 return data242 return data
214 }243 }
215 244
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+
216 func hostileHistoryDeclarations(chunkCount, rowCount int) []byte {257 func hostileHistoryDeclarations(chunkCount, rowCount int) []byte {
217 data := make([]byte, 9, 9+chunkCount*(4+rowCount*4))258 data := make([]byte, 9, 9+chunkCount*(4+rowCount*4))
218 copy(data, historyMagic)259 copy(data, historyMagic)
@@ -248,6 +289,7 @@ func TestHistoryPreflightMatchesUnmarshalMalformedInput(t *testing.T) {
248 {name: "invalid rune", data: invalidRune},289 {name: "invalid rune", data: invalidRune},
249 {name: "invalid underline style", data: invalidUnderlineStyle},290 {name: "invalid underline style", data: invalidUnderlineStyle},
250 {name: "aggregate row budget", data: hostileHistoryDeclarations(47, maxHistoryChunkRows)},291 {name: "aggregate row budget", data: hostileHistoryDeclarations(47, maxHistoryChunkRows)},
292+ {name: "aggregate cell budget", data: aggregateCellLimitDeclaration()},
251 {name: "zero row count", data: []byte{'V', 'T', 'H', '1', historyVersion, 0, 0, 0, 1, 0, 0, 0, 0}},293 {name: "zero row count", data: []byte{'V', 'T', 'H', '1', historyVersion, 0, 0, 0, 1, 0, 0, 0, 0}},
252 }294 }
253 for _, tt := range tests {295 for _, tt := range tests {
M pkg/vt/history_test.go +107 -9
@@ -1,11 +1,20 @@
1 package vt1 package vt
2 2
3 import (3 import (
4+ "errors"
5+ "math"
4 "testing"6 "testing"
5 7
6 "github.com/bnema/vev/pkg/renderer"8 "github.com/bnema/vev/pkg/renderer"
7 )9 )
8 10
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+
9 func TestHistoryOrdersSealedChunksAndEvictsAtCapacity(t *testing.T) {18 func TestHistoryOrdersSealedChunksAndEvictsAtCapacity(t *testing.T) {
10 tests := []struct {19 tests := []struct {
11 name string20 name string
@@ -22,11 +31,11 @@ func TestHistoryOrdersSealedChunksAndEvictsAtCapacity(t *testing.T) {
22 want: []string{"cccc", "dddd", "eeee", "ffff"},31 want: []string{"cccc", "dddd", "eeee", "ffff"},
23 },32 },
24 {33 {
25- name: "nonmultiple capacity evicts a whole oldest immutable chunk",34+ name: "nonmultiple capacity evicts exactly the oldest row",
26 maxRows: 5,35 maxRows: 5,
27 chunkRows: 2,36 chunkRows: 2,
28 rows: []string{"0000", "1111", "2222", "3333", "4444", "5555"},37 rows: []string{"0000", "1111", "2222", "3333", "4444", "5555"},
29- want: []string{"2222", "3333", "4444", "5555"},38+ want: []string{"1111", "2222", "3333", "4444", "5555"},
30 },39 },
31 {40 {
32 name: "rows preserve append order across chunk boundaries",41 name: "rows preserve append order across chunk boundaries",
@@ -41,7 +50,7 @@ func TestHistoryOrdersSealedChunksAndEvictsAtCapacity(t *testing.T) {
41 t.Run(tt.name, func(t *testing.T) {50 t.Run(tt.name, func(t *testing.T) {
42 history := NewHistory(HistoryConfig{MaxRows: tt.maxRows, ChunkRows: tt.chunkRows})51 history := NewHistory(HistoryConfig{MaxRows: tt.maxRows, ChunkRows: tt.chunkRows})
43 for _, text := range tt.rows {52 for _, text := range tt.rows {
44- history.Append(historyRow(text))53+ requireHistoryAppend(t, history, historyRow(text))
45 }54 }
46 55
47 view := history.View()56 view := history.View()
@@ -70,15 +79,15 @@ func TestHistoryTailRotationAndStableViews(t *testing.T) {
70 t.Run(tt.name, func(t *testing.T) {79 t.Run(tt.name, func(t *testing.T) {
71 history := NewHistory(HistoryConfig{MaxRows: 6, ChunkRows: 2})80 history := NewHistory(HistoryConfig{MaxRows: 6, ChunkRows: 2})
72 first := historyRow("AAAA")81 first := historyRow("AAAA")
73- history.Append(first)82+ requireHistoryAppend(t, history, first)
74- history.Append(historyRow("BBBB"))83+ requireHistoryAppend(t, history, historyRow("BBBB"))
75 view := history.View()84 view := history.View()
76 if got, want := view.ChunkCount(), 1; got != want {85 if got, want := view.ChunkCount(), 1; got != want {
77 t.Fatalf("sealed chunk count = %d, want %d", got, want)86 t.Fatalf("sealed chunk count = %d, want %d", got, want)
78 }87 }
79 88
80 first[0].Rune = 'X'89 first[0].Rune = 'X'
81- history.Append(historyRow("CCCC"))90+ requireHistoryAppend(t, history, historyRow("CCCC"))
82 91
83 if got, want := historyViewTexts(view), []string{"AAAA", "BBBB"}; !equalStrings(got, want) {92 if got, want := historyViewTexts(view), []string{"AAAA", "BBBB"}; !equalStrings(got, want) {
84 t.Fatalf("stable view rows = %#v, want %#v", got, want)93 t.Fatalf("stable view rows = %#v, want %#v", got, want)
@@ -100,11 +109,11 @@ func TestHistoryReusesSealedChunkIdentityAcrossViews(t *testing.T) {
100 for _, tt := range tests {109 for _, tt := range tests {
101 t.Run(tt.name, func(t *testing.T) {110 t.Run(tt.name, func(t *testing.T) {
102 history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2})111 history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2})
103- history.Append(historyRow("AAAA"))112+ requireHistoryAppend(t, history, historyRow("AAAA"))
104- history.Append(historyRow("BBBB"))113+ requireHistoryAppend(t, history, historyRow("BBBB"))
105 before := history.View()114 before := history.View()
106 115
107- history.Append(historyRow("CCCC"))116+ requireHistoryAppend(t, history, historyRow("CCCC"))
108 after := history.View()117 after := history.View()
109 118
110 if before.ChunkCount() == 0 || after.ChunkCount() == 0 {119 if before.ChunkCount() == 0 || after.ChunkCount() == 0 {
@@ -192,6 +201,95 @@ func TestHistoryRecordsOnlyTopEdgeScrollEvictions(t *testing.T) {
192 }201 }
193 }202 }
194 203
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+
195 func historyRow(text string) []renderer.Cell {293 func historyRow(text string) []renderer.Cell {
196 row := make([]renderer.Cell, len(text))294 row := make([]renderer.Cell, len(text))
197 for i, r := range text {295 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 @@
1 package vt1 package vt
2 2
3-import "github.com/bnema/vev/pkg/renderer"3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
4 8
5 func (s *Screen) index() {9 func (s *Screen) index() {
6 if s.Frame.Height == 0 {10 if s.Frame.Height == 0 {
@@ -88,7 +92,12 @@ func (s *Screen) emitLineEvicted(top, n int) {
88 92
89 func (s *Screen) recordEvicted(row []renderer.Cell) {93 func (s *Screen) recordEvicted(row []renderer.Cell) {
90 if s.history != nil {94 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.
92 }101 }
93 if s.OnLineEvicted != nil {102 if s.OnLineEvicted != nil {
94 s.OnLineEvicted(append([]renderer.Cell(nil), row...))103 s.OnLineEvicted(append([]renderer.Cell(nil), row...))
M pkg/vt/snapshot.go +46 -5
@@ -54,6 +54,12 @@ func MarshalHistoryChunk(chunk *HistoryChunk) ([]byte, error) {
54 // MarshalEmptyHistoryTail returns the mandatory canonical empty tail blob.54 // MarshalEmptyHistoryTail returns the mandatory canonical empty tail blob.
55 func MarshalEmptyHistoryTail() ([]byte, error) { return MarshalHistory(HistoryView{}) }55 func MarshalEmptyHistoryTail() ([]byte, error) { return MarshalHistory(HistoryView{}) }
56 56
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+
57 // MarshalSealedHistory serializes a SealAndView result as oldest-first,63 // MarshalSealedHistory serializes a SealAndView result as oldest-first,
58 // self-contained sealed blobs plus a mandatory empty canonical tail blob.64 // self-contained sealed blobs plus a mandatory empty canonical tail blob.
59 func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) {65 func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) {
@@ -84,18 +90,23 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist
84 if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 {90 if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 {
85 return nil, fmt.Errorf("restore sealed history: %w", errInvalidHistory)91 return nil, fmt.Errorf("restore sealed history: %w", errInvalidHistory)
86 }92 }
93+ h.evictUntil(len(view.chunks[0].rows), view.Cells())
87 h.chunks = append(h.chunks, view.chunks[0])94 h.chunks = append(h.chunks, view.chunks[0])
88 h.rows += len(view.chunks[0].rows)95 h.rows += len(view.chunks[0].rows)
96+ h.cells += view.Cells()
89 }97 }
90 view, err := UnmarshalHistory(tail)98 view, err := UnmarshalHistory(tail)
91 if err != nil || len(view.chunks) > 1 {99 if err != nil || len(view.chunks) > 1 {
92 return nil, fmt.Errorf("restore history tail: %w", errInvalidHistory)100 return nil, fmt.Errorf("restore history tail: %w", errInvalidHistory)
93 }101 }
94 if len(view.chunks) == 1 {102 if len(view.chunks) == 1 {
103+ h.evictUntil(len(view.chunks[0].rows), view.Cells())
95 h.tail = view.chunks[0].rows104 h.tail = view.chunks[0].rows
96 h.rows += len(h.tail)105 h.rows += len(h.tail)
106+ h.cells += view.Cells()
97 }107 }
98 h.evict()108 h.evict()
109+ h.normalizeTail()
99 return h, nil110 return h, nil
100 }111 }
101 112
@@ -130,17 +141,47 @@ func MarshalVisible(frame renderer.Frame) ([]byte, error) {
130 return marshalVisible(frame, boundaries)141 return marshalVisible(frame, boundaries)
131 }142 }
132 143
133-// MarshalPrimaryVisible preserves the primary buffer's logical-line boundaries144+// PrimaryVisibleSnapshot is an owned copy of the primary buffer's visible
134-// alongside its cells so a restored viewport can subsequently reflow.145+// state. Capture it while the screen owner is synchronized, then Marshal it
135-func (s *Screen) MarshalPrimaryVisible() ([]byte, error) {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 {
136 b := s.buffer156 b := s.buffer
137 if s.alternate != nil {157 if s.alternate != nil {
138 b = s.alternate.buffer158 b = s.alternate.buffer
139 }159 }
140 if b == nil {160 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...),
142 }166 }
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()
144 }185 }
145 186
146 func marshalVisible(frame renderer.Frame, boundaries []lineBoundary) ([]byte, error) {187 func marshalVisible(frame renderer.Frame, boundaries []lineBoundary) ([]byte, error) {
M pkg/vt/snapshot_test.go +79 -0
@@ -4,9 +4,88 @@ import (
4 "strings"4 "strings"
5 "testing"5 "testing"
6 6
7+ "github.com/bnema/vev/pkg/renderer"
7 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
8 )9 )
9 10
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+
10 func TestPrimaryVisibleSnapshotRetainsReflowBoundaries(t *testing.T) {89 func TestPrimaryVisibleSnapshotRetainsReflowBoundaries(t *testing.T) {
11 s := NewScreen(5, 3)90 s := NewScreen(5, 3)
12 s.Write([]byte("abcdefgh"))91 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