detached 73a5ab3

refactor(snapshot): remove monolithic persistence contract

commit
73a5ab3e0608c3343d0c6340b69bfba23daeb1ec
author
brice · · 2026-07-21 22:11 UTC
parent
8aaf1e5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
21 files · +101 -3152
M internal/adapters/snapshot/legacy.go +2 -3
@@ -13,9 +13,8 @@ import (
1313 "github.com/bnema/vev/internal/ports"
1414 )
1515
16-// LoadLegacy reads only pre-incremental root .snap files. Store remains the
17-// legacy writer until the migration cutover, but is deliberately not used by
18-// this read path so incremental repository directories are never traversed.
16+// LoadLegacy reads only pre-incremental root .snap files. It is deliberately
17+// isolated from repository reads so incremental directories are never traversed.
1918 func (r *Repository) LoadLegacy(ctx context.Context) ([]ports.LegacySnapshot, error) {
2019 if err := ctx.Err(); err != nil {
2120 return nil, err
@@ -13,9 +13,8 @@ import (
13 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
14 )14 )
15 15
16-// LoadLegacy reads only pre-incremental root .snap files. Store remains the16+// LoadLegacy reads only pre-incremental root .snap files. It is deliberately
17-// legacy writer until the migration cutover, but is deliberately not used by17+// isolated from repository reads so incremental directories are never traversed.
18-// this read path so incremental repository directories are never traversed.
19 func (r *Repository) LoadLegacy(ctx context.Context) ([]ports.LegacySnapshot, error) {18 func (r *Repository) LoadLegacy(ctx context.Context) ([]ports.LegacySnapshot, error) {
20 if err := ctx.Err(); err != nil {19 if err := ctx.Err(); err != nil {
21 return nil, err20 return nil, err
M internal/adapters/snapshot/path.go +15 -0
@@ -5,12 +5,27 @@ import (
55 "encoding/hex"
66 "fmt"
77 "path/filepath"
8+ "regexp"
89 "strconv"
910 "strings"
1011
1112 "github.com/bnema/vev/internal/ports"
1213 )
1314
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+
1429 func (r *Repository) sessionPath(key string) string {
1530 return filepath.Join(r.dir, repositorySessionsDir, key)
1631 }
@@ -5,12 +5,27 @@ import (
5 "encoding/hex"5 "encoding/hex"
6 "fmt"6 "fmt"
7 "path/filepath"7 "path/filepath"
8+ "regexp"
8 "strconv"9 "strconv"
9 "strings"10 "strings"
10 11
11 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
12 )13 )
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+
14 func (r *Repository) sessionPath(key string) string {29 func (r *Repository) sessionPath(key string) string {
15 return filepath.Join(r.dir, repositorySessionsDir, key)30 return filepath.Join(r.dir, repositorySessionsDir, key)
16 }31 }
D internal/adapters/snapshot/store.go +0 -139
@@ -1,139 +0,0 @@
1-package snapshot
2-
3-import (
4- "crypto/sha256"
5- "encoding/hex"
6- "errors"
7- "fmt"
8- "log/slog"
9- "os"
10- "path/filepath"
11- "regexp"
12- "strings"
13-
14- "github.com/bnema/vev/internal/ports"
15- "github.com/bnema/vev/pkg/safedir"
16-)
17-
18-var safeNameRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,200}$`)
19-
20-const maxSnapshotFileSize = 16 + (256 << 20)
21-
22-// Store persists raw session snapshot blobs on disk.
23-type Store struct {
24- dir string
25- log *slog.Logger
26-}
27-
28-var _ ports.SnapshotStore = (*Store)(nil)
29-
30-// NewStore creates a file-backed snapshot store rooted at dir.
31-func NewStore(dir string) *Store {
32- return &Store{dir: dir, log: slog.Default()}
33-}
34-
35-// Write atomically writes data for name. The previous snapshot remains in place
36-// until the final rename succeeds.
37-func (s *Store) Write(name string, data []byte) error {
38- if err := safedir.EnsurePrivate(s.dir); err != nil {
39- return fmt.Errorf("create snapshot dir: %w", err)
40- }
41-
42- tmp, err := os.CreateTemp(s.dir, ".tmp-*")
43- if err != nil {
44- return fmt.Errorf("create temp snapshot: %w", err)
45- }
46- tmpName := tmp.Name()
47- keepTmp := false
48- defer func() {
49- if !keepTmp {
50- _ = os.Remove(tmpName)
51- }
52- }()
53-
54- if err := tmp.Chmod(0o600); err != nil {
55- _ = tmp.Close()
56- return fmt.Errorf("chmod temp snapshot: %w", err)
57- }
58- if _, err := tmp.Write(data); err != nil {
59- _ = tmp.Close()
60- return fmt.Errorf("write temp snapshot: %w", err)
61- }
62- if err := tmp.Sync(); err != nil {
63- _ = tmp.Close()
64- return fmt.Errorf("sync temp snapshot: %w", err)
65- }
66- if err := tmp.Close(); err != nil {
67- return fmt.Errorf("close temp snapshot: %w", err)
68- }
69-
70- path := filepath.Join(s.dir, filenameForName(name))
71- if err := os.Rename(tmpName, path); err != nil {
72- return fmt.Errorf("rename snapshot: %w", err)
73- }
74- keepTmp = true
75- if err := syncDirectory(s.dir); err != nil {
76- return fmt.Errorf("sync snapshot dir: %w", err)
77- }
78- return nil
79-}
80-
81-// Load returns all raw .snap blobs. It does not validate the blob format.
82-func (s *Store) Load() ([]ports.SnapshotBlob, error) {
83- entries, err := os.ReadDir(s.dir)
84- if errors.Is(err, os.ErrNotExist) {
85- return nil, nil
86- }
87- if err != nil {
88- return nil, fmt.Errorf("read snapshot dir: %w", err)
89- }
90-
91- blobs := make([]ports.SnapshotBlob, 0, len(entries))
92- for _, entry := range entries {
93- base := entry.Name()
94- path := filepath.Join(s.dir, base)
95- if strings.HasPrefix(base, ".tmp-") {
96- if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
97- s.log.Warn("remove stale snapshot temp", "path", path, "err", err)
98- }
99- continue
100- }
101- if entry.IsDir() || !strings.HasSuffix(base, ".snap") {
102- continue
103- }
104- data, err := readBounded(path)
105- if err != nil {
106- s.log.Warn("read snapshot", "path", path, "err", err)
107- continue
108- }
109- name := strings.TrimSuffix(base, ".snap")
110- if strings.HasPrefix(name, "@") || !safeNameRE.MatchString(name) {
111- name = ""
112- }
113- blobs = append(blobs, ports.SnapshotBlob{Name: name, Data: data})
114- }
115- return blobs, nil
116-}
117-
118-// Delete removes the deterministic snapshot file for name. Missing files are ignored.
119-func (s *Store) Delete(name string) error {
120- err := os.Remove(filepath.Join(s.dir, filenameForName(name)))
121- if errors.Is(err, os.ErrNotExist) {
122- return nil
123- }
124- if err != nil {
125- return fmt.Errorf("delete snapshot: %w", err)
126- }
127- if err := syncDirectory(s.dir); err != nil {
128- return fmt.Errorf("sync snapshot dir: %w", err)
129- }
130- return nil
131-}
132-
133-func filenameForName(name string) string {
134- if safeNameRE.MatchString(name) {
135- return name + ".snap"
136- }
137- sum := sha256.Sum256([]byte(name))
138- return "@" + hex.EncodeToString(sum[:])[:40] + ".snap"
139-}
@@ -1,139 +0,0 @@
1-package snapshot
2-
3-import (
4- "crypto/sha256"
5- "encoding/hex"
6- "errors"
7- "fmt"
8- "log/slog"
9- "os"
10- "path/filepath"
11- "regexp"
12- "strings"
13-
14- "github.com/bnema/vev/internal/ports"
15- "github.com/bnema/vev/pkg/safedir"
16-)
17-
18-var safeNameRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,200}$`)
19-
20-const maxSnapshotFileSize = 16 + (256 << 20)
21-
22-// Store persists raw session snapshot blobs on disk.
23-type Store struct {
24- dir string
25- log *slog.Logger
26-}
27-
28-var _ ports.SnapshotStore = (*Store)(nil)
29-
30-// NewStore creates a file-backed snapshot store rooted at dir.
31-func NewStore(dir string) *Store {
32- return &Store{dir: dir, log: slog.Default()}
33-}
34-
35-// Write atomically writes data for name. The previous snapshot remains in place
36-// until the final rename succeeds.
37-func (s *Store) Write(name string, data []byte) error {
38- if err := safedir.EnsurePrivate(s.dir); err != nil {
39- return fmt.Errorf("create snapshot dir: %w", err)
40- }
41-
42- tmp, err := os.CreateTemp(s.dir, ".tmp-*")
43- if err != nil {
44- return fmt.Errorf("create temp snapshot: %w", err)
45- }
46- tmpName := tmp.Name()
47- keepTmp := false
48- defer func() {
49- if !keepTmp {
50- _ = os.Remove(tmpName)
51- }
52- }()
53-
54- if err := tmp.Chmod(0o600); err != nil {
55- _ = tmp.Close()
56- return fmt.Errorf("chmod temp snapshot: %w", err)
57- }
58- if _, err := tmp.Write(data); err != nil {
59- _ = tmp.Close()
60- return fmt.Errorf("write temp snapshot: %w", err)
61- }
62- if err := tmp.Sync(); err != nil {
63- _ = tmp.Close()
64- return fmt.Errorf("sync temp snapshot: %w", err)
65- }
66- if err := tmp.Close(); err != nil {
67- return fmt.Errorf("close temp snapshot: %w", err)
68- }
69-
70- path := filepath.Join(s.dir, filenameForName(name))
71- if err := os.Rename(tmpName, path); err != nil {
72- return fmt.Errorf("rename snapshot: %w", err)
73- }
74- keepTmp = true
75- if err := syncDirectory(s.dir); err != nil {
76- return fmt.Errorf("sync snapshot dir: %w", err)
77- }
78- return nil
79-}
80-
81-// Load returns all raw .snap blobs. It does not validate the blob format.
82-func (s *Store) Load() ([]ports.SnapshotBlob, error) {
83- entries, err := os.ReadDir(s.dir)
84- if errors.Is(err, os.ErrNotExist) {
85- return nil, nil
86- }
87- if err != nil {
88- return nil, fmt.Errorf("read snapshot dir: %w", err)
89- }
90-
91- blobs := make([]ports.SnapshotBlob, 0, len(entries))
92- for _, entry := range entries {
93- base := entry.Name()
94- path := filepath.Join(s.dir, base)
95- if strings.HasPrefix(base, ".tmp-") {
96- if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
97- s.log.Warn("remove stale snapshot temp", "path", path, "err", err)
98- }
99- continue
100- }
101- if entry.IsDir() || !strings.HasSuffix(base, ".snap") {
102- continue
103- }
104- data, err := readBounded(path)
105- if err != nil {
106- s.log.Warn("read snapshot", "path", path, "err", err)
107- continue
108- }
109- name := strings.TrimSuffix(base, ".snap")
110- if strings.HasPrefix(name, "@") || !safeNameRE.MatchString(name) {
111- name = ""
112- }
113- blobs = append(blobs, ports.SnapshotBlob{Name: name, Data: data})
114- }
115- return blobs, nil
116-}
117-
118-// Delete removes the deterministic snapshot file for name. Missing files are ignored.
119-func (s *Store) Delete(name string) error {
120- err := os.Remove(filepath.Join(s.dir, filenameForName(name)))
121- if errors.Is(err, os.ErrNotExist) {
122- return nil
123- }
124- if err != nil {
125- return fmt.Errorf("delete snapshot: %w", err)
126- }
127- if err := syncDirectory(s.dir); err != nil {
128- return fmt.Errorf("sync snapshot dir: %w", err)
129- }
130- return nil
131-}
132-
133-func filenameForName(name string) string {
134- if safeNameRE.MatchString(name) {
135- return name + ".snap"
136- }
137- sum := sha256.Sum256([]byte(name))
138- return "@" + hex.EncodeToString(sum[:])[:40] + ".snap"
139-}
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-}
@@ -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+}
@@ -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+}
M internal/app/run_test.go +5 -8
@@ -352,10 +352,7 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
352352 if err := p.Close(); err != nil {
353353 t.Fatalf("Close error = %v", err)
354354 }
355- snapshots := snapshot.NewStore(filepath.Join(stateRoot, "vev", "snapshots"))
356- if err := snapshots.Write("stored", []byte("snapshot bytes")); err != nil {
357- t.Fatalf("snapshot Write error = %v", err)
358- }
355+ snapshots := snapshot.NewRepository(filepath.Join(stateRoot, "vev", "snapshots"))
359356
360357 got := captureStdout(t, func() {
361358 if err := runKill(context.Background(), "stored", false, false); err != nil {
@@ -372,12 +369,12 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
372369 if len(records) != 0 {
373370 t.Fatalf("records after kill = %#v, want none", records)
374371 }
375- blobs, err := snapshots.Load()
372+ names, err := snapshots.List(context.Background())
376373 if err != nil {
377- t.Fatalf("snapshot Load error = %v", err)
374+ t.Fatalf("snapshot List error = %v", err)
378375 }
379- if len(blobs) != 0 {
380- t.Fatalf("snapshots after kill = %#v, want none", blobs)
376+ if len(names) != 0 {
377+ t.Fatalf("snapshots after kill = %#v, want none", names)
381378 }
382379 }
383380
@@ -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 +0 -190
@@ -336,196 +336,6 @@ func (_c *MockStore_Sync_Call) RunAndReturn(run func() error) *MockStore_Sync_Ca
336336 return _c
337337 }
338338
339-// 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.
340-// The first argument is typically a *testing.T value.
341-func NewMockSnapshotStore(t interface {
342- mock.TestingT
343- Cleanup(func())
344-}) *MockSnapshotStore {
345- mock := &MockSnapshotStore{}
346- mock.Mock.Test(t)
347-
348- t.Cleanup(func() { mock.AssertExpectations(t) })
349-
350- return mock
351-}
352-
353-// MockSnapshotStore is an autogenerated mock type for the SnapshotStore type
354-type MockSnapshotStore struct {
355- mock.Mock
356-}
357-
358-type MockSnapshotStore_Expecter struct {
359- mock *mock.Mock
360-}
361-
362-func (_m *MockSnapshotStore) EXPECT() *MockSnapshotStore_Expecter {
363- return &MockSnapshotStore_Expecter{mock: &_m.Mock}
364-}
365-
366-// Delete provides a mock function for the type MockSnapshotStore
367-func (_mock *MockSnapshotStore) Delete(name string) error {
368- ret := _mock.Called(name)
369-
370- if len(ret) == 0 {
371- panic("no return value specified for Delete")
372- }
373-
374- var r0 error
375- if returnFunc, ok := ret.Get(0).(func(string) error); ok {
376- r0 = returnFunc(name)
377- } else {
378- r0 = ret.Error(0)
379- }
380- return r0
381-}
382-
383-// MockSnapshotStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
384-type MockSnapshotStore_Delete_Call struct {
385- *mock.Call
386-}
387-
388-// Delete is a helper method to define mock.On call
389-// - name string
390-func (_e *MockSnapshotStore_Expecter) Delete(name any) *MockSnapshotStore_Delete_Call {
391- return &MockSnapshotStore_Delete_Call{Call: _e.mock.On("Delete", name)}
392-}
393-
394-func (_c *MockSnapshotStore_Delete_Call) Run(run func(name string)) *MockSnapshotStore_Delete_Call {
395- _c.Call.Run(func(args mock.Arguments) {
396- var arg0 string
397- if args[0] != nil {
398- arg0 = args[0].(string)
399- }
400- run(
401- arg0,
402- )
403- })
404- return _c
405-}
406-
407-func (_c *MockSnapshotStore_Delete_Call) Return(err error) *MockSnapshotStore_Delete_Call {
408- _c.Call.Return(err)
409- return _c
410-}
411-
412-func (_c *MockSnapshotStore_Delete_Call) RunAndReturn(run func(name string) error) *MockSnapshotStore_Delete_Call {
413- _c.Call.Return(run)
414- return _c
415-}
416-
417-// Load provides a mock function for the type MockSnapshotStore
418-func (_mock *MockSnapshotStore) Load() ([]ports.SnapshotBlob, error) {
419- ret := _mock.Called()
420-
421- if len(ret) == 0 {
422- panic("no return value specified for Load")
423- }
424-
425- var r0 []ports.SnapshotBlob
426- var r1 error
427- if returnFunc, ok := ret.Get(0).(func() ([]ports.SnapshotBlob, error)); ok {
428- return returnFunc()
429- }
430- if returnFunc, ok := ret.Get(0).(func() []ports.SnapshotBlob); ok {
431- r0 = returnFunc()
432- } else {
433- if ret.Get(0) != nil {
434- r0 = ret.Get(0).([]ports.SnapshotBlob)
435- }
436- }
437- if returnFunc, ok := ret.Get(1).(func() error); ok {
438- r1 = returnFunc()
439- } else {
440- r1 = ret.Error(1)
441- }
442- return r0, r1
443-}
444-
445-// MockSnapshotStore_Load_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Load'
446-type MockSnapshotStore_Load_Call struct {
447- *mock.Call
448-}
449-
450-// Load is a helper method to define mock.On call
451-func (_e *MockSnapshotStore_Expecter) Load() *MockSnapshotStore_Load_Call {
452- return &MockSnapshotStore_Load_Call{Call: _e.mock.On("Load")}
453-}
454-
455-func (_c *MockSnapshotStore_Load_Call) Run(run func()) *MockSnapshotStore_Load_Call {
456- _c.Call.Run(func(args mock.Arguments) {
457- run()
458- })
459- return _c
460-}
461-
462-func (_c *MockSnapshotStore_Load_Call) Return(snapshotBlobs []ports.SnapshotBlob, err error) *MockSnapshotStore_Load_Call {
463- _c.Call.Return(snapshotBlobs, err)
464- return _c
465-}
466-
467-func (_c *MockSnapshotStore_Load_Call) RunAndReturn(run func() ([]ports.SnapshotBlob, error)) *MockSnapshotStore_Load_Call {
468- _c.Call.Return(run)
469- return _c
470-}
471-
472-// Write provides a mock function for the type MockSnapshotStore
473-func (_mock *MockSnapshotStore) Write(name string, data []byte) error {
474- ret := _mock.Called(name, data)
475-
476- if len(ret) == 0 {
477- panic("no return value specified for Write")
478- }
479-
480- var r0 error
481- if returnFunc, ok := ret.Get(0).(func(string, []byte) error); ok {
482- r0 = returnFunc(name, data)
483- } else {
484- r0 = ret.Error(0)
485- }
486- return r0
487-}
488-
489-// MockSnapshotStore_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write'
490-type MockSnapshotStore_Write_Call struct {
491- *mock.Call
492-}
493-
494-// Write is a helper method to define mock.On call
495-// - name string
496-// - data []byte
497-func (_e *MockSnapshotStore_Expecter) Write(name any, data any) *MockSnapshotStore_Write_Call {
498- return &MockSnapshotStore_Write_Call{Call: _e.mock.On("Write", name, data)}
499-}
500-
501-func (_c *MockSnapshotStore_Write_Call) Run(run func(name string, data []byte)) *MockSnapshotStore_Write_Call {
502- _c.Call.Run(func(args mock.Arguments) {
503- var arg0 string
504- if args[0] != nil {
505- arg0 = args[0].(string)
506- }
507- var arg1 []byte
508- if args[1] != nil {
509- arg1 = args[1].([]byte)
510- }
511- run(
512- arg0,
513- arg1,
514- )
515- })
516- return _c
517-}
518-
519-func (_c *MockSnapshotStore_Write_Call) Return(err error) *MockSnapshotStore_Write_Call {
520- _c.Call.Return(err)
521- return _c
522-}
523-
524-func (_c *MockSnapshotStore_Write_Call) RunAndReturn(run func(name string, data []byte) error) *MockSnapshotStore_Write_Call {
525- _c.Call.Return(run)
526- return _c
527-}
528-
529339 // 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.
530340 // The first argument is typically a *testing.T value.
531341 func NewMockSnapshotRepository(t interface {
@@ -336,196 +336,6 @@ func (_c *MockStore_Sync_Call) RunAndReturn(run func() error) *MockStore_Sync_Ca
336 return _c336 return _c
337 }337 }
338 338
339-// 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.
340-// The first argument is typically a *testing.T value.
341-func NewMockSnapshotStore(t interface {
342- mock.TestingT
343- Cleanup(func())
344-}) *MockSnapshotStore {
345- mock := &MockSnapshotStore{}
346- mock.Mock.Test(t)
347-
348- t.Cleanup(func() { mock.AssertExpectations(t) })
349-
350- return mock
351-}
352-
353-// MockSnapshotStore is an autogenerated mock type for the SnapshotStore type
354-type MockSnapshotStore struct {
355- mock.Mock
356-}
357-
358-type MockSnapshotStore_Expecter struct {
359- mock *mock.Mock
360-}
361-
362-func (_m *MockSnapshotStore) EXPECT() *MockSnapshotStore_Expecter {
363- return &MockSnapshotStore_Expecter{mock: &_m.Mock}
364-}
365-
366-// Delete provides a mock function for the type MockSnapshotStore
367-func (_mock *MockSnapshotStore) Delete(name string) error {
368- ret := _mock.Called(name)
369-
370- if len(ret) == 0 {
371- panic("no return value specified for Delete")
372- }
373-
374- var r0 error
375- if returnFunc, ok := ret.Get(0).(func(string) error); ok {
376- r0 = returnFunc(name)
377- } else {
378- r0 = ret.Error(0)
379- }
380- return r0
381-}
382-
383-// MockSnapshotStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
384-type MockSnapshotStore_Delete_Call struct {
385- *mock.Call
386-}
387-
388-// Delete is a helper method to define mock.On call
389-// - name string
390-func (_e *MockSnapshotStore_Expecter) Delete(name any) *MockSnapshotStore_Delete_Call {
391- return &MockSnapshotStore_Delete_Call{Call: _e.mock.On("Delete", name)}
392-}
393-
394-func (_c *MockSnapshotStore_Delete_Call) Run(run func(name string)) *MockSnapshotStore_Delete_Call {
395- _c.Call.Run(func(args mock.Arguments) {
396- var arg0 string
397- if args[0] != nil {
398- arg0 = args[0].(string)
399- }
400- run(
401- arg0,
402- )
403- })
404- return _c
405-}
406-
407-func (_c *MockSnapshotStore_Delete_Call) Return(err error) *MockSnapshotStore_Delete_Call {
408- _c.Call.Return(err)
409- return _c
410-}
411-
412-func (_c *MockSnapshotStore_Delete_Call) RunAndReturn(run func(name string) error) *MockSnapshotStore_Delete_Call {
413- _c.Call.Return(run)
414- return _c
415-}
416-
417-// Load provides a mock function for the type MockSnapshotStore
418-func (_mock *MockSnapshotStore) Load() ([]ports.SnapshotBlob, error) {
419- ret := _mock.Called()
420-
421- if len(ret) == 0 {
422- panic("no return value specified for Load")
423- }
424-
425- var r0 []ports.SnapshotBlob
426- var r1 error
427- if returnFunc, ok := ret.Get(0).(func() ([]ports.SnapshotBlob, error)); ok {
428- return returnFunc()
429- }
430- if returnFunc, ok := ret.Get(0).(func() []ports.SnapshotBlob); ok {
431- r0 = returnFunc()
432- } else {
433- if ret.Get(0) != nil {
434- r0 = ret.Get(0).([]ports.SnapshotBlob)
435- }
436- }
437- if returnFunc, ok := ret.Get(1).(func() error); ok {
438- r1 = returnFunc()
439- } else {
440- r1 = ret.Error(1)
441- }
442- return r0, r1
443-}
444-
445-// MockSnapshotStore_Load_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Load'
446-type MockSnapshotStore_Load_Call struct {
447- *mock.Call
448-}
449-
450-// Load is a helper method to define mock.On call
451-func (_e *MockSnapshotStore_Expecter) Load() *MockSnapshotStore_Load_Call {
452- return &MockSnapshotStore_Load_Call{Call: _e.mock.On("Load")}
453-}
454-
455-func (_c *MockSnapshotStore_Load_Call) Run(run func()) *MockSnapshotStore_Load_Call {
456- _c.Call.Run(func(args mock.Arguments) {
457- run()
458- })
459- return _c
460-}
461-
462-func (_c *MockSnapshotStore_Load_Call) Return(snapshotBlobs []ports.SnapshotBlob, err error) *MockSnapshotStore_Load_Call {
463- _c.Call.Return(snapshotBlobs, err)
464- return _c
465-}
466-
467-func (_c *MockSnapshotStore_Load_Call) RunAndReturn(run func() ([]ports.SnapshotBlob, error)) *MockSnapshotStore_Load_Call {
468- _c.Call.Return(run)
469- return _c
470-}
471-
472-// Write provides a mock function for the type MockSnapshotStore
473-func (_mock *MockSnapshotStore) Write(name string, data []byte) error {
474- ret := _mock.Called(name, data)
475-
476- if len(ret) == 0 {
477- panic("no return value specified for Write")
478- }
479-
480- var r0 error
481- if returnFunc, ok := ret.Get(0).(func(string, []byte) error); ok {
482- r0 = returnFunc(name, data)
483- } else {
484- r0 = ret.Error(0)
485- }
486- return r0
487-}
488-
489-// MockSnapshotStore_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write'
490-type MockSnapshotStore_Write_Call struct {
491- *mock.Call
492-}
493-
494-// Write is a helper method to define mock.On call
495-// - name string
496-// - data []byte
497-func (_e *MockSnapshotStore_Expecter) Write(name any, data any) *MockSnapshotStore_Write_Call {
498- return &MockSnapshotStore_Write_Call{Call: _e.mock.On("Write", name, data)}
499-}
500-
501-func (_c *MockSnapshotStore_Write_Call) Run(run func(name string, data []byte)) *MockSnapshotStore_Write_Call {
502- _c.Call.Run(func(args mock.Arguments) {
503- var arg0 string
504- if args[0] != nil {
505- arg0 = args[0].(string)
506- }
507- var arg1 []byte
508- if args[1] != nil {
509- arg1 = args[1].([]byte)
510- }
511- run(
512- arg0,
513- arg1,
514- )
515- })
516- return _c
517-}
518-
519-func (_c *MockSnapshotStore_Write_Call) Return(err error) *MockSnapshotStore_Write_Call {
520- _c.Call.Return(err)
521- return _c
522-}
523-
524-func (_c *MockSnapshotStore_Write_Call) RunAndReturn(run func(name string, data []byte) error) *MockSnapshotStore_Write_Call {
525- _c.Call.Return(run)
526- return _c
527-}
528-
529 // 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.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.
530 // The first argument is typically a *testing.T value.340 // The first argument is typically a *testing.T value.
531 func NewMockSnapshotRepository(t interface {341 func NewMockSnapshotRepository(t interface {
D internal/ports/snapshot_test.go +0 -59
@@ -1,59 +0,0 @@
1-package ports
2-
3-import (
4- "context"
5- "crypto/sha256"
6- "testing"
7-)
8-
9-type snapshotRepositoryContract struct{}
10-
11-func (snapshotRepositoryContract) Publish(context.Context, SnapshotPublication) error { return nil }
12-func (snapshotRepositoryContract) List(context.Context) ([]string, error) { return nil, nil }
13-func (snapshotRepositoryContract) Load(context.Context, string) (SnapshotGeneration, error) {
14- return SnapshotGeneration{}, nil
15-}
16-func (snapshotRepositoryContract) Delete(context.Context, string) error { return nil }
17-func (snapshotRepositoryContract) Maintain(context.Context) error { return nil }
18-
19-type legacySnapshotSourceContract struct{}
20-
21-func (legacySnapshotSourceContract) LoadLegacy(context.Context) ([]LegacySnapshot, error) {
22- return nil, nil
23-}
24-func (legacySnapshotSourceContract) DeleteLegacy(context.Context, string) error { return nil }
25-
26-func TestSnapshotRepositoryContracts(t *testing.T) {
27- var _ SnapshotRepository = snapshotRepositoryContract{}
28- var _ LegacySnapshotSource = legacySnapshotSourceContract{}
29-
30- publication := SnapshotPublication{
31- Name: "named-session",
32- Generation: 7,
33- Manifest: []byte("VEVM"),
34- Objects: []SnapshotObject{{Digest: SnapshotDigest{1}, Data: []byte("VEVO")}},
35- }
36- generation := SnapshotGeneration{
37- Name: publication.Name,
38- Generation: publication.Generation,
39- Manifest: publication.Manifest,
40- Objects: map[SnapshotDigest][]byte{publication.Objects[0].Digest: publication.Objects[0].Data},
41- Fallback: true,
42- }
43- if publication.Name != "named-session" || generation.Generation != 7 || !generation.Fallback {
44- t.Fatalf("snapshot publication/generation = %#v", generation)
45- }
46- if len(SnapshotDigest{}) != sha256.Size {
47- t.Fatalf("SnapshotDigest has length %d, want %d", len(SnapshotDigest{}), sha256.Size)
48- }
49-}
50-
51-type snapshotStoreContract struct{}
52-
53-func (snapshotStoreContract) Write(name string, data []byte) error { return nil }
54-func (snapshotStoreContract) Load() ([]SnapshotBlob, error) { return nil, nil }
55-func (snapshotStoreContract) Delete(name string) error { return nil }
56-
57-func TestSnapshotStoreContract(t *testing.T) {
58- var _ SnapshotStore = snapshotStoreContract{}
59-}
@@ -1,59 +0,0 @@
1-package ports
2-
3-import (
4- "context"
5- "crypto/sha256"
6- "testing"
7-)
8-
9-type snapshotRepositoryContract struct{}
10-
11-func (snapshotRepositoryContract) Publish(context.Context, SnapshotPublication) error { return nil }
12-func (snapshotRepositoryContract) List(context.Context) ([]string, error) { return nil, nil }
13-func (snapshotRepositoryContract) Load(context.Context, string) (SnapshotGeneration, error) {
14- return SnapshotGeneration{}, nil
15-}
16-func (snapshotRepositoryContract) Delete(context.Context, string) error { return nil }
17-func (snapshotRepositoryContract) Maintain(context.Context) error { return nil }
18-
19-type legacySnapshotSourceContract struct{}
20-
21-func (legacySnapshotSourceContract) LoadLegacy(context.Context) ([]LegacySnapshot, error) {
22- return nil, nil
23-}
24-func (legacySnapshotSourceContract) DeleteLegacy(context.Context, string) error { return nil }
25-
26-func TestSnapshotRepositoryContracts(t *testing.T) {
27- var _ SnapshotRepository = snapshotRepositoryContract{}
28- var _ LegacySnapshotSource = legacySnapshotSourceContract{}
29-
30- publication := SnapshotPublication{
31- Name: "named-session",
32- Generation: 7,
33- Manifest: []byte("VEVM"),
34- Objects: []SnapshotObject{{Digest: SnapshotDigest{1}, Data: []byte("VEVO")}},
35- }
36- generation := SnapshotGeneration{
37- Name: publication.Name,
38- Generation: publication.Generation,
39- Manifest: publication.Manifest,
40- Objects: map[SnapshotDigest][]byte{publication.Objects[0].Digest: publication.Objects[0].Data},
41- Fallback: true,
42- }
43- if publication.Name != "named-session" || generation.Generation != 7 || !generation.Fallback {
44- t.Fatalf("snapshot publication/generation = %#v", generation)
45- }
46- if len(SnapshotDigest{}) != sha256.Size {
47- t.Fatalf("SnapshotDigest has length %d, want %d", len(SnapshotDigest{}), sha256.Size)
48- }
49-}
50-
51-type snapshotStoreContract struct{}
52-
53-func (snapshotStoreContract) Write(name string, data []byte) error { return nil }
54-func (snapshotStoreContract) Load() ([]SnapshotBlob, error) { return nil, nil }
55-func (snapshotStoreContract) Delete(name string) error { return nil }
56-
57-func TestSnapshotStoreContract(t *testing.T) {
58- var _ SnapshotStore = snapshotStoreContract{}
59-}
M internal/ports/store.go +0 -13
@@ -22,19 +22,6 @@ type Store interface {
2222 Close() error
2323 }
2424
25-// SnapshotBlob is a durable named session snapshot payload.
26-type SnapshotBlob struct {
27- Name string
28- Data []byte
29-}
30-
31-// SnapshotStore persists encoded session snapshots by name.
32-type SnapshotStore interface {
33- Write(name string, data []byte) error
34- Load() ([]SnapshotBlob, error)
35- Delete(name string) error
36-}
37-
3825 // SnapshotDigest is the SHA-256 content address of a complete snapshot object.
3926 type SnapshotDigest [sha256.Size]byte
4027
@@ -22,19 +22,6 @@ type Store interface {
22 Close() error22 Close() error
23 }23 }
24 24
25-// SnapshotBlob is a durable named session snapshot payload.
26-type SnapshotBlob struct {
27- Name string
28- Data []byte
29-}
30-
31-// SnapshotStore persists encoded session snapshots by name.
32-type SnapshotStore interface {
33- Write(name string, data []byte) error
34- Load() ([]SnapshotBlob, error)
35- Delete(name string) error
36-}
37-
38 // SnapshotDigest is the SHA-256 content address of a complete snapshot object.25 // SnapshotDigest is the SHA-256 content address of a complete snapshot object.
39 type SnapshotDigest [sha256.Size]byte26 type SnapshotDigest [sha256.Size]byte
40 27
M internal/usecase/daemon/daemon.go +3 -16
@@ -117,12 +117,10 @@ type Daemon struct {
117117 shellOverride bool
118118 persist *persist.Persister
119119 persistEnabled bool
120- // snapshotRepository is the sole production checkpoint contract. snaps is
121- // retained temporarily only for the v3 test bridge; application wiring
122- // always installs snapshotRepository.
120+ // snapshotRepository is the sole checkpoint contract. legacySnapshots is
121+ // read-only migration input and is never used for new writes.
123122 snapshotRepository ports.SnapshotRepository
124123 legacySnapshots ports.LegacySnapshotSource
125- snaps ports.SnapshotStore
126124 snapsEnabled bool
127125 noticeStore ports.NoticeStore
128126 snapshotMarshal func(snapcodec.Session) ([]byte, error)
@@ -265,15 +263,6 @@ func WithSnapshotRepository(repository ports.SnapshotRepository, legacy ports.Le
265263 }
266264 }
267265
268-// WithSnapshotStore is the v3 compatibility seam for existing embedders. New
269-// application wiring must use WithSnapshotRepository.
270-func WithSnapshotStore(store ports.SnapshotStore) Option {
271- return func(d *Daemon) {
272- d.snaps = store
273- d.snapsEnabled = store != nil
274- }
275-}
276-
277266 // WithNoticeStore enables persisting undeliverable notices across daemon
278267 // restarts. A nil store keeps the daemon in no-op notice-persistence mode.
279268 func WithNoticeStore(store ports.NoticeStore) Option {
@@ -478,7 +467,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
478467 if d.snapsEnabled {
479468 d.startSnapshotEncodeWorker()
480469 d.sessWg.Go(func() {
481- d.snapshotSaver(d.serveCtx)
470+ d.snapshotRepositorySaver(d.serveCtx)
482471 })
483472 d.sessWg.Go(func() {
484473 d.restoreSnapshots(d.serveCtx)
@@ -661,8 +650,6 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
661650 var deleteErr error
662651 if d.snapshotRepository != nil {
663652 deleteErr = d.snapshotRepository.Delete(context.Background(), k.Name)
664- } else if d.snaps != nil {
665- deleteErr = d.snaps.Delete(k.Name)
666653 }
667654 if deleteErr != nil {
668655 d.log.Warn("deleting stopped session snapshot failed", "err", deleteErr, "session", k.Name)
@@ -117,12 +117,10 @@ type Daemon struct {
117 shellOverride bool117 shellOverride bool
118 persist *persist.Persister118 persist *persist.Persister
119 persistEnabled bool119 persistEnabled bool
120- // snapshotRepository is the sole production checkpoint contract. snaps is120+ // snapshotRepository is the sole checkpoint contract. legacySnapshots is
121- // retained temporarily only for the v3 test bridge; application wiring121+ // read-only migration input and is never used for new writes.
122- // always installs snapshotRepository.
123 snapshotRepository ports.SnapshotRepository122 snapshotRepository ports.SnapshotRepository
124 legacySnapshots ports.LegacySnapshotSource123 legacySnapshots ports.LegacySnapshotSource
125- snaps ports.SnapshotStore
126 snapsEnabled bool124 snapsEnabled bool
127 noticeStore ports.NoticeStore125 noticeStore ports.NoticeStore
128 snapshotMarshal func(snapcodec.Session) ([]byte, error)126 snapshotMarshal func(snapcodec.Session) ([]byte, error)
@@ -265,15 +263,6 @@ func WithSnapshotRepository(repository ports.SnapshotRepository, legacy ports.Le
265 }263 }
266 }264 }
267 265
268-// WithSnapshotStore is the v3 compatibility seam for existing embedders. New
269-// application wiring must use WithSnapshotRepository.
270-func WithSnapshotStore(store ports.SnapshotStore) Option {
271- return func(d *Daemon) {
272- d.snaps = store
273- d.snapsEnabled = store != nil
274- }
275-}
276-
277 // WithNoticeStore enables persisting undeliverable notices across daemon266 // WithNoticeStore enables persisting undeliverable notices across daemon
278 // restarts. A nil store keeps the daemon in no-op notice-persistence mode.267 // restarts. A nil store keeps the daemon in no-op notice-persistence mode.
279 func WithNoticeStore(store ports.NoticeStore) Option {268 func WithNoticeStore(store ports.NoticeStore) Option {
@@ -478,7 +467,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
478 if d.snapsEnabled {467 if d.snapsEnabled {
479 d.startSnapshotEncodeWorker()468 d.startSnapshotEncodeWorker()
480 d.sessWg.Go(func() {469 d.sessWg.Go(func() {
481- d.snapshotSaver(d.serveCtx)470+ d.snapshotRepositorySaver(d.serveCtx)
482 })471 })
483 d.sessWg.Go(func() {472 d.sessWg.Go(func() {
484 d.restoreSnapshots(d.serveCtx)473 d.restoreSnapshots(d.serveCtx)
@@ -661,8 +650,6 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
661 var deleteErr error650 var deleteErr error
662 if d.snapshotRepository != nil {651 if d.snapshotRepository != nil {
663 deleteErr = d.snapshotRepository.Delete(context.Background(), k.Name)652 deleteErr = d.snapshotRepository.Delete(context.Background(), k.Name)
664- } else if d.snaps != nil {
665- deleteErr = d.snaps.Delete(k.Name)
666 }653 }
667 if deleteErr != nil {654 if deleteErr != nil {
668 d.log.Warn("deleting stopped session snapshot failed", "err", deleteErr, "session", k.Name)655 d.log.Warn("deleting stopped session snapshot failed", "err", deleteErr, "session", k.Name)
M internal/usecase/daemon/notice_persist_test.go +0 -90
@@ -1,17 +1,13 @@
11 package daemon
22
33 import (
4- "fmt"
5- "sync/atomic"
64 "testing"
75
8- "github.com/stretchr/testify/mock"
96 "github.com/stretchr/testify/require"
107
118 "github.com/bnema/vev/internal/domain"
129 "github.com/bnema/vev/internal/ports"
1310 portsmocks "github.com/bnema/vev/internal/ports/mocks"
14- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
1511 )
1612
1713 // TestKillSessionServerShutdownSaturatedAppendsNotice proves that when the
@@ -19,92 +15,6 @@ import (
1915 // no way to retain the session's terminal state and no attached client to
2016 // toast, because the daemon is dying — the failure is handed to the
2117 // NoticeStore instead of being silently lost.
22-func TestKillSessionServerShutdownSaturatedAppendsNotice(t *testing.T) {
23- store := discardSnapshotStore{}
24- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
25- WithSnapshotStore(store)(d)
26- startSnapshotEncodeWorker(t, d)
27-
28- entered := make(chan struct{})
29- release := make(chan struct{})
30- defer close(release)
31- var calls atomic.Int32
32- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
33- if calls.Add(1) == 1 {
34- close(entered)
35- <-release
36- }
37- return snapcodec.Marshal(s)
38- }
39-
40- active := newSnapshotTestSession(t, "active", false, "/work")
41- queued := newSnapshotTestSession(t, "queued", false, "/work")
42- markSnapshotDirty(active)
43- markSnapshotDirty(queued)
44- require.True(t, d.scheduleSnapshot(active))
45- <-entered // the worker is now parked inside marshal for active
46- require.True(t, d.scheduleSnapshot(queued), "one capture waits in the bounded queue")
47- for i := range snapshotFinalQueueCapacity {
48- s := newSnapshotTestSession(t, fmt.Sprintf("terminal-%d", i), false, "/work")
49- markSnapshotDirty(s)
50- require.True(t, d.scheduleFinalSnapshot(s), "fill the terminal-retention fallback to capacity")
51- }
52-
53- target := newSnapshotTestSession(t, "target", false, "/work")
54- targetPTY, ok := target.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
55- require.True(t, ok, "target pane PTY must be a MockPTY")
56- targetPTY.EXPECT().Close().Return(nil).Maybe()
57- d.mu.Lock()
58- d.sessions[target.id] = target
59- d.mu.Unlock()
60- markSnapshotDirty(target)
61-
62- notices := portsmocks.NewMockNoticeStore(t)
63- var appended domain.Notification
64- notices.EXPECT().Append(mock.Anything).RunAndReturn(func(n domain.Notification) error {
65- appended = n
66- return nil
67- }).Once()
68- d.noticeStore = notices
69-
70- err := d.killSession(target, ports.ReasonServerShutdown, false)
71- require.ErrorContains(t, err, "snapshot worker unavailable or saturated")
72-
73- d.mu.Lock()
74- _, stillPresent := d.sessions[target.id]
75- d.mu.Unlock()
76- require.False(t, stillPresent, "the session must be torn down even though its state could not be saved")
77-
78- require.Equal(t, domain.NoticeSnapshotWrite, appended.Code)
79- require.Equal(t, domain.NoticeError, appended.Severity)
80- require.Equal(t, "session target shut down without saving terminal state", appended.Message)
81- require.NotEmpty(t, appended.Details)
82-}
83-
84-// TestKillSessionServerShutdownNotSaturatedSkipsAppend proves the notice store
85-// is left untouched when the final snapshot is retained normally.
86-func TestKillSessionServerShutdownNotSaturatedSkipsAppend(t *testing.T) {
87- store := discardSnapshotStore{}
88- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
89- WithSnapshotStore(store)(d)
90- startSnapshotEncodeWorker(t, d)
91-
92- notices := portsmocks.NewMockNoticeStore(t) // no .EXPECT(): Append must not be called
93- d.noticeStore = notices
94-
95- target := newSnapshotTestSession(t, "target", false, "/work")
96- targetPTY, ok := target.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
97- require.True(t, ok, "target pane PTY must be a MockPTY")
98- targetPTY.EXPECT().Close().Return(nil).Maybe()
99- d.mu.Lock()
100- d.sessions[target.id] = target
101- d.mu.Unlock()
102- markSnapshotDirty(target)
103-
104- err := d.killSession(target, ports.ReasonServerShutdown, false)
105- require.NoError(t, err)
106-}
107-
10818 // TestStartupClaimRecordsAndToastsPendingNotices proves notices persisted by
10919 // a previous daemon are recorded to history and toasted to the first client
11020 // that attaches after this daemon starts before their claim is acknowledged.
@@ -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/performance_benchmark_test.go +31 -23
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "context"
45 "fmt"
56 "io"
67 "sync"
@@ -15,16 +16,16 @@ import (
1516 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
1617 )
1718
18-func TestCountingSnapshotStoreWriteCountsAndRetainsOpaquePayload(t *testing.T) {
19- store := &countingSnapshotStore{}
19+func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) {
20+ repository := &countingSnapshotRepository{}
2021 payload := []byte{0xff, 0x00, 0xfe}
2122
22- require.NoError(t, store.Write("session", payload))
23- require.Equal(t, countingSnapshotMetrics{writes: 1, bytes: uint64(len(payload))}, store.metrics())
24- require.Equal(t, payload, store.lastPayload())
23+ require.NoError(t, repository.Publish(context.Background(), ports.SnapshotPublication{Name: "session", Manifest: payload}))
24+ require.Equal(t, countingSnapshotMetrics{writes: 1, bytes: uint64(len(payload))}, repository.metrics())
25+ require.Equal(t, payload, repository.lastPayload())
2526
2627 payload[0] = 0x01
27- require.Equal(t, payload, store.lastPayload(), "the counting sink must retain the payload slice header, not copy it")
28+ require.Equal(t, payload, repository.lastPayload(), "the counting sink must retain the payload slice header, not copy it")
2829 }
2930
3031 func TestCountingOutputTransportCountsOpaquePayloadAndRejectsShortPayload(t *testing.T) {
@@ -157,13 +158,13 @@ func TestPerformanceFixtureSnapshotCaptureRetainsSealedHistory(t *testing.T) {
157158 require.True(t, ok)
158159 require.Len(t, first.tabs, 1)
159160 require.Len(t, first.tabs[0].panes, 2)
160- require.Equal(t, 10_000, first.tabs[0].panes[0].history.Len())
161- require.Equal(t, 40, first.tabs[0].panes[0].history.ChunkCount())
161+ require.Equal(t, 10_000, first.tabs[0].panes[0].sealed.Len())
162+ require.Positive(t, first.tabs[0].panes[0].sealed.ChunkCount())
162163
163164 second, ok := fixture.d.captureSnapshotState(fixture.sess, 2)
164165 require.True(t, ok)
165166 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) {
167+ if secondPane == nil || first.tabs[0].panes[0].sealed.Chunk(0) != secondPane.sealed.Chunk(0) {
167168 t.Fatal("unchanged sealed history chunk was not reused across captures")
168169 }
169170 }
@@ -674,7 +675,7 @@ type performanceFixture struct {
674675 sess *session
675676 ac *attachedClient
676677 output *countingOutputTransport
677- snaps *countingSnapshotStore
678+ snapshots *countingSnapshotRepository
678679 activePane *pane
679680 liveWrites [][]byte
680681 paints int
@@ -727,13 +728,13 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
727728 sess: sess,
728729 ac: ac,
729730 output: output,
730- snaps: &countingSnapshotStore{},
731+ snapshots: &countingSnapshotRepository{},
731732 pty: pty,
732733 clock: clock,
733734 liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")},
734735 resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size},
735736 }
736- WithSnapshotStore(fixture.snaps)(d)
737+ WithSnapshotRepository(fixture.snapshots, nil)(d)
737738 d.startSnapshotEncodeWorker()
738739 t.Cleanup(d.stopSnapshotEncodeWorker)
739740 for tabIndex, tb := range sess.tabs {
@@ -927,7 +928,7 @@ func (f *performanceFixture) resized() bool {
927928
928929 func (f *performanceFixture) resetMetrics() {
929930 f.output.reset()
930- f.snaps.reset()
931+ f.snapshots.reset()
931932 f.renderCaptures = 0
932933 f.renderCompositions = 0
933934 f.renderEmissions = 0
@@ -943,7 +944,7 @@ func (f *performanceFixture) resetMetrics() {
943944
944945 func (f *performanceFixture) metrics() performanceMetrics {
945946 output := f.output.metrics()
946- snapshots := f.snaps.metrics()
947+ snapshots := f.snapshots.metrics()
947948 metrics := performanceMetrics{
948949 outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes,
949950 snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes,
@@ -1011,34 +1012,41 @@ func (t *countingOutputTransport) lastPayload() []byte {
10111012 }
10121013
10131014 type countingSnapshotMetrics struct{ writes, bytes uint64 }
1014-type countingSnapshotStore struct {
1015+type countingSnapshotRepository struct {
10151016 mu sync.Mutex
10161017 countingSnapshotMetrics
10171018 last []byte
10181019 }
10191020
1020-func (s *countingSnapshotStore) Write(_ string, data []byte) error {
1021+func (s *countingSnapshotRepository) Publish(_ context.Context, publication ports.SnapshotPublication) error {
10211022 s.mu.Lock()
10221023 s.writes++
1023- s.bytes += uint64(len(data))
1024- s.last = data
1024+ s.bytes += uint64(len(publication.Manifest))
1025+ for _, object := range publication.Objects {
1026+ s.bytes += uint64(len(object.Data))
1027+ }
1028+ s.last = publication.Manifest
10251029 s.mu.Unlock()
10261030 return nil
10271031 }
1028-func (*countingSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
1029-func (*countingSnapshotStore) Delete(string) error { return nil }
1030-func (s *countingSnapshotStore) reset() {
1032+func (*countingSnapshotRepository) List(context.Context) ([]string, error) { return nil, nil }
1033+func (*countingSnapshotRepository) Load(context.Context, string) (ports.SnapshotGeneration, error) {
1034+ return ports.SnapshotGeneration{}, nil
1035+}
1036+func (*countingSnapshotRepository) Delete(context.Context, string) error { return nil }
1037+func (*countingSnapshotRepository) Maintain(context.Context) error { return nil }
1038+func (s *countingSnapshotRepository) reset() {
10311039 s.mu.Lock()
10321040 s.countingSnapshotMetrics = countingSnapshotMetrics{}
10331041 s.last = nil
10341042 s.mu.Unlock()
10351043 }
1036-func (s *countingSnapshotStore) metrics() countingSnapshotMetrics {
1044+func (s *countingSnapshotRepository) metrics() countingSnapshotMetrics {
10371045 s.mu.Lock()
10381046 defer s.mu.Unlock()
10391047 return s.countingSnapshotMetrics
10401048 }
1041-func (s *countingSnapshotStore) lastPayload() []byte {
1049+func (s *countingSnapshotRepository) lastPayload() []byte {
10421050 s.mu.Lock()
10431051 defer s.mu.Unlock()
10441052 return s.last
@@ -1,6 +1,7 @@
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"
@@ -15,16 +16,16 @@ import (
15 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"16 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
16 )17 )
17 18
18-func TestCountingSnapshotStoreWriteCountsAndRetainsOpaquePayload(t *testing.T) {19+func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) {
19- store := &countingSnapshotStore{}20+ repository := &countingSnapshotRepository{}
20 payload := []byte{0xff, 0x00, 0xfe}21 payload := []byte{0xff, 0x00, 0xfe}
21 22
22- require.NoError(t, store.Write("session", payload))23+ require.NoError(t, repository.Publish(context.Background(), ports.SnapshotPublication{Name: "session", Manifest: payload}))
23- require.Equal(t, countingSnapshotMetrics{writes: 1, bytes: uint64(len(payload))}, store.metrics())24+ require.Equal(t, countingSnapshotMetrics{writes: 1, bytes: uint64(len(payload))}, repository.metrics())
24- require.Equal(t, payload, store.lastPayload())25+ require.Equal(t, payload, repository.lastPayload())
25 26
26 payload[0] = 0x0127 payload[0] = 0x01
27- require.Equal(t, payload, store.lastPayload(), "the counting sink must retain the payload slice header, not copy it")28+ require.Equal(t, payload, repository.lastPayload(), "the counting sink must retain the payload slice header, not copy it")
28 }29 }
29 30
30 func TestCountingOutputTransportCountsOpaquePayloadAndRejectsShortPayload(t *testing.T) {31 func TestCountingOutputTransportCountsOpaquePayloadAndRejectsShortPayload(t *testing.T) {
@@ -157,13 +158,13 @@ func TestPerformanceFixtureSnapshotCaptureRetainsSealedHistory(t *testing.T) {
157 require.True(t, ok)158 require.True(t, ok)
158 require.Len(t, first.tabs, 1)159 require.Len(t, first.tabs, 1)
159 require.Len(t, first.tabs[0].panes, 2)160 require.Len(t, first.tabs[0].panes, 2)
160- require.Equal(t, 10_000, first.tabs[0].panes[0].history.Len())161+ require.Equal(t, 10_000, first.tabs[0].panes[0].sealed.Len())
161- require.Equal(t, 40, first.tabs[0].panes[0].history.ChunkCount())162+ require.Positive(t, first.tabs[0].panes[0].sealed.ChunkCount())
162 163
163 second, ok := fixture.d.captureSnapshotState(fixture.sess, 2)164 second, ok := fixture.d.captureSnapshotState(fixture.sess, 2)
164 require.True(t, ok)165 require.True(t, ok)
165 secondPane := capturePaneByID(second, first.tabs[0].panes[0].id)166 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) {167+ 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")168 t.Fatal("unchanged sealed history chunk was not reused across captures")
168 }169 }
169 }170 }
@@ -674,7 +675,7 @@ type performanceFixture struct {
674 sess *session675 sess *session
675 ac *attachedClient676 ac *attachedClient
676 output *countingOutputTransport677 output *countingOutputTransport
677- snaps *countingSnapshotStore678+ snapshots *countingSnapshotRepository
678 activePane *pane679 activePane *pane
679 liveWrites [][]byte680 liveWrites [][]byte
680 paints int681 paints int
@@ -727,13 +728,13 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
727 sess: sess,728 sess: sess,
728 ac: ac,729 ac: ac,
729 output: output,730 output: output,
730- snaps: &countingSnapshotStore{},731+ snapshots: &countingSnapshotRepository{},
731 pty: pty,732 pty: pty,
732 clock: clock,733 clock: clock,
733 liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")},734 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},735 resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size},
735 }736 }
736- WithSnapshotStore(fixture.snaps)(d)737+ WithSnapshotRepository(fixture.snapshots, nil)(d)
737 d.startSnapshotEncodeWorker()738 d.startSnapshotEncodeWorker()
738 t.Cleanup(d.stopSnapshotEncodeWorker)739 t.Cleanup(d.stopSnapshotEncodeWorker)
739 for tabIndex, tb := range sess.tabs {740 for tabIndex, tb := range sess.tabs {
@@ -927,7 +928,7 @@ func (f *performanceFixture) resized() bool {
927 928
928 func (f *performanceFixture) resetMetrics() {929 func (f *performanceFixture) resetMetrics() {
929 f.output.reset()930 f.output.reset()
930- f.snaps.reset()931+ f.snapshots.reset()
931 f.renderCaptures = 0932 f.renderCaptures = 0
932 f.renderCompositions = 0933 f.renderCompositions = 0
933 f.renderEmissions = 0934 f.renderEmissions = 0
@@ -943,7 +944,7 @@ func (f *performanceFixture) resetMetrics() {
943 944
944 func (f *performanceFixture) metrics() performanceMetrics {945 func (f *performanceFixture) metrics() performanceMetrics {
945 output := f.output.metrics()946 output := f.output.metrics()
946- snapshots := f.snaps.metrics()947+ snapshots := f.snapshots.metrics()
947 metrics := performanceMetrics{948 metrics := performanceMetrics{
948 outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes,949 outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes,
949 snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes,950 snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes,
@@ -1011,34 +1012,41 @@ func (t *countingOutputTransport) lastPayload() []byte {
1011 }1012 }
1012 1013
1013 type countingSnapshotMetrics struct{ writes, bytes uint64 }1014 type countingSnapshotMetrics struct{ writes, bytes uint64 }
1014-type countingSnapshotStore struct {1015+type countingSnapshotRepository struct {
1015 mu sync.Mutex1016 mu sync.Mutex
1016 countingSnapshotMetrics1017 countingSnapshotMetrics
1017 last []byte1018 last []byte
1018 }1019 }
1019 1020
1020-func (s *countingSnapshotStore) Write(_ string, data []byte) error {1021+func (s *countingSnapshotRepository) Publish(_ context.Context, publication ports.SnapshotPublication) error {
1021 s.mu.Lock()1022 s.mu.Lock()
1022 s.writes++1023 s.writes++
1023- s.bytes += uint64(len(data))1024+ s.bytes += uint64(len(publication.Manifest))
1024- s.last = data1025+ for _, object := range publication.Objects {
1026+ s.bytes += uint64(len(object.Data))
1027+ }
1028+ s.last = publication.Manifest
1025 s.mu.Unlock()1029 s.mu.Unlock()
1026 return nil1030 return nil
1027 }1031 }
1028-func (*countingSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }1032+func (*countingSnapshotRepository) List(context.Context) ([]string, error) { return nil, nil }
1029-func (*countingSnapshotStore) Delete(string) error { return nil }1033+func (*countingSnapshotRepository) Load(context.Context, string) (ports.SnapshotGeneration, error) {
1030-func (s *countingSnapshotStore) reset() {1034+ return ports.SnapshotGeneration{}, nil
1035+}
1036+func (*countingSnapshotRepository) Delete(context.Context, string) error { return nil }
1037+func (*countingSnapshotRepository) Maintain(context.Context) error { return nil }
1038+func (s *countingSnapshotRepository) reset() {
1031 s.mu.Lock()1039 s.mu.Lock()
1032 s.countingSnapshotMetrics = countingSnapshotMetrics{}1040 s.countingSnapshotMetrics = countingSnapshotMetrics{}
1033 s.last = nil1041 s.last = nil
1034 s.mu.Unlock()1042 s.mu.Unlock()
1035 }1043 }
1036-func (s *countingSnapshotStore) metrics() countingSnapshotMetrics {1044+func (s *countingSnapshotRepository) metrics() countingSnapshotMetrics {
1037 s.mu.Lock()1045 s.mu.Lock()
1038 defer s.mu.Unlock()1046 defer s.mu.Unlock()
1039 return s.countingSnapshotMetrics1047 return s.countingSnapshotMetrics
1040 }1048 }
1041-func (s *countingSnapshotStore) lastPayload() []byte {1049+func (s *countingSnapshotRepository) lastPayload() []byte {
1042 s.mu.Lock()1050 s.mu.Lock()
1043 defer s.mu.Unlock()1051 defer s.mu.Unlock()
1044 return s.last1052 return s.last
M internal/usecase/daemon/picker_test.go +11 -5
@@ -35,11 +35,17 @@ func (failingDeleteStore) Range(func(k, v []byte) bool) {}
3535 func (failingDeleteStore) Sync() error { return nil }
3636 func (failingDeleteStore) Close() error { return nil }
3737
38-type refusingSnapshotDeleteStore struct{ err error }
38+type refusingSnapshotDeleteRepository struct{ err error }
3939
40-func (refusingSnapshotDeleteStore) Write(string, []byte) error { return nil }
41-func (refusingSnapshotDeleteStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
42-func (s refusingSnapshotDeleteStore) Delete(string) error { return s.err }
40+func (refusingSnapshotDeleteRepository) Publish(context.Context, ports.SnapshotPublication) error {
41+ return nil
42+}
43+func (refusingSnapshotDeleteRepository) List(context.Context) ([]string, error) { return nil, nil }
44+func (refusingSnapshotDeleteRepository) Load(context.Context, string) (ports.SnapshotGeneration, error) {
45+ return ports.SnapshotGeneration{}, nil
46+}
47+func (s refusingSnapshotDeleteRepository) Delete(context.Context, string) error { return s.err }
48+func (refusingSnapshotDeleteRepository) Maintain(context.Context) error { return nil }
4349
4450 func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab {
4551 tb := newTab(p, domain.Size{Cols: 80, Rows: 23})
@@ -998,7 +1004,7 @@ func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t
9981004 d, from, ac, sends, releases := newRecentNavigationTestSessions(t)
9991005 defer releaseAll(releases)
10001006 cause := errors.New("snapshot delete refused")
1001- WithSnapshotStore(refusingSnapshotDeleteStore{err: cause})(d)
1007+ WithSnapshotRepository(refusingSnapshotDeleteRepository{err: cause}, nil)(d)
10021008 target := d.sessions[domain.SessionID("recent")]
10031009
10041010 d.enterPicker(from, ac)
@@ -35,11 +35,17 @@ 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) Maintain(context.Context) error { return nil }
43 49
44 func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab {50 func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab {
45 tb := newTab(p, domain.Size{Cols: 80, Rows: 23})51 tb := newTab(p, domain.Size{Cols: 80, Rows: 23})
@@ -998,7 +1004,7 @@ func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t
998 d, from, ac, sends, releases := newRecentNavigationTestSessions(t)1004 d, from, ac, sends, releases := newRecentNavigationTestSessions(t)
999 defer releaseAll(releases)1005 defer releaseAll(releases)
1000 cause := errors.New("snapshot delete refused")1006 cause := errors.New("snapshot delete refused")
1001- WithSnapshotStore(refusingSnapshotDeleteStore{err: cause})(d)1007+ WithSnapshotRepository(refusingSnapshotDeleteRepository{err: cause}, nil)(d)
1002 target := d.sessions[domain.SessionID("recent")]1008 target := d.sessions[domain.SessionID("recent")]
1003 1009
1004 d.enterPicker(from, ac)1010 d.enterPicker(from, ac)
M internal/usecase/daemon/session.go +0 -16
@@ -570,14 +570,6 @@ func (d *Daemon) renameSession(sess *session, name string) error {
570570 sess.mu.Unlock()
571571 return err
572572 }
573- } else if d.snapsEnabled && d.snaps != nil {
574- if err := d.snaps.Delete(oldName); err != nil {
575- if cleanupErr := d.persist.Delete(name); cleanupErr != nil {
576- d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name)
577- }
578- sess.mu.Unlock()
579- return err
580- }
581573 }
582574 if err := d.persist.Delete(oldName); err != nil {
583575 if cleanupErr := d.persist.Delete(name); cleanupErr != nil {
@@ -745,18 +737,10 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
745737 var snapshotDeleteErr, terminalSnapshotErr error
746738 if d.snapsEnabled && !isEphemeral {
747739 if purge {
748- sess.mu.Lock()
749- name := sess.name
750- sess.mu.Unlock()
751740 if d.snapshotRepository != nil {
752741 // Repository deletion is intentionally deferred until the render
753742 // coordinator is cancelled and joined below. See client.go's lock
754743 // ordering notes: no coordinator callback may race a purge.
755- } else if d.snaps != nil {
756- if err := d.snaps.Delete(name); err != nil {
757- snapshotDeleteErr = err
758- d.log.Warn("deleting session snapshot failed", "err", err, "session", name)
759- }
760744 }
761745 } else {
762746 markSnapshotDirty(sess)
@@ -570,14 +570,6 @@ func (d *Daemon) renameSession(sess *session, name string) error {
570 sess.mu.Unlock()570 sess.mu.Unlock()
571 return err571 return err
572 }572 }
573- } else if d.snapsEnabled && d.snaps != nil {
574- if err := d.snaps.Delete(oldName); err != nil {
575- if cleanupErr := d.persist.Delete(name); cleanupErr != nil {
576- d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name)
577- }
578- sess.mu.Unlock()
579- return err
580- }
581 }573 }
582 if err := d.persist.Delete(oldName); err != nil {574 if err := d.persist.Delete(oldName); err != nil {
583 if cleanupErr := d.persist.Delete(name); cleanupErr != nil {575 if cleanupErr := d.persist.Delete(name); cleanupErr != nil {
@@ -745,18 +737,10 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
745 var snapshotDeleteErr, terminalSnapshotErr error737 var snapshotDeleteErr, terminalSnapshotErr error
746 if d.snapsEnabled && !isEphemeral {738 if d.snapsEnabled && !isEphemeral {
747 if purge {739 if purge {
748- sess.mu.Lock()
749- name := sess.name
750- sess.mu.Unlock()
751 if d.snapshotRepository != nil {740 if d.snapshotRepository != nil {
752 // Repository deletion is intentionally deferred until the render741 // Repository deletion is intentionally deferred until the render
753 // coordinator is cancelled and joined below. See client.go's lock742 // coordinator is cancelled and joined below. See client.go's lock
754 // ordering notes: no coordinator callback may race a purge.743 // ordering notes: no coordinator callback may race a purge.
755- } else if d.snaps != nil {
756- if err := d.snaps.Delete(name); err != nil {
757- snapshotDeleteErr = err
758- d.log.Warn("deleting session snapshot failed", "err", err, "session", name)
759- }
760 }744 }
761 } else {745 } else {
762 markSnapshotDirty(sess)746 markSnapshotDirty(sess)
M internal/usecase/daemon/snapshot.go +8 -86
@@ -124,39 +124,8 @@ func (d *Daemon) restoreSnapshots(ctx context.Context) {
124124 }
125125 if d.snapshotRepository != nil {
126126 d.restoreIncrementalSnapshots(ctx)
127- return
128- }
129- if d.snaps == nil {
130- return
131- }
132- blobs, err := d.snaps.Load()
133- if err != nil {
134- d.log.Warn("loading session snapshots failed", "err", err)
135- d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore,
136- "couldn't load saved sessions after restart", err)
137- return
138- }
139- for _, blob := range blobs {
140- select {
141- case <-ctx.Done():
142- return
143- default:
144- }
145- snap, err := snapcodec.Unmarshal(blob.Data)
146- if err != nil {
147- d.log.Warn("decoding session snapshot failed", "err", err, "session", blob.Name)
148- d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore,
149- "couldn't restore session "+blob.Name+" after restart", err)
150- continue
151- }
152- if err := d.restoreSession(ctx, snap); err != nil {
153- d.log.Warn("restoring session snapshot failed", "err", err, "session", snap.Name)
154- if ctx.Err() == nil {
155- d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore,
156- "couldn't restore session "+snap.Name+" after restart", err)
157- }
158- }
159127 }
128+
160129 }
161130
162131 func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) error {
@@ -410,14 +379,14 @@ func (d *Daemon) scheduleSnapshot(sess *session) bool {
410379 }
411380
412381 // scheduleFinalSnapshot captures the terminal state even when an older capture
413-// is pending. A removed session cannot be retried by snapshotSaver, so the
382+// is pending. A removed session cannot be retried by the repository scheduler, so the
414383 // worker drains its retained terminal state before it stops.
415384 func (d *Daemon) scheduleFinalSnapshot(sess *session) bool {
416385 return d.scheduleSnapshotWithFinalFallback(sess, true)
417386 }
418387
419388 func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bool {
420- if d == nil || sess == nil || !d.snapsEnabled || (d.snapshotRepository == nil && d.snaps == nil) || !sess.snapEligible.Load() {
389+ if d == nil || sess == nil || !d.snapsEnabled || d.snapshotRepository == nil || !sess.snapEligible.Load() {
421390 return true
422391 }
423392 kind := snapshotAttemptRoutine
@@ -565,12 +534,8 @@ func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapsh
565534 visible: visible,
566535 visibleErr: visibleErr,
567536 }
568- if d.snapshotRepository != nil {
569- paneCapture.sealed = p.history.SnapshotView()
570- paneCapture.tail = paneCapture.sealed.Tail()
571- } else {
572- paneCapture.history = p.history.SealAndView()
573- }
537+ paneCapture.sealed = p.history.SnapshotView()
538+ paneCapture.tail = paneCapture.sealed.Tail()
574539 p.mu.Unlock()
575540 paneCapture.cwd = fallbackCwd
576541 if d.procCwd != nil && pid > 0 {
@@ -734,20 +699,9 @@ func (d *Daemon) startSnapshotEncodeWorker() {
734699 d.finishSnapshotCapture(capture, false)
735700 return false
736701 }
737- var err error
738- if d.snapshotRepository != nil {
739- publication, publicationErr := d.incrementalPublication(capture)
740- if publicationErr != nil {
741- err = publicationErr
742- } else if workerCtx.Err() == nil {
743- err = d.snapshotRepository.Publish(workerCtx, publication)
744- }
745- } else {
746- data, encodeErr := d.encodeSnapshotCapture(capture)
747- err = encodeErr
748- if err == nil && workerCtx.Err() == nil {
749- err = d.snaps.Write(capture.name, data)
750- }
702+ publication, err := d.incrementalPublication(capture)
703+ if err == nil && workerCtx.Err() == nil {
704+ err = d.snapshotRepository.Publish(workerCtx, publication)
751705 }
752706 d.clearSnapshotWorkerInFlight(workerID, capture)
753707 if err != nil && workerCtx.Err() == nil {
@@ -922,38 +876,6 @@ func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error)
922876 }
923877 }
924878
925-func (d *Daemon) snapshotSaver(ctx context.Context) {
926- if d.snapshotRepository != nil {
927- d.snapshotRepositorySaver(ctx)
928- return
929- }
930- checkpoint := func() {
931- d.mu.Lock()
932- sessions := d.sessionsSnapshotLocked()
933- d.mu.Unlock()
934- for _, sess := range sessions {
935- if sess.snapEligible.Load() && sess.snapDirty.Load() {
936- d.scheduleSnapshot(sess)
937- }
938- }
939- }
940- // A session born dirty must not wait through a full interval before its
941- // first crash-safe checkpoint. Producers remain non-blocking because each
942- // schedule call only attempts the bounded handoff.
943- checkpoint()
944- t := d.clock.NewTimer(snapshotInterval)
945- defer t.Stop()
946- for {
947- select {
948- case <-ctx.Done():
949- return
950- case <-t.C():
951- checkpoint()
952- t.Reset(snapshotInterval)
953- }
954- }
955-}
956-
957879 // snapshotRepositorySaver waits until the earliest completion-derived routine
958880 // eligibility. State changes wake it immediately, so a newly dirty session is
959881 // captured without waiting for an unrelated session's interval.
@@ -124,39 +124,8 @@ func (d *Daemon) restoreSnapshots(ctx context.Context) {
124 }124 }
125 if d.snapshotRepository != nil {125 if d.snapshotRepository != nil {
126 d.restoreIncrementalSnapshots(ctx)126 d.restoreIncrementalSnapshots(ctx)
127- return
128- }
129- if d.snaps == nil {
130- return
131- }
132- blobs, err := d.snaps.Load()
133- if err != nil {
134- d.log.Warn("loading session snapshots failed", "err", err)
135- d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore,
136- "couldn't load saved sessions after restart", err)
137- return
138- }
139- for _, blob := range blobs {
140- select {
141- case <-ctx.Done():
142- return
143- default:
144- }
145- snap, err := snapcodec.Unmarshal(blob.Data)
146- if err != nil {
147- d.log.Warn("decoding session snapshot failed", "err", err, "session", blob.Name)
148- d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore,
149- "couldn't restore session "+blob.Name+" after restart", err)
150- continue
151- }
152- if err := d.restoreSession(ctx, snap); err != nil {
153- d.log.Warn("restoring session snapshot failed", "err", err, "session", snap.Name)
154- if ctx.Err() == nil {
155- d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore,
156- "couldn't restore session "+snap.Name+" after restart", err)
157- }
158- }
159 }127 }
128+
160 }129 }
161 130
162 func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) error {131 func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) error {
@@ -410,14 +379,14 @@ func (d *Daemon) scheduleSnapshot(sess *session) bool {
410 }379 }
411 380
412 // scheduleFinalSnapshot captures the terminal state even when an older capture381 // scheduleFinalSnapshot captures the terminal state even when an older capture
413-// is pending. A removed session cannot be retried by snapshotSaver, so the382+// is pending. A removed session cannot be retried by the repository scheduler, so the
414 // worker drains its retained terminal state before it stops.383 // worker drains its retained terminal state before it stops.
415 func (d *Daemon) scheduleFinalSnapshot(sess *session) bool {384 func (d *Daemon) scheduleFinalSnapshot(sess *session) bool {
416 return d.scheduleSnapshotWithFinalFallback(sess, true)385 return d.scheduleSnapshotWithFinalFallback(sess, true)
417 }386 }
418 387
419 func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bool {388 func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bool {
420- if d == nil || sess == nil || !d.snapsEnabled || (d.snapshotRepository == nil && d.snaps == nil) || !sess.snapEligible.Load() {389+ if d == nil || sess == nil || !d.snapsEnabled || d.snapshotRepository == nil || !sess.snapEligible.Load() {
421 return true390 return true
422 }391 }
423 kind := snapshotAttemptRoutine392 kind := snapshotAttemptRoutine
@@ -565,12 +534,8 @@ func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapsh
565 visible: visible,534 visible: visible,
566 visibleErr: visibleErr,535 visibleErr: visibleErr,
567 }536 }
568- if d.snapshotRepository != nil {537+ paneCapture.sealed = p.history.SnapshotView()
569- paneCapture.sealed = p.history.SnapshotView()538+ paneCapture.tail = paneCapture.sealed.Tail()
570- paneCapture.tail = paneCapture.sealed.Tail()
571- } else {
572- paneCapture.history = p.history.SealAndView()
573- }
574 p.mu.Unlock()539 p.mu.Unlock()
575 paneCapture.cwd = fallbackCwd540 paneCapture.cwd = fallbackCwd
576 if d.procCwd != nil && pid > 0 {541 if d.procCwd != nil && pid > 0 {
@@ -734,20 +699,9 @@ func (d *Daemon) startSnapshotEncodeWorker() {
734 d.finishSnapshotCapture(capture, false)699 d.finishSnapshotCapture(capture, false)
735 return false700 return false
736 }701 }
737- var err error702+ publication, err := d.incrementalPublication(capture)
738- if d.snapshotRepository != nil {703+ if err == nil && workerCtx.Err() == nil {
739- publication, publicationErr := d.incrementalPublication(capture)704+ err = d.snapshotRepository.Publish(workerCtx, publication)
740- if publicationErr != nil {
741- err = publicationErr
742- } else if workerCtx.Err() == nil {
743- err = d.snapshotRepository.Publish(workerCtx, publication)
744- }
745- } else {
746- data, encodeErr := d.encodeSnapshotCapture(capture)
747- err = encodeErr
748- if err == nil && workerCtx.Err() == nil {
749- err = d.snaps.Write(capture.name, data)
750- }
751 }705 }
752 d.clearSnapshotWorkerInFlight(workerID, capture)706 d.clearSnapshotWorkerInFlight(workerID, capture)
753 if err != nil && workerCtx.Err() == nil {707 if err != nil && workerCtx.Err() == nil {
@@ -922,38 +876,6 @@ func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error)
922 }876 }
923 }877 }
924 878
925-func (d *Daemon) snapshotSaver(ctx context.Context) {
926- if d.snapshotRepository != nil {
927- d.snapshotRepositorySaver(ctx)
928- return
929- }
930- checkpoint := func() {
931- d.mu.Lock()
932- sessions := d.sessionsSnapshotLocked()
933- d.mu.Unlock()
934- for _, sess := range sessions {
935- if sess.snapEligible.Load() && sess.snapDirty.Load() {
936- d.scheduleSnapshot(sess)
937- }
938- }
939- }
940- // A session born dirty must not wait through a full interval before its
941- // first crash-safe checkpoint. Producers remain non-blocking because each
942- // schedule call only attempts the bounded handoff.
943- checkpoint()
944- t := d.clock.NewTimer(snapshotInterval)
945- defer t.Stop()
946- for {
947- select {
948- case <-ctx.Done():
949- return
950- case <-t.C():
951- checkpoint()
952- t.Reset(snapshotInterval)
953- }
954- }
955-}
956-
957 // snapshotRepositorySaver waits until the earliest completion-derived routine879 // snapshotRepositorySaver waits until the earliest completion-derived routine
958 // eligibility. State changes wake it immediately, so a newly dirty session is880 // eligibility. State changes wake it immediately, so a newly dirty session is
959 // captured without waiting for an unrelated session's interval.881 // captured without waiting for an unrelated session's interval.
D internal/usecase/daemon/snapshot_acceptance_test.go +0 -249
@@ -1,249 +0,0 @@
1-package daemon
2-
3-import (
4- "testing"
5-
6- "github.com/stretchr/testify/require"
7-
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"
15- "github.com/bnema/vev/pkg/vt"
16-)
17-
18-func TestSnapshotCaptureMarshalUnmarshalRestorePreservesTopologyAndTerminalState(t *testing.T) {
19- store := &channelSnapshotStore{writes: make(chan []byte, 1)}
20- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
21- WithSnapshotStore(store)(d)
22- startSnapshotEncodeWorker(t, d)
23-
24- sess := newSnapshotTestSession(t, "acceptance", false, "/work")
25- first := sess.tabs[0]
26- first.size = domain.Size{Cols: 80, Rows: 24}
27- first.stableID = "tab-one"
28- first.nextPaneID = 3
29- first.panes["pane-1"].stableID = "pane-one"
30- secondPane := newPaneWithStableID("pane-2", "pane-two", snapshotAcceptancePTY(t), domain.Size{Cols: 40, Rows: 24})
31- require.NoError(t, 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- require.NoError(t, 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)
52- assertCapturedSessionTopology(t, captured)
53-
54- restoreFactory := &restorePTYFactory{}
55- restoredDaemon := newTestDaemon(t, restoreFactory, stubClock{})
56- require.NoError(t, restoredDaemon.restoreSession(t.Context(), captured))
57- restoredDaemon.mu.Lock()
58- restored := restoredDaemon.findByNameLocked("acceptance")
59- restoredDaemon.mu.Unlock()
60- require.NotNil(t, restored)
61- require.Equal(t, 1, restored.active)
62- require.Len(t, restored.tabs, 2)
63-
64- for _, tt := range []struct {
65- name string
66- tab int
67- pane layout.PaneID
68- wantRow string
69- wantFocus layout.PaneID
70- }{
71- {name: "first tab focused second pane", tab: 0, pane: "pane-2", wantRow: "two", wantFocus: "pane-2"},
72- {name: "second tab focused only pane", tab: 1, pane: "pane-1", wantRow: "three", wantFocus: "pane-1"},
73- } {
74- t.Run(tt.name, func(t *testing.T) {
75- tab := restored.tabs[tt.tab]
76- tab.mu.Lock()
77- defer tab.mu.Unlock()
78- require.Equal(t, tt.wantFocus, tab.tree.Focus)
79- pane := tab.panes[tt.pane]
80- require.NotNil(t, pane)
81- pane.mu.Lock()
82- defer pane.mu.Unlock()
83- require.Equal(t, tt.wantRow, rowText(pane.history.View().Row(0)))
84- })
85- }
86-}
87-
88-func TestSnapshotCaptureRetainsImmutableValuesAfterTailMutationAndEviction(t *testing.T) {
89- store := &channelSnapshotStore{writes: make(chan []byte, 1)}
90- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
91- WithSnapshotStore(store)(d)
92- startSnapshotEncodeWorker(t, d)
93-
94- entered := make(chan struct{})
95- release := make(chan struct{})
96- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
97- close(entered)
98- <-release
99- return snapcodec.Marshal(s)
100- }
101- sess := newSnapshotTestSession(t, "frozen", false, "/work")
102- pane := sess.tabs[0].panes["pane-1"]
103- pane.screen = vt.NewScreenWithHistory(8, 3, vt.HistoryConfig{MaxRows: 2, ChunkRows: 1})
104- pane.history = pane.screen.History()
105- require.NoError(t, 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- require.NoError(t, pane.history.Append([]renderer.Cell{{Rune: 'n'}, {Rune: 'e'}, {Rune: 'w'}}))
112- require.NoError(t, 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)
119- restoredHistory, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}, captured.Tabs[0].Panes[0].SealedChunks, captured.Tabs[0].Panes[0].Tail)
120- require.NoError(t, err)
121- require.Equal(t, "old", rowText(restoredHistory.View().Row(0)))
122- frame, err := vt.UnmarshalVisible(captured.Tabs[0].Panes[0].Visible)
123- require.NoError(t, err)
124- require.Contains(t, rowText(frame.Row(0)), "before")
125-}
126-
127-func TestDaemonHistoryEvictionLeavesCopyAndSearchSnapshotsUsable(t *testing.T) {
128- sess := newSnapshotTestSession(t, "copy-lifetime", false, "/work")
129- pane := sess.tabs[0].panes["pane-1"]
130- pane.screen = vt.NewScreenWithHistory(8, 1, vt.HistoryConfig{MaxRows: 2, ChunkRows: 1})
131- pane.history = pane.screen.History()
132- for _, text := range []string{"first", "second"} {
133- require.NoError(t, 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- require.NoError(t, 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-
182-func snapshotAcceptancePTY(t *testing.T) *portsmocks.MockPTY {
183- t.Helper()
184- pty := portsmocks.NewMockPTY(t)
185- pty.EXPECT().Pid().Return(0).Maybe()
186- pty.EXPECT().ForegroundPgid().Return(0, nil).Maybe()
187- return pty
188-}
189-
190-func assertCapturedSessionTopology(t *testing.T, got snapcodec.Session) {
191- t.Helper()
192- require.Equal(t, "acceptance", got.Name)
193- require.Equal(t, uint16(1), got.Active)
194- require.Len(t, got.Tabs, 2)
195- for _, tt := range []struct {
196- name string
197- tab int
198- stableID string
199- focus layout.PaneID
200- nextPaneID uint64
201- paneIDs []layout.PaneID
202- wantSplit bool
203- }{
204- {name: "first split tab", tab: 0, stableID: "tab-one", focus: "pane-2", nextPaneID: 3, paneIDs: []layout.PaneID{"pane-1", "pane-2"}, wantSplit: true},
205- {name: "second leaf tab", tab: 1, stableID: "tab-two", focus: "pane-1", nextPaneID: 2, paneIDs: []layout.PaneID{"pane-1"}},
206- } {
207- t.Run(tt.name, func(t *testing.T) {
208- tab := got.Tabs[tt.tab]
209- require.Equal(t, tt.stableID, tab.StableID)
210- require.Equal(t, tt.focus, tab.Focus)
211- require.Equal(t, tt.nextPaneID, tab.NextPaneID)
212- require.NotNil(t, tab.Tree)
213- require.Equal(t, tt.focus, tab.Tree.Focus)
214- if tt.wantSplit {
215- require.Equal(t, layout.Split, tab.Tree.Root.Kind)
216- }
217- ids := make(map[layout.PaneID]struct{}, len(tab.Panes))
218- for _, pane := range tab.Panes {
219- ids[pane.ID] = struct{}{}
220- }
221- for _, id := range tt.paneIDs {
222- _, ok := ids[id]
223- require.True(t, ok, "missing pane %q", id)
224- }
225- _, focused := ids[tab.Focus]
226- require.True(t, focused, "focus %q is dangling", tab.Focus)
227- assertTreeReferencesCapturedPanes(t, tab.Tree.Root, ids)
228- })
229- }
230-}
231-
232-func assertTreeReferencesCapturedPanes(t *testing.T, node *layout.Node, panes map[layout.PaneID]struct{}) {
233- t.Helper()
234- if node == nil {
235- return
236- }
237- if node.Kind == layout.Leaf {
238- _, ok := panes[node.Leaf]
239- require.True(t, ok, "tree leaf %q is dangling", node.Leaf)
240- return
241- }
242- if node.Kind == layout.Stack && node.Expanded != "" {
243- _, ok := panes[node.Expanded]
244- require.True(t, ok, "stack expanded pane %q is dangling", node.Expanded)
245- }
246- for _, child := range node.Children {
247- assertTreeReferencesCapturedPanes(t, child, panes)
248- }
249-}
@@ -1,249 +0,0 @@
1-package daemon
2-
3-import (
4- "testing"
5-
6- "github.com/stretchr/testify/require"
7-
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"
15- "github.com/bnema/vev/pkg/vt"
16-)
17-
18-func TestSnapshotCaptureMarshalUnmarshalRestorePreservesTopologyAndTerminalState(t *testing.T) {
19- store := &channelSnapshotStore{writes: make(chan []byte, 1)}
20- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
21- WithSnapshotStore(store)(d)
22- startSnapshotEncodeWorker(t, d)
23-
24- sess := newSnapshotTestSession(t, "acceptance", false, "/work")
25- first := sess.tabs[0]
26- first.size = domain.Size{Cols: 80, Rows: 24}
27- first.stableID = "tab-one"
28- first.nextPaneID = 3
29- first.panes["pane-1"].stableID = "pane-one"
30- secondPane := newPaneWithStableID("pane-2", "pane-two", snapshotAcceptancePTY(t), domain.Size{Cols: 40, Rows: 24})
31- require.NoError(t, 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- require.NoError(t, 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)
52- assertCapturedSessionTopology(t, captured)
53-
54- restoreFactory := &restorePTYFactory{}
55- restoredDaemon := newTestDaemon(t, restoreFactory, stubClock{})
56- require.NoError(t, restoredDaemon.restoreSession(t.Context(), captured))
57- restoredDaemon.mu.Lock()
58- restored := restoredDaemon.findByNameLocked("acceptance")
59- restoredDaemon.mu.Unlock()
60- require.NotNil(t, restored)
61- require.Equal(t, 1, restored.active)
62- require.Len(t, restored.tabs, 2)
63-
64- for _, tt := range []struct {
65- name string
66- tab int
67- pane layout.PaneID
68- wantRow string
69- wantFocus layout.PaneID
70- }{
71- {name: "first tab focused second pane", tab: 0, pane: "pane-2", wantRow: "two", wantFocus: "pane-2"},
72- {name: "second tab focused only pane", tab: 1, pane: "pane-1", wantRow: "three", wantFocus: "pane-1"},
73- } {
74- t.Run(tt.name, func(t *testing.T) {
75- tab := restored.tabs[tt.tab]
76- tab.mu.Lock()
77- defer tab.mu.Unlock()
78- require.Equal(t, tt.wantFocus, tab.tree.Focus)
79- pane := tab.panes[tt.pane]
80- require.NotNil(t, pane)
81- pane.mu.Lock()
82- defer pane.mu.Unlock()
83- require.Equal(t, tt.wantRow, rowText(pane.history.View().Row(0)))
84- })
85- }
86-}
87-
88-func TestSnapshotCaptureRetainsImmutableValuesAfterTailMutationAndEviction(t *testing.T) {
89- store := &channelSnapshotStore{writes: make(chan []byte, 1)}
90- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
91- WithSnapshotStore(store)(d)
92- startSnapshotEncodeWorker(t, d)
93-
94- entered := make(chan struct{})
95- release := make(chan struct{})
96- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
97- close(entered)
98- <-release
99- return snapcodec.Marshal(s)
100- }
101- sess := newSnapshotTestSession(t, "frozen", false, "/work")
102- pane := sess.tabs[0].panes["pane-1"]
103- pane.screen = vt.NewScreenWithHistory(8, 3, vt.HistoryConfig{MaxRows: 2, ChunkRows: 1})
104- pane.history = pane.screen.History()
105- require.NoError(t, 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- require.NoError(t, pane.history.Append([]renderer.Cell{{Rune: 'n'}, {Rune: 'e'}, {Rune: 'w'}}))
112- require.NoError(t, 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)
119- restoredHistory, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}, captured.Tabs[0].Panes[0].SealedChunks, captured.Tabs[0].Panes[0].Tail)
120- require.NoError(t, err)
121- require.Equal(t, "old", rowText(restoredHistory.View().Row(0)))
122- frame, err := vt.UnmarshalVisible(captured.Tabs[0].Panes[0].Visible)
123- require.NoError(t, err)
124- require.Contains(t, rowText(frame.Row(0)), "before")
125-}
126-
127-func TestDaemonHistoryEvictionLeavesCopyAndSearchSnapshotsUsable(t *testing.T) {
128- sess := newSnapshotTestSession(t, "copy-lifetime", false, "/work")
129- pane := sess.tabs[0].panes["pane-1"]
130- pane.screen = vt.NewScreenWithHistory(8, 1, vt.HistoryConfig{MaxRows: 2, ChunkRows: 1})
131- pane.history = pane.screen.History()
132- for _, text := range []string{"first", "second"} {
133- require.NoError(t, 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- require.NoError(t, 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-
182-func snapshotAcceptancePTY(t *testing.T) *portsmocks.MockPTY {
183- t.Helper()
184- pty := portsmocks.NewMockPTY(t)
185- pty.EXPECT().Pid().Return(0).Maybe()
186- pty.EXPECT().ForegroundPgid().Return(0, nil).Maybe()
187- return pty
188-}
189-
190-func assertCapturedSessionTopology(t *testing.T, got snapcodec.Session) {
191- t.Helper()
192- require.Equal(t, "acceptance", got.Name)
193- require.Equal(t, uint16(1), got.Active)
194- require.Len(t, got.Tabs, 2)
195- for _, tt := range []struct {
196- name string
197- tab int
198- stableID string
199- focus layout.PaneID
200- nextPaneID uint64
201- paneIDs []layout.PaneID
202- wantSplit bool
203- }{
204- {name: "first split tab", tab: 0, stableID: "tab-one", focus: "pane-2", nextPaneID: 3, paneIDs: []layout.PaneID{"pane-1", "pane-2"}, wantSplit: true},
205- {name: "second leaf tab", tab: 1, stableID: "tab-two", focus: "pane-1", nextPaneID: 2, paneIDs: []layout.PaneID{"pane-1"}},
206- } {
207- t.Run(tt.name, func(t *testing.T) {
208- tab := got.Tabs[tt.tab]
209- require.Equal(t, tt.stableID, tab.StableID)
210- require.Equal(t, tt.focus, tab.Focus)
211- require.Equal(t, tt.nextPaneID, tab.NextPaneID)
212- require.NotNil(t, tab.Tree)
213- require.Equal(t, tt.focus, tab.Tree.Focus)
214- if tt.wantSplit {
215- require.Equal(t, layout.Split, tab.Tree.Root.Kind)
216- }
217- ids := make(map[layout.PaneID]struct{}, len(tab.Panes))
218- for _, pane := range tab.Panes {
219- ids[pane.ID] = struct{}{}
220- }
221- for _, id := range tt.paneIDs {
222- _, ok := ids[id]
223- require.True(t, ok, "missing pane %q", id)
224- }
225- _, focused := ids[tab.Focus]
226- require.True(t, focused, "focus %q is dangling", tab.Focus)
227- assertTreeReferencesCapturedPanes(t, tab.Tree.Root, ids)
228- })
229- }
230-}
231-
232-func assertTreeReferencesCapturedPanes(t *testing.T, node *layout.Node, panes map[layout.PaneID]struct{}) {
233- t.Helper()
234- if node == nil {
235- return
236- }
237- if node.Kind == layout.Leaf {
238- _, ok := panes[node.Leaf]
239- require.True(t, ok, "tree leaf %q is dangling", node.Leaf)
240- return
241- }
242- if node.Kind == layout.Stack && node.Expanded != "" {
243- _, ok := panes[node.Expanded]
244- require.True(t, ok, "stack expanded pane %q is dangling", node.Expanded)
245- }
246- for _, child := range node.Children {
247- assertTreeReferencesCapturedPanes(t, child, panes)
248- }
249-}
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 state; 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.Equal(t, "publish:*errors.errorString", toasts[0].Details)
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-}
@@ -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 state; 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.Equal(t, "publish:*errors.errorString", toasts[0].Details)
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-}
D internal/usecase/daemon/snapshot_restore_test.go +0 -795
@@ -1,795 +0,0 @@
1-package daemon
2-
3-import (
4- "context"
5- "encoding/binary"
6- "errors"
7- "io"
8- "math"
9- "sync"
10- "testing"
11- "time"
12-
13- "github.com/stretchr/testify/require"
14-
15- "github.com/bnema/vev/internal/domain"
16- "github.com/bnema/vev/internal/ports"
17- scopy "github.com/bnema/vev/internal/usecase/copy"
18- "github.com/bnema/vev/internal/usecase/layout"
19- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
20- "github.com/bnema/vev/pkg/renderer"
21- "github.com/bnema/vev/pkg/vt"
22-)
23-
24-func terminalPane(t *testing.T, id, stableID, cwd string, historyRows, visibleRows [][]renderer.Cell) snapcodec.Pane {
25- t.Helper()
26- h := vt.NewHistory(vt.HistoryConfig{MaxRows: defaultScrollbackRows})
27- for _, row := range historyRows {
28- require.NoError(t, h.Append(row))
29- }
30- sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView())
31- require.NoError(t, err)
32- frame := renderer.NewFrame(40, 24)
33- for y, row := range visibleRows {
34- copy(frame.Row(y), row)
35- }
36- visible, err := vt.MarshalVisible(frame)
37- require.NoError(t, err)
38- return snapcodec.Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible}
39-}
40-
41-func TestRestorePaneTerminalRejectsMissingOrMalformedCanonicalBlobs(t *testing.T) {
42- valid := terminalPane(t, "pane", "p", "/work", nil, nil)
43- tests := []struct {
44- name string
45- mutate func(*snapcodec.Pane)
46- }{
47- {name: "missing tail", mutate: func(p *snapcodec.Pane) { p.Tail = nil }},
48- {name: "missing visible", mutate: func(p *snapcodec.Pane) { p.Visible = nil }},
49- {name: "malformed tail", mutate: func(p *snapcodec.Pane) { p.Tail = []byte("bad") }},
50- {name: "malformed visible", mutate: func(p *snapcodec.Pane) { p.Visible = []byte("bad") }},
51- }
52- for _, tt := range tests {
53- t.Run(tt.name, func(t *testing.T) {
54- snap := valid
55- tt.mutate(&snap)
56- p := newPane("pane", newRestorePTY(), domain.Size{Cols: 40, Rows: 24})
57- require.Error(t, restorePaneTerminal(p, snap))
58- })
59- }
60-}
61-
62-func TestRestoreSnapshotsNilDaemonDoesNotPanic(t *testing.T) {
63- var d *Daemon
64- require.NotPanics(t, func() { d.restoreSnapshots(t.Context()) })
65-}
66-
67-func TestRestoreSessionRejectsInvalidActiveTab(t *testing.T) {
68- factory := &restorePTYFactory{}
69- d := newTestDaemon(t, factory, stubClock{})
70-
71- err := d.restoreSession(context.Background(), snapcodec.Session{Name: "bad-active", Active: 1, Tabs: []snapcodec.Tab{{
72- Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/tmp")},
73- }}})
74-
75- require.Error(t, err)
76- require.Empty(t, factory.opens)
77-}
78-
79-func TestRestoreSessionClosesOpenedPTYWhenTerminalRestoreFails(t *testing.T) {
80- factory := &restorePTYFactory{}
81- d := newTestDaemon(t, factory, stubClock{})
82- pane := emptyTerminalPane(t, "pane-1", "/tmp")
83- pane.Tail = []byte("malformed")
84-
85- err := d.restoreSession(context.Background(), snapcodec.Session{Name: "bad", Tabs: []snapcodec.Tab{{
86- Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{pane},
87- }}})
88-
89- require.Error(t, err)
90- require.Len(t, factory.opens, 1)
91- require.Equal(t, 1, factory.opens[0].pty.closeCount())
92-}
93-
94-func TestRestoreSessionUsesDaemonFallbackEnvironmentAndShellBeforeAttach(t *testing.T) {
95- factory := &restorePTYFactory{}
96- d := newTestDaemon(t, factory, stubClock{})
97- d.baseEnv = []string{"ORDINARY=preserved", "SHELL=/usr/local/bin/daemon-shell", "TERM=stale", "RAW"}
98-
99- require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "work", Tabs: []snapcodec.Tab{{
100- StableID: "tab", Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{terminalPane(t, "pane-1", "pane", "/tmp", nil, nil)},
101- }}}))
102- initialOpens := factory.snapshotOpens()
103- require.Len(t, initialOpens, 1)
104- require.Equal(t, "/usr/local/bin/daemon-shell", initialOpens[0].command)
105- require.Equal(t, []string{
106- "ORDINARY=preserved", "SHELL=/usr/local/bin/daemon-shell", "RAW",
107- "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=tab,pane=pane",
108- }, initialOpens[0].env)
109-
110- d.baseEnv[0] = "ORDINARY=changed"
111- d.mu.Lock()
112- restored := d.findByNameLocked("work")
113- d.mu.Unlock()
114- require.NotNil(t, restored)
115- restored.mu.Lock()
116- require.Equal(t, []string{"ORDINARY=preserved", "SHELL=/usr/local/bin/daemon-shell", "TERM=stale", "RAW"}, restored.env)
117- restored.mu.Unlock()
118-
119- tr, _ := newCapturingTransport(t)
120- _, ac, err := d.route(ports.Hello{
121- Version: ports.ProtocolVersion,
122- Intent: ports.IntentAttach,
123- Name: "work",
124- Size: domain.Size{Cols: 80, Rows: 24},
125- Env: []string{"ORDINARY=attached", "SHELL=/bin/attached-shell"},
126- }, tr)
127- require.NoError(t, err)
128- require.NoError(t, d.createTab(restored, ac.size))
129-
130- restored.mu.Lock()
131- attachedTab := restored.tabs[1]
132- restored.mu.Unlock()
133- attachedTab.mu.Lock()
134- attachedPane := attachedTab.panes["pane-1"]
135- attachedTabStableID := attachedTab.stableID
136- attachedPaneStableID := attachedPane.stableID
137- attachedTab.mu.Unlock()
138- expectedVEV := "VEV=session=work,tab=" + attachedTabStableID + ",pane=" + attachedPaneStableID
139- var attachedOpens []restorePTYOpen
140- for _, open := range factory.snapshotOpens() {
141- if containsEnv(open.env, expectedVEV) {
142- attachedOpens = append(attachedOpens, open)
143- }
144- }
145- require.Len(t, attachedOpens, 1)
146- require.Equal(t, "/bin/attached-shell", attachedOpens[0].command)
147- require.Equal(t, []string{
148- "ORDINARY=attached", "SHELL=/bin/attached-shell",
149- "TERM=xterm-256color", "TERM_PROGRAM=vev", expectedVEV,
150- }, attachedOpens[0].env)
151- require.NoError(t, d.killSession(restored, ports.ReasonSessionKilled, false))
152- d.sessWg.Wait()
153-}
154-
155-func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
156- store := &restoreSnapshotStore{}
157- store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
158- Name: "work",
159- CreatedAt: 99,
160- Active: 0,
161- Tabs: []snapcodec.Tab{{
162- StableID: "t_saved",
163- Cols: 80,
164- Rows: 24,
165- NextPaneID: 3,
166- Focus: "pane-2",
167- Tree: &layout.Tree{Focus: "pane-2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
168- layout.NewLeaf("pane-1"),
169- layout.NewLeaf("pane-2"),
170- }}},
171- Panes: []snapcodec.Pane{
172- terminalPane(t, "pane-1", "p_saved_1", "/one", [][]renderer.Cell{cells("old1")}, [][]renderer.Cell{cells("vis1")}),
173- terminalPane(t, "pane-2", "p_saved_2", "/two", [][]renderer.Cell{cells("old2")}, [][]renderer.Cell{cells("vis2")}),
174- },
175- }},
176- })}}
177- factory := &restorePTYFactory{}
178- d := newTestDaemon(t, factory, stubClock{})
179- persistStore, persistState := newMockStore(t)
180- WithStore(persistStore)(d)
181- WithSnapshotStore(store)(d)
182- d.stopped["work"] = stoppedSession{name: "work", createdAt: 99, tabNames: []string{"logs"}}
183-
184- d.restoreSnapshots(context.Background())
185-
186- require.Len(t, factory.opens, 2)
187- require.Equal(t, "/one", factory.opens[0].dir)
188- require.Equal(t, "/two", factory.opens[1].dir)
189- require.Equal(t, domain.Size{Cols: 40, Rows: 24}, factory.opens[0].size)
190-
191- d.mu.Lock()
192- restored := d.findByNameLocked("work")
193- d.mu.Unlock()
194- require.NotNil(t, restored)
195- require.False(t, restored.snapDirty.Load())
196- require.Equal(t, domain.SessionID("sess-0"), restored.id)
197- require.Equal(t, int64(99), restored.createdAt)
198- require.Equal(t, "/one", restored.cwd)
199-
200- restored.mu.Lock()
201- require.Len(t, restored.tabs, 1)
202- tb := restored.tabs[0]
203- restored.mu.Unlock()
204- require.Equal(t, "logs", tb.name)
205- require.Equal(t, []string{"logs"}, persistState.record(t, "work").TabNames)
206- tb.mu.Lock()
207- require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
208- require.Equal(t, 3, tb.nextPaneID)
209- p := tb.panes["pane-2"]
210- tb.mu.Unlock()
211- p.mu.Lock()
212- require.Same(t, p.screen.History(), p.history)
213- require.Equal(t, "old2", rowText(p.history.View().Row(0)))
214- require.Equal(t, 1, p.history.Len())
215- require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4]))
216- copySnap := scopy.NewSnapshot(p.history, p.screen.Frame)
217- require.Equal(t, "old2", rowText(copySnap.Row(0)[:4]))
218- require.Equal(t, "vis2", rowText(copySnap.Row(1)[:4]))
219- p.mu.Unlock()
220-
221- tb.mu.Lock()
222- tabStableID := tb.stableID
223- pane1StableID := tb.panes["pane-1"].stableID
224- pane2StableID := tb.panes["pane-2"].stableID
225- tb.mu.Unlock()
226- require.Equal(t, "t_saved", tabStableID)
227- require.Equal(t, "p_saved_1", pane1StableID)
228- require.Equal(t, "p_saved_2", pane2StableID)
229- require.Contains(t, factory.opens[0].env, "TERM=xterm-256color")
230- require.NotContains(t, factory.opens[0].env, "TERM=xterm-direct")
231- require.NotContains(t, factory.opens[0].env, "COLORTERM=truecolor")
232- require.Contains(t, factory.opens[0].env, "TERM_PROGRAM=vev")
233- require.Contains(t, factory.opens[0].env, "VEV=session=work,tab="+tabStableID+",pane="+pane1StableID)
234- require.Contains(t, factory.opens[1].env, "TERM=xterm-256color")
235- require.NotContains(t, factory.opens[1].env, "TERM=xterm-direct")
236- require.NotContains(t, factory.opens[1].env, "COLORTERM=truecolor")
237- require.Contains(t, factory.opens[1].env, "TERM_PROGRAM=vev")
238- require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID)
239-}
240-
241-func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testing.T) {
242- store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
243- Name: "work", Active: 0, Tabs: []snapcodec.Tab{
244- {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/one")}},
245- {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")}},
246- },
247- })}}}
248- factory := &restorePTYFactory{}
249- d := newTestDaemon(t, factory, stubClock{})
250- d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Command: "btop", Width: 80, Height: 80}})
251- WithSnapshotStore(store)(d)
252- d.restoreSnapshots(context.Background())
253- // Only the persisted normal panes may be restored. Floating runtimes are
254- // deliberately excluded and must wait for a Phase-5 activation path.
255- require.Len(t, factory.opens, 2)
256- d.mu.Lock()
257- restored := d.findByNameLocked("work")
258- d.mu.Unlock()
259- require.NotNil(t, restored)
260- restored.mu.Lock()
261- tabs := append([]*tab(nil), restored.tabs...)
262- restored.mu.Unlock()
263- require.Len(t, tabs, 2)
264- for _, tb := range tabs {
265- tb.mu.Lock()
266- require.Equal(t, floatingUninitialized, tb.floating.state)
267- require.Nil(t, tb.floating.pane)
268- tb.mu.Unlock()
269- }
270-}
271-
272-func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore {
273- t.Helper()
274- pane := emptyTerminalPane(t, "pane-1", "/tmp")
275- pane.Process = proc
276- return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{
277- Name: name,
278- Tabs: []snapcodec.Tab{{
279- Cols: 80,
280- Rows: 24,
281- Tree: layout.NewTree("pane-1"),
282- Panes: []snapcodec.Pane{pane},
283- }},
284- })}}}
285-}
286-
287-func TestRestoreSnapshotsProcessRestoreCommands(t *testing.T) {
288- lessProc := &snapcodec.Process{Argv: []string{"less", "README.md"}, Strategy: processStrategyGeneric}
289- agentProc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}}
290- controlAgentProc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc\nmalicious"}}
291-
292- tests := []struct {
293- name string
294- store *restoreSnapshotStore
295- allow []string
296- wantWrites [][]string
297- }{
298- {
299- name: "allowlisted generic command",
300- store: processRestoreTestStore(t, "allowlisted", lessProc),
301- allow: []string{"less"},
302- wantWrites: [][]string{{"less README.md\n"}},
303- },
304- {
305- name: "denied generic command",
306- store: processRestoreTestStore(t, "denied", lessProc),
307- allow: []string{"vim"},
308- wantWrites: [][]string{{}},
309- },
310- {
311- name: "pane scoped command",
312- store: &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "scoped", Data: mustSnapshotBytes(t, snapcodec.Session{
313- Name: "scoped",
314- Tabs: []snapcodec.Tab{
315- {
316- Cols: 80,
317- Rows: 24,
318- Tree: layout.NewTree("pane-1"),
319- Panes: func() []snapcodec.Pane {
320- pane := emptyTerminalPane(t, "pane-1", "/one")
321- pane.Process = lessProc
322- return []snapcodec.Pane{pane}
323- }(),
324- },
325- {
326- Cols: 80,
327- Rows: 24,
328- Tree: layout.NewTree("pane-1"),
329- Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")},
330- },
331- },
332- })}}},
333- allow: []string{"less"},
334- wantWrites: [][]string{{"less README.md\n"}, {}},
335- },
336- {
337- name: "agent ID command",
338- store: processRestoreTestStore(t, "agent", agentProc),
339- allow: []string{"pi"},
340- wantWrites: [][]string{{"pi --resume abc123\n"}},
341- },
342- {
343- name: "agent ID with control byte rejected",
344- store: processRestoreTestStore(t, "agent-control", controlAgentProc),
345- allow: []string{"pi"},
346- wantWrites: [][]string{{}},
347- },
348- }
349-
350- for _, tt := range tests {
351- t.Run(tt.name, func(t *testing.T) {
352- factory := &restorePTYFactory{}
353- d := newTestDaemon(t, factory, stubClock{})
354- WithSnapshotStore(tt.store)(d)
355- d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: tt.allow}})
356-
357- d.restoreSnapshots(context.Background())
358-
359- require.Len(t, factory.opens, len(tt.wantWrites))
360- for i, want := range tt.wantWrites {
361- if len(want) == 0 {
362- require.Empty(t, factory.opens[i].pty.writes)
363- continue
364- }
365- require.Equal(t, want, factory.opens[i].pty.writes)
366- }
367- })
368- }
369-}
370-
371-func TestRestoreSnapshotsProcessCommandWriteFailureIsNonFatal(t *testing.T) {
372- proc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}}
373- factory := &restorePTYFactory{writeErr: errors.New("boom")}
374- d := newTestDaemon(t, factory, stubClock{})
375- d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}})
376-
377- pane := emptyTerminalPane(t, "pane-1", "/tmp")
378- pane.Process = proc
379- require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "agent", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{pane}}}}))
380- require.Len(t, factory.opens, 1)
381- require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes)
382-}
383-
384-func TestRestoreSessionPassesRestoreContextToPTYOpen(t *testing.T) {
385- factory := &restorePTYFactory{}
386- d := newTestDaemon(t, factory, stubClock{})
387- ctx, cancel := context.WithCancel(context.Background())
388- defer cancel()
389-
390- require.NoError(t, d.restoreSession(ctx, snapcodec.Session{Name: "restore-context", Tabs: []snapcodec.Tab{{
391- Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/tmp")},
392- }}}))
393- require.Len(t, factory.opens, 1)
394- require.Same(t, ctx, factory.opens[0].ctx)
395-}
396-
397-func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) {
398- store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{
399- Name: "stacked",
400- Tabs: []snapcodec.Tab{{
401- Cols: 20,
402- Rows: 4,
403- Tree: &layout.Tree{Focus: "b", Root: &layout.Node{Kind: layout.Stack, Expanded: "b", Children: []*layout.Node{
404- layout.NewLeaf("a"), layout.NewLeaf("b"), layout.NewLeaf("c"),
405- }}},
406- Panes: []snapcodec.Pane{
407- emptyTerminalPane(t, "a", "/a"),
408- emptyTerminalPane(t, "b", "/b"),
409- emptyTerminalPane(t, "c", "/c"),
410- },
411- }},
412- })}}}
413- factory := &restorePTYFactory{}
414- d := newTestDaemon(t, factory, stubClock{})
415- WithSnapshotStore(store)(d)
416-
417- d.restoreSnapshots(context.Background())
418-
419- require.Len(t, factory.opens, 3)
420- for _, open := range factory.opens {
421- require.True(t, open.size.Valid(), "open size for %s was %#v", open.dir, open.size)
422- require.GreaterOrEqual(t, open.size.Cols, layout.MinPaneCols)
423- require.GreaterOrEqual(t, open.size.Rows, layout.MinPaneRows)
424- }
425- d.mu.Lock()
426- restored := d.findByNameLocked("stacked")
427- d.mu.Unlock()
428- require.NotNil(t, restored)
429- restored.mu.Lock()
430- tb := restored.tabs[0]
431- restored.mu.Unlock()
432- tb.mu.Lock()
433- require.Equal(t, layout.Stack, tb.tree.Root.Kind)
434- require.Equal(t, layout.PaneID("b"), tb.tree.Root.Expanded)
435- require.Len(t, tb.panes, 3)
436- tb.mu.Unlock()
437-}
438-
439-func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) {
440- valid := mustSnapshotBytes(t, snapcodec.Session{
441- Name: "live",
442- CreatedAt: 1,
443- Tabs: []snapcodec.Tab{{
444- Cols: 80,
445- Rows: 24,
446- Tree: layout.NewTree("pane-1"),
447- Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/ok")},
448- }},
449- })
450- badVersion := append([]byte(nil), valid...)
451- binary.BigEndian.PutUint16(badVersion[4:6], 1)
452- tests := []struct {
453- name string
454- blobs []ports.SnapshotBlob
455- liveName string
456- wantOpen int
457- }{
458- {name: "empty store"},
459- {name: "corrupt blob", blobs: []ports.SnapshotBlob{{Name: "bad", Data: []byte("not a snapshot")}}},
460- {name: "old v1 blob", blobs: []ports.SnapshotBlob{{Name: "old", Data: badVersion}}},
461- {name: "live name", blobs: []ports.SnapshotBlob{{Name: "live", Data: valid}}, liveName: "live"},
462- }
463- for _, tt := range tests {
464- t.Run(tt.name, func(t *testing.T) {
465- factory := &restorePTYFactory{}
466- d := newTestDaemon(t, factory, stubClock{})
467- WithSnapshotStore(&restoreSnapshotStore{blobs: tt.blobs})(d)
468- if tt.liveName != "" {
469- d.sessions["existing"] = &session{id: "existing", name: tt.liveName}
470- }
471-
472- require.NotPanics(t, func() { d.restoreSnapshots(context.Background()) })
473- require.Len(t, factory.opens, tt.wantOpen)
474- })
475- }
476-}
477-
478-func TestRestoreSnapshotsSkipsCreatedAtOverflow(t *testing.T) {
479- store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "overflow", Data: mustSnapshotBytes(t, snapcodec.Session{
480- Name: "overflow",
481- CreatedAt: uint64(math.MaxInt64) + 1,
482- Tabs: []snapcodec.Tab{{
483- Cols: 80,
484- Rows: 24,
485- Tree: layout.NewTree("pane-1"),
486- Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/bad")},
487- }},
488- })}}}
489- factory := &restorePTYFactory{}
490- d := newTestDaemon(t, factory, stubClock{})
491- WithSnapshotStore(store)(d)
492-
493- d.restoreSnapshots(context.Background())
494-
495- records, err := d.persist.LoadAll()
496- require.NoError(t, err)
497- require.Empty(t, records)
498- require.Len(t, factory.opens, 0)
499- d.mu.Lock()
500- restored := d.findByNameLocked("overflow")
501- d.mu.Unlock()
502- require.Nil(t, restored)
503-}
504-
505-// TestRestoreSnapshotsNotifiesGlobalOnUnrestorableSnapshots proves each
506-// unrestorable snapshot is recorded to history individually, while the
507-// pending-global queue dedups them by code into a single toast with the
508-// combined Count. Both blobs decode fine (valid snapcodec bytes, Marshal
509-// itself validates Active/tab range) but fail in restoreSession itself: an
510-// empty embedded session name is rejected deterministically, before any lock
511-// is taken or PTY is opened.
512-func TestRestoreSnapshotsNotifiesGlobalOnUnrestorableSnapshots(t *testing.T) {
513- badSnap := mustSnapshotBytes(t, snapcodec.Session{Name: ""})
514- store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{
515- {Name: "bad-1", Data: badSnap},
516- {Name: "bad-2", Data: badSnap},
517- }}
518- factory := &restorePTYFactory{}
519- d := newTestDaemon(t, factory, newNoticeClock())
520- WithSnapshotStore(store)(d)
521-
522- // Mirrors real startup (daemon.go's Serve): restoreSnapshots runs off the
523- // caller goroutine and signals completion by closing d.restoreDone.
524- d.sessWg.Go(func() { d.restoreSnapshots(d.serveCtx) })
525-
526- select {
527- case <-d.restoreDone:
528- case <-time.After(2 * time.Second):
529- t.Fatal("restoreSnapshots did not close restoreDone in time")
530- }
531-
532- require.Empty(t, factory.opens, "both snapshots must fail before any PTY is opened")
533- require.Len(t, d.notices.history(), 2, "each failure is recorded individually")
534- for _, n := range d.notices.history() {
535- require.Equal(t, domain.NoticeSnapshotRestore, n.Code)
536- require.Equal(t, domain.NoticeError, n.Severity)
537- }
538-
539- tr, _ := newCapturingTransport(t)
540- sess, ac, err := d.route(ports.Hello{
541- Version: ports.ProtocolVersion,
542- Intent: ports.IntentEphemeral,
543- Size: domain.Size{Cols: 80, Rows: 24},
544- }, tr)
545- require.NoError(t, err)
546- d.firstPaint(sess, ac, ac.size)
547-
548- toasts := awaitToastCount(t, ac, 1)
549- require.Equal(t, domain.NoticeSnapshotRestore, toasts[0].Code)
550- require.Equal(t, 2, toasts[0].Count, "two same-code failures dedup into one toast counted twice")
551- require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue")
552-}
553-
554-func TestRestoreSnapshotsDoesNotNotifyForCancelledRestore(t *testing.T) {
555- ctx, cancel := context.WithCancel(context.Background())
556- factory := &restorePTYFactory{onOpen: cancel}
557- store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "cancelled", Data: mustSnapshotBytes(t, snapcodec.Session{
558- Name: "cancelled",
559- Tabs: []snapcodec.Tab{{
560- Cols: 80, Rows: 24,
561- Tree: &layout.Tree{Focus: "one", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
562- layout.NewLeaf("one"), layout.NewLeaf("two"),
563- }}},
564- Panes: []snapcodec.Pane{
565- emptyTerminalPane(t, "one", "/one"),
566- emptyTerminalPane(t, "two", "/two"),
567- },
568- }},
569- })}}}
570- d := newTestDaemon(t, factory, stubClock{})
571- WithSnapshotStore(store)(d)
572-
573- d.restoreSnapshots(ctx)
574-
575- require.Len(t, factory.snapshotOpens(), 1, "cancellation stops restore before the next pane")
576- require.Empty(t, d.notices.history(), "shutdown cancellation is not a user-visible restore failure")
577-}
578-
579-func TestRestoreSnapshotsNotifiesGlobalOnResumeCommandWriteFailure(t *testing.T) {
580- proc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}}
581- store := processRestoreTestStore(t, "agent-session", proc)
582- factory := &restorePTYFactory{writeErr: errors.New("boom")}
583- d := newTestDaemon(t, factory, newNoticeClock())
584- WithSnapshotStore(store)(d)
585- d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}})
586-
587- // Mirrors real startup (daemon.go's Serve): restoreSnapshots runs off the
588- // caller goroutine and signals completion by closing d.restoreDone.
589- d.sessWg.Go(func() { d.restoreSnapshots(d.serveCtx) })
590-
591- select {
592- case <-d.restoreDone:
593- case <-time.After(2 * time.Second):
594- t.Fatal("restoreSnapshots did not close restoreDone in time")
595- }
596-
597- require.Len(t, factory.opens, 1, "the pane still opens even though the resume write fails")
598- require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes)
599-
600- history := d.notices.history()
601- require.Len(t, history, 1)
602- require.Equal(t, domain.NoticeAutoResume, history[0].Code)
603- require.Equal(t, domain.NoticeWarn, history[0].Severity)
604- require.Equal(t, "couldn't restore the running program in session agent-session", history[0].Message)
605-
606- tr, _ := newCapturingTransport(t)
607- sess, ac, err := d.route(ports.Hello{
608- Version: ports.ProtocolVersion,
609- Intent: ports.IntentEphemeral,
610- Size: domain.Size{Cols: 80, Rows: 24},
611- }, tr)
612- require.NoError(t, err)
613- d.firstPaint(sess, ac, ac.size)
614-
615- toasts := awaitToastCount(t, ac, 1)
616- require.Equal(t, domain.NoticeAutoResume, toasts[0].Code)
617- require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue")
618-}
619-
620-func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) {
621- releaseLoad := make(chan struct{})
622- loaded := make(chan struct{})
623- store := &restoreSnapshotStore{
624- loadFn: func() ([]ports.SnapshotBlob, error) {
625- close(loaded)
626- <-releaseLoad
627- return []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
628- Name: "work",
629- Tabs: []snapcodec.Tab{{
630- Cols: 80,
631- Rows: 24,
632- Tree: layout.NewTree("pane-1"),
633- Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/restored")},
634- }},
635- })}}, nil
636- },
637- }
638- factory := &restorePTYFactory{}
639- d := newTestDaemon(t, factory, stubClock{})
640- WithSnapshotStore(store)(d)
641-
642- go d.restoreSnapshots(context.Background())
643- <-loaded
644-
645- routed := make(chan *session, 1)
646- go func() {
647- tr, _ := newCapturingTransport(t)
648- sess, _, err := d.route(ports.Hello{Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 26}}, tr)
649- 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- }
667-}
668-
669-func TestNamedRouteRestoreBarrierReturnsShutdown(t *testing.T) {
670- d := newTestDaemon(t, &restorePTYFactory{}, stubClock{})
671- WithSnapshotStore(&restoreSnapshotStore{})(d)
672- d.serveCancel()
673-
674- tr, _ := newCapturingTransport(t)
675- sess, ac, err := d.route(ports.Hello{Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}}, tr)
676-
677- require.Nil(t, sess)
678- require.Nil(t, ac)
679- var perr *protoErr
680- require.ErrorAs(t, err, &perr)
681- require.Equal(t, ports.ErrServerShutdown, perr.code)
682-}
683-
684-func emptyTerminalPane(t *testing.T, id, cwd string) snapcodec.Pane {
685- t.Helper()
686- return terminalPane(t, id, "", cwd, nil, nil)
687-}
688-
689-func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte {
690- t.Helper()
691- b, err := snapcodec.Marshal(s)
692- require.NoError(t, err)
693- return b
694-}
695-
696-func cells(s string) []renderer.Cell {
697- out := make([]renderer.Cell, 0, len(s))
698- for _, r := range s {
699- out = append(out, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
700- }
701- return out
702-}
703-
704-type restoreSnapshotStore struct {
705- blobs []ports.SnapshotBlob
706- loadFn func() ([]ports.SnapshotBlob, error)
707-}
708-
709-func (s *restoreSnapshotStore) Write(string, []byte) error { return nil }
710-func (s *restoreSnapshotStore) Delete(string) error { return nil }
711-func (s *restoreSnapshotStore) Load() ([]ports.SnapshotBlob, error) {
712- if s.loadFn != nil {
713- return s.loadFn()
714- }
715- return s.blobs, nil
716-}
717-
718-type restorePTYFactory struct {
719- mu sync.Mutex
720- opens []restorePTYOpen
721- writeErr error
722- onOpen func()
723-}
724-
725-type restorePTYOpen struct {
726- ctx context.Context
727- command string
728- dir string
729- size domain.Size
730- env []string
731- pty *restorePTY
732-}
733-
734-func (f *restorePTYFactory) Open(ctx context.Context, command string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
735- f.mu.Lock()
736- pty := newRestorePTY()
737- pty.writeErr = f.writeErr
738- f.opens = append(f.opens, restorePTYOpen{ctx: ctx, command: command, dir: dir, size: sz, env: append([]string(nil), env...), pty: pty})
739- onOpen := f.onOpen
740- f.mu.Unlock()
741- if onOpen != nil {
742- onOpen()
743- }
744- return pty, nil
745-}
746-
747-func (f *restorePTYFactory) snapshotOpens() []restorePTYOpen {
748- f.mu.Lock()
749- defer f.mu.Unlock()
750- opens := make([]restorePTYOpen, len(f.opens))
751- copy(opens, f.opens)
752- for i := range opens {
753- opens[i].env = append([]string(nil), opens[i].env...)
754- }
755- return opens
756-}
757-
758-type restorePTY struct {
759- mu sync.Mutex
760- writes []string
761- closes int
762- writeErr error
763- done chan struct{}
764- once sync.Once
765-}
766-
767-func newRestorePTY() *restorePTY { return &restorePTY{done: make(chan struct{})} }
768-func (p *restorePTY) Read([]byte) (int, error) {
769- <-p.done
770- return 0, io.EOF
771-}
772-func (p *restorePTY) Write(b []byte) (int, error) {
773- p.mu.Lock()
774- defer p.mu.Unlock()
775- p.writes = append(p.writes, string(b))
776- if p.writeErr != nil {
777- return 0, p.writeErr
778- }
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-}
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 }
@@ -1,795 +0,0 @@
1-package daemon
2-
3-import (
4- "context"
5- "encoding/binary"
6- "errors"
7- "io"
8- "math"
9- "sync"
10- "testing"
11- "time"
12-
13- "github.com/stretchr/testify/require"
14-
15- "github.com/bnema/vev/internal/domain"
16- "github.com/bnema/vev/internal/ports"
17- scopy "github.com/bnema/vev/internal/usecase/copy"
18- "github.com/bnema/vev/internal/usecase/layout"
19- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
20- "github.com/bnema/vev/pkg/renderer"
21- "github.com/bnema/vev/pkg/vt"
22-)
23-
24-func terminalPane(t *testing.T, id, stableID, cwd string, historyRows, visibleRows [][]renderer.Cell) snapcodec.Pane {
25- t.Helper()
26- h := vt.NewHistory(vt.HistoryConfig{MaxRows: defaultScrollbackRows})
27- for _, row := range historyRows {
28- require.NoError(t, h.Append(row))
29- }
30- sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView())
31- require.NoError(t, err)
32- frame := renderer.NewFrame(40, 24)
33- for y, row := range visibleRows {
34- copy(frame.Row(y), row)
35- }
36- visible, err := vt.MarshalVisible(frame)
37- require.NoError(t, err)
38- return snapcodec.Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible}
39-}
40-
41-func TestRestorePaneTerminalRejectsMissingOrMalformedCanonicalBlobs(t *testing.T) {
42- valid := terminalPane(t, "pane", "p", "/work", nil, nil)
43- tests := []struct {
44- name string
45- mutate func(*snapcodec.Pane)
46- }{
47- {name: "missing tail", mutate: func(p *snapcodec.Pane) { p.Tail = nil }},
48- {name: "missing visible", mutate: func(p *snapcodec.Pane) { p.Visible = nil }},
49- {name: "malformed tail", mutate: func(p *snapcodec.Pane) { p.Tail = []byte("bad") }},
50- {name: "malformed visible", mutate: func(p *snapcodec.Pane) { p.Visible = []byte("bad") }},
51- }
52- for _, tt := range tests {
53- t.Run(tt.name, func(t *testing.T) {
54- snap := valid
55- tt.mutate(&snap)
56- p := newPane("pane", newRestorePTY(), domain.Size{Cols: 40, Rows: 24})
57- require.Error(t, restorePaneTerminal(p, snap))
58- })
59- }
60-}
61-
62-func TestRestoreSnapshotsNilDaemonDoesNotPanic(t *testing.T) {
63- var d *Daemon
64- require.NotPanics(t, func() { d.restoreSnapshots(t.Context()) })
65-}
66-
67-func TestRestoreSessionRejectsInvalidActiveTab(t *testing.T) {
68- factory := &restorePTYFactory{}
69- d := newTestDaemon(t, factory, stubClock{})
70-
71- err := d.restoreSession(context.Background(), snapcodec.Session{Name: "bad-active", Active: 1, Tabs: []snapcodec.Tab{{
72- Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/tmp")},
73- }}})
74-
75- require.Error(t, err)
76- require.Empty(t, factory.opens)
77-}
78-
79-func TestRestoreSessionClosesOpenedPTYWhenTerminalRestoreFails(t *testing.T) {
80- factory := &restorePTYFactory{}
81- d := newTestDaemon(t, factory, stubClock{})
82- pane := emptyTerminalPane(t, "pane-1", "/tmp")
83- pane.Tail = []byte("malformed")
84-
85- err := d.restoreSession(context.Background(), snapcodec.Session{Name: "bad", Tabs: []snapcodec.Tab{{
86- Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{pane},
87- }}})
88-
89- require.Error(t, err)
90- require.Len(t, factory.opens, 1)
91- require.Equal(t, 1, factory.opens[0].pty.closeCount())
92-}
93-
94-func TestRestoreSessionUsesDaemonFallbackEnvironmentAndShellBeforeAttach(t *testing.T) {
95- factory := &restorePTYFactory{}
96- d := newTestDaemon(t, factory, stubClock{})
97- d.baseEnv = []string{"ORDINARY=preserved", "SHELL=/usr/local/bin/daemon-shell", "TERM=stale", "RAW"}
98-
99- require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "work", Tabs: []snapcodec.Tab{{
100- StableID: "tab", Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{terminalPane(t, "pane-1", "pane", "/tmp", nil, nil)},
101- }}}))
102- initialOpens := factory.snapshotOpens()
103- require.Len(t, initialOpens, 1)
104- require.Equal(t, "/usr/local/bin/daemon-shell", initialOpens[0].command)
105- require.Equal(t, []string{
106- "ORDINARY=preserved", "SHELL=/usr/local/bin/daemon-shell", "RAW",
107- "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=tab,pane=pane",
108- }, initialOpens[0].env)
109-
110- d.baseEnv[0] = "ORDINARY=changed"
111- d.mu.Lock()
112- restored := d.findByNameLocked("work")
113- d.mu.Unlock()
114- require.NotNil(t, restored)
115- restored.mu.Lock()
116- require.Equal(t, []string{"ORDINARY=preserved", "SHELL=/usr/local/bin/daemon-shell", "TERM=stale", "RAW"}, restored.env)
117- restored.mu.Unlock()
118-
119- tr, _ := newCapturingTransport(t)
120- _, ac, err := d.route(ports.Hello{
121- Version: ports.ProtocolVersion,
122- Intent: ports.IntentAttach,
123- Name: "work",
124- Size: domain.Size{Cols: 80, Rows: 24},
125- Env: []string{"ORDINARY=attached", "SHELL=/bin/attached-shell"},
126- }, tr)
127- require.NoError(t, err)
128- require.NoError(t, d.createTab(restored, ac.size))
129-
130- restored.mu.Lock()
131- attachedTab := restored.tabs[1]
132- restored.mu.Unlock()
133- attachedTab.mu.Lock()
134- attachedPane := attachedTab.panes["pane-1"]
135- attachedTabStableID := attachedTab.stableID
136- attachedPaneStableID := attachedPane.stableID
137- attachedTab.mu.Unlock()
138- expectedVEV := "VEV=session=work,tab=" + attachedTabStableID + ",pane=" + attachedPaneStableID
139- var attachedOpens []restorePTYOpen
140- for _, open := range factory.snapshotOpens() {
141- if containsEnv(open.env, expectedVEV) {
142- attachedOpens = append(attachedOpens, open)
143- }
144- }
145- require.Len(t, attachedOpens, 1)
146- require.Equal(t, "/bin/attached-shell", attachedOpens[0].command)
147- require.Equal(t, []string{
148- "ORDINARY=attached", "SHELL=/bin/attached-shell",
149- "TERM=xterm-256color", "TERM_PROGRAM=vev", expectedVEV,
150- }, attachedOpens[0].env)
151- require.NoError(t, d.killSession(restored, ports.ReasonSessionKilled, false))
152- d.sessWg.Wait()
153-}
154-
155-func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
156- store := &restoreSnapshotStore{}
157- store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
158- Name: "work",
159- CreatedAt: 99,
160- Active: 0,
161- Tabs: []snapcodec.Tab{{
162- StableID: "t_saved",
163- Cols: 80,
164- Rows: 24,
165- NextPaneID: 3,
166- Focus: "pane-2",
167- Tree: &layout.Tree{Focus: "pane-2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
168- layout.NewLeaf("pane-1"),
169- layout.NewLeaf("pane-2"),
170- }}},
171- Panes: []snapcodec.Pane{
172- terminalPane(t, "pane-1", "p_saved_1", "/one", [][]renderer.Cell{cells("old1")}, [][]renderer.Cell{cells("vis1")}),
173- terminalPane(t, "pane-2", "p_saved_2", "/two", [][]renderer.Cell{cells("old2")}, [][]renderer.Cell{cells("vis2")}),
174- },
175- }},
176- })}}
177- factory := &restorePTYFactory{}
178- d := newTestDaemon(t, factory, stubClock{})
179- persistStore, persistState := newMockStore(t)
180- WithStore(persistStore)(d)
181- WithSnapshotStore(store)(d)
182- d.stopped["work"] = stoppedSession{name: "work", createdAt: 99, tabNames: []string{"logs"}}
183-
184- d.restoreSnapshots(context.Background())
185-
186- require.Len(t, factory.opens, 2)
187- require.Equal(t, "/one", factory.opens[0].dir)
188- require.Equal(t, "/two", factory.opens[1].dir)
189- require.Equal(t, domain.Size{Cols: 40, Rows: 24}, factory.opens[0].size)
190-
191- d.mu.Lock()
192- restored := d.findByNameLocked("work")
193- d.mu.Unlock()
194- require.NotNil(t, restored)
195- require.False(t, restored.snapDirty.Load())
196- require.Equal(t, domain.SessionID("sess-0"), restored.id)
197- require.Equal(t, int64(99), restored.createdAt)
198- require.Equal(t, "/one", restored.cwd)
199-
200- restored.mu.Lock()
201- require.Len(t, restored.tabs, 1)
202- tb := restored.tabs[0]
203- restored.mu.Unlock()
204- require.Equal(t, "logs", tb.name)
205- require.Equal(t, []string{"logs"}, persistState.record(t, "work").TabNames)
206- tb.mu.Lock()
207- require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
208- require.Equal(t, 3, tb.nextPaneID)
209- p := tb.panes["pane-2"]
210- tb.mu.Unlock()
211- p.mu.Lock()
212- require.Same(t, p.screen.History(), p.history)
213- require.Equal(t, "old2", rowText(p.history.View().Row(0)))
214- require.Equal(t, 1, p.history.Len())
215- require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4]))
216- copySnap := scopy.NewSnapshot(p.history, p.screen.Frame)
217- require.Equal(t, "old2", rowText(copySnap.Row(0)[:4]))
218- require.Equal(t, "vis2", rowText(copySnap.Row(1)[:4]))
219- p.mu.Unlock()
220-
221- tb.mu.Lock()
222- tabStableID := tb.stableID
223- pane1StableID := tb.panes["pane-1"].stableID
224- pane2StableID := tb.panes["pane-2"].stableID
225- tb.mu.Unlock()
226- require.Equal(t, "t_saved", tabStableID)
227- require.Equal(t, "p_saved_1", pane1StableID)
228- require.Equal(t, "p_saved_2", pane2StableID)
229- require.Contains(t, factory.opens[0].env, "TERM=xterm-256color")
230- require.NotContains(t, factory.opens[0].env, "TERM=xterm-direct")
231- require.NotContains(t, factory.opens[0].env, "COLORTERM=truecolor")
232- require.Contains(t, factory.opens[0].env, "TERM_PROGRAM=vev")
233- require.Contains(t, factory.opens[0].env, "VEV=session=work,tab="+tabStableID+",pane="+pane1StableID)
234- require.Contains(t, factory.opens[1].env, "TERM=xterm-256color")
235- require.NotContains(t, factory.opens[1].env, "TERM=xterm-direct")
236- require.NotContains(t, factory.opens[1].env, "COLORTERM=truecolor")
237- require.Contains(t, factory.opens[1].env, "TERM_PROGRAM=vev")
238- require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID)
239-}
240-
241-func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testing.T) {
242- store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
243- Name: "work", Active: 0, Tabs: []snapcodec.Tab{
244- {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/one")}},
245- {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")}},
246- },
247- })}}}
248- factory := &restorePTYFactory{}
249- d := newTestDaemon(t, factory, stubClock{})
250- d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Command: "btop", Width: 80, Height: 80}})
251- WithSnapshotStore(store)(d)
252- d.restoreSnapshots(context.Background())
253- // Only the persisted normal panes may be restored. Floating runtimes are
254- // deliberately excluded and must wait for a Phase-5 activation path.
255- require.Len(t, factory.opens, 2)
256- d.mu.Lock()
257- restored := d.findByNameLocked("work")
258- d.mu.Unlock()
259- require.NotNil(t, restored)
260- restored.mu.Lock()
261- tabs := append([]*tab(nil), restored.tabs...)
262- restored.mu.Unlock()
263- require.Len(t, tabs, 2)
264- for _, tb := range tabs {
265- tb.mu.Lock()
266- require.Equal(t, floatingUninitialized, tb.floating.state)
267- require.Nil(t, tb.floating.pane)
268- tb.mu.Unlock()
269- }
270-}
271-
272-func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore {
273- t.Helper()
274- pane := emptyTerminalPane(t, "pane-1", "/tmp")
275- pane.Process = proc
276- return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{
277- Name: name,
278- Tabs: []snapcodec.Tab{{
279- Cols: 80,
280- Rows: 24,
281- Tree: layout.NewTree("pane-1"),
282- Panes: []snapcodec.Pane{pane},
283- }},
284- })}}}
285-}
286-
287-func TestRestoreSnapshotsProcessRestoreCommands(t *testing.T) {
288- lessProc := &snapcodec.Process{Argv: []string{"less", "README.md"}, Strategy: processStrategyGeneric}
289- agentProc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}}
290- controlAgentProc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc\nmalicious"}}
291-
292- tests := []struct {
293- name string
294- store *restoreSnapshotStore
295- allow []string
296- wantWrites [][]string
297- }{
298- {
299- name: "allowlisted generic command",
300- store: processRestoreTestStore(t, "allowlisted", lessProc),
301- allow: []string{"less"},
302- wantWrites: [][]string{{"less README.md\n"}},
303- },
304- {
305- name: "denied generic command",
306- store: processRestoreTestStore(t, "denied", lessProc),
307- allow: []string{"vim"},
308- wantWrites: [][]string{{}},
309- },
310- {
311- name: "pane scoped command",
312- store: &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "scoped", Data: mustSnapshotBytes(t, snapcodec.Session{
313- Name: "scoped",
314- Tabs: []snapcodec.Tab{
315- {
316- Cols: 80,
317- Rows: 24,
318- Tree: layout.NewTree("pane-1"),
319- Panes: func() []snapcodec.Pane {
320- pane := emptyTerminalPane(t, "pane-1", "/one")
321- pane.Process = lessProc
322- return []snapcodec.Pane{pane}
323- }(),
324- },
325- {
326- Cols: 80,
327- Rows: 24,
328- Tree: layout.NewTree("pane-1"),
329- Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")},
330- },
331- },
332- })}}},
333- allow: []string{"less"},
334- wantWrites: [][]string{{"less README.md\n"}, {}},
335- },
336- {
337- name: "agent ID command",
338- store: processRestoreTestStore(t, "agent", agentProc),
339- allow: []string{"pi"},
340- wantWrites: [][]string{{"pi --resume abc123\n"}},
341- },
342- {
343- name: "agent ID with control byte rejected",
344- store: processRestoreTestStore(t, "agent-control", controlAgentProc),
345- allow: []string{"pi"},
346- wantWrites: [][]string{{}},
347- },
348- }
349-
350- for _, tt := range tests {
351- t.Run(tt.name, func(t *testing.T) {
352- factory := &restorePTYFactory{}
353- d := newTestDaemon(t, factory, stubClock{})
354- WithSnapshotStore(tt.store)(d)
355- d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: tt.allow}})
356-
357- d.restoreSnapshots(context.Background())
358-
359- require.Len(t, factory.opens, len(tt.wantWrites))
360- for i, want := range tt.wantWrites {
361- if len(want) == 0 {
362- require.Empty(t, factory.opens[i].pty.writes)
363- continue
364- }
365- require.Equal(t, want, factory.opens[i].pty.writes)
366- }
367- })
368- }
369-}
370-
371-func TestRestoreSnapshotsProcessCommandWriteFailureIsNonFatal(t *testing.T) {
372- proc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}}
373- factory := &restorePTYFactory{writeErr: errors.New("boom")}
374- d := newTestDaemon(t, factory, stubClock{})
375- d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}})
376-
377- pane := emptyTerminalPane(t, "pane-1", "/tmp")
378- pane.Process = proc
379- require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "agent", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{pane}}}}))
380- require.Len(t, factory.opens, 1)
381- require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes)
382-}
383-
384-func TestRestoreSessionPassesRestoreContextToPTYOpen(t *testing.T) {
385- factory := &restorePTYFactory{}
386- d := newTestDaemon(t, factory, stubClock{})
387- ctx, cancel := context.WithCancel(context.Background())
388- defer cancel()
389-
390- require.NoError(t, d.restoreSession(ctx, snapcodec.Session{Name: "restore-context", Tabs: []snapcodec.Tab{{
391- Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/tmp")},
392- }}}))
393- require.Len(t, factory.opens, 1)
394- require.Same(t, ctx, factory.opens[0].ctx)
395-}
396-
397-func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) {
398- store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{
399- Name: "stacked",
400- Tabs: []snapcodec.Tab{{
401- Cols: 20,
402- Rows: 4,
403- Tree: &layout.Tree{Focus: "b", Root: &layout.Node{Kind: layout.Stack, Expanded: "b", Children: []*layout.Node{
404- layout.NewLeaf("a"), layout.NewLeaf("b"), layout.NewLeaf("c"),
405- }}},
406- Panes: []snapcodec.Pane{
407- emptyTerminalPane(t, "a", "/a"),
408- emptyTerminalPane(t, "b", "/b"),
409- emptyTerminalPane(t, "c", "/c"),
410- },
411- }},
412- })}}}
413- factory := &restorePTYFactory{}
414- d := newTestDaemon(t, factory, stubClock{})
415- WithSnapshotStore(store)(d)
416-
417- d.restoreSnapshots(context.Background())
418-
419- require.Len(t, factory.opens, 3)
420- for _, open := range factory.opens {
421- require.True(t, open.size.Valid(), "open size for %s was %#v", open.dir, open.size)
422- require.GreaterOrEqual(t, open.size.Cols, layout.MinPaneCols)
423- require.GreaterOrEqual(t, open.size.Rows, layout.MinPaneRows)
424- }
425- d.mu.Lock()
426- restored := d.findByNameLocked("stacked")
427- d.mu.Unlock()
428- require.NotNil(t, restored)
429- restored.mu.Lock()
430- tb := restored.tabs[0]
431- restored.mu.Unlock()
432- tb.mu.Lock()
433- require.Equal(t, layout.Stack, tb.tree.Root.Kind)
434- require.Equal(t, layout.PaneID("b"), tb.tree.Root.Expanded)
435- require.Len(t, tb.panes, 3)
436- tb.mu.Unlock()
437-}
438-
439-func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) {
440- valid := mustSnapshotBytes(t, snapcodec.Session{
441- Name: "live",
442- CreatedAt: 1,
443- Tabs: []snapcodec.Tab{{
444- Cols: 80,
445- Rows: 24,
446- Tree: layout.NewTree("pane-1"),
447- Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/ok")},
448- }},
449- })
450- badVersion := append([]byte(nil), valid...)
451- binary.BigEndian.PutUint16(badVersion[4:6], 1)
452- tests := []struct {
453- name string
454- blobs []ports.SnapshotBlob
455- liveName string
456- wantOpen int
457- }{
458- {name: "empty store"},
459- {name: "corrupt blob", blobs: []ports.SnapshotBlob{{Name: "bad", Data: []byte("not a snapshot")}}},
460- {name: "old v1 blob", blobs: []ports.SnapshotBlob{{Name: "old", Data: badVersion}}},
461- {name: "live name", blobs: []ports.SnapshotBlob{{Name: "live", Data: valid}}, liveName: "live"},
462- }
463- for _, tt := range tests {
464- t.Run(tt.name, func(t *testing.T) {
465- factory := &restorePTYFactory{}
466- d := newTestDaemon(t, factory, stubClock{})
467- WithSnapshotStore(&restoreSnapshotStore{blobs: tt.blobs})(d)
468- if tt.liveName != "" {
469- d.sessions["existing"] = &session{id: "existing", name: tt.liveName}
470- }
471-
472- require.NotPanics(t, func() { d.restoreSnapshots(context.Background()) })
473- require.Len(t, factory.opens, tt.wantOpen)
474- })
475- }
476-}
477-
478-func TestRestoreSnapshotsSkipsCreatedAtOverflow(t *testing.T) {
479- store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "overflow", Data: mustSnapshotBytes(t, snapcodec.Session{
480- Name: "overflow",
481- CreatedAt: uint64(math.MaxInt64) + 1,
482- Tabs: []snapcodec.Tab{{
483- Cols: 80,
484- Rows: 24,
485- Tree: layout.NewTree("pane-1"),
486- Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/bad")},
487- }},
488- })}}}
489- factory := &restorePTYFactory{}
490- d := newTestDaemon(t, factory, stubClock{})
491- WithSnapshotStore(store)(d)
492-
493- d.restoreSnapshots(context.Background())
494-
495- records, err := d.persist.LoadAll()
496- require.NoError(t, err)
497- require.Empty(t, records)
498- require.Len(t, factory.opens, 0)
499- d.mu.Lock()
500- restored := d.findByNameLocked("overflow")
501- d.mu.Unlock()
502- require.Nil(t, restored)
503-}
504-
505-// TestRestoreSnapshotsNotifiesGlobalOnUnrestorableSnapshots proves each
506-// unrestorable snapshot is recorded to history individually, while the
507-// pending-global queue dedups them by code into a single toast with the
508-// combined Count. Both blobs decode fine (valid snapcodec bytes, Marshal
509-// itself validates Active/tab range) but fail in restoreSession itself: an
510-// empty embedded session name is rejected deterministically, before any lock
511-// is taken or PTY is opened.
512-func TestRestoreSnapshotsNotifiesGlobalOnUnrestorableSnapshots(t *testing.T) {
513- badSnap := mustSnapshotBytes(t, snapcodec.Session{Name: ""})
514- store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{
515- {Name: "bad-1", Data: badSnap},
516- {Name: "bad-2", Data: badSnap},
517- }}
518- factory := &restorePTYFactory{}
519- d := newTestDaemon(t, factory, newNoticeClock())
520- WithSnapshotStore(store)(d)
521-
522- // Mirrors real startup (daemon.go's Serve): restoreSnapshots runs off the
523- // caller goroutine and signals completion by closing d.restoreDone.
524- d.sessWg.Go(func() { d.restoreSnapshots(d.serveCtx) })
525-
526- select {
527- case <-d.restoreDone:
528- case <-time.After(2 * time.Second):
529- t.Fatal("restoreSnapshots did not close restoreDone in time")
530- }
531-
532- require.Empty(t, factory.opens, "both snapshots must fail before any PTY is opened")
533- require.Len(t, d.notices.history(), 2, "each failure is recorded individually")
534- for _, n := range d.notices.history() {
535- require.Equal(t, domain.NoticeSnapshotRestore, n.Code)
536- require.Equal(t, domain.NoticeError, n.Severity)
537- }
538-
539- tr, _ := newCapturingTransport(t)
540- sess, ac, err := d.route(ports.Hello{
541- Version: ports.ProtocolVersion,
542- Intent: ports.IntentEphemeral,
543- Size: domain.Size{Cols: 80, Rows: 24},
544- }, tr)
545- require.NoError(t, err)
546- d.firstPaint(sess, ac, ac.size)
547-
548- toasts := awaitToastCount(t, ac, 1)
549- require.Equal(t, domain.NoticeSnapshotRestore, toasts[0].Code)
550- require.Equal(t, 2, toasts[0].Count, "two same-code failures dedup into one toast counted twice")
551- require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue")
552-}
553-
554-func TestRestoreSnapshotsDoesNotNotifyForCancelledRestore(t *testing.T) {
555- ctx, cancel := context.WithCancel(context.Background())
556- factory := &restorePTYFactory{onOpen: cancel}
557- store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "cancelled", Data: mustSnapshotBytes(t, snapcodec.Session{
558- Name: "cancelled",
559- Tabs: []snapcodec.Tab{{
560- Cols: 80, Rows: 24,
561- Tree: &layout.Tree{Focus: "one", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
562- layout.NewLeaf("one"), layout.NewLeaf("two"),
563- }}},
564- Panes: []snapcodec.Pane{
565- emptyTerminalPane(t, "one", "/one"),
566- emptyTerminalPane(t, "two", "/two"),
567- },
568- }},
569- })}}}
570- d := newTestDaemon(t, factory, stubClock{})
571- WithSnapshotStore(store)(d)
572-
573- d.restoreSnapshots(ctx)
574-
575- require.Len(t, factory.snapshotOpens(), 1, "cancellation stops restore before the next pane")
576- require.Empty(t, d.notices.history(), "shutdown cancellation is not a user-visible restore failure")
577-}
578-
579-func TestRestoreSnapshotsNotifiesGlobalOnResumeCommandWriteFailure(t *testing.T) {
580- proc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}}
581- store := processRestoreTestStore(t, "agent-session", proc)
582- factory := &restorePTYFactory{writeErr: errors.New("boom")}
583- d := newTestDaemon(t, factory, newNoticeClock())
584- WithSnapshotStore(store)(d)
585- d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}})
586-
587- // Mirrors real startup (daemon.go's Serve): restoreSnapshots runs off the
588- // caller goroutine and signals completion by closing d.restoreDone.
589- d.sessWg.Go(func() { d.restoreSnapshots(d.serveCtx) })
590-
591- select {
592- case <-d.restoreDone:
593- case <-time.After(2 * time.Second):
594- t.Fatal("restoreSnapshots did not close restoreDone in time")
595- }
596-
597- require.Len(t, factory.opens, 1, "the pane still opens even though the resume write fails")
598- require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes)
599-
600- history := d.notices.history()
601- require.Len(t, history, 1)
602- require.Equal(t, domain.NoticeAutoResume, history[0].Code)
603- require.Equal(t, domain.NoticeWarn, history[0].Severity)
604- require.Equal(t, "couldn't restore the running program in session agent-session", history[0].Message)
605-
606- tr, _ := newCapturingTransport(t)
607- sess, ac, err := d.route(ports.Hello{
608- Version: ports.ProtocolVersion,
609- Intent: ports.IntentEphemeral,
610- Size: domain.Size{Cols: 80, Rows: 24},
611- }, tr)
612- require.NoError(t, err)
613- d.firstPaint(sess, ac, ac.size)
614-
615- toasts := awaitToastCount(t, ac, 1)
616- require.Equal(t, domain.NoticeAutoResume, toasts[0].Code)
617- require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue")
618-}
619-
620-func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) {
621- releaseLoad := make(chan struct{})
622- loaded := make(chan struct{})
623- store := &restoreSnapshotStore{
624- loadFn: func() ([]ports.SnapshotBlob, error) {
625- close(loaded)
626- <-releaseLoad
627- return []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
628- Name: "work",
629- Tabs: []snapcodec.Tab{{
630- Cols: 80,
631- Rows: 24,
632- Tree: layout.NewTree("pane-1"),
633- Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/restored")},
634- }},
635- })}}, nil
636- },
637- }
638- factory := &restorePTYFactory{}
639- d := newTestDaemon(t, factory, stubClock{})
640- WithSnapshotStore(store)(d)
641-
642- go d.restoreSnapshots(context.Background())
643- <-loaded
644-
645- routed := make(chan *session, 1)
646- go func() {
647- tr, _ := newCapturingTransport(t)
648- sess, _, err := d.route(ports.Hello{Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 26}}, tr)
649- 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- }
667-}
668-
669-func TestNamedRouteRestoreBarrierReturnsShutdown(t *testing.T) {
670- d := newTestDaemon(t, &restorePTYFactory{}, stubClock{})
671- WithSnapshotStore(&restoreSnapshotStore{})(d)
672- d.serveCancel()
673-
674- tr, _ := newCapturingTransport(t)
675- sess, ac, err := d.route(ports.Hello{Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}}, tr)
676-
677- require.Nil(t, sess)
678- require.Nil(t, ac)
679- var perr *protoErr
680- require.ErrorAs(t, err, &perr)
681- require.Equal(t, ports.ErrServerShutdown, perr.code)
682-}
683-
684-func emptyTerminalPane(t *testing.T, id, cwd string) snapcodec.Pane {
685- t.Helper()
686- return terminalPane(t, id, "", cwd, nil, nil)
687-}
688-
689-func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte {
690- t.Helper()
691- b, err := snapcodec.Marshal(s)
692- require.NoError(t, err)
693- return b
694-}
695-
696-func cells(s string) []renderer.Cell {
697- out := make([]renderer.Cell, 0, len(s))
698- for _, r := range s {
699- out = append(out, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
700- }
701- return out
702-}
703-
704-type restoreSnapshotStore struct {
705- blobs []ports.SnapshotBlob
706- loadFn func() ([]ports.SnapshotBlob, error)
707-}
708-
709-func (s *restoreSnapshotStore) Write(string, []byte) error { return nil }
710-func (s *restoreSnapshotStore) Delete(string) error { return nil }
711-func (s *restoreSnapshotStore) Load() ([]ports.SnapshotBlob, error) {
712- if s.loadFn != nil {
713- return s.loadFn()
714- }
715- return s.blobs, nil
716-}
717-
718-type restorePTYFactory struct {
719- mu sync.Mutex
720- opens []restorePTYOpen
721- writeErr error
722- onOpen func()
723-}
724-
725-type restorePTYOpen struct {
726- ctx context.Context
727- command string
728- dir string
729- size domain.Size
730- env []string
731- pty *restorePTY
732-}
733-
734-func (f *restorePTYFactory) Open(ctx context.Context, command string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
735- f.mu.Lock()
736- pty := newRestorePTY()
737- pty.writeErr = f.writeErr
738- f.opens = append(f.opens, restorePTYOpen{ctx: ctx, command: command, dir: dir, size: sz, env: append([]string(nil), env...), pty: pty})
739- onOpen := f.onOpen
740- f.mu.Unlock()
741- if onOpen != nil {
742- onOpen()
743- }
744- return pty, nil
745-}
746-
747-func (f *restorePTYFactory) snapshotOpens() []restorePTYOpen {
748- f.mu.Lock()
749- defer f.mu.Unlock()
750- opens := make([]restorePTYOpen, len(f.opens))
751- copy(opens, f.opens)
752- for i := range opens {
753- opens[i].env = append([]string(nil), opens[i].env...)
754- }
755- return opens
756-}
757-
758-type restorePTY struct {
759- mu sync.Mutex
760- writes []string
761- closes int
762- writeErr error
763- done chan struct{}
764- once sync.Once
765-}
766-
767-func newRestorePTY() *restorePTY { return &restorePTY{done: make(chan struct{})} }
768-func (p *restorePTY) Read([]byte) (int, error) {
769- <-p.done
770- return 0, io.EOF
771-}
772-func (p *restorePTY) Write(b []byte) (int, error) {
773- p.mu.Lock()
774- defer p.mu.Unlock()
775- p.writes = append(p.writes, string(b))
776- if p.writeErr != nil {
777- return 0, p.writeErr
778- }
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-}
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 }
M internal/usecase/daemon/snapshot_test.go +10 -1142
@@ -3,10 +3,6 @@ package daemon
33 import (
44 "context"
55 "errors"
6- "fmt"
7- "io"
8- "sync"
9- "sync/atomic"
106 "testing"
117 "time"
128
@@ -16,1012 +12,26 @@ import (
1612 "github.com/bnema/vev/internal/domain"
1713 "github.com/bnema/vev/internal/ports"
1814 portsmocks "github.com/bnema/vev/internal/ports/mocks"
19- "github.com/bnema/vev/internal/usecase/layout"
20- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
2115 "github.com/bnema/vev/pkg/renderer"
22- "github.com/bnema/vev/pkg/vt"
2316 )
2417
25-func TestSnapshotEncodeWorkerDefersMarshalUntilAfterCapture(t *testing.T) {
26- store := &channelSnapshotStore{writes: make(chan []byte, 1)}
18+// TestSnapshotWorkerPublishesContentAddressedCapture verifies that all new
19+// checkpoints use the repository publication contract.
20+func TestSnapshotWorkerPublishesContentAddressedCapture(t *testing.T) {
2721 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
28- WithSnapshotStore(store)(d)
29- t.Cleanup(d.stopSnapshotEncodeWorker)
30- d.startSnapshotEncodeWorker()
31-
32- entered := make(chan struct{})
33- release := make(chan struct{})
34- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
35- close(entered)
36- <-release
37- return snapcodec.Marshal(s)
38- }
39- sess := newSnapshotTestSession(t, "work", false, "/work")
40- markSnapshotDirty(sess)
41- require.True(t, d.scheduleSnapshot(sess))
42- <-entered
43-
44- paneLocked := make(chan struct{})
45- releasePane := make(chan struct{})
46- go func() {
47- p := sess.tabs[0].panes["pane-1"]
48- p.mu.Lock()
49- close(paneLocked)
50- <-releasePane
51- p.mu.Unlock()
52- }()
53- <-paneLocked
54- close(releasePane)
55- close(release)
56- <-store.writes
57-}
58-
59-func TestSnapshotEncodeWorkerDoesNotBlockPTYOrCoordinatorRender(t *testing.T) {
60- exerciseBlockedSnapshotWorker(t, true)
61-}
62-
63-func TestSnapshotStoreWorkerDoesNotBlockPTYOrCoordinatorRender(t *testing.T) {
64- exerciseBlockedSnapshotWorker(t, false)
65-}
66-
67-func exerciseBlockedSnapshotWorker(t *testing.T, blockEncode bool) {
68- t.Helper()
69- store := newGatedSnapshotStore()
70- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
71- WithSnapshotStore(store)(d)
72- d.startSnapshotEncodeWorker()
73- t.Cleanup(func() {
74- store.unblock()
75- d.stopSnapshotEncodeWorker()
76- })
77- if blockEncode {
78- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
79- store.enter()
80- <-store.release
81- return snapcodec.Marshal(s)
82- }
83- }
84-
85- sess := newSnapshotTestSession(t, "live", false, "/live")
86- d.sessions[sess.id] = sess
87- sess.tabs[0].size = domain.Size{Cols: 80, Rows: 24}
88- p := sess.tabs[0].panes["pane-1"]
89- tr, sends, _ := newConn(t, ports.Frame{})
90- ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 8, Rows: 3}}
91- ac.setSession(sess)
92- sess.mu.Lock()
93- sess.client = ac
94- sess.mu.Unlock()
95- rendered := make(chan struct{}, 1)
96- rc := newRenderCoordinator(renderCoordinatorOptions{
97- clock: stubClock{},
98- onInvalidate: func(renderInvalidation) {
99- d.paint(sess, ac, true, nil)
100- rendered <- struct{}{}
101- },
102- })
103- sess.installRenderCoordinator(rc)
104- rc.attach(ac)
105- require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac)))
106- t.Cleanup(func() { rc.beginSessionTeardown().finish(); rc.waitForTimerWorkers() })
107-
108- markSnapshotDirty(sess)
109- require.True(t, d.scheduleSnapshot(sess))
110- <-store.entered
111-
112- paneLocked := make(chan struct{})
113- releasePane := make(chan struct{})
114- go func() {
115- p.mu.Lock()
116- close(paneLocked)
117- <-releasePane
118- p.mu.Unlock()
119- }()
120- <-paneLocked
121- close(releasePane)
122-
123- reader := portsmocks.NewMockPTY(t)
124- var reads atomic.Int32
125- reader.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
126- if reads.Add(1) == 1 {
127- return copy(buf, []byte("ok")), nil
128- }
129- return 0, io.EOF
130- }).Twice()
131- reader.EXPECT().Write(mock.Anything).Return(0, nil).Maybe()
132- p.mu.Lock()
133- p.pty = reader
134- p.onExit = func() {}
135- p.mu.Unlock()
136- d.sessWg.Add(1)
137- go d.ptyReader(sess, sess.tabs[0], p)
138- d.sessWg.Wait()
139- <-rendered
140- _ = awaitFrame(t, sends, ports.MsgOutput)
141- p.mu.Lock()
142- require.Contains(t, screenLineText(p.screen, 0), "ok")
143- p.mu.Unlock()
144-
145- store.unblock()
146- awaitSnapshotIdle(t, sess)
147- require.True(t, sess.snapDirty.Load(), "PTY output published while blocked must remain retryable")
148-}
149-
150-func TestFinalSnapshotFlushPersistsNewestNamedSessionBeforeWorkerTeardown(t *testing.T) {
151- clock := newFinalFlushClock()
152- store := &channelSnapshotStore{writes: make(chan []byte, 1)}
153- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
154- WithSnapshotStore(store)(d)
155- d.startSnapshotEncodeWorker()
156-
157- entered := make(chan struct{})
158- release := make(chan struct{})
159- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
160- close(entered)
161- <-release
162- return snapcodec.Marshal(s)
163- }
164- sess := newSnapshotTestSession(t, "final", false, "/work")
165- pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
166- require.True(t, ok)
167- pty.EXPECT().Close().Return(nil).Once()
168- sess.tabs[0].panes["pane-1"].screen.Write([]byte("\rnewest"))
169- d.sessions[sess.id] = sess
170-
171- require.NoError(t, d.killSession(sess, 0, false))
172- <-entered
173- stopped := make(chan struct{})
174- go func() {
175- d.stopSnapshotEncodeWorker()
176- close(stopped)
177- }()
178- close(release)
179- <-stopped
180- awaitSnapshotIdle(t, sess)
181-
182- select {
183- case data := <-store.writes:
184- snap, err := snapcodec.Unmarshal(data)
185- require.NoError(t, err)
186- frame, err := vt.UnmarshalVisible(snap.Tabs[0].Panes[0].Visible)
187- require.NoError(t, err)
188- require.Contains(t, rowText(frame.Row(0)), "newest")
189- default:
190- t.Fatal("final snapshot was dropped during worker teardown")
191- }
192-}
193-
194-func TestFinalSnapshotSupersedesPendingCaptureBeforeWorkerTeardown(t *testing.T) {
195- clock := newFinalFlushClock()
196- store := &channelSnapshotStore{writes: make(chan []byte, 2)}
197- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
198- WithSnapshotStore(store)(d)
199- d.startSnapshotEncodeWorker()
200-
201- entered := make(chan struct{})
202- release := make(chan struct{})
203- var calls atomic.Int32
204- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
205- if calls.Add(1) == 1 {
206- close(entered)
207- <-release
208- }
209- return snapcodec.Marshal(s)
210- }
211- sess := newSnapshotTestSession(t, "pending-final", false, "/work")
212- markSnapshotDirty(sess)
213- require.True(t, d.scheduleSnapshot(sess))
214- <-entered
215-
216- pane := sess.tabs[0].panes["pane-1"]
217- pane.mu.Lock()
218- pane.screen.Write([]byte("\rterminal"))
219- pane.mu.Unlock()
220- markSnapshotDirty(sess)
221- require.True(t, d.scheduleFinalSnapshot(sess))
222-
223- stopped := make(chan struct{})
224- go func() {
225- d.stopSnapshotEncodeWorker()
226- close(stopped)
227- }()
228- close(release)
229- <-stopped
230-
231- require.Len(t, store.writes, 2, "teardown must flush the newer terminal capture")
232- var latest snapcodec.Session
233- for range 2 {
234- snap, err := snapcodec.Unmarshal(<-store.writes)
235- require.NoError(t, err)
236- latest = snap
237- }
238- frame, err := vt.UnmarshalVisible(latest.Tabs[0].Panes[0].Visible)
239- require.NoError(t, err)
240- require.Contains(t, rowText(frame.Row(0)), "terminal")
241-}
242-
243-func TestFinalSnapshotFallbackCoalescesRepeatedTerminalCaptures(t *testing.T) {
244- store := discardSnapshotStore{}
245- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
246- WithSnapshotStore(store)(d)
247- d.startSnapshotEncodeWorker()
248-
249- entered := make(chan struct{})
250- release := make(chan struct{})
251- var releaseOnce sync.Once
252- releaseWorker := func() { releaseOnce.Do(func() { close(release) }) }
253- defer func() {
254- releaseWorker()
255- d.stopSnapshotEncodeWorker()
256- }()
257- var calls atomic.Int32
258- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
259- if calls.Add(1) == 1 {
260- close(entered)
261- <-release
262- }
263- return snapcodec.Marshal(s)
264- }
265- sess := newSnapshotTestSession(t, "coalesced-final", false, "/work")
266- markSnapshotDirty(sess)
267- require.True(t, d.scheduleSnapshot(sess))
268- <-entered
269-
270- for generation := uint64(1); generation <= 32; generation++ {
271- capture, ok := d.captureSnapshotState(sess, generation)
272- require.True(t, ok)
273- require.True(t, d.enqueueFinalSnapshotCapture(capture))
274- }
275-
276- d.snapshotWorkerMu.Lock()
277- captures := make([]*snapshotCapture, 0, len(d.snapshotFinalJobs))
278- for _, capture := range d.snapshotFinalJobs {
279- captures = append(captures, capture)
280- }
281- d.snapshotWorkerMu.Unlock()
282- require.Len(t, captures, 1, "blocked workers retain only the latest terminal capture per session")
283- require.Equal(t, uint64(32), captures[0].generation)
284- releaseWorker()
285-}
286-
287-func TestFinalSnapshotFallbackBoundsDistinctTerminalSessions(t *testing.T) {
288- store := discardSnapshotStore{}
289- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
290- WithSnapshotStore(store)(d)
291- d.startSnapshotEncodeWorker()
292-
293- entered := make(chan struct{})
294- release := make(chan struct{})
295- var calls atomic.Int32
296- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
297- if calls.Add(1) == 1 {
298- close(entered)
299- <-release
300- }
301- return snapcodec.Marshal(s)
302- }
303- defer func() {
304- close(release)
305- d.stopSnapshotEncodeWorker()
306- }()
307-
308- active := newSnapshotTestSession(t, "active", false, "/work")
309- queued := newSnapshotTestSession(t, "queued", false, "/work")
310- markSnapshotDirty(active)
311- markSnapshotDirty(queued)
312- require.True(t, d.scheduleSnapshot(active))
313- <-entered
314- require.True(t, d.scheduleSnapshot(queued), "fill the regular bounded queue before terminal retirement")
315-
316- var superseded *session
317- for i := range snapshotFinalQueueCapacity {
318- sess := newSnapshotTestSession(t, fmt.Sprintf("terminal-%d", i), false, "/work")
319- markSnapshotDirty(sess)
320- require.True(t, d.scheduleFinalSnapshot(sess))
321- if i == 0 {
322- superseded = sess
323- }
324- }
325- markSnapshotDirty(superseded)
326- require.True(t, d.scheduleFinalSnapshot(superseded), "a newer generation for a retained session must supersede at capacity")
327-
328- rejected := newSnapshotTestSession(t, "terminal-rejected", false, "/work")
329- d.mu.Lock()
330- d.sessions[rejected.id] = rejected
331- d.mu.Unlock()
332- markSnapshotDirty(rejected)
333- err := d.killSession(rejected, ports.ReasonSessionKilled, false)
334- require.ErrorContains(t, err, "snapshot worker unavailable or saturated")
335- d.mu.Lock()
336- require.Same(t, rejected, d.sessions[rejected.id], "rejected terminal state must remain registered for retry")
337- d.mu.Unlock()
338-
339- d.snapshotWorkerMu.Lock()
340- retained := len(d.snapshotFinalJobs)
341- owners := len(d.snapshotFinalOrder)
342- supersedingCapture := d.snapshotFinalJobs[superseded]
343- d.snapshotWorkerMu.Unlock()
344- require.Equal(t, snapshotFinalQueueCapacity, retained, "distinct terminal captures must have a global bound")
345- require.Equal(t, snapshotFinalQueueCapacity, owners, "retention order must not retain extra session owners")
346- require.NotNil(t, supersedingCapture)
347- require.Equal(t, uint64(2), supersedingCapture.generation, "retained session must keep its newest terminal generation")
348- require.NotNil(t, rejected)
349- require.True(t, rejected.snapEligible.Load())
350- require.True(t, rejected.snapDirty.Load(), "a rejected terminal capture must remain retryable")
351- awaitSnapshotIdle(t, rejected)
352-}
353-
354-func TestStopSnapshotEncodeWorkerDoesNotWaitForUncancellableWrite(t *testing.T) {
355- store := newNeverReturningSnapshotStore()
356- clock := newFinalFlushClock()
357- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
358- WithSnapshotStore(store)(d)
359- d.startSnapshotEncodeWorker()
360-
361- sess := newSnapshotTestSession(t, "stuck-write", false, "/work")
362- require.True(t, d.captureSession(sess))
363- <-store.entered
364-
365- stopped := make(chan struct{})
366- go func() {
367- d.stopSnapshotEncodeWorker()
368- close(stopped)
369- }()
370- timer := <-clock.timers
371- timer.ch <- time.Time{}
372- <-stopped
373- awaitSnapshotIdle(t, sess)
374- require.True(t, sess.snapDirty.Load(), "an abandoned write must remain retryable")
375- require.NotPanics(t, func() { require.False(t, d.scheduleSnapshot(sess)) })
376-}
377-
378-func TestRejectedSnapshotCaptureLeavesSessionRetryable(t *testing.T) {
379- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
380- WithSnapshotStore(&channelSnapshotStore{writes: make(chan []byte, 1)})(d)
381- sess := newSnapshotTestSession(t, "rejected", false, "/work")
382- sess.name = ""
383- sess.snapEligible.Store(true)
384-
385- markSnapshotDirty(sess)
386- require.False(t, d.scheduleSnapshot(sess))
387- awaitSnapshotIdle(t, sess)
388- require.True(t, sess.snapDirty.Load())
389-}
390-
391-func TestStoppedSnapshotWorkerLeavesCaptureRetryable(t *testing.T) {
392- store := &channelSnapshotStore{writes: make(chan []byte, 1)}
393- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
394- WithSnapshotStore(store)(d)
395- d.startSnapshotEncodeWorker()
396- d.stopSnapshotEncodeWorker()
397-
398- sess := newSnapshotTestSession(t, "stopped", false, "/work")
399- markSnapshotDirty(sess)
400- require.False(t, d.scheduleSnapshot(sess), "a stopped worker must not accept a capture")
401- awaitSnapshotIdle(t, sess)
402- require.True(t, sess.snapDirty.Load(), "an unsubmitted capture must remain retryable")
403-}
404-
405-func TestSnapshotQueueSaturationLeavesCaptureRetryable(t *testing.T) {
406- store := &channelSnapshotStore{writes: make(chan []byte, 3)}
407- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
408- WithSnapshotStore(store)(d)
409- startSnapshotEncodeWorker(t, d)
410- entered := make(chan struct{})
411- release := make(chan struct{})
412- var calls atomic.Int32
413- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
414- if calls.Add(1) == 1 {
415- close(entered)
416- <-release
417- }
418- return snapcodec.Marshal(s)
419- }
420- first := newSnapshotTestSession(t, "first", false, "/work")
421- second := newSnapshotTestSession(t, "second", false, "/work")
422- third := newSnapshotTestSession(t, "third", false, "/work")
423- markSnapshotDirty(first)
424- markSnapshotDirty(second)
425- markSnapshotDirty(third)
426- require.True(t, d.scheduleSnapshot(first))
427- <-entered
428- require.True(t, d.scheduleSnapshot(second), "one capture may wait in the bounded queue")
429- require.False(t, d.scheduleSnapshot(third), "full queue must not block the producer")
430- require.True(t, third.snapDirty.Load())
431-
432- close(release)
433- awaitSnapshotClean(t, first)
434- awaitSnapshotClean(t, second)
435- require.True(t, d.scheduleSnapshot(third))
436- <-store.writes
437- awaitSnapshotClean(t, third)
438-}
439-
440-func TestNamedFinalSnapshotSurvivesSaturatedQueue(t *testing.T) {
441- for _, tc := range []struct {
442- name string
443- stop func(t *testing.T, d *Daemon, sess *session)
444- }{
445- {
446- name: "kill session",
447- stop: func(t *testing.T, d *Daemon, sess *session) {
448- t.Helper()
449- require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
450- },
451- },
452- {
453- name: "shutdown all",
454- stop: func(t *testing.T, d *Daemon, _ *session) {
455- t.Helper()
456- d.shutdownAll(ports.ReasonServerShutdown)
457- },
458- },
459- } {
460- t.Run(tc.name, func(t *testing.T) {
461- store := &channelSnapshotStore{writes: make(chan []byte, 3)}
462- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
463- WithSnapshotStore(store)(d)
464- startSnapshotEncodeWorker(t, d)
465-
466- entered := make(chan struct{})
467- release := make(chan struct{})
468- var calls atomic.Int32
469- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
470- if calls.Add(1) == 1 {
471- close(entered)
472- <-release
473- }
474- return snapcodec.Marshal(s)
475- }
476- first := newSnapshotTestSession(t, "first", false, "/work")
477- second := newSnapshotTestSession(t, "second", false, "/work")
478- final := newSnapshotTestSession(t, "final", false, "/work")
479- finalPTY, ok := final.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
480- require.True(t, ok)
481- finalPTY.EXPECT().Close().Return(nil).Once()
482- markSnapshotDirty(first)
483- markSnapshotDirty(second)
484- require.True(t, d.scheduleSnapshot(first))
485- <-entered
486- require.True(t, d.scheduleSnapshot(second))
487- d.sessions[final.id] = final
488-
489- tc.stop(t, d, final)
490- require.NotContains(t, d.sessions, final.id, "final capture must survive session removal")
491- close(release)
492-
493- written := make(map[string]bool, 3)
494- deadline := time.NewTimer(time.Second)
495- defer deadline.Stop()
496- for len(written) < 3 {
497- select {
498- case data := <-store.writes:
499- snap, err := snapcodec.Unmarshal(data)
500- require.NoError(t, err)
501- written[snap.Name] = true
502- case <-deadline.C:
503- t.Fatalf("final snapshot was dropped: wrote %v", written)
504- }
505- }
506- require.True(t, written[final.name])
507- })
508- }
509-}
510-
511-func TestSnapshotCompletionSignalsWaiters(t *testing.T) {
512- store := &channelSnapshotStore{writes: make(chan []byte, 1)}
513- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
514- WithSnapshotStore(store)(d)
515- startSnapshotEncodeWorker(t, d)
516-
517- entered := make(chan struct{})
518- release := make(chan struct{})
519- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
520- close(entered)
521- <-release
522- return snapcodec.Marshal(s)
523- }
524- sess := newSnapshotTestSession(t, "completion", false, "/work")
525- require.True(t, d.captureSession(sess))
526- <-entered
527-
528- sess.snapshotMu.Lock()
529- changed := sess.snapshotChangeLocked()
530- sess.snapshotMu.Unlock()
531- close(release)
532- <-changed
533- awaitSnapshotClean(t, sess)
534-}
535-
536-func TestSnapshotSuccessDoesNotClearNewerGeneration(t *testing.T) {
537- store := &channelSnapshotStore{writes: make(chan []byte, 2)}
538- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
539- WithSnapshotStore(store)(d)
22+ repository := portsmocks.NewMockSnapshotRepository(t)
23+ WithSnapshotRepository(repository, nil)(d)
54024 startSnapshotEncodeWorker(t, d)
541- entered := make(chan struct{})
542- release := make(chan struct{})
543- var calls atomic.Int32
544- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
545- if calls.Add(1) == 1 {
546- close(entered)
547- <-release
548- }
549- return snapcodec.Marshal(s)
550- }
551- sess := newSnapshotTestSession(t, "generation", false, "/work")
55225
553- markSnapshotDirty(sess)
554- require.True(t, d.scheduleSnapshot(sess))
555- <-entered
556- markSnapshotDirty(sess)
557- close(release)
558- <-store.writes
559- awaitSnapshotIdle(t, sess)
560- require.True(t, sess.snapDirty.Load(), "a stale successful capture must not clear newer state")
561-
562- require.True(t, d.scheduleSnapshot(sess))
563- <-store.writes
564- awaitSnapshotClean(t, sess)
565-}
566-
567-func TestSnapshotEncodeFailureLeavesSessionDirtyAndRetries(t *testing.T) {
568- store := &channelSnapshotStore{writes: make(chan []byte, 1)}
569- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
570- WithSnapshotStore(store)(d)
571- startSnapshotEncodeWorker(t, d)
572- sess := newSnapshotTestSession(t, "retry-encode", false, "/work")
573- failed := make(chan struct{}, 1)
574- var calls atomic.Int32
575- d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
576- if calls.Add(1) == 1 {
577- failed <- struct{}{}
578- return nil, errors.New("encode failed")
579- }
580- return snapcodec.Marshal(s)
581- }
582-
583- require.True(t, d.captureSession(sess))
584- <-failed
585- awaitSnapshotIdle(t, sess)
586- require.True(t, sess.snapDirty.Load())
587- require.True(t, d.scheduleSnapshot(sess))
588- <-store.writes
589- awaitSnapshotClean(t, sess)
590-}
591-
592-func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) {
593- clock := newManualSnapshotClock()
594- store := portsmocks.NewMockSnapshotStore(t)
595- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
596- WithSnapshotStore(store)(d)
597- d.procCwd = func(int) (string, error) { return "/pane", nil }
598- startSnapshotEncodeWorker(t, d)
599-
600- dirty := newSnapshotTestSession(t, "dirty", false, "/fallback")
601- clean := newSnapshotTestSession(t, "clean", false, "/fallback")
602- ephemeral := newSnapshotTestSession(t, "0", true, "/fallback")
603- dirty.snapDirty.Store(true)
604- ephemeral.snapDirty.Store(true)
605- d.sessions[dirty.id] = dirty
606- d.sessions[clean.id] = clean
607- d.sessions[ephemeral.id] = ephemeral
608-
609- wrote := make(chan struct{}, 1)
610- store.EXPECT().Write("dirty", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
611- snap, err := snapcodec.Unmarshal(data)
612- require.NoError(t, err)
613- require.Equal(t, "dirty", snap.Name)
614- wrote <- struct{}{}
615- return nil
616- }).Once()
617-
618- ctx, cancel := context.WithCancel(t.Context())
619- defer cancel()
620- go d.snapshotSaver(ctx)
621- clock.fireNext(t)
622- select {
623- case <-wrote:
624- case <-time.After(time.Second):
625- t.Fatal("timed out waiting for dirty snapshot write")
626- }
627- awaitSnapshotClean(t, dirty)
628-
629- clock.fireNext(t)
630- select {
631- case <-wrote:
632- t.Fatal("clean second interval wrote unexpectedly")
633- case <-time.After(25 * time.Millisecond):
634- }
635-}
636-
637-func TestSnapshotCaptureExcludesFloatingRuntime(t *testing.T) {
638- store := portsmocks.NewMockSnapshotStore(t)
639- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
640- WithSnapshotStore(store)(d)
641- startSnapshotEncodeWorker(t, d)
642- sess := newSnapshotTestSession(t, "work", false, "/fallback")
643- tb := sess.tabs[0]
644- floatingPTY, _ := newBlockingPTY(t)
645- floating := newPane(layout.PaneID("floating"), floatingPTY, domain.Size{Cols: 20, Rows: 8})
646- tb.mu.Lock()
647- generation := tb.beginFloatingWarmLocked(false)
648- require.True(t, tb.installFloatingLocked(floating, generation))
649- tb.mu.Unlock()
650-
651- store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
652- snap, err := snapcodec.Unmarshal(data)
653- require.NoError(t, err)
654- require.Len(t, snap.Tabs, 1)
655- require.Len(t, snap.Tabs[0].Panes, 1, "floating runtime must not be persisted")
656- require.Equal(t, layout.PaneID("pane-1"), snap.Tabs[0].Panes[0].ID)
657- return nil
658- }).Once()
659- require.True(t, d.captureSession(sess))
660- awaitSnapshotClean(t, sess)
661-}
662-
663-func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) {
664- store := portsmocks.NewMockSnapshotStore(t)
665- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
666- WithSnapshotStore(store)(d)
667- startSnapshotEncodeWorker(t, d)
668- d.procGroupArgv = func(pgid int, shellPid int) ([]string, error) {
669- require.Equal(t, 4321, pgid)
670- require.Equal(t, 1234, shellPid)
671- return []string{"claude", "--session-id", "agent-123"}, nil
672- }
673-
674- sess := newSnapshotTestSession(t, "work", false, "/fallback")
675- pty := snapshotProcessPTY(t, 4321)
676- sess.tabs[0].panes["pane-1"].pty = pty
677-
678- store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
679- snap, err := snapcodec.Unmarshal(data)
680- require.NoError(t, err)
681- proc := snap.Tabs[0].Panes[0].Process
682- require.NotNil(t, proc)
683- require.Equal(t, []string{"claude", "--session-id", "agent-123"}, proc.Argv)
684- require.Equal(t, processStrategyClaude, proc.Strategy)
685- require.Equal(t, "agent-123", proc.Opts.AgentSessionID)
686- return nil
687- }).Once()
688-
689- require.True(t, d.captureSession(sess))
690- awaitSnapshotClean(t, sess)
691-}
692-
693-func TestSnapshotCaptureProcessFailureDoesNotBlockSnapshot(t *testing.T) {
694- store := portsmocks.NewMockSnapshotStore(t)
695- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
696- WithSnapshotStore(store)(d)
697- startSnapshotEncodeWorker(t, d)
698- d.procGroupArgv = func(int, int) ([]string, error) { return nil, errors.New("inspect failed") }
699-
700- sess := newSnapshotTestSession(t, "work", false, "/fallback")
701- pty := snapshotProcessPTY(t, 4321)
702- sess.tabs[0].panes["pane-1"].pty = pty
703-
704- store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
705- snap, err := snapcodec.Unmarshal(data)
706- require.NoError(t, err)
707- require.Nil(t, snap.Tabs[0].Panes[0].Process)
708- return nil
709- }).Once()
710-
711- require.True(t, d.captureSession(sess))
712- awaitSnapshotClean(t, sess)
713-}
714-
715-func TestSnapshotCaptureSkipsBareShellProcess(t *testing.T) {
716- store := portsmocks.NewMockSnapshotStore(t)
717- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
718- WithSnapshotStore(store)(d)
719- startSnapshotEncodeWorker(t, d)
720- d.procGroupArgv = func(int, int) ([]string, error) {
721- t.Fatal("bare shell should not inspect argv")
722- return nil, nil
723- }
724-
725- sess := newSnapshotTestSession(t, "work", false, "/fallback")
726- pty := snapshotProcessPTY(t, 1234)
727- sess.tabs[0].panes["pane-1"].pty = pty
728-
729- store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
730- snap, err := snapcodec.Unmarshal(data)
731- require.NoError(t, err)
732- require.Nil(t, snap.Tabs[0].Panes[0].Process)
733- return nil
734- }).Once()
26+ repository.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(p ports.SnapshotPublication) bool {
27+ return p.Name == "work" && p.Generation == 1 && len(p.Manifest) > 0 && len(p.Objects) > 0
28+ })).Return(nil).Once()
73529
30+ sess := newSnapshotTestSession(t, "work", false, "/work")
73631 require.True(t, d.captureSession(sess))
73732 awaitSnapshotClean(t, sess)
73833 }
73934
740-func TestKillSessionSnapshotsNamedSessionBeforeClosingPanes(t *testing.T) {
741- store := newGatedSnapshotStore()
742- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
743- WithSnapshotStore(store)(d)
744- d.startSnapshotEncodeWorker()
745- t.Cleanup(func() {
746- store.unblock()
747- d.stopSnapshotEncodeWorker()
748- })
749- d.procCwd = func(int) (string, error) { return "/live", nil }
750-
751- sess := newSnapshotTestSession(t, "work", false, "/fallback")
752- d.sessions[sess.id] = sess
753-
754- var closed atomic.Bool
755- mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
756- require.True(t, ok)
757- mockPTY.EXPECT().Close().RunAndReturn(func() error {
758- closed.Store(true)
759- return nil
760- }).Once()
761-
762- require.NoError(t, d.killSession(sess, 0, false))
763- // closeAllPanes runs synchronously inside killSession, which has already
764- // returned here. The store's write gate is still held below, so
765- // persistence cannot have completed yet: this proves pane close did not
766- // wait on it, with zero timing dependence on the encode worker.
767- require.True(t, closed.Load(), "snapshot persistence must not delay pane close")
768-
769- store.unblock()
770- awaitSnapshotClean(t, sess)
771-
772- writes, name, data := store.recorded()
773- require.Equal(t, 1, writes)
774- require.Equal(t, "work", name)
775- snap, err := snapcodec.Unmarshal(data)
776- require.NoError(t, err)
777- require.Equal(t, "work", snap.Name)
778- require.Equal(t, "/live", snap.Tabs[0].Panes[0].Cwd)
779-}
780-
781-func TestPTYReaderSynchronizedUpdateFlushMarksSnapshotDirty(t *testing.T) {
782- d := newTestDaemon(t, nil, stubClock{})
783- p := portsmocks.NewMockPTY(t)
784- chunks := [][]byte{[]byte("\x1b[?2026hhello\x1b[?2026l")}
785- p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
786- if len(chunks) == 0 {
787- return 0, io.EOF
788- }
789- n := copy(buf, chunks[0])
790- chunks = chunks[1:]
791- return n, nil
792- })
793- p.EXPECT().Close().Return(nil).Maybe()
794-
795- sctx, cancel := context.WithCancel(context.Background())
796- defer cancel()
797- tb := newTestTabWithContext(p, sctx, cancel)
798- sess := &session{id: "sync", name: "sync", tabs: []*tab{tb}, ctx: sctx, cancel: cancel}
799- sess.snapEligible.Store(true)
800- d.sessions[sess.id] = sess
801- d.sessWg.Add(1)
802- d.ptyReader(sess, tb, tb.focusedPane())
803-
804- require.True(t, sess.snapDirty.Load())
805-}
806-
807-func TestPTYReaderSameReadSynchronizedOutputUsesUrgentCoordinatorDeadline(t *testing.T) {
808- d := newTestDaemon(t, nil, stubClock{})
809- p := chunkReadPTY(t, []byte("\x1b[?2026hcomplete batch\x1b[?2026l"))
810- sctx, cancel := context.WithCancel(context.Background())
811- defer cancel()
812- tb := newTestTabWithContext(p, sctx, cancel)
813- sess := &session{id: "sync-urgent", name: "sync-urgent", tabs: []*tab{tb}, client: &attachedClient{}, ctx: sctx, cancel: cancel}
814- invs := make(chan renderInvalidation, 1)
815- clock := newCoordinatorMockClock(t, 2)
816- sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{
817- clock: clock.clock,
818- onInvalidate: func(inv renderInvalidation) { invs <- inv },
819- }))
820- d.sessions[sess.id] = sess
821- d.sessWg.Add(1)
822- d.ptyReader(sess, tb, tb.focusedPane())
823-
824- inv := awaitInvalidation(t, invs)
825- require.Equal(t, invalidateUrgent, inv.class, "a complete same-read synchronized batch must flush urgently")
826- timer := awaitCoordinatorScheduledTimer(t, clock)
827- require.Equal(t, urgentRenderDeadline, timer.duration)
828-}
829-
830-func TestSnapshotSaverKeepsDirtyWhenWriteFails(t *testing.T) {
831- clock := newManualSnapshotClock()
832- store := portsmocks.NewMockSnapshotStore(t)
833- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
834- WithSnapshotStore(store)(d)
835- startSnapshotEncodeWorker(t, d)
836-
837- sess := newSnapshotTestSession(t, "retry", false, "/fallback")
838- sess.snapDirty.Store(true)
839- d.sessions[sess.id] = sess
840-
841- failed := make(chan struct{}, 1)
842- store.EXPECT().Write("retry", mock.Anything).RunAndReturn(func(string, []byte) error {
843- failed <- struct{}{}
844- return errors.New("boom")
845- }).Once()
846- wrote := make(chan struct{}, 1)
847- store.EXPECT().Write("retry", mock.Anything).RunAndReturn(func(string, []byte) error {
848- wrote <- struct{}{}
849- return nil
850- }).Once()
851-
852- ctx, cancel := context.WithCancel(t.Context())
853- defer cancel()
854- go d.snapshotSaver(ctx)
855- clock.fireNext(t)
856- <-failed
857- awaitSnapshotIdle(t, sess)
858- require.True(t, sess.snapDirty.Load())
859- clock.fireNext(t)
860- select {
861- case <-wrote:
862- case <-time.After(time.Second):
863- t.Fatal("timed out waiting for retry snapshot write")
864- }
865- awaitSnapshotClean(t, sess)
866- require.False(t, sess.snapDirty.Load())
867-}
868-
869-func TestKillSessionPurgeDeletesSnapshot(t *testing.T) {
870- store := portsmocks.NewMockSnapshotStore(t)
871- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
872- WithSnapshotStore(store)(d)
873-
874- sess := newSnapshotTestSession(t, "work", false, "/fallback")
875- d.sessions[sess.id] = sess
876-
877- store.EXPECT().Delete("work").Return(nil).Once()
878- mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
879- require.True(t, ok)
880- mockPTY.EXPECT().Close().Return(nil).Once()
881-
882- require.NoError(t, d.killSession(sess, 0, true))
883-}
884-
885-func TestKillSessionPurgeReturnsSnapshotDeleteError(t *testing.T) {
886- store := portsmocks.NewMockSnapshotStore(t)
887- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
888- WithSnapshotStore(store)(d)
889-
890- sess := newSnapshotTestSession(t, "work", false, "/fallback")
891- d.sessions[sess.id] = sess
892-
893- store.EXPECT().Delete("work").Return(errors.New("delete failed")).Once()
894- mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
895- require.True(t, ok)
896- mockPTY.EXPECT().Close().Return(nil).Once()
897-
898- err := d.killSession(sess, 0, true)
899- require.ErrorContains(t, err, "delete failed")
900- require.NotContains(t, d.sessions, sess.id)
901- require.NotContains(t, d.stopped, "work")
902-}
903-
904-func TestRefreshSessionCwdMarksSnapshotDirty(t *testing.T) {
905- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
906- d.procCwd = func(int) (string, error) { return "/new", nil }
907- sess := newSnapshotTestSession(t, "work", false, "/old")
908- d.sessions[sess.id] = sess
909-
910- d.refreshSessionCwd(sess)
911- require.Equal(t, "/new", sess.cwd)
912- require.True(t, sess.snapDirty.Load())
913-}
914-
915-func TestStoppedSessionKillPurgesSnapshot(t *testing.T) {
916- store := portsmocks.NewMockSnapshotStore(t)
917- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
918- WithSnapshotStore(store)(d)
919- d.stopped["work"] = stoppedSession{name: "work", cwd: "/work", createdAt: 1}
920-
921- store.EXPECT().Delete("work").Return(nil).Once()
922- tr, _, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})})
923-
924- d.handleKill(tr, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})})
925-
926- require.NotContains(t, d.stopped, "work")
927-}
928-
929-func TestStoppedSessionKillSnapshotDeleteFailureKeepsMetadata(t *testing.T) {
930- store := portsmocks.NewMockSnapshotStore(t)
931- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
932- WithSnapshotStore(store)(d)
933- d.stopped["work"] = stoppedSession{name: "work", cwd: "/work", createdAt: 1}
934-
935- store.EXPECT().Delete("work").Return(errors.New("delete failed")).Once()
936- tr, sends, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})})
937-
938- d.handleKill(tr, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})})
939-
940- require.Contains(t, d.stopped, "work")
941- frame := awaitFrame(t, sends, ports.MsgError)
942- msg, err := ports.UnmarshalErrorMsg(frame.Payload)
943- require.NoError(t, err)
944- require.Equal(t, ports.ErrInternal, msg.Code)
945-}
946-
947-func TestRenameSessionDeletesOldSnapshotAndMarksDirty(t *testing.T) {
948- store := portsmocks.NewMockSnapshotStore(t)
949- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
950- WithSnapshotStore(store)(d)
951- sess := newSnapshotTestSession(t, "old", false, "/fallback")
952- d.sessions[sess.id] = sess
953-
954- store.EXPECT().Delete("old").Return(nil).Once()
955- require.NoError(t, d.renameSession(sess, "new"))
956-
957- require.True(t, sess.snapDirty.Load())
958- require.Nil(t, d.findByNameLocked("old"))
959-}
960-
961-func TestSnapshotSaverCapturesLayoutOnlyDirtySave(t *testing.T) {
962- clock := newManualSnapshotClock()
963- store := portsmocks.NewMockSnapshotStore(t)
964- factory := portsmocks.NewMockPTYFactory(t)
965- d := newTestDaemon(t, factory, clock)
966- WithSnapshotStore(store)(d)
967- startSnapshotEncodeWorker(t, d)
968-
969- sess := newSnapshotTestSession(t, "work", false, "/work")
970- tctx, tcancel := context.WithCancel(context.Background())
971- t.Cleanup(tcancel)
972- sess.ctx = tctx
973- sess.cancel = tcancel
974- sess.tabs[0].ctx = tctx
975- sess.tabs[0].cancel = tcancel
976- for _, p := range sess.tabs[0].panes {
977- p.ctx = tctx
978- p.cancel = tcancel
979- }
980- sess.tabs[0].size = domain.Size{Cols: 41, Rows: 10}
981- d.sessions[sess.id] = sess
982- newPTY := portsmocks.NewMockPTY(t)
983- newPTY.EXPECT().Pid().Return(2345).Maybe()
984- newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
985- newPTY.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe()
986- newPTY.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
987- newPTY.EXPECT().ForegroundPgid().Return(0, nil).Maybe()
988- factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()
989-
990- require.NoError(t, d.splitPane(sess, nil, layout.Right))
991- require.True(t, sess.snapDirty.Load())
992-
993- wrote := make(chan struct{}, 1)
994- store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
995- snap, err := snapcodec.Unmarshal(data)
996- require.NoError(t, err)
997- require.Len(t, snap.Tabs, 1)
998- require.Len(t, snap.Tabs[0].Panes, 2)
999- wrote <- struct{}{}
1000- return nil
1001- }).Once()
1002-
1003- ctx, cancel := context.WithCancel(t.Context())
1004- defer cancel()
1005- go d.snapshotSaver(ctx)
1006- clock.fireNext(t)
1007- select {
1008- case <-wrote:
1009- case <-time.After(time.Second):
1010- t.Fatal("timed out waiting for layout-only dirty snapshot write")
1011- }
1012-}
1013-
1014-func snapshotProcessPTY(t *testing.T, pgid int) *portsmocks.MockPTY {
1015- t.Helper()
1016- pty := portsmocks.NewMockPTY(t)
1017- pty.EXPECT().Pid().Return(1234).Maybe()
1018- pty.EXPECT().Read(mock.Anything).Return(0, errors.New("unused")).Maybe()
1019- pty.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe()
1020- pty.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
1021- pty.EXPECT().ForegroundPgid().Return(pgid, nil).Once()
1022- return pty
1023-}
1024-
102535 func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd string) *session {
102636 t.Helper()
102737 pty := portsmocks.NewMockPTY(t)
@@ -1035,17 +45,7 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin
103545 p := tb.panes["pane-1"]
103646 p.screen.Write([]byte("hello"))
103747 require.NoError(t, p.history.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}}))
1038- sess := &session{
1039- id: domain.SessionID("sess-" + name),
1040- name: name,
1041- ephemeral: ephemeral,
1042- ctx: context.Background(),
1043- cancel: func() {},
1044- tabs: []*tab{tb},
1045- active: 0,
1046- cwd: cwd,
1047- createdAt: 42,
1048- }
48+ sess := &session{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, active: 0, cwd: cwd, createdAt: 42}
104949 sess.snapEligible.Store(!ephemeral && name != "")
105050 return sess
105151 }
@@ -1095,135 +95,3 @@ func startSnapshotEncodeWorker(t *testing.T, d *Daemon) {
109595 d.startSnapshotEncodeWorker()
109696 t.Cleanup(d.stopSnapshotEncodeWorker)
109797 }
1098-
1099-type discardSnapshotStore struct{}
1100-
1101-func (discardSnapshotStore) Write(string, []byte) error { return nil }
1102-func (discardSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
1103-func (discardSnapshotStore) Delete(string) error { return nil }
1104-
1105-type gatedSnapshotStore struct {
1106- entered chan struct{}
1107- release chan struct{}
1108- once sync.Once
1109- releaseOnce sync.Once
1110-
1111- mu sync.Mutex
1112- writes int
1113- name string
1114- data []byte
1115-}
1116-
1117-func newGatedSnapshotStore() *gatedSnapshotStore {
1118- return &gatedSnapshotStore{entered: make(chan struct{}), release: make(chan struct{})}
1119-}
1120-
1121-func (s *gatedSnapshotStore) enter() { s.once.Do(func() { close(s.entered) }) }
1122-func (s *gatedSnapshotStore) unblock() { s.releaseOnce.Do(func() { close(s.release) }) }
1123-
1124-func (s *gatedSnapshotStore) Write(name string, data []byte) error {
1125- s.enter()
1126- <-s.release
1127- s.mu.Lock()
1128- s.writes++
1129- s.name = name
1130- s.data = append([]byte(nil), data...)
1131- s.mu.Unlock()
1132- return nil
1133-}
1134-func (*gatedSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
1135-func (*gatedSnapshotStore) Delete(string) error { return nil }
1136-
1137-// recorded returns the write count and the most recently written name/data,
1138-// captured under the store's own lock so it is safe to call from the test
1139-// goroutine after the write has happened-before via awaitSnapshotClean.
1140-func (s *gatedSnapshotStore) recorded() (int, string, []byte) {
1141- s.mu.Lock()
1142- defer s.mu.Unlock()
1143- return s.writes, s.name, s.data
1144-}
1145-
1146-type neverReturningSnapshotStore struct {
1147- entered chan struct{}
1148- once sync.Once
1149- block chan struct{}
1150-}
1151-
1152-func newNeverReturningSnapshotStore() *neverReturningSnapshotStore {
1153- return &neverReturningSnapshotStore{entered: make(chan struct{}), block: make(chan struct{})}
1154-}
1155-
1156-func (s *neverReturningSnapshotStore) Write(string, []byte) error {
1157- s.once.Do(func() { close(s.entered) })
1158- <-s.block
1159- return nil
1160-}
1161-func (*neverReturningSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
1162-func (*neverReturningSnapshotStore) Delete(string) error { return nil }
1163-
1164-type channelSnapshotStore struct {
1165- writes chan []byte
1166-}
1167-
1168-func (s *channelSnapshotStore) Write(_ string, data []byte) error {
1169- s.writes <- append([]byte(nil), data...)
1170- return nil
1171-}
1172-func (*channelSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
1173-func (*channelSnapshotStore) Delete(string) error { return nil }
1174-
1175-type manualSnapshotClock struct {
1176- mu sync.Mutex
1177- timers []*manualSnapshotTimer
1178-}
1179-
1180-func newManualSnapshotClock() *manualSnapshotClock { return &manualSnapshotClock{} }
1181-
1182-func (c *manualSnapshotClock) Now() time.Time { return time.Unix(0, 0) }
1183-
1184-func (c *manualSnapshotClock) NewTimer(time.Duration) ports.Timer {
1185- t := &manualSnapshotTimer{ch: make(chan time.Time, 1)}
1186- c.mu.Lock()
1187- c.timers = append(c.timers, t)
1188- c.mu.Unlock()
1189- return t
1190-}
1191-
1192-func (c *manualSnapshotClock) fireNext(t *testing.T) {
1193- t.Helper()
1194- deadline := time.After(time.Second)
1195- for {
1196- c.mu.Lock()
1197- if len(c.timers) > 0 {
1198- timer := c.timers[0]
1199- c.mu.Unlock()
1200- timer.ch <- time.Unix(0, 0)
1201- return
1202- }
1203- c.mu.Unlock()
1204- select {
1205- case <-deadline:
1206- t.Fatal("timed out waiting for snapshot timer")
1207- case <-time.After(time.Millisecond):
1208- }
1209- }
1210-}
1211-
1212-type finalFlushClock struct{ timers chan *manualSnapshotTimer }
1213-
1214-func newFinalFlushClock() *finalFlushClock {
1215- return &finalFlushClock{timers: make(chan *manualSnapshotTimer, 1)}
1216-}
1217-
1218-func (c *finalFlushClock) Now() time.Time { return time.Unix(0, 0) }
1219-func (c *finalFlushClock) NewTimer(time.Duration) ports.Timer {
1220- t := &manualSnapshotTimer{ch: make(chan time.Time, 1)}
1221- c.timers <- t
1222- return t
1223-}
1224-
1225-type manualSnapshotTimer struct{ ch chan time.Time }
1226-
1227-func (t *manualSnapshotTimer) C() <-chan time.Time { return t.ch }
1228-func (t *manualSnapshotTimer) Reset(time.Duration) bool { return true }
1229-func (t *manualSnapshotTimer) Stop() bool { return true }
@@ -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)
@@ -1035,17 +45,7 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin
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 require.NoError(t, 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 }
M internal/usecase/snapshot/codec.go +3 -3
@@ -24,7 +24,7 @@ const (
2424 magic = "VEVS"
2525 version = uint16(3)
2626 maxDecodedBodySize = 256 << 20
27- maxSnapshotBlobs = 1 << 16
27+ maxSnapshotObjects = 1 << 16
2828 maxSnapshotBytes = 256 << 20
2929 maxSnapshotRows = 1 << 20
3030 maxSnapshotCells = 16 << 20
@@ -449,7 +449,7 @@ func writePane(w *payloadWriter, p Pane) error {
449449 if err := w.putString(p.Cwd); err != nil {
450450 return err
451451 }
452- if len(p.SealedChunks) > maxSnapshotBlobs {
452+ if len(p.SealedChunks) > maxSnapshotObjects {
453453 return fmt.Errorf("%w: too many sealed chunks", ErrInvalidData)
454454 }
455455 w.putUint32(uint32(len(p.SealedChunks)))
@@ -490,7 +490,7 @@ func readPane(r *payloadReader) (Pane, error) {
490490 if err != nil {
491491 return Pane{}, err
492492 }
493- if n > maxSnapshotBlobs || uint64(n) > uint64(len(r.b))/4 {
493+ if n > maxSnapshotObjects || uint64(n) > uint64(len(r.b))/4 {
494494 return Pane{}, fmt.Errorf("%w: sealed chunks", ErrInvalidData)
495495 }
496496 sealed := make([][]byte, n)
@@ -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
@@ -449,7 +449,7 @@ func writePane(w *payloadWriter, p Pane) error {
449 if err := w.putString(p.Cwd); err != nil {449 if err := w.putString(p.Cwd); err != nil {
450 return err450 return err
451 }451 }
452- if len(p.SealedChunks) > maxSnapshotBlobs {452+ if len(p.SealedChunks) > maxSnapshotObjects {
453 return fmt.Errorf("%w: too many sealed chunks", ErrInvalidData)453 return fmt.Errorf("%w: too many sealed chunks", ErrInvalidData)
454 }454 }
455 w.putUint32(uint32(len(p.SealedChunks)))455 w.putUint32(uint32(len(p.SealedChunks)))
@@ -490,7 +490,7 @@ func readPane(r *payloadReader) (Pane, error) {
490 if err != nil {490 if err != nil {
491 return Pane{}, err491 return Pane{}, err
492 }492 }
493- if n > maxSnapshotBlobs || uint64(n) > uint64(len(r.b))/4 {493+ if n > maxSnapshotObjects || uint64(n) > uint64(len(r.b))/4 {
494 return Pane{}, fmt.Errorf("%w: sealed chunks", ErrInvalidData)494 return Pane{}, fmt.Errorf("%w: sealed chunks", ErrInvalidData)
495 }495 }
496 sealed := make([][]byte, n)496 sealed := make([][]byte, n)
M internal/usecase/snapshot/preflight.go +2 -2
@@ -198,7 +198,7 @@ func preflightPaneStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uin
198198 if err != nil {
199199 return err
200200 }
201- if n > maxSnapshotBlobs || uint64(n) > uint64(len(r.b))/4 {
201+ if n > maxSnapshotObjects || uint64(n) > uint64(len(r.b))/4 {
202202 return fmt.Errorf("%w: sealed chunks", ErrInvalidData)
203203 }
204204 for range n {
@@ -235,7 +235,7 @@ func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, hist
235235 if n == 0 || uint64(n) > uint64(len(r.b)) || uint64(n) > maxSnapshotBytes {
236236 return vt.DecodeStats{}, fmt.Errorf("%w: VT blob length", ErrInvalidData)
237237 }
238- if *blobs >= maxSnapshotBlobs {
238+ if *blobs >= maxSnapshotObjects {
239239 return vt.DecodeStats{}, fmt.Errorf("%w: too many VT blobs", ErrInvalidData)
240240 }
241241 blob := r.b[:n]
@@ -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 {
@@ -235,7 +235,7 @@ func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, hist
235 if n == 0 || uint64(n) > uint64(len(r.b)) || uint64(n) > maxSnapshotBytes {235 if n == 0 || uint64(n) > uint64(len(r.b)) || uint64(n) > maxSnapshotBytes {
236 return vt.DecodeStats{}, fmt.Errorf("%w: VT blob length", ErrInvalidData)236 return vt.DecodeStats{}, fmt.Errorf("%w: VT blob length", ErrInvalidData)
237 }237 }
238- if *blobs >= maxSnapshotBlobs {238+ if *blobs >= maxSnapshotObjects {
239 return vt.DecodeStats{}, fmt.Errorf("%w: too many VT blobs", ErrInvalidData)239 return vt.DecodeStats{}, fmt.Errorf("%w: too many VT blobs", ErrInvalidData)
240 }240 }
241 blob := r.b[:n]241 blob := r.b[:n]

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close