detached 200f44c

Merge pull request #26 from bnema/feature/session-snapshot-restore

feat(daemon): persist named session snapshots
commit
200f44cb7a3fb074fc84e3968dd105635c9a865c
author
bnema · · 2026-07-06 06:12 UTC
committer
GitHub · · 2026-07-06 06:12 UTC
merge of
cabc301 + 5992754 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
24 files · +3028 -20
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, "err", 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, "err", 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, "err", 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, "err", 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 +53 -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,13 @@ 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+ select {
551+ case <-d.restoreDone:
552+ case <-d.serveCtx.Done():
553+ return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
554+ }
555+ }
517556
518557 d.mu.Lock()
519558 // 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,13 @@ 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+ select {
551+ case <-d.restoreDone:
552+ case <-d.serveCtx.Done():
553+ return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
554+ }
555+ }
517 556
518 d.mu.Lock()557 d.mu.Lock()
519 // Shutdown/create interlock: once shutdown has begun (last session removed,558 // 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 +41 -4
@@ -58,6 +58,8 @@ type session struct {
5858 cwd string
5959 createdAt int64
6060 mruAt atomic.Uint64
61+ snapDirty atomic.Bool
62+ snapEligible atomic.Bool
6163 // clipFiles records clipboard-image-transfer temp file paths (see
6264 // clipboard.go) written for this session, removed best-effort in
6365 // killSession.
@@ -152,6 +154,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
152154 if lastUsedSeq > 0 {
153155 sess.mruAt.Store(lastUsedSeq)
154156 }
157+ sess.snapEligible.Store(!ephemeral && name != "")
155158 if !ephemeral {
156159 if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq}); err != nil {
157160 _ = pty.Close()
@@ -263,6 +266,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
263266 d.log.Info("tab created", "session", name, "tab", tabIndex)
264267 d.startTabGoroutines(sess, tb)
265268 d.mu.Unlock()
269+ markSnapshotDirty(sess)
266270 return nil
267271 }
268272
@@ -360,21 +364,25 @@ func (s *session) activeTab() *tab {
360364
361365 func (s *session) switchTab(idx int) bool {
362366 s.mu.Lock()
363- defer s.mu.Unlock()
364367 if idx < 0 || idx >= len(s.tabs) || idx == s.active {
368+ s.mu.Unlock()
365369 return false
366370 }
367371 s.active = idx
372+ s.mu.Unlock()
373+ markSnapshotDirty(s)
368374 return true
369375 }
370376
371377 func (s *session) switchRelative(delta int) bool {
372378 s.mu.Lock()
373- defer s.mu.Unlock()
374379 if len(s.tabs) < 2 {
380+ s.mu.Unlock()
375381 return false
376382 }
377383 s.active = (s.active + delta + len(s.tabs)) % len(s.tabs)
384+ s.mu.Unlock()
385+ markSnapshotDirty(s)
378386 return true
379387 }
380388
@@ -391,7 +399,6 @@ func (d *Daemon) renameSession(sess *session, name string) error {
391399 return errors.New("name already in use")
392400 }
393401 sess.mu.Lock()
394- defer sess.mu.Unlock()
395402 oldName := sess.name
396403 wasEphemeral := sess.ephemeral
397404 createdAt := sess.createdAt
@@ -402,14 +409,25 @@ func (d *Daemon) renameSession(sess *session, name string) error {
402409 lastUsedSeq := sess.mruAt.Load()
403410 if wasEphemeral || oldName != name {
404411 if err := d.persist.Save(persist.Record{Name: name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: time.Now().UnixNano(), LastUsedSeq: lastUsedSeq}); err != nil {
412+ sess.mu.Unlock()
405413 return err
406414 }
407415 }
408416 if !wasEphemeral && oldName != name {
417+ if d.snapsEnabled && d.snaps != nil {
418+ if err := d.snaps.Delete(oldName); err != nil {
419+ if cleanupErr := d.persist.Delete(name); cleanupErr != nil {
420+ d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name)
421+ }
422+ sess.mu.Unlock()
423+ return err
424+ }
425+ }
409426 if err := d.persist.Delete(oldName); err != nil {
410427 if cleanupErr := d.persist.Delete(name); cleanupErr != nil {
411428 d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name)
412429 }
430+ sess.mu.Unlock()
413431 return err
414432 }
415433 }
@@ -417,6 +435,9 @@ func (d *Daemon) renameSession(sess *session, name string) error {
417435 delete(d.stopped, name)
418436 sess.name = name
419437 sess.ephemeral = false
438+ sess.snapEligible.Store(name != "")
439+ sess.mu.Unlock()
440+ markSnapshotDirty(sess)
420441 return nil
421442 }
422443
@@ -451,6 +472,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
451472 name := sess.name
452473 sess.mu.Unlock()
453474 d.log.Info("tab closed", "session", name)
475+ markSnapshotDirty(sess)
454476
455477 d.clearDestroyedTabPreview(tb)
456478 if tb.cancel != nil {
@@ -476,6 +498,20 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
476498 if !purge && !isEphemeral && d.persistEnabled {
477499 d.refreshSessionCwd(sess)
478500 }
501+ var snapshotDeleteErr error
502+ if d.snapsEnabled && !isEphemeral {
503+ if purge {
504+ sess.mu.Lock()
505+ name := sess.name
506+ sess.mu.Unlock()
507+ if err := d.snaps.Delete(name); err != nil {
508+ snapshotDeleteErr = err
509+ d.log.Warn("deleting session snapshot failed", "err", err, "session", name)
510+ }
511+ } else {
512+ d.captureSession(sess)
513+ }
514+ }
479515 d.mu.Lock()
480516 if _, ok := d.sessions[sess.id]; !ok {
481517 d.mu.Unlock()
@@ -503,7 +539,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
503539 d.mu.Unlock()
504540 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)
505541
506- var purgeErr error
542+ purgeErr := snapshotDeleteErr
507543 if !ephemeral && purge {
508544 if err := d.persist.Delete(stoppedName); err != nil {
509545 purgeErr = err
@@ -658,6 +694,7 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
658694 if err := d.persist.Touch(name, cwd, time.Now().UnixNano()); err != nil {
659695 d.log.Warn("touching persisted session cwd failed", "err", err, "session", name)
660696 }
697+ markSnapshotDirty(sess)
661698 }
662699 d.mu.Unlock()
663700 }
@@ -58,6 +58,8 @@ type session struct {
58 cwd string58 cwd string
59 createdAt int6459 createdAt int64
60 mruAt atomic.Uint6460 mruAt atomic.Uint64
61+ snapDirty atomic.Bool
62+ snapEligible atomic.Bool
61 // clipFiles records clipboard-image-transfer temp file paths (see63 // clipFiles records clipboard-image-transfer temp file paths (see
62 // clipboard.go) written for this session, removed best-effort in64 // clipboard.go) written for this session, removed best-effort in
63 // killSession.65 // killSession.
@@ -152,6 +154,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
152 if lastUsedSeq > 0 {154 if lastUsedSeq > 0 {
153 sess.mruAt.Store(lastUsedSeq)155 sess.mruAt.Store(lastUsedSeq)
154 }156 }
157+ sess.snapEligible.Store(!ephemeral && name != "")
155 if !ephemeral {158 if !ephemeral {
156 if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq}); err != nil {159 if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq}); err != nil {
157 _ = pty.Close()160 _ = pty.Close()
@@ -263,6 +266,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
263 d.log.Info("tab created", "session", name, "tab", tabIndex)266 d.log.Info("tab created", "session", name, "tab", tabIndex)
264 d.startTabGoroutines(sess, tb)267 d.startTabGoroutines(sess, tb)
265 d.mu.Unlock()268 d.mu.Unlock()
269+ markSnapshotDirty(sess)
266 return nil270 return nil
267 }271 }
268 272
@@ -360,21 +364,25 @@ func (s *session) activeTab() *tab {
360 364
361 func (s *session) switchTab(idx int) bool {365 func (s *session) switchTab(idx int) bool {
362 s.mu.Lock()366 s.mu.Lock()
363- defer s.mu.Unlock()
364 if idx < 0 || idx >= len(s.tabs) || idx == s.active {367 if idx < 0 || idx >= len(s.tabs) || idx == s.active {
368+ s.mu.Unlock()
365 return false369 return false
366 }370 }
367 s.active = idx371 s.active = idx
372+ s.mu.Unlock()
373+ markSnapshotDirty(s)
368 return true374 return true
369 }375 }
370 376
371 func (s *session) switchRelative(delta int) bool {377 func (s *session) switchRelative(delta int) bool {
372 s.mu.Lock()378 s.mu.Lock()
373- defer s.mu.Unlock()
374 if len(s.tabs) < 2 {379 if len(s.tabs) < 2 {
380+ s.mu.Unlock()
375 return false381 return false
376 }382 }
377 s.active = (s.active + delta + len(s.tabs)) % len(s.tabs)383 s.active = (s.active + delta + len(s.tabs)) % len(s.tabs)
384+ s.mu.Unlock()
385+ markSnapshotDirty(s)
378 return true386 return true
379 }387 }
380 388
@@ -391,7 +399,6 @@ func (d *Daemon) renameSession(sess *session, name string) error {
391 return errors.New("name already in use")399 return errors.New("name already in use")
392 }400 }
393 sess.mu.Lock()401 sess.mu.Lock()
394- defer sess.mu.Unlock()
395 oldName := sess.name402 oldName := sess.name
396 wasEphemeral := sess.ephemeral403 wasEphemeral := sess.ephemeral
397 createdAt := sess.createdAt404 createdAt := sess.createdAt
@@ -402,14 +409,25 @@ func (d *Daemon) renameSession(sess *session, name string) error {
402 lastUsedSeq := sess.mruAt.Load()409 lastUsedSeq := sess.mruAt.Load()
403 if wasEphemeral || oldName != name {410 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 {411 if err := d.persist.Save(persist.Record{Name: name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: time.Now().UnixNano(), LastUsedSeq: lastUsedSeq}); err != nil {
412+ sess.mu.Unlock()
405 return err413 return err
406 }414 }
407 }415 }
408 if !wasEphemeral && oldName != name {416 if !wasEphemeral && oldName != name {
417+ if d.snapsEnabled && d.snaps != nil {
418+ if err := d.snaps.Delete(oldName); err != nil {
419+ if cleanupErr := d.persist.Delete(name); cleanupErr != nil {
420+ d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name)
421+ }
422+ sess.mu.Unlock()
423+ return err
424+ }
425+ }
409 if err := d.persist.Delete(oldName); err != nil {426 if err := d.persist.Delete(oldName); err != nil {
410 if cleanupErr := d.persist.Delete(name); cleanupErr != nil {427 if cleanupErr := d.persist.Delete(name); cleanupErr != nil {
411 d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name)428 d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name)
412 }429 }
430+ sess.mu.Unlock()
413 return err431 return err
414 }432 }
415 }433 }
@@ -417,6 +435,9 @@ func (d *Daemon) renameSession(sess *session, name string) error {
417 delete(d.stopped, name)435 delete(d.stopped, name)
418 sess.name = name436 sess.name = name
419 sess.ephemeral = false437 sess.ephemeral = false
438+ sess.snapEligible.Store(name != "")
439+ sess.mu.Unlock()
440+ markSnapshotDirty(sess)
420 return nil441 return nil
421 }442 }
422 443
@@ -451,6 +472,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
451 name := sess.name472 name := sess.name
452 sess.mu.Unlock()473 sess.mu.Unlock()
453 d.log.Info("tab closed", "session", name)474 d.log.Info("tab closed", "session", name)
475+ markSnapshotDirty(sess)
454 476
455 d.clearDestroyedTabPreview(tb)477 d.clearDestroyedTabPreview(tb)
456 if tb.cancel != nil {478 if tb.cancel != nil {
@@ -476,6 +498,20 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
476 if !purge && !isEphemeral && d.persistEnabled {498 if !purge && !isEphemeral && d.persistEnabled {
477 d.refreshSessionCwd(sess)499 d.refreshSessionCwd(sess)
478 }500 }
501+ var snapshotDeleteErr error
502+ if d.snapsEnabled && !isEphemeral {
503+ if purge {
504+ sess.mu.Lock()
505+ name := sess.name
506+ sess.mu.Unlock()
507+ if err := d.snaps.Delete(name); err != nil {
508+ snapshotDeleteErr = err
509+ d.log.Warn("deleting session snapshot failed", "err", err, "session", name)
510+ }
511+ } else {
512+ d.captureSession(sess)
513+ }
514+ }
479 d.mu.Lock()515 d.mu.Lock()
480 if _, ok := d.sessions[sess.id]; !ok {516 if _, ok := d.sessions[sess.id]; !ok {
481 d.mu.Unlock()517 d.mu.Unlock()
@@ -503,7 +539,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
503 d.mu.Unlock()539 d.mu.Unlock()
504 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)540 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)
505 541
506- var purgeErr error542+ purgeErr := snapshotDeleteErr
507 if !ephemeral && purge {543 if !ephemeral && purge {
508 if err := d.persist.Delete(stoppedName); err != nil {544 if err := d.persist.Delete(stoppedName); err != nil {
509 purgeErr = err545 purgeErr = err
@@ -658,6 +694,7 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
658 if err := d.persist.Touch(name, cwd, time.Now().UnixNano()); err != nil {694 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)695 d.log.Warn("touching persisted session cwd failed", "err", err, "session", name)
660 }696 }
697+ markSnapshotDirty(sess)
661 }698 }
662 d.mu.Unlock()699 d.mu.Unlock()
663 }700 }
A internal/usecase/daemon/snapshot.go +345 -0
@@ -0,0 +1,345 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "fmt"
6+ "math"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/persist"
11+ scopy "github.com/bnema/vev/internal/usecase/copy"
12+ "github.com/bnema/vev/internal/usecase/layout"
13+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
14+ "github.com/bnema/vev/pkg/renderer"
15+ "github.com/bnema/vev/pkg/vt"
16+)
17+
18+const snapshotInterval = 30 * time.Second
19+
20+func (d *Daemon) closeRestoreDone() {
21+ d.restoreOnce.Do(func() { close(d.restoreDone) })
22+}
23+
24+func (d *Daemon) restoreSnapshots(ctx context.Context) {
25+ defer d.closeRestoreDone()
26+ if d == nil || !d.snapsEnabled || d.snaps == nil {
27+ return
28+ }
29+ blobs, err := d.snaps.Load()
30+ if err != nil {
31+ d.log.Warn("loading session snapshots failed", "err", err)
32+ return
33+ }
34+ for _, blob := range blobs {
35+ select {
36+ case <-ctx.Done():
37+ return
38+ default:
39+ }
40+ snap, err := snapcodec.Unmarshal(blob.Data)
41+ if err != nil {
42+ d.log.Warn("decoding session snapshot failed", "err", err, "session", blob.Name)
43+ continue
44+ }
45+ if err := d.restoreSession(ctx, snap); err != nil {
46+ d.log.Warn("restoring session snapshot failed", "err", err, "session", snap.Name)
47+ }
48+ }
49+}
50+
51+func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) error {
52+ if err := ctx.Err(); err != nil {
53+ return err
54+ }
55+ if snap.Name == "" {
56+ return fmt.Errorf("snapshot: empty session name")
57+ }
58+ if snap.CreatedAt > math.MaxInt64 {
59+ return fmt.Errorf("snapshot: created_at overflows int64")
60+ }
61+ d.mu.Lock()
62+ if d.closing || d.findByNameLocked(snap.Name) != nil {
63+ d.mu.Unlock()
64+ return nil
65+ }
66+ d.mu.Unlock()
67+
68+ sctx, cancel := context.WithCancel(d.serveCtx)
69+ opened := make([]*tab, 0, len(snap.Tabs))
70+ closeOpened := func() {
71+ for _, tb := range opened {
72+ tb.closeAllPanes()
73+ }
74+ cancel()
75+ }
76+ for _, tabSnap := range snap.Tabs {
77+ if err := ctx.Err(); err != nil {
78+ closeOpened()
79+ return err
80+ }
81+ tbSize := domain.Size{Cols: int(tabSnap.Cols), Rows: int(tabSnap.Rows)}
82+ if !tbSize.Valid() {
83+ closeOpened()
84+ return fmt.Errorf("snapshot: invalid tab size")
85+ }
86+ placements, ok := layout.Solve(tabSnap.Tree.Root, domain.Rect{Width: tbSize.Cols, Height: tbSize.Rows})
87+ if !ok {
88+ closeOpened()
89+ return fmt.Errorf("snapshot: unsolvable layout")
90+ }
91+ placementByPane := make(map[layout.PaneID]domain.Rect, len(placements))
92+ for _, pl := range placements {
93+ placementByPane[pl.ID] = pl.Content
94+ }
95+ tb := &tab{tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize}
96+ if tb.nextPaneID <= 0 {
97+ tb.nextPaneID = 1
98+ }
99+ tb.ctx, tb.cancel = context.WithCancel(sctx)
100+ opened = append(opened, tb)
101+ for _, paneSnap := range tabSnap.Panes {
102+ if err := ctx.Err(); err != nil {
103+ closeOpened()
104+ return err
105+ }
106+ contentRect, ok := placementByPane[paneSnap.ID]
107+ if !ok {
108+ closeOpened()
109+ return fmt.Errorf("snapshot: missing pane placement")
110+ }
111+ contentSize := restorePTYSize(contentRect, tbSize)
112+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name), paneSnap.Cwd, contentSize)
113+ if err != nil {
114+ closeOpened()
115+ return err
116+ }
117+ p := newPane(paneSnap.ID, pty, contentSize)
118+ p.ctx, p.cancel = context.WithCancel(tb.ctx)
119+ p.rect = contentRect
120+ seedPaneRows(p, paneSnap.Scrollback, paneSnap.Visible)
121+ tb.panes[paneSnap.ID] = p
122+ }
123+ }
124+ if len(opened) == 0 {
125+ cancel()
126+ return nil
127+ }
128+ createdAt := int64(snap.CreatedAt)
129+ sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), createdAt: createdAt}
130+ if sess.active < 0 || sess.active >= len(sess.tabs) {
131+ sess.active = 0
132+ }
133+ sess.snapEligible.Store(true)
134+ if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {
135+ sess.cwd = snap.Tabs[0].Panes[0].Cwd
136+ }
137+ d.mu.Lock()
138+ if stopped, ok := d.stopped[snap.Name]; ok {
139+ sess.mruAt.Store(stopped.lastUsedSeq)
140+ }
141+ d.mu.Unlock()
142+ lastUsedSeq := sess.mruAt.Load()
143+ if err := d.persist.Save(persist.Record{Name: snap.Name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq}); err != nil {
144+ closeOpened()
145+ return err
146+ }
147+ d.mu.Lock()
148+ if d.closing || d.findByNameLocked(snap.Name) != nil || ctx.Err() != nil {
149+ d.mu.Unlock()
150+ closeOpened()
151+ return nil
152+ }
153+ id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
154+ d.nextID++
155+ sess.id = id
156+ delete(d.stopped, snap.Name)
157+ d.sessions[id] = sess
158+ d.mu.Unlock()
159+ sess.snapDirty.Store(false)
160+ for _, tb := range opened {
161+ d.startTabGoroutines(sess, tb)
162+ }
163+ return nil
164+}
165+
166+func markSnapshotDirty(sess *session) {
167+ if sess == nil {
168+ return
169+ }
170+ if sess.snapEligible.Load() {
171+ sess.snapDirty.Store(true)
172+ }
173+}
174+
175+func seedPaneRows(p *pane, scrollback, visible [][]renderer.Cell) {
176+ p.mu.Lock()
177+ defer p.mu.Unlock()
178+ p.scrollback = scopy.NewScrollback(defaultScrollbackRows)
179+ for _, row := range scrollback {
180+ p.scrollback.Append(row)
181+ }
182+ if len(visible) > 0 {
183+ p.screen = vt.NewScreen(p.screen.Frame.Width, p.screen.Frame.Height)
184+ for y, row := range visible {
185+ if y >= p.screen.Frame.Height {
186+ break
187+ }
188+ for x, cell := range row {
189+ if x >= p.screen.Frame.Width {
190+ break
191+ }
192+ p.screen.Frame.Set(x, y, cell)
193+ }
194+ }
195+ }
196+ p.screen.OnLineEvicted = p.scrollback.Append
197+}
198+
199+func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size {
200+ cols := contentRect.Width
201+ if cols <= 0 {
202+ cols = tabSize.Cols
203+ }
204+ if cols < layout.MinPaneCols {
205+ cols = layout.MinPaneCols
206+ }
207+ rows := contentRect.Height
208+ if rows <= 0 {
209+ rows = min(max(tabSize.Rows, layout.MinPaneRows), 24)
210+ }
211+ if rows < layout.MinPaneRows {
212+ rows = layout.MinPaneRows
213+ }
214+ return domain.Size{Cols: cols, Rows: rows}
215+}
216+
217+func (d *Daemon) captureSession(sess *session) bool {
218+ if d == nil || sess == nil || !d.snapsEnabled || d.snaps == nil {
219+ return true
220+ }
221+
222+ sess.mu.Lock()
223+ name := sess.name
224+ ephemeral := sess.ephemeral
225+ createdAt := sess.createdAt
226+ active := sess.active
227+ fallbackCwd := sess.cwd
228+ tabs := append([]*tab(nil), sess.tabs...)
229+ sess.mu.Unlock()
230+ if ephemeral || name == "" {
231+ return true
232+ }
233+
234+ snap := snapcodec.Session{Name: name, CreatedAt: uint64(createdAt), Active: uint16(max(active, 0))}
235+ for _, tb := range tabs {
236+ tb.mu.Lock()
237+ tabSnap := snapcodec.Tab{
238+ Cols: uint16(max(tb.size.Cols, 0)),
239+ Rows: uint16(max(tb.size.Rows, 0)),
240+ NextPaneID: uint64(max(tb.nextPaneID, 0)),
241+ Tree: tb.tree.Clone(),
242+ }
243+ if tb.tree != nil {
244+ tabSnap.Focus = tb.tree.Focus
245+ }
246+ panes := make([]*pane, 0, len(tb.panes))
247+ for _, p := range tb.panes {
248+ panes = append(panes, p)
249+ }
250+ tb.mu.Unlock()
251+
252+ paneIDs := make(map[layout.PaneID]struct{}, len(panes))
253+ paneSnaps := make([]snapcodec.Pane, 0, len(panes))
254+ for _, p := range panes {
255+ p.mu.Lock()
256+ pid := 0
257+ if p.pty != nil {
258+ pid = p.pty.Pid()
259+ }
260+ paneSnap := snapcodec.Pane{
261+ ID: p.id,
262+ Scrollback: cloneRows(p.scrollback.Snapshot()),
263+ Visible: cloneRows(p.screen.PrimaryVisibleRows()),
264+ }
265+ p.mu.Unlock()
266+ paneSnap.Cwd = fallbackCwd
267+ if d.procCwd != nil && pid > 0 {
268+ if cwd, err := d.procCwd(pid); err == nil && cwd != "" {
269+ paneSnap.Cwd = cwd
270+ }
271+ }
272+ paneIDs[paneSnap.ID] = struct{}{}
273+ paneSnaps = append(paneSnaps, paneSnap)
274+ }
275+ if tabSnap.Tree != nil {
276+ pruneTreeToPanes(tabSnap.Tree.Root, paneIDs)
277+ }
278+ tabSnap.Panes = paneSnaps
279+ snap.Tabs = append(snap.Tabs, tabSnap)
280+ }
281+
282+ data, err := snapcodec.Marshal(snap)
283+ if err != nil {
284+ d.log.Warn("marshaling session snapshot failed", "err", err, "session", name)
285+ return false
286+ }
287+ if err := d.snaps.Write(name, data); err != nil {
288+ d.log.Warn("writing session snapshot failed", "err", err, "session", name)
289+ return false
290+ }
291+ return true
292+}
293+
294+func (d *Daemon) snapshotSaver(ctx context.Context) {
295+ t := d.clock.NewTimer(snapshotInterval)
296+ defer t.Stop()
297+ for {
298+ select {
299+ case <-ctx.Done():
300+ return
301+ case <-t.C():
302+ }
303+
304+ d.mu.Lock()
305+ sessions := d.sessionsSnapshotLocked()
306+ d.mu.Unlock()
307+ for _, sess := range sessions {
308+ if sess.snapEligible.Load() && sess.snapDirty.Swap(false) {
309+ if !d.captureSession(sess) {
310+ sess.snapDirty.Store(true)
311+ }
312+ }
313+ }
314+ t.Reset(snapshotInterval)
315+ }
316+}
317+
318+func cloneRows(rows [][]renderer.Cell) [][]renderer.Cell {
319+ if len(rows) == 0 {
320+ return nil
321+ }
322+ out := make([][]renderer.Cell, len(rows))
323+ for i, row := range rows {
324+ out[i] = append([]renderer.Cell(nil), row...)
325+ }
326+ return out
327+}
328+
329+func pruneTreeToPanes(n *layout.Node, panes map[layout.PaneID]struct{}) bool {
330+ if n == nil {
331+ return false
332+ }
333+ if n.Kind == layout.Leaf {
334+ _, ok := panes[n.Leaf]
335+ return ok
336+ }
337+ kept := n.Children[:0]
338+ for _, child := range n.Children {
339+ if pruneTreeToPanes(child, panes) {
340+ kept = append(kept, child)
341+ }
342+ }
343+ n.Children = kept
344+ return len(n.Children) > 0
345+}
@@ -0,0 +1,345 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "fmt"
6+ "math"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/persist"
11+ scopy "github.com/bnema/vev/internal/usecase/copy"
12+ "github.com/bnema/vev/internal/usecase/layout"
13+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
14+ "github.com/bnema/vev/pkg/renderer"
15+ "github.com/bnema/vev/pkg/vt"
16+)
17+
18+const snapshotInterval = 30 * time.Second
19+
20+func (d *Daemon) closeRestoreDone() {
21+ d.restoreOnce.Do(func() { close(d.restoreDone) })
22+}
23+
24+func (d *Daemon) restoreSnapshots(ctx context.Context) {
25+ defer d.closeRestoreDone()
26+ if d == nil || !d.snapsEnabled || d.snaps == nil {
27+ return
28+ }
29+ blobs, err := d.snaps.Load()
30+ if err != nil {
31+ d.log.Warn("loading session snapshots failed", "err", err)
32+ return
33+ }
34+ for _, blob := range blobs {
35+ select {
36+ case <-ctx.Done():
37+ return
38+ default:
39+ }
40+ snap, err := snapcodec.Unmarshal(blob.Data)
41+ if err != nil {
42+ d.log.Warn("decoding session snapshot failed", "err", err, "session", blob.Name)
43+ continue
44+ }
45+ if err := d.restoreSession(ctx, snap); err != nil {
46+ d.log.Warn("restoring session snapshot failed", "err", err, "session", snap.Name)
47+ }
48+ }
49+}
50+
51+func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) error {
52+ if err := ctx.Err(); err != nil {
53+ return err
54+ }
55+ if snap.Name == "" {
56+ return fmt.Errorf("snapshot: empty session name")
57+ }
58+ if snap.CreatedAt > math.MaxInt64 {
59+ return fmt.Errorf("snapshot: created_at overflows int64")
60+ }
61+ d.mu.Lock()
62+ if d.closing || d.findByNameLocked(snap.Name) != nil {
63+ d.mu.Unlock()
64+ return nil
65+ }
66+ d.mu.Unlock()
67+
68+ sctx, cancel := context.WithCancel(d.serveCtx)
69+ opened := make([]*tab, 0, len(snap.Tabs))
70+ closeOpened := func() {
71+ for _, tb := range opened {
72+ tb.closeAllPanes()
73+ }
74+ cancel()
75+ }
76+ for _, tabSnap := range snap.Tabs {
77+ if err := ctx.Err(); err != nil {
78+ closeOpened()
79+ return err
80+ }
81+ tbSize := domain.Size{Cols: int(tabSnap.Cols), Rows: int(tabSnap.Rows)}
82+ if !tbSize.Valid() {
83+ closeOpened()
84+ return fmt.Errorf("snapshot: invalid tab size")
85+ }
86+ placements, ok := layout.Solve(tabSnap.Tree.Root, domain.Rect{Width: tbSize.Cols, Height: tbSize.Rows})
87+ if !ok {
88+ closeOpened()
89+ return fmt.Errorf("snapshot: unsolvable layout")
90+ }
91+ placementByPane := make(map[layout.PaneID]domain.Rect, len(placements))
92+ for _, pl := range placements {
93+ placementByPane[pl.ID] = pl.Content
94+ }
95+ tb := &tab{tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize}
96+ if tb.nextPaneID <= 0 {
97+ tb.nextPaneID = 1
98+ }
99+ tb.ctx, tb.cancel = context.WithCancel(sctx)
100+ opened = append(opened, tb)
101+ for _, paneSnap := range tabSnap.Panes {
102+ if err := ctx.Err(); err != nil {
103+ closeOpened()
104+ return err
105+ }
106+ contentRect, ok := placementByPane[paneSnap.ID]
107+ if !ok {
108+ closeOpened()
109+ return fmt.Errorf("snapshot: missing pane placement")
110+ }
111+ contentSize := restorePTYSize(contentRect, tbSize)
112+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name), paneSnap.Cwd, contentSize)
113+ if err != nil {
114+ closeOpened()
115+ return err
116+ }
117+ p := newPane(paneSnap.ID, pty, contentSize)
118+ p.ctx, p.cancel = context.WithCancel(tb.ctx)
119+ p.rect = contentRect
120+ seedPaneRows(p, paneSnap.Scrollback, paneSnap.Visible)
121+ tb.panes[paneSnap.ID] = p
122+ }
123+ }
124+ if len(opened) == 0 {
125+ cancel()
126+ return nil
127+ }
128+ createdAt := int64(snap.CreatedAt)
129+ sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), createdAt: createdAt}
130+ if sess.active < 0 || sess.active >= len(sess.tabs) {
131+ sess.active = 0
132+ }
133+ sess.snapEligible.Store(true)
134+ if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {
135+ sess.cwd = snap.Tabs[0].Panes[0].Cwd
136+ }
137+ d.mu.Lock()
138+ if stopped, ok := d.stopped[snap.Name]; ok {
139+ sess.mruAt.Store(stopped.lastUsedSeq)
140+ }
141+ d.mu.Unlock()
142+ lastUsedSeq := sess.mruAt.Load()
143+ if err := d.persist.Save(persist.Record{Name: snap.Name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq}); err != nil {
144+ closeOpened()
145+ return err
146+ }
147+ d.mu.Lock()
148+ if d.closing || d.findByNameLocked(snap.Name) != nil || ctx.Err() != nil {
149+ d.mu.Unlock()
150+ closeOpened()
151+ return nil
152+ }
153+ id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
154+ d.nextID++
155+ sess.id = id
156+ delete(d.stopped, snap.Name)
157+ d.sessions[id] = sess
158+ d.mu.Unlock()
159+ sess.snapDirty.Store(false)
160+ for _, tb := range opened {
161+ d.startTabGoroutines(sess, tb)
162+ }
163+ return nil
164+}
165+
166+func markSnapshotDirty(sess *session) {
167+ if sess == nil {
168+ return
169+ }
170+ if sess.snapEligible.Load() {
171+ sess.snapDirty.Store(true)
172+ }
173+}
174+
175+func seedPaneRows(p *pane, scrollback, visible [][]renderer.Cell) {
176+ p.mu.Lock()
177+ defer p.mu.Unlock()
178+ p.scrollback = scopy.NewScrollback(defaultScrollbackRows)
179+ for _, row := range scrollback {
180+ p.scrollback.Append(row)
181+ }
182+ if len(visible) > 0 {
183+ p.screen = vt.NewScreen(p.screen.Frame.Width, p.screen.Frame.Height)
184+ for y, row := range visible {
185+ if y >= p.screen.Frame.Height {
186+ break
187+ }
188+ for x, cell := range row {
189+ if x >= p.screen.Frame.Width {
190+ break
191+ }
192+ p.screen.Frame.Set(x, y, cell)
193+ }
194+ }
195+ }
196+ p.screen.OnLineEvicted = p.scrollback.Append
197+}
198+
199+func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size {
200+ cols := contentRect.Width
201+ if cols <= 0 {
202+ cols = tabSize.Cols
203+ }
204+ if cols < layout.MinPaneCols {
205+ cols = layout.MinPaneCols
206+ }
207+ rows := contentRect.Height
208+ if rows <= 0 {
209+ rows = min(max(tabSize.Rows, layout.MinPaneRows), 24)
210+ }
211+ if rows < layout.MinPaneRows {
212+ rows = layout.MinPaneRows
213+ }
214+ return domain.Size{Cols: cols, Rows: rows}
215+}
216+
217+func (d *Daemon) captureSession(sess *session) bool {
218+ if d == nil || sess == nil || !d.snapsEnabled || d.snaps == nil {
219+ return true
220+ }
221+
222+ sess.mu.Lock()
223+ name := sess.name
224+ ephemeral := sess.ephemeral
225+ createdAt := sess.createdAt
226+ active := sess.active
227+ fallbackCwd := sess.cwd
228+ tabs := append([]*tab(nil), sess.tabs...)
229+ sess.mu.Unlock()
230+ if ephemeral || name == "" {
231+ return true
232+ }
233+
234+ snap := snapcodec.Session{Name: name, CreatedAt: uint64(createdAt), Active: uint16(max(active, 0))}
235+ for _, tb := range tabs {
236+ tb.mu.Lock()
237+ tabSnap := snapcodec.Tab{
238+ Cols: uint16(max(tb.size.Cols, 0)),
239+ Rows: uint16(max(tb.size.Rows, 0)),
240+ NextPaneID: uint64(max(tb.nextPaneID, 0)),
241+ Tree: tb.tree.Clone(),
242+ }
243+ if tb.tree != nil {
244+ tabSnap.Focus = tb.tree.Focus
245+ }
246+ panes := make([]*pane, 0, len(tb.panes))
247+ for _, p := range tb.panes {
248+ panes = append(panes, p)
249+ }
250+ tb.mu.Unlock()
251+
252+ paneIDs := make(map[layout.PaneID]struct{}, len(panes))
253+ paneSnaps := make([]snapcodec.Pane, 0, len(panes))
254+ for _, p := range panes {
255+ p.mu.Lock()
256+ pid := 0
257+ if p.pty != nil {
258+ pid = p.pty.Pid()
259+ }
260+ paneSnap := snapcodec.Pane{
261+ ID: p.id,
262+ Scrollback: cloneRows(p.scrollback.Snapshot()),
263+ Visible: cloneRows(p.screen.PrimaryVisibleRows()),
264+ }
265+ p.mu.Unlock()
266+ paneSnap.Cwd = fallbackCwd
267+ if d.procCwd != nil && pid > 0 {
268+ if cwd, err := d.procCwd(pid); err == nil && cwd != "" {
269+ paneSnap.Cwd = cwd
270+ }
271+ }
272+ paneIDs[paneSnap.ID] = struct{}{}
273+ paneSnaps = append(paneSnaps, paneSnap)
274+ }
275+ if tabSnap.Tree != nil {
276+ pruneTreeToPanes(tabSnap.Tree.Root, paneIDs)
277+ }
278+ tabSnap.Panes = paneSnaps
279+ snap.Tabs = append(snap.Tabs, tabSnap)
280+ }
281+
282+ data, err := snapcodec.Marshal(snap)
283+ if err != nil {
284+ d.log.Warn("marshaling session snapshot failed", "err", err, "session", name)
285+ return false
286+ }
287+ if err := d.snaps.Write(name, data); err != nil {
288+ d.log.Warn("writing session snapshot failed", "err", err, "session", name)
289+ return false
290+ }
291+ return true
292+}
293+
294+func (d *Daemon) snapshotSaver(ctx context.Context) {
295+ t := d.clock.NewTimer(snapshotInterval)
296+ defer t.Stop()
297+ for {
298+ select {
299+ case <-ctx.Done():
300+ return
301+ case <-t.C():
302+ }
303+
304+ d.mu.Lock()
305+ sessions := d.sessionsSnapshotLocked()
306+ d.mu.Unlock()
307+ for _, sess := range sessions {
308+ if sess.snapEligible.Load() && sess.snapDirty.Swap(false) {
309+ if !d.captureSession(sess) {
310+ sess.snapDirty.Store(true)
311+ }
312+ }
313+ }
314+ t.Reset(snapshotInterval)
315+ }
316+}
317+
318+func cloneRows(rows [][]renderer.Cell) [][]renderer.Cell {
319+ if len(rows) == 0 {
320+ return nil
321+ }
322+ out := make([][]renderer.Cell, len(rows))
323+ for i, row := range rows {
324+ out[i] = append([]renderer.Cell(nil), row...)
325+ }
326+ return out
327+}
328+
329+func pruneTreeToPanes(n *layout.Node, panes map[layout.PaneID]struct{}) bool {
330+ if n == nil {
331+ return false
332+ }
333+ if n.Kind == layout.Leaf {
334+ _, ok := panes[n.Leaf]
335+ return ok
336+ }
337+ kept := n.Children[:0]
338+ for _, child := range n.Children {
339+ if pruneTreeToPanes(child, panes) {
340+ kept = append(kept, child)
341+ }
342+ }
343+ n.Children = kept
344+ return len(n.Children) > 0
345+}
A internal/usecase/daemon/snapshot_restore_test.go +289 -0
@@ -0,0 +1,289 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "io"
6+ "math"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ scopy "github.com/bnema/vev/internal/usecase/copy"
16+ "github.com/bnema/vev/internal/usecase/layout"
17+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
18+ "github.com/bnema/vev/pkg/renderer"
19+)
20+
21+func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
22+ store := &restoreSnapshotStore{}
23+ store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
24+ Name: "work",
25+ CreatedAt: 99,
26+ Active: 0,
27+ Tabs: []snapcodec.Tab{{
28+ Cols: 80,
29+ Rows: 24,
30+ NextPaneID: 3,
31+ Focus: "pane-2",
32+ Tree: &layout.Tree{Focus: "pane-2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
33+ layout.NewLeaf("pane-1"),
34+ layout.NewLeaf("pane-2"),
35+ }}},
36+ Panes: []snapcodec.Pane{
37+ {ID: "pane-1", Cwd: "/one", Scrollback: [][]renderer.Cell{cells("old1")}, Visible: [][]renderer.Cell{cells("vis1")}},
38+ {ID: "pane-2", Cwd: "/two", Scrollback: [][]renderer.Cell{cells("old2")}, Visible: [][]renderer.Cell{cells("vis2")}},
39+ },
40+ }},
41+ })}}
42+ factory := &restorePTYFactory{}
43+ d := newTestDaemon(t, factory, stubClock{})
44+ WithSnapshotStore(store)(d)
45+
46+ d.restoreSnapshots(context.Background())
47+
48+ require.Len(t, factory.opens, 2)
49+ require.Equal(t, "/one", factory.opens[0].dir)
50+ require.Equal(t, "/two", factory.opens[1].dir)
51+ require.Equal(t, domain.Size{Cols: 40, Rows: 24}, factory.opens[0].size)
52+
53+ d.mu.Lock()
54+ restored := d.findByNameLocked("work")
55+ d.mu.Unlock()
56+ require.NotNil(t, restored)
57+ require.False(t, restored.snapDirty.Load())
58+ require.Equal(t, domain.SessionID("sess-0"), restored.id)
59+ require.Equal(t, int64(99), restored.createdAt)
60+ require.Equal(t, "/one", restored.cwd)
61+
62+ restored.mu.Lock()
63+ require.Len(t, restored.tabs, 1)
64+ tb := restored.tabs[0]
65+ restored.mu.Unlock()
66+ tb.mu.Lock()
67+ require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
68+ require.Equal(t, 3, tb.nextPaneID)
69+ p := tb.panes["pane-2"]
70+ tb.mu.Unlock()
71+ p.mu.Lock()
72+ require.Equal(t, "old2", rowText(p.scrollback.Row(0)))
73+ require.Equal(t, 1, p.scrollback.Len())
74+ require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4]))
75+ copySnap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
76+ require.Equal(t, "old2", rowText(copySnap.Rows[0][:4]))
77+ require.Equal(t, "vis2", rowText(copySnap.Rows[1][:4]))
78+ p.mu.Unlock()
79+}
80+
81+func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) {
82+ store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{
83+ Name: "stacked",
84+ Tabs: []snapcodec.Tab{{
85+ Cols: 20,
86+ Rows: 4,
87+ Tree: &layout.Tree{Focus: "b", Root: &layout.Node{Kind: layout.Stack, Expanded: "b", Children: []*layout.Node{
88+ layout.NewLeaf("a"), layout.NewLeaf("b"), layout.NewLeaf("c"),
89+ }}},
90+ Panes: []snapcodec.Pane{{ID: "a", Cwd: "/a"}, {ID: "b", Cwd: "/b"}, {ID: "c", Cwd: "/c"}},
91+ }},
92+ })}}}
93+ factory := &restorePTYFactory{}
94+ d := newTestDaemon(t, factory, stubClock{})
95+ WithSnapshotStore(store)(d)
96+
97+ d.restoreSnapshots(context.Background())
98+
99+ require.Len(t, factory.opens, 3)
100+ for _, open := range factory.opens {
101+ require.True(t, open.size.Valid(), "open size for %s was %#v", open.dir, open.size)
102+ require.GreaterOrEqual(t, open.size.Cols, layout.MinPaneCols)
103+ require.GreaterOrEqual(t, open.size.Rows, layout.MinPaneRows)
104+ }
105+ d.mu.Lock()
106+ restored := d.findByNameLocked("stacked")
107+ d.mu.Unlock()
108+ require.NotNil(t, restored)
109+ restored.mu.Lock()
110+ tb := restored.tabs[0]
111+ restored.mu.Unlock()
112+ tb.mu.Lock()
113+ require.Equal(t, layout.Stack, tb.tree.Root.Kind)
114+ require.Equal(t, layout.PaneID("b"), tb.tree.Root.Expanded)
115+ require.Len(t, tb.panes, 3)
116+ tb.mu.Unlock()
117+}
118+
119+func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) {
120+ 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"}}}}})
121+ tests := []struct {
122+ name string
123+ blobs []ports.SnapshotBlob
124+ liveName string
125+ wantOpen int
126+ }{
127+ {name: "empty store"},
128+ {name: "corrupt blob", blobs: []ports.SnapshotBlob{{Name: "bad", Data: []byte("not a snapshot")}}},
129+ {name: "live name", blobs: []ports.SnapshotBlob{{Name: "live", Data: valid}}, liveName: "live"},
130+ }
131+ for _, tt := range tests {
132+ t.Run(tt.name, func(t *testing.T) {
133+ factory := &restorePTYFactory{}
134+ d := newTestDaemon(t, factory, stubClock{})
135+ WithSnapshotStore(&restoreSnapshotStore{blobs: tt.blobs})(d)
136+ if tt.liveName != "" {
137+ d.sessions["existing"] = &session{id: "existing", name: tt.liveName}
138+ }
139+
140+ require.NotPanics(t, func() { d.restoreSnapshots(context.Background()) })
141+ require.Len(t, factory.opens, tt.wantOpen)
142+ })
143+ }
144+}
145+
146+func TestRestoreSnapshotsSkipsCreatedAtOverflow(t *testing.T) {
147+ store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "overflow", Data: mustSnapshotBytes(t, snapcodec.Session{
148+ Name: "overflow",
149+ CreatedAt: uint64(math.MaxInt64) + 1,
150+ Tabs: []snapcodec.Tab{{
151+ Cols: 80,
152+ Rows: 24,
153+ Tree: layout.NewTree("pane-1"),
154+ Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/bad"}},
155+ }},
156+ })}}}
157+ factory := &restorePTYFactory{}
158+ d := newTestDaemon(t, factory, stubClock{})
159+ WithSnapshotStore(store)(d)
160+
161+ d.restoreSnapshots(context.Background())
162+
163+ records, err := d.persist.LoadAll()
164+ require.NoError(t, err)
165+ require.Empty(t, records)
166+ require.Len(t, factory.opens, 0)
167+ d.mu.Lock()
168+ restored := d.findByNameLocked("overflow")
169+ d.mu.Unlock()
170+ require.Nil(t, restored)
171+}
172+
173+func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) {
174+ releaseLoad := make(chan struct{})
175+ loaded := make(chan struct{})
176+ store := &restoreSnapshotStore{
177+ loadFn: func() ([]ports.SnapshotBlob, error) {
178+ close(loaded)
179+ <-releaseLoad
180+ 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
181+ },
182+ }
183+ factory := &restorePTYFactory{}
184+ d := newTestDaemon(t, factory, stubClock{})
185+ WithSnapshotStore(store)(d)
186+
187+ go d.restoreSnapshots(context.Background())
188+ <-loaded
189+
190+ routed := make(chan *session, 1)
191+ go func() {
192+ tr, _ := newCapturingTransport(t)
193+ sess, _, err := d.route(ports.Hello{Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 26}}, tr)
194+ require.NoError(t, err)
195+ routed <- sess
196+ }()
197+
198+ select {
199+ case <-routed:
200+ t.Fatal("named attach routed before restore barrier closed")
201+ case <-time.After(25 * time.Millisecond):
202+ }
203+ close(releaseLoad)
204+ select {
205+ case sess := <-routed:
206+ require.Equal(t, "work", sess.name)
207+ require.Len(t, factory.opens, 1)
208+ require.Equal(t, "/restored", factory.opens[0].dir)
209+ case <-time.After(time.Second):
210+ t.Fatal("timed out waiting for named attach after restore")
211+ }
212+}
213+
214+func TestNamedRouteRestoreBarrierReturnsShutdown(t *testing.T) {
215+ d := newTestDaemon(t, &restorePTYFactory{}, stubClock{})
216+ WithSnapshotStore(&restoreSnapshotStore{})(d)
217+ d.serveCancel()
218+
219+ tr, _ := newCapturingTransport(t)
220+ sess, ac, err := d.route(ports.Hello{Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}}, tr)
221+
222+ require.Nil(t, sess)
223+ require.Nil(t, ac)
224+ var perr *protoErr
225+ require.ErrorAs(t, err, &perr)
226+ require.Equal(t, ports.ErrServerShutdown, perr.code)
227+}
228+
229+func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte {
230+ t.Helper()
231+ b, err := snapcodec.Marshal(s)
232+ require.NoError(t, err)
233+ return b
234+}
235+
236+func cells(s string) []renderer.Cell {
237+ out := make([]renderer.Cell, 0, len(s))
238+ for _, r := range s {
239+ out = append(out, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
240+ }
241+ return out
242+}
243+
244+type restoreSnapshotStore struct {
245+ blobs []ports.SnapshotBlob
246+ loadFn func() ([]ports.SnapshotBlob, error)
247+}
248+
249+func (s *restoreSnapshotStore) Write(string, []byte) error { return nil }
250+func (s *restoreSnapshotStore) Delete(string) error { return nil }
251+func (s *restoreSnapshotStore) Load() ([]ports.SnapshotBlob, error) {
252+ if s.loadFn != nil {
253+ return s.loadFn()
254+ }
255+ return s.blobs, nil
256+}
257+
258+type restorePTYFactory struct {
259+ mu sync.Mutex
260+ opens []restorePTYOpen
261+}
262+
263+type restorePTYOpen struct {
264+ dir string
265+ size domain.Size
266+}
267+
268+func (f *restorePTYFactory) Open(_ string, _ []string, _ []string, dir string, sz domain.Size) (ports.PTY, error) {
269+ f.mu.Lock()
270+ defer f.mu.Unlock()
271+ f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz})
272+ return newRestorePTY(), nil
273+}
274+
275+type restorePTY struct {
276+ done chan struct{}
277+ once sync.Once
278+}
279+
280+func newRestorePTY() *restorePTY { return &restorePTY{done: make(chan struct{})} }
281+func (p *restorePTY) Read([]byte) (int, error) {
282+ <-p.done
283+ return 0, io.EOF
284+}
285+func (p *restorePTY) Write(b []byte) (int, error) { return len(b), nil }
286+func (p *restorePTY) Close() error { p.once.Do(func() { close(p.done) }); return nil }
287+func (p *restorePTY) Resize(domain.Size) error { return nil }
288+func (p *restorePTY) Pid() int { return 0 }
289+func (p *restorePTY) ForegroundPgid() (int, error) { return 0, nil }
@@ -0,0 +1,289 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "io"
6+ "math"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ scopy "github.com/bnema/vev/internal/usecase/copy"
16+ "github.com/bnema/vev/internal/usecase/layout"
17+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
18+ "github.com/bnema/vev/pkg/renderer"
19+)
20+
21+func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
22+ store := &restoreSnapshotStore{}
23+ store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
24+ Name: "work",
25+ CreatedAt: 99,
26+ Active: 0,
27+ Tabs: []snapcodec.Tab{{
28+ Cols: 80,
29+ Rows: 24,
30+ NextPaneID: 3,
31+ Focus: "pane-2",
32+ Tree: &layout.Tree{Focus: "pane-2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
33+ layout.NewLeaf("pane-1"),
34+ layout.NewLeaf("pane-2"),
35+ }}},
36+ Panes: []snapcodec.Pane{
37+ {ID: "pane-1", Cwd: "/one", Scrollback: [][]renderer.Cell{cells("old1")}, Visible: [][]renderer.Cell{cells("vis1")}},
38+ {ID: "pane-2", Cwd: "/two", Scrollback: [][]renderer.Cell{cells("old2")}, Visible: [][]renderer.Cell{cells("vis2")}},
39+ },
40+ }},
41+ })}}
42+ factory := &restorePTYFactory{}
43+ d := newTestDaemon(t, factory, stubClock{})
44+ WithSnapshotStore(store)(d)
45+
46+ d.restoreSnapshots(context.Background())
47+
48+ require.Len(t, factory.opens, 2)
49+ require.Equal(t, "/one", factory.opens[0].dir)
50+ require.Equal(t, "/two", factory.opens[1].dir)
51+ require.Equal(t, domain.Size{Cols: 40, Rows: 24}, factory.opens[0].size)
52+
53+ d.mu.Lock()
54+ restored := d.findByNameLocked("work")
55+ d.mu.Unlock()
56+ require.NotNil(t, restored)
57+ require.False(t, restored.snapDirty.Load())
58+ require.Equal(t, domain.SessionID("sess-0"), restored.id)
59+ require.Equal(t, int64(99), restored.createdAt)
60+ require.Equal(t, "/one", restored.cwd)
61+
62+ restored.mu.Lock()
63+ require.Len(t, restored.tabs, 1)
64+ tb := restored.tabs[0]
65+ restored.mu.Unlock()
66+ tb.mu.Lock()
67+ require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
68+ require.Equal(t, 3, tb.nextPaneID)
69+ p := tb.panes["pane-2"]
70+ tb.mu.Unlock()
71+ p.mu.Lock()
72+ require.Equal(t, "old2", rowText(p.scrollback.Row(0)))
73+ require.Equal(t, 1, p.scrollback.Len())
74+ require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4]))
75+ copySnap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
76+ require.Equal(t, "old2", rowText(copySnap.Rows[0][:4]))
77+ require.Equal(t, "vis2", rowText(copySnap.Rows[1][:4]))
78+ p.mu.Unlock()
79+}
80+
81+func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) {
82+ store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{
83+ Name: "stacked",
84+ Tabs: []snapcodec.Tab{{
85+ Cols: 20,
86+ Rows: 4,
87+ Tree: &layout.Tree{Focus: "b", Root: &layout.Node{Kind: layout.Stack, Expanded: "b", Children: []*layout.Node{
88+ layout.NewLeaf("a"), layout.NewLeaf("b"), layout.NewLeaf("c"),
89+ }}},
90+ Panes: []snapcodec.Pane{{ID: "a", Cwd: "/a"}, {ID: "b", Cwd: "/b"}, {ID: "c", Cwd: "/c"}},
91+ }},
92+ })}}}
93+ factory := &restorePTYFactory{}
94+ d := newTestDaemon(t, factory, stubClock{})
95+ WithSnapshotStore(store)(d)
96+
97+ d.restoreSnapshots(context.Background())
98+
99+ require.Len(t, factory.opens, 3)
100+ for _, open := range factory.opens {
101+ require.True(t, open.size.Valid(), "open size for %s was %#v", open.dir, open.size)
102+ require.GreaterOrEqual(t, open.size.Cols, layout.MinPaneCols)
103+ require.GreaterOrEqual(t, open.size.Rows, layout.MinPaneRows)
104+ }
105+ d.mu.Lock()
106+ restored := d.findByNameLocked("stacked")
107+ d.mu.Unlock()
108+ require.NotNil(t, restored)
109+ restored.mu.Lock()
110+ tb := restored.tabs[0]
111+ restored.mu.Unlock()
112+ tb.mu.Lock()
113+ require.Equal(t, layout.Stack, tb.tree.Root.Kind)
114+ require.Equal(t, layout.PaneID("b"), tb.tree.Root.Expanded)
115+ require.Len(t, tb.panes, 3)
116+ tb.mu.Unlock()
117+}
118+
119+func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) {
120+ 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"}}}}})
121+ tests := []struct {
122+ name string
123+ blobs []ports.SnapshotBlob
124+ liveName string
125+ wantOpen int
126+ }{
127+ {name: "empty store"},
128+ {name: "corrupt blob", blobs: []ports.SnapshotBlob{{Name: "bad", Data: []byte("not a snapshot")}}},
129+ {name: "live name", blobs: []ports.SnapshotBlob{{Name: "live", Data: valid}}, liveName: "live"},
130+ }
131+ for _, tt := range tests {
132+ t.Run(tt.name, func(t *testing.T) {
133+ factory := &restorePTYFactory{}
134+ d := newTestDaemon(t, factory, stubClock{})
135+ WithSnapshotStore(&restoreSnapshotStore{blobs: tt.blobs})(d)
136+ if tt.liveName != "" {
137+ d.sessions["existing"] = &session{id: "existing", name: tt.liveName}
138+ }
139+
140+ require.NotPanics(t, func() { d.restoreSnapshots(context.Background()) })
141+ require.Len(t, factory.opens, tt.wantOpen)
142+ })
143+ }
144+}
145+
146+func TestRestoreSnapshotsSkipsCreatedAtOverflow(t *testing.T) {
147+ store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "overflow", Data: mustSnapshotBytes(t, snapcodec.Session{
148+ Name: "overflow",
149+ CreatedAt: uint64(math.MaxInt64) + 1,
150+ Tabs: []snapcodec.Tab{{
151+ Cols: 80,
152+ Rows: 24,
153+ Tree: layout.NewTree("pane-1"),
154+ Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/bad"}},
155+ }},
156+ })}}}
157+ factory := &restorePTYFactory{}
158+ d := newTestDaemon(t, factory, stubClock{})
159+ WithSnapshotStore(store)(d)
160+
161+ d.restoreSnapshots(context.Background())
162+
163+ records, err := d.persist.LoadAll()
164+ require.NoError(t, err)
165+ require.Empty(t, records)
166+ require.Len(t, factory.opens, 0)
167+ d.mu.Lock()
168+ restored := d.findByNameLocked("overflow")
169+ d.mu.Unlock()
170+ require.Nil(t, restored)
171+}
172+
173+func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) {
174+ releaseLoad := make(chan struct{})
175+ loaded := make(chan struct{})
176+ store := &restoreSnapshotStore{
177+ loadFn: func() ([]ports.SnapshotBlob, error) {
178+ close(loaded)
179+ <-releaseLoad
180+ 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
181+ },
182+ }
183+ factory := &restorePTYFactory{}
184+ d := newTestDaemon(t, factory, stubClock{})
185+ WithSnapshotStore(store)(d)
186+
187+ go d.restoreSnapshots(context.Background())
188+ <-loaded
189+
190+ routed := make(chan *session, 1)
191+ go func() {
192+ tr, _ := newCapturingTransport(t)
193+ sess, _, err := d.route(ports.Hello{Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 26}}, tr)
194+ require.NoError(t, err)
195+ routed <- sess
196+ }()
197+
198+ select {
199+ case <-routed:
200+ t.Fatal("named attach routed before restore barrier closed")
201+ case <-time.After(25 * time.Millisecond):
202+ }
203+ close(releaseLoad)
204+ select {
205+ case sess := <-routed:
206+ require.Equal(t, "work", sess.name)
207+ require.Len(t, factory.opens, 1)
208+ require.Equal(t, "/restored", factory.opens[0].dir)
209+ case <-time.After(time.Second):
210+ t.Fatal("timed out waiting for named attach after restore")
211+ }
212+}
213+
214+func TestNamedRouteRestoreBarrierReturnsShutdown(t *testing.T) {
215+ d := newTestDaemon(t, &restorePTYFactory{}, stubClock{})
216+ WithSnapshotStore(&restoreSnapshotStore{})(d)
217+ d.serveCancel()
218+
219+ tr, _ := newCapturingTransport(t)
220+ sess, ac, err := d.route(ports.Hello{Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}}, tr)
221+
222+ require.Nil(t, sess)
223+ require.Nil(t, ac)
224+ var perr *protoErr
225+ require.ErrorAs(t, err, &perr)
226+ require.Equal(t, ports.ErrServerShutdown, perr.code)
227+}
228+
229+func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte {
230+ t.Helper()
231+ b, err := snapcodec.Marshal(s)
232+ require.NoError(t, err)
233+ return b
234+}
235+
236+func cells(s string) []renderer.Cell {
237+ out := make([]renderer.Cell, 0, len(s))
238+ for _, r := range s {
239+ out = append(out, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
240+ }
241+ return out
242+}
243+
244+type restoreSnapshotStore struct {
245+ blobs []ports.SnapshotBlob
246+ loadFn func() ([]ports.SnapshotBlob, error)
247+}
248+
249+func (s *restoreSnapshotStore) Write(string, []byte) error { return nil }
250+func (s *restoreSnapshotStore) Delete(string) error { return nil }
251+func (s *restoreSnapshotStore) Load() ([]ports.SnapshotBlob, error) {
252+ if s.loadFn != nil {
253+ return s.loadFn()
254+ }
255+ return s.blobs, nil
256+}
257+
258+type restorePTYFactory struct {
259+ mu sync.Mutex
260+ opens []restorePTYOpen
261+}
262+
263+type restorePTYOpen struct {
264+ dir string
265+ size domain.Size
266+}
267+
268+func (f *restorePTYFactory) Open(_ string, _ []string, _ []string, dir string, sz domain.Size) (ports.PTY, error) {
269+ f.mu.Lock()
270+ defer f.mu.Unlock()
271+ f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz})
272+ return newRestorePTY(), nil
273+}
274+
275+type restorePTY struct {
276+ done chan struct{}
277+ once sync.Once
278+}
279+
280+func newRestorePTY() *restorePTY { return &restorePTY{done: make(chan struct{})} }
281+func (p *restorePTY) Read([]byte) (int, error) {
282+ <-p.done
283+ return 0, io.EOF
284+}
285+func (p *restorePTY) Write(b []byte) (int, error) { return len(b), nil }
286+func (p *restorePTY) Close() error { p.once.Do(func() { close(p.done) }); return nil }
287+func (p *restorePTY) Resize(domain.Size) error { return nil }
288+func (p *restorePTY) Pid() int { return 0 }
289+func (p *restorePTY) ForegroundPgid() (int, error) { return 0, nil }
A internal/usecase/daemon/snapshot_test.go +365 -0
@@ -0,0 +1,365 @@
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+ sess.snapEligible.Store(true)
114+ d.sessions[sess.id] = sess
115+ d.sessWg.Add(1)
116+ d.ptyReader(sess, tb, tb.focusedPane())
117+
118+ require.True(t, sess.snapDirty.Load())
119+}
120+
121+func TestSnapshotSaverKeepsDirtyWhenWriteFails(t *testing.T) {
122+ clock := newManualSnapshotClock()
123+ store := portsmocks.NewMockSnapshotStore(t)
124+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
125+ WithSnapshotStore(store)(d)
126+
127+ sess := newSnapshotTestSession(t, "retry", false, "/fallback")
128+ sess.snapDirty.Store(true)
129+ d.sessions[sess.id] = sess
130+
131+ store.EXPECT().Write("retry", mock.Anything).Return(errors.New("boom")).Once()
132+ wrote := make(chan struct{}, 1)
133+ store.EXPECT().Write("retry", mock.Anything).RunAndReturn(func(string, []byte) error {
134+ wrote <- struct{}{}
135+ return nil
136+ }).Once()
137+
138+ ctx, cancel := context.WithCancel(t.Context())
139+ defer cancel()
140+ go d.snapshotSaver(ctx)
141+ clock.fireNext(t)
142+ require.Eventually(t, func() bool { return sess.snapDirty.Load() }, time.Second, time.Millisecond)
143+ clock.fireNext(t)
144+ select {
145+ case <-wrote:
146+ case <-time.After(time.Second):
147+ t.Fatal("timed out waiting for retry snapshot write")
148+ }
149+ require.False(t, sess.snapDirty.Load())
150+}
151+
152+func TestKillSessionPurgeDeletesSnapshot(t *testing.T) {
153+ store := portsmocks.NewMockSnapshotStore(t)
154+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
155+ WithSnapshotStore(store)(d)
156+
157+ sess := newSnapshotTestSession(t, "work", false, "/fallback")
158+ d.sessions[sess.id] = sess
159+
160+ store.EXPECT().Delete("work").Return(nil).Once()
161+ mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
162+ require.True(t, ok)
163+ mockPTY.EXPECT().Close().Return(nil).Once()
164+
165+ require.NoError(t, d.killSession(sess, 0, true))
166+}
167+
168+func TestKillSessionPurgeReturnsSnapshotDeleteError(t *testing.T) {
169+ store := portsmocks.NewMockSnapshotStore(t)
170+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
171+ WithSnapshotStore(store)(d)
172+
173+ sess := newSnapshotTestSession(t, "work", false, "/fallback")
174+ d.sessions[sess.id] = sess
175+
176+ store.EXPECT().Delete("work").Return(errors.New("delete failed")).Once()
177+ mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
178+ require.True(t, ok)
179+ mockPTY.EXPECT().Close().Return(nil).Once()
180+
181+ err := d.killSession(sess, 0, true)
182+ require.ErrorContains(t, err, "delete failed")
183+ require.NotContains(t, d.sessions, sess.id)
184+ require.NotContains(t, d.stopped, "work")
185+}
186+
187+func TestRefreshSessionCwdMarksSnapshotDirty(t *testing.T) {
188+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
189+ d.procCwd = func(int) (string, error) { return "/new", nil }
190+ sess := newSnapshotTestSession(t, "work", false, "/old")
191+ d.sessions[sess.id] = sess
192+
193+ d.refreshSessionCwd(sess)
194+ require.Equal(t, "/new", sess.cwd)
195+ require.True(t, sess.snapDirty.Load())
196+}
197+
198+func TestStoppedSessionKillPurgesSnapshot(t *testing.T) {
199+ store := portsmocks.NewMockSnapshotStore(t)
200+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
201+ WithSnapshotStore(store)(d)
202+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/work", createdAt: 1}
203+
204+ store.EXPECT().Delete("work").Return(nil).Once()
205+ tr, _, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})})
206+
207+ d.handleKill(tr, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})})
208+
209+ require.NotContains(t, d.stopped, "work")
210+}
211+
212+func TestStoppedSessionKillSnapshotDeleteFailureKeepsMetadata(t *testing.T) {
213+ store := portsmocks.NewMockSnapshotStore(t)
214+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
215+ WithSnapshotStore(store)(d)
216+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/work", createdAt: 1}
217+
218+ store.EXPECT().Delete("work").Return(errors.New("delete failed")).Once()
219+ tr, sends, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})})
220+
221+ d.handleKill(tr, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})})
222+
223+ require.Contains(t, d.stopped, "work")
224+ frame := awaitFrame(t, sends, ports.MsgError)
225+ msg, err := ports.UnmarshalErrorMsg(frame.Payload)
226+ require.NoError(t, err)
227+ require.Equal(t, ports.ErrInternal, msg.Code)
228+}
229+
230+func TestRenameSessionDeletesOldSnapshotAndMarksDirty(t *testing.T) {
231+ store := portsmocks.NewMockSnapshotStore(t)
232+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
233+ WithSnapshotStore(store)(d)
234+ sess := newSnapshotTestSession(t, "old", false, "/fallback")
235+ d.sessions[sess.id] = sess
236+
237+ store.EXPECT().Delete("old").Return(nil).Once()
238+ require.NoError(t, d.renameSession(sess, "new"))
239+
240+ require.True(t, sess.snapDirty.Load())
241+ require.Nil(t, d.findByNameLocked("old"))
242+}
243+
244+func TestSnapshotSaverCapturesLayoutOnlyDirtySave(t *testing.T) {
245+ clock := newManualSnapshotClock()
246+ store := portsmocks.NewMockSnapshotStore(t)
247+ factory := portsmocks.NewMockPTYFactory(t)
248+ d := newTestDaemon(t, factory, clock)
249+ WithSnapshotStore(store)(d)
250+
251+ sess := newSnapshotTestSession(t, "work", false, "/work")
252+ tctx, tcancel := context.WithCancel(context.Background())
253+ t.Cleanup(tcancel)
254+ sess.ctx = tctx
255+ sess.cancel = tcancel
256+ sess.tabs[0].ctx = tctx
257+ sess.tabs[0].cancel = tcancel
258+ for _, p := range sess.tabs[0].panes {
259+ p.ctx = tctx
260+ p.cancel = tcancel
261+ }
262+ sess.tabs[0].size = domain.Size{Cols: 41, Rows: 10}
263+ d.sessions[sess.id] = sess
264+ newPTY := portsmocks.NewMockPTY(t)
265+ newPTY.EXPECT().Pid().Return(2345).Maybe()
266+ newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
267+ newPTY.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe()
268+ newPTY.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
269+ newPTY.EXPECT().ForegroundPgid().Return(0, nil).Maybe()
270+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()
271+
272+ require.NoError(t, d.splitPane(sess, nil, layout.Right))
273+ require.True(t, sess.snapDirty.Load())
274+
275+ wrote := make(chan struct{}, 1)
276+ store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
277+ snap, err := snapcodec.Unmarshal(data)
278+ require.NoError(t, err)
279+ require.Len(t, snap.Tabs, 1)
280+ require.Len(t, snap.Tabs[0].Panes, 2)
281+ wrote <- struct{}{}
282+ return nil
283+ }).Once()
284+
285+ ctx, cancel := context.WithCancel(t.Context())
286+ defer cancel()
287+ go d.snapshotSaver(ctx)
288+ clock.fireNext(t)
289+ select {
290+ case <-wrote:
291+ case <-time.After(time.Second):
292+ t.Fatal("timed out waiting for layout-only dirty snapshot write")
293+ }
294+}
295+
296+func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd string) *session {
297+ t.Helper()
298+ pty := portsmocks.NewMockPTY(t)
299+ pty.EXPECT().Pid().Return(1234).Maybe()
300+ pty.EXPECT().Read(mock.Anything).Return(0, errors.New("unused")).Maybe()
301+ pty.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe()
302+ pty.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
303+ pty.EXPECT().ForegroundPgid().Return(0, nil).Maybe()
304+
305+ tb := newTab(pty, domain.Size{Cols: 8, Rows: 3})
306+ p := tb.panes["pane-1"]
307+ p.screen.Write([]byte("hello"))
308+ p.scrollback.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})
309+ sess := &session{
310+ id: domain.SessionID("sess-" + name),
311+ name: name,
312+ ephemeral: ephemeral,
313+ ctx: context.Background(),
314+ cancel: func() {},
315+ tabs: []*tab{tb},
316+ active: 0,
317+ cwd: cwd,
318+ createdAt: 42,
319+ }
320+ sess.snapEligible.Store(!ephemeral && name != "")
321+ return sess
322+}
323+
324+type manualSnapshotClock struct {
325+ mu sync.Mutex
326+ timers []*manualSnapshotTimer
327+}
328+
329+func newManualSnapshotClock() *manualSnapshotClock { return &manualSnapshotClock{} }
330+
331+func (c *manualSnapshotClock) Now() time.Time { return time.Unix(0, 0) }
332+
333+func (c *manualSnapshotClock) NewTimer(time.Duration) ports.Timer {
334+ t := &manualSnapshotTimer{ch: make(chan time.Time, 1)}
335+ c.mu.Lock()
336+ c.timers = append(c.timers, t)
337+ c.mu.Unlock()
338+ return t
339+}
340+
341+func (c *manualSnapshotClock) fireNext(t *testing.T) {
342+ t.Helper()
343+ deadline := time.After(time.Second)
344+ for {
345+ c.mu.Lock()
346+ if len(c.timers) > 0 {
347+ timer := c.timers[0]
348+ c.mu.Unlock()
349+ timer.ch <- time.Unix(0, 0)
350+ return
351+ }
352+ c.mu.Unlock()
353+ select {
354+ case <-deadline:
355+ t.Fatal("timed out waiting for snapshot timer")
356+ case <-time.After(time.Millisecond):
357+ }
358+ }
359+}
360+
361+type manualSnapshotTimer struct{ ch chan time.Time }
362+
363+func (t *manualSnapshotTimer) C() <-chan time.Time { return t.ch }
364+func (t *manualSnapshotTimer) Reset(time.Duration) bool { return true }
365+func (t *manualSnapshotTimer) Stop() bool { return true }
@@ -0,0 +1,365 @@
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+ sess.snapEligible.Store(true)
114+ d.sessions[sess.id] = sess
115+ d.sessWg.Add(1)
116+ d.ptyReader(sess, tb, tb.focusedPane())
117+
118+ require.True(t, sess.snapDirty.Load())
119+}
120+
121+func TestSnapshotSaverKeepsDirtyWhenWriteFails(t *testing.T) {
122+ clock := newManualSnapshotClock()
123+ store := portsmocks.NewMockSnapshotStore(t)
124+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
125+ WithSnapshotStore(store)(d)
126+
127+ sess := newSnapshotTestSession(t, "retry", false, "/fallback")
128+ sess.snapDirty.Store(true)
129+ d.sessions[sess.id] = sess
130+
131+ store.EXPECT().Write("retry", mock.Anything).Return(errors.New("boom")).Once()
132+ wrote := make(chan struct{}, 1)
133+ store.EXPECT().Write("retry", mock.Anything).RunAndReturn(func(string, []byte) error {
134+ wrote <- struct{}{}
135+ return nil
136+ }).Once()
137+
138+ ctx, cancel := context.WithCancel(t.Context())
139+ defer cancel()
140+ go d.snapshotSaver(ctx)
141+ clock.fireNext(t)
142+ require.Eventually(t, func() bool { return sess.snapDirty.Load() }, time.Second, time.Millisecond)
143+ clock.fireNext(t)
144+ select {
145+ case <-wrote:
146+ case <-time.After(time.Second):
147+ t.Fatal("timed out waiting for retry snapshot write")
148+ }
149+ require.False(t, sess.snapDirty.Load())
150+}
151+
152+func TestKillSessionPurgeDeletesSnapshot(t *testing.T) {
153+ store := portsmocks.NewMockSnapshotStore(t)
154+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
155+ WithSnapshotStore(store)(d)
156+
157+ sess := newSnapshotTestSession(t, "work", false, "/fallback")
158+ d.sessions[sess.id] = sess
159+
160+ store.EXPECT().Delete("work").Return(nil).Once()
161+ mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
162+ require.True(t, ok)
163+ mockPTY.EXPECT().Close().Return(nil).Once()
164+
165+ require.NoError(t, d.killSession(sess, 0, true))
166+}
167+
168+func TestKillSessionPurgeReturnsSnapshotDeleteError(t *testing.T) {
169+ store := portsmocks.NewMockSnapshotStore(t)
170+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
171+ WithSnapshotStore(store)(d)
172+
173+ sess := newSnapshotTestSession(t, "work", false, "/fallback")
174+ d.sessions[sess.id] = sess
175+
176+ store.EXPECT().Delete("work").Return(errors.New("delete failed")).Once()
177+ mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
178+ require.True(t, ok)
179+ mockPTY.EXPECT().Close().Return(nil).Once()
180+
181+ err := d.killSession(sess, 0, true)
182+ require.ErrorContains(t, err, "delete failed")
183+ require.NotContains(t, d.sessions, sess.id)
184+ require.NotContains(t, d.stopped, "work")
185+}
186+
187+func TestRefreshSessionCwdMarksSnapshotDirty(t *testing.T) {
188+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
189+ d.procCwd = func(int) (string, error) { return "/new", nil }
190+ sess := newSnapshotTestSession(t, "work", false, "/old")
191+ d.sessions[sess.id] = sess
192+
193+ d.refreshSessionCwd(sess)
194+ require.Equal(t, "/new", sess.cwd)
195+ require.True(t, sess.snapDirty.Load())
196+}
197+
198+func TestStoppedSessionKillPurgesSnapshot(t *testing.T) {
199+ store := portsmocks.NewMockSnapshotStore(t)
200+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
201+ WithSnapshotStore(store)(d)
202+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/work", createdAt: 1}
203+
204+ store.EXPECT().Delete("work").Return(nil).Once()
205+ tr, _, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})})
206+
207+ d.handleKill(tr, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})})
208+
209+ require.NotContains(t, d.stopped, "work")
210+}
211+
212+func TestStoppedSessionKillSnapshotDeleteFailureKeepsMetadata(t *testing.T) {
213+ store := portsmocks.NewMockSnapshotStore(t)
214+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
215+ WithSnapshotStore(store)(d)
216+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/work", createdAt: 1}
217+
218+ store.EXPECT().Delete("work").Return(errors.New("delete failed")).Once()
219+ tr, sends, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})})
220+
221+ d.handleKill(tr, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})})
222+
223+ require.Contains(t, d.stopped, "work")
224+ frame := awaitFrame(t, sends, ports.MsgError)
225+ msg, err := ports.UnmarshalErrorMsg(frame.Payload)
226+ require.NoError(t, err)
227+ require.Equal(t, ports.ErrInternal, msg.Code)
228+}
229+
230+func TestRenameSessionDeletesOldSnapshotAndMarksDirty(t *testing.T) {
231+ store := portsmocks.NewMockSnapshotStore(t)
232+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
233+ WithSnapshotStore(store)(d)
234+ sess := newSnapshotTestSession(t, "old", false, "/fallback")
235+ d.sessions[sess.id] = sess
236+
237+ store.EXPECT().Delete("old").Return(nil).Once()
238+ require.NoError(t, d.renameSession(sess, "new"))
239+
240+ require.True(t, sess.snapDirty.Load())
241+ require.Nil(t, d.findByNameLocked("old"))
242+}
243+
244+func TestSnapshotSaverCapturesLayoutOnlyDirtySave(t *testing.T) {
245+ clock := newManualSnapshotClock()
246+ store := portsmocks.NewMockSnapshotStore(t)
247+ factory := portsmocks.NewMockPTYFactory(t)
248+ d := newTestDaemon(t, factory, clock)
249+ WithSnapshotStore(store)(d)
250+
251+ sess := newSnapshotTestSession(t, "work", false, "/work")
252+ tctx, tcancel := context.WithCancel(context.Background())
253+ t.Cleanup(tcancel)
254+ sess.ctx = tctx
255+ sess.cancel = tcancel
256+ sess.tabs[0].ctx = tctx
257+ sess.tabs[0].cancel = tcancel
258+ for _, p := range sess.tabs[0].panes {
259+ p.ctx = tctx
260+ p.cancel = tcancel
261+ }
262+ sess.tabs[0].size = domain.Size{Cols: 41, Rows: 10}
263+ d.sessions[sess.id] = sess
264+ newPTY := portsmocks.NewMockPTY(t)
265+ newPTY.EXPECT().Pid().Return(2345).Maybe()
266+ newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
267+ newPTY.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe()
268+ newPTY.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
269+ newPTY.EXPECT().ForegroundPgid().Return(0, nil).Maybe()
270+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()
271+
272+ require.NoError(t, d.splitPane(sess, nil, layout.Right))
273+ require.True(t, sess.snapDirty.Load())
274+
275+ wrote := make(chan struct{}, 1)
276+ store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
277+ snap, err := snapcodec.Unmarshal(data)
278+ require.NoError(t, err)
279+ require.Len(t, snap.Tabs, 1)
280+ require.Len(t, snap.Tabs[0].Panes, 2)
281+ wrote <- struct{}{}
282+ return nil
283+ }).Once()
284+
285+ ctx, cancel := context.WithCancel(t.Context())
286+ defer cancel()
287+ go d.snapshotSaver(ctx)
288+ clock.fireNext(t)
289+ select {
290+ case <-wrote:
291+ case <-time.After(time.Second):
292+ t.Fatal("timed out waiting for layout-only dirty snapshot write")
293+ }
294+}
295+
296+func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd string) *session {
297+ t.Helper()
298+ pty := portsmocks.NewMockPTY(t)
299+ pty.EXPECT().Pid().Return(1234).Maybe()
300+ pty.EXPECT().Read(mock.Anything).Return(0, errors.New("unused")).Maybe()
301+ pty.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe()
302+ pty.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
303+ pty.EXPECT().ForegroundPgid().Return(0, nil).Maybe()
304+
305+ tb := newTab(pty, domain.Size{Cols: 8, Rows: 3})
306+ p := tb.panes["pane-1"]
307+ p.screen.Write([]byte("hello"))
308+ p.scrollback.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})
309+ sess := &session{
310+ id: domain.SessionID("sess-" + name),
311+ name: name,
312+ ephemeral: ephemeral,
313+ ctx: context.Background(),
314+ cancel: func() {},
315+ tabs: []*tab{tb},
316+ active: 0,
317+ cwd: cwd,
318+ createdAt: 42,
319+ }
320+ sess.snapEligible.Store(!ephemeral && name != "")
321+ return sess
322+}
323+
324+type manualSnapshotClock struct {
325+ mu sync.Mutex
326+ timers []*manualSnapshotTimer
327+}
328+
329+func newManualSnapshotClock() *manualSnapshotClock { return &manualSnapshotClock{} }
330+
331+func (c *manualSnapshotClock) Now() time.Time { return time.Unix(0, 0) }
332+
333+func (c *manualSnapshotClock) NewTimer(time.Duration) ports.Timer {
334+ t := &manualSnapshotTimer{ch: make(chan time.Time, 1)}
335+ c.mu.Lock()
336+ c.timers = append(c.timers, t)
337+ c.mu.Unlock()
338+ return t
339+}
340+
341+func (c *manualSnapshotClock) fireNext(t *testing.T) {
342+ t.Helper()
343+ deadline := time.After(time.Second)
344+ for {
345+ c.mu.Lock()
346+ if len(c.timers) > 0 {
347+ timer := c.timers[0]
348+ c.mu.Unlock()
349+ timer.ch <- time.Unix(0, 0)
350+ return
351+ }
352+ c.mu.Unlock()
353+ select {
354+ case <-deadline:
355+ t.Fatal("timed out waiting for snapshot timer")
356+ case <-time.After(time.Millisecond):
357+ }
358+ }
359+}
360+
361+type manualSnapshotTimer struct{ ch chan time.Time }
362+
363+func (t *manualSnapshotTimer) C() <-chan time.Time { return t.ch }
364+func (t *manualSnapshotTimer) Reset(time.Duration) bool { return true }
365+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 +748 -0
@@ -0,0 +1,748 @@
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+ var root *layout.Node
277+ if t.Tree != nil {
278+ root = t.Tree.Root
279+ }
280+ if err := writeNode(w, root); err != nil {
281+ return err
282+ }
283+ if len(t.Panes) > math.MaxUint16 {
284+ return fmt.Errorf("%w: too many panes", ErrInvalidData)
285+ }
286+ w.putUint16(uint16(len(t.Panes)))
287+ for _, p := range t.Panes {
288+ if err := writePane(w, p); err != nil {
289+ return err
290+ }
291+ }
292+ return nil
293+}
294+
295+func readTab(r *payloadReader) (Tab, error) {
296+ cols, err := r.getUint16()
297+ if err != nil {
298+ return Tab{}, err
299+ }
300+ rows, err := r.getUint16()
301+ if err != nil {
302+ return Tab{}, err
303+ }
304+ next, err := r.getUint64()
305+ if err != nil {
306+ return Tab{}, err
307+ }
308+ focus, err := r.getString()
309+ if err != nil {
310+ return Tab{}, err
311+ }
312+ root, err := readNode(r)
313+ if err != nil {
314+ return Tab{}, err
315+ }
316+ np, err := r.getUint16()
317+ if err != nil {
318+ return Tab{}, err
319+ }
320+ 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)}
321+ for i := range t.Panes {
322+ p, err := readPane(r)
323+ if err != nil {
324+ return Tab{}, err
325+ }
326+ t.Panes[i] = p
327+ }
328+ return t, nil
329+}
330+
331+func writeNode(w *payloadWriter, n *layout.Node) error {
332+ if n == nil {
333+ w.putUint8(3)
334+ return nil
335+ }
336+ switch n.Kind {
337+ case layout.Leaf:
338+ w.putUint8(0)
339+ return w.putString(string(n.Leaf))
340+ case layout.Split:
341+ w.putUint8(1)
342+ w.putUint8(uint8(n.Dir))
343+ return writeChildren(w, n.Children)
344+ case layout.Stack:
345+ w.putUint8(2)
346+ if err := w.putString(string(n.Expanded)); err != nil {
347+ return err
348+ }
349+ return writeChildren(w, n.Children)
350+ default:
351+ return fmt.Errorf("%w: unknown node kind", ErrInvalidData)
352+ }
353+}
354+
355+func writeChildren(w *payloadWriter, children []*layout.Node) error {
356+ if len(children) > math.MaxUint16 {
357+ return fmt.Errorf("%w: too many children", ErrInvalidData)
358+ }
359+ w.putUint16(uint16(len(children)))
360+ for _, c := range children {
361+ if err := writeNode(w, c); err != nil {
362+ return err
363+ }
364+ }
365+ return nil
366+}
367+
368+func readNode(r *payloadReader) (*layout.Node, error) {
369+ kind, err := r.getUint8()
370+ if err != nil {
371+ return nil, err
372+ }
373+ switch kind {
374+ case 0:
375+ leaf, err := r.getString()
376+ if err != nil {
377+ return nil, err
378+ }
379+ return layout.NewLeaf(layout.PaneID(leaf)), nil
380+ case 1:
381+ dir, err := r.getUint8()
382+ if err != nil {
383+ return nil, err
384+ }
385+ children, err := readChildren(r)
386+ if err != nil {
387+ return nil, err
388+ }
389+ return &layout.Node{Kind: layout.Split, Dir: layout.SplitDir(dir), Children: children}, nil
390+ case 2:
391+ exp, err := r.getString()
392+ if err != nil {
393+ return nil, err
394+ }
395+ children, err := readChildren(r)
396+ if err != nil {
397+ return nil, err
398+ }
399+ return &layout.Node{Kind: layout.Stack, Expanded: layout.PaneID(exp), Children: children}, nil
400+ case 3:
401+ return nil, nil
402+ default:
403+ return nil, fmt.Errorf("%w: unknown node kind", ErrInvalidData)
404+ }
405+}
406+
407+func readChildren(r *payloadReader) ([]*layout.Node, error) {
408+ n, err := r.getUint16()
409+ if err != nil {
410+ return nil, err
411+ }
412+ out := make([]*layout.Node, n)
413+ for i := range out {
414+ c, err := readNode(r)
415+ if err != nil {
416+ return nil, err
417+ }
418+ out[i] = c
419+ }
420+ return out, nil
421+}
422+
423+func writePane(w *payloadWriter, p Pane) error {
424+ if err := w.putString(string(p.ID)); err != nil {
425+ return err
426+ }
427+ if err := w.putString(p.Cwd); err != nil {
428+ return err
429+ }
430+ styles := styleTable(p)
431+ if len(styles) > math.MaxUint16 {
432+ return fmt.Errorf("%w: too many styles", ErrInvalidData)
433+ }
434+ w.putUint16(uint16(len(styles)))
435+ for _, s := range styles {
436+ writeStyle(w, s)
437+ }
438+ idx := make(map[renderer.Style]uint16, len(styles))
439+ for i, s := range styles {
440+ idx[s] = uint16(i)
441+ }
442+ if err := writeRows(w, p.Scrollback, idx, false); err != nil {
443+ return err
444+ }
445+ return writeRows(w, p.Visible, idx, true)
446+}
447+
448+func readPane(r *payloadReader) (Pane, error) {
449+ id, err := r.getString()
450+ if err != nil {
451+ return Pane{}, err
452+ }
453+ cwd, err := r.getString()
454+ if err != nil {
455+ return Pane{}, err
456+ }
457+ ns, err := r.getUint16()
458+ if err != nil {
459+ return Pane{}, err
460+ }
461+ styles := make([]renderer.Style, ns)
462+ for i := range styles {
463+ s, err := readStyle(r)
464+ if err != nil {
465+ return Pane{}, err
466+ }
467+ styles[i] = s
468+ }
469+ budget := rowBudget{cells: maxDecodedCells, runs: maxDecodedRuns}
470+ sb, err := readRows(r, styles, &budget)
471+ if err != nil {
472+ return Pane{}, err
473+ }
474+ vis, err := readRows(r, styles, &budget)
475+ if err != nil {
476+ return Pane{}, err
477+ }
478+ return Pane{ID: layout.PaneID(id), Cwd: cwd, Scrollback: sb, Visible: vis}, nil
479+}
480+
481+func writeRows(w *payloadWriter, rows [][]renderer.Cell, idx map[renderer.Style]uint16, trimBlankRows bool) error {
482+ clean := trimRows(rows, trimBlankRows)
483+ if len(clean) > math.MaxUint32 {
484+ return fmt.Errorf("%w: too many rows", ErrInvalidData)
485+ }
486+ w.putUint32(uint32(len(clean)))
487+ for _, row := range clean {
488+ runs := makeRuns(row)
489+ if len(runs) > math.MaxUint16 {
490+ return fmt.Errorf("%w: too many runs", ErrInvalidData)
491+ }
492+ w.putUint16(uint16(len(runs)))
493+ for _, run := range runs {
494+ w.putUint16(uint16(run.len))
495+ w.putUint32(uint32(run.cell.Rune))
496+ w.putUint16(idx[run.cell.Style])
497+ var flags uint8
498+ if run.cell.Continuation {
499+ flags |= 1
500+ }
501+ w.putUint8(flags)
502+ }
503+ }
504+ return nil
505+}
506+
507+type rowBudget struct {
508+ cells int
509+ runs int
510+}
511+
512+func readRows(r *payloadReader, styles []renderer.Style, budget *rowBudget) ([][]renderer.Cell, error) {
513+ n, err := r.getUint32()
514+ if err != nil {
515+ return nil, err
516+ }
517+ if n > maxDecodedRows {
518+ return nil, fmt.Errorf("%w: too many rows", ErrInvalidData)
519+ }
520+ if uint64(n) > uint64(len(r.b))/2 {
521+ return nil, ErrShortPayload
522+ }
523+ rows := make([][]renderer.Cell, int(n))
524+ for i := range rows {
525+ nr, err := r.getUint16()
526+ if err != nil {
527+ return nil, err
528+ }
529+ if budget != nil {
530+ if int(nr) > budget.runs {
531+ return nil, fmt.Errorf("%w: too many runs", ErrInvalidData)
532+ }
533+ budget.runs -= int(nr)
534+ }
535+ if uint64(nr) > uint64(len(r.b))/9 {
536+ return nil, ErrShortPayload
537+ }
538+ rowCells := 0
539+ for j := 0; j < int(nr); j++ {
540+ ln, err := r.getUint16()
541+ if err != nil {
542+ return nil, err
543+ }
544+ rn, err := r.getUint32()
545+ if err != nil {
546+ return nil, err
547+ }
548+ si, err := r.getUint16()
549+ if err != nil {
550+ return nil, err
551+ }
552+ flags, err := r.getUint8()
553+ if err != nil {
554+ return nil, err
555+ }
556+ if int(si) >= len(styles) {
557+ return nil, ErrStyleOOB
558+ }
559+ if flags&^1 != 0 {
560+ return nil, fmt.Errorf("%w: unknown cell flags", ErrInvalidData)
561+ }
562+ if budget != nil {
563+ if int(ln) > budget.cells {
564+ return nil, fmt.Errorf("%w: too many cells", ErrInvalidData)
565+ }
566+ budget.cells -= int(ln)
567+ }
568+ cell := renderer.Cell{Rune: rune(rn), Style: styles[si], Continuation: flags&1 != 0}
569+ rowCells += int(ln)
570+ rows[i] = append(rows[i], make([]renderer.Cell, int(ln))...)
571+ for k := len(rows[i]) - int(ln); k < len(rows[i]); k++ {
572+ rows[i][k] = cell
573+ }
574+ }
575+ if rowCells == 0 {
576+ rows[i] = nil
577+ }
578+ }
579+ return rows, nil
580+}
581+
582+type cellRun struct {
583+ len int
584+ cell renderer.Cell
585+}
586+
587+func makeRuns(row []renderer.Cell) []cellRun {
588+ if len(row) == 0 {
589+ return nil
590+ }
591+ runs := []cellRun{{len: 1, cell: row[0]}}
592+ for _, c := range row[1:] {
593+ last := &runs[len(runs)-1]
594+ if last.cell.Equal(c) && last.len < math.MaxUint16 {
595+ last.len++
596+ } else {
597+ runs = append(runs, cellRun{len: 1, cell: c})
598+ }
599+ }
600+ return runs
601+}
602+
603+func trimRows(rows [][]renderer.Cell, trimBlankRows bool) [][]renderer.Cell {
604+ out := make([][]renderer.Cell, len(rows))
605+ for i, row := range rows {
606+ out[i] = trimTrailingBlankCells(row)
607+ }
608+ if trimBlankRows {
609+ for len(out) > 0 && len(out[len(out)-1]) == 0 {
610+ out = out[:len(out)-1]
611+ }
612+ }
613+ return out
614+}
615+
616+func trimTrailingBlankCells(row []renderer.Cell) []renderer.Cell {
617+ end := len(row)
618+ for end > 0 && isBlank(row[end-1]) {
619+ end--
620+ }
621+ return append([]renderer.Cell(nil), row[:end]...)
622+}
623+func isBlank(c renderer.Cell) bool {
624+ return !c.Continuation && c.Rune == ' ' && c.Style.Equal(renderer.DefaultStyle())
625+}
626+
627+func styleTable(p Pane) []renderer.Style {
628+ seen := map[renderer.Style]bool{}
629+ var styles []renderer.Style
630+ addRows := func(rows [][]renderer.Cell, trimBlankRows bool) {
631+ for _, row := range trimRows(rows, trimBlankRows) {
632+ for _, c := range row {
633+ if !seen[c.Style] {
634+ seen[c.Style] = true
635+ styles = append(styles, c.Style)
636+ }
637+ }
638+ }
639+ }
640+ addRows(p.Scrollback, false)
641+ addRows(p.Visible, true)
642+ return styles
643+}
644+
645+func writeStyle(w *payloadWriter, s renderer.Style) {
646+ var flags uint8
647+ if s.Bold {
648+ flags |= 1
649+ }
650+ if s.Inverse {
651+ flags |= 2
652+ }
653+ if s.HasForegroundRGB {
654+ flags |= 4
655+ }
656+ if s.HasBackgroundRGB {
657+ flags |= 8
658+ }
659+ w.putUint8(flags)
660+ w.putUint32(uint32(int32(s.Foreground)))
661+ w.putUint32(uint32(int32(s.Background)))
662+ w.putUint8(s.ForegroundRGB.R)
663+ w.putUint8(s.ForegroundRGB.G)
664+ w.putUint8(s.ForegroundRGB.B)
665+ w.putUint8(s.BackgroundRGB.R)
666+ w.putUint8(s.BackgroundRGB.G)
667+ w.putUint8(s.BackgroundRGB.B)
668+}
669+
670+func readStyle(r *payloadReader) (renderer.Style, error) {
671+ flags, err := r.getUint8()
672+ if err != nil {
673+ return renderer.Style{}, err
674+ }
675+ fg, err := r.getUint32()
676+ if err != nil {
677+ return renderer.Style{}, err
678+ }
679+ bg, err := r.getUint32()
680+ if err != nil {
681+ return renderer.Style{}, err
682+ }
683+ fr, err := r.getUint8()
684+ if err != nil {
685+ return renderer.Style{}, err
686+ }
687+ fgc, err := r.getUint8()
688+ if err != nil {
689+ return renderer.Style{}, err
690+ }
691+ fb, err := r.getUint8()
692+ if err != nil {
693+ return renderer.Style{}, err
694+ }
695+ br, err := r.getUint8()
696+ if err != nil {
697+ return renderer.Style{}, err
698+ }
699+ bgc, err := r.getUint8()
700+ if err != nil {
701+ return renderer.Style{}, err
702+ }
703+ bb, err := r.getUint8()
704+ if err != nil {
705+ return renderer.Style{}, err
706+ }
707+ 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
708+}
709+
710+func validateTree(t *layout.Tree, ids map[layout.PaneID]struct{}) error {
711+ if t == nil || t.Root == nil {
712+ return nil
713+ }
714+ return validateNode(t.Root, ids)
715+}
716+
717+func validateNode(n *layout.Node, ids map[layout.PaneID]struct{}) error {
718+ if n == nil {
719+ return fmt.Errorf("%w: nil child node", ErrInvalidData)
720+ }
721+ switch n.Kind {
722+ case layout.Leaf:
723+ if _, ok := ids[n.Leaf]; !ok {
724+ return ErrUnknownPane
725+ }
726+ case layout.Split:
727+ if n.Dir != layout.Horizontal && n.Dir != layout.Vertical {
728+ return fmt.Errorf("%w: split dir", ErrInvalidData)
729+ }
730+ for _, c := range n.Children {
731+ if err := validateNode(c, ids); err != nil {
732+ return err
733+ }
734+ }
735+ case layout.Stack:
736+ if _, ok := ids[n.Expanded]; n.Expanded != "" && !ok {
737+ return ErrUnknownPane
738+ }
739+ for _, c := range n.Children {
740+ if err := validateNode(c, ids); err != nil {
741+ return err
742+ }
743+ }
744+ default:
745+ return fmt.Errorf("%w: node kind", ErrInvalidData)
746+ }
747+ return nil
748+}
@@ -0,0 +1,748 @@
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+ var root *layout.Node
277+ if t.Tree != nil {
278+ root = t.Tree.Root
279+ }
280+ if err := writeNode(w, root); err != nil {
281+ return err
282+ }
283+ if len(t.Panes) > math.MaxUint16 {
284+ return fmt.Errorf("%w: too many panes", ErrInvalidData)
285+ }
286+ w.putUint16(uint16(len(t.Panes)))
287+ for _, p := range t.Panes {
288+ if err := writePane(w, p); err != nil {
289+ return err
290+ }
291+ }
292+ return nil
293+}
294+
295+func readTab(r *payloadReader) (Tab, error) {
296+ cols, err := r.getUint16()
297+ if err != nil {
298+ return Tab{}, err
299+ }
300+ rows, err := r.getUint16()
301+ if err != nil {
302+ return Tab{}, err
303+ }
304+ next, err := r.getUint64()
305+ if err != nil {
306+ return Tab{}, err
307+ }
308+ focus, err := r.getString()
309+ if err != nil {
310+ return Tab{}, err
311+ }
312+ root, err := readNode(r)
313+ if err != nil {
314+ return Tab{}, err
315+ }
316+ np, err := r.getUint16()
317+ if err != nil {
318+ return Tab{}, err
319+ }
320+ 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)}
321+ for i := range t.Panes {
322+ p, err := readPane(r)
323+ if err != nil {
324+ return Tab{}, err
325+ }
326+ t.Panes[i] = p
327+ }
328+ return t, nil
329+}
330+
331+func writeNode(w *payloadWriter, n *layout.Node) error {
332+ if n == nil {
333+ w.putUint8(3)
334+ return nil
335+ }
336+ switch n.Kind {
337+ case layout.Leaf:
338+ w.putUint8(0)
339+ return w.putString(string(n.Leaf))
340+ case layout.Split:
341+ w.putUint8(1)
342+ w.putUint8(uint8(n.Dir))
343+ return writeChildren(w, n.Children)
344+ case layout.Stack:
345+ w.putUint8(2)
346+ if err := w.putString(string(n.Expanded)); err != nil {
347+ return err
348+ }
349+ return writeChildren(w, n.Children)
350+ default:
351+ return fmt.Errorf("%w: unknown node kind", ErrInvalidData)
352+ }
353+}
354+
355+func writeChildren(w *payloadWriter, children []*layout.Node) error {
356+ if len(children) > math.MaxUint16 {
357+ return fmt.Errorf("%w: too many children", ErrInvalidData)
358+ }
359+ w.putUint16(uint16(len(children)))
360+ for _, c := range children {
361+ if err := writeNode(w, c); err != nil {
362+ return err
363+ }
364+ }
365+ return nil
366+}
367+
368+func readNode(r *payloadReader) (*layout.Node, error) {
369+ kind, err := r.getUint8()
370+ if err != nil {
371+ return nil, err
372+ }
373+ switch kind {
374+ case 0:
375+ leaf, err := r.getString()
376+ if err != nil {
377+ return nil, err
378+ }
379+ return layout.NewLeaf(layout.PaneID(leaf)), nil
380+ case 1:
381+ dir, err := r.getUint8()
382+ if err != nil {
383+ return nil, err
384+ }
385+ children, err := readChildren(r)
386+ if err != nil {
387+ return nil, err
388+ }
389+ return &layout.Node{Kind: layout.Split, Dir: layout.SplitDir(dir), Children: children}, nil
390+ case 2:
391+ exp, err := r.getString()
392+ if err != nil {
393+ return nil, err
394+ }
395+ children, err := readChildren(r)
396+ if err != nil {
397+ return nil, err
398+ }
399+ return &layout.Node{Kind: layout.Stack, Expanded: layout.PaneID(exp), Children: children}, nil
400+ case 3:
401+ return nil, nil
402+ default:
403+ return nil, fmt.Errorf("%w: unknown node kind", ErrInvalidData)
404+ }
405+}
406+
407+func readChildren(r *payloadReader) ([]*layout.Node, error) {
408+ n, err := r.getUint16()
409+ if err != nil {
410+ return nil, err
411+ }
412+ out := make([]*layout.Node, n)
413+ for i := range out {
414+ c, err := readNode(r)
415+ if err != nil {
416+ return nil, err
417+ }
418+ out[i] = c
419+ }
420+ return out, nil
421+}
422+
423+func writePane(w *payloadWriter, p Pane) error {
424+ if err := w.putString(string(p.ID)); err != nil {
425+ return err
426+ }
427+ if err := w.putString(p.Cwd); err != nil {
428+ return err
429+ }
430+ styles := styleTable(p)
431+ if len(styles) > math.MaxUint16 {
432+ return fmt.Errorf("%w: too many styles", ErrInvalidData)
433+ }
434+ w.putUint16(uint16(len(styles)))
435+ for _, s := range styles {
436+ writeStyle(w, s)
437+ }
438+ idx := make(map[renderer.Style]uint16, len(styles))
439+ for i, s := range styles {
440+ idx[s] = uint16(i)
441+ }
442+ if err := writeRows(w, p.Scrollback, idx, false); err != nil {
443+ return err
444+ }
445+ return writeRows(w, p.Visible, idx, true)
446+}
447+
448+func readPane(r *payloadReader) (Pane, error) {
449+ id, err := r.getString()
450+ if err != nil {
451+ return Pane{}, err
452+ }
453+ cwd, err := r.getString()
454+ if err != nil {
455+ return Pane{}, err
456+ }
457+ ns, err := r.getUint16()
458+ if err != nil {
459+ return Pane{}, err
460+ }
461+ styles := make([]renderer.Style, ns)
462+ for i := range styles {
463+ s, err := readStyle(r)
464+ if err != nil {
465+ return Pane{}, err
466+ }
467+ styles[i] = s
468+ }
469+ budget := rowBudget{cells: maxDecodedCells, runs: maxDecodedRuns}
470+ sb, err := readRows(r, styles, &budget)
471+ if err != nil {
472+ return Pane{}, err
473+ }
474+ vis, err := readRows(r, styles, &budget)
475+ if err != nil {
476+ return Pane{}, err
477+ }
478+ return Pane{ID: layout.PaneID(id), Cwd: cwd, Scrollback: sb, Visible: vis}, nil
479+}
480+
481+func writeRows(w *payloadWriter, rows [][]renderer.Cell, idx map[renderer.Style]uint16, trimBlankRows bool) error {
482+ clean := trimRows(rows, trimBlankRows)
483+ if len(clean) > math.MaxUint32 {
484+ return fmt.Errorf("%w: too many rows", ErrInvalidData)
485+ }
486+ w.putUint32(uint32(len(clean)))
487+ for _, row := range clean {
488+ runs := makeRuns(row)
489+ if len(runs) > math.MaxUint16 {
490+ return fmt.Errorf("%w: too many runs", ErrInvalidData)
491+ }
492+ w.putUint16(uint16(len(runs)))
493+ for _, run := range runs {
494+ w.putUint16(uint16(run.len))
495+ w.putUint32(uint32(run.cell.Rune))
496+ w.putUint16(idx[run.cell.Style])
497+ var flags uint8
498+ if run.cell.Continuation {
499+ flags |= 1
500+ }
501+ w.putUint8(flags)
502+ }
503+ }
504+ return nil
505+}
506+
507+type rowBudget struct {
508+ cells int
509+ runs int
510+}
511+
512+func readRows(r *payloadReader, styles []renderer.Style, budget *rowBudget) ([][]renderer.Cell, error) {
513+ n, err := r.getUint32()
514+ if err != nil {
515+ return nil, err
516+ }
517+ if n > maxDecodedRows {
518+ return nil, fmt.Errorf("%w: too many rows", ErrInvalidData)
519+ }
520+ if uint64(n) > uint64(len(r.b))/2 {
521+ return nil, ErrShortPayload
522+ }
523+ rows := make([][]renderer.Cell, int(n))
524+ for i := range rows {
525+ nr, err := r.getUint16()
526+ if err != nil {
527+ return nil, err
528+ }
529+ if budget != nil {
530+ if int(nr) > budget.runs {
531+ return nil, fmt.Errorf("%w: too many runs", ErrInvalidData)
532+ }
533+ budget.runs -= int(nr)
534+ }
535+ if uint64(nr) > uint64(len(r.b))/9 {
536+ return nil, ErrShortPayload
537+ }
538+ rowCells := 0
539+ for j := 0; j < int(nr); j++ {
540+ ln, err := r.getUint16()
541+ if err != nil {
542+ return nil, err
543+ }
544+ rn, err := r.getUint32()
545+ if err != nil {
546+ return nil, err
547+ }
548+ si, err := r.getUint16()
549+ if err != nil {
550+ return nil, err
551+ }
552+ flags, err := r.getUint8()
553+ if err != nil {
554+ return nil, err
555+ }
556+ if int(si) >= len(styles) {
557+ return nil, ErrStyleOOB
558+ }
559+ if flags&^1 != 0 {
560+ return nil, fmt.Errorf("%w: unknown cell flags", ErrInvalidData)
561+ }
562+ if budget != nil {
563+ if int(ln) > budget.cells {
564+ return nil, fmt.Errorf("%w: too many cells", ErrInvalidData)
565+ }
566+ budget.cells -= int(ln)
567+ }
568+ cell := renderer.Cell{Rune: rune(rn), Style: styles[si], Continuation: flags&1 != 0}
569+ rowCells += int(ln)
570+ rows[i] = append(rows[i], make([]renderer.Cell, int(ln))...)
571+ for k := len(rows[i]) - int(ln); k < len(rows[i]); k++ {
572+ rows[i][k] = cell
573+ }
574+ }
575+ if rowCells == 0 {
576+ rows[i] = nil
577+ }
578+ }
579+ return rows, nil
580+}
581+
582+type cellRun struct {
583+ len int
584+ cell renderer.Cell
585+}
586+
587+func makeRuns(row []renderer.Cell) []cellRun {
588+ if len(row) == 0 {
589+ return nil
590+ }
591+ runs := []cellRun{{len: 1, cell: row[0]}}
592+ for _, c := range row[1:] {
593+ last := &runs[len(runs)-1]
594+ if last.cell.Equal(c) && last.len < math.MaxUint16 {
595+ last.len++
596+ } else {
597+ runs = append(runs, cellRun{len: 1, cell: c})
598+ }
599+ }
600+ return runs
601+}
602+
603+func trimRows(rows [][]renderer.Cell, trimBlankRows bool) [][]renderer.Cell {
604+ out := make([][]renderer.Cell, len(rows))
605+ for i, row := range rows {
606+ out[i] = trimTrailingBlankCells(row)
607+ }
608+ if trimBlankRows {
609+ for len(out) > 0 && len(out[len(out)-1]) == 0 {
610+ out = out[:len(out)-1]
611+ }
612+ }
613+ return out
614+}
615+
616+func trimTrailingBlankCells(row []renderer.Cell) []renderer.Cell {
617+ end := len(row)
618+ for end > 0 && isBlank(row[end-1]) {
619+ end--
620+ }
621+ return append([]renderer.Cell(nil), row[:end]...)
622+}
623+func isBlank(c renderer.Cell) bool {
624+ return !c.Continuation && c.Rune == ' ' && c.Style.Equal(renderer.DefaultStyle())
625+}
626+
627+func styleTable(p Pane) []renderer.Style {
628+ seen := map[renderer.Style]bool{}
629+ var styles []renderer.Style
630+ addRows := func(rows [][]renderer.Cell, trimBlankRows bool) {
631+ for _, row := range trimRows(rows, trimBlankRows) {
632+ for _, c := range row {
633+ if !seen[c.Style] {
634+ seen[c.Style] = true
635+ styles = append(styles, c.Style)
636+ }
637+ }
638+ }
639+ }
640+ addRows(p.Scrollback, false)
641+ addRows(p.Visible, true)
642+ return styles
643+}
644+
645+func writeStyle(w *payloadWriter, s renderer.Style) {
646+ var flags uint8
647+ if s.Bold {
648+ flags |= 1
649+ }
650+ if s.Inverse {
651+ flags |= 2
652+ }
653+ if s.HasForegroundRGB {
654+ flags |= 4
655+ }
656+ if s.HasBackgroundRGB {
657+ flags |= 8
658+ }
659+ w.putUint8(flags)
660+ w.putUint32(uint32(int32(s.Foreground)))
661+ w.putUint32(uint32(int32(s.Background)))
662+ w.putUint8(s.ForegroundRGB.R)
663+ w.putUint8(s.ForegroundRGB.G)
664+ w.putUint8(s.ForegroundRGB.B)
665+ w.putUint8(s.BackgroundRGB.R)
666+ w.putUint8(s.BackgroundRGB.G)
667+ w.putUint8(s.BackgroundRGB.B)
668+}
669+
670+func readStyle(r *payloadReader) (renderer.Style, error) {
671+ flags, err := r.getUint8()
672+ if err != nil {
673+ return renderer.Style{}, err
674+ }
675+ fg, err := r.getUint32()
676+ if err != nil {
677+ return renderer.Style{}, err
678+ }
679+ bg, err := r.getUint32()
680+ if err != nil {
681+ return renderer.Style{}, err
682+ }
683+ fr, err := r.getUint8()
684+ if err != nil {
685+ return renderer.Style{}, err
686+ }
687+ fgc, err := r.getUint8()
688+ if err != nil {
689+ return renderer.Style{}, err
690+ }
691+ fb, err := r.getUint8()
692+ if err != nil {
693+ return renderer.Style{}, err
694+ }
695+ br, err := r.getUint8()
696+ if err != nil {
697+ return renderer.Style{}, err
698+ }
699+ bgc, err := r.getUint8()
700+ if err != nil {
701+ return renderer.Style{}, err
702+ }
703+ bb, err := r.getUint8()
704+ if err != nil {
705+ return renderer.Style{}, err
706+ }
707+ 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
708+}
709+
710+func validateTree(t *layout.Tree, ids map[layout.PaneID]struct{}) error {
711+ if t == nil || t.Root == nil {
712+ return nil
713+ }
714+ return validateNode(t.Root, ids)
715+}
716+
717+func validateNode(n *layout.Node, ids map[layout.PaneID]struct{}) error {
718+ if n == nil {
719+ return fmt.Errorf("%w: nil child node", ErrInvalidData)
720+ }
721+ switch n.Kind {
722+ case layout.Leaf:
723+ if _, ok := ids[n.Leaf]; !ok {
724+ return ErrUnknownPane
725+ }
726+ case layout.Split:
727+ if n.Dir != layout.Horizontal && n.Dir != layout.Vertical {
728+ return fmt.Errorf("%w: split dir", ErrInvalidData)
729+ }
730+ for _, c := range n.Children {
731+ if err := validateNode(c, ids); err != nil {
732+ return err
733+ }
734+ }
735+ case layout.Stack:
736+ if _, ok := ids[n.Expanded]; n.Expanded != "" && !ok {
737+ return ErrUnknownPane
738+ }
739+ for _, c := range n.Children {
740+ if err := validateNode(c, ids); err != nil {
741+ return err
742+ }
743+ }
744+ default:
745+ return fmt.Errorf("%w: node kind", ErrInvalidData)
746+ }
747+ return nil
748+}
A internal/usecase/snapshot/codec_test.go +379 -0
@@ -0,0 +1,379 @@
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 TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) {
30+ cases := []struct {
31+ name string
32+ tab Tab
33+ }{
34+ {name: "nil tree", tab: Tab{Tree: nil}},
35+ {name: "nil root", tab: Tab{Tree: &layout.Tree{Root: nil}}},
36+ }
37+ for _, tc := range cases {
38+ t.Run(tc.name, func(t *testing.T) {
39+ b, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}})
40+ if err != nil {
41+ t.Fatalf("Marshal() error = %v", err)
42+ }
43+ got, err := Unmarshal(b)
44+ if err != nil {
45+ t.Fatalf("Unmarshal() error = %v", err)
46+ }
47+ if len(got.Tabs) != 1 {
48+ t.Fatalf("tabs len = %d, want 1", len(got.Tabs))
49+ }
50+ if got.Tabs[0].Tree == nil {
51+ t.Fatalf("Tree = nil, want tree with nil root")
52+ }
53+ if got.Tabs[0].Tree.Root != nil {
54+ t.Fatalf("Root = %#v, want nil", got.Tabs[0].Tree.Root)
55+ }
56+ })
57+ }
58+}
59+
60+func TestRoundTripSessions(t *testing.T) {
61+ boldRGB := renderer.DefaultStyle()
62+ boldRGB.Bold = true
63+ boldRGB.HasForegroundRGB = true
64+ boldRGB.ForegroundRGB = renderer.RGB{R: 1, G: 2, B: 3}
65+ indexed := renderer.DefaultStyle()
66+ indexed.Foreground = 2
67+ indexed.Background = 4
68+ indexed.Inverse = true
69+ deepTree := &layout.Tree{Focus: "2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
70+ layout.NewLeaf("1"),
71+ &layout.Node{Kind: layout.Stack, Expanded: "2", Children: []*layout.Node{layout.NewLeaf("2"), layout.NewLeaf("3")}},
72+ }}}
73+ cjkVisible := [][]renderer.Cell{{
74+ {Rune: '好', Style: boldRGB},
75+ {Continuation: true, Style: boldRGB},
76+ {Rune: 'x', Style: indexed},
77+ }}
78+ multi := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{
79+ {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"}}},
80+ {Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", Visible: cjkVisible}}},
81+ }}
82+ empty := Session{Name: "blank", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: nil, Visible: rows(" ", "")}}}}}
83+ large := Session{Name: "large", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: manyRows(6000), Visible: rows("tail")}}}}}
84+ cases := []struct {
85+ name string
86+ s Session
87+ }{
88+ {name: "multi tab deep tree", s: multi},
89+ {name: "empty and all blank", s: empty},
90+ {name: "large scrollback", s: large},
91+ }
92+ for _, tc := range cases {
93+ t.Run(tc.name, func(t *testing.T) {
94+ b, err := Marshal(tc.s)
95+ if err != nil {
96+ t.Fatalf("Marshal() error = %v", err)
97+ }
98+ got, err := Unmarshal(b)
99+ if err != nil {
100+ t.Fatalf("Unmarshal() error = %v", err)
101+ }
102+ if !equalSession(got, trimSession(tc.s)) {
103+ t.Fatalf("round trip mismatch\ngot %#v\nwant %#v", got, trimSession(tc.s))
104+ }
105+ })
106+ }
107+}
108+
109+func TestUnmarshalRejectsMalformedWithoutPanic(t *testing.T) {
110+ good, err := Marshal(Session{Name: "s", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p"}}}}})
111+ if err != nil {
112+ t.Fatal(err)
113+ }
114+ cases := []struct {
115+ name string
116+ data []byte
117+ }{
118+ {"bad magic", append([]byte("NOPE"), good[4:]...)},
119+ {"bad version", replaceU16(good, 4, 2)},
120+ {"bad crc", append([]byte(nil), good[:len(good)-1]...)},
121+ {"trailing", append(append([]byte(nil), good...), 0)},
122+ {"body len overrun", replaceU32(good, 8, 999999)},
123+ {"prefix", good[:len(good)/2]},
124+ {"style oob", styleOOBSnapshot(t)},
125+ {"unknown pane in tree", unknownPaneSnapshot(t)},
126+ }
127+ for _, tc := range cases {
128+ t.Run(tc.name, func(t *testing.T) {
129+ defer func() {
130+ if r := recover(); r != nil {
131+ t.Fatalf("Unmarshal panicked: %v", r)
132+ }
133+ }()
134+ if _, err := Unmarshal(tc.data); err == nil {
135+ t.Fatalf("Unmarshal() error = nil")
136+ }
137+ })
138+ }
139+ for i := 0; i < len(good); i++ {
140+ if _, err := Unmarshal(good[:i]); err == nil {
141+ t.Fatalf("prefix length %d unexpectedly succeeded", i)
142+ }
143+ }
144+}
145+
146+func TestUnmarshalRejectsOversizedBodiesBeforeAllocation(t *testing.T) {
147+ body := []byte{0}
148+ oversizedRaw := reheader(body, 0)
149+ binary.BigEndian.PutUint32(oversizedRaw[8:12], uint32(maxDecodedBodySize+1))
150+ oversizedFlate := reheader(body, flagFlate)
151+ binary.BigEndian.PutUint32(oversizedFlate[8:12], uint32(maxDecodedBodySize+1))
152+ cases := []struct {
153+ name string
154+ data []byte
155+ }{
156+ {name: "raw", data: oversizedRaw},
157+ {name: "flate", data: oversizedFlate},
158+ }
159+ for _, tc := range cases {
160+ t.Run(tc.name, func(t *testing.T) {
161+ _, err := Unmarshal(tc.data)
162+ if !errors.Is(err, ErrInvalidData) {
163+ t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData)
164+ }
165+ })
166+ }
167+}
168+
169+func TestUnmarshalRejectsMalformedRowCountsWithoutAllocation(t *testing.T) {
170+ cases := []struct {
171+ name string
172+ data []byte
173+ want error
174+ }{
175+ {name: "huge row count", data: malformedRowsSnapshot(math.MaxUint32, 0), want: ErrInvalidData},
176+ {name: "huge run count", data: malformedRowsSnapshot(1, math.MaxUint16), want: ErrShortPayload},
177+ }
178+ for _, tc := range cases {
179+ t.Run(tc.name, func(t *testing.T) {
180+ defer func() {
181+ if r := recover(); r != nil {
182+ t.Fatalf("Unmarshal panicked: %v", r)
183+ }
184+ }()
185+ _, err := Unmarshal(tc.data)
186+ if !errors.Is(err, tc.want) {
187+ t.Fatalf("Unmarshal() error = %v, want %v", err, tc.want)
188+ }
189+ })
190+ }
191+}
192+
193+func TestFlateOnOff(t *testing.T) {
194+ small, err := Marshal(Session{Name: "small"})
195+ if err != nil {
196+ t.Fatal(err)
197+ }
198+ if flags := binary.BigEndian.Uint16(small[6:8]); flags != 0 {
199+ t.Fatalf("small flags = %#x, want 0", flags)
200+ }
201+ large, err := Marshal(Session{Name: "large", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Scrollback: manyRows(5000)}}}}})
202+ if err != nil {
203+ t.Fatal(err)
204+ }
205+ if flags := binary.BigEndian.Uint16(large[6:8]); flags&1 == 0 {
206+ t.Fatalf("large flags = %#x, want flate bit", flags)
207+ }
208+}
209+
210+func BenchmarkMarshal10KRows(b *testing.B) {
211+ s := Session{Name: "bench", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/tmp", Scrollback: manyRows(10000), Visible: rows("visible")}}}}}
212+ b.ReportAllocs()
213+ for i := 0; i < b.N; i++ {
214+ if _, err := Marshal(s); err != nil {
215+ b.Fatal(err)
216+ }
217+ }
218+}
219+
220+func rows(lines ...string) [][]renderer.Cell {
221+ out := make([][]renderer.Cell, len(lines))
222+ for i, line := range lines {
223+ for _, r := range line {
224+ out[i] = append(out[i], renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
225+ }
226+ }
227+ return out
228+}
229+
230+func manyRows(n int) [][]renderer.Cell {
231+ out := make([][]renderer.Cell, n)
232+ for i := range out {
233+ out[i] = rows("row data row data row data")[0]
234+ }
235+ return out
236+}
237+
238+func equalSession(a, b Session) bool {
239+ if a.Name != b.Name || a.CreatedAt != b.CreatedAt || a.Active != b.Active || len(a.Tabs) != len(b.Tabs) {
240+ return false
241+ }
242+ for i := range a.Tabs {
243+ if !equalTab(a.Tabs[i], b.Tabs[i]) {
244+ return false
245+ }
246+ }
247+ return true
248+}
249+
250+func equalTab(a, b Tab) bool {
251+ if a.Cols != b.Cols || a.Rows != b.Rows || a.NextPaneID != b.NextPaneID || a.Focus != b.Focus || len(a.Panes) != len(b.Panes) || !equalNode(treeRoot(a.Tree), treeRoot(b.Tree)) {
252+ return false
253+ }
254+ for i := range a.Panes {
255+ 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) {
256+ return false
257+ }
258+ }
259+ return true
260+}
261+
262+func treeRoot(t *layout.Tree) *layout.Node {
263+ if t == nil {
264+ return nil
265+ }
266+ return t.Root
267+}
268+
269+func equalNode(a, b *layout.Node) bool {
270+ if a == nil || b == nil {
271+ return a == b
272+ }
273+ if a.Kind != b.Kind || a.Dir != b.Dir || a.Leaf != b.Leaf || a.Expanded != b.Expanded || len(a.Children) != len(b.Children) {
274+ return false
275+ }
276+ for i := range a.Children {
277+ if !equalNode(a.Children[i], b.Children[i]) {
278+ return false
279+ }
280+ }
281+ return true
282+}
283+
284+func equalRows(a, b [][]renderer.Cell) bool {
285+ if len(a) != len(b) {
286+ return false
287+ }
288+ for i := range a {
289+ if len(a[i]) != len(b[i]) {
290+ return false
291+ }
292+ for j := range a[i] {
293+ if !a[i][j].Equal(b[i][j]) {
294+ return false
295+ }
296+ }
297+ }
298+ return true
299+}
300+
301+func trimSession(s Session) Session {
302+ for ti := range s.Tabs {
303+ for pi := range s.Tabs[ti].Panes {
304+ s.Tabs[ti].Panes[pi].Scrollback = trimRows(s.Tabs[ti].Panes[pi].Scrollback, false)
305+ s.Tabs[ti].Panes[pi].Visible = trimRows(s.Tabs[ti].Panes[pi].Visible, true)
306+ }
307+ }
308+ return s
309+}
310+
311+func reheader(body []byte, flags uint16) []byte {
312+ out := make([]byte, 16+len(body))
313+ copy(out[:4], []byte("VEVS"))
314+ binary.BigEndian.PutUint16(out[4:6], 1)
315+ binary.BigEndian.PutUint16(out[6:8], flags)
316+ binary.BigEndian.PutUint32(out[8:12], uint32(len(body)))
317+ binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))
318+ copy(out[16:], body)
319+ return out
320+}
321+
322+func replaceU16(b []byte, off int, v uint16) []byte {
323+ out := append([]byte(nil), b...)
324+ binary.BigEndian.PutUint16(out[off:], v)
325+ return out
326+}
327+func replaceU32(b []byte, off int, v uint32) []byte {
328+ out := append([]byte(nil), b...)
329+ binary.BigEndian.PutUint32(out[off:], v)
330+ return out
331+}
332+
333+func malformedRowsSnapshot(rowCount uint32, runCount uint16) []byte {
334+ var w payloadWriter
335+ _ = w.putString("x")
336+ w.putUint64(0)
337+ w.putUint16(0)
338+ w.putUint16(1)
339+ w.putUint16(0)
340+ w.putUint16(0)
341+ w.putUint64(0)
342+ _ = w.putString("p")
343+ _ = writeNode(&w, layout.NewLeaf("p"))
344+ w.putUint16(1)
345+ _ = w.putString("p")
346+ _ = w.putString("")
347+ w.putUint16(0)
348+ w.putUint32(rowCount)
349+ if rowCount > 0 {
350+ w.putUint16(runCount)
351+ }
352+ return reheader(w.b, 0)
353+}
354+
355+func styleOOBSnapshot(t *testing.T) []byte {
356+ b, err := Marshal(Session{Name: "x", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Visible: rows("x")}}}}})
357+ if err != nil {
358+ t.Fatal(err)
359+ }
360+ bodyLen := int(binary.BigEndian.Uint32(b[8:12]))
361+ body := append([]byte(nil), b[16:16+bodyLen]...)
362+ for i := 0; i < len(body)-5; i++ { // run len=1,rune='x', style=0,cflags=0
363+ if body[i] == 0 && body[i+1] == 1 && body[i+2] == 0 && body[i+3] == 0 && body[i+4] == 0 && body[i+5] == 'x' {
364+ body[i+6] = 0
365+ body[i+7] = 2
366+ return reheader(body, 0)
367+ }
368+ }
369+ t.Fatal("run not found")
370+ return nil
371+}
372+
373+func unknownPaneSnapshot(t *testing.T) []byte {
374+ b, err := Marshal(Session{Name: "x", Tabs: []Tab{{Tree: layout.NewTree("missing"), Focus: "missing", Panes: []Pane{{ID: "p"}}}}})
375+ if err != nil {
376+ t.Fatal(err)
377+ }
378+ return b
379+}
@@ -0,0 +1,379 @@
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 TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) {
30+ cases := []struct {
31+ name string
32+ tab Tab
33+ }{
34+ {name: "nil tree", tab: Tab{Tree: nil}},
35+ {name: "nil root", tab: Tab{Tree: &layout.Tree{Root: nil}}},
36+ }
37+ for _, tc := range cases {
38+ t.Run(tc.name, func(t *testing.T) {
39+ b, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}})
40+ if err != nil {
41+ t.Fatalf("Marshal() error = %v", err)
42+ }
43+ got, err := Unmarshal(b)
44+ if err != nil {
45+ t.Fatalf("Unmarshal() error = %v", err)
46+ }
47+ if len(got.Tabs) != 1 {
48+ t.Fatalf("tabs len = %d, want 1", len(got.Tabs))
49+ }
50+ if got.Tabs[0].Tree == nil {
51+ t.Fatalf("Tree = nil, want tree with nil root")
52+ }
53+ if got.Tabs[0].Tree.Root != nil {
54+ t.Fatalf("Root = %#v, want nil", got.Tabs[0].Tree.Root)
55+ }
56+ })
57+ }
58+}
59+
60+func TestRoundTripSessions(t *testing.T) {
61+ boldRGB := renderer.DefaultStyle()
62+ boldRGB.Bold = true
63+ boldRGB.HasForegroundRGB = true
64+ boldRGB.ForegroundRGB = renderer.RGB{R: 1, G: 2, B: 3}
65+ indexed := renderer.DefaultStyle()
66+ indexed.Foreground = 2
67+ indexed.Background = 4
68+ indexed.Inverse = true
69+ deepTree := &layout.Tree{Focus: "2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
70+ layout.NewLeaf("1"),
71+ &layout.Node{Kind: layout.Stack, Expanded: "2", Children: []*layout.Node{layout.NewLeaf("2"), layout.NewLeaf("3")}},
72+ }}}
73+ cjkVisible := [][]renderer.Cell{{
74+ {Rune: '好', Style: boldRGB},
75+ {Continuation: true, Style: boldRGB},
76+ {Rune: 'x', Style: indexed},
77+ }}
78+ multi := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{
79+ {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"}}},
80+ {Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", Visible: cjkVisible}}},
81+ }}
82+ empty := Session{Name: "blank", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: nil, Visible: rows(" ", "")}}}}}
83+ large := Session{Name: "large", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: manyRows(6000), Visible: rows("tail")}}}}}
84+ cases := []struct {
85+ name string
86+ s Session
87+ }{
88+ {name: "multi tab deep tree", s: multi},
89+ {name: "empty and all blank", s: empty},
90+ {name: "large scrollback", s: large},
91+ }
92+ for _, tc := range cases {
93+ t.Run(tc.name, func(t *testing.T) {
94+ b, err := Marshal(tc.s)
95+ if err != nil {
96+ t.Fatalf("Marshal() error = %v", err)
97+ }
98+ got, err := Unmarshal(b)
99+ if err != nil {
100+ t.Fatalf("Unmarshal() error = %v", err)
101+ }
102+ if !equalSession(got, trimSession(tc.s)) {
103+ t.Fatalf("round trip mismatch\ngot %#v\nwant %#v", got, trimSession(tc.s))
104+ }
105+ })
106+ }
107+}
108+
109+func TestUnmarshalRejectsMalformedWithoutPanic(t *testing.T) {
110+ good, err := Marshal(Session{Name: "s", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p"}}}}})
111+ if err != nil {
112+ t.Fatal(err)
113+ }
114+ cases := []struct {
115+ name string
116+ data []byte
117+ }{
118+ {"bad magic", append([]byte("NOPE"), good[4:]...)},
119+ {"bad version", replaceU16(good, 4, 2)},
120+ {"bad crc", append([]byte(nil), good[:len(good)-1]...)},
121+ {"trailing", append(append([]byte(nil), good...), 0)},
122+ {"body len overrun", replaceU32(good, 8, 999999)},
123+ {"prefix", good[:len(good)/2]},
124+ {"style oob", styleOOBSnapshot(t)},
125+ {"unknown pane in tree", unknownPaneSnapshot(t)},
126+ }
127+ for _, tc := range cases {
128+ t.Run(tc.name, func(t *testing.T) {
129+ defer func() {
130+ if r := recover(); r != nil {
131+ t.Fatalf("Unmarshal panicked: %v", r)
132+ }
133+ }()
134+ if _, err := Unmarshal(tc.data); err == nil {
135+ t.Fatalf("Unmarshal() error = nil")
136+ }
137+ })
138+ }
139+ for i := 0; i < len(good); i++ {
140+ if _, err := Unmarshal(good[:i]); err == nil {
141+ t.Fatalf("prefix length %d unexpectedly succeeded", i)
142+ }
143+ }
144+}
145+
146+func TestUnmarshalRejectsOversizedBodiesBeforeAllocation(t *testing.T) {
147+ body := []byte{0}
148+ oversizedRaw := reheader(body, 0)
149+ binary.BigEndian.PutUint32(oversizedRaw[8:12], uint32(maxDecodedBodySize+1))
150+ oversizedFlate := reheader(body, flagFlate)
151+ binary.BigEndian.PutUint32(oversizedFlate[8:12], uint32(maxDecodedBodySize+1))
152+ cases := []struct {
153+ name string
154+ data []byte
155+ }{
156+ {name: "raw", data: oversizedRaw},
157+ {name: "flate", data: oversizedFlate},
158+ }
159+ for _, tc := range cases {
160+ t.Run(tc.name, func(t *testing.T) {
161+ _, err := Unmarshal(tc.data)
162+ if !errors.Is(err, ErrInvalidData) {
163+ t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData)
164+ }
165+ })
166+ }
167+}
168+
169+func TestUnmarshalRejectsMalformedRowCountsWithoutAllocation(t *testing.T) {
170+ cases := []struct {
171+ name string
172+ data []byte
173+ want error
174+ }{
175+ {name: "huge row count", data: malformedRowsSnapshot(math.MaxUint32, 0), want: ErrInvalidData},
176+ {name: "huge run count", data: malformedRowsSnapshot(1, math.MaxUint16), want: ErrShortPayload},
177+ }
178+ for _, tc := range cases {
179+ t.Run(tc.name, func(t *testing.T) {
180+ defer func() {
181+ if r := recover(); r != nil {
182+ t.Fatalf("Unmarshal panicked: %v", r)
183+ }
184+ }()
185+ _, err := Unmarshal(tc.data)
186+ if !errors.Is(err, tc.want) {
187+ t.Fatalf("Unmarshal() error = %v, want %v", err, tc.want)
188+ }
189+ })
190+ }
191+}
192+
193+func TestFlateOnOff(t *testing.T) {
194+ small, err := Marshal(Session{Name: "small"})
195+ if err != nil {
196+ t.Fatal(err)
197+ }
198+ if flags := binary.BigEndian.Uint16(small[6:8]); flags != 0 {
199+ t.Fatalf("small flags = %#x, want 0", flags)
200+ }
201+ large, err := Marshal(Session{Name: "large", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Scrollback: manyRows(5000)}}}}})
202+ if err != nil {
203+ t.Fatal(err)
204+ }
205+ if flags := binary.BigEndian.Uint16(large[6:8]); flags&1 == 0 {
206+ t.Fatalf("large flags = %#x, want flate bit", flags)
207+ }
208+}
209+
210+func BenchmarkMarshal10KRows(b *testing.B) {
211+ s := Session{Name: "bench", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/tmp", Scrollback: manyRows(10000), Visible: rows("visible")}}}}}
212+ b.ReportAllocs()
213+ for i := 0; i < b.N; i++ {
214+ if _, err := Marshal(s); err != nil {
215+ b.Fatal(err)
216+ }
217+ }
218+}
219+
220+func rows(lines ...string) [][]renderer.Cell {
221+ out := make([][]renderer.Cell, len(lines))
222+ for i, line := range lines {
223+ for _, r := range line {
224+ out[i] = append(out[i], renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
225+ }
226+ }
227+ return out
228+}
229+
230+func manyRows(n int) [][]renderer.Cell {
231+ out := make([][]renderer.Cell, n)
232+ for i := range out {
233+ out[i] = rows("row data row data row data")[0]
234+ }
235+ return out
236+}
237+
238+func equalSession(a, b Session) bool {
239+ if a.Name != b.Name || a.CreatedAt != b.CreatedAt || a.Active != b.Active || len(a.Tabs) != len(b.Tabs) {
240+ return false
241+ }
242+ for i := range a.Tabs {
243+ if !equalTab(a.Tabs[i], b.Tabs[i]) {
244+ return false
245+ }
246+ }
247+ return true
248+}
249+
250+func equalTab(a, b Tab) bool {
251+ if a.Cols != b.Cols || a.Rows != b.Rows || a.NextPaneID != b.NextPaneID || a.Focus != b.Focus || len(a.Panes) != len(b.Panes) || !equalNode(treeRoot(a.Tree), treeRoot(b.Tree)) {
252+ return false
253+ }
254+ for i := range a.Panes {
255+ 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) {
256+ return false
257+ }
258+ }
259+ return true
260+}
261+
262+func treeRoot(t *layout.Tree) *layout.Node {
263+ if t == nil {
264+ return nil
265+ }
266+ return t.Root
267+}
268+
269+func equalNode(a, b *layout.Node) bool {
270+ if a == nil || b == nil {
271+ return a == b
272+ }
273+ if a.Kind != b.Kind || a.Dir != b.Dir || a.Leaf != b.Leaf || a.Expanded != b.Expanded || len(a.Children) != len(b.Children) {
274+ return false
275+ }
276+ for i := range a.Children {
277+ if !equalNode(a.Children[i], b.Children[i]) {
278+ return false
279+ }
280+ }
281+ return true
282+}
283+
284+func equalRows(a, b [][]renderer.Cell) bool {
285+ if len(a) != len(b) {
286+ return false
287+ }
288+ for i := range a {
289+ if len(a[i]) != len(b[i]) {
290+ return false
291+ }
292+ for j := range a[i] {
293+ if !a[i][j].Equal(b[i][j]) {
294+ return false
295+ }
296+ }
297+ }
298+ return true
299+}
300+
301+func trimSession(s Session) Session {
302+ for ti := range s.Tabs {
303+ for pi := range s.Tabs[ti].Panes {
304+ s.Tabs[ti].Panes[pi].Scrollback = trimRows(s.Tabs[ti].Panes[pi].Scrollback, false)
305+ s.Tabs[ti].Panes[pi].Visible = trimRows(s.Tabs[ti].Panes[pi].Visible, true)
306+ }
307+ }
308+ return s
309+}
310+
311+func reheader(body []byte, flags uint16) []byte {
312+ out := make([]byte, 16+len(body))
313+ copy(out[:4], []byte("VEVS"))
314+ binary.BigEndian.PutUint16(out[4:6], 1)
315+ binary.BigEndian.PutUint16(out[6:8], flags)
316+ binary.BigEndian.PutUint32(out[8:12], uint32(len(body)))
317+ binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))
318+ copy(out[16:], body)
319+ return out
320+}
321+
322+func replaceU16(b []byte, off int, v uint16) []byte {
323+ out := append([]byte(nil), b...)
324+ binary.BigEndian.PutUint16(out[off:], v)
325+ return out
326+}
327+func replaceU32(b []byte, off int, v uint32) []byte {
328+ out := append([]byte(nil), b...)
329+ binary.BigEndian.PutUint32(out[off:], v)
330+ return out
331+}
332+
333+func malformedRowsSnapshot(rowCount uint32, runCount uint16) []byte {
334+ var w payloadWriter
335+ _ = w.putString("x")
336+ w.putUint64(0)
337+ w.putUint16(0)
338+ w.putUint16(1)
339+ w.putUint16(0)
340+ w.putUint16(0)
341+ w.putUint64(0)
342+ _ = w.putString("p")
343+ _ = writeNode(&w, layout.NewLeaf("p"))
344+ w.putUint16(1)
345+ _ = w.putString("p")
346+ _ = w.putString("")
347+ w.putUint16(0)
348+ w.putUint32(rowCount)
349+ if rowCount > 0 {
350+ w.putUint16(runCount)
351+ }
352+ return reheader(w.b, 0)
353+}
354+
355+func styleOOBSnapshot(t *testing.T) []byte {
356+ b, err := Marshal(Session{Name: "x", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Visible: rows("x")}}}}})
357+ if err != nil {
358+ t.Fatal(err)
359+ }
360+ bodyLen := int(binary.BigEndian.Uint32(b[8:12]))
361+ body := append([]byte(nil), b[16:16+bodyLen]...)
362+ for i := 0; i < len(body)-5; i++ { // run len=1,rune='x', style=0,cflags=0
363+ if body[i] == 0 && body[i+1] == 1 && body[i+2] == 0 && body[i+3] == 0 && body[i+4] == 0 && body[i+5] == 'x' {
364+ body[i+6] = 0
365+ body[i+7] = 2
366+ return reheader(body, 0)
367+ }
368+ }
369+ t.Fatal("run not found")
370+ return nil
371+}
372+
373+func unknownPaneSnapshot(t *testing.T) []byte {
374+ b, err := Marshal(Session{Name: "x", Tabs: []Tab{{Tree: layout.NewTree("missing"), Focus: "missing", Panes: []Pane{{ID: "p"}}}}})
375+ if err != nil {
376+ t.Fatal(err)
377+ }
378+ return b
379+}
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 12

Tags 11

↑↓ moveenter openesc close