detached 6454fbc

feat(daemon): persist named session snapshots

commit
6454fbcd621fbf305b179ca1111ac7965c70ac8b
author
brice · · 2026-07-06 02:32 UTC
committer
brice · · 2026-07-06 02:53 UTC
parent
74411ec
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
24 files · +2917 -19
A internal/adapters/snapshot/store.go +170 -0
@@ -0,0 +1,170 @@
1+package snapshot
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/hex"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "log/slog"
10+ "os"
11+ "path/filepath"
12+ "regexp"
13+ "strings"
14+
15+ "github.com/bnema/vev/internal/ports"
16+)
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 := os.MkdirAll(s.dir, 0o700); err != nil {
39+ return fmt.Errorf("create snapshot dir: %w", err)
40+ }
41+ if err := os.Chmod(s.dir, 0o700); err != nil {
42+ return fmt.Errorf("chmod snapshot dir: %w", err)
43+ }
44+
45+ tmp, err := os.CreateTemp(s.dir, ".tmp-*")
46+ if err != nil {
47+ return fmt.Errorf("create temp snapshot: %w", err)
48+ }
49+ tmpName := tmp.Name()
50+ keepTmp := false
51+ defer func() {
52+ if !keepTmp {
53+ _ = os.Remove(tmpName)
54+ }
55+ }()
56+
57+ if err := tmp.Chmod(0o600); err != nil {
58+ _ = tmp.Close()
59+ return fmt.Errorf("chmod temp snapshot: %w", err)
60+ }
61+ if _, err := tmp.Write(data); err != nil {
62+ _ = tmp.Close()
63+ return fmt.Errorf("write temp snapshot: %w", err)
64+ }
65+ if err := tmp.Sync(); err != nil {
66+ _ = tmp.Close()
67+ return fmt.Errorf("sync temp snapshot: %w", err)
68+ }
69+ if err := tmp.Close(); err != nil {
70+ return fmt.Errorf("close temp snapshot: %w", err)
71+ }
72+
73+ path := filepath.Join(s.dir, filenameForName(name))
74+ if err := os.Rename(tmpName, path); err != nil {
75+ return fmt.Errorf("rename snapshot: %w", err)
76+ }
77+ keepTmp = true
78+ syncDir(s.dir)
79+ return nil
80+}
81+
82+// Load returns all raw .snap blobs. It does not validate the blob format.
83+func (s *Store) Load() ([]ports.SnapshotBlob, error) {
84+ entries, err := os.ReadDir(s.dir)
85+ if errors.Is(err, os.ErrNotExist) {
86+ return nil, nil
87+ }
88+ if err != nil {
89+ return nil, fmt.Errorf("read snapshot dir: %w", err)
90+ }
91+
92+ blobs := make([]ports.SnapshotBlob, 0, len(entries))
93+ for _, entry := range entries {
94+ base := entry.Name()
95+ path := filepath.Join(s.dir, base)
96+ if strings.HasPrefix(base, ".tmp-") {
97+ if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
98+ s.log.Warn("remove stale snapshot temp", "path", path, "error", err)
99+ }
100+ continue
101+ }
102+ if entry.IsDir() || !strings.HasSuffix(base, ".snap") {
103+ continue
104+ }
105+ data, err := readSnapshotFileBounded(path)
106+ if err != nil {
107+ s.log.Warn("read snapshot", "path", path, "error", err)
108+ continue
109+ }
110+ name := strings.TrimSuffix(base, ".snap")
111+ if strings.HasPrefix(name, "@") || !safeNameRE.MatchString(name) {
112+ name = ""
113+ }
114+ blobs = append(blobs, ports.SnapshotBlob{Name: name, Data: data})
115+ }
116+ return blobs, nil
117+}
118+
119+// Delete removes the deterministic snapshot file for name. Missing files are ignored.
120+func (s *Store) Delete(name string) error {
121+ err := os.Remove(filepath.Join(s.dir, filenameForName(name)))
122+ if errors.Is(err, os.ErrNotExist) {
123+ return nil
124+ }
125+ if err != nil {
126+ return fmt.Errorf("delete snapshot: %w", err)
127+ }
128+ syncDir(s.dir)
129+ return nil
130+}
131+
132+func readSnapshotFileBounded(path string) ([]byte, error) {
133+ f, err := os.Open(path)
134+ if err != nil {
135+ return nil, err
136+ }
137+ defer func() { _ = f.Close() }()
138+ info, err := f.Stat()
139+ if err != nil {
140+ return nil, err
141+ }
142+ if info.Size() > maxSnapshotFileSize {
143+ return nil, fmt.Errorf("snapshot too large: %d bytes", info.Size())
144+ }
145+ data, err := io.ReadAll(io.LimitReader(f, maxSnapshotFileSize+1))
146+ if err != nil {
147+ return nil, err
148+ }
149+ if len(data) > maxSnapshotFileSize {
150+ return nil, fmt.Errorf("snapshot too large: %d bytes", len(data))
151+ }
152+ return data, nil
153+}
154+
155+func filenameForName(name string) string {
156+ if safeNameRE.MatchString(name) {
157+ return name + ".snap"
158+ }
159+ sum := sha256.Sum256([]byte(name))
160+ return "@" + hex.EncodeToString(sum[:])[:40] + ".snap"
161+}
162+
163+func syncDir(dir string) {
164+ f, err := os.Open(dir)
165+ if err != nil {
166+ return
167+ }
168+ defer func() { _ = f.Close() }()
169+ _ = f.Sync()
170+}
@@ -0,0 +1,170 @@
1+package snapshot
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/hex"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "log/slog"
10+ "os"
11+ "path/filepath"
12+ "regexp"
13+ "strings"
14+
15+ "github.com/bnema/vev/internal/ports"
16+)
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 := os.MkdirAll(s.dir, 0o700); err != nil {
39+ return fmt.Errorf("create snapshot dir: %w", err)
40+ }
41+ if err := os.Chmod(s.dir, 0o700); err != nil {
42+ return fmt.Errorf("chmod snapshot dir: %w", err)
43+ }
44+
45+ tmp, err := os.CreateTemp(s.dir, ".tmp-*")
46+ if err != nil {
47+ return fmt.Errorf("create temp snapshot: %w", err)
48+ }
49+ tmpName := tmp.Name()
50+ keepTmp := false
51+ defer func() {
52+ if !keepTmp {
53+ _ = os.Remove(tmpName)
54+ }
55+ }()
56+
57+ if err := tmp.Chmod(0o600); err != nil {
58+ _ = tmp.Close()
59+ return fmt.Errorf("chmod temp snapshot: %w", err)
60+ }
61+ if _, err := tmp.Write(data); err != nil {
62+ _ = tmp.Close()
63+ return fmt.Errorf("write temp snapshot: %w", err)
64+ }
65+ if err := tmp.Sync(); err != nil {
66+ _ = tmp.Close()
67+ return fmt.Errorf("sync temp snapshot: %w", err)
68+ }
69+ if err := tmp.Close(); err != nil {
70+ return fmt.Errorf("close temp snapshot: %w", err)
71+ }
72+
73+ path := filepath.Join(s.dir, filenameForName(name))
74+ if err := os.Rename(tmpName, path); err != nil {
75+ return fmt.Errorf("rename snapshot: %w", err)
76+ }
77+ keepTmp = true
78+ syncDir(s.dir)
79+ return nil
80+}
81+
82+// Load returns all raw .snap blobs. It does not validate the blob format.
83+func (s *Store) Load() ([]ports.SnapshotBlob, error) {
84+ entries, err := os.ReadDir(s.dir)
85+ if errors.Is(err, os.ErrNotExist) {
86+ return nil, nil
87+ }
88+ if err != nil {
89+ return nil, fmt.Errorf("read snapshot dir: %w", err)
90+ }
91+
92+ blobs := make([]ports.SnapshotBlob, 0, len(entries))
93+ for _, entry := range entries {
94+ base := entry.Name()
95+ path := filepath.Join(s.dir, base)
96+ if strings.HasPrefix(base, ".tmp-") {
97+ if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
98+ s.log.Warn("remove stale snapshot temp", "path", path, "error", err)
99+ }
100+ continue
101+ }
102+ if entry.IsDir() || !strings.HasSuffix(base, ".snap") {
103+ continue
104+ }
105+ data, err := readSnapshotFileBounded(path)
106+ if err != nil {
107+ s.log.Warn("read snapshot", "path", path, "error", err)
108+ continue
109+ }
110+ name := strings.TrimSuffix(base, ".snap")
111+ if strings.HasPrefix(name, "@") || !safeNameRE.MatchString(name) {
112+ name = ""
113+ }
114+ blobs = append(blobs, ports.SnapshotBlob{Name: name, Data: data})
115+ }
116+ return blobs, nil
117+}
118+
119+// Delete removes the deterministic snapshot file for name. Missing files are ignored.
120+func (s *Store) Delete(name string) error {
121+ err := os.Remove(filepath.Join(s.dir, filenameForName(name)))
122+ if errors.Is(err, os.ErrNotExist) {
123+ return nil
124+ }
125+ if err != nil {
126+ return fmt.Errorf("delete snapshot: %w", err)
127+ }
128+ syncDir(s.dir)
129+ return nil
130+}
131+
132+func readSnapshotFileBounded(path string) ([]byte, error) {
133+ f, err := os.Open(path)
134+ if err != nil {
135+ return nil, err
136+ }
137+ defer func() { _ = f.Close() }()
138+ info, err := f.Stat()
139+ if err != nil {
140+ return nil, err
141+ }
142+ if info.Size() > maxSnapshotFileSize {
143+ return nil, fmt.Errorf("snapshot too large: %d bytes", info.Size())
144+ }
145+ data, err := io.ReadAll(io.LimitReader(f, maxSnapshotFileSize+1))
146+ if err != nil {
147+ return nil, err
148+ }
149+ if len(data) > maxSnapshotFileSize {
150+ return nil, fmt.Errorf("snapshot too large: %d bytes", len(data))
151+ }
152+ return data, nil
153+}
154+
155+func filenameForName(name string) string {
156+ if safeNameRE.MatchString(name) {
157+ return name + ".snap"
158+ }
159+ sum := sha256.Sum256([]byte(name))
160+ return "@" + hex.EncodeToString(sum[:])[:40] + ".snap"
161+}
162+
163+func syncDir(dir string) {
164+ f, err := os.Open(dir)
165+ if err != nil {
166+ return
167+ }
168+ defer func() { _ = f.Close() }()
169+ _ = f.Sync()
170+}
A internal/adapters/snapshot/store_test.go +181 -0
@@ -0,0 +1,181 @@
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 TestStoreWriteLoadRoundTripAndSupersede(t *testing.T) {
16+ t.Parallel()
17+ dir := t.TempDir()
18+ store := NewStore(dir)
19+
20+ if err := store.Write("alpha", []byte("first")); err != nil {
21+ t.Fatalf("Write first: %v", err)
22+ }
23+ if err := store.Write("alpha", []byte("second")); err != nil {
24+ t.Fatalf("Write second: %v", err)
25+ }
26+
27+ got := loadMap(t, store)
28+ if string(got["alpha"]) != "second" {
29+ t.Fatalf("loaded alpha = %q, want second", got["alpha"])
30+ }
31+ info, err := os.Stat(filepath.Join(dir, "alpha.snap"))
32+ if err != nil {
33+ t.Fatalf("stat snapshot: %v", err)
34+ }
35+ if gotMode := info.Mode().Perm(); gotMode != 0o600 {
36+ t.Fatalf("snapshot mode = %v, want 0600", gotMode)
37+ }
38+ if dirInfo, err := os.Stat(dir); err != nil {
39+ t.Fatalf("stat dir: %v", err)
40+ } else if gotMode := dirInfo.Mode().Perm(); gotMode != 0o700 {
41+ t.Fatalf("dir mode = %v, want 0700", gotMode)
42+ }
43+}
44+
45+func TestStoreDelete(t *testing.T) {
46+ t.Parallel()
47+ dir := t.TempDir()
48+ store := NewStore(dir)
49+
50+ if err := store.Write("gone", []byte("data")); err != nil {
51+ t.Fatalf("Write: %v", err)
52+ }
53+ if err := store.Delete("gone"); err != nil {
54+ t.Fatalf("Delete existing: %v", err)
55+ }
56+ if err := store.Delete("gone"); err != nil {
57+ t.Fatalf("Delete missing: %v", err)
58+ }
59+ if got := loadMap(t, store); len(got) != 0 {
60+ t.Fatalf("Load after delete = %#v, want empty", got)
61+ }
62+}
63+
64+func TestStoreUnsafeNameUsesDeterministicHash(t *testing.T) {
65+ t.Parallel()
66+ dir := t.TempDir()
67+ store := NewStore(dir)
68+ name := "../unsafe/session name"
69+
70+ if err := store.Write(name, []byte("raw")); err != nil {
71+ t.Fatalf("Write unsafe: %v", err)
72+ }
73+ sum := sha256.Sum256([]byte(name))
74+ wantBase := "@" + hex.EncodeToString(sum[:])[:40] + ".snap"
75+ if _, err := os.Stat(filepath.Join(dir, wantBase)); err != nil {
76+ t.Fatalf("stat hashed snapshot %q: %v", wantBase, err)
77+ }
78+ loaded, err := store.Load()
79+ if err != nil {
80+ t.Fatalf("Load: %v", err)
81+ }
82+ if len(loaded) != 1 || loaded[0].Name != "" || string(loaded[0].Data) != "raw" {
83+ t.Fatalf("Load unsafe = %#v, want one nameless raw blob", loaded)
84+ }
85+}
86+
87+func TestStoreMissingDirAndStaleTmpCleanup(t *testing.T) {
88+ t.Parallel()
89+ dir := filepath.Join(t.TempDir(), "missing")
90+ store := NewStore(dir)
91+
92+ loaded, err := store.Load()
93+ if err != nil {
94+ t.Fatalf("Load missing dir: %v", err)
95+ }
96+ if len(loaded) != 0 {
97+ t.Fatalf("Load missing dir len = %d, want 0", len(loaded))
98+ }
99+ if err := os.MkdirAll(dir, 0o700); err != nil {
100+ t.Fatalf("MkdirAll: %v", err)
101+ }
102+ if err := os.WriteFile(filepath.Join(dir, ".tmp-stale"), []byte("tmp"), 0o600); err != nil {
103+ t.Fatalf("write stale tmp: %v", err)
104+ }
105+ if err := os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("ignore"), 0o600); err != nil {
106+ t.Fatalf("write ignored: %v", err)
107+ }
108+ if err := os.WriteFile(filepath.Join(dir, "beta.snap"), []byte("beta"), 0o600); err != nil {
109+ t.Fatalf("write snap: %v", err)
110+ }
111+
112+ got := loadMap(t, store)
113+ if string(got["beta"]) != "beta" || len(got) != 1 {
114+ t.Fatalf("Load = %#v, want only beta", got)
115+ }
116+ if _, err := os.Stat(filepath.Join(dir, ".tmp-stale")); !errors.Is(err, os.ErrNotExist) {
117+ t.Fatalf("stale tmp stat err = %v, want not exist", err)
118+ }
119+}
120+
121+func TestStoreLoadRejectsOversizedFileBeforeRead(t *testing.T) {
122+ t.Parallel()
123+ dir := t.TempDir()
124+ store := NewStore(dir)
125+ if err := os.WriteFile(filepath.Join(dir, "ok.snap"), []byte("ok"), 0o600); err != nil {
126+ t.Fatalf("write ok: %v", err)
127+ }
128+ tooLarge := filepath.Join(dir, "huge.snap")
129+ f, err := os.Create(tooLarge)
130+ if err != nil {
131+ t.Fatalf("create huge: %v", err)
132+ }
133+ if err := f.Truncate(maxSnapshotFileSize + 1); err != nil {
134+ _ = f.Close()
135+ t.Fatalf("truncate huge: %v", err)
136+ }
137+ if err := f.Close(); err != nil {
138+ t.Fatalf("close huge: %v", err)
139+ }
140+
141+ got := loadMap(t, store)
142+ if len(got) != 1 || string(got["ok"]) != "ok" {
143+ t.Fatalf("Load = %#v, want only ok", got)
144+ }
145+}
146+
147+func TestStoreLoadSkipsUnreadableFile(t *testing.T) {
148+ if runtime.GOOS == "windows" || os.Geteuid() == 0 {
149+ t.Skip("permission bits do not make file unreadable on this platform/user")
150+ }
151+ t.Parallel()
152+ dir := t.TempDir()
153+ store := NewStore(dir)
154+ if err := os.WriteFile(filepath.Join(dir, "ok.snap"), []byte("ok"), 0o600); err != nil {
155+ t.Fatalf("write ok: %v", err)
156+ }
157+ bad := filepath.Join(dir, "bad.snap")
158+ if err := os.WriteFile(bad, []byte("bad"), 0o000); err != nil {
159+ t.Fatalf("write bad: %v", err)
160+ }
161+ t.Cleanup(func() { _ = os.Chmod(bad, 0o600) })
162+
163+ got := loadMap(t, store)
164+ if len(got) != 1 || string(got["ok"]) != "ok" {
165+ t.Fatalf("Load = %#v, want only ok", got)
166+ }
167+}
168+
169+func loadMap(t *testing.T, store *Store) map[string][]byte {
170+ t.Helper()
171+ loaded, err := store.Load()
172+ if err != nil {
173+ t.Fatalf("Load: %v", err)
174+ }
175+ sort.Slice(loaded, func(i, j int) bool { return strings.Compare(loaded[i].Name, loaded[j].Name) < 0 })
176+ out := make(map[string][]byte, len(loaded))
177+ for _, blob := range loaded {
178+ out[blob.Name] = blob.Data
179+ }
180+ return out
181+}
@@ -0,0 +1,181 @@
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 TestStoreWriteLoadRoundTripAndSupersede(t *testing.T) {
16+ t.Parallel()
17+ dir := t.TempDir()
18+ store := NewStore(dir)
19+
20+ if err := store.Write("alpha", []byte("first")); err != nil {
21+ t.Fatalf("Write first: %v", err)
22+ }
23+ if err := store.Write("alpha", []byte("second")); err != nil {
24+ t.Fatalf("Write second: %v", err)
25+ }
26+
27+ got := loadMap(t, store)
28+ if string(got["alpha"]) != "second" {
29+ t.Fatalf("loaded alpha = %q, want second", got["alpha"])
30+ }
31+ info, err := os.Stat(filepath.Join(dir, "alpha.snap"))
32+ if err != nil {
33+ t.Fatalf("stat snapshot: %v", err)
34+ }
35+ if gotMode := info.Mode().Perm(); gotMode != 0o600 {
36+ t.Fatalf("snapshot mode = %v, want 0600", gotMode)
37+ }
38+ if dirInfo, err := os.Stat(dir); err != nil {
39+ t.Fatalf("stat dir: %v", err)
40+ } else if gotMode := dirInfo.Mode().Perm(); gotMode != 0o700 {
41+ t.Fatalf("dir mode = %v, want 0700", gotMode)
42+ }
43+}
44+
45+func TestStoreDelete(t *testing.T) {
46+ t.Parallel()
47+ dir := t.TempDir()
48+ store := NewStore(dir)
49+
50+ if err := store.Write("gone", []byte("data")); err != nil {
51+ t.Fatalf("Write: %v", err)
52+ }
53+ if err := store.Delete("gone"); err != nil {
54+ t.Fatalf("Delete existing: %v", err)
55+ }
56+ if err := store.Delete("gone"); err != nil {
57+ t.Fatalf("Delete missing: %v", err)
58+ }
59+ if got := loadMap(t, store); len(got) != 0 {
60+ t.Fatalf("Load after delete = %#v, want empty", got)
61+ }
62+}
63+
64+func TestStoreUnsafeNameUsesDeterministicHash(t *testing.T) {
65+ t.Parallel()
66+ dir := t.TempDir()
67+ store := NewStore(dir)
68+ name := "../unsafe/session name"
69+
70+ if err := store.Write(name, []byte("raw")); err != nil {
71+ t.Fatalf("Write unsafe: %v", err)
72+ }
73+ sum := sha256.Sum256([]byte(name))
74+ wantBase := "@" + hex.EncodeToString(sum[:])[:40] + ".snap"
75+ if _, err := os.Stat(filepath.Join(dir, wantBase)); err != nil {
76+ t.Fatalf("stat hashed snapshot %q: %v", wantBase, err)
77+ }
78+ loaded, err := store.Load()
79+ if err != nil {
80+ t.Fatalf("Load: %v", err)
81+ }
82+ if len(loaded) != 1 || loaded[0].Name != "" || string(loaded[0].Data) != "raw" {
83+ t.Fatalf("Load unsafe = %#v, want one nameless raw blob", loaded)
84+ }
85+}
86+
87+func TestStoreMissingDirAndStaleTmpCleanup(t *testing.T) {
88+ t.Parallel()
89+ dir := filepath.Join(t.TempDir(), "missing")
90+ store := NewStore(dir)
91+
92+ loaded, err := store.Load()
93+ if err != nil {
94+ t.Fatalf("Load missing dir: %v", err)
95+ }
96+ if len(loaded) != 0 {
97+ t.Fatalf("Load missing dir len = %d, want 0", len(loaded))
98+ }
99+ if err := os.MkdirAll(dir, 0o700); err != nil {
100+ t.Fatalf("MkdirAll: %v", err)
101+ }
102+ if err := os.WriteFile(filepath.Join(dir, ".tmp-stale"), []byte("tmp"), 0o600); err != nil {
103+ t.Fatalf("write stale tmp: %v", err)
104+ }
105+ if err := os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("ignore"), 0o600); err != nil {
106+ t.Fatalf("write ignored: %v", err)
107+ }
108+ if err := os.WriteFile(filepath.Join(dir, "beta.snap"), []byte("beta"), 0o600); err != nil {
109+ t.Fatalf("write snap: %v", err)
110+ }
111+
112+ got := loadMap(t, store)
113+ if string(got["beta"]) != "beta" || len(got) != 1 {
114+ t.Fatalf("Load = %#v, want only beta", got)
115+ }
116+ if _, err := os.Stat(filepath.Join(dir, ".tmp-stale")); !errors.Is(err, os.ErrNotExist) {
117+ t.Fatalf("stale tmp stat err = %v, want not exist", err)
118+ }
119+}
120+
121+func TestStoreLoadRejectsOversizedFileBeforeRead(t *testing.T) {
122+ t.Parallel()
123+ dir := t.TempDir()
124+ store := NewStore(dir)
125+ if err := os.WriteFile(filepath.Join(dir, "ok.snap"), []byte("ok"), 0o600); err != nil {
126+ t.Fatalf("write ok: %v", err)
127+ }
128+ tooLarge := filepath.Join(dir, "huge.snap")
129+ f, err := os.Create(tooLarge)
130+ if err != nil {
131+ t.Fatalf("create huge: %v", err)
132+ }
133+ if err := f.Truncate(maxSnapshotFileSize + 1); err != nil {
134+ _ = f.Close()
135+ t.Fatalf("truncate huge: %v", err)
136+ }
137+ if err := f.Close(); err != nil {
138+ t.Fatalf("close huge: %v", err)
139+ }
140+
141+ got := loadMap(t, store)
142+ if len(got) != 1 || string(got["ok"]) != "ok" {
143+ t.Fatalf("Load = %#v, want only ok", got)
144+ }
145+}
146+
147+func TestStoreLoadSkipsUnreadableFile(t *testing.T) {
148+ if runtime.GOOS == "windows" || os.Geteuid() == 0 {
149+ t.Skip("permission bits do not make file unreadable on this platform/user")
150+ }
151+ t.Parallel()
152+ dir := t.TempDir()
153+ store := NewStore(dir)
154+ if err := os.WriteFile(filepath.Join(dir, "ok.snap"), []byte("ok"), 0o600); err != nil {
155+ t.Fatalf("write ok: %v", err)
156+ }
157+ bad := filepath.Join(dir, "bad.snap")
158+ if err := os.WriteFile(bad, []byte("bad"), 0o000); err != nil {
159+ t.Fatalf("write bad: %v", err)
160+ }
161+ t.Cleanup(func() { _ = os.Chmod(bad, 0o600) })
162+
163+ got := loadMap(t, store)
164+ if len(got) != 1 || string(got["ok"]) != "ok" {
165+ t.Fatalf("Load = %#v, want only ok", got)
166+ }
167+}
168+
169+func loadMap(t *testing.T, store *Store) map[string][]byte {
170+ t.Helper()
171+ loaded, err := store.Load()
172+ if err != nil {
173+ t.Fatalf("Load: %v", err)
174+ }
175+ sort.Slice(loaded, func(i, j int) bool { return strings.Compare(loaded[i].Name, loaded[j].Name) < 0 })
176+ out := make(map[string][]byte, len(loaded))
177+ for _, blob := range loaded {
178+ out[blob.Name] = blob.Data
179+ }
180+ return out
181+}
M internal/app/run.go +11 -0
@@ -17,6 +17,7 @@ import (
1717 _ "net/http/pprof"
1818 "os"
1919 "os/signal"
20+ "path/filepath"
2021 "syscall"
2122 "text/tabwriter"
2223 "time"
@@ -27,6 +28,7 @@ import (
2728 "github.com/bnema/vev/internal/adapters/dgram"
2829 "github.com/bnema/vev/internal/adapters/ipc"
2930 "github.com/bnema/vev/internal/adapters/pty"
31+ snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot"
3032 "github.com/bnema/vev/internal/adapters/sshstdio"
3133 "github.com/bnema/vev/internal/adapters/term"
3234 "github.com/bnema/vev/internal/domain"
@@ -235,6 +237,10 @@ func logConfigWarnings(log *slog.Logger, warnings []domain.Warning) {
235237 }
236238 }
237239
240+func snapshotDir() string {
241+ return filepath.Join(platform.StateDir(), "snapshots")
242+}
243+
238244 // runDaemon runs the daemon in the foreground (the hidden --daemon path,
239245 // entered by an auto-spawned child): it sets up logging, binds the socket,
240246 // constructs the daemon, and serves until the last session exits or a
@@ -275,6 +281,7 @@ func runDaemon() error {
275281 }
276282 logConfigWarnings(log, warnings)
277283 daemonOpts = append(daemonOpts, daemon.WithConfig(cfg))
284+ daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir())))
278285 storePath := persist.StorePath(platform.StateDir())
279286 if store, err := kv.Open(storePath); err != nil {
280287 log.Warn("opening session store failed; persistence disabled", "path", storePath, "err", err)
@@ -776,6 +783,10 @@ func runKill(_ context.Context, name string, all, daemon bool) error {
776783 if closeErr := p.Close(); closeErr != nil {
777784 return fmt.Errorf("vev: closing stored sessions: %w", closeErr)
778785 }
786+ snapshots := snapshotadapter.NewStore(snapshotDir())
787+ if deleteErr := snapshots.Delete(name); deleteErr != nil {
788+ return fmt.Errorf("vev: deleting session snapshot: %w", deleteErr)
789+ }
779790 printKillSuccess(name, all, daemon)
780791 return nil
781792 }
@@ -17,6 +17,7 @@ import (
17 _ "net/http/pprof"17 _ "net/http/pprof"
18 "os"18 "os"
19 "os/signal"19 "os/signal"
20+ "path/filepath"
20 "syscall"21 "syscall"
21 "text/tabwriter"22 "text/tabwriter"
22 "time"23 "time"
@@ -27,6 +28,7 @@ import (
27 "github.com/bnema/vev/internal/adapters/dgram"28 "github.com/bnema/vev/internal/adapters/dgram"
28 "github.com/bnema/vev/internal/adapters/ipc"29 "github.com/bnema/vev/internal/adapters/ipc"
29 "github.com/bnema/vev/internal/adapters/pty"30 "github.com/bnema/vev/internal/adapters/pty"
31+ snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot"
30 "github.com/bnema/vev/internal/adapters/sshstdio"32 "github.com/bnema/vev/internal/adapters/sshstdio"
31 "github.com/bnema/vev/internal/adapters/term"33 "github.com/bnema/vev/internal/adapters/term"
32 "github.com/bnema/vev/internal/domain"34 "github.com/bnema/vev/internal/domain"
@@ -235,6 +237,10 @@ func logConfigWarnings(log *slog.Logger, warnings []domain.Warning) {
235 }237 }
236 }238 }
237 239
240+func snapshotDir() string {
241+ return filepath.Join(platform.StateDir(), "snapshots")
242+}
243+
238 // runDaemon runs the daemon in the foreground (the hidden --daemon path,244 // runDaemon runs the daemon in the foreground (the hidden --daemon path,
239 // entered by an auto-spawned child): it sets up logging, binds the socket,245 // entered by an auto-spawned child): it sets up logging, binds the socket,
240 // constructs the daemon, and serves until the last session exits or a246 // constructs the daemon, and serves until the last session exits or a
@@ -275,6 +281,7 @@ func runDaemon() error {
275 }281 }
276 logConfigWarnings(log, warnings)282 logConfigWarnings(log, warnings)
277 daemonOpts = append(daemonOpts, daemon.WithConfig(cfg))283 daemonOpts = append(daemonOpts, daemon.WithConfig(cfg))
284+ daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir())))
278 storePath := persist.StorePath(platform.StateDir())285 storePath := persist.StorePath(platform.StateDir())
279 if store, err := kv.Open(storePath); err != nil {286 if store, err := kv.Open(storePath); err != nil {
280 log.Warn("opening session store failed; persistence disabled", "path", storePath, "err", err)287 log.Warn("opening session store failed; persistence disabled", "path", storePath, "err", err)
@@ -776,6 +783,10 @@ func runKill(_ context.Context, name string, all, daemon bool) error {
776 if closeErr := p.Close(); closeErr != nil {783 if closeErr := p.Close(); closeErr != nil {
777 return fmt.Errorf("vev: closing stored sessions: %w", closeErr)784 return fmt.Errorf("vev: closing stored sessions: %w", closeErr)
778 }785 }
786+ snapshots := snapshotadapter.NewStore(snapshotDir())
787+ if deleteErr := snapshots.Delete(name); deleteErr != nil {
788+ return fmt.Errorf("vev: deleting session snapshot: %w", deleteErr)
789+ }
779 printKillSuccess(name, all, daemon)790 printKillSuccess(name, all, daemon)
780 return nil791 return nil
781 }792 }
M internal/app/run_test.go +12 -0
@@ -12,6 +12,7 @@ import (
1212 "testing"
1313 "time"
1414
15+ "github.com/bnema/vev/internal/adapters/snapshot"
1516 "github.com/bnema/vev/internal/persist"
1617 "github.com/bnema/vev/internal/ports"
1718 "github.com/bnema/vev/internal/usecase/client"
@@ -175,6 +176,10 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
175176 if err := p.Close(); err != nil {
176177 t.Fatalf("Close error = %v", err)
177178 }
179+ snapshots := snapshot.NewStore(filepath.Join(stateRoot, "vev", "snapshots"))
180+ if err := snapshots.Write("stored", []byte("snapshot bytes")); err != nil {
181+ t.Fatalf("snapshot Write error = %v", err)
182+ }
178183
179184 got := captureStdout(t, func() {
180185 if err := runKill(context.Background(), "stored", false, false); err != nil {
@@ -191,6 +196,13 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
191196 if len(records) != 0 {
192197 t.Fatalf("records after kill = %#v, want none", records)
193198 }
199+ blobs, err := snapshots.Load()
200+ if err != nil {
201+ t.Fatalf("snapshot Load error = %v", err)
202+ }
203+ if len(blobs) != 0 {
204+ t.Fatalf("snapshots after kill = %#v, want none", blobs)
205+ }
194206 }
195207
196208 func captureStdout(t *testing.T, fn func()) string {
@@ -12,6 +12,7 @@ import (
12 "testing"12 "testing"
13 "time"13 "time"
14 14
15+ "github.com/bnema/vev/internal/adapters/snapshot"
15 "github.com/bnema/vev/internal/persist"16 "github.com/bnema/vev/internal/persist"
16 "github.com/bnema/vev/internal/ports"17 "github.com/bnema/vev/internal/ports"
17 "github.com/bnema/vev/internal/usecase/client"18 "github.com/bnema/vev/internal/usecase/client"
@@ -175,6 +176,10 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
175 if err := p.Close(); err != nil {176 if err := p.Close(); err != nil {
176 t.Fatalf("Close error = %v", err)177 t.Fatalf("Close error = %v", err)
177 }178 }
179+ snapshots := snapshot.NewStore(filepath.Join(stateRoot, "vev", "snapshots"))
180+ if err := snapshots.Write("stored", []byte("snapshot bytes")); err != nil {
181+ t.Fatalf("snapshot Write error = %v", err)
182+ }
178 183
179 got := captureStdout(t, func() {184 got := captureStdout(t, func() {
180 if err := runKill(context.Background(), "stored", false, false); err != nil {185 if err := runKill(context.Background(), "stored", false, false); err != nil {
@@ -191,6 +196,13 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
191 if len(records) != 0 {196 if len(records) != 0 {
192 t.Fatalf("records after kill = %#v, want none", records)197 t.Fatalf("records after kill = %#v, want none", records)
193 }198 }
199+ blobs, err := snapshots.Load()
200+ if err != nil {
201+ t.Fatalf("snapshot Load error = %v", err)
202+ }
203+ if len(blobs) != 0 {
204+ t.Fatalf("snapshots after kill = %#v, want none", blobs)
205+ }
194 }206 }
195 207
196 func captureStdout(t *testing.T, fn func()) string {208 func captureStdout(t *testing.T, fn func()) string {
M internal/ports/mocks/mocks.go +190 -0
@@ -1586,6 +1586,196 @@ func (_c *MockStore_Sync_Call) RunAndReturn(run func() error) *MockStore_Sync_Ca
15861586 return _c
15871587 }
15881588
1589+// 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.
1590+// The first argument is typically a *testing.T value.
1591+func NewMockSnapshotStore(t interface {
1592+ mock.TestingT
1593+ Cleanup(func())
1594+}) *MockSnapshotStore {
1595+ mock := &MockSnapshotStore{}
1596+ mock.Mock.Test(t)
1597+
1598+ t.Cleanup(func() { mock.AssertExpectations(t) })
1599+
1600+ return mock
1601+}
1602+
1603+// MockSnapshotStore is an autogenerated mock type for the SnapshotStore type
1604+type MockSnapshotStore struct {
1605+ mock.Mock
1606+}
1607+
1608+type MockSnapshotStore_Expecter struct {
1609+ mock *mock.Mock
1610+}
1611+
1612+func (_m *MockSnapshotStore) EXPECT() *MockSnapshotStore_Expecter {
1613+ return &MockSnapshotStore_Expecter{mock: &_m.Mock}
1614+}
1615+
1616+// Delete provides a mock function for the type MockSnapshotStore
1617+func (_mock *MockSnapshotStore) Delete(name string) error {
1618+ ret := _mock.Called(name)
1619+
1620+ if len(ret) == 0 {
1621+ panic("no return value specified for Delete")
1622+ }
1623+
1624+ var r0 error
1625+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
1626+ r0 = returnFunc(name)
1627+ } else {
1628+ r0 = ret.Error(0)
1629+ }
1630+ return r0
1631+}
1632+
1633+// MockSnapshotStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
1634+type MockSnapshotStore_Delete_Call struct {
1635+ *mock.Call
1636+}
1637+
1638+// Delete is a helper method to define mock.On call
1639+// - name string
1640+func (_e *MockSnapshotStore_Expecter) Delete(name any) *MockSnapshotStore_Delete_Call {
1641+ return &MockSnapshotStore_Delete_Call{Call: _e.mock.On("Delete", name)}
1642+}
1643+
1644+func (_c *MockSnapshotStore_Delete_Call) Run(run func(name string)) *MockSnapshotStore_Delete_Call {
1645+ _c.Call.Run(func(args mock.Arguments) {
1646+ var arg0 string
1647+ if args[0] != nil {
1648+ arg0 = args[0].(string)
1649+ }
1650+ run(
1651+ arg0,
1652+ )
1653+ })
1654+ return _c
1655+}
1656+
1657+func (_c *MockSnapshotStore_Delete_Call) Return(err error) *MockSnapshotStore_Delete_Call {
1658+ _c.Call.Return(err)
1659+ return _c
1660+}
1661+
1662+func (_c *MockSnapshotStore_Delete_Call) RunAndReturn(run func(name string) error) *MockSnapshotStore_Delete_Call {
1663+ _c.Call.Return(run)
1664+ return _c
1665+}
1666+
1667+// Load provides a mock function for the type MockSnapshotStore
1668+func (_mock *MockSnapshotStore) Load() ([]ports.SnapshotBlob, error) {
1669+ ret := _mock.Called()
1670+
1671+ if len(ret) == 0 {
1672+ panic("no return value specified for Load")
1673+ }
1674+
1675+ var r0 []ports.SnapshotBlob
1676+ var r1 error
1677+ if returnFunc, ok := ret.Get(0).(func() ([]ports.SnapshotBlob, error)); ok {
1678+ return returnFunc()
1679+ }
1680+ if returnFunc, ok := ret.Get(0).(func() []ports.SnapshotBlob); ok {
1681+ r0 = returnFunc()
1682+ } else {
1683+ if ret.Get(0) != nil {
1684+ r0 = ret.Get(0).([]ports.SnapshotBlob)
1685+ }
1686+ }
1687+ if returnFunc, ok := ret.Get(1).(func() error); ok {
1688+ r1 = returnFunc()
1689+ } else {
1690+ r1 = ret.Error(1)
1691+ }
1692+ return r0, r1
1693+}
1694+
1695+// MockSnapshotStore_Load_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Load'
1696+type MockSnapshotStore_Load_Call struct {
1697+ *mock.Call
1698+}
1699+
1700+// Load is a helper method to define mock.On call
1701+func (_e *MockSnapshotStore_Expecter) Load() *MockSnapshotStore_Load_Call {
1702+ return &MockSnapshotStore_Load_Call{Call: _e.mock.On("Load")}
1703+}
1704+
1705+func (_c *MockSnapshotStore_Load_Call) Run(run func()) *MockSnapshotStore_Load_Call {
1706+ _c.Call.Run(func(args mock.Arguments) {
1707+ run()
1708+ })
1709+ return _c
1710+}
1711+
1712+func (_c *MockSnapshotStore_Load_Call) Return(snapshotBlobs []ports.SnapshotBlob, err error) *MockSnapshotStore_Load_Call {
1713+ _c.Call.Return(snapshotBlobs, err)
1714+ return _c
1715+}
1716+
1717+func (_c *MockSnapshotStore_Load_Call) RunAndReturn(run func() ([]ports.SnapshotBlob, error)) *MockSnapshotStore_Load_Call {
1718+ _c.Call.Return(run)
1719+ return _c
1720+}
1721+
1722+// Write provides a mock function for the type MockSnapshotStore
1723+func (_mock *MockSnapshotStore) Write(name string, data []byte) error {
1724+ ret := _mock.Called(name, data)
1725+
1726+ if len(ret) == 0 {
1727+ panic("no return value specified for Write")
1728+ }
1729+
1730+ var r0 error
1731+ if returnFunc, ok := ret.Get(0).(func(string, []byte) error); ok {
1732+ r0 = returnFunc(name, data)
1733+ } else {
1734+ r0 = ret.Error(0)
1735+ }
1736+ return r0
1737+}
1738+
1739+// MockSnapshotStore_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write'
1740+type MockSnapshotStore_Write_Call struct {
1741+ *mock.Call
1742+}
1743+
1744+// Write is a helper method to define mock.On call
1745+// - name string
1746+// - data []byte
1747+func (_e *MockSnapshotStore_Expecter) Write(name any, data any) *MockSnapshotStore_Write_Call {
1748+ return &MockSnapshotStore_Write_Call{Call: _e.mock.On("Write", name, data)}
1749+}
1750+
1751+func (_c *MockSnapshotStore_Write_Call) Run(run func(name string, data []byte)) *MockSnapshotStore_Write_Call {
1752+ _c.Call.Run(func(args mock.Arguments) {
1753+ var arg0 string
1754+ if args[0] != nil {
1755+ arg0 = args[0].(string)
1756+ }
1757+ var arg1 []byte
1758+ if args[1] != nil {
1759+ arg1 = args[1].([]byte)
1760+ }
1761+ run(
1762+ arg0,
1763+ arg1,
1764+ )
1765+ })
1766+ return _c
1767+}
1768+
1769+func (_c *MockSnapshotStore_Write_Call) Return(err error) *MockSnapshotStore_Write_Call {
1770+ _c.Call.Return(err)
1771+ return _c
1772+}
1773+
1774+func (_c *MockSnapshotStore_Write_Call) RunAndReturn(run func(name string, data []byte) error) *MockSnapshotStore_Write_Call {
1775+ _c.Call.Return(run)
1776+ return _c
1777+}
1778+
15891779 // NewMockClock creates a new instance of MockClock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15901780 // The first argument is typically a *testing.T value.
15911781 func NewMockClock(t interface {
@@ -1586,6 +1586,196 @@ func (_c *MockStore_Sync_Call) RunAndReturn(run func() error) *MockStore_Sync_Ca
1586 return _c1586 return _c
1587 }1587 }
1588 1588
1589+// 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.
1590+// The first argument is typically a *testing.T value.
1591+func NewMockSnapshotStore(t interface {
1592+ mock.TestingT
1593+ Cleanup(func())
1594+}) *MockSnapshotStore {
1595+ mock := &MockSnapshotStore{}
1596+ mock.Mock.Test(t)
1597+
1598+ t.Cleanup(func() { mock.AssertExpectations(t) })
1599+
1600+ return mock
1601+}
1602+
1603+// MockSnapshotStore is an autogenerated mock type for the SnapshotStore type
1604+type MockSnapshotStore struct {
1605+ mock.Mock
1606+}
1607+
1608+type MockSnapshotStore_Expecter struct {
1609+ mock *mock.Mock
1610+}
1611+
1612+func (_m *MockSnapshotStore) EXPECT() *MockSnapshotStore_Expecter {
1613+ return &MockSnapshotStore_Expecter{mock: &_m.Mock}
1614+}
1615+
1616+// Delete provides a mock function for the type MockSnapshotStore
1617+func (_mock *MockSnapshotStore) Delete(name string) error {
1618+ ret := _mock.Called(name)
1619+
1620+ if len(ret) == 0 {
1621+ panic("no return value specified for Delete")
1622+ }
1623+
1624+ var r0 error
1625+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
1626+ r0 = returnFunc(name)
1627+ } else {
1628+ r0 = ret.Error(0)
1629+ }
1630+ return r0
1631+}
1632+
1633+// MockSnapshotStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
1634+type MockSnapshotStore_Delete_Call struct {
1635+ *mock.Call
1636+}
1637+
1638+// Delete is a helper method to define mock.On call
1639+// - name string
1640+func (_e *MockSnapshotStore_Expecter) Delete(name any) *MockSnapshotStore_Delete_Call {
1641+ return &MockSnapshotStore_Delete_Call{Call: _e.mock.On("Delete", name)}
1642+}
1643+
1644+func (_c *MockSnapshotStore_Delete_Call) Run(run func(name string)) *MockSnapshotStore_Delete_Call {
1645+ _c.Call.Run(func(args mock.Arguments) {
1646+ var arg0 string
1647+ if args[0] != nil {
1648+ arg0 = args[0].(string)
1649+ }
1650+ run(
1651+ arg0,
1652+ )
1653+ })
1654+ return _c
1655+}
1656+
1657+func (_c *MockSnapshotStore_Delete_Call) Return(err error) *MockSnapshotStore_Delete_Call {
1658+ _c.Call.Return(err)
1659+ return _c
1660+}
1661+
1662+func (_c *MockSnapshotStore_Delete_Call) RunAndReturn(run func(name string) error) *MockSnapshotStore_Delete_Call {
1663+ _c.Call.Return(run)
1664+ return _c
1665+}
1666+
1667+// Load provides a mock function for the type MockSnapshotStore
1668+func (_mock *MockSnapshotStore) Load() ([]ports.SnapshotBlob, error) {
1669+ ret := _mock.Called()
1670+
1671+ if len(ret) == 0 {
1672+ panic("no return value specified for Load")
1673+ }
1674+
1675+ var r0 []ports.SnapshotBlob
1676+ var r1 error
1677+ if returnFunc, ok := ret.Get(0).(func() ([]ports.SnapshotBlob, error)); ok {
1678+ return returnFunc()
1679+ }
1680+ if returnFunc, ok := ret.Get(0).(func() []ports.SnapshotBlob); ok {
1681+ r0 = returnFunc()
1682+ } else {
1683+ if ret.Get(0) != nil {
1684+ r0 = ret.Get(0).([]ports.SnapshotBlob)
1685+ }
1686+ }
1687+ if returnFunc, ok := ret.Get(1).(func() error); ok {
1688+ r1 = returnFunc()
1689+ } else {
1690+ r1 = ret.Error(1)
1691+ }
1692+ return r0, r1
1693+}
1694+
1695+// MockSnapshotStore_Load_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Load'
1696+type MockSnapshotStore_Load_Call struct {
1697+ *mock.Call
1698+}
1699+
1700+// Load is a helper method to define mock.On call
1701+func (_e *MockSnapshotStore_Expecter) Load() *MockSnapshotStore_Load_Call {
1702+ return &MockSnapshotStore_Load_Call{Call: _e.mock.On("Load")}
1703+}
1704+
1705+func (_c *MockSnapshotStore_Load_Call) Run(run func()) *MockSnapshotStore_Load_Call {
1706+ _c.Call.Run(func(args mock.Arguments) {
1707+ run()
1708+ })
1709+ return _c
1710+}
1711+
1712+func (_c *MockSnapshotStore_Load_Call) Return(snapshotBlobs []ports.SnapshotBlob, err error) *MockSnapshotStore_Load_Call {
1713+ _c.Call.Return(snapshotBlobs, err)
1714+ return _c
1715+}
1716+
1717+func (_c *MockSnapshotStore_Load_Call) RunAndReturn(run func() ([]ports.SnapshotBlob, error)) *MockSnapshotStore_Load_Call {
1718+ _c.Call.Return(run)
1719+ return _c
1720+}
1721+
1722+// Write provides a mock function for the type MockSnapshotStore
1723+func (_mock *MockSnapshotStore) Write(name string, data []byte) error {
1724+ ret := _mock.Called(name, data)
1725+
1726+ if len(ret) == 0 {
1727+ panic("no return value specified for Write")
1728+ }
1729+
1730+ var r0 error
1731+ if returnFunc, ok := ret.Get(0).(func(string, []byte) error); ok {
1732+ r0 = returnFunc(name, data)
1733+ } else {
1734+ r0 = ret.Error(0)
1735+ }
1736+ return r0
1737+}
1738+
1739+// MockSnapshotStore_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write'
1740+type MockSnapshotStore_Write_Call struct {
1741+ *mock.Call
1742+}
1743+
1744+// Write is a helper method to define mock.On call
1745+// - name string
1746+// - data []byte
1747+func (_e *MockSnapshotStore_Expecter) Write(name any, data any) *MockSnapshotStore_Write_Call {
1748+ return &MockSnapshotStore_Write_Call{Call: _e.mock.On("Write", name, data)}
1749+}
1750+
1751+func (_c *MockSnapshotStore_Write_Call) Run(run func(name string, data []byte)) *MockSnapshotStore_Write_Call {
1752+ _c.Call.Run(func(args mock.Arguments) {
1753+ var arg0 string
1754+ if args[0] != nil {
1755+ arg0 = args[0].(string)
1756+ }
1757+ var arg1 []byte
1758+ if args[1] != nil {
1759+ arg1 = args[1].([]byte)
1760+ }
1761+ run(
1762+ arg0,
1763+ arg1,
1764+ )
1765+ })
1766+ return _c
1767+}
1768+
1769+func (_c *MockSnapshotStore_Write_Call) Return(err error) *MockSnapshotStore_Write_Call {
1770+ _c.Call.Return(err)
1771+ return _c
1772+}
1773+
1774+func (_c *MockSnapshotStore_Write_Call) RunAndReturn(run func(name string, data []byte) error) *MockSnapshotStore_Write_Call {
1775+ _c.Call.Return(run)
1776+ return _c
1777+}
1778+
1589 // NewMockClock creates a new instance of MockClock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.1779 // NewMockClock creates a new instance of MockClock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1590 // The first argument is typically a *testing.T value.1780 // The first argument is typically a *testing.T value.
1591 func NewMockClock(t interface {1781 func NewMockClock(t interface {
M internal/ports/ports.go +13 -0
@@ -70,6 +70,19 @@ type Store interface {
7070 Close() error
7171 }
7272
73+// SnapshotBlob is a durable named session snapshot payload.
74+type SnapshotBlob struct {
75+ Name string
76+ Data []byte
77+}
78+
79+// SnapshotStore persists encoded session snapshots by name.
80+type SnapshotStore interface {
81+ Write(name string, data []byte) error
82+ Load() ([]SnapshotBlob, error)
83+ Delete(name string) error
84+}
85+
7386 // Clock abstracts time so usecases can be tested without real delays.
7487 type Clock interface {
7588 Now() time.Time
@@ -70,6 +70,19 @@ type Store interface {
70 Close() error70 Close() error
71 }71 }
72 72
73+// SnapshotBlob is a durable named session snapshot payload.
74+type SnapshotBlob struct {
75+ Name string
76+ Data []byte
77+}
78+
79+// SnapshotStore persists encoded session snapshots by name.
80+type SnapshotStore interface {
81+ Write(name string, data []byte) error
82+ Load() ([]SnapshotBlob, error)
83+ Delete(name string) error
84+}
85+
73 // Clock abstracts time so usecases can be tested without real delays.86 // Clock abstracts time so usecases can be tested without real delays.
74 type Clock interface {87 type Clock interface {
75 Now() time.Time88 Now() time.Time
A internal/ports/snapshot_test.go +21 -0
@@ -0,0 +1,21 @@
1+package ports
2+
3+import "testing"
4+
5+type snapshotStoreContract struct{}
6+
7+func (snapshotStoreContract) Write(name string, data []byte) error { return nil }
8+func (snapshotStoreContract) Load() ([]SnapshotBlob, error) { return nil, nil }
9+func (snapshotStoreContract) Delete(name string) error { return nil }
10+
11+func TestSnapshotStoreContract(t *testing.T) {
12+ var _ SnapshotStore = snapshotStoreContract{}
13+
14+ blob := SnapshotBlob{Name: "named-session", Data: []byte("snapshot-bytes")}
15+ if blob.Name != "named-session" {
16+ t.Fatalf("SnapshotBlob.Name = %q, want %q", blob.Name, "named-session")
17+ }
18+ if string(blob.Data) != "snapshot-bytes" {
19+ t.Fatalf("SnapshotBlob.Data = %q, want %q", string(blob.Data), "snapshot-bytes")
20+ }
21+}
@@ -0,0 +1,21 @@
1+package ports
2+
3+import "testing"
4+
5+type snapshotStoreContract struct{}
6+
7+func (snapshotStoreContract) Write(name string, data []byte) error { return nil }
8+func (snapshotStoreContract) Load() ([]SnapshotBlob, error) { return nil, nil }
9+func (snapshotStoreContract) Delete(name string) error { return nil }
10+
11+func TestSnapshotStoreContract(t *testing.T) {
12+ var _ SnapshotStore = snapshotStoreContract{}
13+
14+ blob := SnapshotBlob{Name: "named-session", Data: []byte("snapshot-bytes")}
15+ if blob.Name != "named-session" {
16+ t.Fatalf("SnapshotBlob.Name = %q, want %q", blob.Name, "named-session")
17+ }
18+ if string(blob.Data) != "snapshot-bytes" {
19+ t.Fatalf("SnapshotBlob.Data = %q, want %q", string(blob.Data), "snapshot-bytes")
20+ }
21+}
M internal/usecase/copy/scrollback.go +15 -0
@@ -41,6 +41,21 @@ func (s *Scrollback) Append(row []renderer.Cell) {
4141 s.head = (s.head + 1) % len(s.rows)
4242 }
4343
44+// Snapshot returns retained rows oldest-first.
45+//
46+// The returned outer slice is a copy, but row slices are shared with the
47+// scrollback storage. Callers must treat the returned rows as read-only.
48+func (s *Scrollback) Snapshot() [][]renderer.Cell {
49+ if s.len == 0 || len(s.rows) == 0 {
50+ return nil
51+ }
52+ rows := make([][]renderer.Cell, s.len)
53+ for i := range s.len {
54+ rows[i] = s.rows[(s.head+i)%len(s.rows)]
55+ }
56+ return rows
57+}
58+
4459 // Row returns a copy of the retained row at logical index i, oldest first.
4560 func (s *Scrollback) Row(i int) []renderer.Cell {
4661 if i < 0 || i >= s.len || len(s.rows) == 0 {
@@ -41,6 +41,21 @@ func (s *Scrollback) Append(row []renderer.Cell) {
41 s.head = (s.head + 1) % len(s.rows)41 s.head = (s.head + 1) % len(s.rows)
42 }42 }
43 43
44+// Snapshot returns retained rows oldest-first.
45+//
46+// The returned outer slice is a copy, but row slices are shared with the
47+// scrollback storage. Callers must treat the returned rows as read-only.
48+func (s *Scrollback) Snapshot() [][]renderer.Cell {
49+ if s.len == 0 || len(s.rows) == 0 {
50+ return nil
51+ }
52+ rows := make([][]renderer.Cell, s.len)
53+ for i := range s.len {
54+ rows[i] = s.rows[(s.head+i)%len(s.rows)]
55+ }
56+ return rows
57+}
58+
44 // Row returns a copy of the retained row at logical index i, oldest first.59 // Row returns a copy of the retained row at logical index i, oldest first.
45 func (s *Scrollback) Row(i int) []renderer.Cell {60 func (s *Scrollback) Row(i int) []renderer.Cell {
46 if i < 0 || i >= s.len || len(s.rows) == 0 {61 if i < 0 || i >= s.len || len(s.rows) == 0 {
M internal/usecase/copy/scrollback_test.go +64 -0
@@ -111,3 +111,67 @@ func TestScrollbackCopiesRows(t *testing.T) {
111111 })
112112 }
113113 }
114+
115+func TestScrollbackSnapshot(t *testing.T) {
116+ tests := []struct {
117+ name string
118+ cap int
119+ append []string
120+ wantRows []string
121+ }{
122+ {
123+ name: "empty capacity snapshots no rows",
124+ cap: 0,
125+ append: []string{"aa", "bb"},
126+ wantRows: nil,
127+ },
128+ {
129+ name: "oldest first after ring wrap",
130+ cap: 3,
131+ append: []string{"aa", "bb", "cc", "dd"},
132+ wantRows: []string{"bb", "cc", "dd"},
133+ },
134+ }
135+
136+ for _, tt := range tests {
137+ t.Run(tt.name, func(t *testing.T) {
138+ sb := NewScrollback(tt.cap)
139+ for _, r := range tt.append {
140+ sb.Append(row(r))
141+ }
142+
143+ got := sb.Snapshot()
144+ if len(got) != len(tt.wantRows) {
145+ t.Fatalf("Snapshot() len = %d, want %d", len(got), len(tt.wantRows))
146+ }
147+ for i, want := range tt.wantRows {
148+ if text := rowText(got[i]); text != want {
149+ t.Fatalf("Snapshot()[%d] = %q, want %q", i, text, want)
150+ }
151+ }
152+ if len(got) > 0 {
153+ got[0] = row("zz")
154+ if text := rowText(sb.Row(0)); text != tt.wantRows[0] {
155+ t.Fatalf("Snapshot() outer slice mutation changed storage: Row(0) = %q, want %q", text, tt.wantRows[0])
156+ }
157+ }
158+ })
159+ }
160+}
161+
162+func TestScrollbackAppendDoesNotMutateSnapshottedRows(t *testing.T) {
163+ sb := NewScrollback(2)
164+ sb.Append(row("aa"))
165+ sb.Append(row("bb"))
166+ snapshot := sb.Snapshot()
167+
168+ sb.Append(row("cc"))
169+ sb.Append(row("dd"))
170+
171+ if text := rowText(snapshot[0]); text != "aa" {
172+ t.Fatalf("snapshot[0] after append = %q, want original row", text)
173+ }
174+ if text := rowText(snapshot[1]); text != "bb" {
175+ t.Fatalf("snapshot[1] after append = %q, want original row", text)
176+ }
177+}
@@ -111,3 +111,67 @@ func TestScrollbackCopiesRows(t *testing.T) {
111 })111 })
112 }112 }
113 }113 }
114+
115+func TestScrollbackSnapshot(t *testing.T) {
116+ tests := []struct {
117+ name string
118+ cap int
119+ append []string
120+ wantRows []string
121+ }{
122+ {
123+ name: "empty capacity snapshots no rows",
124+ cap: 0,
125+ append: []string{"aa", "bb"},
126+ wantRows: nil,
127+ },
128+ {
129+ name: "oldest first after ring wrap",
130+ cap: 3,
131+ append: []string{"aa", "bb", "cc", "dd"},
132+ wantRows: []string{"bb", "cc", "dd"},
133+ },
134+ }
135+
136+ for _, tt := range tests {
137+ t.Run(tt.name, func(t *testing.T) {
138+ sb := NewScrollback(tt.cap)
139+ for _, r := range tt.append {
140+ sb.Append(row(r))
141+ }
142+
143+ got := sb.Snapshot()
144+ if len(got) != len(tt.wantRows) {
145+ t.Fatalf("Snapshot() len = %d, want %d", len(got), len(tt.wantRows))
146+ }
147+ for i, want := range tt.wantRows {
148+ if text := rowText(got[i]); text != want {
149+ t.Fatalf("Snapshot()[%d] = %q, want %q", i, text, want)
150+ }
151+ }
152+ if len(got) > 0 {
153+ got[0] = row("zz")
154+ if text := rowText(sb.Row(0)); text != tt.wantRows[0] {
155+ t.Fatalf("Snapshot() outer slice mutation changed storage: Row(0) = %q, want %q", text, tt.wantRows[0])
156+ }
157+ }
158+ })
159+ }
160+}
161+
162+func TestScrollbackAppendDoesNotMutateSnapshottedRows(t *testing.T) {
163+ sb := NewScrollback(2)
164+ sb.Append(row("aa"))
165+ sb.Append(row("bb"))
166+ snapshot := sb.Snapshot()
167+
168+ sb.Append(row("cc"))
169+ sb.Append(row("dd"))
170+
171+ if text := rowText(snapshot[0]); text != "aa" {
172+ t.Fatalf("snapshot[0] after append = %q, want original row", text)
173+ }
174+ if text := rowText(snapshot[1]); text != "bb" {
175+ t.Fatalf("snapshot[1] after append = %q, want original row", text)
176+ }
177+}
M internal/usecase/daemon/client.go +1 -0
@@ -537,6 +537,7 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
537537 d.applyLayoutLocked(tb)
538538 tb.mu.Unlock()
539539 }
540+ markSnapshotDirty(sess)
540541 if ac != nil {
541542 d.paint(sess, ac, true)
542543 }
@@ -537,6 +537,7 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
537 d.applyLayoutLocked(tb)537 d.applyLayoutLocked(tb)
538 tb.mu.Unlock()538 tb.mu.Unlock()
539 }539 }
540+ markSnapshotDirty(sess)
540 if ac != nil {541 if ac != nil {
541 d.paint(sess, ac, true)542 d.paint(sess, ac, true)
542 }543 }
M internal/usecase/daemon/daemon.go +49 -14
@@ -104,6 +104,10 @@ type Daemon struct {
104104 shellArgs []string
105105 persist *persist.Persister
106106 persistEnabled bool
107+ snaps ports.SnapshotStore
108+ snapsEnabled bool
109+ restoreDone chan struct{}
110+ restoreOnce sync.Once
107111 procCwd func(int) (string, error)
108112 procComm func(int) (string, error)
109113 bindings atomic.Pointer[keys.Bindings]
@@ -168,6 +172,15 @@ func WithStore(store ports.Store) Option {
168172 }
169173 }
170174
175+// WithSnapshotStore enables durable named session snapshots. A nil store keeps
176+// the daemon in no-op snapshot mode.
177+func WithSnapshotStore(store ports.SnapshotStore) Option {
178+ return func(d *Daemon) {
179+ d.snaps = store
180+ d.snapsEnabled = store != nil
181+ }
182+}
183+
171184 // WithCwdReader overrides the process cwd reader used for persistence tests.
172185 func WithCwdReader(fn func(int) (string, error)) Option {
173186 return func(d *Daemon) {
@@ -204,19 +217,20 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
204217 shell = "/bin/sh"
205218 }
206219 d := &Daemon{
207- sessions: make(map[domain.SessionID]*session),
208- stopped: make(map[string]stoppedSession),
209- parked: make(map[uint64]*parkedAttachment),
210- ptys: ptys,
211- clock: clock,
212- log: log,
213- baseEnv: os.Environ(),
214- shell: shell,
215- persist: persist.New(nil),
216- procCwd: platform.ProcessCwd,
217- procComm: platform.ProcessComm,
218- done: make(chan struct{}),
219- animWake: make(chan struct{}, 1),
220+ sessions: make(map[domain.SessionID]*session),
221+ stopped: make(map[string]stoppedSession),
222+ parked: make(map[uint64]*parkedAttachment),
223+ ptys: ptys,
224+ clock: clock,
225+ log: log,
226+ baseEnv: os.Environ(),
227+ shell: shell,
228+ persist: persist.New(nil),
229+ procCwd: platform.ProcessCwd,
230+ procComm: platform.ProcessComm,
231+ done: make(chan struct{}),
232+ restoreDone: make(chan struct{}),
233+ animWake: make(chan struct{}, 1),
220234 }
221235 for _, o := range opts {
222236 o(d)
@@ -269,6 +283,16 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
269283 d.cwdSampler(d.serveCtx)
270284 })
271285 }
286+ if d.snapsEnabled {
287+ d.sessWg.Go(func() {
288+ d.snapshotSaver(d.serveCtx)
289+ })
290+ d.sessWg.Go(func() {
291+ d.restoreSnapshots(d.serveCtx)
292+ })
293+ } else {
294+ d.closeRestoreDone()
295+ }
272296
273297 // Break the accept loop when either the parent context is cancelled or the
274298 // registry drains to empty: both close the listener, which fails Accept.
@@ -432,12 +456,20 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
432456 target := d.findByNameLocked(k.Name)
433457 if target == nil {
434458 if stopped, ok := d.stopped[k.Name]; ok {
459+ d.mu.Unlock()
460+ if d.snapsEnabled && d.snaps != nil {
461+ if err := d.snaps.Delete(k.Name); err != nil {
462+ d.log.Warn("deleting stopped session snapshot failed", "err", err, "session", k.Name)
463+ _ = tr.Send(frameError(ports.ErrInternal, "deleting stopped session snapshot failed"))
464+ return
465+ }
466+ }
435467 if err := d.persist.Delete(k.Name); err != nil {
436- d.mu.Unlock()
437468 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", k.Name)
438469 _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted stopped session failed"))
439470 return
440471 }
472+ d.mu.Lock()
441473 if cur, ok := d.stopped[k.Name]; ok && cur == stopped {
442474 delete(d.stopped, k.Name)
443475 }
@@ -514,6 +546,9 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
514546 if !sz.Valid() {
515547 sz = defaultSize
516548 }
549+ if d.snapsEnabled && (h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach || h.Intent == ports.IntentNew) {
550+ <-d.restoreDone
551+ }
517552
518553 d.mu.Lock()
519554 // Shutdown/create interlock: once shutdown has begun (last session removed,
@@ -104,6 +104,10 @@ type Daemon struct {
104 shellArgs []string104 shellArgs []string
105 persist *persist.Persister105 persist *persist.Persister
106 persistEnabled bool106 persistEnabled bool
107+ snaps ports.SnapshotStore
108+ snapsEnabled bool
109+ restoreDone chan struct{}
110+ restoreOnce sync.Once
107 procCwd func(int) (string, error)111 procCwd func(int) (string, error)
108 procComm func(int) (string, error)112 procComm func(int) (string, error)
109 bindings atomic.Pointer[keys.Bindings]113 bindings atomic.Pointer[keys.Bindings]
@@ -168,6 +172,15 @@ func WithStore(store ports.Store) Option {
168 }172 }
169 }173 }
170 174
175+// WithSnapshotStore enables durable named session snapshots. A nil store keeps
176+// the daemon in no-op snapshot mode.
177+func WithSnapshotStore(store ports.SnapshotStore) Option {
178+ return func(d *Daemon) {
179+ d.snaps = store
180+ d.snapsEnabled = store != nil
181+ }
182+}
183+
171 // WithCwdReader overrides the process cwd reader used for persistence tests.184 // WithCwdReader overrides the process cwd reader used for persistence tests.
172 func WithCwdReader(fn func(int) (string, error)) Option {185 func WithCwdReader(fn func(int) (string, error)) Option {
173 return func(d *Daemon) {186 return func(d *Daemon) {
@@ -204,19 +217,20 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
204 shell = "/bin/sh"217 shell = "/bin/sh"
205 }218 }
206 d := &Daemon{219 d := &Daemon{
207- sessions: make(map[domain.SessionID]*session),220+ sessions: make(map[domain.SessionID]*session),
208- stopped: make(map[string]stoppedSession),221+ stopped: make(map[string]stoppedSession),
209- parked: make(map[uint64]*parkedAttachment),222+ parked: make(map[uint64]*parkedAttachment),
210- ptys: ptys,223+ ptys: ptys,
211- clock: clock,224+ clock: clock,
212- log: log,225+ log: log,
213- baseEnv: os.Environ(),226+ baseEnv: os.Environ(),
214- shell: shell,227+ shell: shell,
215- persist: persist.New(nil),228+ persist: persist.New(nil),
216- procCwd: platform.ProcessCwd,229+ procCwd: platform.ProcessCwd,
217- procComm: platform.ProcessComm,230+ procComm: platform.ProcessComm,
218- done: make(chan struct{}),231+ done: make(chan struct{}),
219- animWake: make(chan struct{}, 1),232+ restoreDone: make(chan struct{}),
233+ animWake: make(chan struct{}, 1),
220 }234 }
221 for _, o := range opts {235 for _, o := range opts {
222 o(d)236 o(d)
@@ -269,6 +283,16 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
269 d.cwdSampler(d.serveCtx)283 d.cwdSampler(d.serveCtx)
270 })284 })
271 }285 }
286+ if d.snapsEnabled {
287+ d.sessWg.Go(func() {
288+ d.snapshotSaver(d.serveCtx)
289+ })
290+ d.sessWg.Go(func() {
291+ d.restoreSnapshots(d.serveCtx)
292+ })
293+ } else {
294+ d.closeRestoreDone()
295+ }
272 296
273 // Break the accept loop when either the parent context is cancelled or the297 // Break the accept loop when either the parent context is cancelled or the
274 // registry drains to empty: both close the listener, which fails Accept.298 // registry drains to empty: both close the listener, which fails Accept.
@@ -432,12 +456,20 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
432 target := d.findByNameLocked(k.Name)456 target := d.findByNameLocked(k.Name)
433 if target == nil {457 if target == nil {
434 if stopped, ok := d.stopped[k.Name]; ok {458 if stopped, ok := d.stopped[k.Name]; ok {
459+ d.mu.Unlock()
460+ if d.snapsEnabled && d.snaps != nil {
461+ if err := d.snaps.Delete(k.Name); err != nil {
462+ d.log.Warn("deleting stopped session snapshot failed", "err", err, "session", k.Name)
463+ _ = tr.Send(frameError(ports.ErrInternal, "deleting stopped session snapshot failed"))
464+ return
465+ }
466+ }
435 if err := d.persist.Delete(k.Name); err != nil {467 if err := d.persist.Delete(k.Name); err != nil {
436- d.mu.Unlock()
437 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", k.Name)468 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", k.Name)
438 _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted stopped session failed"))469 _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted stopped session failed"))
439 return470 return
440 }471 }
472+ d.mu.Lock()
441 if cur, ok := d.stopped[k.Name]; ok && cur == stopped {473 if cur, ok := d.stopped[k.Name]; ok && cur == stopped {
442 delete(d.stopped, k.Name)474 delete(d.stopped, k.Name)
443 }475 }
@@ -514,6 +546,9 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
514 if !sz.Valid() {546 if !sz.Valid() {
515 sz = defaultSize547 sz = defaultSize
516 }548 }
549+ if d.snapsEnabled && (h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach || h.Intent == ports.IntentNew) {
550+ <-d.restoreDone
551+ }
517 552
518 d.mu.Lock()553 d.mu.Lock()
519 // Shutdown/create interlock: once shutdown has begun (last session removed,554 // Shutdown/create interlock: once shutdown has begun (last session removed,
M internal/usecase/daemon/pane_actions.go +12 -2
@@ -87,6 +87,7 @@ func (d *Daemon) spawnPaneOp(
8787 tb.mu.Unlock()
8888
8989 d.startPaneGoroutines(sess, tb, p)
90+ markSnapshotDirty(sess)
9091 if ac != nil {
9192 d.paint(sess, ac, true)
9293 }
@@ -174,8 +175,11 @@ func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
174175 d.applyLayoutLocked(tb)
175176 }
176177 tb.mu.Unlock()
177- if err == nil && ac != nil {
178- d.paint(sess, ac, true)
178+ if err == nil {
179+ markSnapshotDirty(sess)
180+ if ac != nil {
181+ d.paint(sess, ac, true)
182+ }
179183 }
180184 return err
181185 }
@@ -238,6 +242,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
238242 _ = p.pty.Close()
239243 }
240244 d.log.Info("pane closed", "session", sess.name, "pane", id)
245+ markSnapshotDirty(sess)
241246 if repaint {
242247 if ac == nil {
243248 sess.mu.Lock()
@@ -268,6 +273,11 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
268273 d.applyLayoutLocked(tb)
269274 }
270275 tb.mu.Unlock()
276+ if err == nil {
277+ if newFocus != oldFocus {
278+ markSnapshotDirty(sess)
279+ }
280+ }
271281 if err == nil && ac != nil {
272282 if newFocus != oldFocus {
273283 d.exitCopyMode(ac)
@@ -87,6 +87,7 @@ func (d *Daemon) spawnPaneOp(
87 tb.mu.Unlock()87 tb.mu.Unlock()
88 88
89 d.startPaneGoroutines(sess, tb, p)89 d.startPaneGoroutines(sess, tb, p)
90+ markSnapshotDirty(sess)
90 if ac != nil {91 if ac != nil {
91 d.paint(sess, ac, true)92 d.paint(sess, ac, true)
92 }93 }
@@ -174,8 +175,11 @@ func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
174 d.applyLayoutLocked(tb)175 d.applyLayoutLocked(tb)
175 }176 }
176 tb.mu.Unlock()177 tb.mu.Unlock()
177- if err == nil && ac != nil {178+ if err == nil {
178- d.paint(sess, ac, true)179+ markSnapshotDirty(sess)
180+ if ac != nil {
181+ d.paint(sess, ac, true)
182+ }
179 }183 }
180 return err184 return err
181 }185 }
@@ -238,6 +242,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
238 _ = p.pty.Close()242 _ = p.pty.Close()
239 }243 }
240 d.log.Info("pane closed", "session", sess.name, "pane", id)244 d.log.Info("pane closed", "session", sess.name, "pane", id)
245+ markSnapshotDirty(sess)
241 if repaint {246 if repaint {
242 if ac == nil {247 if ac == nil {
243 sess.mu.Lock()248 sess.mu.Lock()
@@ -268,6 +273,11 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
268 d.applyLayoutLocked(tb)273 d.applyLayoutLocked(tb)
269 }274 }
270 tb.mu.Unlock()275 tb.mu.Unlock()
276+ if err == nil {
277+ if newFocus != oldFocus {
278+ markSnapshotDirty(sess)
279+ }
280+ }
271 if err == nil && ac != nil {281 if err == nil && ac != nil {
272 if newFocus != oldFocus {282 if newFocus != oldFocus {
273 d.exitCopyMode(ac)283 d.exitCopyMode(ac)
M internal/usecase/daemon/render.go +1 -0
@@ -71,6 +71,7 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
7171 p.screen.Write(data)
7272 isSyncing := p.screen.SyncUpdateActive()
7373 p.mu.Unlock()
74+ markSnapshotDirty(sess)
7475 select {
7576 case <-attentionCh:
7677 d.noteAttention(sess, tb)
@@ -71,6 +71,7 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
71 p.screen.Write(data)71 p.screen.Write(data)
72 isSyncing := p.screen.SyncUpdateActive()72 isSyncing := p.screen.SyncUpdateActive()
73 p.mu.Unlock()73 p.mu.Unlock()
74+ markSnapshotDirty(sess)
74 select {75 select {
75 case <-attentionCh:76 case <-attentionCh:
76 d.noteAttention(sess, tb)77 d.noteAttention(sess, tb)
M internal/usecase/daemon/session.go +36 -3
@@ -58,6 +58,7 @@ type session struct {
5858 cwd string
5959 createdAt int64
6060 mruAt atomic.Uint64
61+ snapDirty atomic.Bool
6162 // clipFiles records clipboard-image-transfer temp file paths (see
6263 // clipboard.go) written for this session, removed best-effort in
6364 // killSession.
@@ -263,6 +264,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
263264 d.log.Info("tab created", "session", name, "tab", tabIndex)
264265 d.startTabGoroutines(sess, tb)
265266 d.mu.Unlock()
267+ markSnapshotDirty(sess)
266268 return nil
267269 }
268270
@@ -360,21 +362,25 @@ func (s *session) activeTab() *tab {
360362
361363 func (s *session) switchTab(idx int) bool {
362364 s.mu.Lock()
363- defer s.mu.Unlock()
364365 if idx < 0 || idx >= len(s.tabs) || idx == s.active {
366+ s.mu.Unlock()
365367 return false
366368 }
367369 s.active = idx
370+ s.mu.Unlock()
371+ markSnapshotDirty(s)
368372 return true
369373 }
370374
371375 func (s *session) switchRelative(delta int) bool {
372376 s.mu.Lock()
373- defer s.mu.Unlock()
374377 if len(s.tabs) < 2 {
378+ s.mu.Unlock()
375379 return false
376380 }
377381 s.active = (s.active + delta + len(s.tabs)) % len(s.tabs)
382+ s.mu.Unlock()
383+ markSnapshotDirty(s)
378384 return true
379385 }
380386
@@ -391,7 +397,6 @@ func (d *Daemon) renameSession(sess *session, name string) error {
391397 return errors.New("name already in use")
392398 }
393399 sess.mu.Lock()
394- defer sess.mu.Unlock()
395400 oldName := sess.name
396401 wasEphemeral := sess.ephemeral
397402 createdAt := sess.createdAt
@@ -402,14 +407,25 @@ func (d *Daemon) renameSession(sess *session, name string) error {
402407 lastUsedSeq := sess.mruAt.Load()
403408 if wasEphemeral || oldName != name {
404409 if err := d.persist.Save(persist.Record{Name: name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: time.Now().UnixNano(), LastUsedSeq: lastUsedSeq}); err != nil {
410+ sess.mu.Unlock()
405411 return err
406412 }
407413 }
408414 if !wasEphemeral && oldName != name {
415+ if d.snapsEnabled && d.snaps != nil {
416+ if err := d.snaps.Delete(oldName); err != nil {
417+ if cleanupErr := d.persist.Delete(name); cleanupErr != nil {
418+ d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name)
419+ }
420+ sess.mu.Unlock()
421+ return err
422+ }
423+ }
409424 if err := d.persist.Delete(oldName); err != nil {
410425 if cleanupErr := d.persist.Delete(name); cleanupErr != nil {
411426 d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name)
412427 }
428+ sess.mu.Unlock()
413429 return err
414430 }
415431 }
@@ -417,6 +433,8 @@ func (d *Daemon) renameSession(sess *session, name string) error {
417433 delete(d.stopped, name)
418434 sess.name = name
419435 sess.ephemeral = false
436+ sess.mu.Unlock()
437+ markSnapshotDirty(sess)
420438 return nil
421439 }
422440
@@ -451,6 +469,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
451469 name := sess.name
452470 sess.mu.Unlock()
453471 d.log.Info("tab closed", "session", name)
472+ markSnapshotDirty(sess)
454473
455474 d.clearDestroyedTabPreview(tb)
456475 if tb.cancel != nil {
@@ -476,6 +495,19 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
476495 if !purge && !isEphemeral && d.persistEnabled {
477496 d.refreshSessionCwd(sess)
478497 }
498+ if d.snapsEnabled && !isEphemeral {
499+ if purge {
500+ sess.mu.Lock()
501+ name := sess.name
502+ sess.mu.Unlock()
503+ if err := d.snaps.Delete(name); err != nil {
504+ d.log.Warn("deleting session snapshot failed", "err", err, "session", name)
505+ return err
506+ }
507+ } else {
508+ d.captureSession(sess)
509+ }
510+ }
479511 d.mu.Lock()
480512 if _, ok := d.sessions[sess.id]; !ok {
481513 d.mu.Unlock()
@@ -658,6 +690,7 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
658690 if err := d.persist.Touch(name, cwd, time.Now().UnixNano()); err != nil {
659691 d.log.Warn("touching persisted session cwd failed", "err", err, "session", name)
660692 }
693+ markSnapshotDirty(sess)
661694 }
662695 d.mu.Unlock()
663696 }
@@ -58,6 +58,7 @@ type session struct {
58 cwd string58 cwd string
59 createdAt int6459 createdAt int64
60 mruAt atomic.Uint6460 mruAt atomic.Uint64
61+ snapDirty atomic.Bool
61 // clipFiles records clipboard-image-transfer temp file paths (see62 // clipFiles records clipboard-image-transfer temp file paths (see
62 // clipboard.go) written for this session, removed best-effort in63 // clipboard.go) written for this session, removed best-effort in
63 // killSession.64 // killSession.
@@ -263,6 +264,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
263 d.log.Info("tab created", "session", name, "tab", tabIndex)264 d.log.Info("tab created", "session", name, "tab", tabIndex)
264 d.startTabGoroutines(sess, tb)265 d.startTabGoroutines(sess, tb)
265 d.mu.Unlock()266 d.mu.Unlock()
267+ markSnapshotDirty(sess)
266 return nil268 return nil
267 }269 }
268 270
@@ -360,21 +362,25 @@ func (s *session) activeTab() *tab {
360 362
361 func (s *session) switchTab(idx int) bool {363 func (s *session) switchTab(idx int) bool {
362 s.mu.Lock()364 s.mu.Lock()
363- defer s.mu.Unlock()
364 if idx < 0 || idx >= len(s.tabs) || idx == s.active {365 if idx < 0 || idx >= len(s.tabs) || idx == s.active {
366+ s.mu.Unlock()
365 return false367 return false
366 }368 }
367 s.active = idx369 s.active = idx
370+ s.mu.Unlock()
371+ markSnapshotDirty(s)
368 return true372 return true
369 }373 }
370 374
371 func (s *session) switchRelative(delta int) bool {375 func (s *session) switchRelative(delta int) bool {
372 s.mu.Lock()376 s.mu.Lock()
373- defer s.mu.Unlock()
374 if len(s.tabs) < 2 {377 if len(s.tabs) < 2 {
378+ s.mu.Unlock()
375 return false379 return false
376 }380 }
377 s.active = (s.active + delta + len(s.tabs)) % len(s.tabs)381 s.active = (s.active + delta + len(s.tabs)) % len(s.tabs)
382+ s.mu.Unlock()
383+ markSnapshotDirty(s)
378 return true384 return true
379 }385 }
380 386
@@ -391,7 +397,6 @@ func (d *Daemon) renameSession(sess *session, name string) error {
391 return errors.New("name already in use")397 return errors.New("name already in use")
392 }398 }
393 sess.mu.Lock()399 sess.mu.Lock()
394- defer sess.mu.Unlock()
395 oldName := sess.name400 oldName := sess.name
396 wasEphemeral := sess.ephemeral401 wasEphemeral := sess.ephemeral
397 createdAt := sess.createdAt402 createdAt := sess.createdAt
@@ -402,14 +407,25 @@ func (d *Daemon) renameSession(sess *session, name string) error {
402 lastUsedSeq := sess.mruAt.Load()407 lastUsedSeq := sess.mruAt.Load()
403 if wasEphemeral || oldName != name {408 if wasEphemeral || oldName != name {
404 if err := d.persist.Save(persist.Record{Name: name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: time.Now().UnixNano(), LastUsedSeq: lastUsedSeq}); err != nil {409 if err := d.persist.Save(persist.Record{Name: name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: time.Now().UnixNano(), LastUsedSeq: lastUsedSeq}); err != nil {
410+ sess.mu.Unlock()
405 return err411 return err
406 }412 }
407 }413 }
408 if !wasEphemeral && oldName != name {414 if !wasEphemeral && oldName != name {
415+ if d.snapsEnabled && d.snaps != nil {
416+ if err := d.snaps.Delete(oldName); err != nil {
417+ if cleanupErr := d.persist.Delete(name); cleanupErr != nil {
418+ d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name)
419+ }
420+ sess.mu.Unlock()
421+ return err
422+ }
423+ }
409 if err := d.persist.Delete(oldName); err != nil {424 if err := d.persist.Delete(oldName); err != nil {
410 if cleanupErr := d.persist.Delete(name); cleanupErr != nil {425 if cleanupErr := d.persist.Delete(name); cleanupErr != nil {
411 d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name)426 d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name)
412 }427 }
428+ sess.mu.Unlock()
413 return err429 return err
414 }430 }
415 }431 }
@@ -417,6 +433,8 @@ func (d *Daemon) renameSession(sess *session, name string) error {
417 delete(d.stopped, name)433 delete(d.stopped, name)
418 sess.name = name434 sess.name = name
419 sess.ephemeral = false435 sess.ephemeral = false
436+ sess.mu.Unlock()
437+ markSnapshotDirty(sess)
420 return nil438 return nil
421 }439 }
422 440
@@ -451,6 +469,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
451 name := sess.name469 name := sess.name
452 sess.mu.Unlock()470 sess.mu.Unlock()
453 d.log.Info("tab closed", "session", name)471 d.log.Info("tab closed", "session", name)
472+ markSnapshotDirty(sess)
454 473
455 d.clearDestroyedTabPreview(tb)474 d.clearDestroyedTabPreview(tb)
456 if tb.cancel != nil {475 if tb.cancel != nil {
@@ -476,6 +495,19 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
476 if !purge && !isEphemeral && d.persistEnabled {495 if !purge && !isEphemeral && d.persistEnabled {
477 d.refreshSessionCwd(sess)496 d.refreshSessionCwd(sess)
478 }497 }
498+ if d.snapsEnabled && !isEphemeral {
499+ if purge {
500+ sess.mu.Lock()
501+ name := sess.name
502+ sess.mu.Unlock()
503+ if err := d.snaps.Delete(name); err != nil {
504+ d.log.Warn("deleting session snapshot failed", "err", err, "session", name)
505+ return err
506+ }
507+ } else {
508+ d.captureSession(sess)
509+ }
510+ }
479 d.mu.Lock()511 d.mu.Lock()
480 if _, ok := d.sessions[sess.id]; !ok {512 if _, ok := d.sessions[sess.id]; !ok {
481 d.mu.Unlock()513 d.mu.Unlock()
@@ -658,6 +690,7 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
658 if err := d.persist.Touch(name, cwd, time.Now().UnixNano()); err != nil {690 if err := d.persist.Touch(name, cwd, time.Now().UnixNano()); err != nil {
659 d.log.Warn("touching persisted session cwd failed", "err", err, "session", name)691 d.log.Warn("touching persisted session cwd failed", "err", err, "session", name)
660 }692 }
693+ markSnapshotDirty(sess)
661 }694 }
662 d.mu.Unlock()695 d.mu.Unlock()
663 }696 }
A internal/usecase/daemon/snapshot.go +335 -0
@@ -0,0 +1,335 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/persist"
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/pkg/renderer"
14+ "github.com/bnema/vev/pkg/vt"
15+)
16+
17+const snapshotInterval = 30 * time.Second
18+
19+func (d *Daemon) closeRestoreDone() {
20+ d.restoreOnce.Do(func() { close(d.restoreDone) })
21+}
22+
23+func (d *Daemon) restoreSnapshots(ctx context.Context) {
24+ defer d.closeRestoreDone()
25+ if d == nil || !d.snapsEnabled || d.snaps == nil {
26+ return
27+ }
28+ blobs, err := d.snaps.Load()
29+ if err != nil {
30+ d.log.Warn("loading session snapshots failed", "err", err)
31+ return
32+ }
33+ for _, blob := range blobs {
34+ select {
35+ case <-ctx.Done():
36+ return
37+ default:
38+ }
39+ snap, err := snapcodec.Unmarshal(blob.Data)
40+ if err != nil {
41+ d.log.Warn("decoding session snapshot failed", "err", err, "session", blob.Name)
42+ continue
43+ }
44+ if err := d.restoreSession(ctx, snap); err != nil {
45+ d.log.Warn("restoring session snapshot failed", "err", err, "session", snap.Name)
46+ }
47+ }
48+}
49+
50+func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) error {
51+ if snap.Name == "" {
52+ return fmt.Errorf("snapshot: empty session name")
53+ }
54+ d.mu.Lock()
55+ if d.closing || d.findByNameLocked(snap.Name) != nil {
56+ d.mu.Unlock()
57+ return nil
58+ }
59+ d.mu.Unlock()
60+
61+ sctx, cancel := context.WithCancel(d.serveCtx)
62+ opened := make([]*tab, 0, len(snap.Tabs))
63+ closeOpened := func() {
64+ for _, tb := range opened {
65+ tb.closeAllPanes()
66+ }
67+ cancel()
68+ }
69+ for _, tabSnap := range snap.Tabs {
70+ tbSize := domain.Size{Cols: int(tabSnap.Cols), Rows: int(tabSnap.Rows)}
71+ if !tbSize.Valid() {
72+ closeOpened()
73+ return fmt.Errorf("snapshot: invalid tab size")
74+ }
75+ placements, ok := layout.Solve(tabSnap.Tree.Root, domain.Rect{Width: tbSize.Cols, Height: tbSize.Rows})
76+ if !ok {
77+ closeOpened()
78+ return fmt.Errorf("snapshot: unsolvable layout")
79+ }
80+ placementByPane := make(map[layout.PaneID]domain.Rect, len(placements))
81+ for _, pl := range placements {
82+ placementByPane[pl.ID] = pl.Content
83+ }
84+ tb := &tab{tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize}
85+ if tb.nextPaneID <= 0 {
86+ tb.nextPaneID = 1
87+ }
88+ tb.ctx, tb.cancel = context.WithCancel(sctx)
89+ opened = append(opened, tb)
90+ for _, paneSnap := range tabSnap.Panes {
91+ contentRect, ok := placementByPane[paneSnap.ID]
92+ if !ok {
93+ closeOpened()
94+ return fmt.Errorf("snapshot: missing pane placement")
95+ }
96+ contentSize := restorePTYSize(contentRect, tbSize)
97+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name), paneSnap.Cwd, contentSize)
98+ if err != nil {
99+ closeOpened()
100+ return err
101+ }
102+ p := newPane(paneSnap.ID, pty, contentSize)
103+ p.ctx, p.cancel = context.WithCancel(tb.ctx)
104+ p.rect = contentRect
105+ seedPaneRows(p, paneSnap.Scrollback, paneSnap.Visible)
106+ tb.panes[paneSnap.ID] = p
107+ }
108+ }
109+ if len(opened) == 0 {
110+ cancel()
111+ return nil
112+ }
113+ createdAt := int64(snap.CreatedAt)
114+ sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), createdAt: createdAt}
115+ if sess.active < 0 || sess.active >= len(sess.tabs) {
116+ sess.active = 0
117+ }
118+ if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {
119+ sess.cwd = snap.Tabs[0].Panes[0].Cwd
120+ }
121+ d.mu.Lock()
122+ if stopped, ok := d.stopped[snap.Name]; ok {
123+ sess.mruAt.Store(stopped.lastUsedSeq)
124+ }
125+ d.mu.Unlock()
126+ lastUsedSeq := sess.mruAt.Load()
127+ if err := d.persist.Save(persist.Record{Name: snap.Name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq}); err != nil {
128+ closeOpened()
129+ return err
130+ }
131+ d.mu.Lock()
132+ if d.closing || d.findByNameLocked(snap.Name) != nil || ctx.Err() != nil {
133+ d.mu.Unlock()
134+ closeOpened()
135+ return nil
136+ }
137+ id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
138+ d.nextID++
139+ sess.id = id
140+ delete(d.stopped, snap.Name)
141+ d.sessions[id] = sess
142+ d.mu.Unlock()
143+ sess.snapDirty.Store(false)
144+ for _, tb := range opened {
145+ d.startTabGoroutines(sess, tb)
146+ }
147+ return nil
148+}
149+
150+func markSnapshotDirty(sess *session) {
151+ if sess == nil {
152+ return
153+ }
154+ sess.mu.Lock()
155+ named := !sess.ephemeral && sess.name != ""
156+ sess.mu.Unlock()
157+ if named {
158+ sess.snapDirty.Store(true)
159+ }
160+}
161+
162+func seedPaneRows(p *pane, scrollback, visible [][]renderer.Cell) {
163+ p.mu.Lock()
164+ defer p.mu.Unlock()
165+ p.scrollback = scopy.NewScrollback(defaultScrollbackRows)
166+ for _, row := range scrollback {
167+ p.scrollback.Append(row)
168+ }
169+ if len(visible) > 0 {
170+ p.screen = vt.NewScreen(p.screen.Frame.Width, p.screen.Frame.Height)
171+ for y, row := range visible {
172+ if y >= p.screen.Frame.Height {
173+ break
174+ }
175+ for x, cell := range row {
176+ if x >= p.screen.Frame.Width {
177+ break
178+ }
179+ p.screen.Frame.Set(x, y, cell)
180+ }
181+ }
182+ }
183+ p.screen.OnLineEvicted = p.scrollback.Append
184+}
185+
186+func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size {
187+ cols := contentRect.Width
188+ if cols <= 0 {
189+ cols = tabSize.Cols
190+ }
191+ if cols < layout.MinPaneCols {
192+ cols = layout.MinPaneCols
193+ }
194+ rows := contentRect.Height
195+ if rows <= 0 {
196+ rows = min(max(tabSize.Rows, layout.MinPaneRows), 24)
197+ }
198+ if rows < layout.MinPaneRows {
199+ rows = layout.MinPaneRows
200+ }
201+ return domain.Size{Cols: cols, Rows: rows}
202+}
203+
204+func (d *Daemon) captureSession(sess *session) bool {
205+ if d == nil || sess == nil || !d.snapsEnabled || d.snaps == nil {
206+ return true
207+ }
208+
209+ sess.mu.Lock()
210+ name := sess.name
211+ ephemeral := sess.ephemeral
212+ createdAt := sess.createdAt
213+ active := sess.active
214+ fallbackCwd := sess.cwd
215+ tabs := append([]*tab(nil), sess.tabs...)
216+ sess.mu.Unlock()
217+ if ephemeral || name == "" {
218+ return true
219+ }
220+
221+ snap := snapcodec.Session{Name: name, CreatedAt: uint64(createdAt), Active: uint16(max(active, 0))}
222+ for _, tb := range tabs {
223+ tb.mu.Lock()
224+ tabSnap := snapcodec.Tab{
225+ Cols: uint16(max(tb.size.Cols, 0)),
226+ Rows: uint16(max(tb.size.Rows, 0)),
227+ NextPaneID: uint64(max(tb.nextPaneID, 0)),
228+ Tree: tb.tree.Clone(),
229+ }
230+ if tb.tree != nil {
231+ tabSnap.Focus = tb.tree.Focus
232+ }
233+ panes := make([]*pane, 0, len(tb.panes))
234+ for _, p := range tb.panes {
235+ panes = append(panes, p)
236+ }
237+ tb.mu.Unlock()
238+
239+ paneIDs := make(map[layout.PaneID]struct{}, len(panes))
240+ paneSnaps := make([]snapcodec.Pane, 0, len(panes))
241+ for _, p := range panes {
242+ p.mu.Lock()
243+ pid := 0
244+ if p.pty != nil {
245+ pid = p.pty.Pid()
246+ }
247+ paneSnap := snapcodec.Pane{
248+ ID: p.id,
249+ Scrollback: cloneRows(p.scrollback.Snapshot()),
250+ Visible: cloneRows(p.screen.PrimaryVisibleRows()),
251+ }
252+ p.mu.Unlock()
253+ paneSnap.Cwd = fallbackCwd
254+ if d.procCwd != nil && pid > 0 {
255+ if cwd, err := d.procCwd(pid); err == nil && cwd != "" {
256+ paneSnap.Cwd = cwd
257+ }
258+ }
259+ paneIDs[paneSnap.ID] = struct{}{}
260+ paneSnaps = append(paneSnaps, paneSnap)
261+ }
262+ if tabSnap.Tree != nil {
263+ pruneTreeToPanes(tabSnap.Tree.Root, paneIDs)
264+ }
265+ tabSnap.Panes = paneSnaps
266+ snap.Tabs = append(snap.Tabs, tabSnap)
267+ }
268+
269+ data, err := snapcodec.Marshal(snap)
270+ if err != nil {
271+ d.log.Warn("marshaling session snapshot failed", "err", err, "session", name)
272+ return false
273+ }
274+ if err := d.snaps.Write(name, data); err != nil {
275+ d.log.Warn("writing session snapshot failed", "err", err, "session", name)
276+ return false
277+ }
278+ return true
279+}
280+
281+func (d *Daemon) snapshotSaver(ctx context.Context) {
282+ t := d.clock.NewTimer(snapshotInterval)
283+ defer t.Stop()
284+ for {
285+ select {
286+ case <-ctx.Done():
287+ return
288+ case <-t.C():
289+ }
290+
291+ d.mu.Lock()
292+ sessions := d.sessionsSnapshotLocked()
293+ d.mu.Unlock()
294+ for _, sess := range sessions {
295+ sess.mu.Lock()
296+ named := !sess.ephemeral && sess.name != ""
297+ sess.mu.Unlock()
298+ if named && sess.snapDirty.Swap(false) {
299+ if !d.captureSession(sess) {
300+ sess.snapDirty.Store(true)
301+ }
302+ }
303+ }
304+ t.Reset(snapshotInterval)
305+ }
306+}
307+
308+func cloneRows(rows [][]renderer.Cell) [][]renderer.Cell {
309+ if len(rows) == 0 {
310+ return nil
311+ }
312+ out := make([][]renderer.Cell, len(rows))
313+ for i, row := range rows {
314+ out[i] = append([]renderer.Cell(nil), row...)
315+ }
316+ return out
317+}
318+
319+func pruneTreeToPanes(n *layout.Node, panes map[layout.PaneID]struct{}) bool {
320+ if n == nil {
321+ return false
322+ }
323+ if n.Kind == layout.Leaf {
324+ _, ok := panes[n.Leaf]
325+ return ok
326+ }
327+ kept := n.Children[:0]
328+ for _, child := range n.Children {
329+ if pruneTreeToPanes(child, panes) {
330+ kept = append(kept, child)
331+ }
332+ }
333+ n.Children = kept
334+ return len(n.Children) > 0
335+}
@@ -0,0 +1,335 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/persist"
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/pkg/renderer"
14+ "github.com/bnema/vev/pkg/vt"
15+)
16+
17+const snapshotInterval = 30 * time.Second
18+
19+func (d *Daemon) closeRestoreDone() {
20+ d.restoreOnce.Do(func() { close(d.restoreDone) })
21+}
22+
23+func (d *Daemon) restoreSnapshots(ctx context.Context) {
24+ defer d.closeRestoreDone()
25+ if d == nil || !d.snapsEnabled || d.snaps == nil {
26+ return
27+ }
28+ blobs, err := d.snaps.Load()
29+ if err != nil {
30+ d.log.Warn("loading session snapshots failed", "err", err)
31+ return
32+ }
33+ for _, blob := range blobs {
34+ select {
35+ case <-ctx.Done():
36+ return
37+ default:
38+ }
39+ snap, err := snapcodec.Unmarshal(blob.Data)
40+ if err != nil {
41+ d.log.Warn("decoding session snapshot failed", "err", err, "session", blob.Name)
42+ continue
43+ }
44+ if err := d.restoreSession(ctx, snap); err != nil {
45+ d.log.Warn("restoring session snapshot failed", "err", err, "session", snap.Name)
46+ }
47+ }
48+}
49+
50+func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) error {
51+ if snap.Name == "" {
52+ return fmt.Errorf("snapshot: empty session name")
53+ }
54+ d.mu.Lock()
55+ if d.closing || d.findByNameLocked(snap.Name) != nil {
56+ d.mu.Unlock()
57+ return nil
58+ }
59+ d.mu.Unlock()
60+
61+ sctx, cancel := context.WithCancel(d.serveCtx)
62+ opened := make([]*tab, 0, len(snap.Tabs))
63+ closeOpened := func() {
64+ for _, tb := range opened {
65+ tb.closeAllPanes()
66+ }
67+ cancel()
68+ }
69+ for _, tabSnap := range snap.Tabs {
70+ tbSize := domain.Size{Cols: int(tabSnap.Cols), Rows: int(tabSnap.Rows)}
71+ if !tbSize.Valid() {
72+ closeOpened()
73+ return fmt.Errorf("snapshot: invalid tab size")
74+ }
75+ placements, ok := layout.Solve(tabSnap.Tree.Root, domain.Rect{Width: tbSize.Cols, Height: tbSize.Rows})
76+ if !ok {
77+ closeOpened()
78+ return fmt.Errorf("snapshot: unsolvable layout")
79+ }
80+ placementByPane := make(map[layout.PaneID]domain.Rect, len(placements))
81+ for _, pl := range placements {
82+ placementByPane[pl.ID] = pl.Content
83+ }
84+ tb := &tab{tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize}
85+ if tb.nextPaneID <= 0 {
86+ tb.nextPaneID = 1
87+ }
88+ tb.ctx, tb.cancel = context.WithCancel(sctx)
89+ opened = append(opened, tb)
90+ for _, paneSnap := range tabSnap.Panes {
91+ contentRect, ok := placementByPane[paneSnap.ID]
92+ if !ok {
93+ closeOpened()
94+ return fmt.Errorf("snapshot: missing pane placement")
95+ }
96+ contentSize := restorePTYSize(contentRect, tbSize)
97+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name), paneSnap.Cwd, contentSize)
98+ if err != nil {
99+ closeOpened()
100+ return err
101+ }
102+ p := newPane(paneSnap.ID, pty, contentSize)
103+ p.ctx, p.cancel = context.WithCancel(tb.ctx)
104+ p.rect = contentRect
105+ seedPaneRows(p, paneSnap.Scrollback, paneSnap.Visible)
106+ tb.panes[paneSnap.ID] = p
107+ }
108+ }
109+ if len(opened) == 0 {
110+ cancel()
111+ return nil
112+ }
113+ createdAt := int64(snap.CreatedAt)
114+ sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), createdAt: createdAt}
115+ if sess.active < 0 || sess.active >= len(sess.tabs) {
116+ sess.active = 0
117+ }
118+ if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {
119+ sess.cwd = snap.Tabs[0].Panes[0].Cwd
120+ }
121+ d.mu.Lock()
122+ if stopped, ok := d.stopped[snap.Name]; ok {
123+ sess.mruAt.Store(stopped.lastUsedSeq)
124+ }
125+ d.mu.Unlock()
126+ lastUsedSeq := sess.mruAt.Load()
127+ if err := d.persist.Save(persist.Record{Name: snap.Name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq}); err != nil {
128+ closeOpened()
129+ return err
130+ }
131+ d.mu.Lock()
132+ if d.closing || d.findByNameLocked(snap.Name) != nil || ctx.Err() != nil {
133+ d.mu.Unlock()
134+ closeOpened()
135+ return nil
136+ }
137+ id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
138+ d.nextID++
139+ sess.id = id
140+ delete(d.stopped, snap.Name)
141+ d.sessions[id] = sess
142+ d.mu.Unlock()
143+ sess.snapDirty.Store(false)
144+ for _, tb := range opened {
145+ d.startTabGoroutines(sess, tb)
146+ }
147+ return nil
148+}
149+
150+func markSnapshotDirty(sess *session) {
151+ if sess == nil {
152+ return
153+ }
154+ sess.mu.Lock()
155+ named := !sess.ephemeral && sess.name != ""
156+ sess.mu.Unlock()
157+ if named {
158+ sess.snapDirty.Store(true)
159+ }
160+}
161+
162+func seedPaneRows(p *pane, scrollback, visible [][]renderer.Cell) {
163+ p.mu.Lock()
164+ defer p.mu.Unlock()
165+ p.scrollback = scopy.NewScrollback(defaultScrollbackRows)
166+ for _, row := range scrollback {
167+ p.scrollback.Append(row)
168+ }
169+ if len(visible) > 0 {
170+ p.screen = vt.NewScreen(p.screen.Frame.Width, p.screen.Frame.Height)
171+ for y, row := range visible {
172+ if y >= p.screen.Frame.Height {
173+ break
174+ }
175+ for x, cell := range row {
176+ if x >= p.screen.Frame.Width {
177+ break
178+ }
179+ p.screen.Frame.Set(x, y, cell)
180+ }
181+ }
182+ }
183+ p.screen.OnLineEvicted = p.scrollback.Append
184+}
185+
186+func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size {
187+ cols := contentRect.Width
188+ if cols <= 0 {
189+ cols = tabSize.Cols
190+ }
191+ if cols < layout.MinPaneCols {
192+ cols = layout.MinPaneCols
193+ }
194+ rows := contentRect.Height
195+ if rows <= 0 {
196+ rows = min(max(tabSize.Rows, layout.MinPaneRows), 24)
197+ }
198+ if rows < layout.MinPaneRows {
199+ rows = layout.MinPaneRows
200+ }
201+ return domain.Size{Cols: cols, Rows: rows}
202+}
203+
204+func (d *Daemon) captureSession(sess *session) bool {
205+ if d == nil || sess == nil || !d.snapsEnabled || d.snaps == nil {
206+ return true
207+ }
208+
209+ sess.mu.Lock()
210+ name := sess.name
211+ ephemeral := sess.ephemeral
212+ createdAt := sess.createdAt
213+ active := sess.active
214+ fallbackCwd := sess.cwd
215+ tabs := append([]*tab(nil), sess.tabs...)
216+ sess.mu.Unlock()
217+ if ephemeral || name == "" {
218+ return true
219+ }
220+
221+ snap := snapcodec.Session{Name: name, CreatedAt: uint64(createdAt), Active: uint16(max(active, 0))}
222+ for _, tb := range tabs {
223+ tb.mu.Lock()
224+ tabSnap := snapcodec.Tab{
225+ Cols: uint16(max(tb.size.Cols, 0)),
226+ Rows: uint16(max(tb.size.Rows, 0)),
227+ NextPaneID: uint64(max(tb.nextPaneID, 0)),
228+ Tree: tb.tree.Clone(),
229+ }
230+ if tb.tree != nil {
231+ tabSnap.Focus = tb.tree.Focus
232+ }
233+ panes := make([]*pane, 0, len(tb.panes))
234+ for _, p := range tb.panes {
235+ panes = append(panes, p)
236+ }
237+ tb.mu.Unlock()
238+
239+ paneIDs := make(map[layout.PaneID]struct{}, len(panes))
240+ paneSnaps := make([]snapcodec.Pane, 0, len(panes))
241+ for _, p := range panes {
242+ p.mu.Lock()
243+ pid := 0
244+ if p.pty != nil {
245+ pid = p.pty.Pid()
246+ }
247+ paneSnap := snapcodec.Pane{
248+ ID: p.id,
249+ Scrollback: cloneRows(p.scrollback.Snapshot()),
250+ Visible: cloneRows(p.screen.PrimaryVisibleRows()),
251+ }
252+ p.mu.Unlock()
253+ paneSnap.Cwd = fallbackCwd
254+ if d.procCwd != nil && pid > 0 {
255+ if cwd, err := d.procCwd(pid); err == nil && cwd != "" {
256+ paneSnap.Cwd = cwd
257+ }
258+ }
259+ paneIDs[paneSnap.ID] = struct{}{}
260+ paneSnaps = append(paneSnaps, paneSnap)
261+ }
262+ if tabSnap.Tree != nil {
263+ pruneTreeToPanes(tabSnap.Tree.Root, paneIDs)
264+ }
265+ tabSnap.Panes = paneSnaps
266+ snap.Tabs = append(snap.Tabs, tabSnap)
267+ }
268+
269+ data, err := snapcodec.Marshal(snap)
270+ if err != nil {
271+ d.log.Warn("marshaling session snapshot failed", "err", err, "session", name)
272+ return false
273+ }
274+ if err := d.snaps.Write(name, data); err != nil {
275+ d.log.Warn("writing session snapshot failed", "err", err, "session", name)
276+ return false
277+ }
278+ return true
279+}
280+
281+func (d *Daemon) snapshotSaver(ctx context.Context) {
282+ t := d.clock.NewTimer(snapshotInterval)
283+ defer t.Stop()
284+ for {
285+ select {
286+ case <-ctx.Done():
287+ return
288+ case <-t.C():
289+ }
290+
291+ d.mu.Lock()
292+ sessions := d.sessionsSnapshotLocked()
293+ d.mu.Unlock()
294+ for _, sess := range sessions {
295+ sess.mu.Lock()
296+ named := !sess.ephemeral && sess.name != ""
297+ sess.mu.Unlock()
298+ if named && sess.snapDirty.Swap(false) {
299+ if !d.captureSession(sess) {
300+ sess.snapDirty.Store(true)
301+ }
302+ }
303+ }
304+ t.Reset(snapshotInterval)
305+ }
306+}
307+
308+func cloneRows(rows [][]renderer.Cell) [][]renderer.Cell {
309+ if len(rows) == 0 {
310+ return nil
311+ }
312+ out := make([][]renderer.Cell, len(rows))
313+ for i, row := range rows {
314+ out[i] = append([]renderer.Cell(nil), row...)
315+ }
316+ return out
317+}
318+
319+func pruneTreeToPanes(n *layout.Node, panes map[layout.PaneID]struct{}) bool {
320+ if n == nil {
321+ return false
322+ }
323+ if n.Kind == layout.Leaf {
324+ _, ok := panes[n.Leaf]
325+ return ok
326+ }
327+ kept := n.Children[:0]
328+ for _, child := range n.Children {
329+ if pruneTreeToPanes(child, panes) {
330+ kept = append(kept, child)
331+ }
332+ }
333+ n.Children = kept
334+ return len(n.Children) > 0
335+}
A internal/usecase/daemon/snapshot_restore_test.go +246 -0
@@ -0,0 +1,246 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "io"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ scopy "github.com/bnema/vev/internal/usecase/copy"
15+ "github.com/bnema/vev/internal/usecase/layout"
16+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
17+ "github.com/bnema/vev/pkg/renderer"
18+)
19+
20+func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
21+ store := &restoreSnapshotStore{}
22+ store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
23+ Name: "work",
24+ CreatedAt: 99,
25+ Active: 0,
26+ Tabs: []snapcodec.Tab{{
27+ Cols: 80,
28+ Rows: 24,
29+ NextPaneID: 3,
30+ Focus: "pane-2",
31+ Tree: &layout.Tree{Focus: "pane-2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
32+ layout.NewLeaf("pane-1"),
33+ layout.NewLeaf("pane-2"),
34+ }}},
35+ Panes: []snapcodec.Pane{
36+ {ID: "pane-1", Cwd: "/one", Scrollback: [][]renderer.Cell{cells("old1")}, Visible: [][]renderer.Cell{cells("vis1")}},
37+ {ID: "pane-2", Cwd: "/two", Scrollback: [][]renderer.Cell{cells("old2")}, Visible: [][]renderer.Cell{cells("vis2")}},
38+ },
39+ }},
40+ })}}
41+ factory := &restorePTYFactory{}
42+ d := newTestDaemon(t, factory, stubClock{})
43+ WithSnapshotStore(store)(d)
44+
45+ d.restoreSnapshots(context.Background())
46+
47+ require.Len(t, factory.opens, 2)
48+ require.Equal(t, "/one", factory.opens[0].dir)
49+ require.Equal(t, "/two", factory.opens[1].dir)
50+ require.Equal(t, domain.Size{Cols: 40, Rows: 24}, factory.opens[0].size)
51+
52+ d.mu.Lock()
53+ restored := d.findByNameLocked("work")
54+ d.mu.Unlock()
55+ require.NotNil(t, restored)
56+ require.False(t, restored.snapDirty.Load())
57+ require.Equal(t, domain.SessionID("sess-0"), restored.id)
58+ require.Equal(t, int64(99), restored.createdAt)
59+ require.Equal(t, "/one", restored.cwd)
60+
61+ restored.mu.Lock()
62+ require.Len(t, restored.tabs, 1)
63+ tb := restored.tabs[0]
64+ restored.mu.Unlock()
65+ tb.mu.Lock()
66+ require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
67+ require.Equal(t, 3, tb.nextPaneID)
68+ p := tb.panes["pane-2"]
69+ tb.mu.Unlock()
70+ p.mu.Lock()
71+ require.Equal(t, "old2", rowText(p.scrollback.Row(0)))
72+ require.Equal(t, 1, p.scrollback.Len())
73+ require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4]))
74+ copySnap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
75+ require.Equal(t, "old2", rowText(copySnap.Rows[0][:4]))
76+ require.Equal(t, "vis2", rowText(copySnap.Rows[1][:4]))
77+ p.mu.Unlock()
78+}
79+
80+func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) {
81+ store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{
82+ Name: "stacked",
83+ Tabs: []snapcodec.Tab{{
84+ Cols: 20,
85+ Rows: 4,
86+ Tree: &layout.Tree{Focus: "b", Root: &layout.Node{Kind: layout.Stack, Expanded: "b", Children: []*layout.Node{
87+ layout.NewLeaf("a"), layout.NewLeaf("b"), layout.NewLeaf("c"),
88+ }}},
89+ Panes: []snapcodec.Pane{{ID: "a", Cwd: "/a"}, {ID: "b", Cwd: "/b"}, {ID: "c", Cwd: "/c"}},
90+ }},
91+ })}}}
92+ factory := &restorePTYFactory{}
93+ d := newTestDaemon(t, factory, stubClock{})
94+ WithSnapshotStore(store)(d)
95+
96+ d.restoreSnapshots(context.Background())
97+
98+ require.Len(t, factory.opens, 3)
99+ for _, open := range factory.opens {
100+ require.True(t, open.size.Valid(), "open size for %s was %#v", open.dir, open.size)
101+ require.GreaterOrEqual(t, open.size.Cols, layout.MinPaneCols)
102+ require.GreaterOrEqual(t, open.size.Rows, layout.MinPaneRows)
103+ }
104+ d.mu.Lock()
105+ restored := d.findByNameLocked("stacked")
106+ d.mu.Unlock()
107+ require.NotNil(t, restored)
108+ restored.mu.Lock()
109+ tb := restored.tabs[0]
110+ restored.mu.Unlock()
111+ tb.mu.Lock()
112+ require.Equal(t, layout.Stack, tb.tree.Root.Kind)
113+ require.Equal(t, layout.PaneID("b"), tb.tree.Root.Expanded)
114+ require.Len(t, tb.panes, 3)
115+ tb.mu.Unlock()
116+}
117+
118+func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) {
119+ valid := mustSnapshotBytes(t, snapcodec.Session{Name: "live", CreatedAt: 1, Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/ok"}}}}})
120+ tests := []struct {
121+ name string
122+ blobs []ports.SnapshotBlob
123+ liveName string
124+ wantOpen int
125+ }{
126+ {name: "empty store"},
127+ {name: "corrupt blob", blobs: []ports.SnapshotBlob{{Name: "bad", Data: []byte("not a snapshot")}}},
128+ {name: "live name", blobs: []ports.SnapshotBlob{{Name: "live", Data: valid}}, liveName: "live"},
129+ }
130+ for _, tt := range tests {
131+ t.Run(tt.name, func(t *testing.T) {
132+ factory := &restorePTYFactory{}
133+ d := newTestDaemon(t, factory, stubClock{})
134+ WithSnapshotStore(&restoreSnapshotStore{blobs: tt.blobs})(d)
135+ if tt.liveName != "" {
136+ d.sessions["existing"] = &session{id: "existing", name: tt.liveName}
137+ }
138+
139+ require.NotPanics(t, func() { d.restoreSnapshots(context.Background()) })
140+ require.Len(t, factory.opens, tt.wantOpen)
141+ })
142+ }
143+}
144+
145+func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) {
146+ releaseLoad := make(chan struct{})
147+ loaded := make(chan struct{})
148+ store := &restoreSnapshotStore{
149+ loadFn: func() ([]ports.SnapshotBlob, error) {
150+ close(loaded)
151+ <-releaseLoad
152+ return []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{Name: "work", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/restored"}}}}})}}, nil
153+ },
154+ }
155+ factory := &restorePTYFactory{}
156+ d := newTestDaemon(t, factory, stubClock{})
157+ WithSnapshotStore(store)(d)
158+
159+ go d.restoreSnapshots(context.Background())
160+ <-loaded
161+
162+ routed := make(chan *session, 1)
163+ go func() {
164+ tr, _ := newCapturingTransport(t)
165+ sess, _, err := d.route(ports.Hello{Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 26}}, tr)
166+ require.NoError(t, err)
167+ routed <- sess
168+ }()
169+
170+ select {
171+ case <-routed:
172+ t.Fatal("named attach routed before restore barrier closed")
173+ case <-time.After(25 * time.Millisecond):
174+ }
175+ close(releaseLoad)
176+ select {
177+ case sess := <-routed:
178+ require.Equal(t, "work", sess.name)
179+ require.Len(t, factory.opens, 1)
180+ require.Equal(t, "/restored", factory.opens[0].dir)
181+ case <-time.After(time.Second):
182+ t.Fatal("timed out waiting for named attach after restore")
183+ }
184+}
185+
186+func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte {
187+ t.Helper()
188+ b, err := snapcodec.Marshal(s)
189+ require.NoError(t, err)
190+ return b
191+}
192+
193+func cells(s string) []renderer.Cell {
194+ out := make([]renderer.Cell, 0, len(s))
195+ for _, r := range s {
196+ out = append(out, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
197+ }
198+ return out
199+}
200+
201+type restoreSnapshotStore struct {
202+ blobs []ports.SnapshotBlob
203+ loadFn func() ([]ports.SnapshotBlob, error)
204+}
205+
206+func (s *restoreSnapshotStore) Write(string, []byte) error { return nil }
207+func (s *restoreSnapshotStore) Delete(string) error { return nil }
208+func (s *restoreSnapshotStore) Load() ([]ports.SnapshotBlob, error) {
209+ if s.loadFn != nil {
210+ return s.loadFn()
211+ }
212+ return s.blobs, nil
213+}
214+
215+type restorePTYFactory struct {
216+ mu sync.Mutex
217+ opens []restorePTYOpen
218+}
219+
220+type restorePTYOpen struct {
221+ dir string
222+ size domain.Size
223+}
224+
225+func (f *restorePTYFactory) Open(_ string, _ []string, _ []string, dir string, sz domain.Size) (ports.PTY, error) {
226+ f.mu.Lock()
227+ defer f.mu.Unlock()
228+ f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz})
229+ return newRestorePTY(), nil
230+}
231+
232+type restorePTY struct {
233+ done chan struct{}
234+ once sync.Once
235+}
236+
237+func newRestorePTY() *restorePTY { return &restorePTY{done: make(chan struct{})} }
238+func (p *restorePTY) Read([]byte) (int, error) {
239+ <-p.done
240+ return 0, io.EOF
241+}
242+func (p *restorePTY) Write(b []byte) (int, error) { return len(b), nil }
243+func (p *restorePTY) Close() error { p.once.Do(func() { close(p.done) }); return nil }
244+func (p *restorePTY) Resize(domain.Size) error { return nil }
245+func (p *restorePTY) Pid() int { return 0 }
246+func (p *restorePTY) ForegroundPgid() (int, error) { return 0, nil }
@@ -0,0 +1,246 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "io"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ scopy "github.com/bnema/vev/internal/usecase/copy"
15+ "github.com/bnema/vev/internal/usecase/layout"
16+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
17+ "github.com/bnema/vev/pkg/renderer"
18+)
19+
20+func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
21+ store := &restoreSnapshotStore{}
22+ store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
23+ Name: "work",
24+ CreatedAt: 99,
25+ Active: 0,
26+ Tabs: []snapcodec.Tab{{
27+ Cols: 80,
28+ Rows: 24,
29+ NextPaneID: 3,
30+ Focus: "pane-2",
31+ Tree: &layout.Tree{Focus: "pane-2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
32+ layout.NewLeaf("pane-1"),
33+ layout.NewLeaf("pane-2"),
34+ }}},
35+ Panes: []snapcodec.Pane{
36+ {ID: "pane-1", Cwd: "/one", Scrollback: [][]renderer.Cell{cells("old1")}, Visible: [][]renderer.Cell{cells("vis1")}},
37+ {ID: "pane-2", Cwd: "/two", Scrollback: [][]renderer.Cell{cells("old2")}, Visible: [][]renderer.Cell{cells("vis2")}},
38+ },
39+ }},
40+ })}}
41+ factory := &restorePTYFactory{}
42+ d := newTestDaemon(t, factory, stubClock{})
43+ WithSnapshotStore(store)(d)
44+
45+ d.restoreSnapshots(context.Background())
46+
47+ require.Len(t, factory.opens, 2)
48+ require.Equal(t, "/one", factory.opens[0].dir)
49+ require.Equal(t, "/two", factory.opens[1].dir)
50+ require.Equal(t, domain.Size{Cols: 40, Rows: 24}, factory.opens[0].size)
51+
52+ d.mu.Lock()
53+ restored := d.findByNameLocked("work")
54+ d.mu.Unlock()
55+ require.NotNil(t, restored)
56+ require.False(t, restored.snapDirty.Load())
57+ require.Equal(t, domain.SessionID("sess-0"), restored.id)
58+ require.Equal(t, int64(99), restored.createdAt)
59+ require.Equal(t, "/one", restored.cwd)
60+
61+ restored.mu.Lock()
62+ require.Len(t, restored.tabs, 1)
63+ tb := restored.tabs[0]
64+ restored.mu.Unlock()
65+ tb.mu.Lock()
66+ require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
67+ require.Equal(t, 3, tb.nextPaneID)
68+ p := tb.panes["pane-2"]
69+ tb.mu.Unlock()
70+ p.mu.Lock()
71+ require.Equal(t, "old2", rowText(p.scrollback.Row(0)))
72+ require.Equal(t, 1, p.scrollback.Len())
73+ require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4]))
74+ copySnap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
75+ require.Equal(t, "old2", rowText(copySnap.Rows[0][:4]))
76+ require.Equal(t, "vis2", rowText(copySnap.Rows[1][:4]))
77+ p.mu.Unlock()
78+}
79+
80+func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) {
81+ store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{
82+ Name: "stacked",
83+ Tabs: []snapcodec.Tab{{
84+ Cols: 20,
85+ Rows: 4,
86+ Tree: &layout.Tree{Focus: "b", Root: &layout.Node{Kind: layout.Stack, Expanded: "b", Children: []*layout.Node{
87+ layout.NewLeaf("a"), layout.NewLeaf("b"), layout.NewLeaf("c"),
88+ }}},
89+ Panes: []snapcodec.Pane{{ID: "a", Cwd: "/a"}, {ID: "b", Cwd: "/b"}, {ID: "c", Cwd: "/c"}},
90+ }},
91+ })}}}
92+ factory := &restorePTYFactory{}
93+ d := newTestDaemon(t, factory, stubClock{})
94+ WithSnapshotStore(store)(d)
95+
96+ d.restoreSnapshots(context.Background())
97+
98+ require.Len(t, factory.opens, 3)
99+ for _, open := range factory.opens {
100+ require.True(t, open.size.Valid(), "open size for %s was %#v", open.dir, open.size)
101+ require.GreaterOrEqual(t, open.size.Cols, layout.MinPaneCols)
102+ require.GreaterOrEqual(t, open.size.Rows, layout.MinPaneRows)
103+ }
104+ d.mu.Lock()
105+ restored := d.findByNameLocked("stacked")
106+ d.mu.Unlock()
107+ require.NotNil(t, restored)
108+ restored.mu.Lock()
109+ tb := restored.tabs[0]
110+ restored.mu.Unlock()
111+ tb.mu.Lock()
112+ require.Equal(t, layout.Stack, tb.tree.Root.Kind)
113+ require.Equal(t, layout.PaneID("b"), tb.tree.Root.Expanded)
114+ require.Len(t, tb.panes, 3)
115+ tb.mu.Unlock()
116+}
117+
118+func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) {
119+ valid := mustSnapshotBytes(t, snapcodec.Session{Name: "live", CreatedAt: 1, Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/ok"}}}}})
120+ tests := []struct {
121+ name string
122+ blobs []ports.SnapshotBlob
123+ liveName string
124+ wantOpen int
125+ }{
126+ {name: "empty store"},
127+ {name: "corrupt blob", blobs: []ports.SnapshotBlob{{Name: "bad", Data: []byte("not a snapshot")}}},
128+ {name: "live name", blobs: []ports.SnapshotBlob{{Name: "live", Data: valid}}, liveName: "live"},
129+ }
130+ for _, tt := range tests {
131+ t.Run(tt.name, func(t *testing.T) {
132+ factory := &restorePTYFactory{}
133+ d := newTestDaemon(t, factory, stubClock{})
134+ WithSnapshotStore(&restoreSnapshotStore{blobs: tt.blobs})(d)
135+ if tt.liveName != "" {
136+ d.sessions["existing"] = &session{id: "existing", name: tt.liveName}
137+ }
138+
139+ require.NotPanics(t, func() { d.restoreSnapshots(context.Background()) })
140+ require.Len(t, factory.opens, tt.wantOpen)
141+ })
142+ }
143+}
144+
145+func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) {
146+ releaseLoad := make(chan struct{})
147+ loaded := make(chan struct{})
148+ store := &restoreSnapshotStore{
149+ loadFn: func() ([]ports.SnapshotBlob, error) {
150+ close(loaded)
151+ <-releaseLoad
152+ return []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{Name: "work", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/restored"}}}}})}}, nil
153+ },
154+ }
155+ factory := &restorePTYFactory{}
156+ d := newTestDaemon(t, factory, stubClock{})
157+ WithSnapshotStore(store)(d)
158+
159+ go d.restoreSnapshots(context.Background())
160+ <-loaded
161+
162+ routed := make(chan *session, 1)
163+ go func() {
164+ tr, _ := newCapturingTransport(t)
165+ sess, _, err := d.route(ports.Hello{Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 26}}, tr)
166+ require.NoError(t, err)
167+ routed <- sess
168+ }()
169+
170+ select {
171+ case <-routed:
172+ t.Fatal("named attach routed before restore barrier closed")
173+ case <-time.After(25 * time.Millisecond):
174+ }
175+ close(releaseLoad)
176+ select {
177+ case sess := <-routed:
178+ require.Equal(t, "work", sess.name)
179+ require.Len(t, factory.opens, 1)
180+ require.Equal(t, "/restored", factory.opens[0].dir)
181+ case <-time.After(time.Second):
182+ t.Fatal("timed out waiting for named attach after restore")
183+ }
184+}
185+
186+func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte {
187+ t.Helper()
188+ b, err := snapcodec.Marshal(s)
189+ require.NoError(t, err)
190+ return b
191+}
192+
193+func cells(s string) []renderer.Cell {
194+ out := make([]renderer.Cell, 0, len(s))
195+ for _, r := range s {
196+ out = append(out, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
197+ }
198+ return out
199+}
200+
201+type restoreSnapshotStore struct {
202+ blobs []ports.SnapshotBlob
203+ loadFn func() ([]ports.SnapshotBlob, error)
204+}
205+
206+func (s *restoreSnapshotStore) Write(string, []byte) error { return nil }
207+func (s *restoreSnapshotStore) Delete(string) error { return nil }
208+func (s *restoreSnapshotStore) Load() ([]ports.SnapshotBlob, error) {
209+ if s.loadFn != nil {
210+ return s.loadFn()
211+ }
212+ return s.blobs, nil
213+}
214+
215+type restorePTYFactory struct {
216+ mu sync.Mutex
217+ opens []restorePTYOpen
218+}
219+
220+type restorePTYOpen struct {
221+ dir string
222+ size domain.Size
223+}
224+
225+func (f *restorePTYFactory) Open(_ string, _ []string, _ []string, dir string, sz domain.Size) (ports.PTY, error) {
226+ f.mu.Lock()
227+ defer f.mu.Unlock()
228+ f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz})
229+ return newRestorePTY(), nil
230+}
231+
232+type restorePTY struct {
233+ done chan struct{}
234+ once sync.Once
235+}
236+
237+func newRestorePTY() *restorePTY { return &restorePTY{done: make(chan struct{})} }
238+func (p *restorePTY) Read([]byte) (int, error) {
239+ <-p.done
240+ return 0, io.EOF
241+}
242+func (p *restorePTY) Write(b []byte) (int, error) { return len(b), nil }
243+func (p *restorePTY) Close() error { p.once.Do(func() { close(p.done) }); return nil }
244+func (p *restorePTY) Resize(domain.Size) error { return nil }
245+func (p *restorePTY) Pid() int { return 0 }
246+func (p *restorePTY) ForegroundPgid() (int, error) { return 0, nil }
A internal/usecase/daemon/snapshot_test.go +357 -0
@@ -0,0 +1,357 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "sync"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/ports"
17+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
18+ "github.com/bnema/vev/internal/usecase/layout"
19+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
20+ "github.com/bnema/vev/pkg/renderer"
21+)
22+
23+func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) {
24+ clock := newManualSnapshotClock()
25+ store := portsmocks.NewMockSnapshotStore(t)
26+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
27+ WithSnapshotStore(store)(d)
28+ d.procCwd = func(int) (string, error) { return "/pane", nil }
29+
30+ dirty := newSnapshotTestSession(t, "dirty", false, "/fallback")
31+ clean := newSnapshotTestSession(t, "clean", false, "/fallback")
32+ ephemeral := newSnapshotTestSession(t, "0", true, "/fallback")
33+ dirty.snapDirty.Store(true)
34+ ephemeral.snapDirty.Store(true)
35+ d.sessions[dirty.id] = dirty
36+ d.sessions[clean.id] = clean
37+ d.sessions[ephemeral.id] = ephemeral
38+
39+ wrote := make(chan struct{}, 1)
40+ store.EXPECT().Write("dirty", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
41+ snap, err := snapcodec.Unmarshal(data)
42+ require.NoError(t, err)
43+ require.Equal(t, "dirty", snap.Name)
44+ wrote <- struct{}{}
45+ return nil
46+ }).Once()
47+
48+ ctx, cancel := context.WithCancel(t.Context())
49+ defer cancel()
50+ go d.snapshotSaver(ctx)
51+ clock.fireNext(t)
52+ select {
53+ case <-wrote:
54+ case <-time.After(time.Second):
55+ t.Fatal("timed out waiting for dirty snapshot write")
56+ }
57+
58+ clock.fireNext(t)
59+ select {
60+ case <-wrote:
61+ t.Fatal("clean second interval wrote unexpectedly")
62+ case <-time.After(25 * time.Millisecond):
63+ }
64+}
65+
66+func TestKillSessionSnapshotsNamedSessionBeforeClosingPanes(t *testing.T) {
67+ store := portsmocks.NewMockSnapshotStore(t)
68+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
69+ WithSnapshotStore(store)(d)
70+ d.procCwd = func(int) (string, error) { return "/live", nil }
71+
72+ sess := newSnapshotTestSession(t, "work", false, "/fallback")
73+ d.sessions[sess.id] = sess
74+
75+ var wrote atomic.Bool
76+ store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
77+ snap, err := snapcodec.Unmarshal(data)
78+ require.NoError(t, err)
79+ require.Equal(t, "work", snap.Name)
80+ require.Equal(t, "/live", snap.Tabs[0].Panes[0].Cwd)
81+ wrote.Store(true)
82+ return nil
83+ }).Once()
84+ mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
85+ require.True(t, ok)
86+ mockPTY.EXPECT().Close().RunAndReturn(func() error {
87+ require.True(t, wrote.Load(), "snapshot Write must happen before pane Close")
88+ return nil
89+ }).Once()
90+
91+ require.NoError(t, d.killSession(sess, 0, false))
92+ require.True(t, wrote.Load())
93+}
94+
95+func TestPTYReaderSynchronizedUpdateFlushMarksSnapshotDirty(t *testing.T) {
96+ d := newTestDaemon(t, nil, stubClock{})
97+ p := portsmocks.NewMockPTY(t)
98+ chunks := [][]byte{[]byte("\x1b[?2026hhello\x1b[?2026l")}
99+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
100+ if len(chunks) == 0 {
101+ return 0, io.EOF
102+ }
103+ n := copy(buf, chunks[0])
104+ chunks = chunks[1:]
105+ return n, nil
106+ })
107+ p.EXPECT().Close().Return(nil).Maybe()
108+
109+ sctx, cancel := context.WithCancel(context.Background())
110+ defer cancel()
111+ tb := newTestTabWithContext(p, sctx, cancel)
112+ sess := &session{id: "sync", name: "sync", tabs: []*tab{tb}, ctx: sctx, cancel: cancel}
113+ d.sessions[sess.id] = sess
114+ d.sessWg.Add(1)
115+ d.ptyReader(sess, tb, tb.focusedPane())
116+
117+ require.True(t, sess.snapDirty.Load())
118+}
119+
120+func TestSnapshotSaverKeepsDirtyWhenWriteFails(t *testing.T) {
121+ clock := newManualSnapshotClock()
122+ store := portsmocks.NewMockSnapshotStore(t)
123+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
124+ WithSnapshotStore(store)(d)
125+
126+ sess := newSnapshotTestSession(t, "retry", false, "/fallback")
127+ sess.snapDirty.Store(true)
128+ d.sessions[sess.id] = sess
129+
130+ store.EXPECT().Write("retry", mock.Anything).Return(errors.New("boom")).Once()
131+ wrote := make(chan struct{}, 1)
132+ store.EXPECT().Write("retry", mock.Anything).RunAndReturn(func(string, []byte) error {
133+ wrote <- struct{}{}
134+ return nil
135+ }).Once()
136+
137+ ctx, cancel := context.WithCancel(t.Context())
138+ defer cancel()
139+ go d.snapshotSaver(ctx)
140+ clock.fireNext(t)
141+ require.Eventually(t, func() bool { return sess.snapDirty.Load() }, time.Second, time.Millisecond)
142+ clock.fireNext(t)
143+ select {
144+ case <-wrote:
145+ case <-time.After(time.Second):
146+ t.Fatal("timed out waiting for retry snapshot write")
147+ }
148+ require.False(t, sess.snapDirty.Load())
149+}
150+
151+func TestKillSessionPurgeDeletesSnapshot(t *testing.T) {
152+ store := portsmocks.NewMockSnapshotStore(t)
153+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
154+ WithSnapshotStore(store)(d)
155+
156+ sess := newSnapshotTestSession(t, "work", false, "/fallback")
157+ d.sessions[sess.id] = sess
158+
159+ store.EXPECT().Delete("work").Return(nil).Once()
160+ mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
161+ require.True(t, ok)
162+ mockPTY.EXPECT().Close().Return(nil).Once()
163+
164+ require.NoError(t, d.killSession(sess, 0, true))
165+}
166+
167+func TestKillSessionPurgeReturnsSnapshotDeleteError(t *testing.T) {
168+ store := portsmocks.NewMockSnapshotStore(t)
169+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
170+ WithSnapshotStore(store)(d)
171+
172+ sess := newSnapshotTestSession(t, "work", false, "/fallback")
173+ d.sessions[sess.id] = sess
174+
175+ store.EXPECT().Delete("work").Return(errors.New("delete failed")).Once()
176+ err := d.killSession(sess, 0, true)
177+ require.ErrorContains(t, err, "delete failed")
178+ require.Contains(t, d.sessions, sess.id)
179+}
180+
181+func TestRefreshSessionCwdMarksSnapshotDirty(t *testing.T) {
182+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
183+ d.procCwd = func(int) (string, error) { return "/new", nil }
184+ sess := newSnapshotTestSession(t, "work", false, "/old")
185+ d.sessions[sess.id] = sess
186+
187+ d.refreshSessionCwd(sess)
188+ require.Equal(t, "/new", sess.cwd)
189+ require.True(t, sess.snapDirty.Load())
190+}
191+
192+func TestStoppedSessionKillPurgesSnapshot(t *testing.T) {
193+ store := portsmocks.NewMockSnapshotStore(t)
194+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
195+ WithSnapshotStore(store)(d)
196+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/work", createdAt: 1}
197+
198+ store.EXPECT().Delete("work").Return(nil).Once()
199+ tr, _, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})})
200+
201+ d.handleKill(tr, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})})
202+
203+ require.NotContains(t, d.stopped, "work")
204+}
205+
206+func TestStoppedSessionKillSnapshotDeleteFailureKeepsMetadata(t *testing.T) {
207+ store := portsmocks.NewMockSnapshotStore(t)
208+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
209+ WithSnapshotStore(store)(d)
210+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/work", createdAt: 1}
211+
212+ store.EXPECT().Delete("work").Return(errors.New("delete failed")).Once()
213+ tr, sends, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})})
214+
215+ d.handleKill(tr, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})})
216+
217+ require.Contains(t, d.stopped, "work")
218+ frame := awaitFrame(t, sends, ports.MsgError)
219+ msg, err := ports.UnmarshalErrorMsg(frame.Payload)
220+ require.NoError(t, err)
221+ require.Equal(t, ports.ErrInternal, msg.Code)
222+}
223+
224+func TestRenameSessionDeletesOldSnapshotAndMarksDirty(t *testing.T) {
225+ store := portsmocks.NewMockSnapshotStore(t)
226+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
227+ WithSnapshotStore(store)(d)
228+ sess := newSnapshotTestSession(t, "old", false, "/fallback")
229+ d.sessions[sess.id] = sess
230+
231+ store.EXPECT().Delete("old").Return(nil).Once()
232+ require.NoError(t, d.renameSession(sess, "new"))
233+
234+ require.True(t, sess.snapDirty.Load())
235+ require.Nil(t, d.findByNameLocked("old"))
236+}
237+
238+func TestSnapshotSaverCapturesLayoutOnlyDirtySave(t *testing.T) {
239+ clock := newManualSnapshotClock()
240+ store := portsmocks.NewMockSnapshotStore(t)
241+ factory := portsmocks.NewMockPTYFactory(t)
242+ d := newTestDaemon(t, factory, clock)
243+ WithSnapshotStore(store)(d)
244+
245+ sess := newSnapshotTestSession(t, "work", false, "/work")
246+ tctx, tcancel := context.WithCancel(context.Background())
247+ t.Cleanup(tcancel)
248+ sess.ctx = tctx
249+ sess.cancel = tcancel
250+ sess.tabs[0].ctx = tctx
251+ sess.tabs[0].cancel = tcancel
252+ for _, p := range sess.tabs[0].panes {
253+ p.ctx = tctx
254+ p.cancel = tcancel
255+ }
256+ sess.tabs[0].size = domain.Size{Cols: 41, Rows: 10}
257+ d.sessions[sess.id] = sess
258+ newPTY := portsmocks.NewMockPTY(t)
259+ newPTY.EXPECT().Pid().Return(2345).Maybe()
260+ newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
261+ newPTY.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe()
262+ newPTY.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
263+ newPTY.EXPECT().ForegroundPgid().Return(0, nil).Maybe()
264+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()
265+
266+ require.NoError(t, d.splitPane(sess, nil, layout.Right))
267+ require.True(t, sess.snapDirty.Load())
268+
269+ wrote := make(chan struct{}, 1)
270+ store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
271+ snap, err := snapcodec.Unmarshal(data)
272+ require.NoError(t, err)
273+ require.Len(t, snap.Tabs, 1)
274+ require.Len(t, snap.Tabs[0].Panes, 2)
275+ wrote <- struct{}{}
276+ return nil
277+ }).Once()
278+
279+ ctx, cancel := context.WithCancel(t.Context())
280+ defer cancel()
281+ go d.snapshotSaver(ctx)
282+ clock.fireNext(t)
283+ select {
284+ case <-wrote:
285+ case <-time.After(time.Second):
286+ t.Fatal("timed out waiting for layout-only dirty snapshot write")
287+ }
288+}
289+
290+func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd string) *session {
291+ t.Helper()
292+ pty := portsmocks.NewMockPTY(t)
293+ pty.EXPECT().Pid().Return(1234).Maybe()
294+ pty.EXPECT().Read(mock.Anything).Return(0, errors.New("unused")).Maybe()
295+ pty.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe()
296+ pty.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
297+ pty.EXPECT().ForegroundPgid().Return(0, nil).Maybe()
298+
299+ tb := newTab(pty, domain.Size{Cols: 8, Rows: 3})
300+ p := tb.panes["pane-1"]
301+ p.screen.Write([]byte("hello"))
302+ p.scrollback.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})
303+ return &session{
304+ id: domain.SessionID("sess-" + name),
305+ name: name,
306+ ephemeral: ephemeral,
307+ ctx: context.Background(),
308+ cancel: func() {},
309+ tabs: []*tab{tb},
310+ active: 0,
311+ cwd: cwd,
312+ createdAt: 42,
313+ }
314+}
315+
316+type manualSnapshotClock struct {
317+ mu sync.Mutex
318+ timers []*manualSnapshotTimer
319+}
320+
321+func newManualSnapshotClock() *manualSnapshotClock { return &manualSnapshotClock{} }
322+
323+func (c *manualSnapshotClock) Now() time.Time { return time.Unix(0, 0) }
324+
325+func (c *manualSnapshotClock) NewTimer(time.Duration) ports.Timer {
326+ t := &manualSnapshotTimer{ch: make(chan time.Time, 1)}
327+ c.mu.Lock()
328+ c.timers = append(c.timers, t)
329+ c.mu.Unlock()
330+ return t
331+}
332+
333+func (c *manualSnapshotClock) fireNext(t *testing.T) {
334+ t.Helper()
335+ deadline := time.After(time.Second)
336+ for {
337+ c.mu.Lock()
338+ if len(c.timers) > 0 {
339+ timer := c.timers[0]
340+ c.mu.Unlock()
341+ timer.ch <- time.Unix(0, 0)
342+ return
343+ }
344+ c.mu.Unlock()
345+ select {
346+ case <-deadline:
347+ t.Fatal("timed out waiting for snapshot timer")
348+ case <-time.After(time.Millisecond):
349+ }
350+ }
351+}
352+
353+type manualSnapshotTimer struct{ ch chan time.Time }
354+
355+func (t *manualSnapshotTimer) C() <-chan time.Time { return t.ch }
356+func (t *manualSnapshotTimer) Reset(time.Duration) bool { return true }
357+func (t *manualSnapshotTimer) Stop() bool { return true }
@@ -0,0 +1,357 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "sync"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/ports"
17+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
18+ "github.com/bnema/vev/internal/usecase/layout"
19+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
20+ "github.com/bnema/vev/pkg/renderer"
21+)
22+
23+func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) {
24+ clock := newManualSnapshotClock()
25+ store := portsmocks.NewMockSnapshotStore(t)
26+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
27+ WithSnapshotStore(store)(d)
28+ d.procCwd = func(int) (string, error) { return "/pane", nil }
29+
30+ dirty := newSnapshotTestSession(t, "dirty", false, "/fallback")
31+ clean := newSnapshotTestSession(t, "clean", false, "/fallback")
32+ ephemeral := newSnapshotTestSession(t, "0", true, "/fallback")
33+ dirty.snapDirty.Store(true)
34+ ephemeral.snapDirty.Store(true)
35+ d.sessions[dirty.id] = dirty
36+ d.sessions[clean.id] = clean
37+ d.sessions[ephemeral.id] = ephemeral
38+
39+ wrote := make(chan struct{}, 1)
40+ store.EXPECT().Write("dirty", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
41+ snap, err := snapcodec.Unmarshal(data)
42+ require.NoError(t, err)
43+ require.Equal(t, "dirty", snap.Name)
44+ wrote <- struct{}{}
45+ return nil
46+ }).Once()
47+
48+ ctx, cancel := context.WithCancel(t.Context())
49+ defer cancel()
50+ go d.snapshotSaver(ctx)
51+ clock.fireNext(t)
52+ select {
53+ case <-wrote:
54+ case <-time.After(time.Second):
55+ t.Fatal("timed out waiting for dirty snapshot write")
56+ }
57+
58+ clock.fireNext(t)
59+ select {
60+ case <-wrote:
61+ t.Fatal("clean second interval wrote unexpectedly")
62+ case <-time.After(25 * time.Millisecond):
63+ }
64+}
65+
66+func TestKillSessionSnapshotsNamedSessionBeforeClosingPanes(t *testing.T) {
67+ store := portsmocks.NewMockSnapshotStore(t)
68+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
69+ WithSnapshotStore(store)(d)
70+ d.procCwd = func(int) (string, error) { return "/live", nil }
71+
72+ sess := newSnapshotTestSession(t, "work", false, "/fallback")
73+ d.sessions[sess.id] = sess
74+
75+ var wrote atomic.Bool
76+ store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
77+ snap, err := snapcodec.Unmarshal(data)
78+ require.NoError(t, err)
79+ require.Equal(t, "work", snap.Name)
80+ require.Equal(t, "/live", snap.Tabs[0].Panes[0].Cwd)
81+ wrote.Store(true)
82+ return nil
83+ }).Once()
84+ mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
85+ require.True(t, ok)
86+ mockPTY.EXPECT().Close().RunAndReturn(func() error {
87+ require.True(t, wrote.Load(), "snapshot Write must happen before pane Close")
88+ return nil
89+ }).Once()
90+
91+ require.NoError(t, d.killSession(sess, 0, false))
92+ require.True(t, wrote.Load())
93+}
94+
95+func TestPTYReaderSynchronizedUpdateFlushMarksSnapshotDirty(t *testing.T) {
96+ d := newTestDaemon(t, nil, stubClock{})
97+ p := portsmocks.NewMockPTY(t)
98+ chunks := [][]byte{[]byte("\x1b[?2026hhello\x1b[?2026l")}
99+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
100+ if len(chunks) == 0 {
101+ return 0, io.EOF
102+ }
103+ n := copy(buf, chunks[0])
104+ chunks = chunks[1:]
105+ return n, nil
106+ })
107+ p.EXPECT().Close().Return(nil).Maybe()
108+
109+ sctx, cancel := context.WithCancel(context.Background())
110+ defer cancel()
111+ tb := newTestTabWithContext(p, sctx, cancel)
112+ sess := &session{id: "sync", name: "sync", tabs: []*tab{tb}, ctx: sctx, cancel: cancel}
113+ d.sessions[sess.id] = sess
114+ d.sessWg.Add(1)
115+ d.ptyReader(sess, tb, tb.focusedPane())
116+
117+ require.True(t, sess.snapDirty.Load())
118+}
119+
120+func TestSnapshotSaverKeepsDirtyWhenWriteFails(t *testing.T) {
121+ clock := newManualSnapshotClock()
122+ store := portsmocks.NewMockSnapshotStore(t)
123+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
124+ WithSnapshotStore(store)(d)
125+
126+ sess := newSnapshotTestSession(t, "retry", false, "/fallback")
127+ sess.snapDirty.Store(true)
128+ d.sessions[sess.id] = sess
129+
130+ store.EXPECT().Write("retry", mock.Anything).Return(errors.New("boom")).Once()
131+ wrote := make(chan struct{}, 1)
132+ store.EXPECT().Write("retry", mock.Anything).RunAndReturn(func(string, []byte) error {
133+ wrote <- struct{}{}
134+ return nil
135+ }).Once()
136+
137+ ctx, cancel := context.WithCancel(t.Context())
138+ defer cancel()
139+ go d.snapshotSaver(ctx)
140+ clock.fireNext(t)
141+ require.Eventually(t, func() bool { return sess.snapDirty.Load() }, time.Second, time.Millisecond)
142+ clock.fireNext(t)
143+ select {
144+ case <-wrote:
145+ case <-time.After(time.Second):
146+ t.Fatal("timed out waiting for retry snapshot write")
147+ }
148+ require.False(t, sess.snapDirty.Load())
149+}
150+
151+func TestKillSessionPurgeDeletesSnapshot(t *testing.T) {
152+ store := portsmocks.NewMockSnapshotStore(t)
153+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
154+ WithSnapshotStore(store)(d)
155+
156+ sess := newSnapshotTestSession(t, "work", false, "/fallback")
157+ d.sessions[sess.id] = sess
158+
159+ store.EXPECT().Delete("work").Return(nil).Once()
160+ mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
161+ require.True(t, ok)
162+ mockPTY.EXPECT().Close().Return(nil).Once()
163+
164+ require.NoError(t, d.killSession(sess, 0, true))
165+}
166+
167+func TestKillSessionPurgeReturnsSnapshotDeleteError(t *testing.T) {
168+ store := portsmocks.NewMockSnapshotStore(t)
169+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
170+ WithSnapshotStore(store)(d)
171+
172+ sess := newSnapshotTestSession(t, "work", false, "/fallback")
173+ d.sessions[sess.id] = sess
174+
175+ store.EXPECT().Delete("work").Return(errors.New("delete failed")).Once()
176+ err := d.killSession(sess, 0, true)
177+ require.ErrorContains(t, err, "delete failed")
178+ require.Contains(t, d.sessions, sess.id)
179+}
180+
181+func TestRefreshSessionCwdMarksSnapshotDirty(t *testing.T) {
182+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
183+ d.procCwd = func(int) (string, error) { return "/new", nil }
184+ sess := newSnapshotTestSession(t, "work", false, "/old")
185+ d.sessions[sess.id] = sess
186+
187+ d.refreshSessionCwd(sess)
188+ require.Equal(t, "/new", sess.cwd)
189+ require.True(t, sess.snapDirty.Load())
190+}
191+
192+func TestStoppedSessionKillPurgesSnapshot(t *testing.T) {
193+ store := portsmocks.NewMockSnapshotStore(t)
194+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
195+ WithSnapshotStore(store)(d)
196+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/work", createdAt: 1}
197+
198+ store.EXPECT().Delete("work").Return(nil).Once()
199+ tr, _, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})})
200+
201+ d.handleKill(tr, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})})
202+
203+ require.NotContains(t, d.stopped, "work")
204+}
205+
206+func TestStoppedSessionKillSnapshotDeleteFailureKeepsMetadata(t *testing.T) {
207+ store := portsmocks.NewMockSnapshotStore(t)
208+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
209+ WithSnapshotStore(store)(d)
210+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/work", createdAt: 1}
211+
212+ store.EXPECT().Delete("work").Return(errors.New("delete failed")).Once()
213+ tr, sends, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})})
214+
215+ d.handleKill(tr, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})})
216+
217+ require.Contains(t, d.stopped, "work")
218+ frame := awaitFrame(t, sends, ports.MsgError)
219+ msg, err := ports.UnmarshalErrorMsg(frame.Payload)
220+ require.NoError(t, err)
221+ require.Equal(t, ports.ErrInternal, msg.Code)
222+}
223+
224+func TestRenameSessionDeletesOldSnapshotAndMarksDirty(t *testing.T) {
225+ store := portsmocks.NewMockSnapshotStore(t)
226+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
227+ WithSnapshotStore(store)(d)
228+ sess := newSnapshotTestSession(t, "old", false, "/fallback")
229+ d.sessions[sess.id] = sess
230+
231+ store.EXPECT().Delete("old").Return(nil).Once()
232+ require.NoError(t, d.renameSession(sess, "new"))
233+
234+ require.True(t, sess.snapDirty.Load())
235+ require.Nil(t, d.findByNameLocked("old"))
236+}
237+
238+func TestSnapshotSaverCapturesLayoutOnlyDirtySave(t *testing.T) {
239+ clock := newManualSnapshotClock()
240+ store := portsmocks.NewMockSnapshotStore(t)
241+ factory := portsmocks.NewMockPTYFactory(t)
242+ d := newTestDaemon(t, factory, clock)
243+ WithSnapshotStore(store)(d)
244+
245+ sess := newSnapshotTestSession(t, "work", false, "/work")
246+ tctx, tcancel := context.WithCancel(context.Background())
247+ t.Cleanup(tcancel)
248+ sess.ctx = tctx
249+ sess.cancel = tcancel
250+ sess.tabs[0].ctx = tctx
251+ sess.tabs[0].cancel = tcancel
252+ for _, p := range sess.tabs[0].panes {
253+ p.ctx = tctx
254+ p.cancel = tcancel
255+ }
256+ sess.tabs[0].size = domain.Size{Cols: 41, Rows: 10}
257+ d.sessions[sess.id] = sess
258+ newPTY := portsmocks.NewMockPTY(t)
259+ newPTY.EXPECT().Pid().Return(2345).Maybe()
260+ newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
261+ newPTY.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe()
262+ newPTY.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
263+ newPTY.EXPECT().ForegroundPgid().Return(0, nil).Maybe()
264+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()
265+
266+ require.NoError(t, d.splitPane(sess, nil, layout.Right))
267+ require.True(t, sess.snapDirty.Load())
268+
269+ wrote := make(chan struct{}, 1)
270+ store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
271+ snap, err := snapcodec.Unmarshal(data)
272+ require.NoError(t, err)
273+ require.Len(t, snap.Tabs, 1)
274+ require.Len(t, snap.Tabs[0].Panes, 2)
275+ wrote <- struct{}{}
276+ return nil
277+ }).Once()
278+
279+ ctx, cancel := context.WithCancel(t.Context())
280+ defer cancel()
281+ go d.snapshotSaver(ctx)
282+ clock.fireNext(t)
283+ select {
284+ case <-wrote:
285+ case <-time.After(time.Second):
286+ t.Fatal("timed out waiting for layout-only dirty snapshot write")
287+ }
288+}
289+
290+func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd string) *session {
291+ t.Helper()
292+ pty := portsmocks.NewMockPTY(t)
293+ pty.EXPECT().Pid().Return(1234).Maybe()
294+ pty.EXPECT().Read(mock.Anything).Return(0, errors.New("unused")).Maybe()
295+ pty.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe()
296+ pty.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
297+ pty.EXPECT().ForegroundPgid().Return(0, nil).Maybe()
298+
299+ tb := newTab(pty, domain.Size{Cols: 8, Rows: 3})
300+ p := tb.panes["pane-1"]
301+ p.screen.Write([]byte("hello"))
302+ p.scrollback.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})
303+ return &session{
304+ id: domain.SessionID("sess-" + name),
305+ name: name,
306+ ephemeral: ephemeral,
307+ ctx: context.Background(),
308+ cancel: func() {},
309+ tabs: []*tab{tb},
310+ active: 0,
311+ cwd: cwd,
312+ createdAt: 42,
313+ }
314+}
315+
316+type manualSnapshotClock struct {
317+ mu sync.Mutex
318+ timers []*manualSnapshotTimer
319+}
320+
321+func newManualSnapshotClock() *manualSnapshotClock { return &manualSnapshotClock{} }
322+
323+func (c *manualSnapshotClock) Now() time.Time { return time.Unix(0, 0) }
324+
325+func (c *manualSnapshotClock) NewTimer(time.Duration) ports.Timer {
326+ t := &manualSnapshotTimer{ch: make(chan time.Time, 1)}
327+ c.mu.Lock()
328+ c.timers = append(c.timers, t)
329+ c.mu.Unlock()
330+ return t
331+}
332+
333+func (c *manualSnapshotClock) fireNext(t *testing.T) {
334+ t.Helper()
335+ deadline := time.After(time.Second)
336+ for {
337+ c.mu.Lock()
338+ if len(c.timers) > 0 {
339+ timer := c.timers[0]
340+ c.mu.Unlock()
341+ timer.ch <- time.Unix(0, 0)
342+ return
343+ }
344+ c.mu.Unlock()
345+ select {
346+ case <-deadline:
347+ t.Fatal("timed out waiting for snapshot timer")
348+ case <-time.After(time.Millisecond):
349+ }
350+ }
351+}
352+
353+type manualSnapshotTimer struct{ ch chan time.Time }
354+
355+func (t *manualSnapshotTimer) C() <-chan time.Time { return t.ch }
356+func (t *manualSnapshotTimer) Reset(time.Duration) bool { return true }
357+func (t *manualSnapshotTimer) Stop() bool { return true }
M internal/usecase/layout/layout_test.go +24 -0
@@ -109,6 +109,30 @@ func TestNilTreeCloneIsSafe(t *testing.T) {
109109 t.Parallel()
110110 var tr *Tree
111111 require.Nil(t, tr.clone())
112+ require.Nil(t, tr.Clone())
113+}
114+
115+func TestTreeCloneReturnsIndependentCopy(t *testing.T) {
116+ t.Parallel()
117+ tr := &Tree{
118+ Root: &Node{
119+ Kind: Split,
120+ Dir: Horizontal,
121+ Children: []*Node{
122+ NewLeaf("a"),
123+ NewLeaf("b"),
124+ },
125+ },
126+ Focus: "b",
127+ }
128+
129+ clone := tr.Clone()
130+ require.Equal(t, tr, clone)
131+
132+ clone.Root.Children[0].Leaf = "changed"
133+ clone.Focus = "changed"
134+ require.Equal(t, PaneID("a"), tr.Root.Children[0].Leaf)
135+ require.Equal(t, PaneID("b"), tr.Focus)
112136 }
113137
114138 func TestTooSmallRefusalsDoNotMutate(t *testing.T) {
@@ -109,6 +109,30 @@ func TestNilTreeCloneIsSafe(t *testing.T) {
109 t.Parallel()109 t.Parallel()
110 var tr *Tree110 var tr *Tree
111 require.Nil(t, tr.clone())111 require.Nil(t, tr.clone())
112+ require.Nil(t, tr.Clone())
113+}
114+
115+func TestTreeCloneReturnsIndependentCopy(t *testing.T) {
116+ t.Parallel()
117+ tr := &Tree{
118+ Root: &Node{
119+ Kind: Split,
120+ Dir: Horizontal,
121+ Children: []*Node{
122+ NewLeaf("a"),
123+ NewLeaf("b"),
124+ },
125+ },
126+ Focus: "b",
127+ }
128+
129+ clone := tr.Clone()
130+ require.Equal(t, tr, clone)
131+
132+ clone.Root.Children[0].Leaf = "changed"
133+ clone.Focus = "changed"
134+ require.Equal(t, PaneID("a"), tr.Root.Children[0].Leaf)
135+ require.Equal(t, PaneID("b"), tr.Focus)
112 }136 }
113 137
114 func TestTooSmallRefusalsDoNotMutate(t *testing.T) {138 func TestTooSmallRefusalsDoNotMutate(t *testing.T) {
M internal/usecase/layout/tree.go +3 -0
@@ -94,3 +94,6 @@ func (t *Tree) clone() *Tree {
9494 }
9595 return &Tree{Root: t.Root.clone(), Focus: t.Focus}
9696 }
97+
98+// Clone returns an independent copy of the tree.
99+func (t *Tree) Clone() *Tree { return t.clone() }
@@ -94,3 +94,6 @@ func (t *Tree) clone() *Tree {
94 }94 }
95 return &Tree{Root: t.Root.clone(), Focus: t.Focus}95 return &Tree{Root: t.Root.clone(), Focus: t.Focus}
96 }96 }
97+
98+// Clone returns an independent copy of the tree.
99+func (t *Tree) Clone() *Tree { return t.clone() }
A internal/usecase/snapshot/codec.go +745 -0
@@ -0,0 +1,745 @@
1+package snapshot
2+
3+import (
4+ "bytes"
5+ "compress/flate"
6+ "encoding/binary"
7+ "errors"
8+ "fmt"
9+ "hash/crc32"
10+ "io"
11+ "math"
12+
13+ "github.com/bnema/vev/internal/usecase/layout"
14+ "github.com/bnema/vev/pkg/renderer"
15+)
16+
17+var (
18+ ErrBadMagic = errors.New("snapshot: bad magic")
19+ ErrBadVersion = errors.New("snapshot: bad version")
20+ ErrBadCRC = errors.New("snapshot: bad crc")
21+ ErrShortPayload = errors.New("snapshot: payload too short")
22+ ErrTrailingBytes = errors.New("snapshot: trailing bytes")
23+ ErrStyleOOB = errors.New("snapshot: style index out of range")
24+ ErrUnknownPane = errors.New("snapshot: tree references unknown pane")
25+ ErrInvalidData = errors.New("snapshot: invalid data")
26+)
27+
28+const (
29+ magic = "VEVS"
30+ version = uint16(1)
31+ flagFlate = uint16(1 << 0)
32+ flateCutoff = 4 << 10
33+ maxDecodedBodySize = 256 << 20
34+ maxDecodedRows = 1 << 20
35+ maxDecodedRuns = 16 << 20
36+ maxDecodedCells = 16 << 20
37+)
38+
39+// Marshal encodes a v1 durable snapshot.
40+func Marshal(s Session) ([]byte, error) {
41+ var w payloadWriter
42+ if err := writeSession(&w, s); err != nil {
43+ return nil, err
44+ }
45+ body := w.b
46+ flags := uint16(0)
47+ if len(body) > flateCutoff {
48+ var compressed bytes.Buffer
49+ fw, err := flate.NewWriter(&compressed, flate.DefaultCompression)
50+ if err != nil {
51+ return nil, err
52+ }
53+ if _, err := fw.Write(body); err != nil {
54+ _ = fw.Close()
55+ return nil, err
56+ }
57+ if err := fw.Close(); err != nil {
58+ return nil, err
59+ }
60+ body = compressed.Bytes()
61+ flags |= flagFlate
62+ }
63+ if len(body) > math.MaxUint32 {
64+ return nil, fmt.Errorf("%w: body too large", ErrInvalidData)
65+ }
66+ out := make([]byte, 16+len(body))
67+ copy(out[:4], magic)
68+ binary.BigEndian.PutUint16(out[4:6], version)
69+ binary.BigEndian.PutUint16(out[6:8], flags)
70+ binary.BigEndian.PutUint32(out[8:12], uint32(len(body)))
71+ binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))
72+ copy(out[16:], body)
73+ return out, nil
74+}
75+
76+// Unmarshal decodes a strict v1 durable snapshot.
77+func Unmarshal(b []byte) (Session, error) {
78+ if len(b) < 16 {
79+ return Session{}, ErrShortPayload
80+ }
81+ if string(b[:4]) != magic {
82+ return Session{}, ErrBadMagic
83+ }
84+ if binary.BigEndian.Uint16(b[4:6]) != version {
85+ return Session{}, ErrBadVersion
86+ }
87+ flags := binary.BigEndian.Uint16(b[6:8])
88+ if flags&^flagFlate != 0 {
89+ return Session{}, fmt.Errorf("%w: unknown flags", ErrInvalidData)
90+ }
91+ bodyLen := int(binary.BigEndian.Uint32(b[8:12]))
92+ if bodyLen > maxDecodedBodySize {
93+ return Session{}, fmt.Errorf("%w: body too large", ErrInvalidData)
94+ }
95+ if len(b) < 16+bodyLen {
96+ return Session{}, ErrShortPayload
97+ }
98+ if len(b) != 16+bodyLen {
99+ return Session{}, ErrTrailingBytes
100+ }
101+ body := b[16:]
102+ if crc32.ChecksumIEEE(body) != binary.BigEndian.Uint32(b[12:16]) {
103+ return Session{}, ErrBadCRC
104+ }
105+ if flags&flagFlate != 0 {
106+ fr := flate.NewReader(bytes.NewReader(body))
107+ decompressed, err := io.ReadAll(io.LimitReader(fr, maxDecodedBodySize+1))
108+ cerr := fr.Close()
109+ if err != nil {
110+ return Session{}, err
111+ }
112+ if cerr != nil {
113+ return Session{}, cerr
114+ }
115+ if len(decompressed) > maxDecodedBodySize {
116+ return Session{}, fmt.Errorf("%w: decompressed body too large", ErrInvalidData)
117+ }
118+ body = decompressed
119+ }
120+ r := payloadReader{b: body}
121+ s, err := readSession(&r)
122+ if err != nil {
123+ return Session{}, err
124+ }
125+ if err := r.done(); err != nil {
126+ return Session{}, err
127+ }
128+ for _, tab := range s.Tabs {
129+ ids := make(map[layout.PaneID]struct{}, len(tab.Panes))
130+ for _, p := range tab.Panes {
131+ ids[p.ID] = struct{}{}
132+ }
133+ if _, ok := ids[tab.Focus]; tab.Focus != "" && !ok {
134+ return Session{}, ErrUnknownPane
135+ }
136+ if err := validateTree(tab.Tree, ids); err != nil {
137+ return Session{}, err
138+ }
139+ }
140+ return s, nil
141+}
142+
143+type payloadWriter struct{ b []byte }
144+
145+func (w *payloadWriter) putUint8(v uint8) { w.b = append(w.b, v) }
146+func (w *payloadWriter) putUint16(v uint16) {
147+ var t [2]byte
148+ binary.BigEndian.PutUint16(t[:], v)
149+ w.b = append(w.b, t[:]...)
150+}
151+func (w *payloadWriter) putUint32(v uint32) {
152+ var t [4]byte
153+ binary.BigEndian.PutUint32(t[:], v)
154+ w.b = append(w.b, t[:]...)
155+}
156+func (w *payloadWriter) putUint64(v uint64) {
157+ var t [8]byte
158+ binary.BigEndian.PutUint64(t[:], v)
159+ w.b = append(w.b, t[:]...)
160+}
161+func (w *payloadWriter) putString(s string) error {
162+ if len(s) > math.MaxUint16 {
163+ return fmt.Errorf("%w: string too long", ErrInvalidData)
164+ }
165+ w.putUint16(uint16(len(s)))
166+ w.b = append(w.b, s...)
167+ return nil
168+}
169+
170+type payloadReader struct{ b []byte }
171+
172+func (r *payloadReader) getUint8() (uint8, error) {
173+ if len(r.b) < 1 {
174+ return 0, ErrShortPayload
175+ }
176+ v := r.b[0]
177+ r.b = r.b[1:]
178+ return v, nil
179+}
180+func (r *payloadReader) getUint16() (uint16, error) {
181+ if len(r.b) < 2 {
182+ return 0, ErrShortPayload
183+ }
184+ v := binary.BigEndian.Uint16(r.b)
185+ r.b = r.b[2:]
186+ return v, nil
187+}
188+func (r *payloadReader) getUint32() (uint32, error) {
189+ if len(r.b) < 4 {
190+ return 0, ErrShortPayload
191+ }
192+ v := binary.BigEndian.Uint32(r.b)
193+ r.b = r.b[4:]
194+ return v, nil
195+}
196+func (r *payloadReader) getUint64() (uint64, error) {
197+ if len(r.b) < 8 {
198+ return 0, ErrShortPayload
199+ }
200+ v := binary.BigEndian.Uint64(r.b)
201+ r.b = r.b[8:]
202+ return v, nil
203+}
204+func (r *payloadReader) getString() (string, error) {
205+ n, err := r.getUint16()
206+ if err != nil {
207+ return "", err
208+ }
209+ if len(r.b) < int(n) {
210+ return "", ErrShortPayload
211+ }
212+ s := string(r.b[:n])
213+ r.b = r.b[n:]
214+ return s, nil
215+}
216+func (r *payloadReader) done() error {
217+ if len(r.b) != 0 {
218+ return ErrTrailingBytes
219+ }
220+ return nil
221+}
222+
223+func writeSession(w *payloadWriter, s Session) error {
224+ if err := w.putString(s.Name); err != nil {
225+ return err
226+ }
227+ w.putUint64(s.CreatedAt)
228+ w.putUint16(s.Active)
229+ if len(s.Tabs) > math.MaxUint16 {
230+ return fmt.Errorf("%w: too many tabs", ErrInvalidData)
231+ }
232+ w.putUint16(uint16(len(s.Tabs)))
233+ for _, t := range s.Tabs {
234+ if err := writeTab(w, t); err != nil {
235+ return err
236+ }
237+ }
238+ return nil
239+}
240+
241+func readSession(r *payloadReader) (Session, error) {
242+ name, err := r.getString()
243+ if err != nil {
244+ return Session{}, err
245+ }
246+ created, err := r.getUint64()
247+ if err != nil {
248+ return Session{}, err
249+ }
250+ active, err := r.getUint16()
251+ if err != nil {
252+ return Session{}, err
253+ }
254+ nt, err := r.getUint16()
255+ if err != nil {
256+ return Session{}, err
257+ }
258+ s := Session{Name: name, CreatedAt: created, Active: active, Tabs: make([]Tab, nt)}
259+ for i := range s.Tabs {
260+ t, err := readTab(r)
261+ if err != nil {
262+ return Session{}, err
263+ }
264+ s.Tabs[i] = t
265+ }
266+ return s, nil
267+}
268+
269+func writeTab(w *payloadWriter, t Tab) error {
270+ w.putUint16(t.Cols)
271+ w.putUint16(t.Rows)
272+ w.putUint64(t.NextPaneID)
273+ if err := w.putString(string(t.Focus)); err != nil {
274+ return err
275+ }
276+ if t.Tree == nil {
277+ t.Tree = &layout.Tree{Focus: t.Focus}
278+ }
279+ if err := writeNode(w, t.Tree.Root); err != nil {
280+ return err
281+ }
282+ if len(t.Panes) > math.MaxUint16 {
283+ return fmt.Errorf("%w: too many panes", ErrInvalidData)
284+ }
285+ w.putUint16(uint16(len(t.Panes)))
286+ for _, p := range t.Panes {
287+ if err := writePane(w, p); err != nil {
288+ return err
289+ }
290+ }
291+ return nil
292+}
293+
294+func readTab(r *payloadReader) (Tab, error) {
295+ cols, err := r.getUint16()
296+ if err != nil {
297+ return Tab{}, err
298+ }
299+ rows, err := r.getUint16()
300+ if err != nil {
301+ return Tab{}, err
302+ }
303+ next, err := r.getUint64()
304+ if err != nil {
305+ return Tab{}, err
306+ }
307+ focus, err := r.getString()
308+ if err != nil {
309+ return Tab{}, err
310+ }
311+ root, err := readNode(r)
312+ if err != nil {
313+ return Tab{}, err
314+ }
315+ np, err := r.getUint16()
316+ if err != nil {
317+ return Tab{}, err
318+ }
319+ t := Tab{Cols: cols, Rows: rows, NextPaneID: next, Focus: layout.PaneID(focus), Tree: &layout.Tree{Root: root, Focus: layout.PaneID(focus)}, Panes: make([]Pane, np)}
320+ for i := range t.Panes {
321+ p, err := readPane(r)
322+ if err != nil {
323+ return Tab{}, err
324+ }
325+ t.Panes[i] = p
326+ }
327+ return t, nil
328+}
329+
330+func writeNode(w *payloadWriter, n *layout.Node) error {
331+ if n == nil {
332+ w.putUint8(0)
333+ return w.putString("")
334+ }
335+ switch n.Kind {
336+ case layout.Leaf:
337+ w.putUint8(0)
338+ return w.putString(string(n.Leaf))
339+ case layout.Split:
340+ w.putUint8(1)
341+ w.putUint8(uint8(n.Dir))
342+ return writeChildren(w, n.Children)
343+ case layout.Stack:
344+ w.putUint8(2)
345+ if err := w.putString(string(n.Expanded)); err != nil {
346+ return err
347+ }
348+ return writeChildren(w, n.Children)
349+ default:
350+ return fmt.Errorf("%w: unknown node kind", ErrInvalidData)
351+ }
352+}
353+
354+func writeChildren(w *payloadWriter, children []*layout.Node) error {
355+ if len(children) > math.MaxUint16 {
356+ return fmt.Errorf("%w: too many children", ErrInvalidData)
357+ }
358+ w.putUint16(uint16(len(children)))
359+ for _, c := range children {
360+ if err := writeNode(w, c); err != nil {
361+ return err
362+ }
363+ }
364+ return nil
365+}
366+
367+func readNode(r *payloadReader) (*layout.Node, error) {
368+ kind, err := r.getUint8()
369+ if err != nil {
370+ return nil, err
371+ }
372+ switch kind {
373+ case 0:
374+ leaf, err := r.getString()
375+ if err != nil {
376+ return nil, err
377+ }
378+ return layout.NewLeaf(layout.PaneID(leaf)), nil
379+ case 1:
380+ dir, err := r.getUint8()
381+ if err != nil {
382+ return nil, err
383+ }
384+ children, err := readChildren(r)
385+ if err != nil {
386+ return nil, err
387+ }
388+ return &layout.Node{Kind: layout.Split, Dir: layout.SplitDir(dir), Children: children}, nil
389+ case 2:
390+ exp, err := r.getString()
391+ if err != nil {
392+ return nil, err
393+ }
394+ children, err := readChildren(r)
395+ if err != nil {
396+ return nil, err
397+ }
398+ return &layout.Node{Kind: layout.Stack, Expanded: layout.PaneID(exp), Children: children}, nil
399+ default:
400+ return nil, fmt.Errorf("%w: unknown node kind", ErrInvalidData)
401+ }
402+}
403+
404+func readChildren(r *payloadReader) ([]*layout.Node, error) {
405+ n, err := r.getUint16()
406+ if err != nil {
407+ return nil, err
408+ }
409+ out := make([]*layout.Node, n)
410+ for i := range out {
411+ c, err := readNode(r)
412+ if err != nil {
413+ return nil, err
414+ }
415+ out[i] = c
416+ }
417+ return out, nil
418+}
419+
420+func writePane(w *payloadWriter, p Pane) error {
421+ if err := w.putString(string(p.ID)); err != nil {
422+ return err
423+ }
424+ if err := w.putString(p.Cwd); err != nil {
425+ return err
426+ }
427+ styles := styleTable(p)
428+ if len(styles) > math.MaxUint16 {
429+ return fmt.Errorf("%w: too many styles", ErrInvalidData)
430+ }
431+ w.putUint16(uint16(len(styles)))
432+ for _, s := range styles {
433+ writeStyle(w, s)
434+ }
435+ idx := make(map[renderer.Style]uint16, len(styles))
436+ for i, s := range styles {
437+ idx[s] = uint16(i)
438+ }
439+ if err := writeRows(w, p.Scrollback, idx, false); err != nil {
440+ return err
441+ }
442+ return writeRows(w, p.Visible, idx, true)
443+}
444+
445+func readPane(r *payloadReader) (Pane, error) {
446+ id, err := r.getString()
447+ if err != nil {
448+ return Pane{}, err
449+ }
450+ cwd, err := r.getString()
451+ if err != nil {
452+ return Pane{}, err
453+ }
454+ ns, err := r.getUint16()
455+ if err != nil {
456+ return Pane{}, err
457+ }
458+ styles := make([]renderer.Style, ns)
459+ for i := range styles {
460+ s, err := readStyle(r)
461+ if err != nil {
462+ return Pane{}, err
463+ }
464+ styles[i] = s
465+ }
466+ budget := rowBudget{cells: maxDecodedCells, runs: maxDecodedRuns}
467+ sb, err := readRows(r, styles, &budget)
468+ if err != nil {
469+ return Pane{}, err
470+ }
471+ vis, err := readRows(r, styles, &budget)
472+ if err != nil {
473+ return Pane{}, err
474+ }
475+ return Pane{ID: layout.PaneID(id), Cwd: cwd, Scrollback: sb, Visible: vis}, nil
476+}
477+
478+func writeRows(w *payloadWriter, rows [][]renderer.Cell, idx map[renderer.Style]uint16, trimBlankRows bool) error {
479+ clean := trimRows(rows, trimBlankRows)
480+ if len(clean) > math.MaxUint32 {
481+ return fmt.Errorf("%w: too many rows", ErrInvalidData)
482+ }
483+ w.putUint32(uint32(len(clean)))
484+ for _, row := range clean {
485+ runs := makeRuns(row)
486+ if len(runs) > math.MaxUint16 {
487+ return fmt.Errorf("%w: too many runs", ErrInvalidData)
488+ }
489+ w.putUint16(uint16(len(runs)))
490+ for _, run := range runs {
491+ w.putUint16(uint16(run.len))
492+ w.putUint32(uint32(run.cell.Rune))
493+ w.putUint16(idx[run.cell.Style])
494+ var flags uint8
495+ if run.cell.Continuation {
496+ flags |= 1
497+ }
498+ w.putUint8(flags)
499+ }
500+ }
501+ return nil
502+}
503+
504+type rowBudget struct {
505+ cells int
506+ runs int
507+}
508+
509+func readRows(r *payloadReader, styles []renderer.Style, budget *rowBudget) ([][]renderer.Cell, error) {
510+ n, err := r.getUint32()
511+ if err != nil {
512+ return nil, err
513+ }
514+ if n > maxDecodedRows {
515+ return nil, fmt.Errorf("%w: too many rows", ErrInvalidData)
516+ }
517+ if uint64(n) > uint64(len(r.b))/2 {
518+ return nil, ErrShortPayload
519+ }
520+ rows := make([][]renderer.Cell, int(n))
521+ for i := range rows {
522+ nr, err := r.getUint16()
523+ if err != nil {
524+ return nil, err
525+ }
526+ if budget != nil {
527+ if int(nr) > budget.runs {
528+ return nil, fmt.Errorf("%w: too many runs", ErrInvalidData)
529+ }
530+ budget.runs -= int(nr)
531+ }
532+ if uint64(nr) > uint64(len(r.b))/9 {
533+ return nil, ErrShortPayload
534+ }
535+ rowCells := 0
536+ for j := 0; j < int(nr); j++ {
537+ ln, err := r.getUint16()
538+ if err != nil {
539+ return nil, err
540+ }
541+ rn, err := r.getUint32()
542+ if err != nil {
543+ return nil, err
544+ }
545+ si, err := r.getUint16()
546+ if err != nil {
547+ return nil, err
548+ }
549+ flags, err := r.getUint8()
550+ if err != nil {
551+ return nil, err
552+ }
553+ if int(si) >= len(styles) {
554+ return nil, ErrStyleOOB
555+ }
556+ if flags&^1 != 0 {
557+ return nil, fmt.Errorf("%w: unknown cell flags", ErrInvalidData)
558+ }
559+ if budget != nil {
560+ if int(ln) > budget.cells {
561+ return nil, fmt.Errorf("%w: too many cells", ErrInvalidData)
562+ }
563+ budget.cells -= int(ln)
564+ }
565+ cell := renderer.Cell{Rune: rune(rn), Style: styles[si], Continuation: flags&1 != 0}
566+ rowCells += int(ln)
567+ rows[i] = append(rows[i], make([]renderer.Cell, int(ln))...)
568+ for k := len(rows[i]) - int(ln); k < len(rows[i]); k++ {
569+ rows[i][k] = cell
570+ }
571+ }
572+ if rowCells == 0 {
573+ rows[i] = nil
574+ }
575+ }
576+ return rows, nil
577+}
578+
579+type cellRun struct {
580+ len int
581+ cell renderer.Cell
582+}
583+
584+func makeRuns(row []renderer.Cell) []cellRun {
585+ if len(row) == 0 {
586+ return nil
587+ }
588+ runs := []cellRun{{len: 1, cell: row[0]}}
589+ for _, c := range row[1:] {
590+ last := &runs[len(runs)-1]
591+ if last.cell.Equal(c) && last.len < math.MaxUint16 {
592+ last.len++
593+ } else {
594+ runs = append(runs, cellRun{len: 1, cell: c})
595+ }
596+ }
597+ return runs
598+}
599+
600+func trimRows(rows [][]renderer.Cell, trimBlankRows bool) [][]renderer.Cell {
601+ out := make([][]renderer.Cell, len(rows))
602+ for i, row := range rows {
603+ out[i] = trimTrailingBlankCells(row)
604+ }
605+ if trimBlankRows {
606+ for len(out) > 0 && len(out[len(out)-1]) == 0 {
607+ out = out[:len(out)-1]
608+ }
609+ }
610+ return out
611+}
612+
613+func trimTrailingBlankCells(row []renderer.Cell) []renderer.Cell {
614+ end := len(row)
615+ for end > 0 && isBlank(row[end-1]) {
616+ end--
617+ }
618+ return append([]renderer.Cell(nil), row[:end]...)
619+}
620+func isBlank(c renderer.Cell) bool {
621+ return !c.Continuation && c.Rune == ' ' && c.Style.Equal(renderer.DefaultStyle())
622+}
623+
624+func styleTable(p Pane) []renderer.Style {
625+ seen := map[renderer.Style]bool{}
626+ var styles []renderer.Style
627+ addRows := func(rows [][]renderer.Cell, trimBlankRows bool) {
628+ for _, row := range trimRows(rows, trimBlankRows) {
629+ for _, c := range row {
630+ if !seen[c.Style] {
631+ seen[c.Style] = true
632+ styles = append(styles, c.Style)
633+ }
634+ }
635+ }
636+ }
637+ addRows(p.Scrollback, false)
638+ addRows(p.Visible, true)
639+ return styles
640+}
641+
642+func writeStyle(w *payloadWriter, s renderer.Style) {
643+ var flags uint8
644+ if s.Bold {
645+ flags |= 1
646+ }
647+ if s.Inverse {
648+ flags |= 2
649+ }
650+ if s.HasForegroundRGB {
651+ flags |= 4
652+ }
653+ if s.HasBackgroundRGB {
654+ flags |= 8
655+ }
656+ w.putUint8(flags)
657+ w.putUint32(uint32(int32(s.Foreground)))
658+ w.putUint32(uint32(int32(s.Background)))
659+ w.putUint8(s.ForegroundRGB.R)
660+ w.putUint8(s.ForegroundRGB.G)
661+ w.putUint8(s.ForegroundRGB.B)
662+ w.putUint8(s.BackgroundRGB.R)
663+ w.putUint8(s.BackgroundRGB.G)
664+ w.putUint8(s.BackgroundRGB.B)
665+}
666+
667+func readStyle(r *payloadReader) (renderer.Style, error) {
668+ flags, err := r.getUint8()
669+ if err != nil {
670+ return renderer.Style{}, err
671+ }
672+ fg, err := r.getUint32()
673+ if err != nil {
674+ return renderer.Style{}, err
675+ }
676+ bg, err := r.getUint32()
677+ if err != nil {
678+ return renderer.Style{}, err
679+ }
680+ fr, err := r.getUint8()
681+ if err != nil {
682+ return renderer.Style{}, err
683+ }
684+ fgc, err := r.getUint8()
685+ if err != nil {
686+ return renderer.Style{}, err
687+ }
688+ fb, err := r.getUint8()
689+ if err != nil {
690+ return renderer.Style{}, err
691+ }
692+ br, err := r.getUint8()
693+ if err != nil {
694+ return renderer.Style{}, err
695+ }
696+ bgc, err := r.getUint8()
697+ if err != nil {
698+ return renderer.Style{}, err
699+ }
700+ bb, err := r.getUint8()
701+ if err != nil {
702+ return renderer.Style{}, err
703+ }
704+ return renderer.Style{Bold: flags&1 != 0, Inverse: flags&2 != 0, HasForegroundRGB: flags&4 != 0, HasBackgroundRGB: flags&8 != 0, Foreground: int(int32(fg)), Background: int(int32(bg)), ForegroundRGB: renderer.RGB{R: fr, G: fgc, B: fb}, BackgroundRGB: renderer.RGB{R: br, G: bgc, B: bb}}, nil
705+}
706+
707+func validateTree(t *layout.Tree, ids map[layout.PaneID]struct{}) error {
708+ if t == nil || t.Root == nil {
709+ return nil
710+ }
711+ return validateNode(t.Root, ids)
712+}
713+
714+func validateNode(n *layout.Node, ids map[layout.PaneID]struct{}) error {
715+ if n == nil {
716+ return nil
717+ }
718+ switch n.Kind {
719+ case layout.Leaf:
720+ if _, ok := ids[n.Leaf]; !ok {
721+ return ErrUnknownPane
722+ }
723+ case layout.Split:
724+ if n.Dir != layout.Horizontal && n.Dir != layout.Vertical {
725+ return fmt.Errorf("%w: split dir", ErrInvalidData)
726+ }
727+ for _, c := range n.Children {
728+ if err := validateNode(c, ids); err != nil {
729+ return err
730+ }
731+ }
732+ case layout.Stack:
733+ if _, ok := ids[n.Expanded]; n.Expanded != "" && !ok {
734+ return ErrUnknownPane
735+ }
736+ for _, c := range n.Children {
737+ if err := validateNode(c, ids); err != nil {
738+ return err
739+ }
740+ }
741+ default:
742+ return fmt.Errorf("%w: node kind", ErrInvalidData)
743+ }
744+ return nil
745+}
@@ -0,0 +1,745 @@
1+package snapshot
2+
3+import (
4+ "bytes"
5+ "compress/flate"
6+ "encoding/binary"
7+ "errors"
8+ "fmt"
9+ "hash/crc32"
10+ "io"
11+ "math"
12+
13+ "github.com/bnema/vev/internal/usecase/layout"
14+ "github.com/bnema/vev/pkg/renderer"
15+)
16+
17+var (
18+ ErrBadMagic = errors.New("snapshot: bad magic")
19+ ErrBadVersion = errors.New("snapshot: bad version")
20+ ErrBadCRC = errors.New("snapshot: bad crc")
21+ ErrShortPayload = errors.New("snapshot: payload too short")
22+ ErrTrailingBytes = errors.New("snapshot: trailing bytes")
23+ ErrStyleOOB = errors.New("snapshot: style index out of range")
24+ ErrUnknownPane = errors.New("snapshot: tree references unknown pane")
25+ ErrInvalidData = errors.New("snapshot: invalid data")
26+)
27+
28+const (
29+ magic = "VEVS"
30+ version = uint16(1)
31+ flagFlate = uint16(1 << 0)
32+ flateCutoff = 4 << 10
33+ maxDecodedBodySize = 256 << 20
34+ maxDecodedRows = 1 << 20
35+ maxDecodedRuns = 16 << 20
36+ maxDecodedCells = 16 << 20
37+)
38+
39+// Marshal encodes a v1 durable snapshot.
40+func Marshal(s Session) ([]byte, error) {
41+ var w payloadWriter
42+ if err := writeSession(&w, s); err != nil {
43+ return nil, err
44+ }
45+ body := w.b
46+ flags := uint16(0)
47+ if len(body) > flateCutoff {
48+ var compressed bytes.Buffer
49+ fw, err := flate.NewWriter(&compressed, flate.DefaultCompression)
50+ if err != nil {
51+ return nil, err
52+ }
53+ if _, err := fw.Write(body); err != nil {
54+ _ = fw.Close()
55+ return nil, err
56+ }
57+ if err := fw.Close(); err != nil {
58+ return nil, err
59+ }
60+ body = compressed.Bytes()
61+ flags |= flagFlate
62+ }
63+ if len(body) > math.MaxUint32 {
64+ return nil, fmt.Errorf("%w: body too large", ErrInvalidData)
65+ }
66+ out := make([]byte, 16+len(body))
67+ copy(out[:4], magic)
68+ binary.BigEndian.PutUint16(out[4:6], version)
69+ binary.BigEndian.PutUint16(out[6:8], flags)
70+ binary.BigEndian.PutUint32(out[8:12], uint32(len(body)))
71+ binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))
72+ copy(out[16:], body)
73+ return out, nil
74+}
75+
76+// Unmarshal decodes a strict v1 durable snapshot.
77+func Unmarshal(b []byte) (Session, error) {
78+ if len(b) < 16 {
79+ return Session{}, ErrShortPayload
80+ }
81+ if string(b[:4]) != magic {
82+ return Session{}, ErrBadMagic
83+ }
84+ if binary.BigEndian.Uint16(b[4:6]) != version {
85+ return Session{}, ErrBadVersion
86+ }
87+ flags := binary.BigEndian.Uint16(b[6:8])
88+ if flags&^flagFlate != 0 {
89+ return Session{}, fmt.Errorf("%w: unknown flags", ErrInvalidData)
90+ }
91+ bodyLen := int(binary.BigEndian.Uint32(b[8:12]))
92+ if bodyLen > maxDecodedBodySize {
93+ return Session{}, fmt.Errorf("%w: body too large", ErrInvalidData)
94+ }
95+ if len(b) < 16+bodyLen {
96+ return Session{}, ErrShortPayload
97+ }
98+ if len(b) != 16+bodyLen {
99+ return Session{}, ErrTrailingBytes
100+ }
101+ body := b[16:]
102+ if crc32.ChecksumIEEE(body) != binary.BigEndian.Uint32(b[12:16]) {
103+ return Session{}, ErrBadCRC
104+ }
105+ if flags&flagFlate != 0 {
106+ fr := flate.NewReader(bytes.NewReader(body))
107+ decompressed, err := io.ReadAll(io.LimitReader(fr, maxDecodedBodySize+1))
108+ cerr := fr.Close()
109+ if err != nil {
110+ return Session{}, err
111+ }
112+ if cerr != nil {
113+ return Session{}, cerr
114+ }
115+ if len(decompressed) > maxDecodedBodySize {
116+ return Session{}, fmt.Errorf("%w: decompressed body too large", ErrInvalidData)
117+ }
118+ body = decompressed
119+ }
120+ r := payloadReader{b: body}
121+ s, err := readSession(&r)
122+ if err != nil {
123+ return Session{}, err
124+ }
125+ if err := r.done(); err != nil {
126+ return Session{}, err
127+ }
128+ for _, tab := range s.Tabs {
129+ ids := make(map[layout.PaneID]struct{}, len(tab.Panes))
130+ for _, p := range tab.Panes {
131+ ids[p.ID] = struct{}{}
132+ }
133+ if _, ok := ids[tab.Focus]; tab.Focus != "" && !ok {
134+ return Session{}, ErrUnknownPane
135+ }
136+ if err := validateTree(tab.Tree, ids); err != nil {
137+ return Session{}, err
138+ }
139+ }
140+ return s, nil
141+}
142+
143+type payloadWriter struct{ b []byte }
144+
145+func (w *payloadWriter) putUint8(v uint8) { w.b = append(w.b, v) }
146+func (w *payloadWriter) putUint16(v uint16) {
147+ var t [2]byte
148+ binary.BigEndian.PutUint16(t[:], v)
149+ w.b = append(w.b, t[:]...)
150+}
151+func (w *payloadWriter) putUint32(v uint32) {
152+ var t [4]byte
153+ binary.BigEndian.PutUint32(t[:], v)
154+ w.b = append(w.b, t[:]...)
155+}
156+func (w *payloadWriter) putUint64(v uint64) {
157+ var t [8]byte
158+ binary.BigEndian.PutUint64(t[:], v)
159+ w.b = append(w.b, t[:]...)
160+}
161+func (w *payloadWriter) putString(s string) error {
162+ if len(s) > math.MaxUint16 {
163+ return fmt.Errorf("%w: string too long", ErrInvalidData)
164+ }
165+ w.putUint16(uint16(len(s)))
166+ w.b = append(w.b, s...)
167+ return nil
168+}
169+
170+type payloadReader struct{ b []byte }
171+
172+func (r *payloadReader) getUint8() (uint8, error) {
173+ if len(r.b) < 1 {
174+ return 0, ErrShortPayload
175+ }
176+ v := r.b[0]
177+ r.b = r.b[1:]
178+ return v, nil
179+}
180+func (r *payloadReader) getUint16() (uint16, error) {
181+ if len(r.b) < 2 {
182+ return 0, ErrShortPayload
183+ }
184+ v := binary.BigEndian.Uint16(r.b)
185+ r.b = r.b[2:]
186+ return v, nil
187+}
188+func (r *payloadReader) getUint32() (uint32, error) {
189+ if len(r.b) < 4 {
190+ return 0, ErrShortPayload
191+ }
192+ v := binary.BigEndian.Uint32(r.b)
193+ r.b = r.b[4:]
194+ return v, nil
195+}
196+func (r *payloadReader) getUint64() (uint64, error) {
197+ if len(r.b) < 8 {
198+ return 0, ErrShortPayload
199+ }
200+ v := binary.BigEndian.Uint64(r.b)
201+ r.b = r.b[8:]
202+ return v, nil
203+}
204+func (r *payloadReader) getString() (string, error) {
205+ n, err := r.getUint16()
206+ if err != nil {
207+ return "", err
208+ }
209+ if len(r.b) < int(n) {
210+ return "", ErrShortPayload
211+ }
212+ s := string(r.b[:n])
213+ r.b = r.b[n:]
214+ return s, nil
215+}
216+func (r *payloadReader) done() error {
217+ if len(r.b) != 0 {
218+ return ErrTrailingBytes
219+ }
220+ return nil
221+}
222+
223+func writeSession(w *payloadWriter, s Session) error {
224+ if err := w.putString(s.Name); err != nil {
225+ return err
226+ }
227+ w.putUint64(s.CreatedAt)
228+ w.putUint16(s.Active)
229+ if len(s.Tabs) > math.MaxUint16 {
230+ return fmt.Errorf("%w: too many tabs", ErrInvalidData)
231+ }
232+ w.putUint16(uint16(len(s.Tabs)))
233+ for _, t := range s.Tabs {
234+ if err := writeTab(w, t); err != nil {
235+ return err
236+ }
237+ }
238+ return nil
239+}
240+
241+func readSession(r *payloadReader) (Session, error) {
242+ name, err := r.getString()
243+ if err != nil {
244+ return Session{}, err
245+ }
246+ created, err := r.getUint64()
247+ if err != nil {
248+ return Session{}, err
249+ }
250+ active, err := r.getUint16()
251+ if err != nil {
252+ return Session{}, err
253+ }
254+ nt, err := r.getUint16()
255+ if err != nil {
256+ return Session{}, err
257+ }
258+ s := Session{Name: name, CreatedAt: created, Active: active, Tabs: make([]Tab, nt)}
259+ for i := range s.Tabs {
260+ t, err := readTab(r)
261+ if err != nil {
262+ return Session{}, err
263+ }
264+ s.Tabs[i] = t
265+ }
266+ return s, nil
267+}
268+
269+func writeTab(w *payloadWriter, t Tab) error {
270+ w.putUint16(t.Cols)
271+ w.putUint16(t.Rows)
272+ w.putUint64(t.NextPaneID)
273+ if err := w.putString(string(t.Focus)); err != nil {
274+ return err
275+ }
276+ if t.Tree == nil {
277+ t.Tree = &layout.Tree{Focus: t.Focus}
278+ }
279+ if err := writeNode(w, t.Tree.Root); err != nil {
280+ return err
281+ }
282+ if len(t.Panes) > math.MaxUint16 {
283+ return fmt.Errorf("%w: too many panes", ErrInvalidData)
284+ }
285+ w.putUint16(uint16(len(t.Panes)))
286+ for _, p := range t.Panes {
287+ if err := writePane(w, p); err != nil {
288+ return err
289+ }
290+ }
291+ return nil
292+}
293+
294+func readTab(r *payloadReader) (Tab, error) {
295+ cols, err := r.getUint16()
296+ if err != nil {
297+ return Tab{}, err
298+ }
299+ rows, err := r.getUint16()
300+ if err != nil {
301+ return Tab{}, err
302+ }
303+ next, err := r.getUint64()
304+ if err != nil {
305+ return Tab{}, err
306+ }
307+ focus, err := r.getString()
308+ if err != nil {
309+ return Tab{}, err
310+ }
311+ root, err := readNode(r)
312+ if err != nil {
313+ return Tab{}, err
314+ }
315+ np, err := r.getUint16()
316+ if err != nil {
317+ return Tab{}, err
318+ }
319+ t := Tab{Cols: cols, Rows: rows, NextPaneID: next, Focus: layout.PaneID(focus), Tree: &layout.Tree{Root: root, Focus: layout.PaneID(focus)}, Panes: make([]Pane, np)}
320+ for i := range t.Panes {
321+ p, err := readPane(r)
322+ if err != nil {
323+ return Tab{}, err
324+ }
325+ t.Panes[i] = p
326+ }
327+ return t, nil
328+}
329+
330+func writeNode(w *payloadWriter, n *layout.Node) error {
331+ if n == nil {
332+ w.putUint8(0)
333+ return w.putString("")
334+ }
335+ switch n.Kind {
336+ case layout.Leaf:
337+ w.putUint8(0)
338+ return w.putString(string(n.Leaf))
339+ case layout.Split:
340+ w.putUint8(1)
341+ w.putUint8(uint8(n.Dir))
342+ return writeChildren(w, n.Children)
343+ case layout.Stack:
344+ w.putUint8(2)
345+ if err := w.putString(string(n.Expanded)); err != nil {
346+ return err
347+ }
348+ return writeChildren(w, n.Children)
349+ default:
350+ return fmt.Errorf("%w: unknown node kind", ErrInvalidData)
351+ }
352+}
353+
354+func writeChildren(w *payloadWriter, children []*layout.Node) error {
355+ if len(children) > math.MaxUint16 {
356+ return fmt.Errorf("%w: too many children", ErrInvalidData)
357+ }
358+ w.putUint16(uint16(len(children)))
359+ for _, c := range children {
360+ if err := writeNode(w, c); err != nil {
361+ return err
362+ }
363+ }
364+ return nil
365+}
366+
367+func readNode(r *payloadReader) (*layout.Node, error) {
368+ kind, err := r.getUint8()
369+ if err != nil {
370+ return nil, err
371+ }
372+ switch kind {
373+ case 0:
374+ leaf, err := r.getString()
375+ if err != nil {
376+ return nil, err
377+ }
378+ return layout.NewLeaf(layout.PaneID(leaf)), nil
379+ case 1:
380+ dir, err := r.getUint8()
381+ if err != nil {
382+ return nil, err
383+ }
384+ children, err := readChildren(r)
385+ if err != nil {
386+ return nil, err
387+ }
388+ return &layout.Node{Kind: layout.Split, Dir: layout.SplitDir(dir), Children: children}, nil
389+ case 2:
390+ exp, err := r.getString()
391+ if err != nil {
392+ return nil, err
393+ }
394+ children, err := readChildren(r)
395+ if err != nil {
396+ return nil, err
397+ }
398+ return &layout.Node{Kind: layout.Stack, Expanded: layout.PaneID(exp), Children: children}, nil
399+ default:
400+ return nil, fmt.Errorf("%w: unknown node kind", ErrInvalidData)
401+ }
402+}
403+
404+func readChildren(r *payloadReader) ([]*layout.Node, error) {
405+ n, err := r.getUint16()
406+ if err != nil {
407+ return nil, err
408+ }
409+ out := make([]*layout.Node, n)
410+ for i := range out {
411+ c, err := readNode(r)
412+ if err != nil {
413+ return nil, err
414+ }
415+ out[i] = c
416+ }
417+ return out, nil
418+}
419+
420+func writePane(w *payloadWriter, p Pane) error {
421+ if err := w.putString(string(p.ID)); err != nil {
422+ return err
423+ }
424+ if err := w.putString(p.Cwd); err != nil {
425+ return err
426+ }
427+ styles := styleTable(p)
428+ if len(styles) > math.MaxUint16 {
429+ return fmt.Errorf("%w: too many styles", ErrInvalidData)
430+ }
431+ w.putUint16(uint16(len(styles)))
432+ for _, s := range styles {
433+ writeStyle(w, s)
434+ }
435+ idx := make(map[renderer.Style]uint16, len(styles))
436+ for i, s := range styles {
437+ idx[s] = uint16(i)
438+ }
439+ if err := writeRows(w, p.Scrollback, idx, false); err != nil {
440+ return err
441+ }
442+ return writeRows(w, p.Visible, idx, true)
443+}
444+
445+func readPane(r *payloadReader) (Pane, error) {
446+ id, err := r.getString()
447+ if err != nil {
448+ return Pane{}, err
449+ }
450+ cwd, err := r.getString()
451+ if err != nil {
452+ return Pane{}, err
453+ }
454+ ns, err := r.getUint16()
455+ if err != nil {
456+ return Pane{}, err
457+ }
458+ styles := make([]renderer.Style, ns)
459+ for i := range styles {
460+ s, err := readStyle(r)
461+ if err != nil {
462+ return Pane{}, err
463+ }
464+ styles[i] = s
465+ }
466+ budget := rowBudget{cells: maxDecodedCells, runs: maxDecodedRuns}
467+ sb, err := readRows(r, styles, &budget)
468+ if err != nil {
469+ return Pane{}, err
470+ }
471+ vis, err := readRows(r, styles, &budget)
472+ if err != nil {
473+ return Pane{}, err
474+ }
475+ return Pane{ID: layout.PaneID(id), Cwd: cwd, Scrollback: sb, Visible: vis}, nil
476+}
477+
478+func writeRows(w *payloadWriter, rows [][]renderer.Cell, idx map[renderer.Style]uint16, trimBlankRows bool) error {
479+ clean := trimRows(rows, trimBlankRows)
480+ if len(clean) > math.MaxUint32 {
481+ return fmt.Errorf("%w: too many rows", ErrInvalidData)
482+ }
483+ w.putUint32(uint32(len(clean)))
484+ for _, row := range clean {
485+ runs := makeRuns(row)
486+ if len(runs) > math.MaxUint16 {
487+ return fmt.Errorf("%w: too many runs", ErrInvalidData)
488+ }
489+ w.putUint16(uint16(len(runs)))
490+ for _, run := range runs {
491+ w.putUint16(uint16(run.len))
492+ w.putUint32(uint32(run.cell.Rune))
493+ w.putUint16(idx[run.cell.Style])
494+ var flags uint8
495+ if run.cell.Continuation {
496+ flags |= 1
497+ }
498+ w.putUint8(flags)
499+ }
500+ }
501+ return nil
502+}
503+
504+type rowBudget struct {
505+ cells int
506+ runs int
507+}
508+
509+func readRows(r *payloadReader, styles []renderer.Style, budget *rowBudget) ([][]renderer.Cell, error) {
510+ n, err := r.getUint32()
511+ if err != nil {
512+ return nil, err
513+ }
514+ if n > maxDecodedRows {
515+ return nil, fmt.Errorf("%w: too many rows", ErrInvalidData)
516+ }
517+ if uint64(n) > uint64(len(r.b))/2 {
518+ return nil, ErrShortPayload
519+ }
520+ rows := make([][]renderer.Cell, int(n))
521+ for i := range rows {
522+ nr, err := r.getUint16()
523+ if err != nil {
524+ return nil, err
525+ }
526+ if budget != nil {
527+ if int(nr) > budget.runs {
528+ return nil, fmt.Errorf("%w: too many runs", ErrInvalidData)
529+ }
530+ budget.runs -= int(nr)
531+ }
532+ if uint64(nr) > uint64(len(r.b))/9 {
533+ return nil, ErrShortPayload
534+ }
535+ rowCells := 0
536+ for j := 0; j < int(nr); j++ {
537+ ln, err := r.getUint16()
538+ if err != nil {
539+ return nil, err
540+ }
541+ rn, err := r.getUint32()
542+ if err != nil {
543+ return nil, err
544+ }
545+ si, err := r.getUint16()
546+ if err != nil {
547+ return nil, err
548+ }
549+ flags, err := r.getUint8()
550+ if err != nil {
551+ return nil, err
552+ }
553+ if int(si) >= len(styles) {
554+ return nil, ErrStyleOOB
555+ }
556+ if flags&^1 != 0 {
557+ return nil, fmt.Errorf("%w: unknown cell flags", ErrInvalidData)
558+ }
559+ if budget != nil {
560+ if int(ln) > budget.cells {
561+ return nil, fmt.Errorf("%w: too many cells", ErrInvalidData)
562+ }
563+ budget.cells -= int(ln)
564+ }
565+ cell := renderer.Cell{Rune: rune(rn), Style: styles[si], Continuation: flags&1 != 0}
566+ rowCells += int(ln)
567+ rows[i] = append(rows[i], make([]renderer.Cell, int(ln))...)
568+ for k := len(rows[i]) - int(ln); k < len(rows[i]); k++ {
569+ rows[i][k] = cell
570+ }
571+ }
572+ if rowCells == 0 {
573+ rows[i] = nil
574+ }
575+ }
576+ return rows, nil
577+}
578+
579+type cellRun struct {
580+ len int
581+ cell renderer.Cell
582+}
583+
584+func makeRuns(row []renderer.Cell) []cellRun {
585+ if len(row) == 0 {
586+ return nil
587+ }
588+ runs := []cellRun{{len: 1, cell: row[0]}}
589+ for _, c := range row[1:] {
590+ last := &runs[len(runs)-1]
591+ if last.cell.Equal(c) && last.len < math.MaxUint16 {
592+ last.len++
593+ } else {
594+ runs = append(runs, cellRun{len: 1, cell: c})
595+ }
596+ }
597+ return runs
598+}
599+
600+func trimRows(rows [][]renderer.Cell, trimBlankRows bool) [][]renderer.Cell {
601+ out := make([][]renderer.Cell, len(rows))
602+ for i, row := range rows {
603+ out[i] = trimTrailingBlankCells(row)
604+ }
605+ if trimBlankRows {
606+ for len(out) > 0 && len(out[len(out)-1]) == 0 {
607+ out = out[:len(out)-1]
608+ }
609+ }
610+ return out
611+}
612+
613+func trimTrailingBlankCells(row []renderer.Cell) []renderer.Cell {
614+ end := len(row)
615+ for end > 0 && isBlank(row[end-1]) {
616+ end--
617+ }
618+ return append([]renderer.Cell(nil), row[:end]...)
619+}
620+func isBlank(c renderer.Cell) bool {
621+ return !c.Continuation && c.Rune == ' ' && c.Style.Equal(renderer.DefaultStyle())
622+}
623+
624+func styleTable(p Pane) []renderer.Style {
625+ seen := map[renderer.Style]bool{}
626+ var styles []renderer.Style
627+ addRows := func(rows [][]renderer.Cell, trimBlankRows bool) {
628+ for _, row := range trimRows(rows, trimBlankRows) {
629+ for _, c := range row {
630+ if !seen[c.Style] {
631+ seen[c.Style] = true
632+ styles = append(styles, c.Style)
633+ }
634+ }
635+ }
636+ }
637+ addRows(p.Scrollback, false)
638+ addRows(p.Visible, true)
639+ return styles
640+}
641+
642+func writeStyle(w *payloadWriter, s renderer.Style) {
643+ var flags uint8
644+ if s.Bold {
645+ flags |= 1
646+ }
647+ if s.Inverse {
648+ flags |= 2
649+ }
650+ if s.HasForegroundRGB {
651+ flags |= 4
652+ }
653+ if s.HasBackgroundRGB {
654+ flags |= 8
655+ }
656+ w.putUint8(flags)
657+ w.putUint32(uint32(int32(s.Foreground)))
658+ w.putUint32(uint32(int32(s.Background)))
659+ w.putUint8(s.ForegroundRGB.R)
660+ w.putUint8(s.ForegroundRGB.G)
661+ w.putUint8(s.ForegroundRGB.B)
662+ w.putUint8(s.BackgroundRGB.R)
663+ w.putUint8(s.BackgroundRGB.G)
664+ w.putUint8(s.BackgroundRGB.B)
665+}
666+
667+func readStyle(r *payloadReader) (renderer.Style, error) {
668+ flags, err := r.getUint8()
669+ if err != nil {
670+ return renderer.Style{}, err
671+ }
672+ fg, err := r.getUint32()
673+ if err != nil {
674+ return renderer.Style{}, err
675+ }
676+ bg, err := r.getUint32()
677+ if err != nil {
678+ return renderer.Style{}, err
679+ }
680+ fr, err := r.getUint8()
681+ if err != nil {
682+ return renderer.Style{}, err
683+ }
684+ fgc, err := r.getUint8()
685+ if err != nil {
686+ return renderer.Style{}, err
687+ }
688+ fb, err := r.getUint8()
689+ if err != nil {
690+ return renderer.Style{}, err
691+ }
692+ br, err := r.getUint8()
693+ if err != nil {
694+ return renderer.Style{}, err
695+ }
696+ bgc, err := r.getUint8()
697+ if err != nil {
698+ return renderer.Style{}, err
699+ }
700+ bb, err := r.getUint8()
701+ if err != nil {
702+ return renderer.Style{}, err
703+ }
704+ return renderer.Style{Bold: flags&1 != 0, Inverse: flags&2 != 0, HasForegroundRGB: flags&4 != 0, HasBackgroundRGB: flags&8 != 0, Foreground: int(int32(fg)), Background: int(int32(bg)), ForegroundRGB: renderer.RGB{R: fr, G: fgc, B: fb}, BackgroundRGB: renderer.RGB{R: br, G: bgc, B: bb}}, nil
705+}
706+
707+func validateTree(t *layout.Tree, ids map[layout.PaneID]struct{}) error {
708+ if t == nil || t.Root == nil {
709+ return nil
710+ }
711+ return validateNode(t.Root, ids)
712+}
713+
714+func validateNode(n *layout.Node, ids map[layout.PaneID]struct{}) error {
715+ if n == nil {
716+ return nil
717+ }
718+ switch n.Kind {
719+ case layout.Leaf:
720+ if _, ok := ids[n.Leaf]; !ok {
721+ return ErrUnknownPane
722+ }
723+ case layout.Split:
724+ if n.Dir != layout.Horizontal && n.Dir != layout.Vertical {
725+ return fmt.Errorf("%w: split dir", ErrInvalidData)
726+ }
727+ for _, c := range n.Children {
728+ if err := validateNode(c, ids); err != nil {
729+ return err
730+ }
731+ }
732+ case layout.Stack:
733+ if _, ok := ids[n.Expanded]; n.Expanded != "" && !ok {
734+ return ErrUnknownPane
735+ }
736+ for _, c := range n.Children {
737+ if err := validateNode(c, ids); err != nil {
738+ return err
739+ }
740+ }
741+ default:
742+ return fmt.Errorf("%w: node kind", ErrInvalidData)
743+ }
744+ return nil
745+}
A internal/usecase/snapshot/codec_test.go +341 -0
@@ -0,0 +1,341 @@
1+package snapshot
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "errors"
7+ "hash/crc32"
8+ "math"
9+ "testing"
10+
11+ "github.com/bnema/vev/internal/usecase/layout"
12+ "github.com/bnema/vev/pkg/renderer"
13+)
14+
15+func TestMarshalMinimalGolden(t *testing.T) {
16+ got, err := Marshal(Session{Name: "s", CreatedAt: 7})
17+ if err != nil {
18+ t.Fatalf("Marshal() error = %v", err)
19+ }
20+ want := []byte{
21+ 'V', 'E', 'V', 'S', 0, 1, 0, 0, 0, 0, 0, 15, 0xff, 0xc2, 0x2e, 0xcb,
22+ 0, 1, 's', 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0,
23+ }
24+ if !bytes.Equal(got, want) {
25+ t.Fatalf("Marshal minimal bytes\ngot % x\nwant % x", got, want)
26+ }
27+}
28+
29+func TestRoundTripSessions(t *testing.T) {
30+ boldRGB := renderer.DefaultStyle()
31+ boldRGB.Bold = true
32+ boldRGB.HasForegroundRGB = true
33+ boldRGB.ForegroundRGB = renderer.RGB{R: 1, G: 2, B: 3}
34+ indexed := renderer.DefaultStyle()
35+ indexed.Foreground = 2
36+ indexed.Background = 4
37+ indexed.Inverse = true
38+ deepTree := &layout.Tree{Focus: "2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
39+ layout.NewLeaf("1"),
40+ &layout.Node{Kind: layout.Stack, Expanded: "2", Children: []*layout.Node{layout.NewLeaf("2"), layout.NewLeaf("3")}},
41+ }}}
42+ cjkVisible := [][]renderer.Cell{{
43+ {Rune: '好', Style: boldRGB},
44+ {Continuation: true, Style: boldRGB},
45+ {Rune: 'x', Style: indexed},
46+ }}
47+ multi := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{
48+ {Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2", Tree: deepTree, Panes: []Pane{{ID: "1", Cwd: "/a", Scrollback: rows("abc"), Visible: rows("v")}, {ID: "2", Cwd: "/b", Visible: rows("focus")}, {ID: "3", Cwd: "/c"}}},
49+ {Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", Visible: cjkVisible}}},
50+ }}
51+ empty := Session{Name: "blank", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: nil, Visible: rows(" ", "")}}}}}
52+ large := Session{Name: "large", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: manyRows(6000), Visible: rows("tail")}}}}}
53+ cases := []struct {
54+ name string
55+ s Session
56+ }{
57+ {name: "multi tab deep tree", s: multi},
58+ {name: "empty and all blank", s: empty},
59+ {name: "large scrollback", s: large},
60+ }
61+ for _, tc := range cases {
62+ t.Run(tc.name, func(t *testing.T) {
63+ b, err := Marshal(tc.s)
64+ if err != nil {
65+ t.Fatalf("Marshal() error = %v", err)
66+ }
67+ got, err := Unmarshal(b)
68+ if err != nil {
69+ t.Fatalf("Unmarshal() error = %v", err)
70+ }
71+ if !equalSession(got, trimSession(tc.s)) {
72+ t.Fatalf("round trip mismatch\ngot %#v\nwant %#v", got, trimSession(tc.s))
73+ }
74+ })
75+ }
76+}
77+
78+func TestUnmarshalRejectsMalformedWithoutPanic(t *testing.T) {
79+ good, err := Marshal(Session{Name: "s", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p"}}}}})
80+ if err != nil {
81+ t.Fatal(err)
82+ }
83+ cases := []struct {
84+ name string
85+ data []byte
86+ }{
87+ {"bad magic", append([]byte("NOPE"), good[4:]...)},
88+ {"bad version", replaceU16(good, 4, 2)},
89+ {"bad crc", append([]byte(nil), good[:len(good)-1]...)},
90+ {"trailing", append(append([]byte(nil), good...), 0)},
91+ {"body len overrun", replaceU32(good, 8, 999999)},
92+ {"prefix", good[:len(good)/2]},
93+ {"style oob", styleOOBSnapshot(t)},
94+ {"unknown pane in tree", unknownPaneSnapshot(t)},
95+ }
96+ for _, tc := range cases {
97+ t.Run(tc.name, func(t *testing.T) {
98+ defer func() {
99+ if r := recover(); r != nil {
100+ t.Fatalf("Unmarshal panicked: %v", r)
101+ }
102+ }()
103+ if _, err := Unmarshal(tc.data); err == nil {
104+ t.Fatalf("Unmarshal() error = nil")
105+ }
106+ })
107+ }
108+ for i := 0; i < len(good); i++ {
109+ if _, err := Unmarshal(good[:i]); err == nil {
110+ t.Fatalf("prefix length %d unexpectedly succeeded", i)
111+ }
112+ }
113+}
114+
115+func TestUnmarshalRejectsOversizedBodiesBeforeAllocation(t *testing.T) {
116+ body := []byte{0}
117+ oversizedRaw := reheader(body, 0)
118+ binary.BigEndian.PutUint32(oversizedRaw[8:12], uint32(maxDecodedBodySize+1))
119+ oversizedFlate := reheader(body, flagFlate)
120+ binary.BigEndian.PutUint32(oversizedFlate[8:12], uint32(maxDecodedBodySize+1))
121+ cases := []struct {
122+ name string
123+ data []byte
124+ }{
125+ {name: "raw", data: oversizedRaw},
126+ {name: "flate", data: oversizedFlate},
127+ }
128+ for _, tc := range cases {
129+ t.Run(tc.name, func(t *testing.T) {
130+ _, err := Unmarshal(tc.data)
131+ if !errors.Is(err, ErrInvalidData) {
132+ t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData)
133+ }
134+ })
135+ }
136+}
137+
138+func TestUnmarshalRejectsMalformedRowCountsWithoutAllocation(t *testing.T) {
139+ cases := []struct {
140+ name string
141+ data []byte
142+ want error
143+ }{
144+ {name: "huge row count", data: malformedRowsSnapshot(math.MaxUint32, 0), want: ErrInvalidData},
145+ {name: "huge run count", data: malformedRowsSnapshot(1, math.MaxUint16), want: ErrShortPayload},
146+ }
147+ for _, tc := range cases {
148+ t.Run(tc.name, func(t *testing.T) {
149+ defer func() {
150+ if r := recover(); r != nil {
151+ t.Fatalf("Unmarshal panicked: %v", r)
152+ }
153+ }()
154+ _, err := Unmarshal(tc.data)
155+ if !errors.Is(err, tc.want) {
156+ t.Fatalf("Unmarshal() error = %v, want %v", err, tc.want)
157+ }
158+ })
159+ }
160+}
161+
162+func TestFlateOnOff(t *testing.T) {
163+ small, err := Marshal(Session{Name: "small"})
164+ if err != nil {
165+ t.Fatal(err)
166+ }
167+ if flags := binary.BigEndian.Uint16(small[6:8]); flags != 0 {
168+ t.Fatalf("small flags = %#x, want 0", flags)
169+ }
170+ large, err := Marshal(Session{Name: "large", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Scrollback: manyRows(5000)}}}}})
171+ if err != nil {
172+ t.Fatal(err)
173+ }
174+ if flags := binary.BigEndian.Uint16(large[6:8]); flags&1 == 0 {
175+ t.Fatalf("large flags = %#x, want flate bit", flags)
176+ }
177+}
178+
179+func BenchmarkMarshal10KRows(b *testing.B) {
180+ s := Session{Name: "bench", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/tmp", Scrollback: manyRows(10000), Visible: rows("visible")}}}}}
181+ b.ReportAllocs()
182+ for i := 0; i < b.N; i++ {
183+ if _, err := Marshal(s); err != nil {
184+ b.Fatal(err)
185+ }
186+ }
187+}
188+
189+func rows(lines ...string) [][]renderer.Cell {
190+ out := make([][]renderer.Cell, len(lines))
191+ for i, line := range lines {
192+ for _, r := range line {
193+ out[i] = append(out[i], renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
194+ }
195+ }
196+ return out
197+}
198+
199+func manyRows(n int) [][]renderer.Cell {
200+ out := make([][]renderer.Cell, n)
201+ for i := range out {
202+ out[i] = rows("row data row data row data")[0]
203+ }
204+ return out
205+}
206+
207+func equalSession(a, b Session) bool {
208+ if a.Name != b.Name || a.CreatedAt != b.CreatedAt || a.Active != b.Active || len(a.Tabs) != len(b.Tabs) {
209+ return false
210+ }
211+ for i := range a.Tabs {
212+ if !equalTab(a.Tabs[i], b.Tabs[i]) {
213+ return false
214+ }
215+ }
216+ return true
217+}
218+
219+func equalTab(a, b Tab) bool {
220+ if a.Cols != b.Cols || a.Rows != b.Rows || a.NextPaneID != b.NextPaneID || a.Focus != b.Focus || len(a.Panes) != len(b.Panes) || !equalNode(a.Tree.Root, b.Tree.Root) {
221+ return false
222+ }
223+ for i := range a.Panes {
224+ if a.Panes[i].ID != b.Panes[i].ID || a.Panes[i].Cwd != b.Panes[i].Cwd || !equalRows(a.Panes[i].Scrollback, b.Panes[i].Scrollback) || !equalRows(a.Panes[i].Visible, b.Panes[i].Visible) {
225+ return false
226+ }
227+ }
228+ return true
229+}
230+
231+func equalNode(a, b *layout.Node) bool {
232+ if a == nil || b == nil {
233+ return a == b
234+ }
235+ if a.Kind != b.Kind || a.Dir != b.Dir || a.Leaf != b.Leaf || a.Expanded != b.Expanded || len(a.Children) != len(b.Children) {
236+ return false
237+ }
238+ for i := range a.Children {
239+ if !equalNode(a.Children[i], b.Children[i]) {
240+ return false
241+ }
242+ }
243+ return true
244+}
245+
246+func equalRows(a, b [][]renderer.Cell) bool {
247+ if len(a) != len(b) {
248+ return false
249+ }
250+ for i := range a {
251+ if len(a[i]) != len(b[i]) {
252+ return false
253+ }
254+ for j := range a[i] {
255+ if !a[i][j].Equal(b[i][j]) {
256+ return false
257+ }
258+ }
259+ }
260+ return true
261+}
262+
263+func trimSession(s Session) Session {
264+ for ti := range s.Tabs {
265+ for pi := range s.Tabs[ti].Panes {
266+ s.Tabs[ti].Panes[pi].Scrollback = trimRows(s.Tabs[ti].Panes[pi].Scrollback, false)
267+ s.Tabs[ti].Panes[pi].Visible = trimRows(s.Tabs[ti].Panes[pi].Visible, true)
268+ }
269+ }
270+ return s
271+}
272+
273+func reheader(body []byte, flags uint16) []byte {
274+ out := make([]byte, 16+len(body))
275+ copy(out[:4], []byte("VEVS"))
276+ binary.BigEndian.PutUint16(out[4:6], 1)
277+ binary.BigEndian.PutUint16(out[6:8], flags)
278+ binary.BigEndian.PutUint32(out[8:12], uint32(len(body)))
279+ binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))
280+ copy(out[16:], body)
281+ return out
282+}
283+
284+func replaceU16(b []byte, off int, v uint16) []byte {
285+ out := append([]byte(nil), b...)
286+ binary.BigEndian.PutUint16(out[off:], v)
287+ return out
288+}
289+func replaceU32(b []byte, off int, v uint32) []byte {
290+ out := append([]byte(nil), b...)
291+ binary.BigEndian.PutUint32(out[off:], v)
292+ return out
293+}
294+
295+func malformedRowsSnapshot(rowCount uint32, runCount uint16) []byte {
296+ var w payloadWriter
297+ _ = w.putString("x")
298+ w.putUint64(0)
299+ w.putUint16(0)
300+ w.putUint16(1)
301+ w.putUint16(0)
302+ w.putUint16(0)
303+ w.putUint64(0)
304+ _ = w.putString("p")
305+ _ = writeNode(&w, layout.NewLeaf("p"))
306+ w.putUint16(1)
307+ _ = w.putString("p")
308+ _ = w.putString("")
309+ w.putUint16(0)
310+ w.putUint32(rowCount)
311+ if rowCount > 0 {
312+ w.putUint16(runCount)
313+ }
314+ return reheader(w.b, 0)
315+}
316+
317+func styleOOBSnapshot(t *testing.T) []byte {
318+ b, err := Marshal(Session{Name: "x", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Visible: rows("x")}}}}})
319+ if err != nil {
320+ t.Fatal(err)
321+ }
322+ bodyLen := int(binary.BigEndian.Uint32(b[8:12]))
323+ body := append([]byte(nil), b[16:16+bodyLen]...)
324+ for i := 0; i < len(body)-5; i++ { // run len=1,rune='x', style=0,cflags=0
325+ if body[i] == 0 && body[i+1] == 1 && body[i+2] == 0 && body[i+3] == 0 && body[i+4] == 0 && body[i+5] == 'x' {
326+ body[i+6] = 0
327+ body[i+7] = 2
328+ return reheader(body, 0)
329+ }
330+ }
331+ t.Fatal("run not found")
332+ return nil
333+}
334+
335+func unknownPaneSnapshot(t *testing.T) []byte {
336+ b, err := Marshal(Session{Name: "x", Tabs: []Tab{{Tree: layout.NewTree("missing"), Focus: "missing", Panes: []Pane{{ID: "p"}}}}})
337+ if err != nil {
338+ t.Fatal(err)
339+ }
340+ return b
341+}
@@ -0,0 +1,341 @@
1+package snapshot
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "errors"
7+ "hash/crc32"
8+ "math"
9+ "testing"
10+
11+ "github.com/bnema/vev/internal/usecase/layout"
12+ "github.com/bnema/vev/pkg/renderer"
13+)
14+
15+func TestMarshalMinimalGolden(t *testing.T) {
16+ got, err := Marshal(Session{Name: "s", CreatedAt: 7})
17+ if err != nil {
18+ t.Fatalf("Marshal() error = %v", err)
19+ }
20+ want := []byte{
21+ 'V', 'E', 'V', 'S', 0, 1, 0, 0, 0, 0, 0, 15, 0xff, 0xc2, 0x2e, 0xcb,
22+ 0, 1, 's', 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0,
23+ }
24+ if !bytes.Equal(got, want) {
25+ t.Fatalf("Marshal minimal bytes\ngot % x\nwant % x", got, want)
26+ }
27+}
28+
29+func TestRoundTripSessions(t *testing.T) {
30+ boldRGB := renderer.DefaultStyle()
31+ boldRGB.Bold = true
32+ boldRGB.HasForegroundRGB = true
33+ boldRGB.ForegroundRGB = renderer.RGB{R: 1, G: 2, B: 3}
34+ indexed := renderer.DefaultStyle()
35+ indexed.Foreground = 2
36+ indexed.Background = 4
37+ indexed.Inverse = true
38+ deepTree := &layout.Tree{Focus: "2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
39+ layout.NewLeaf("1"),
40+ &layout.Node{Kind: layout.Stack, Expanded: "2", Children: []*layout.Node{layout.NewLeaf("2"), layout.NewLeaf("3")}},
41+ }}}
42+ cjkVisible := [][]renderer.Cell{{
43+ {Rune: '好', Style: boldRGB},
44+ {Continuation: true, Style: boldRGB},
45+ {Rune: 'x', Style: indexed},
46+ }}
47+ multi := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{
48+ {Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2", Tree: deepTree, Panes: []Pane{{ID: "1", Cwd: "/a", Scrollback: rows("abc"), Visible: rows("v")}, {ID: "2", Cwd: "/b", Visible: rows("focus")}, {ID: "3", Cwd: "/c"}}},
49+ {Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", Visible: cjkVisible}}},
50+ }}
51+ empty := Session{Name: "blank", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: nil, Visible: rows(" ", "")}}}}}
52+ large := Session{Name: "large", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: manyRows(6000), Visible: rows("tail")}}}}}
53+ cases := []struct {
54+ name string
55+ s Session
56+ }{
57+ {name: "multi tab deep tree", s: multi},
58+ {name: "empty and all blank", s: empty},
59+ {name: "large scrollback", s: large},
60+ }
61+ for _, tc := range cases {
62+ t.Run(tc.name, func(t *testing.T) {
63+ b, err := Marshal(tc.s)
64+ if err != nil {
65+ t.Fatalf("Marshal() error = %v", err)
66+ }
67+ got, err := Unmarshal(b)
68+ if err != nil {
69+ t.Fatalf("Unmarshal() error = %v", err)
70+ }
71+ if !equalSession(got, trimSession(tc.s)) {
72+ t.Fatalf("round trip mismatch\ngot %#v\nwant %#v", got, trimSession(tc.s))
73+ }
74+ })
75+ }
76+}
77+
78+func TestUnmarshalRejectsMalformedWithoutPanic(t *testing.T) {
79+ good, err := Marshal(Session{Name: "s", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p"}}}}})
80+ if err != nil {
81+ t.Fatal(err)
82+ }
83+ cases := []struct {
84+ name string
85+ data []byte
86+ }{
87+ {"bad magic", append([]byte("NOPE"), good[4:]...)},
88+ {"bad version", replaceU16(good, 4, 2)},
89+ {"bad crc", append([]byte(nil), good[:len(good)-1]...)},
90+ {"trailing", append(append([]byte(nil), good...), 0)},
91+ {"body len overrun", replaceU32(good, 8, 999999)},
92+ {"prefix", good[:len(good)/2]},
93+ {"style oob", styleOOBSnapshot(t)},
94+ {"unknown pane in tree", unknownPaneSnapshot(t)},
95+ }
96+ for _, tc := range cases {
97+ t.Run(tc.name, func(t *testing.T) {
98+ defer func() {
99+ if r := recover(); r != nil {
100+ t.Fatalf("Unmarshal panicked: %v", r)
101+ }
102+ }()
103+ if _, err := Unmarshal(tc.data); err == nil {
104+ t.Fatalf("Unmarshal() error = nil")
105+ }
106+ })
107+ }
108+ for i := 0; i < len(good); i++ {
109+ if _, err := Unmarshal(good[:i]); err == nil {
110+ t.Fatalf("prefix length %d unexpectedly succeeded", i)
111+ }
112+ }
113+}
114+
115+func TestUnmarshalRejectsOversizedBodiesBeforeAllocation(t *testing.T) {
116+ body := []byte{0}
117+ oversizedRaw := reheader(body, 0)
118+ binary.BigEndian.PutUint32(oversizedRaw[8:12], uint32(maxDecodedBodySize+1))
119+ oversizedFlate := reheader(body, flagFlate)
120+ binary.BigEndian.PutUint32(oversizedFlate[8:12], uint32(maxDecodedBodySize+1))
121+ cases := []struct {
122+ name string
123+ data []byte
124+ }{
125+ {name: "raw", data: oversizedRaw},
126+ {name: "flate", data: oversizedFlate},
127+ }
128+ for _, tc := range cases {
129+ t.Run(tc.name, func(t *testing.T) {
130+ _, err := Unmarshal(tc.data)
131+ if !errors.Is(err, ErrInvalidData) {
132+ t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData)
133+ }
134+ })
135+ }
136+}
137+
138+func TestUnmarshalRejectsMalformedRowCountsWithoutAllocation(t *testing.T) {
139+ cases := []struct {
140+ name string
141+ data []byte
142+ want error
143+ }{
144+ {name: "huge row count", data: malformedRowsSnapshot(math.MaxUint32, 0), want: ErrInvalidData},
145+ {name: "huge run count", data: malformedRowsSnapshot(1, math.MaxUint16), want: ErrShortPayload},
146+ }
147+ for _, tc := range cases {
148+ t.Run(tc.name, func(t *testing.T) {
149+ defer func() {
150+ if r := recover(); r != nil {
151+ t.Fatalf("Unmarshal panicked: %v", r)
152+ }
153+ }()
154+ _, err := Unmarshal(tc.data)
155+ if !errors.Is(err, tc.want) {
156+ t.Fatalf("Unmarshal() error = %v, want %v", err, tc.want)
157+ }
158+ })
159+ }
160+}
161+
162+func TestFlateOnOff(t *testing.T) {
163+ small, err := Marshal(Session{Name: "small"})
164+ if err != nil {
165+ t.Fatal(err)
166+ }
167+ if flags := binary.BigEndian.Uint16(small[6:8]); flags != 0 {
168+ t.Fatalf("small flags = %#x, want 0", flags)
169+ }
170+ large, err := Marshal(Session{Name: "large", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Scrollback: manyRows(5000)}}}}})
171+ if err != nil {
172+ t.Fatal(err)
173+ }
174+ if flags := binary.BigEndian.Uint16(large[6:8]); flags&1 == 0 {
175+ t.Fatalf("large flags = %#x, want flate bit", flags)
176+ }
177+}
178+
179+func BenchmarkMarshal10KRows(b *testing.B) {
180+ s := Session{Name: "bench", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/tmp", Scrollback: manyRows(10000), Visible: rows("visible")}}}}}
181+ b.ReportAllocs()
182+ for i := 0; i < b.N; i++ {
183+ if _, err := Marshal(s); err != nil {
184+ b.Fatal(err)
185+ }
186+ }
187+}
188+
189+func rows(lines ...string) [][]renderer.Cell {
190+ out := make([][]renderer.Cell, len(lines))
191+ for i, line := range lines {
192+ for _, r := range line {
193+ out[i] = append(out[i], renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
194+ }
195+ }
196+ return out
197+}
198+
199+func manyRows(n int) [][]renderer.Cell {
200+ out := make([][]renderer.Cell, n)
201+ for i := range out {
202+ out[i] = rows("row data row data row data")[0]
203+ }
204+ return out
205+}
206+
207+func equalSession(a, b Session) bool {
208+ if a.Name != b.Name || a.CreatedAt != b.CreatedAt || a.Active != b.Active || len(a.Tabs) != len(b.Tabs) {
209+ return false
210+ }
211+ for i := range a.Tabs {
212+ if !equalTab(a.Tabs[i], b.Tabs[i]) {
213+ return false
214+ }
215+ }
216+ return true
217+}
218+
219+func equalTab(a, b Tab) bool {
220+ if a.Cols != b.Cols || a.Rows != b.Rows || a.NextPaneID != b.NextPaneID || a.Focus != b.Focus || len(a.Panes) != len(b.Panes) || !equalNode(a.Tree.Root, b.Tree.Root) {
221+ return false
222+ }
223+ for i := range a.Panes {
224+ if a.Panes[i].ID != b.Panes[i].ID || a.Panes[i].Cwd != b.Panes[i].Cwd || !equalRows(a.Panes[i].Scrollback, b.Panes[i].Scrollback) || !equalRows(a.Panes[i].Visible, b.Panes[i].Visible) {
225+ return false
226+ }
227+ }
228+ return true
229+}
230+
231+func equalNode(a, b *layout.Node) bool {
232+ if a == nil || b == nil {
233+ return a == b
234+ }
235+ if a.Kind != b.Kind || a.Dir != b.Dir || a.Leaf != b.Leaf || a.Expanded != b.Expanded || len(a.Children) != len(b.Children) {
236+ return false
237+ }
238+ for i := range a.Children {
239+ if !equalNode(a.Children[i], b.Children[i]) {
240+ return false
241+ }
242+ }
243+ return true
244+}
245+
246+func equalRows(a, b [][]renderer.Cell) bool {
247+ if len(a) != len(b) {
248+ return false
249+ }
250+ for i := range a {
251+ if len(a[i]) != len(b[i]) {
252+ return false
253+ }
254+ for j := range a[i] {
255+ if !a[i][j].Equal(b[i][j]) {
256+ return false
257+ }
258+ }
259+ }
260+ return true
261+}
262+
263+func trimSession(s Session) Session {
264+ for ti := range s.Tabs {
265+ for pi := range s.Tabs[ti].Panes {
266+ s.Tabs[ti].Panes[pi].Scrollback = trimRows(s.Tabs[ti].Panes[pi].Scrollback, false)
267+ s.Tabs[ti].Panes[pi].Visible = trimRows(s.Tabs[ti].Panes[pi].Visible, true)
268+ }
269+ }
270+ return s
271+}
272+
273+func reheader(body []byte, flags uint16) []byte {
274+ out := make([]byte, 16+len(body))
275+ copy(out[:4], []byte("VEVS"))
276+ binary.BigEndian.PutUint16(out[4:6], 1)
277+ binary.BigEndian.PutUint16(out[6:8], flags)
278+ binary.BigEndian.PutUint32(out[8:12], uint32(len(body)))
279+ binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))
280+ copy(out[16:], body)
281+ return out
282+}
283+
284+func replaceU16(b []byte, off int, v uint16) []byte {
285+ out := append([]byte(nil), b...)
286+ binary.BigEndian.PutUint16(out[off:], v)
287+ return out
288+}
289+func replaceU32(b []byte, off int, v uint32) []byte {
290+ out := append([]byte(nil), b...)
291+ binary.BigEndian.PutUint32(out[off:], v)
292+ return out
293+}
294+
295+func malformedRowsSnapshot(rowCount uint32, runCount uint16) []byte {
296+ var w payloadWriter
297+ _ = w.putString("x")
298+ w.putUint64(0)
299+ w.putUint16(0)
300+ w.putUint16(1)
301+ w.putUint16(0)
302+ w.putUint16(0)
303+ w.putUint64(0)
304+ _ = w.putString("p")
305+ _ = writeNode(&w, layout.NewLeaf("p"))
306+ w.putUint16(1)
307+ _ = w.putString("p")
308+ _ = w.putString("")
309+ w.putUint16(0)
310+ w.putUint32(rowCount)
311+ if rowCount > 0 {
312+ w.putUint16(runCount)
313+ }
314+ return reheader(w.b, 0)
315+}
316+
317+func styleOOBSnapshot(t *testing.T) []byte {
318+ b, err := Marshal(Session{Name: "x", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Visible: rows("x")}}}}})
319+ if err != nil {
320+ t.Fatal(err)
321+ }
322+ bodyLen := int(binary.BigEndian.Uint32(b[8:12]))
323+ body := append([]byte(nil), b[16:16+bodyLen]...)
324+ for i := 0; i < len(body)-5; i++ { // run len=1,rune='x', style=0,cflags=0
325+ if body[i] == 0 && body[i+1] == 1 && body[i+2] == 0 && body[i+3] == 0 && body[i+4] == 0 && body[i+5] == 'x' {
326+ body[i+6] = 0
327+ body[i+7] = 2
328+ return reheader(body, 0)
329+ }
330+ }
331+ t.Fatal("run not found")
332+ return nil
333+}
334+
335+func unknownPaneSnapshot(t *testing.T) []byte {
336+ b, err := Marshal(Session{Name: "x", Tabs: []Tab{{Tree: layout.NewTree("missing"), Focus: "missing", Panes: []Pane{{ID: "p"}}}}})
337+ if err != nil {
338+ t.Fatal(err)
339+ }
340+ return b
341+}
A internal/usecase/snapshot/snapshot.go +33 -0
@@ -0,0 +1,33 @@
1+package snapshot
2+
3+import (
4+ "github.com/bnema/vev/internal/usecase/layout"
5+ "github.com/bnema/vev/pkg/renderer"
6+)
7+
8+// Session is the durable transfer representation for a named daemon session.
9+type Session struct {
10+ Name string
11+ CreatedAt uint64
12+ Active uint16
13+ Tabs []Tab
14+}
15+
16+// Tab captures one tab's layout, dimensions, and pane snapshots.
17+type Tab struct {
18+ Cols uint16
19+ Rows uint16
20+ NextPaneID uint64
21+ Focus layout.PaneID
22+ Tree *layout.Tree
23+ Panes []Pane
24+}
25+
26+// Pane captures terminal state needed to restore a fresh shell in the same cwd
27+// with its scrollback and final visible primary screen.
28+type Pane struct {
29+ ID layout.PaneID
30+ Cwd string
31+ Scrollback [][]renderer.Cell
32+ Visible [][]renderer.Cell
33+}
@@ -0,0 +1,33 @@
1+package snapshot
2+
3+import (
4+ "github.com/bnema/vev/internal/usecase/layout"
5+ "github.com/bnema/vev/pkg/renderer"
6+)
7+
8+// Session is the durable transfer representation for a named daemon session.
9+type Session struct {
10+ Name string
11+ CreatedAt uint64
12+ Active uint16
13+ Tabs []Tab
14+}
15+
16+// Tab captures one tab's layout, dimensions, and pane snapshots.
17+type Tab struct {
18+ Cols uint16
19+ Rows uint16
20+ NextPaneID uint64
21+ Focus layout.PaneID
22+ Tree *layout.Tree
23+ Panes []Pane
24+}
25+
26+// Pane captures terminal state needed to restore a fresh shell in the same cwd
27+// with its scrollback and final visible primary screen.
28+type Pane struct {
29+ ID layout.PaneID
30+ Cwd string
31+ Scrollback [][]renderer.Cell
32+ Visible [][]renderer.Cell
33+}
A pkg/vt/snapshot.go +18 -0
@@ -0,0 +1,18 @@
1+package vt
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+// PrimaryVisibleRows returns a deep copy of the visible primary-screen rows.
6+// When the alternate screen is active, it snapshots the saved primary screen
7+// rather than the currently displayed alternate frame.
8+func (s *Screen) PrimaryVisibleRows() [][]renderer.Cell {
9+ frame := s.Frame
10+ if s.alternate != nil {
11+ frame = s.alternate.frame
12+ }
13+ rows := make([][]renderer.Cell, frame.Height)
14+ for y := range frame.Height {
15+ rows[y] = append([]renderer.Cell(nil), frame.Row(y)...)
16+ }
17+ return rows
18+}
@@ -0,0 +1,18 @@
1+package vt
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+// PrimaryVisibleRows returns a deep copy of the visible primary-screen rows.
6+// When the alternate screen is active, it snapshots the saved primary screen
7+// rather than the currently displayed alternate frame.
8+func (s *Screen) PrimaryVisibleRows() [][]renderer.Cell {
9+ frame := s.Frame
10+ if s.alternate != nil {
11+ frame = s.alternate.frame
12+ }
13+ rows := make([][]renderer.Cell, frame.Height)
14+ for y := range frame.Height {
15+ rows[y] = append([]renderer.Cell(nil), frame.Row(y)...)
16+ }
17+ return rows
18+}
A pkg/vt/snapshot_test.go +39 -0
@@ -0,0 +1,39 @@
1+package vt
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestPrimaryVisibleRowsCopiesActivePrimaryScreen(t *testing.T) {
11+ s := NewScreen(6, 2)
12+ s.Write([]byte("hello"))
13+
14+ rows := s.PrimaryVisibleRows()
15+ require.Len(t, rows, 2)
16+ require.Equal(t, "hello ", rowString(rows[0]))
17+
18+ rows[0][0].Rune = 'X'
19+ rows[0] = nil
20+
21+ require.Equal(t, "hello ", rowString(s.Frame.Row(0)))
22+ require.Equal(t, "hello ", rowString(s.PrimaryVisibleRows()[0]))
23+}
24+
25+func TestPrimaryVisibleRowsUsesSavedPrimaryScreenWhenAlternateActive(t *testing.T) {
26+ s := NewScreen(8, 2)
27+ s.Write([]byte("primary"))
28+ s.Write([]byte("\x1b[?1049h"))
29+ s.Write([]byte("alt"))
30+
31+ rows := s.PrimaryVisibleRows()
32+ require.Len(t, rows, 2)
33+ require.Equal(t, "primary ", rowString(rows[0]))
34+ require.False(t, strings.Contains(rowString(rows[0]), "alt"))
35+
36+ rows[0][0].Rune = 'X'
37+ s.Write([]byte("\x1b[?1049l"))
38+ require.Equal(t, "primary ", rowString(s.Frame.Row(0)))
39+}
@@ -0,0 +1,39 @@
1+package vt
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestPrimaryVisibleRowsCopiesActivePrimaryScreen(t *testing.T) {
11+ s := NewScreen(6, 2)
12+ s.Write([]byte("hello"))
13+
14+ rows := s.PrimaryVisibleRows()
15+ require.Len(t, rows, 2)
16+ require.Equal(t, "hello ", rowString(rows[0]))
17+
18+ rows[0][0].Rune = 'X'
19+ rows[0] = nil
20+
21+ require.Equal(t, "hello ", rowString(s.Frame.Row(0)))
22+ require.Equal(t, "hello ", rowString(s.PrimaryVisibleRows()[0]))
23+}
24+
25+func TestPrimaryVisibleRowsUsesSavedPrimaryScreenWhenAlternateActive(t *testing.T) {
26+ s := NewScreen(8, 2)
27+ s.Write([]byte("primary"))
28+ s.Write([]byte("\x1b[?1049h"))
29+ s.Write([]byte("alt"))
30+
31+ rows := s.PrimaryVisibleRows()
32+ require.Len(t, rows, 2)
33+ require.Equal(t, "primary ", rowString(rows[0]))
34+ require.False(t, strings.Contains(rowString(rows[0]), "alt"))
35+
36+ rows[0][0].Rune = 'X'
37+ s.Write([]byte("\x1b[?1049l"))
38+ require.Equal(t, "primary ", rowString(s.Frame.Row(0)))
39+}

Keyboard shortcuts

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

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

Branches 2

Tags 13

↑↓ moveenter openesc close