feat(daemon): persist named session snapshots
- commit
- 6454fbcd621fbf305b179ca1111ac7965c70ac8b
- author
- brice · · 2026-07-06 02:32 UTC
- committer
- brice · · 2026-07-06 02:53 UTC
- parent
- 74411ec
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
24 files · +2917 -19
A internal/adapters/snapshot/store.go +170 -0
| @@ -0,0 +1,170 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/sha256" | |
| 5 | + | "encoding/hex" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 9 | + | "log/slog" | |
| 10 | + | "os" | |
| 11 | + | "path/filepath" | |
| 12 | + | "regexp" | |
| 13 | + | "strings" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/vev/internal/ports" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | var safeNameRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,200}$`) | |
| 19 | + | ||
| 20 | + | const maxSnapshotFileSize = 16 + (256 << 20) | |
| 21 | + | ||
| 22 | + | // Store persists raw session snapshot blobs on disk. | |
| 23 | + | type Store struct { | |
| 24 | + | dir string | |
| 25 | + | log *slog.Logger | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | var _ ports.SnapshotStore = (*Store)(nil) | |
| 29 | + | ||
| 30 | + | // NewStore creates a file-backed snapshot store rooted at dir. | |
| 31 | + | func NewStore(dir string) *Store { | |
| 32 | + | return &Store{dir: dir, log: slog.Default()} | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // Write atomically writes data for name. The previous snapshot remains in place | |
| 36 | + | // until the final rename succeeds. | |
| 37 | + | func (s *Store) Write(name string, data []byte) error { | |
| 38 | + | if err := os.MkdirAll(s.dir, 0o700); err != nil { | |
| 39 | + | return fmt.Errorf("create snapshot dir: %w", err) | |
| 40 | + | } | |
| 41 | + | if err := os.Chmod(s.dir, 0o700); err != nil { | |
| 42 | + | return fmt.Errorf("chmod snapshot dir: %w", err) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | tmp, err := os.CreateTemp(s.dir, ".tmp-*") | |
| 46 | + | if err != nil { | |
| 47 | + | return fmt.Errorf("create temp snapshot: %w", err) | |
| 48 | + | } | |
| 49 | + | tmpName := tmp.Name() | |
| 50 | + | keepTmp := false | |
| 51 | + | defer func() { | |
| 52 | + | if !keepTmp { | |
| 53 | + | _ = os.Remove(tmpName) | |
| 54 | + | } | |
| 55 | + | }() | |
| 56 | + | ||
| 57 | + | if err := tmp.Chmod(0o600); err != nil { | |
| 58 | + | _ = tmp.Close() | |
| 59 | + | return fmt.Errorf("chmod temp snapshot: %w", err) | |
| 60 | + | } | |
| 61 | + | if _, err := tmp.Write(data); err != nil { | |
| 62 | + | _ = tmp.Close() | |
| 63 | + | return fmt.Errorf("write temp snapshot: %w", err) | |
| 64 | + | } | |
| 65 | + | if err := tmp.Sync(); err != nil { | |
| 66 | + | _ = tmp.Close() | |
| 67 | + | return fmt.Errorf("sync temp snapshot: %w", err) | |
| 68 | + | } | |
| 69 | + | if err := tmp.Close(); err != nil { | |
| 70 | + | return fmt.Errorf("close temp snapshot: %w", err) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | path := filepath.Join(s.dir, filenameForName(name)) | |
| 74 | + | if err := os.Rename(tmpName, path); err != nil { | |
| 75 | + | return fmt.Errorf("rename snapshot: %w", err) | |
| 76 | + | } | |
| 77 | + | keepTmp = true | |
| 78 | + | syncDir(s.dir) | |
| 79 | + | return nil | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // Load returns all raw .snap blobs. It does not validate the blob format. | |
| 83 | + | func (s *Store) Load() ([]ports.SnapshotBlob, error) { | |
| 84 | + | entries, err := os.ReadDir(s.dir) | |
| 85 | + | if errors.Is(err, os.ErrNotExist) { | |
| 86 | + | return nil, nil | |
| 87 | + | } | |
| 88 | + | if err != nil { | |
| 89 | + | return nil, fmt.Errorf("read snapshot dir: %w", err) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | blobs := make([]ports.SnapshotBlob, 0, len(entries)) | |
| 93 | + | for _, entry := range entries { | |
| 94 | + | base := entry.Name() | |
| 95 | + | path := filepath.Join(s.dir, base) | |
| 96 | + | if strings.HasPrefix(base, ".tmp-") { | |
| 97 | + | if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 98 | + | s.log.Warn("remove stale snapshot temp", "path", path, "error", err) | |
| 99 | + | } | |
| 100 | + | continue | |
| 101 | + | } | |
| 102 | + | if entry.IsDir() || !strings.HasSuffix(base, ".snap") { | |
| 103 | + | continue | |
| 104 | + | } | |
| 105 | + | data, err := readSnapshotFileBounded(path) | |
| 106 | + | if err != nil { | |
| 107 | + | s.log.Warn("read snapshot", "path", path, "error", err) | |
| 108 | + | continue | |
| 109 | + | } | |
| 110 | + | name := strings.TrimSuffix(base, ".snap") | |
| 111 | + | if strings.HasPrefix(name, "@") || !safeNameRE.MatchString(name) { | |
| 112 | + | name = "" | |
| 113 | + | } | |
| 114 | + | blobs = append(blobs, ports.SnapshotBlob{Name: name, Data: data}) | |
| 115 | + | } | |
| 116 | + | return blobs, nil | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // Delete removes the deterministic snapshot file for name. Missing files are ignored. | |
| 120 | + | func (s *Store) Delete(name string) error { | |
| 121 | + | err := os.Remove(filepath.Join(s.dir, filenameForName(name))) | |
| 122 | + | if errors.Is(err, os.ErrNotExist) { | |
| 123 | + | return nil | |
| 124 | + | } | |
| 125 | + | if err != nil { | |
| 126 | + | return fmt.Errorf("delete snapshot: %w", err) | |
| 127 | + | } | |
| 128 | + | syncDir(s.dir) | |
| 129 | + | return nil | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func readSnapshotFileBounded(path string) ([]byte, error) { | |
| 133 | + | f, err := os.Open(path) | |
| 134 | + | if err != nil { | |
| 135 | + | return nil, err | |
| 136 | + | } | |
| 137 | + | defer func() { _ = f.Close() }() | |
| 138 | + | info, err := f.Stat() | |
| 139 | + | if err != nil { | |
| 140 | + | return nil, err | |
| 141 | + | } | |
| 142 | + | if info.Size() > maxSnapshotFileSize { | |
| 143 | + | return nil, fmt.Errorf("snapshot too large: %d bytes", info.Size()) | |
| 144 | + | } | |
| 145 | + | data, err := io.ReadAll(io.LimitReader(f, maxSnapshotFileSize+1)) | |
| 146 | + | if err != nil { | |
| 147 | + | return nil, err | |
| 148 | + | } | |
| 149 | + | if len(data) > maxSnapshotFileSize { | |
| 150 | + | return nil, fmt.Errorf("snapshot too large: %d bytes", len(data)) | |
| 151 | + | } | |
| 152 | + | return data, nil | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func filenameForName(name string) string { | |
| 156 | + | if safeNameRE.MatchString(name) { | |
| 157 | + | return name + ".snap" | |
| 158 | + | } | |
| 159 | + | sum := sha256.Sum256([]byte(name)) | |
| 160 | + | return "@" + hex.EncodeToString(sum[:])[:40] + ".snap" | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func syncDir(dir string) { | |
| 164 | + | f, err := os.Open(dir) | |
| 165 | + | if err != nil { | |
| 166 | + | return | |
| 167 | + | } | |
| 168 | + | defer func() { _ = f.Close() }() | |
| 169 | + | _ = f.Sync() | |
| 170 | + | } | |
| @@ -0,0 +1,170 @@ | |||||
| 1 | + | package snapshot | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "crypto/sha256" | |||
| 5 | + | "encoding/hex" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "io" | |||
| 9 | + | "log/slog" | |||
| 10 | + | "os" | |||
| 11 | + | "path/filepath" | |||
| 12 | + | "regexp" | |||
| 13 | + | "strings" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/vev/internal/ports" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | var safeNameRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,200}$`) | |||
| 19 | + | ||||
| 20 | + | const maxSnapshotFileSize = 16 + (256 << 20) | |||
| 21 | + | ||||
| 22 | + | // Store persists raw session snapshot blobs on disk. | |||
| 23 | + | type Store struct { | |||
| 24 | + | dir string | |||
| 25 | + | log *slog.Logger | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | var _ ports.SnapshotStore = (*Store)(nil) | |||
| 29 | + | ||||
| 30 | + | // NewStore creates a file-backed snapshot store rooted at dir. | |||
| 31 | + | func NewStore(dir string) *Store { | |||
| 32 | + | return &Store{dir: dir, log: slog.Default()} | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // Write atomically writes data for name. The previous snapshot remains in place | |||
| 36 | + | // until the final rename succeeds. | |||
| 37 | + | func (s *Store) Write(name string, data []byte) error { | |||
| 38 | + | if err := os.MkdirAll(s.dir, 0o700); err != nil { | |||
| 39 | + | return fmt.Errorf("create snapshot dir: %w", err) | |||
| 40 | + | } | |||
| 41 | + | if err := os.Chmod(s.dir, 0o700); err != nil { | |||
| 42 | + | return fmt.Errorf("chmod snapshot dir: %w", err) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | tmp, err := os.CreateTemp(s.dir, ".tmp-*") | |||
| 46 | + | if err != nil { | |||
| 47 | + | return fmt.Errorf("create temp snapshot: %w", err) | |||
| 48 | + | } | |||
| 49 | + | tmpName := tmp.Name() | |||
| 50 | + | keepTmp := false | |||
| 51 | + | defer func() { | |||
| 52 | + | if !keepTmp { | |||
| 53 | + | _ = os.Remove(tmpName) | |||
| 54 | + | } | |||
| 55 | + | }() | |||
| 56 | + | ||||
| 57 | + | if err := tmp.Chmod(0o600); err != nil { | |||
| 58 | + | _ = tmp.Close() | |||
| 59 | + | return fmt.Errorf("chmod temp snapshot: %w", err) | |||
| 60 | + | } | |||
| 61 | + | if _, err := tmp.Write(data); err != nil { | |||
| 62 | + | _ = tmp.Close() | |||
| 63 | + | return fmt.Errorf("write temp snapshot: %w", err) | |||
| 64 | + | } | |||
| 65 | + | if err := tmp.Sync(); err != nil { | |||
| 66 | + | _ = tmp.Close() | |||
| 67 | + | return fmt.Errorf("sync temp snapshot: %w", err) | |||
| 68 | + | } | |||
| 69 | + | if err := tmp.Close(); err != nil { | |||
| 70 | + | return fmt.Errorf("close temp snapshot: %w", err) | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | path := filepath.Join(s.dir, filenameForName(name)) | |||
| 74 | + | if err := os.Rename(tmpName, path); err != nil { | |||
| 75 | + | return fmt.Errorf("rename snapshot: %w", err) | |||
| 76 | + | } | |||
| 77 | + | keepTmp = true | |||
| 78 | + | syncDir(s.dir) | |||
| 79 | + | return nil | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // Load returns all raw .snap blobs. It does not validate the blob format. | |||
| 83 | + | func (s *Store) Load() ([]ports.SnapshotBlob, error) { | |||
| 84 | + | entries, err := os.ReadDir(s.dir) | |||
| 85 | + | if errors.Is(err, os.ErrNotExist) { | |||
| 86 | + | return nil, nil | |||
| 87 | + | } | |||
| 88 | + | if err != nil { | |||
| 89 | + | return nil, fmt.Errorf("read snapshot dir: %w", err) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | blobs := make([]ports.SnapshotBlob, 0, len(entries)) | |||
| 93 | + | for _, entry := range entries { | |||
| 94 | + | base := entry.Name() | |||
| 95 | + | path := filepath.Join(s.dir, base) | |||
| 96 | + | if strings.HasPrefix(base, ".tmp-") { | |||
| 97 | + | if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |||
| 98 | + | s.log.Warn("remove stale snapshot temp", "path", path, "error", err) | |||
| 99 | + | } | |||
| 100 | + | continue | |||
| 101 | + | } | |||
| 102 | + | if entry.IsDir() || !strings.HasSuffix(base, ".snap") { | |||
| 103 | + | continue | |||
| 104 | + | } | |||
| 105 | + | data, err := readSnapshotFileBounded(path) | |||
| 106 | + | if err != nil { | |||
| 107 | + | s.log.Warn("read snapshot", "path", path, "error", err) | |||
| 108 | + | continue | |||
| 109 | + | } | |||
| 110 | + | name := strings.TrimSuffix(base, ".snap") | |||
| 111 | + | if strings.HasPrefix(name, "@") || !safeNameRE.MatchString(name) { | |||
| 112 | + | name = "" | |||
| 113 | + | } | |||
| 114 | + | blobs = append(blobs, ports.SnapshotBlob{Name: name, Data: data}) | |||
| 115 | + | } | |||
| 116 | + | return blobs, nil | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // Delete removes the deterministic snapshot file for name. Missing files are ignored. | |||
| 120 | + | func (s *Store) Delete(name string) error { | |||
| 121 | + | err := os.Remove(filepath.Join(s.dir, filenameForName(name))) | |||
| 122 | + | if errors.Is(err, os.ErrNotExist) { | |||
| 123 | + | return nil | |||
| 124 | + | } | |||
| 125 | + | if err != nil { | |||
| 126 | + | return fmt.Errorf("delete snapshot: %w", err) | |||
| 127 | + | } | |||
| 128 | + | syncDir(s.dir) | |||
| 129 | + | return nil | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func readSnapshotFileBounded(path string) ([]byte, error) { | |||
| 133 | + | f, err := os.Open(path) | |||
| 134 | + | if err != nil { | |||
| 135 | + | return nil, err | |||
| 136 | + | } | |||
| 137 | + | defer func() { _ = f.Close() }() | |||
| 138 | + | info, err := f.Stat() | |||
| 139 | + | if err != nil { | |||
| 140 | + | return nil, err | |||
| 141 | + | } | |||
| 142 | + | if info.Size() > maxSnapshotFileSize { | |||
| 143 | + | return nil, fmt.Errorf("snapshot too large: %d bytes", info.Size()) | |||
| 144 | + | } | |||
| 145 | + | data, err := io.ReadAll(io.LimitReader(f, maxSnapshotFileSize+1)) | |||
| 146 | + | if err != nil { | |||
| 147 | + | return nil, err | |||
| 148 | + | } | |||
| 149 | + | if len(data) > maxSnapshotFileSize { | |||
| 150 | + | return nil, fmt.Errorf("snapshot too large: %d bytes", len(data)) | |||
| 151 | + | } | |||
| 152 | + | return data, nil | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func filenameForName(name string) string { | |||
| 156 | + | if safeNameRE.MatchString(name) { | |||
| 157 | + | return name + ".snap" | |||
| 158 | + | } | |||
| 159 | + | sum := sha256.Sum256([]byte(name)) | |||
| 160 | + | return "@" + hex.EncodeToString(sum[:])[:40] + ".snap" | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func syncDir(dir string) { | |||
| 164 | + | f, err := os.Open(dir) | |||
| 165 | + | if err != nil { | |||
| 166 | + | return | |||
| 167 | + | } | |||
| 168 | + | defer func() { _ = f.Close() }() | |||
| 169 | + | _ = f.Sync() | |||
| 170 | + | } | |||
A internal/adapters/snapshot/store_test.go +181 -0
| @@ -0,0 +1,181 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/sha256" | |
| 5 | + | "encoding/hex" | |
| 6 | + | "errors" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "runtime" | |
| 10 | + | "sort" | |
| 11 | + | "strings" | |
| 12 | + | "testing" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestStoreWriteLoadRoundTripAndSupersede(t *testing.T) { | |
| 16 | + | t.Parallel() | |
| 17 | + | dir := t.TempDir() | |
| 18 | + | store := NewStore(dir) | |
| 19 | + | ||
| 20 | + | if err := store.Write("alpha", []byte("first")); err != nil { | |
| 21 | + | t.Fatalf("Write first: %v", err) | |
| 22 | + | } | |
| 23 | + | if err := store.Write("alpha", []byte("second")); err != nil { | |
| 24 | + | t.Fatalf("Write second: %v", err) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | got := loadMap(t, store) | |
| 28 | + | if string(got["alpha"]) != "second" { | |
| 29 | + | t.Fatalf("loaded alpha = %q, want second", got["alpha"]) | |
| 30 | + | } | |
| 31 | + | info, err := os.Stat(filepath.Join(dir, "alpha.snap")) | |
| 32 | + | if err != nil { | |
| 33 | + | t.Fatalf("stat snapshot: %v", err) | |
| 34 | + | } | |
| 35 | + | if gotMode := info.Mode().Perm(); gotMode != 0o600 { | |
| 36 | + | t.Fatalf("snapshot mode = %v, want 0600", gotMode) | |
| 37 | + | } | |
| 38 | + | if dirInfo, err := os.Stat(dir); err != nil { | |
| 39 | + | t.Fatalf("stat dir: %v", err) | |
| 40 | + | } else if gotMode := dirInfo.Mode().Perm(); gotMode != 0o700 { | |
| 41 | + | t.Fatalf("dir mode = %v, want 0700", gotMode) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestStoreDelete(t *testing.T) { | |
| 46 | + | t.Parallel() | |
| 47 | + | dir := t.TempDir() | |
| 48 | + | store := NewStore(dir) | |
| 49 | + | ||
| 50 | + | if err := store.Write("gone", []byte("data")); err != nil { | |
| 51 | + | t.Fatalf("Write: %v", err) | |
| 52 | + | } | |
| 53 | + | if err := store.Delete("gone"); err != nil { | |
| 54 | + | t.Fatalf("Delete existing: %v", err) | |
| 55 | + | } | |
| 56 | + | if err := store.Delete("gone"); err != nil { | |
| 57 | + | t.Fatalf("Delete missing: %v", err) | |
| 58 | + | } | |
| 59 | + | if got := loadMap(t, store); len(got) != 0 { | |
| 60 | + | t.Fatalf("Load after delete = %#v, want empty", got) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestStoreUnsafeNameUsesDeterministicHash(t *testing.T) { | |
| 65 | + | t.Parallel() | |
| 66 | + | dir := t.TempDir() | |
| 67 | + | store := NewStore(dir) | |
| 68 | + | name := "../unsafe/session name" | |
| 69 | + | ||
| 70 | + | if err := store.Write(name, []byte("raw")); err != nil { | |
| 71 | + | t.Fatalf("Write unsafe: %v", err) | |
| 72 | + | } | |
| 73 | + | sum := sha256.Sum256([]byte(name)) | |
| 74 | + | wantBase := "@" + hex.EncodeToString(sum[:])[:40] + ".snap" | |
| 75 | + | if _, err := os.Stat(filepath.Join(dir, wantBase)); err != nil { | |
| 76 | + | t.Fatalf("stat hashed snapshot %q: %v", wantBase, err) | |
| 77 | + | } | |
| 78 | + | loaded, err := store.Load() | |
| 79 | + | if err != nil { | |
| 80 | + | t.Fatalf("Load: %v", err) | |
| 81 | + | } | |
| 82 | + | if len(loaded) != 1 || loaded[0].Name != "" || string(loaded[0].Data) != "raw" { | |
| 83 | + | t.Fatalf("Load unsafe = %#v, want one nameless raw blob", loaded) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestStoreMissingDirAndStaleTmpCleanup(t *testing.T) { | |
| 88 | + | t.Parallel() | |
| 89 | + | dir := filepath.Join(t.TempDir(), "missing") | |
| 90 | + | store := NewStore(dir) | |
| 91 | + | ||
| 92 | + | loaded, err := store.Load() | |
| 93 | + | if err != nil { | |
| 94 | + | t.Fatalf("Load missing dir: %v", err) | |
| 95 | + | } | |
| 96 | + | if len(loaded) != 0 { | |
| 97 | + | t.Fatalf("Load missing dir len = %d, want 0", len(loaded)) | |
| 98 | + | } | |
| 99 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 100 | + | t.Fatalf("MkdirAll: %v", err) | |
| 101 | + | } | |
| 102 | + | if err := os.WriteFile(filepath.Join(dir, ".tmp-stale"), []byte("tmp"), 0o600); err != nil { | |
| 103 | + | t.Fatalf("write stale tmp: %v", err) | |
| 104 | + | } | |
| 105 | + | if err := os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("ignore"), 0o600); err != nil { | |
| 106 | + | t.Fatalf("write ignored: %v", err) | |
| 107 | + | } | |
| 108 | + | if err := os.WriteFile(filepath.Join(dir, "beta.snap"), []byte("beta"), 0o600); err != nil { | |
| 109 | + | t.Fatalf("write snap: %v", err) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | got := loadMap(t, store) | |
| 113 | + | if string(got["beta"]) != "beta" || len(got) != 1 { | |
| 114 | + | t.Fatalf("Load = %#v, want only beta", got) | |
| 115 | + | } | |
| 116 | + | if _, err := os.Stat(filepath.Join(dir, ".tmp-stale")); !errors.Is(err, os.ErrNotExist) { | |
| 117 | + | t.Fatalf("stale tmp stat err = %v, want not exist", err) | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func TestStoreLoadRejectsOversizedFileBeforeRead(t *testing.T) { | |
| 122 | + | t.Parallel() | |
| 123 | + | dir := t.TempDir() | |
| 124 | + | store := NewStore(dir) | |
| 125 | + | if err := os.WriteFile(filepath.Join(dir, "ok.snap"), []byte("ok"), 0o600); err != nil { | |
| 126 | + | t.Fatalf("write ok: %v", err) | |
| 127 | + | } | |
| 128 | + | tooLarge := filepath.Join(dir, "huge.snap") | |
| 129 | + | f, err := os.Create(tooLarge) | |
| 130 | + | if err != nil { | |
| 131 | + | t.Fatalf("create huge: %v", err) | |
| 132 | + | } | |
| 133 | + | if err := f.Truncate(maxSnapshotFileSize + 1); err != nil { | |
| 134 | + | _ = f.Close() | |
| 135 | + | t.Fatalf("truncate huge: %v", err) | |
| 136 | + | } | |
| 137 | + | if err := f.Close(); err != nil { | |
| 138 | + | t.Fatalf("close huge: %v", err) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | got := loadMap(t, store) | |
| 142 | + | if len(got) != 1 || string(got["ok"]) != "ok" { | |
| 143 | + | t.Fatalf("Load = %#v, want only ok", got) | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func TestStoreLoadSkipsUnreadableFile(t *testing.T) { | |
| 148 | + | if runtime.GOOS == "windows" || os.Geteuid() == 0 { | |
| 149 | + | t.Skip("permission bits do not make file unreadable on this platform/user") | |
| 150 | + | } | |
| 151 | + | t.Parallel() | |
| 152 | + | dir := t.TempDir() | |
| 153 | + | store := NewStore(dir) | |
| 154 | + | if err := os.WriteFile(filepath.Join(dir, "ok.snap"), []byte("ok"), 0o600); err != nil { | |
| 155 | + | t.Fatalf("write ok: %v", err) | |
| 156 | + | } | |
| 157 | + | bad := filepath.Join(dir, "bad.snap") | |
| 158 | + | if err := os.WriteFile(bad, []byte("bad"), 0o000); err != nil { | |
| 159 | + | t.Fatalf("write bad: %v", err) | |
| 160 | + | } | |
| 161 | + | t.Cleanup(func() { _ = os.Chmod(bad, 0o600) }) | |
| 162 | + | ||
| 163 | + | got := loadMap(t, store) | |
| 164 | + | if len(got) != 1 || string(got["ok"]) != "ok" { | |
| 165 | + | t.Fatalf("Load = %#v, want only ok", got) | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func loadMap(t *testing.T, store *Store) map[string][]byte { | |
| 170 | + | t.Helper() | |
| 171 | + | loaded, err := store.Load() | |
| 172 | + | if err != nil { | |
| 173 | + | t.Fatalf("Load: %v", err) | |
| 174 | + | } | |
| 175 | + | sort.Slice(loaded, func(i, j int) bool { return strings.Compare(loaded[i].Name, loaded[j].Name) < 0 }) | |
| 176 | + | out := make(map[string][]byte, len(loaded)) | |
| 177 | + | for _, blob := range loaded { | |
| 178 | + | out[blob.Name] = blob.Data | |
| 179 | + | } | |
| 180 | + | return out | |
| 181 | + | } | |
| @@ -0,0 +1,181 @@ | |||||
| 1 | + | package snapshot | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "crypto/sha256" | |||
| 5 | + | "encoding/hex" | |||
| 6 | + | "errors" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "runtime" | |||
| 10 | + | "sort" | |||
| 11 | + | "strings" | |||
| 12 | + | "testing" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestStoreWriteLoadRoundTripAndSupersede(t *testing.T) { | |||
| 16 | + | t.Parallel() | |||
| 17 | + | dir := t.TempDir() | |||
| 18 | + | store := NewStore(dir) | |||
| 19 | + | ||||
| 20 | + | if err := store.Write("alpha", []byte("first")); err != nil { | |||
| 21 | + | t.Fatalf("Write first: %v", err) | |||
| 22 | + | } | |||
| 23 | + | if err := store.Write("alpha", []byte("second")); err != nil { | |||
| 24 | + | t.Fatalf("Write second: %v", err) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | got := loadMap(t, store) | |||
| 28 | + | if string(got["alpha"]) != "second" { | |||
| 29 | + | t.Fatalf("loaded alpha = %q, want second", got["alpha"]) | |||
| 30 | + | } | |||
| 31 | + | info, err := os.Stat(filepath.Join(dir, "alpha.snap")) | |||
| 32 | + | if err != nil { | |||
| 33 | + | t.Fatalf("stat snapshot: %v", err) | |||
| 34 | + | } | |||
| 35 | + | if gotMode := info.Mode().Perm(); gotMode != 0o600 { | |||
| 36 | + | t.Fatalf("snapshot mode = %v, want 0600", gotMode) | |||
| 37 | + | } | |||
| 38 | + | if dirInfo, err := os.Stat(dir); err != nil { | |||
| 39 | + | t.Fatalf("stat dir: %v", err) | |||
| 40 | + | } else if gotMode := dirInfo.Mode().Perm(); gotMode != 0o700 { | |||
| 41 | + | t.Fatalf("dir mode = %v, want 0700", gotMode) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestStoreDelete(t *testing.T) { | |||
| 46 | + | t.Parallel() | |||
| 47 | + | dir := t.TempDir() | |||
| 48 | + | store := NewStore(dir) | |||
| 49 | + | ||||
| 50 | + | if err := store.Write("gone", []byte("data")); err != nil { | |||
| 51 | + | t.Fatalf("Write: %v", err) | |||
| 52 | + | } | |||
| 53 | + | if err := store.Delete("gone"); err != nil { | |||
| 54 | + | t.Fatalf("Delete existing: %v", err) | |||
| 55 | + | } | |||
| 56 | + | if err := store.Delete("gone"); err != nil { | |||
| 57 | + | t.Fatalf("Delete missing: %v", err) | |||
| 58 | + | } | |||
| 59 | + | if got := loadMap(t, store); len(got) != 0 { | |||
| 60 | + | t.Fatalf("Load after delete = %#v, want empty", got) | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestStoreUnsafeNameUsesDeterministicHash(t *testing.T) { | |||
| 65 | + | t.Parallel() | |||
| 66 | + | dir := t.TempDir() | |||
| 67 | + | store := NewStore(dir) | |||
| 68 | + | name := "../unsafe/session name" | |||
| 69 | + | ||||
| 70 | + | if err := store.Write(name, []byte("raw")); err != nil { | |||
| 71 | + | t.Fatalf("Write unsafe: %v", err) | |||
| 72 | + | } | |||
| 73 | + | sum := sha256.Sum256([]byte(name)) | |||
| 74 | + | wantBase := "@" + hex.EncodeToString(sum[:])[:40] + ".snap" | |||
| 75 | + | if _, err := os.Stat(filepath.Join(dir, wantBase)); err != nil { | |||
| 76 | + | t.Fatalf("stat hashed snapshot %q: %v", wantBase, err) | |||
| 77 | + | } | |||
| 78 | + | loaded, err := store.Load() | |||
| 79 | + | if err != nil { | |||
| 80 | + | t.Fatalf("Load: %v", err) | |||
| 81 | + | } | |||
| 82 | + | if len(loaded) != 1 || loaded[0].Name != "" || string(loaded[0].Data) != "raw" { | |||
| 83 | + | t.Fatalf("Load unsafe = %#v, want one nameless raw blob", loaded) | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func TestStoreMissingDirAndStaleTmpCleanup(t *testing.T) { | |||
| 88 | + | t.Parallel() | |||
| 89 | + | dir := filepath.Join(t.TempDir(), "missing") | |||
| 90 | + | store := NewStore(dir) | |||
| 91 | + | ||||
| 92 | + | loaded, err := store.Load() | |||
| 93 | + | if err != nil { | |||
| 94 | + | t.Fatalf("Load missing dir: %v", err) | |||
| 95 | + | } | |||
| 96 | + | if len(loaded) != 0 { | |||
| 97 | + | t.Fatalf("Load missing dir len = %d, want 0", len(loaded)) | |||
| 98 | + | } | |||
| 99 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |||
| 100 | + | t.Fatalf("MkdirAll: %v", err) | |||
| 101 | + | } | |||
| 102 | + | if err := os.WriteFile(filepath.Join(dir, ".tmp-stale"), []byte("tmp"), 0o600); err != nil { | |||
| 103 | + | t.Fatalf("write stale tmp: %v", err) | |||
| 104 | + | } | |||
| 105 | + | if err := os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("ignore"), 0o600); err != nil { | |||
| 106 | + | t.Fatalf("write ignored: %v", err) | |||
| 107 | + | } | |||
| 108 | + | if err := os.WriteFile(filepath.Join(dir, "beta.snap"), []byte("beta"), 0o600); err != nil { | |||
| 109 | + | t.Fatalf("write snap: %v", err) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | got := loadMap(t, store) | |||
| 113 | + | if string(got["beta"]) != "beta" || len(got) != 1 { | |||
| 114 | + | t.Fatalf("Load = %#v, want only beta", got) | |||
| 115 | + | } | |||
| 116 | + | if _, err := os.Stat(filepath.Join(dir, ".tmp-stale")); !errors.Is(err, os.ErrNotExist) { | |||
| 117 | + | t.Fatalf("stale tmp stat err = %v, want not exist", err) | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func TestStoreLoadRejectsOversizedFileBeforeRead(t *testing.T) { | |||
| 122 | + | t.Parallel() | |||
| 123 | + | dir := t.TempDir() | |||
| 124 | + | store := NewStore(dir) | |||
| 125 | + | if err := os.WriteFile(filepath.Join(dir, "ok.snap"), []byte("ok"), 0o600); err != nil { | |||
| 126 | + | t.Fatalf("write ok: %v", err) | |||
| 127 | + | } | |||
| 128 | + | tooLarge := filepath.Join(dir, "huge.snap") | |||
| 129 | + | f, err := os.Create(tooLarge) | |||
| 130 | + | if err != nil { | |||
| 131 | + | t.Fatalf("create huge: %v", err) | |||
| 132 | + | } | |||
| 133 | + | if err := f.Truncate(maxSnapshotFileSize + 1); err != nil { | |||
| 134 | + | _ = f.Close() | |||
| 135 | + | t.Fatalf("truncate huge: %v", err) | |||
| 136 | + | } | |||
| 137 | + | if err := f.Close(); err != nil { | |||
| 138 | + | t.Fatalf("close huge: %v", err) | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | got := loadMap(t, store) | |||
| 142 | + | if len(got) != 1 || string(got["ok"]) != "ok" { | |||
| 143 | + | t.Fatalf("Load = %#v, want only ok", got) | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func TestStoreLoadSkipsUnreadableFile(t *testing.T) { | |||
| 148 | + | if runtime.GOOS == "windows" || os.Geteuid() == 0 { | |||
| 149 | + | t.Skip("permission bits do not make file unreadable on this platform/user") | |||
| 150 | + | } | |||
| 151 | + | t.Parallel() | |||
| 152 | + | dir := t.TempDir() | |||
| 153 | + | store := NewStore(dir) | |||
| 154 | + | if err := os.WriteFile(filepath.Join(dir, "ok.snap"), []byte("ok"), 0o600); err != nil { | |||
| 155 | + | t.Fatalf("write ok: %v", err) | |||
| 156 | + | } | |||
| 157 | + | bad := filepath.Join(dir, "bad.snap") | |||
| 158 | + | if err := os.WriteFile(bad, []byte("bad"), 0o000); err != nil { | |||
| 159 | + | t.Fatalf("write bad: %v", err) | |||
| 160 | + | } | |||
| 161 | + | t.Cleanup(func() { _ = os.Chmod(bad, 0o600) }) | |||
| 162 | + | ||||
| 163 | + | got := loadMap(t, store) | |||
| 164 | + | if len(got) != 1 || string(got["ok"]) != "ok" { | |||
| 165 | + | t.Fatalf("Load = %#v, want only ok", got) | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func loadMap(t *testing.T, store *Store) map[string][]byte { | |||
| 170 | + | t.Helper() | |||
| 171 | + | loaded, err := store.Load() | |||
| 172 | + | if err != nil { | |||
| 173 | + | t.Fatalf("Load: %v", err) | |||
| 174 | + | } | |||
| 175 | + | sort.Slice(loaded, func(i, j int) bool { return strings.Compare(loaded[i].Name, loaded[j].Name) < 0 }) | |||
| 176 | + | out := make(map[string][]byte, len(loaded)) | |||
| 177 | + | for _, blob := range loaded { | |||
| 178 | + | out[blob.Name] = blob.Data | |||
| 179 | + | } | |||
| 180 | + | return out | |||
| 181 | + | } | |||
M internal/app/run.go +11 -0
| @@ -17,6 +17,7 @@ import ( | |||
| 17 | 17 | _ "net/http/pprof" | |
| 18 | 18 | "os" | |
| 19 | 19 | "os/signal" | |
| 20 | + | "path/filepath" | |
| 20 | 21 | "syscall" | |
| 21 | 22 | "text/tabwriter" | |
| 22 | 23 | "time" | |
| @@ -27,6 +28,7 @@ import ( | |||
| 27 | 28 | "github.com/bnema/vev/internal/adapters/dgram" | |
| 28 | 29 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 29 | 30 | "github.com/bnema/vev/internal/adapters/pty" | |
| 31 | + | snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot" | |
| 30 | 32 | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 31 | 33 | "github.com/bnema/vev/internal/adapters/term" | |
| 32 | 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 | 244 | // runDaemon runs the daemon in the foreground (the hidden --daemon path, | |
| 239 | 245 | // entered by an auto-spawned child): it sets up logging, binds the socket, | |
| 240 | 246 | // constructs the daemon, and serves until the last session exits or a | |
| @@ -275,6 +281,7 @@ func runDaemon() error { | |||
| 275 | 281 | } | |
| 276 | 282 | logConfigWarnings(log, warnings) | |
| 277 | 283 | daemonOpts = append(daemonOpts, daemon.WithConfig(cfg)) | |
| 284 | + | daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir()))) | |
| 278 | 285 | storePath := persist.StorePath(platform.StateDir()) | |
| 279 | 286 | if store, err := kv.Open(storePath); err != nil { | |
| 280 | 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 | 783 | if closeErr := p.Close(); closeErr != nil { | |
| 777 | 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 | 790 | printKillSuccess(name, all, daemon) | |
| 780 | 791 | return nil | |
| 781 | 792 | } | |
| @@ -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 a | 246 | // 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 nil | 791 | return nil | ||
| 781 | } | 792 | } | ||
M internal/app/run_test.go +12 -0
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "testing" | |
| 13 | 13 | "time" | |
| 14 | 14 | ||
| 15 | + | "github.com/bnema/vev/internal/adapters/snapshot" | |
| 15 | 16 | "github.com/bnema/vev/internal/persist" | |
| 16 | 17 | "github.com/bnema/vev/internal/ports" | |
| 17 | 18 | "github.com/bnema/vev/internal/usecase/client" | |
| @@ -175,6 +176,10 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) { | |||
| 175 | 176 | if err := p.Close(); err != nil { | |
| 176 | 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 | 184 | got := captureStdout(t, func() { | |
| 180 | 185 | if err := runKill(context.Background(), "stored", false, false); err != nil { | |
| @@ -191,6 +196,13 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) { | |||
| 191 | 196 | if len(records) != 0 { | |
| 192 | 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 | 208 | 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 | |||
| 1586 | 1586 | 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 | 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 | 1780 | // The first argument is typically a *testing.T value. | |
| 1591 | 1781 | func NewMockClock(t interface { | |
| @@ -1586,6 +1586,196 @@ func (_c *MockStore_Sync_Call) RunAndReturn(run func() error) *MockStore_Sync_Ca | |||||
| 1586 | return _c | 1586 | 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 { | |||
| 70 | 70 | 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 | 86 | // Clock abstracts time so usecases can be tested without real delays. | |
| 74 | 87 | type Clock interface { | |
| 75 | 88 | Now() time.Time | |
| @@ -70,6 +70,19 @@ type Store interface { | |||||
| 70 | Close() error | 70 | 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.Time | 88 | 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) { | |||
| 41 | 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 | 59 | // Row returns a copy of the retained row at logical index i, oldest first. | |
| 45 | 60 | func (s *Scrollback) Row(i int) []renderer.Cell { | |
| 46 | 61 | 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) { | |||
| 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 | + | } | |
| @@ -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) { | |||
| 537 | 537 | d.applyLayoutLocked(tb) | |
| 538 | 538 | tb.mu.Unlock() | |
| 539 | 539 | } | |
| 540 | + | markSnapshotDirty(sess) | |
| 540 | 541 | if ac != nil { | |
| 541 | 542 | d.paint(sess, ac, true) | |
| 542 | 543 | } | |
| @@ -537,6 +537,7 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||||
| 537 | d.applyLayoutLocked(tb) | 537 | d.applyLayoutLocked(tb) | ||
| 538 | tb.mu.Unlock() | 538 | tb.mu.Unlock() | ||
| 539 | } | 539 | } | ||
| 540 | + | markSnapshotDirty(sess) | |||
| 540 | if ac != nil { | 541 | if ac != nil { | ||
| 541 | d.paint(sess, ac, true) | 542 | d.paint(sess, ac, true) | ||
| 542 | } | 543 | } | ||
M internal/usecase/daemon/daemon.go +49 -14
| @@ -104,6 +104,10 @@ type Daemon struct { | |||
| 104 | 104 | shellArgs []string | |
| 105 | 105 | persist *persist.Persister | |
| 106 | 106 | persistEnabled bool | |
| 107 | + | snaps ports.SnapshotStore | |
| 108 | + | snapsEnabled bool | |
| 109 | + | restoreDone chan struct{} | |
| 110 | + | restoreOnce sync.Once | |
| 107 | 111 | procCwd func(int) (string, error) | |
| 108 | 112 | procComm func(int) (string, error) | |
| 109 | 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 | 184 | // WithCwdReader overrides the process cwd reader used for persistence tests. | |
| 172 | 185 | func WithCwdReader(fn func(int) (string, error)) Option { | |
| 173 | 186 | return func(d *Daemon) { | |
| @@ -204,19 +217,20 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 204 | 217 | shell = "/bin/sh" | |
| 205 | 218 | } | |
| 206 | 219 | 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), | |
| 220 | 234 | } | |
| 221 | 235 | for _, o := range opts { | |
| 222 | 236 | o(d) | |
| @@ -269,6 +283,16 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||
| 269 | 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 | 297 | // Break the accept loop when either the parent context is cancelled or the | |
| 274 | 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 | 456 | target := d.findByNameLocked(k.Name) | |
| 433 | 457 | if target == nil { | |
| 434 | 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 | 467 | if err := d.persist.Delete(k.Name); err != nil { | |
| 436 | - | d.mu.Unlock() | |
| 437 | 468 | d.log.Warn("deleting persisted stopped session failed", "err", err, "session", k.Name) | |
| 438 | 469 | _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted stopped session failed")) | |
| 439 | 470 | return | |
| 440 | 471 | } | |
| 472 | + | d.mu.Lock() | |
| 441 | 473 | if cur, ok := d.stopped[k.Name]; ok && cur == stopped { | |
| 442 | 474 | delete(d.stopped, k.Name) | |
| 443 | 475 | } | |
| @@ -514,6 +546,9 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 514 | 546 | if !sz.Valid() { | |
| 515 | 547 | sz = defaultSize | |
| 516 | 548 | } | |
| 549 | + | if d.snapsEnabled && (h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach || h.Intent == ports.IntentNew) { | |
| 550 | + | <-d.restoreDone | |
| 551 | + | } | |
| 517 | 552 | ||
| 518 | 553 | d.mu.Lock() | |
| 519 | 554 | // Shutdown/create interlock: once shutdown has begun (last session removed, | |
| @@ -104,6 +104,10 @@ type Daemon struct { | |||||
| 104 | shellArgs []string | 104 | shellArgs []string | ||
| 105 | persist *persist.Persister | 105 | persist *persist.Persister | ||
| 106 | persistEnabled bool | 106 | 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 the | 297 | // 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 | return | 470 | return | ||
| 440 | } | 471 | } | ||
| 472 | + | d.mu.Lock() | |||
| 441 | if cur, ok := d.stopped[k.Name]; ok && cur == stopped { | 473 | if cur, ok := d.stopped[k.Name]; ok && cur == stopped { | ||
| 442 | delete(d.stopped, k.Name) | 474 | delete(d.stopped, k.Name) | ||
| 443 | } | 475 | } | ||
| @@ -514,6 +546,9 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 514 | if !sz.Valid() { | 546 | if !sz.Valid() { | ||
| 515 | sz = defaultSize | 547 | sz = defaultSize | ||
| 516 | } | 548 | } | ||
| 549 | + | if d.snapsEnabled && (h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach || h.Intent == ports.IntentNew) { | |||
| 550 | + | <-d.restoreDone | |||
| 551 | + | } | |||
| 517 | 552 | ||||
| 518 | d.mu.Lock() | 553 | d.mu.Lock() | ||
| 519 | // Shutdown/create interlock: once shutdown has begun (last session removed, | 554 | // Shutdown/create interlock: once shutdown has begun (last session removed, | ||
M internal/usecase/daemon/pane_actions.go +12 -2
| @@ -87,6 +87,7 @@ func (d *Daemon) spawnPaneOp( | |||
| 87 | 87 | tb.mu.Unlock() | |
| 88 | 88 | ||
| 89 | 89 | d.startPaneGoroutines(sess, tb, p) | |
| 90 | + | markSnapshotDirty(sess) | |
| 90 | 91 | if ac != nil { | |
| 91 | 92 | d.paint(sess, ac, true) | |
| 92 | 93 | } | |
| @@ -174,8 +175,11 @@ func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error { | |||
| 174 | 175 | d.applyLayoutLocked(tb) | |
| 175 | 176 | } | |
| 176 | 177 | 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 | + | } | |
| 179 | 183 | } | |
| 180 | 184 | return err | |
| 181 | 185 | } | |
| @@ -238,6 +242,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||
| 238 | 242 | _ = p.pty.Close() | |
| 239 | 243 | } | |
| 240 | 244 | d.log.Info("pane closed", "session", sess.name, "pane", id) | |
| 245 | + | markSnapshotDirty(sess) | |
| 241 | 246 | if repaint { | |
| 242 | 247 | if ac == nil { | |
| 243 | 248 | sess.mu.Lock() | |
| @@ -268,6 +273,11 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio | |||
| 268 | 273 | d.applyLayoutLocked(tb) | |
| 269 | 274 | } | |
| 270 | 275 | tb.mu.Unlock() | |
| 276 | + | if err == nil { | |
| 277 | + | if newFocus != oldFocus { | |
| 278 | + | markSnapshotDirty(sess) | |
| 279 | + | } | |
| 280 | + | } | |
| 271 | 281 | if err == nil && ac != nil { | |
| 272 | 282 | if newFocus != oldFocus { | |
| 273 | 283 | 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 err | 184 | 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) { | |||
| 71 | 71 | p.screen.Write(data) | |
| 72 | 72 | isSyncing := p.screen.SyncUpdateActive() | |
| 73 | 73 | p.mu.Unlock() | |
| 74 | + | markSnapshotDirty(sess) | |
| 74 | 75 | select { | |
| 75 | 76 | case <-attentionCh: | |
| 76 | 77 | d.noteAttention(sess, tb) | |
| @@ -71,6 +71,7 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) { | |||||
| 71 | p.screen.Write(data) | 71 | p.screen.Write(data) | ||
| 72 | isSyncing := p.screen.SyncUpdateActive() | 72 | isSyncing := p.screen.SyncUpdateActive() | ||
| 73 | p.mu.Unlock() | 73 | p.mu.Unlock() | ||
| 74 | + | markSnapshotDirty(sess) | |||
| 74 | select { | 75 | select { | ||
| 75 | case <-attentionCh: | 76 | case <-attentionCh: | ||
| 76 | d.noteAttention(sess, tb) | 77 | d.noteAttention(sess, tb) | ||
M internal/usecase/daemon/session.go +36 -3
| @@ -58,6 +58,7 @@ type session struct { | |||
| 58 | 58 | cwd string | |
| 59 | 59 | createdAt int64 | |
| 60 | 60 | mruAt atomic.Uint64 | |
| 61 | + | snapDirty atomic.Bool | |
| 61 | 62 | // clipFiles records clipboard-image-transfer temp file paths (see | |
| 62 | 63 | // clipboard.go) written for this session, removed best-effort in | |
| 63 | 64 | // killSession. | |
| @@ -263,6 +264,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||
| 263 | 264 | d.log.Info("tab created", "session", name, "tab", tabIndex) | |
| 264 | 265 | d.startTabGoroutines(sess, tb) | |
| 265 | 266 | d.mu.Unlock() | |
| 267 | + | markSnapshotDirty(sess) | |
| 266 | 268 | return nil | |
| 267 | 269 | } | |
| 268 | 270 | ||
| @@ -360,21 +362,25 @@ func (s *session) activeTab() *tab { | |||
| 360 | 362 | ||
| 361 | 363 | func (s *session) switchTab(idx int) bool { | |
| 362 | 364 | s.mu.Lock() | |
| 363 | - | defer s.mu.Unlock() | |
| 364 | 365 | if idx < 0 || idx >= len(s.tabs) || idx == s.active { | |
| 366 | + | s.mu.Unlock() | |
| 365 | 367 | return false | |
| 366 | 368 | } | |
| 367 | 369 | s.active = idx | |
| 370 | + | s.mu.Unlock() | |
| 371 | + | markSnapshotDirty(s) | |
| 368 | 372 | return true | |
| 369 | 373 | } | |
| 370 | 374 | ||
| 371 | 375 | func (s *session) switchRelative(delta int) bool { | |
| 372 | 376 | s.mu.Lock() | |
| 373 | - | defer s.mu.Unlock() | |
| 374 | 377 | if len(s.tabs) < 2 { | |
| 378 | + | s.mu.Unlock() | |
| 375 | 379 | return false | |
| 376 | 380 | } | |
| 377 | 381 | s.active = (s.active + delta + len(s.tabs)) % len(s.tabs) | |
| 382 | + | s.mu.Unlock() | |
| 383 | + | markSnapshotDirty(s) | |
| 378 | 384 | return true | |
| 379 | 385 | } | |
| 380 | 386 | ||
| @@ -391,7 +397,6 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||
| 391 | 397 | return errors.New("name already in use") | |
| 392 | 398 | } | |
| 393 | 399 | sess.mu.Lock() | |
| 394 | - | defer sess.mu.Unlock() | |
| 395 | 400 | oldName := sess.name | |
| 396 | 401 | wasEphemeral := sess.ephemeral | |
| 397 | 402 | createdAt := sess.createdAt | |
| @@ -402,14 +407,25 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||
| 402 | 407 | lastUsedSeq := sess.mruAt.Load() | |
| 403 | 408 | if wasEphemeral || oldName != name { | |
| 404 | 409 | if err := d.persist.Save(persist.Record{Name: name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: time.Now().UnixNano(), LastUsedSeq: lastUsedSeq}); err != nil { | |
| 410 | + | sess.mu.Unlock() | |
| 405 | 411 | return err | |
| 406 | 412 | } | |
| 407 | 413 | } | |
| 408 | 414 | if !wasEphemeral && oldName != name { | |
| 415 | + | if d.snapsEnabled && d.snaps != nil { | |
| 416 | + | if err := d.snaps.Delete(oldName); err != nil { | |
| 417 | + | if cleanupErr := d.persist.Delete(name); cleanupErr != nil { | |
| 418 | + | d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name) | |
| 419 | + | } | |
| 420 | + | sess.mu.Unlock() | |
| 421 | + | return err | |
| 422 | + | } | |
| 423 | + | } | |
| 409 | 424 | if err := d.persist.Delete(oldName); err != nil { | |
| 410 | 425 | if cleanupErr := d.persist.Delete(name); cleanupErr != nil { | |
| 411 | 426 | d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name) | |
| 412 | 427 | } | |
| 428 | + | sess.mu.Unlock() | |
| 413 | 429 | return err | |
| 414 | 430 | } | |
| 415 | 431 | } | |
| @@ -417,6 +433,8 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||
| 417 | 433 | delete(d.stopped, name) | |
| 418 | 434 | sess.name = name | |
| 419 | 435 | sess.ephemeral = false | |
| 436 | + | sess.mu.Unlock() | |
| 437 | + | markSnapshotDirty(sess) | |
| 420 | 438 | return nil | |
| 421 | 439 | } | |
| 422 | 440 | ||
| @@ -451,6 +469,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||
| 451 | 469 | name := sess.name | |
| 452 | 470 | sess.mu.Unlock() | |
| 453 | 471 | d.log.Info("tab closed", "session", name) | |
| 472 | + | markSnapshotDirty(sess) | |
| 454 | 473 | ||
| 455 | 474 | d.clearDestroyedTabPreview(tb) | |
| 456 | 475 | if tb.cancel != nil { | |
| @@ -476,6 +495,19 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||
| 476 | 495 | if !purge && !isEphemeral && d.persistEnabled { | |
| 477 | 496 | d.refreshSessionCwd(sess) | |
| 478 | 497 | } | |
| 498 | + | if d.snapsEnabled && !isEphemeral { | |
| 499 | + | if purge { | |
| 500 | + | sess.mu.Lock() | |
| 501 | + | name := sess.name | |
| 502 | + | sess.mu.Unlock() | |
| 503 | + | if err := d.snaps.Delete(name); err != nil { | |
| 504 | + | d.log.Warn("deleting session snapshot failed", "err", err, "session", name) | |
| 505 | + | return err | |
| 506 | + | } | |
| 507 | + | } else { | |
| 508 | + | d.captureSession(sess) | |
| 509 | + | } | |
| 510 | + | } | |
| 479 | 511 | d.mu.Lock() | |
| 480 | 512 | if _, ok := d.sessions[sess.id]; !ok { | |
| 481 | 513 | d.mu.Unlock() | |
| @@ -658,6 +690,7 @@ func (d *Daemon) refreshSessionCwd(sess *session) { | |||
| 658 | 690 | if err := d.persist.Touch(name, cwd, time.Now().UnixNano()); err != nil { | |
| 659 | 691 | d.log.Warn("touching persisted session cwd failed", "err", err, "session", name) | |
| 660 | 692 | } | |
| 693 | + | markSnapshotDirty(sess) | |
| 661 | 694 | } | |
| 662 | 695 | d.mu.Unlock() | |
| 663 | 696 | } | |
| @@ -58,6 +58,7 @@ type session struct { | |||||
| 58 | cwd string | 58 | cwd string | ||
| 59 | createdAt int64 | 59 | createdAt int64 | ||
| 60 | mruAt atomic.Uint64 | 60 | mruAt atomic.Uint64 | ||
| 61 | + | snapDirty atomic.Bool | |||
| 61 | // clipFiles records clipboard-image-transfer temp file paths (see | 62 | // clipFiles records clipboard-image-transfer temp file paths (see | ||
| 62 | // clipboard.go) written for this session, removed best-effort in | 63 | // clipboard.go) written for this session, removed best-effort in | ||
| 63 | // killSession. | 64 | // killSession. | ||
| @@ -263,6 +264,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||||
| 263 | d.log.Info("tab created", "session", name, "tab", tabIndex) | 264 | d.log.Info("tab created", "session", name, "tab", tabIndex) | ||
| 264 | d.startTabGoroutines(sess, tb) | 265 | d.startTabGoroutines(sess, tb) | ||
| 265 | d.mu.Unlock() | 266 | d.mu.Unlock() | ||
| 267 | + | markSnapshotDirty(sess) | |||
| 266 | return nil | 268 | return nil | ||
| 267 | } | 269 | } | ||
| 268 | 270 | ||||
| @@ -360,21 +362,25 @@ func (s *session) activeTab() *tab { | |||||
| 360 | 362 | ||||
| 361 | func (s *session) switchTab(idx int) bool { | 363 | func (s *session) switchTab(idx int) bool { | ||
| 362 | s.mu.Lock() | 364 | s.mu.Lock() | ||
| 363 | - | defer s.mu.Unlock() | |||
| 364 | if idx < 0 || idx >= len(s.tabs) || idx == s.active { | 365 | if idx < 0 || idx >= len(s.tabs) || idx == s.active { | ||
| 366 | + | s.mu.Unlock() | |||
| 365 | return false | 367 | return false | ||
| 366 | } | 368 | } | ||
| 367 | s.active = idx | 369 | s.active = idx | ||
| 370 | + | s.mu.Unlock() | |||
| 371 | + | markSnapshotDirty(s) | |||
| 368 | return true | 372 | return true | ||
| 369 | } | 373 | } | ||
| 370 | 374 | ||||
| 371 | func (s *session) switchRelative(delta int) bool { | 375 | func (s *session) switchRelative(delta int) bool { | ||
| 372 | s.mu.Lock() | 376 | s.mu.Lock() | ||
| 373 | - | defer s.mu.Unlock() | |||
| 374 | if len(s.tabs) < 2 { | 377 | if len(s.tabs) < 2 { | ||
| 378 | + | s.mu.Unlock() | |||
| 375 | return false | 379 | return false | ||
| 376 | } | 380 | } | ||
| 377 | s.active = (s.active + delta + len(s.tabs)) % len(s.tabs) | 381 | s.active = (s.active + delta + len(s.tabs)) % len(s.tabs) | ||
| 382 | + | s.mu.Unlock() | |||
| 383 | + | markSnapshotDirty(s) | |||
| 378 | return true | 384 | return true | ||
| 379 | } | 385 | } | ||
| 380 | 386 | ||||
| @@ -391,7 +397,6 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||||
| 391 | return errors.New("name already in use") | 397 | return errors.New("name already in use") | ||
| 392 | } | 398 | } | ||
| 393 | sess.mu.Lock() | 399 | sess.mu.Lock() | ||
| 394 | - | defer sess.mu.Unlock() | |||
| 395 | oldName := sess.name | 400 | oldName := sess.name | ||
| 396 | wasEphemeral := sess.ephemeral | 401 | wasEphemeral := sess.ephemeral | ||
| 397 | createdAt := sess.createdAt | 402 | createdAt := sess.createdAt | ||
| @@ -402,14 +407,25 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||||
| 402 | lastUsedSeq := sess.mruAt.Load() | 407 | lastUsedSeq := sess.mruAt.Load() | ||
| 403 | if wasEphemeral || oldName != name { | 408 | if wasEphemeral || oldName != name { | ||
| 404 | if err := d.persist.Save(persist.Record{Name: name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: time.Now().UnixNano(), LastUsedSeq: lastUsedSeq}); err != nil { | 409 | if err := d.persist.Save(persist.Record{Name: name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: time.Now().UnixNano(), LastUsedSeq: lastUsedSeq}); err != nil { | ||
| 410 | + | sess.mu.Unlock() | |||
| 405 | return err | 411 | return err | ||
| 406 | } | 412 | } | ||
| 407 | } | 413 | } | ||
| 408 | if !wasEphemeral && oldName != name { | 414 | if !wasEphemeral && oldName != name { | ||
| 415 | + | if d.snapsEnabled && d.snaps != nil { | |||
| 416 | + | if err := d.snaps.Delete(oldName); err != nil { | |||
| 417 | + | if cleanupErr := d.persist.Delete(name); cleanupErr != nil { | |||
| 418 | + | d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name) | |||
| 419 | + | } | |||
| 420 | + | sess.mu.Unlock() | |||
| 421 | + | return err | |||
| 422 | + | } | |||
| 423 | + | } | |||
| 409 | if err := d.persist.Delete(oldName); err != nil { | 424 | if err := d.persist.Delete(oldName); err != nil { | ||
| 410 | if cleanupErr := d.persist.Delete(name); cleanupErr != nil { | 425 | if cleanupErr := d.persist.Delete(name); cleanupErr != nil { | ||
| 411 | d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name) | 426 | d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name) | ||
| 412 | } | 427 | } | ||
| 428 | + | sess.mu.Unlock() | |||
| 413 | return err | 429 | return err | ||
| 414 | } | 430 | } | ||
| 415 | } | 431 | } | ||
| @@ -417,6 +433,8 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||||
| 417 | delete(d.stopped, name) | 433 | delete(d.stopped, name) | ||
| 418 | sess.name = name | 434 | sess.name = name | ||
| 419 | sess.ephemeral = false | 435 | sess.ephemeral = false | ||
| 436 | + | sess.mu.Unlock() | |||
| 437 | + | markSnapshotDirty(sess) | |||
| 420 | return nil | 438 | return nil | ||
| 421 | } | 439 | } | ||
| 422 | 440 | ||||
| @@ -451,6 +469,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||||
| 451 | name := sess.name | 469 | name := sess.name | ||
| 452 | sess.mu.Unlock() | 470 | sess.mu.Unlock() | ||
| 453 | d.log.Info("tab closed", "session", name) | 471 | d.log.Info("tab closed", "session", name) | ||
| 472 | + | markSnapshotDirty(sess) | |||
| 454 | 473 | ||||
| 455 | d.clearDestroyedTabPreview(tb) | 474 | d.clearDestroyedTabPreview(tb) | ||
| 456 | if tb.cancel != nil { | 475 | if tb.cancel != nil { | ||
| @@ -476,6 +495,19 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||||
| 476 | if !purge && !isEphemeral && d.persistEnabled { | 495 | if !purge && !isEphemeral && d.persistEnabled { | ||
| 477 | d.refreshSessionCwd(sess) | 496 | d.refreshSessionCwd(sess) | ||
| 478 | } | 497 | } | ||
| 498 | + | if d.snapsEnabled && !isEphemeral { | |||
| 499 | + | if purge { | |||
| 500 | + | sess.mu.Lock() | |||
| 501 | + | name := sess.name | |||
| 502 | + | sess.mu.Unlock() | |||
| 503 | + | if err := d.snaps.Delete(name); err != nil { | |||
| 504 | + | d.log.Warn("deleting session snapshot failed", "err", err, "session", name) | |||
| 505 | + | return err | |||
| 506 | + | } | |||
| 507 | + | } else { | |||
| 508 | + | d.captureSession(sess) | |||
| 509 | + | } | |||
| 510 | + | } | |||
| 479 | d.mu.Lock() | 511 | d.mu.Lock() | ||
| 480 | if _, ok := d.sessions[sess.id]; !ok { | 512 | if _, ok := d.sessions[sess.id]; !ok { | ||
| 481 | d.mu.Unlock() | 513 | d.mu.Unlock() | ||
| @@ -658,6 +690,7 @@ func (d *Daemon) refreshSessionCwd(sess *session) { | |||||
| 658 | if err := d.persist.Touch(name, cwd, time.Now().UnixNano()); err != nil { | 690 | if err := d.persist.Touch(name, cwd, time.Now().UnixNano()); err != nil { | ||
| 659 | d.log.Warn("touching persisted session cwd failed", "err", err, "session", name) | 691 | d.log.Warn("touching persisted session cwd failed", "err", err, "session", name) | ||
| 660 | } | 692 | } | ||
| 693 | + | markSnapshotDirty(sess) | |||
| 661 | } | 694 | } | ||
| 662 | d.mu.Unlock() | 695 | d.mu.Unlock() | ||
| 663 | } | 696 | } | ||
A internal/usecase/daemon/snapshot.go +335 -0
| @@ -0,0 +1,335 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/persist" | |
| 10 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 11 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 13 | + | "github.com/bnema/vev/pkg/renderer" | |
| 14 | + | "github.com/bnema/vev/pkg/vt" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | const snapshotInterval = 30 * time.Second | |
| 18 | + | ||
| 19 | + | func (d *Daemon) closeRestoreDone() { | |
| 20 | + | d.restoreOnce.Do(func() { close(d.restoreDone) }) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func (d *Daemon) restoreSnapshots(ctx context.Context) { | |
| 24 | + | defer d.closeRestoreDone() | |
| 25 | + | if d == nil || !d.snapsEnabled || d.snaps == nil { | |
| 26 | + | return | |
| 27 | + | } | |
| 28 | + | blobs, err := d.snaps.Load() | |
| 29 | + | if err != nil { | |
| 30 | + | d.log.Warn("loading session snapshots failed", "err", err) | |
| 31 | + | return | |
| 32 | + | } | |
| 33 | + | for _, blob := range blobs { | |
| 34 | + | select { | |
| 35 | + | case <-ctx.Done(): | |
| 36 | + | return | |
| 37 | + | default: | |
| 38 | + | } | |
| 39 | + | snap, err := snapcodec.Unmarshal(blob.Data) | |
| 40 | + | if err != nil { | |
| 41 | + | d.log.Warn("decoding session snapshot failed", "err", err, "session", blob.Name) | |
| 42 | + | continue | |
| 43 | + | } | |
| 44 | + | if err := d.restoreSession(ctx, snap); err != nil { | |
| 45 | + | d.log.Warn("restoring session snapshot failed", "err", err, "session", snap.Name) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) error { | |
| 51 | + | if snap.Name == "" { | |
| 52 | + | return fmt.Errorf("snapshot: empty session name") | |
| 53 | + | } | |
| 54 | + | d.mu.Lock() | |
| 55 | + | if d.closing || d.findByNameLocked(snap.Name) != nil { | |
| 56 | + | d.mu.Unlock() | |
| 57 | + | return nil | |
| 58 | + | } | |
| 59 | + | d.mu.Unlock() | |
| 60 | + | ||
| 61 | + | sctx, cancel := context.WithCancel(d.serveCtx) | |
| 62 | + | opened := make([]*tab, 0, len(snap.Tabs)) | |
| 63 | + | closeOpened := func() { | |
| 64 | + | for _, tb := range opened { | |
| 65 | + | tb.closeAllPanes() | |
| 66 | + | } | |
| 67 | + | cancel() | |
| 68 | + | } | |
| 69 | + | for _, tabSnap := range snap.Tabs { | |
| 70 | + | tbSize := domain.Size{Cols: int(tabSnap.Cols), Rows: int(tabSnap.Rows)} | |
| 71 | + | if !tbSize.Valid() { | |
| 72 | + | closeOpened() | |
| 73 | + | return fmt.Errorf("snapshot: invalid tab size") | |
| 74 | + | } | |
| 75 | + | placements, ok := layout.Solve(tabSnap.Tree.Root, domain.Rect{Width: tbSize.Cols, Height: tbSize.Rows}) | |
| 76 | + | if !ok { | |
| 77 | + | closeOpened() | |
| 78 | + | return fmt.Errorf("snapshot: unsolvable layout") | |
| 79 | + | } | |
| 80 | + | placementByPane := make(map[layout.PaneID]domain.Rect, len(placements)) | |
| 81 | + | for _, pl := range placements { | |
| 82 | + | placementByPane[pl.ID] = pl.Content | |
| 83 | + | } | |
| 84 | + | tb := &tab{tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize} | |
| 85 | + | if tb.nextPaneID <= 0 { | |
| 86 | + | tb.nextPaneID = 1 | |
| 87 | + | } | |
| 88 | + | tb.ctx, tb.cancel = context.WithCancel(sctx) | |
| 89 | + | opened = append(opened, tb) | |
| 90 | + | for _, paneSnap := range tabSnap.Panes { | |
| 91 | + | contentRect, ok := placementByPane[paneSnap.ID] | |
| 92 | + | if !ok { | |
| 93 | + | closeOpened() | |
| 94 | + | return fmt.Errorf("snapshot: missing pane placement") | |
| 95 | + | } | |
| 96 | + | contentSize := restorePTYSize(contentRect, tbSize) | |
| 97 | + | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name), paneSnap.Cwd, contentSize) | |
| 98 | + | if err != nil { | |
| 99 | + | closeOpened() | |
| 100 | + | return err | |
| 101 | + | } | |
| 102 | + | p := newPane(paneSnap.ID, pty, contentSize) | |
| 103 | + | p.ctx, p.cancel = context.WithCancel(tb.ctx) | |
| 104 | + | p.rect = contentRect | |
| 105 | + | seedPaneRows(p, paneSnap.Scrollback, paneSnap.Visible) | |
| 106 | + | tb.panes[paneSnap.ID] = p | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | if len(opened) == 0 { | |
| 110 | + | cancel() | |
| 111 | + | return nil | |
| 112 | + | } | |
| 113 | + | createdAt := int64(snap.CreatedAt) | |
| 114 | + | sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), createdAt: createdAt} | |
| 115 | + | if sess.active < 0 || sess.active >= len(sess.tabs) { | |
| 116 | + | sess.active = 0 | |
| 117 | + | } | |
| 118 | + | if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 { | |
| 119 | + | sess.cwd = snap.Tabs[0].Panes[0].Cwd | |
| 120 | + | } | |
| 121 | + | d.mu.Lock() | |
| 122 | + | if stopped, ok := d.stopped[snap.Name]; ok { | |
| 123 | + | sess.mruAt.Store(stopped.lastUsedSeq) | |
| 124 | + | } | |
| 125 | + | d.mu.Unlock() | |
| 126 | + | lastUsedSeq := sess.mruAt.Load() | |
| 127 | + | if err := d.persist.Save(persist.Record{Name: snap.Name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq}); err != nil { | |
| 128 | + | closeOpened() | |
| 129 | + | return err | |
| 130 | + | } | |
| 131 | + | d.mu.Lock() | |
| 132 | + | if d.closing || d.findByNameLocked(snap.Name) != nil || ctx.Err() != nil { | |
| 133 | + | d.mu.Unlock() | |
| 134 | + | closeOpened() | |
| 135 | + | return nil | |
| 136 | + | } | |
| 137 | + | id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | |
| 138 | + | d.nextID++ | |
| 139 | + | sess.id = id | |
| 140 | + | delete(d.stopped, snap.Name) | |
| 141 | + | d.sessions[id] = sess | |
| 142 | + | d.mu.Unlock() | |
| 143 | + | sess.snapDirty.Store(false) | |
| 144 | + | for _, tb := range opened { | |
| 145 | + | d.startTabGoroutines(sess, tb) | |
| 146 | + | } | |
| 147 | + | return nil | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func markSnapshotDirty(sess *session) { | |
| 151 | + | if sess == nil { | |
| 152 | + | return | |
| 153 | + | } | |
| 154 | + | sess.mu.Lock() | |
| 155 | + | named := !sess.ephemeral && sess.name != "" | |
| 156 | + | sess.mu.Unlock() | |
| 157 | + | if named { | |
| 158 | + | sess.snapDirty.Store(true) | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func seedPaneRows(p *pane, scrollback, visible [][]renderer.Cell) { | |
| 163 | + | p.mu.Lock() | |
| 164 | + | defer p.mu.Unlock() | |
| 165 | + | p.scrollback = scopy.NewScrollback(defaultScrollbackRows) | |
| 166 | + | for _, row := range scrollback { | |
| 167 | + | p.scrollback.Append(row) | |
| 168 | + | } | |
| 169 | + | if len(visible) > 0 { | |
| 170 | + | p.screen = vt.NewScreen(p.screen.Frame.Width, p.screen.Frame.Height) | |
| 171 | + | for y, row := range visible { | |
| 172 | + | if y >= p.screen.Frame.Height { | |
| 173 | + | break | |
| 174 | + | } | |
| 175 | + | for x, cell := range row { | |
| 176 | + | if x >= p.screen.Frame.Width { | |
| 177 | + | break | |
| 178 | + | } | |
| 179 | + | p.screen.Frame.Set(x, y, cell) | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | p.screen.OnLineEvicted = p.scrollback.Append | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size { | |
| 187 | + | cols := contentRect.Width | |
| 188 | + | if cols <= 0 { | |
| 189 | + | cols = tabSize.Cols | |
| 190 | + | } | |
| 191 | + | if cols < layout.MinPaneCols { | |
| 192 | + | cols = layout.MinPaneCols | |
| 193 | + | } | |
| 194 | + | rows := contentRect.Height | |
| 195 | + | if rows <= 0 { | |
| 196 | + | rows = min(max(tabSize.Rows, layout.MinPaneRows), 24) | |
| 197 | + | } | |
| 198 | + | if rows < layout.MinPaneRows { | |
| 199 | + | rows = layout.MinPaneRows | |
| 200 | + | } | |
| 201 | + | return domain.Size{Cols: cols, Rows: rows} | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func (d *Daemon) captureSession(sess *session) bool { | |
| 205 | + | if d == nil || sess == nil || !d.snapsEnabled || d.snaps == nil { | |
| 206 | + | return true | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | sess.mu.Lock() | |
| 210 | + | name := sess.name | |
| 211 | + | ephemeral := sess.ephemeral | |
| 212 | + | createdAt := sess.createdAt | |
| 213 | + | active := sess.active | |
| 214 | + | fallbackCwd := sess.cwd | |
| 215 | + | tabs := append([]*tab(nil), sess.tabs...) | |
| 216 | + | sess.mu.Unlock() | |
| 217 | + | if ephemeral || name == "" { | |
| 218 | + | return true | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | snap := snapcodec.Session{Name: name, CreatedAt: uint64(createdAt), Active: uint16(max(active, 0))} | |
| 222 | + | for _, tb := range tabs { | |
| 223 | + | tb.mu.Lock() | |
| 224 | + | tabSnap := snapcodec.Tab{ | |
| 225 | + | Cols: uint16(max(tb.size.Cols, 0)), | |
| 226 | + | Rows: uint16(max(tb.size.Rows, 0)), | |
| 227 | + | NextPaneID: uint64(max(tb.nextPaneID, 0)), | |
| 228 | + | Tree: tb.tree.Clone(), | |
| 229 | + | } | |
| 230 | + | if tb.tree != nil { | |
| 231 | + | tabSnap.Focus = tb.tree.Focus | |
| 232 | + | } | |
| 233 | + | panes := make([]*pane, 0, len(tb.panes)) | |
| 234 | + | for _, p := range tb.panes { | |
| 235 | + | panes = append(panes, p) | |
| 236 | + | } | |
| 237 | + | tb.mu.Unlock() | |
| 238 | + | ||
| 239 | + | paneIDs := make(map[layout.PaneID]struct{}, len(panes)) | |
| 240 | + | paneSnaps := make([]snapcodec.Pane, 0, len(panes)) | |
| 241 | + | for _, p := range panes { | |
| 242 | + | p.mu.Lock() | |
| 243 | + | pid := 0 | |
| 244 | + | if p.pty != nil { | |
| 245 | + | pid = p.pty.Pid() | |
| 246 | + | } | |
| 247 | + | paneSnap := snapcodec.Pane{ | |
| 248 | + | ID: p.id, | |
| 249 | + | Scrollback: cloneRows(p.scrollback.Snapshot()), | |
| 250 | + | Visible: cloneRows(p.screen.PrimaryVisibleRows()), | |
| 251 | + | } | |
| 252 | + | p.mu.Unlock() | |
| 253 | + | paneSnap.Cwd = fallbackCwd | |
| 254 | + | if d.procCwd != nil && pid > 0 { | |
| 255 | + | if cwd, err := d.procCwd(pid); err == nil && cwd != "" { | |
| 256 | + | paneSnap.Cwd = cwd | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | paneIDs[paneSnap.ID] = struct{}{} | |
| 260 | + | paneSnaps = append(paneSnaps, paneSnap) | |
| 261 | + | } | |
| 262 | + | if tabSnap.Tree != nil { | |
| 263 | + | pruneTreeToPanes(tabSnap.Tree.Root, paneIDs) | |
| 264 | + | } | |
| 265 | + | tabSnap.Panes = paneSnaps | |
| 266 | + | snap.Tabs = append(snap.Tabs, tabSnap) | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | data, err := snapcodec.Marshal(snap) | |
| 270 | + | if err != nil { | |
| 271 | + | d.log.Warn("marshaling session snapshot failed", "err", err, "session", name) | |
| 272 | + | return false | |
| 273 | + | } | |
| 274 | + | if err := d.snaps.Write(name, data); err != nil { | |
| 275 | + | d.log.Warn("writing session snapshot failed", "err", err, "session", name) | |
| 276 | + | return false | |
| 277 | + | } | |
| 278 | + | return true | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | func (d *Daemon) snapshotSaver(ctx context.Context) { | |
| 282 | + | t := d.clock.NewTimer(snapshotInterval) | |
| 283 | + | defer t.Stop() | |
| 284 | + | for { | |
| 285 | + | select { | |
| 286 | + | case <-ctx.Done(): | |
| 287 | + | return | |
| 288 | + | case <-t.C(): | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | d.mu.Lock() | |
| 292 | + | sessions := d.sessionsSnapshotLocked() | |
| 293 | + | d.mu.Unlock() | |
| 294 | + | for _, sess := range sessions { | |
| 295 | + | sess.mu.Lock() | |
| 296 | + | named := !sess.ephemeral && sess.name != "" | |
| 297 | + | sess.mu.Unlock() | |
| 298 | + | if named && sess.snapDirty.Swap(false) { | |
| 299 | + | if !d.captureSession(sess) { | |
| 300 | + | sess.snapDirty.Store(true) | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | } | |
| 304 | + | t.Reset(snapshotInterval) | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | func cloneRows(rows [][]renderer.Cell) [][]renderer.Cell { | |
| 309 | + | if len(rows) == 0 { | |
| 310 | + | return nil | |
| 311 | + | } | |
| 312 | + | out := make([][]renderer.Cell, len(rows)) | |
| 313 | + | for i, row := range rows { | |
| 314 | + | out[i] = append([]renderer.Cell(nil), row...) | |
| 315 | + | } | |
| 316 | + | return out | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | func pruneTreeToPanes(n *layout.Node, panes map[layout.PaneID]struct{}) bool { | |
| 320 | + | if n == nil { | |
| 321 | + | return false | |
| 322 | + | } | |
| 323 | + | if n.Kind == layout.Leaf { | |
| 324 | + | _, ok := panes[n.Leaf] | |
| 325 | + | return ok | |
| 326 | + | } | |
| 327 | + | kept := n.Children[:0] | |
| 328 | + | for _, child := range n.Children { | |
| 329 | + | if pruneTreeToPanes(child, panes) { | |
| 330 | + | kept = append(kept, child) | |
| 331 | + | } | |
| 332 | + | } | |
| 333 | + | n.Children = kept | |
| 334 | + | return len(n.Children) > 0 | |
| 335 | + | } | |
| @@ -0,0 +1,335 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | "github.com/bnema/vev/internal/persist" | |||
| 10 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 11 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 12 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 13 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 14 | + | "github.com/bnema/vev/pkg/vt" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | const snapshotInterval = 30 * time.Second | |||
| 18 | + | ||||
| 19 | + | func (d *Daemon) closeRestoreDone() { | |||
| 20 | + | d.restoreOnce.Do(func() { close(d.restoreDone) }) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func (d *Daemon) restoreSnapshots(ctx context.Context) { | |||
| 24 | + | defer d.closeRestoreDone() | |||
| 25 | + | if d == nil || !d.snapsEnabled || d.snaps == nil { | |||
| 26 | + | return | |||
| 27 | + | } | |||
| 28 | + | blobs, err := d.snaps.Load() | |||
| 29 | + | if err != nil { | |||
| 30 | + | d.log.Warn("loading session snapshots failed", "err", err) | |||
| 31 | + | return | |||
| 32 | + | } | |||
| 33 | + | for _, blob := range blobs { | |||
| 34 | + | select { | |||
| 35 | + | case <-ctx.Done(): | |||
| 36 | + | return | |||
| 37 | + | default: | |||
| 38 | + | } | |||
| 39 | + | snap, err := snapcodec.Unmarshal(blob.Data) | |||
| 40 | + | if err != nil { | |||
| 41 | + | d.log.Warn("decoding session snapshot failed", "err", err, "session", blob.Name) | |||
| 42 | + | continue | |||
| 43 | + | } | |||
| 44 | + | if err := d.restoreSession(ctx, snap); err != nil { | |||
| 45 | + | d.log.Warn("restoring session snapshot failed", "err", err, "session", snap.Name) | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) error { | |||
| 51 | + | if snap.Name == "" { | |||
| 52 | + | return fmt.Errorf("snapshot: empty session name") | |||
| 53 | + | } | |||
| 54 | + | d.mu.Lock() | |||
| 55 | + | if d.closing || d.findByNameLocked(snap.Name) != nil { | |||
| 56 | + | d.mu.Unlock() | |||
| 57 | + | return nil | |||
| 58 | + | } | |||
| 59 | + | d.mu.Unlock() | |||
| 60 | + | ||||
| 61 | + | sctx, cancel := context.WithCancel(d.serveCtx) | |||
| 62 | + | opened := make([]*tab, 0, len(snap.Tabs)) | |||
| 63 | + | closeOpened := func() { | |||
| 64 | + | for _, tb := range opened { | |||
| 65 | + | tb.closeAllPanes() | |||
| 66 | + | } | |||
| 67 | + | cancel() | |||
| 68 | + | } | |||
| 69 | + | for _, tabSnap := range snap.Tabs { | |||
| 70 | + | tbSize := domain.Size{Cols: int(tabSnap.Cols), Rows: int(tabSnap.Rows)} | |||
| 71 | + | if !tbSize.Valid() { | |||
| 72 | + | closeOpened() | |||
| 73 | + | return fmt.Errorf("snapshot: invalid tab size") | |||
| 74 | + | } | |||
| 75 | + | placements, ok := layout.Solve(tabSnap.Tree.Root, domain.Rect{Width: tbSize.Cols, Height: tbSize.Rows}) | |||
| 76 | + | if !ok { | |||
| 77 | + | closeOpened() | |||
| 78 | + | return fmt.Errorf("snapshot: unsolvable layout") | |||
| 79 | + | } | |||
| 80 | + | placementByPane := make(map[layout.PaneID]domain.Rect, len(placements)) | |||
| 81 | + | for _, pl := range placements { | |||
| 82 | + | placementByPane[pl.ID] = pl.Content | |||
| 83 | + | } | |||
| 84 | + | tb := &tab{tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize} | |||
| 85 | + | if tb.nextPaneID <= 0 { | |||
| 86 | + | tb.nextPaneID = 1 | |||
| 87 | + | } | |||
| 88 | + | tb.ctx, tb.cancel = context.WithCancel(sctx) | |||
| 89 | + | opened = append(opened, tb) | |||
| 90 | + | for _, paneSnap := range tabSnap.Panes { | |||
| 91 | + | contentRect, ok := placementByPane[paneSnap.ID] | |||
| 92 | + | if !ok { | |||
| 93 | + | closeOpened() | |||
| 94 | + | return fmt.Errorf("snapshot: missing pane placement") | |||
| 95 | + | } | |||
| 96 | + | contentSize := restorePTYSize(contentRect, tbSize) | |||
| 97 | + | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name), paneSnap.Cwd, contentSize) | |||
| 98 | + | if err != nil { | |||
| 99 | + | closeOpened() | |||
| 100 | + | return err | |||
| 101 | + | } | |||
| 102 | + | p := newPane(paneSnap.ID, pty, contentSize) | |||
| 103 | + | p.ctx, p.cancel = context.WithCancel(tb.ctx) | |||
| 104 | + | p.rect = contentRect | |||
| 105 | + | seedPaneRows(p, paneSnap.Scrollback, paneSnap.Visible) | |||
| 106 | + | tb.panes[paneSnap.ID] = p | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | if len(opened) == 0 { | |||
| 110 | + | cancel() | |||
| 111 | + | return nil | |||
| 112 | + | } | |||
| 113 | + | createdAt := int64(snap.CreatedAt) | |||
| 114 | + | sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), createdAt: createdAt} | |||
| 115 | + | if sess.active < 0 || sess.active >= len(sess.tabs) { | |||
| 116 | + | sess.active = 0 | |||
| 117 | + | } | |||
| 118 | + | if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 { | |||
| 119 | + | sess.cwd = snap.Tabs[0].Panes[0].Cwd | |||
| 120 | + | } | |||
| 121 | + | d.mu.Lock() | |||
| 122 | + | if stopped, ok := d.stopped[snap.Name]; ok { | |||
| 123 | + | sess.mruAt.Store(stopped.lastUsedSeq) | |||
| 124 | + | } | |||
| 125 | + | d.mu.Unlock() | |||
| 126 | + | lastUsedSeq := sess.mruAt.Load() | |||
| 127 | + | if err := d.persist.Save(persist.Record{Name: snap.Name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq}); err != nil { | |||
| 128 | + | closeOpened() | |||
| 129 | + | return err | |||
| 130 | + | } | |||
| 131 | + | d.mu.Lock() | |||
| 132 | + | if d.closing || d.findByNameLocked(snap.Name) != nil || ctx.Err() != nil { | |||
| 133 | + | d.mu.Unlock() | |||
| 134 | + | closeOpened() | |||
| 135 | + | return nil | |||
| 136 | + | } | |||
| 137 | + | id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | |||
| 138 | + | d.nextID++ | |||
| 139 | + | sess.id = id | |||
| 140 | + | delete(d.stopped, snap.Name) | |||
| 141 | + | d.sessions[id] = sess | |||
| 142 | + | d.mu.Unlock() | |||
| 143 | + | sess.snapDirty.Store(false) | |||
| 144 | + | for _, tb := range opened { | |||
| 145 | + | d.startTabGoroutines(sess, tb) | |||
| 146 | + | } | |||
| 147 | + | return nil | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func markSnapshotDirty(sess *session) { | |||
| 151 | + | if sess == nil { | |||
| 152 | + | return | |||
| 153 | + | } | |||
| 154 | + | sess.mu.Lock() | |||
| 155 | + | named := !sess.ephemeral && sess.name != "" | |||
| 156 | + | sess.mu.Unlock() | |||
| 157 | + | if named { | |||
| 158 | + | sess.snapDirty.Store(true) | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func seedPaneRows(p *pane, scrollback, visible [][]renderer.Cell) { | |||
| 163 | + | p.mu.Lock() | |||
| 164 | + | defer p.mu.Unlock() | |||
| 165 | + | p.scrollback = scopy.NewScrollback(defaultScrollbackRows) | |||
| 166 | + | for _, row := range scrollback { | |||
| 167 | + | p.scrollback.Append(row) | |||
| 168 | + | } | |||
| 169 | + | if len(visible) > 0 { | |||
| 170 | + | p.screen = vt.NewScreen(p.screen.Frame.Width, p.screen.Frame.Height) | |||
| 171 | + | for y, row := range visible { | |||
| 172 | + | if y >= p.screen.Frame.Height { | |||
| 173 | + | break | |||
| 174 | + | } | |||
| 175 | + | for x, cell := range row { | |||
| 176 | + | if x >= p.screen.Frame.Width { | |||
| 177 | + | break | |||
| 178 | + | } | |||
| 179 | + | p.screen.Frame.Set(x, y, cell) | |||
| 180 | + | } | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | p.screen.OnLineEvicted = p.scrollback.Append | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size { | |||
| 187 | + | cols := contentRect.Width | |||
| 188 | + | if cols <= 0 { | |||
| 189 | + | cols = tabSize.Cols | |||
| 190 | + | } | |||
| 191 | + | if cols < layout.MinPaneCols { | |||
| 192 | + | cols = layout.MinPaneCols | |||
| 193 | + | } | |||
| 194 | + | rows := contentRect.Height | |||
| 195 | + | if rows <= 0 { | |||
| 196 | + | rows = min(max(tabSize.Rows, layout.MinPaneRows), 24) | |||
| 197 | + | } | |||
| 198 | + | if rows < layout.MinPaneRows { | |||
| 199 | + | rows = layout.MinPaneRows | |||
| 200 | + | } | |||
| 201 | + | return domain.Size{Cols: cols, Rows: rows} | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | func (d *Daemon) captureSession(sess *session) bool { | |||
| 205 | + | if d == nil || sess == nil || !d.snapsEnabled || d.snaps == nil { | |||
| 206 | + | return true | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | sess.mu.Lock() | |||
| 210 | + | name := sess.name | |||
| 211 | + | ephemeral := sess.ephemeral | |||
| 212 | + | createdAt := sess.createdAt | |||
| 213 | + | active := sess.active | |||
| 214 | + | fallbackCwd := sess.cwd | |||
| 215 | + | tabs := append([]*tab(nil), sess.tabs...) | |||
| 216 | + | sess.mu.Unlock() | |||
| 217 | + | if ephemeral || name == "" { | |||
| 218 | + | return true | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | snap := snapcodec.Session{Name: name, CreatedAt: uint64(createdAt), Active: uint16(max(active, 0))} | |||
| 222 | + | for _, tb := range tabs { | |||
| 223 | + | tb.mu.Lock() | |||
| 224 | + | tabSnap := snapcodec.Tab{ | |||
| 225 | + | Cols: uint16(max(tb.size.Cols, 0)), | |||
| 226 | + | Rows: uint16(max(tb.size.Rows, 0)), | |||
| 227 | + | NextPaneID: uint64(max(tb.nextPaneID, 0)), | |||
| 228 | + | Tree: tb.tree.Clone(), | |||
| 229 | + | } | |||
| 230 | + | if tb.tree != nil { | |||
| 231 | + | tabSnap.Focus = tb.tree.Focus | |||
| 232 | + | } | |||
| 233 | + | panes := make([]*pane, 0, len(tb.panes)) | |||
| 234 | + | for _, p := range tb.panes { | |||
| 235 | + | panes = append(panes, p) | |||
| 236 | + | } | |||
| 237 | + | tb.mu.Unlock() | |||
| 238 | + | ||||
| 239 | + | paneIDs := make(map[layout.PaneID]struct{}, len(panes)) | |||
| 240 | + | paneSnaps := make([]snapcodec.Pane, 0, len(panes)) | |||
| 241 | + | for _, p := range panes { | |||
| 242 | + | p.mu.Lock() | |||
| 243 | + | pid := 0 | |||
| 244 | + | if p.pty != nil { | |||
| 245 | + | pid = p.pty.Pid() | |||
| 246 | + | } | |||
| 247 | + | paneSnap := snapcodec.Pane{ | |||
| 248 | + | ID: p.id, | |||
| 249 | + | Scrollback: cloneRows(p.scrollback.Snapshot()), | |||
| 250 | + | Visible: cloneRows(p.screen.PrimaryVisibleRows()), | |||
| 251 | + | } | |||
| 252 | + | p.mu.Unlock() | |||
| 253 | + | paneSnap.Cwd = fallbackCwd | |||
| 254 | + | if d.procCwd != nil && pid > 0 { | |||
| 255 | + | if cwd, err := d.procCwd(pid); err == nil && cwd != "" { | |||
| 256 | + | paneSnap.Cwd = cwd | |||
| 257 | + | } | |||
| 258 | + | } | |||
| 259 | + | paneIDs[paneSnap.ID] = struct{}{} | |||
| 260 | + | paneSnaps = append(paneSnaps, paneSnap) | |||
| 261 | + | } | |||
| 262 | + | if tabSnap.Tree != nil { | |||
| 263 | + | pruneTreeToPanes(tabSnap.Tree.Root, paneIDs) | |||
| 264 | + | } | |||
| 265 | + | tabSnap.Panes = paneSnaps | |||
| 266 | + | snap.Tabs = append(snap.Tabs, tabSnap) | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | data, err := snapcodec.Marshal(snap) | |||
| 270 | + | if err != nil { | |||
| 271 | + | d.log.Warn("marshaling session snapshot failed", "err", err, "session", name) | |||
| 272 | + | return false | |||
| 273 | + | } | |||
| 274 | + | if err := d.snaps.Write(name, data); err != nil { | |||
| 275 | + | d.log.Warn("writing session snapshot failed", "err", err, "session", name) | |||
| 276 | + | return false | |||
| 277 | + | } | |||
| 278 | + | return true | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | func (d *Daemon) snapshotSaver(ctx context.Context) { | |||
| 282 | + | t := d.clock.NewTimer(snapshotInterval) | |||
| 283 | + | defer t.Stop() | |||
| 284 | + | for { | |||
| 285 | + | select { | |||
| 286 | + | case <-ctx.Done(): | |||
| 287 | + | return | |||
| 288 | + | case <-t.C(): | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | d.mu.Lock() | |||
| 292 | + | sessions := d.sessionsSnapshotLocked() | |||
| 293 | + | d.mu.Unlock() | |||
| 294 | + | for _, sess := range sessions { | |||
| 295 | + | sess.mu.Lock() | |||
| 296 | + | named := !sess.ephemeral && sess.name != "" | |||
| 297 | + | sess.mu.Unlock() | |||
| 298 | + | if named && sess.snapDirty.Swap(false) { | |||
| 299 | + | if !d.captureSession(sess) { | |||
| 300 | + | sess.snapDirty.Store(true) | |||
| 301 | + | } | |||
| 302 | + | } | |||
| 303 | + | } | |||
| 304 | + | t.Reset(snapshotInterval) | |||
| 305 | + | } | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | func cloneRows(rows [][]renderer.Cell) [][]renderer.Cell { | |||
| 309 | + | if len(rows) == 0 { | |||
| 310 | + | return nil | |||
| 311 | + | } | |||
| 312 | + | out := make([][]renderer.Cell, len(rows)) | |||
| 313 | + | for i, row := range rows { | |||
| 314 | + | out[i] = append([]renderer.Cell(nil), row...) | |||
| 315 | + | } | |||
| 316 | + | return out | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | func pruneTreeToPanes(n *layout.Node, panes map[layout.PaneID]struct{}) bool { | |||
| 320 | + | if n == nil { | |||
| 321 | + | return false | |||
| 322 | + | } | |||
| 323 | + | if n.Kind == layout.Leaf { | |||
| 324 | + | _, ok := panes[n.Leaf] | |||
| 325 | + | return ok | |||
| 326 | + | } | |||
| 327 | + | kept := n.Children[:0] | |||
| 328 | + | for _, child := range n.Children { | |||
| 329 | + | if pruneTreeToPanes(child, panes) { | |||
| 330 | + | kept = append(kept, child) | |||
| 331 | + | } | |||
| 332 | + | } | |||
| 333 | + | n.Children = kept | |||
| 334 | + | return len(n.Children) > 0 | |||
| 335 | + | } | |||
A internal/usecase/daemon/snapshot_restore_test.go +246 -0
| @@ -0,0 +1,246 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "io" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/domain" | |
| 13 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 15 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 16 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 17 | + | "github.com/bnema/vev/pkg/renderer" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |
| 21 | + | store := &restoreSnapshotStore{} | |
| 22 | + | store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| 23 | + | Name: "work", | |
| 24 | + | CreatedAt: 99, | |
| 25 | + | Active: 0, | |
| 26 | + | Tabs: []snapcodec.Tab{{ | |
| 27 | + | Cols: 80, | |
| 28 | + | Rows: 24, | |
| 29 | + | NextPaneID: 3, | |
| 30 | + | Focus: "pane-2", | |
| 31 | + | Tree: &layout.Tree{Focus: "pane-2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |
| 32 | + | layout.NewLeaf("pane-1"), | |
| 33 | + | layout.NewLeaf("pane-2"), | |
| 34 | + | }}}, | |
| 35 | + | Panes: []snapcodec.Pane{ | |
| 36 | + | {ID: "pane-1", Cwd: "/one", Scrollback: [][]renderer.Cell{cells("old1")}, Visible: [][]renderer.Cell{cells("vis1")}}, | |
| 37 | + | {ID: "pane-2", Cwd: "/two", Scrollback: [][]renderer.Cell{cells("old2")}, Visible: [][]renderer.Cell{cells("vis2")}}, | |
| 38 | + | }, | |
| 39 | + | }}, | |
| 40 | + | })}} | |
| 41 | + | factory := &restorePTYFactory{} | |
| 42 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 43 | + | WithSnapshotStore(store)(d) | |
| 44 | + | ||
| 45 | + | d.restoreSnapshots(context.Background()) | |
| 46 | + | ||
| 47 | + | require.Len(t, factory.opens, 2) | |
| 48 | + | require.Equal(t, "/one", factory.opens[0].dir) | |
| 49 | + | require.Equal(t, "/two", factory.opens[1].dir) | |
| 50 | + | require.Equal(t, domain.Size{Cols: 40, Rows: 24}, factory.opens[0].size) | |
| 51 | + | ||
| 52 | + | d.mu.Lock() | |
| 53 | + | restored := d.findByNameLocked("work") | |
| 54 | + | d.mu.Unlock() | |
| 55 | + | require.NotNil(t, restored) | |
| 56 | + | require.False(t, restored.snapDirty.Load()) | |
| 57 | + | require.Equal(t, domain.SessionID("sess-0"), restored.id) | |
| 58 | + | require.Equal(t, int64(99), restored.createdAt) | |
| 59 | + | require.Equal(t, "/one", restored.cwd) | |
| 60 | + | ||
| 61 | + | restored.mu.Lock() | |
| 62 | + | require.Len(t, restored.tabs, 1) | |
| 63 | + | tb := restored.tabs[0] | |
| 64 | + | restored.mu.Unlock() | |
| 65 | + | tb.mu.Lock() | |
| 66 | + | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |
| 67 | + | require.Equal(t, 3, tb.nextPaneID) | |
| 68 | + | p := tb.panes["pane-2"] | |
| 69 | + | tb.mu.Unlock() | |
| 70 | + | p.mu.Lock() | |
| 71 | + | require.Equal(t, "old2", rowText(p.scrollback.Row(0))) | |
| 72 | + | require.Equal(t, 1, p.scrollback.Len()) | |
| 73 | + | require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4])) | |
| 74 | + | copySnap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 75 | + | require.Equal(t, "old2", rowText(copySnap.Rows[0][:4])) | |
| 76 | + | require.Equal(t, "vis2", rowText(copySnap.Rows[1][:4])) | |
| 77 | + | p.mu.Unlock() | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) { | |
| 81 | + | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| 82 | + | Name: "stacked", | |
| 83 | + | Tabs: []snapcodec.Tab{{ | |
| 84 | + | Cols: 20, | |
| 85 | + | Rows: 4, | |
| 86 | + | Tree: &layout.Tree{Focus: "b", Root: &layout.Node{Kind: layout.Stack, Expanded: "b", Children: []*layout.Node{ | |
| 87 | + | layout.NewLeaf("a"), layout.NewLeaf("b"), layout.NewLeaf("c"), | |
| 88 | + | }}}, | |
| 89 | + | Panes: []snapcodec.Pane{{ID: "a", Cwd: "/a"}, {ID: "b", Cwd: "/b"}, {ID: "c", Cwd: "/c"}}, | |
| 90 | + | }}, | |
| 91 | + | })}}} | |
| 92 | + | factory := &restorePTYFactory{} | |
| 93 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 94 | + | WithSnapshotStore(store)(d) | |
| 95 | + | ||
| 96 | + | d.restoreSnapshots(context.Background()) | |
| 97 | + | ||
| 98 | + | require.Len(t, factory.opens, 3) | |
| 99 | + | for _, open := range factory.opens { | |
| 100 | + | require.True(t, open.size.Valid(), "open size for %s was %#v", open.dir, open.size) | |
| 101 | + | require.GreaterOrEqual(t, open.size.Cols, layout.MinPaneCols) | |
| 102 | + | require.GreaterOrEqual(t, open.size.Rows, layout.MinPaneRows) | |
| 103 | + | } | |
| 104 | + | d.mu.Lock() | |
| 105 | + | restored := d.findByNameLocked("stacked") | |
| 106 | + | d.mu.Unlock() | |
| 107 | + | require.NotNil(t, restored) | |
| 108 | + | restored.mu.Lock() | |
| 109 | + | tb := restored.tabs[0] | |
| 110 | + | restored.mu.Unlock() | |
| 111 | + | tb.mu.Lock() | |
| 112 | + | require.Equal(t, layout.Stack, tb.tree.Root.Kind) | |
| 113 | + | require.Equal(t, layout.PaneID("b"), tb.tree.Root.Expanded) | |
| 114 | + | require.Len(t, tb.panes, 3) | |
| 115 | + | tb.mu.Unlock() | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) { | |
| 119 | + | valid := mustSnapshotBytes(t, snapcodec.Session{Name: "live", CreatedAt: 1, Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/ok"}}}}}) | |
| 120 | + | tests := []struct { | |
| 121 | + | name string | |
| 122 | + | blobs []ports.SnapshotBlob | |
| 123 | + | liveName string | |
| 124 | + | wantOpen int | |
| 125 | + | }{ | |
| 126 | + | {name: "empty store"}, | |
| 127 | + | {name: "corrupt blob", blobs: []ports.SnapshotBlob{{Name: "bad", Data: []byte("not a snapshot")}}}, | |
| 128 | + | {name: "live name", blobs: []ports.SnapshotBlob{{Name: "live", Data: valid}}, liveName: "live"}, | |
| 129 | + | } | |
| 130 | + | for _, tt := range tests { | |
| 131 | + | t.Run(tt.name, func(t *testing.T) { | |
| 132 | + | factory := &restorePTYFactory{} | |
| 133 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 134 | + | WithSnapshotStore(&restoreSnapshotStore{blobs: tt.blobs})(d) | |
| 135 | + | if tt.liveName != "" { | |
| 136 | + | d.sessions["existing"] = &session{id: "existing", name: tt.liveName} | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | require.NotPanics(t, func() { d.restoreSnapshots(context.Background()) }) | |
| 140 | + | require.Len(t, factory.opens, tt.wantOpen) | |
| 141 | + | }) | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) { | |
| 146 | + | releaseLoad := make(chan struct{}) | |
| 147 | + | loaded := make(chan struct{}) | |
| 148 | + | store := &restoreSnapshotStore{ | |
| 149 | + | loadFn: func() ([]ports.SnapshotBlob, error) { | |
| 150 | + | close(loaded) | |
| 151 | + | <-releaseLoad | |
| 152 | + | return []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{Name: "work", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/restored"}}}}})}}, nil | |
| 153 | + | }, | |
| 154 | + | } | |
| 155 | + | factory := &restorePTYFactory{} | |
| 156 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 157 | + | WithSnapshotStore(store)(d) | |
| 158 | + | ||
| 159 | + | go d.restoreSnapshots(context.Background()) | |
| 160 | + | <-loaded | |
| 161 | + | ||
| 162 | + | routed := make(chan *session, 1) | |
| 163 | + | go func() { | |
| 164 | + | tr, _ := newCapturingTransport(t) | |
| 165 | + | sess, _, err := d.route(ports.Hello{Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 26}}, tr) | |
| 166 | + | require.NoError(t, err) | |
| 167 | + | routed <- sess | |
| 168 | + | }() | |
| 169 | + | ||
| 170 | + | select { | |
| 171 | + | case <-routed: | |
| 172 | + | t.Fatal("named attach routed before restore barrier closed") | |
| 173 | + | case <-time.After(25 * time.Millisecond): | |
| 174 | + | } | |
| 175 | + | close(releaseLoad) | |
| 176 | + | select { | |
| 177 | + | case sess := <-routed: | |
| 178 | + | require.Equal(t, "work", sess.name) | |
| 179 | + | require.Len(t, factory.opens, 1) | |
| 180 | + | require.Equal(t, "/restored", factory.opens[0].dir) | |
| 181 | + | case <-time.After(time.Second): | |
| 182 | + | t.Fatal("timed out waiting for named attach after restore") | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte { | |
| 187 | + | t.Helper() | |
| 188 | + | b, err := snapcodec.Marshal(s) | |
| 189 | + | require.NoError(t, err) | |
| 190 | + | return b | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | func cells(s string) []renderer.Cell { | |
| 194 | + | out := make([]renderer.Cell, 0, len(s)) | |
| 195 | + | for _, r := range s { | |
| 196 | + | out = append(out, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |
| 197 | + | } | |
| 198 | + | return out | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | type restoreSnapshotStore struct { | |
| 202 | + | blobs []ports.SnapshotBlob | |
| 203 | + | loadFn func() ([]ports.SnapshotBlob, error) | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func (s *restoreSnapshotStore) Write(string, []byte) error { return nil } | |
| 207 | + | func (s *restoreSnapshotStore) Delete(string) error { return nil } | |
| 208 | + | func (s *restoreSnapshotStore) Load() ([]ports.SnapshotBlob, error) { | |
| 209 | + | if s.loadFn != nil { | |
| 210 | + | return s.loadFn() | |
| 211 | + | } | |
| 212 | + | return s.blobs, nil | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | type restorePTYFactory struct { | |
| 216 | + | mu sync.Mutex | |
| 217 | + | opens []restorePTYOpen | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | type restorePTYOpen struct { | |
| 221 | + | dir string | |
| 222 | + | size domain.Size | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func (f *restorePTYFactory) Open(_ string, _ []string, _ []string, dir string, sz domain.Size) (ports.PTY, error) { | |
| 226 | + | f.mu.Lock() | |
| 227 | + | defer f.mu.Unlock() | |
| 228 | + | f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz}) | |
| 229 | + | return newRestorePTY(), nil | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | type restorePTY struct { | |
| 233 | + | done chan struct{} | |
| 234 | + | once sync.Once | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func newRestorePTY() *restorePTY { return &restorePTY{done: make(chan struct{})} } | |
| 238 | + | func (p *restorePTY) Read([]byte) (int, error) { | |
| 239 | + | <-p.done | |
| 240 | + | return 0, io.EOF | |
| 241 | + | } | |
| 242 | + | func (p *restorePTY) Write(b []byte) (int, error) { return len(b), nil } | |
| 243 | + | func (p *restorePTY) Close() error { p.once.Do(func() { close(p.done) }); return nil } | |
| 244 | + | func (p *restorePTY) Resize(domain.Size) error { return nil } | |
| 245 | + | func (p *restorePTY) Pid() int { return 0 } | |
| 246 | + | func (p *restorePTY) ForegroundPgid() (int, error) { return 0, nil } | |
| @@ -0,0 +1,246 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "io" | |||
| 6 | + | "sync" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/vev/internal/domain" | |||
| 13 | + | "github.com/bnema/vev/internal/ports" | |||
| 14 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 15 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 16 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 17 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||
| 21 | + | store := &restoreSnapshotStore{} | |||
| 22 | + | store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | |||
| 23 | + | Name: "work", | |||
| 24 | + | CreatedAt: 99, | |||
| 25 | + | Active: 0, | |||
| 26 | + | Tabs: []snapcodec.Tab{{ | |||
| 27 | + | Cols: 80, | |||
| 28 | + | Rows: 24, | |||
| 29 | + | NextPaneID: 3, | |||
| 30 | + | Focus: "pane-2", | |||
| 31 | + | Tree: &layout.Tree{Focus: "pane-2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |||
| 32 | + | layout.NewLeaf("pane-1"), | |||
| 33 | + | layout.NewLeaf("pane-2"), | |||
| 34 | + | }}}, | |||
| 35 | + | Panes: []snapcodec.Pane{ | |||
| 36 | + | {ID: "pane-1", Cwd: "/one", Scrollback: [][]renderer.Cell{cells("old1")}, Visible: [][]renderer.Cell{cells("vis1")}}, | |||
| 37 | + | {ID: "pane-2", Cwd: "/two", Scrollback: [][]renderer.Cell{cells("old2")}, Visible: [][]renderer.Cell{cells("vis2")}}, | |||
| 38 | + | }, | |||
| 39 | + | }}, | |||
| 40 | + | })}} | |||
| 41 | + | factory := &restorePTYFactory{} | |||
| 42 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 43 | + | WithSnapshotStore(store)(d) | |||
| 44 | + | ||||
| 45 | + | d.restoreSnapshots(context.Background()) | |||
| 46 | + | ||||
| 47 | + | require.Len(t, factory.opens, 2) | |||
| 48 | + | require.Equal(t, "/one", factory.opens[0].dir) | |||
| 49 | + | require.Equal(t, "/two", factory.opens[1].dir) | |||
| 50 | + | require.Equal(t, domain.Size{Cols: 40, Rows: 24}, factory.opens[0].size) | |||
| 51 | + | ||||
| 52 | + | d.mu.Lock() | |||
| 53 | + | restored := d.findByNameLocked("work") | |||
| 54 | + | d.mu.Unlock() | |||
| 55 | + | require.NotNil(t, restored) | |||
| 56 | + | require.False(t, restored.snapDirty.Load()) | |||
| 57 | + | require.Equal(t, domain.SessionID("sess-0"), restored.id) | |||
| 58 | + | require.Equal(t, int64(99), restored.createdAt) | |||
| 59 | + | require.Equal(t, "/one", restored.cwd) | |||
| 60 | + | ||||
| 61 | + | restored.mu.Lock() | |||
| 62 | + | require.Len(t, restored.tabs, 1) | |||
| 63 | + | tb := restored.tabs[0] | |||
| 64 | + | restored.mu.Unlock() | |||
| 65 | + | tb.mu.Lock() | |||
| 66 | + | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |||
| 67 | + | require.Equal(t, 3, tb.nextPaneID) | |||
| 68 | + | p := tb.panes["pane-2"] | |||
| 69 | + | tb.mu.Unlock() | |||
| 70 | + | p.mu.Lock() | |||
| 71 | + | require.Equal(t, "old2", rowText(p.scrollback.Row(0))) | |||
| 72 | + | require.Equal(t, 1, p.scrollback.Len()) | |||
| 73 | + | require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4])) | |||
| 74 | + | copySnap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |||
| 75 | + | require.Equal(t, "old2", rowText(copySnap.Rows[0][:4])) | |||
| 76 | + | require.Equal(t, "vis2", rowText(copySnap.Rows[1][:4])) | |||
| 77 | + | p.mu.Unlock() | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) { | |||
| 81 | + | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{ | |||
| 82 | + | Name: "stacked", | |||
| 83 | + | Tabs: []snapcodec.Tab{{ | |||
| 84 | + | Cols: 20, | |||
| 85 | + | Rows: 4, | |||
| 86 | + | Tree: &layout.Tree{Focus: "b", Root: &layout.Node{Kind: layout.Stack, Expanded: "b", Children: []*layout.Node{ | |||
| 87 | + | layout.NewLeaf("a"), layout.NewLeaf("b"), layout.NewLeaf("c"), | |||
| 88 | + | }}}, | |||
| 89 | + | Panes: []snapcodec.Pane{{ID: "a", Cwd: "/a"}, {ID: "b", Cwd: "/b"}, {ID: "c", Cwd: "/c"}}, | |||
| 90 | + | }}, | |||
| 91 | + | })}}} | |||
| 92 | + | factory := &restorePTYFactory{} | |||
| 93 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 94 | + | WithSnapshotStore(store)(d) | |||
| 95 | + | ||||
| 96 | + | d.restoreSnapshots(context.Background()) | |||
| 97 | + | ||||
| 98 | + | require.Len(t, factory.opens, 3) | |||
| 99 | + | for _, open := range factory.opens { | |||
| 100 | + | require.True(t, open.size.Valid(), "open size for %s was %#v", open.dir, open.size) | |||
| 101 | + | require.GreaterOrEqual(t, open.size.Cols, layout.MinPaneCols) | |||
| 102 | + | require.GreaterOrEqual(t, open.size.Rows, layout.MinPaneRows) | |||
| 103 | + | } | |||
| 104 | + | d.mu.Lock() | |||
| 105 | + | restored := d.findByNameLocked("stacked") | |||
| 106 | + | d.mu.Unlock() | |||
| 107 | + | require.NotNil(t, restored) | |||
| 108 | + | restored.mu.Lock() | |||
| 109 | + | tb := restored.tabs[0] | |||
| 110 | + | restored.mu.Unlock() | |||
| 111 | + | tb.mu.Lock() | |||
| 112 | + | require.Equal(t, layout.Stack, tb.tree.Root.Kind) | |||
| 113 | + | require.Equal(t, layout.PaneID("b"), tb.tree.Root.Expanded) | |||
| 114 | + | require.Len(t, tb.panes, 3) | |||
| 115 | + | tb.mu.Unlock() | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) { | |||
| 119 | + | valid := mustSnapshotBytes(t, snapcodec.Session{Name: "live", CreatedAt: 1, Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/ok"}}}}}) | |||
| 120 | + | tests := []struct { | |||
| 121 | + | name string | |||
| 122 | + | blobs []ports.SnapshotBlob | |||
| 123 | + | liveName string | |||
| 124 | + | wantOpen int | |||
| 125 | + | }{ | |||
| 126 | + | {name: "empty store"}, | |||
| 127 | + | {name: "corrupt blob", blobs: []ports.SnapshotBlob{{Name: "bad", Data: []byte("not a snapshot")}}}, | |||
| 128 | + | {name: "live name", blobs: []ports.SnapshotBlob{{Name: "live", Data: valid}}, liveName: "live"}, | |||
| 129 | + | } | |||
| 130 | + | for _, tt := range tests { | |||
| 131 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 132 | + | factory := &restorePTYFactory{} | |||
| 133 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 134 | + | WithSnapshotStore(&restoreSnapshotStore{blobs: tt.blobs})(d) | |||
| 135 | + | if tt.liveName != "" { | |||
| 136 | + | d.sessions["existing"] = &session{id: "existing", name: tt.liveName} | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | require.NotPanics(t, func() { d.restoreSnapshots(context.Background()) }) | |||
| 140 | + | require.Len(t, factory.opens, tt.wantOpen) | |||
| 141 | + | }) | |||
| 142 | + | } | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) { | |||
| 146 | + | releaseLoad := make(chan struct{}) | |||
| 147 | + | loaded := make(chan struct{}) | |||
| 148 | + | store := &restoreSnapshotStore{ | |||
| 149 | + | loadFn: func() ([]ports.SnapshotBlob, error) { | |||
| 150 | + | close(loaded) | |||
| 151 | + | <-releaseLoad | |||
| 152 | + | return []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{Name: "work", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/restored"}}}}})}}, nil | |||
| 153 | + | }, | |||
| 154 | + | } | |||
| 155 | + | factory := &restorePTYFactory{} | |||
| 156 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 157 | + | WithSnapshotStore(store)(d) | |||
| 158 | + | ||||
| 159 | + | go d.restoreSnapshots(context.Background()) | |||
| 160 | + | <-loaded | |||
| 161 | + | ||||
| 162 | + | routed := make(chan *session, 1) | |||
| 163 | + | go func() { | |||
| 164 | + | tr, _ := newCapturingTransport(t) | |||
| 165 | + | sess, _, err := d.route(ports.Hello{Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 26}}, tr) | |||
| 166 | + | require.NoError(t, err) | |||
| 167 | + | routed <- sess | |||
| 168 | + | }() | |||
| 169 | + | ||||
| 170 | + | select { | |||
| 171 | + | case <-routed: | |||
| 172 | + | t.Fatal("named attach routed before restore barrier closed") | |||
| 173 | + | case <-time.After(25 * time.Millisecond): | |||
| 174 | + | } | |||
| 175 | + | close(releaseLoad) | |||
| 176 | + | select { | |||
| 177 | + | case sess := <-routed: | |||
| 178 | + | require.Equal(t, "work", sess.name) | |||
| 179 | + | require.Len(t, factory.opens, 1) | |||
| 180 | + | require.Equal(t, "/restored", factory.opens[0].dir) | |||
| 181 | + | case <-time.After(time.Second): | |||
| 182 | + | t.Fatal("timed out waiting for named attach after restore") | |||
| 183 | + | } | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte { | |||
| 187 | + | t.Helper() | |||
| 188 | + | b, err := snapcodec.Marshal(s) | |||
| 189 | + | require.NoError(t, err) | |||
| 190 | + | return b | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | func cells(s string) []renderer.Cell { | |||
| 194 | + | out := make([]renderer.Cell, 0, len(s)) | |||
| 195 | + | for _, r := range s { | |||
| 196 | + | out = append(out, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |||
| 197 | + | } | |||
| 198 | + | return out | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | type restoreSnapshotStore struct { | |||
| 202 | + | blobs []ports.SnapshotBlob | |||
| 203 | + | loadFn func() ([]ports.SnapshotBlob, error) | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | func (s *restoreSnapshotStore) Write(string, []byte) error { return nil } | |||
| 207 | + | func (s *restoreSnapshotStore) Delete(string) error { return nil } | |||
| 208 | + | func (s *restoreSnapshotStore) Load() ([]ports.SnapshotBlob, error) { | |||
| 209 | + | if s.loadFn != nil { | |||
| 210 | + | return s.loadFn() | |||
| 211 | + | } | |||
| 212 | + | return s.blobs, nil | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | type restorePTYFactory struct { | |||
| 216 | + | mu sync.Mutex | |||
| 217 | + | opens []restorePTYOpen | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | type restorePTYOpen struct { | |||
| 221 | + | dir string | |||
| 222 | + | size domain.Size | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | func (f *restorePTYFactory) Open(_ string, _ []string, _ []string, dir string, sz domain.Size) (ports.PTY, error) { | |||
| 226 | + | f.mu.Lock() | |||
| 227 | + | defer f.mu.Unlock() | |||
| 228 | + | f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz}) | |||
| 229 | + | return newRestorePTY(), nil | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | type restorePTY struct { | |||
| 233 | + | done chan struct{} | |||
| 234 | + | once sync.Once | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | func newRestorePTY() *restorePTY { return &restorePTY{done: make(chan struct{})} } | |||
| 238 | + | func (p *restorePTY) Read([]byte) (int, error) { | |||
| 239 | + | <-p.done | |||
| 240 | + | return 0, io.EOF | |||
| 241 | + | } | |||
| 242 | + | func (p *restorePTY) Write(b []byte) (int, error) { return len(b), nil } | |||
| 243 | + | func (p *restorePTY) Close() error { p.once.Do(func() { close(p.done) }); return nil } | |||
| 244 | + | func (p *restorePTY) Resize(domain.Size) error { return nil } | |||
| 245 | + | func (p *restorePTY) Pid() int { return 0 } | |||
| 246 | + | func (p *restorePTY) ForegroundPgid() (int, error) { return 0, nil } | |||
A internal/usecase/daemon/snapshot_test.go +357 -0
| @@ -0,0 +1,357 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "io" | |
| 7 | + | "sync" | |
| 8 | + | "sync/atomic" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/stretchr/testify/mock" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/vev/internal/domain" | |
| 16 | + | "github.com/bnema/vev/internal/ports" | |
| 17 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 18 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 19 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 20 | + | "github.com/bnema/vev/pkg/renderer" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) { | |
| 24 | + | clock := newManualSnapshotClock() | |
| 25 | + | store := portsmocks.NewMockSnapshotStore(t) | |
| 26 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | |
| 27 | + | WithSnapshotStore(store)(d) | |
| 28 | + | d.procCwd = func(int) (string, error) { return "/pane", nil } | |
| 29 | + | ||
| 30 | + | dirty := newSnapshotTestSession(t, "dirty", false, "/fallback") | |
| 31 | + | clean := newSnapshotTestSession(t, "clean", false, "/fallback") | |
| 32 | + | ephemeral := newSnapshotTestSession(t, "0", true, "/fallback") | |
| 33 | + | dirty.snapDirty.Store(true) | |
| 34 | + | ephemeral.snapDirty.Store(true) | |
| 35 | + | d.sessions[dirty.id] = dirty | |
| 36 | + | d.sessions[clean.id] = clean | |
| 37 | + | d.sessions[ephemeral.id] = ephemeral | |
| 38 | + | ||
| 39 | + | wrote := make(chan struct{}, 1) | |
| 40 | + | store.EXPECT().Write("dirty", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |
| 41 | + | snap, err := snapcodec.Unmarshal(data) | |
| 42 | + | require.NoError(t, err) | |
| 43 | + | require.Equal(t, "dirty", snap.Name) | |
| 44 | + | wrote <- struct{}{} | |
| 45 | + | return nil | |
| 46 | + | }).Once() | |
| 47 | + | ||
| 48 | + | ctx, cancel := context.WithCancel(t.Context()) | |
| 49 | + | defer cancel() | |
| 50 | + | go d.snapshotSaver(ctx) | |
| 51 | + | clock.fireNext(t) | |
| 52 | + | select { | |
| 53 | + | case <-wrote: | |
| 54 | + | case <-time.After(time.Second): | |
| 55 | + | t.Fatal("timed out waiting for dirty snapshot write") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | clock.fireNext(t) | |
| 59 | + | select { | |
| 60 | + | case <-wrote: | |
| 61 | + | t.Fatal("clean second interval wrote unexpectedly") | |
| 62 | + | case <-time.After(25 * time.Millisecond): | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestKillSessionSnapshotsNamedSessionBeforeClosingPanes(t *testing.T) { | |
| 67 | + | store := portsmocks.NewMockSnapshotStore(t) | |
| 68 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 69 | + | WithSnapshotStore(store)(d) | |
| 70 | + | d.procCwd = func(int) (string, error) { return "/live", nil } | |
| 71 | + | ||
| 72 | + | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |
| 73 | + | d.sessions[sess.id] = sess | |
| 74 | + | ||
| 75 | + | var wrote atomic.Bool | |
| 76 | + | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |
| 77 | + | snap, err := snapcodec.Unmarshal(data) | |
| 78 | + | require.NoError(t, err) | |
| 79 | + | require.Equal(t, "work", snap.Name) | |
| 80 | + | require.Equal(t, "/live", snap.Tabs[0].Panes[0].Cwd) | |
| 81 | + | wrote.Store(true) | |
| 82 | + | return nil | |
| 83 | + | }).Once() | |
| 84 | + | mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |
| 85 | + | require.True(t, ok) | |
| 86 | + | mockPTY.EXPECT().Close().RunAndReturn(func() error { | |
| 87 | + | require.True(t, wrote.Load(), "snapshot Write must happen before pane Close") | |
| 88 | + | return nil | |
| 89 | + | }).Once() | |
| 90 | + | ||
| 91 | + | require.NoError(t, d.killSession(sess, 0, false)) | |
| 92 | + | require.True(t, wrote.Load()) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestPTYReaderSynchronizedUpdateFlushMarksSnapshotDirty(t *testing.T) { | |
| 96 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 97 | + | p := portsmocks.NewMockPTY(t) | |
| 98 | + | chunks := [][]byte{[]byte("\x1b[?2026hhello\x1b[?2026l")} | |
| 99 | + | p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) { | |
| 100 | + | if len(chunks) == 0 { | |
| 101 | + | return 0, io.EOF | |
| 102 | + | } | |
| 103 | + | n := copy(buf, chunks[0]) | |
| 104 | + | chunks = chunks[1:] | |
| 105 | + | return n, nil | |
| 106 | + | }) | |
| 107 | + | p.EXPECT().Close().Return(nil).Maybe() | |
| 108 | + | ||
| 109 | + | sctx, cancel := context.WithCancel(context.Background()) | |
| 110 | + | defer cancel() | |
| 111 | + | tb := newTestTabWithContext(p, sctx, cancel) | |
| 112 | + | sess := &session{id: "sync", name: "sync", tabs: []*tab{tb}, ctx: sctx, cancel: cancel} | |
| 113 | + | d.sessions[sess.id] = sess | |
| 114 | + | d.sessWg.Add(1) | |
| 115 | + | d.ptyReader(sess, tb, tb.focusedPane()) | |
| 116 | + | ||
| 117 | + | require.True(t, sess.snapDirty.Load()) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestSnapshotSaverKeepsDirtyWhenWriteFails(t *testing.T) { | |
| 121 | + | clock := newManualSnapshotClock() | |
| 122 | + | store := portsmocks.NewMockSnapshotStore(t) | |
| 123 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | |
| 124 | + | WithSnapshotStore(store)(d) | |
| 125 | + | ||
| 126 | + | sess := newSnapshotTestSession(t, "retry", false, "/fallback") | |
| 127 | + | sess.snapDirty.Store(true) | |
| 128 | + | d.sessions[sess.id] = sess | |
| 129 | + | ||
| 130 | + | store.EXPECT().Write("retry", mock.Anything).Return(errors.New("boom")).Once() | |
| 131 | + | wrote := make(chan struct{}, 1) | |
| 132 | + | store.EXPECT().Write("retry", mock.Anything).RunAndReturn(func(string, []byte) error { | |
| 133 | + | wrote <- struct{}{} | |
| 134 | + | return nil | |
| 135 | + | }).Once() | |
| 136 | + | ||
| 137 | + | ctx, cancel := context.WithCancel(t.Context()) | |
| 138 | + | defer cancel() | |
| 139 | + | go d.snapshotSaver(ctx) | |
| 140 | + | clock.fireNext(t) | |
| 141 | + | require.Eventually(t, func() bool { return sess.snapDirty.Load() }, time.Second, time.Millisecond) | |
| 142 | + | clock.fireNext(t) | |
| 143 | + | select { | |
| 144 | + | case <-wrote: | |
| 145 | + | case <-time.After(time.Second): | |
| 146 | + | t.Fatal("timed out waiting for retry snapshot write") | |
| 147 | + | } | |
| 148 | + | require.False(t, sess.snapDirty.Load()) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestKillSessionPurgeDeletesSnapshot(t *testing.T) { | |
| 152 | + | store := portsmocks.NewMockSnapshotStore(t) | |
| 153 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 154 | + | WithSnapshotStore(store)(d) | |
| 155 | + | ||
| 156 | + | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |
| 157 | + | d.sessions[sess.id] = sess | |
| 158 | + | ||
| 159 | + | store.EXPECT().Delete("work").Return(nil).Once() | |
| 160 | + | mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |
| 161 | + | require.True(t, ok) | |
| 162 | + | mockPTY.EXPECT().Close().Return(nil).Once() | |
| 163 | + | ||
| 164 | + | require.NoError(t, d.killSession(sess, 0, true)) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func TestKillSessionPurgeReturnsSnapshotDeleteError(t *testing.T) { | |
| 168 | + | store := portsmocks.NewMockSnapshotStore(t) | |
| 169 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 170 | + | WithSnapshotStore(store)(d) | |
| 171 | + | ||
| 172 | + | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |
| 173 | + | d.sessions[sess.id] = sess | |
| 174 | + | ||
| 175 | + | store.EXPECT().Delete("work").Return(errors.New("delete failed")).Once() | |
| 176 | + | err := d.killSession(sess, 0, true) | |
| 177 | + | require.ErrorContains(t, err, "delete failed") | |
| 178 | + | require.Contains(t, d.sessions, sess.id) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func TestRefreshSessionCwdMarksSnapshotDirty(t *testing.T) { | |
| 182 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 183 | + | d.procCwd = func(int) (string, error) { return "/new", nil } | |
| 184 | + | sess := newSnapshotTestSession(t, "work", false, "/old") | |
| 185 | + | d.sessions[sess.id] = sess | |
| 186 | + | ||
| 187 | + | d.refreshSessionCwd(sess) | |
| 188 | + | require.Equal(t, "/new", sess.cwd) | |
| 189 | + | require.True(t, sess.snapDirty.Load()) | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func TestStoppedSessionKillPurgesSnapshot(t *testing.T) { | |
| 193 | + | store := portsmocks.NewMockSnapshotStore(t) | |
| 194 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 195 | + | WithSnapshotStore(store)(d) | |
| 196 | + | d.stopped["work"] = stoppedSession{name: "work", cwd: "/work", createdAt: 1} | |
| 197 | + | ||
| 198 | + | store.EXPECT().Delete("work").Return(nil).Once() | |
| 199 | + | tr, _, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})}) | |
| 200 | + | ||
| 201 | + | d.handleKill(tr, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})}) | |
| 202 | + | ||
| 203 | + | require.NotContains(t, d.stopped, "work") | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func TestStoppedSessionKillSnapshotDeleteFailureKeepsMetadata(t *testing.T) { | |
| 207 | + | store := portsmocks.NewMockSnapshotStore(t) | |
| 208 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 209 | + | WithSnapshotStore(store)(d) | |
| 210 | + | d.stopped["work"] = stoppedSession{name: "work", cwd: "/work", createdAt: 1} | |
| 211 | + | ||
| 212 | + | store.EXPECT().Delete("work").Return(errors.New("delete failed")).Once() | |
| 213 | + | tr, sends, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})}) | |
| 214 | + | ||
| 215 | + | d.handleKill(tr, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})}) | |
| 216 | + | ||
| 217 | + | require.Contains(t, d.stopped, "work") | |
| 218 | + | frame := awaitFrame(t, sends, ports.MsgError) | |
| 219 | + | msg, err := ports.UnmarshalErrorMsg(frame.Payload) | |
| 220 | + | require.NoError(t, err) | |
| 221 | + | require.Equal(t, ports.ErrInternal, msg.Code) | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | func TestRenameSessionDeletesOldSnapshotAndMarksDirty(t *testing.T) { | |
| 225 | + | store := portsmocks.NewMockSnapshotStore(t) | |
| 226 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 227 | + | WithSnapshotStore(store)(d) | |
| 228 | + | sess := newSnapshotTestSession(t, "old", false, "/fallback") | |
| 229 | + | d.sessions[sess.id] = sess | |
| 230 | + | ||
| 231 | + | store.EXPECT().Delete("old").Return(nil).Once() | |
| 232 | + | require.NoError(t, d.renameSession(sess, "new")) | |
| 233 | + | ||
| 234 | + | require.True(t, sess.snapDirty.Load()) | |
| 235 | + | require.Nil(t, d.findByNameLocked("old")) | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | func TestSnapshotSaverCapturesLayoutOnlyDirtySave(t *testing.T) { | |
| 239 | + | clock := newManualSnapshotClock() | |
| 240 | + | store := portsmocks.NewMockSnapshotStore(t) | |
| 241 | + | factory := portsmocks.NewMockPTYFactory(t) | |
| 242 | + | d := newTestDaemon(t, factory, clock) | |
| 243 | + | WithSnapshotStore(store)(d) | |
| 244 | + | ||
| 245 | + | sess := newSnapshotTestSession(t, "work", false, "/work") | |
| 246 | + | tctx, tcancel := context.WithCancel(context.Background()) | |
| 247 | + | t.Cleanup(tcancel) | |
| 248 | + | sess.ctx = tctx | |
| 249 | + | sess.cancel = tcancel | |
| 250 | + | sess.tabs[0].ctx = tctx | |
| 251 | + | sess.tabs[0].cancel = tcancel | |
| 252 | + | for _, p := range sess.tabs[0].panes { | |
| 253 | + | p.ctx = tctx | |
| 254 | + | p.cancel = tcancel | |
| 255 | + | } | |
| 256 | + | sess.tabs[0].size = domain.Size{Cols: 41, Rows: 10} | |
| 257 | + | d.sessions[sess.id] = sess | |
| 258 | + | newPTY := portsmocks.NewMockPTY(t) | |
| 259 | + | newPTY.EXPECT().Pid().Return(2345).Maybe() | |
| 260 | + | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | |
| 261 | + | newPTY.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe() | |
| 262 | + | newPTY.EXPECT().Resize(mock.Anything).Return(nil).Maybe() | |
| 263 | + | newPTY.EXPECT().ForegroundPgid().Return(0, nil).Maybe() | |
| 264 | + | factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once() | |
| 265 | + | ||
| 266 | + | require.NoError(t, d.splitPane(sess, nil, layout.Right)) | |
| 267 | + | require.True(t, sess.snapDirty.Load()) | |
| 268 | + | ||
| 269 | + | wrote := make(chan struct{}, 1) | |
| 270 | + | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |
| 271 | + | snap, err := snapcodec.Unmarshal(data) | |
| 272 | + | require.NoError(t, err) | |
| 273 | + | require.Len(t, snap.Tabs, 1) | |
| 274 | + | require.Len(t, snap.Tabs[0].Panes, 2) | |
| 275 | + | wrote <- struct{}{} | |
| 276 | + | return nil | |
| 277 | + | }).Once() | |
| 278 | + | ||
| 279 | + | ctx, cancel := context.WithCancel(t.Context()) | |
| 280 | + | defer cancel() | |
| 281 | + | go d.snapshotSaver(ctx) | |
| 282 | + | clock.fireNext(t) | |
| 283 | + | select { | |
| 284 | + | case <-wrote: | |
| 285 | + | case <-time.After(time.Second): | |
| 286 | + | t.Fatal("timed out waiting for layout-only dirty snapshot write") | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd string) *session { | |
| 291 | + | t.Helper() | |
| 292 | + | pty := portsmocks.NewMockPTY(t) | |
| 293 | + | pty.EXPECT().Pid().Return(1234).Maybe() | |
| 294 | + | pty.EXPECT().Read(mock.Anything).Return(0, errors.New("unused")).Maybe() | |
| 295 | + | pty.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe() | |
| 296 | + | pty.EXPECT().Resize(mock.Anything).Return(nil).Maybe() | |
| 297 | + | pty.EXPECT().ForegroundPgid().Return(0, nil).Maybe() | |
| 298 | + | ||
| 299 | + | tb := newTab(pty, domain.Size{Cols: 8, Rows: 3}) | |
| 300 | + | p := tb.panes["pane-1"] | |
| 301 | + | p.screen.Write([]byte("hello")) | |
| 302 | + | p.scrollback.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}}) | |
| 303 | + | return &session{ | |
| 304 | + | id: domain.SessionID("sess-" + name), | |
| 305 | + | name: name, | |
| 306 | + | ephemeral: ephemeral, | |
| 307 | + | ctx: context.Background(), | |
| 308 | + | cancel: func() {}, | |
| 309 | + | tabs: []*tab{tb}, | |
| 310 | + | active: 0, | |
| 311 | + | cwd: cwd, | |
| 312 | + | createdAt: 42, | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | type manualSnapshotClock struct { | |
| 317 | + | mu sync.Mutex | |
| 318 | + | timers []*manualSnapshotTimer | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | func newManualSnapshotClock() *manualSnapshotClock { return &manualSnapshotClock{} } | |
| 322 | + | ||
| 323 | + | func (c *manualSnapshotClock) Now() time.Time { return time.Unix(0, 0) } | |
| 324 | + | ||
| 325 | + | func (c *manualSnapshotClock) NewTimer(time.Duration) ports.Timer { | |
| 326 | + | t := &manualSnapshotTimer{ch: make(chan time.Time, 1)} | |
| 327 | + | c.mu.Lock() | |
| 328 | + | c.timers = append(c.timers, t) | |
| 329 | + | c.mu.Unlock() | |
| 330 | + | return t | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | func (c *manualSnapshotClock) fireNext(t *testing.T) { | |
| 334 | + | t.Helper() | |
| 335 | + | deadline := time.After(time.Second) | |
| 336 | + | for { | |
| 337 | + | c.mu.Lock() | |
| 338 | + | if len(c.timers) > 0 { | |
| 339 | + | timer := c.timers[0] | |
| 340 | + | c.mu.Unlock() | |
| 341 | + | timer.ch <- time.Unix(0, 0) | |
| 342 | + | return | |
| 343 | + | } | |
| 344 | + | c.mu.Unlock() | |
| 345 | + | select { | |
| 346 | + | case <-deadline: | |
| 347 | + | t.Fatal("timed out waiting for snapshot timer") | |
| 348 | + | case <-time.After(time.Millisecond): | |
| 349 | + | } | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | type manualSnapshotTimer struct{ ch chan time.Time } | |
| 354 | + | ||
| 355 | + | func (t *manualSnapshotTimer) C() <-chan time.Time { return t.ch } | |
| 356 | + | func (t *manualSnapshotTimer) Reset(time.Duration) bool { return true } | |
| 357 | + | func (t *manualSnapshotTimer) Stop() bool { return true } | |
| @@ -0,0 +1,357 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "io" | |||
| 7 | + | "sync" | |||
| 8 | + | "sync/atomic" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/stretchr/testify/mock" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/vev/internal/domain" | |||
| 16 | + | "github.com/bnema/vev/internal/ports" | |||
| 17 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 18 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 19 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 20 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) { | |||
| 24 | + | clock := newManualSnapshotClock() | |||
| 25 | + | store := portsmocks.NewMockSnapshotStore(t) | |||
| 26 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | |||
| 27 | + | WithSnapshotStore(store)(d) | |||
| 28 | + | d.procCwd = func(int) (string, error) { return "/pane", nil } | |||
| 29 | + | ||||
| 30 | + | dirty := newSnapshotTestSession(t, "dirty", false, "/fallback") | |||
| 31 | + | clean := newSnapshotTestSession(t, "clean", false, "/fallback") | |||
| 32 | + | ephemeral := newSnapshotTestSession(t, "0", true, "/fallback") | |||
| 33 | + | dirty.snapDirty.Store(true) | |||
| 34 | + | ephemeral.snapDirty.Store(true) | |||
| 35 | + | d.sessions[dirty.id] = dirty | |||
| 36 | + | d.sessions[clean.id] = clean | |||
| 37 | + | d.sessions[ephemeral.id] = ephemeral | |||
| 38 | + | ||||
| 39 | + | wrote := make(chan struct{}, 1) | |||
| 40 | + | store.EXPECT().Write("dirty", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |||
| 41 | + | snap, err := snapcodec.Unmarshal(data) | |||
| 42 | + | require.NoError(t, err) | |||
| 43 | + | require.Equal(t, "dirty", snap.Name) | |||
| 44 | + | wrote <- struct{}{} | |||
| 45 | + | return nil | |||
| 46 | + | }).Once() | |||
| 47 | + | ||||
| 48 | + | ctx, cancel := context.WithCancel(t.Context()) | |||
| 49 | + | defer cancel() | |||
| 50 | + | go d.snapshotSaver(ctx) | |||
| 51 | + | clock.fireNext(t) | |||
| 52 | + | select { | |||
| 53 | + | case <-wrote: | |||
| 54 | + | case <-time.After(time.Second): | |||
| 55 | + | t.Fatal("timed out waiting for dirty snapshot write") | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | clock.fireNext(t) | |||
| 59 | + | select { | |||
| 60 | + | case <-wrote: | |||
| 61 | + | t.Fatal("clean second interval wrote unexpectedly") | |||
| 62 | + | case <-time.After(25 * time.Millisecond): | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestKillSessionSnapshotsNamedSessionBeforeClosingPanes(t *testing.T) { | |||
| 67 | + | store := portsmocks.NewMockSnapshotStore(t) | |||
| 68 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 69 | + | WithSnapshotStore(store)(d) | |||
| 70 | + | d.procCwd = func(int) (string, error) { return "/live", nil } | |||
| 71 | + | ||||
| 72 | + | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |||
| 73 | + | d.sessions[sess.id] = sess | |||
| 74 | + | ||||
| 75 | + | var wrote atomic.Bool | |||
| 76 | + | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |||
| 77 | + | snap, err := snapcodec.Unmarshal(data) | |||
| 78 | + | require.NoError(t, err) | |||
| 79 | + | require.Equal(t, "work", snap.Name) | |||
| 80 | + | require.Equal(t, "/live", snap.Tabs[0].Panes[0].Cwd) | |||
| 81 | + | wrote.Store(true) | |||
| 82 | + | return nil | |||
| 83 | + | }).Once() | |||
| 84 | + | mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |||
| 85 | + | require.True(t, ok) | |||
| 86 | + | mockPTY.EXPECT().Close().RunAndReturn(func() error { | |||
| 87 | + | require.True(t, wrote.Load(), "snapshot Write must happen before pane Close") | |||
| 88 | + | return nil | |||
| 89 | + | }).Once() | |||
| 90 | + | ||||
| 91 | + | require.NoError(t, d.killSession(sess, 0, false)) | |||
| 92 | + | require.True(t, wrote.Load()) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func TestPTYReaderSynchronizedUpdateFlushMarksSnapshotDirty(t *testing.T) { | |||
| 96 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 97 | + | p := portsmocks.NewMockPTY(t) | |||
| 98 | + | chunks := [][]byte{[]byte("\x1b[?2026hhello\x1b[?2026l")} | |||
| 99 | + | p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) { | |||
| 100 | + | if len(chunks) == 0 { | |||
| 101 | + | return 0, io.EOF | |||
| 102 | + | } | |||
| 103 | + | n := copy(buf, chunks[0]) | |||
| 104 | + | chunks = chunks[1:] | |||
| 105 | + | return n, nil | |||
| 106 | + | }) | |||
| 107 | + | p.EXPECT().Close().Return(nil).Maybe() | |||
| 108 | + | ||||
| 109 | + | sctx, cancel := context.WithCancel(context.Background()) | |||
| 110 | + | defer cancel() | |||
| 111 | + | tb := newTestTabWithContext(p, sctx, cancel) | |||
| 112 | + | sess := &session{id: "sync", name: "sync", tabs: []*tab{tb}, ctx: sctx, cancel: cancel} | |||
| 113 | + | d.sessions[sess.id] = sess | |||
| 114 | + | d.sessWg.Add(1) | |||
| 115 | + | d.ptyReader(sess, tb, tb.focusedPane()) | |||
| 116 | + | ||||
| 117 | + | require.True(t, sess.snapDirty.Load()) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestSnapshotSaverKeepsDirtyWhenWriteFails(t *testing.T) { | |||
| 121 | + | clock := newManualSnapshotClock() | |||
| 122 | + | store := portsmocks.NewMockSnapshotStore(t) | |||
| 123 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | |||
| 124 | + | WithSnapshotStore(store)(d) | |||
| 125 | + | ||||
| 126 | + | sess := newSnapshotTestSession(t, "retry", false, "/fallback") | |||
| 127 | + | sess.snapDirty.Store(true) | |||
| 128 | + | d.sessions[sess.id] = sess | |||
| 129 | + | ||||
| 130 | + | store.EXPECT().Write("retry", mock.Anything).Return(errors.New("boom")).Once() | |||
| 131 | + | wrote := make(chan struct{}, 1) | |||
| 132 | + | store.EXPECT().Write("retry", mock.Anything).RunAndReturn(func(string, []byte) error { | |||
| 133 | + | wrote <- struct{}{} | |||
| 134 | + | return nil | |||
| 135 | + | }).Once() | |||
| 136 | + | ||||
| 137 | + | ctx, cancel := context.WithCancel(t.Context()) | |||
| 138 | + | defer cancel() | |||
| 139 | + | go d.snapshotSaver(ctx) | |||
| 140 | + | clock.fireNext(t) | |||
| 141 | + | require.Eventually(t, func() bool { return sess.snapDirty.Load() }, time.Second, time.Millisecond) | |||
| 142 | + | clock.fireNext(t) | |||
| 143 | + | select { | |||
| 144 | + | case <-wrote: | |||
| 145 | + | case <-time.After(time.Second): | |||
| 146 | + | t.Fatal("timed out waiting for retry snapshot write") | |||
| 147 | + | } | |||
| 148 | + | require.False(t, sess.snapDirty.Load()) | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func TestKillSessionPurgeDeletesSnapshot(t *testing.T) { | |||
| 152 | + | store := portsmocks.NewMockSnapshotStore(t) | |||
| 153 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 154 | + | WithSnapshotStore(store)(d) | |||
| 155 | + | ||||
| 156 | + | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |||
| 157 | + | d.sessions[sess.id] = sess | |||
| 158 | + | ||||
| 159 | + | store.EXPECT().Delete("work").Return(nil).Once() | |||
| 160 | + | mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |||
| 161 | + | require.True(t, ok) | |||
| 162 | + | mockPTY.EXPECT().Close().Return(nil).Once() | |||
| 163 | + | ||||
| 164 | + | require.NoError(t, d.killSession(sess, 0, true)) | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | func TestKillSessionPurgeReturnsSnapshotDeleteError(t *testing.T) { | |||
| 168 | + | store := portsmocks.NewMockSnapshotStore(t) | |||
| 169 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 170 | + | WithSnapshotStore(store)(d) | |||
| 171 | + | ||||
| 172 | + | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |||
| 173 | + | d.sessions[sess.id] = sess | |||
| 174 | + | ||||
| 175 | + | store.EXPECT().Delete("work").Return(errors.New("delete failed")).Once() | |||
| 176 | + | err := d.killSession(sess, 0, true) | |||
| 177 | + | require.ErrorContains(t, err, "delete failed") | |||
| 178 | + | require.Contains(t, d.sessions, sess.id) | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func TestRefreshSessionCwdMarksSnapshotDirty(t *testing.T) { | |||
| 182 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 183 | + | d.procCwd = func(int) (string, error) { return "/new", nil } | |||
| 184 | + | sess := newSnapshotTestSession(t, "work", false, "/old") | |||
| 185 | + | d.sessions[sess.id] = sess | |||
| 186 | + | ||||
| 187 | + | d.refreshSessionCwd(sess) | |||
| 188 | + | require.Equal(t, "/new", sess.cwd) | |||
| 189 | + | require.True(t, sess.snapDirty.Load()) | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | func TestStoppedSessionKillPurgesSnapshot(t *testing.T) { | |||
| 193 | + | store := portsmocks.NewMockSnapshotStore(t) | |||
| 194 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 195 | + | WithSnapshotStore(store)(d) | |||
| 196 | + | d.stopped["work"] = stoppedSession{name: "work", cwd: "/work", createdAt: 1} | |||
| 197 | + | ||||
| 198 | + | store.EXPECT().Delete("work").Return(nil).Once() | |||
| 199 | + | tr, _, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})}) | |||
| 200 | + | ||||
| 201 | + | d.handleKill(tr, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})}) | |||
| 202 | + | ||||
| 203 | + | require.NotContains(t, d.stopped, "work") | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | func TestStoppedSessionKillSnapshotDeleteFailureKeepsMetadata(t *testing.T) { | |||
| 207 | + | store := portsmocks.NewMockSnapshotStore(t) | |||
| 208 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 209 | + | WithSnapshotStore(store)(d) | |||
| 210 | + | d.stopped["work"] = stoppedSession{name: "work", cwd: "/work", createdAt: 1} | |||
| 211 | + | ||||
| 212 | + | store.EXPECT().Delete("work").Return(errors.New("delete failed")).Once() | |||
| 213 | + | tr, sends, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})}) | |||
| 214 | + | ||||
| 215 | + | d.handleKill(tr, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})}) | |||
| 216 | + | ||||
| 217 | + | require.Contains(t, d.stopped, "work") | |||
| 218 | + | frame := awaitFrame(t, sends, ports.MsgError) | |||
| 219 | + | msg, err := ports.UnmarshalErrorMsg(frame.Payload) | |||
| 220 | + | require.NoError(t, err) | |||
| 221 | + | require.Equal(t, ports.ErrInternal, msg.Code) | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | func TestRenameSessionDeletesOldSnapshotAndMarksDirty(t *testing.T) { | |||
| 225 | + | store := portsmocks.NewMockSnapshotStore(t) | |||
| 226 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 227 | + | WithSnapshotStore(store)(d) | |||
| 228 | + | sess := newSnapshotTestSession(t, "old", false, "/fallback") | |||
| 229 | + | d.sessions[sess.id] = sess | |||
| 230 | + | ||||
| 231 | + | store.EXPECT().Delete("old").Return(nil).Once() | |||
| 232 | + | require.NoError(t, d.renameSession(sess, "new")) | |||
| 233 | + | ||||
| 234 | + | require.True(t, sess.snapDirty.Load()) | |||
| 235 | + | require.Nil(t, d.findByNameLocked("old")) | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | func TestSnapshotSaverCapturesLayoutOnlyDirtySave(t *testing.T) { | |||
| 239 | + | clock := newManualSnapshotClock() | |||
| 240 | + | store := portsmocks.NewMockSnapshotStore(t) | |||
| 241 | + | factory := portsmocks.NewMockPTYFactory(t) | |||
| 242 | + | d := newTestDaemon(t, factory, clock) | |||
| 243 | + | WithSnapshotStore(store)(d) | |||
| 244 | + | ||||
| 245 | + | sess := newSnapshotTestSession(t, "work", false, "/work") | |||
| 246 | + | tctx, tcancel := context.WithCancel(context.Background()) | |||
| 247 | + | t.Cleanup(tcancel) | |||
| 248 | + | sess.ctx = tctx | |||
| 249 | + | sess.cancel = tcancel | |||
| 250 | + | sess.tabs[0].ctx = tctx | |||
| 251 | + | sess.tabs[0].cancel = tcancel | |||
| 252 | + | for _, p := range sess.tabs[0].panes { | |||
| 253 | + | p.ctx = tctx | |||
| 254 | + | p.cancel = tcancel | |||
| 255 | + | } | |||
| 256 | + | sess.tabs[0].size = domain.Size{Cols: 41, Rows: 10} | |||
| 257 | + | d.sessions[sess.id] = sess | |||
| 258 | + | newPTY := portsmocks.NewMockPTY(t) | |||
| 259 | + | newPTY.EXPECT().Pid().Return(2345).Maybe() | |||
| 260 | + | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | |||
| 261 | + | newPTY.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe() | |||
| 262 | + | newPTY.EXPECT().Resize(mock.Anything).Return(nil).Maybe() | |||
| 263 | + | newPTY.EXPECT().ForegroundPgid().Return(0, nil).Maybe() | |||
| 264 | + | factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once() | |||
| 265 | + | ||||
| 266 | + | require.NoError(t, d.splitPane(sess, nil, layout.Right)) | |||
| 267 | + | require.True(t, sess.snapDirty.Load()) | |||
| 268 | + | ||||
| 269 | + | wrote := make(chan struct{}, 1) | |||
| 270 | + | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |||
| 271 | + | snap, err := snapcodec.Unmarshal(data) | |||
| 272 | + | require.NoError(t, err) | |||
| 273 | + | require.Len(t, snap.Tabs, 1) | |||
| 274 | + | require.Len(t, snap.Tabs[0].Panes, 2) | |||
| 275 | + | wrote <- struct{}{} | |||
| 276 | + | return nil | |||
| 277 | + | }).Once() | |||
| 278 | + | ||||
| 279 | + | ctx, cancel := context.WithCancel(t.Context()) | |||
| 280 | + | defer cancel() | |||
| 281 | + | go d.snapshotSaver(ctx) | |||
| 282 | + | clock.fireNext(t) | |||
| 283 | + | select { | |||
| 284 | + | case <-wrote: | |||
| 285 | + | case <-time.After(time.Second): | |||
| 286 | + | t.Fatal("timed out waiting for layout-only dirty snapshot write") | |||
| 287 | + | } | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd string) *session { | |||
| 291 | + | t.Helper() | |||
| 292 | + | pty := portsmocks.NewMockPTY(t) | |||
| 293 | + | pty.EXPECT().Pid().Return(1234).Maybe() | |||
| 294 | + | pty.EXPECT().Read(mock.Anything).Return(0, errors.New("unused")).Maybe() | |||
| 295 | + | pty.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe() | |||
| 296 | + | pty.EXPECT().Resize(mock.Anything).Return(nil).Maybe() | |||
| 297 | + | pty.EXPECT().ForegroundPgid().Return(0, nil).Maybe() | |||
| 298 | + | ||||
| 299 | + | tb := newTab(pty, domain.Size{Cols: 8, Rows: 3}) | |||
| 300 | + | p := tb.panes["pane-1"] | |||
| 301 | + | p.screen.Write([]byte("hello")) | |||
| 302 | + | p.scrollback.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}}) | |||
| 303 | + | return &session{ | |||
| 304 | + | id: domain.SessionID("sess-" + name), | |||
| 305 | + | name: name, | |||
| 306 | + | ephemeral: ephemeral, | |||
| 307 | + | ctx: context.Background(), | |||
| 308 | + | cancel: func() {}, | |||
| 309 | + | tabs: []*tab{tb}, | |||
| 310 | + | active: 0, | |||
| 311 | + | cwd: cwd, | |||
| 312 | + | createdAt: 42, | |||
| 313 | + | } | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | type manualSnapshotClock struct { | |||
| 317 | + | mu sync.Mutex | |||
| 318 | + | timers []*manualSnapshotTimer | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | func newManualSnapshotClock() *manualSnapshotClock { return &manualSnapshotClock{} } | |||
| 322 | + | ||||
| 323 | + | func (c *manualSnapshotClock) Now() time.Time { return time.Unix(0, 0) } | |||
| 324 | + | ||||
| 325 | + | func (c *manualSnapshotClock) NewTimer(time.Duration) ports.Timer { | |||
| 326 | + | t := &manualSnapshotTimer{ch: make(chan time.Time, 1)} | |||
| 327 | + | c.mu.Lock() | |||
| 328 | + | c.timers = append(c.timers, t) | |||
| 329 | + | c.mu.Unlock() | |||
| 330 | + | return t | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | func (c *manualSnapshotClock) fireNext(t *testing.T) { | |||
| 334 | + | t.Helper() | |||
| 335 | + | deadline := time.After(time.Second) | |||
| 336 | + | for { | |||
| 337 | + | c.mu.Lock() | |||
| 338 | + | if len(c.timers) > 0 { | |||
| 339 | + | timer := c.timers[0] | |||
| 340 | + | c.mu.Unlock() | |||
| 341 | + | timer.ch <- time.Unix(0, 0) | |||
| 342 | + | return | |||
| 343 | + | } | |||
| 344 | + | c.mu.Unlock() | |||
| 345 | + | select { | |||
| 346 | + | case <-deadline: | |||
| 347 | + | t.Fatal("timed out waiting for snapshot timer") | |||
| 348 | + | case <-time.After(time.Millisecond): | |||
| 349 | + | } | |||
| 350 | + | } | |||
| 351 | + | } | |||
| 352 | + | ||||
| 353 | + | type manualSnapshotTimer struct{ ch chan time.Time } | |||
| 354 | + | ||||
| 355 | + | func (t *manualSnapshotTimer) C() <-chan time.Time { return t.ch } | |||
| 356 | + | func (t *manualSnapshotTimer) Reset(time.Duration) bool { return true } | |||
| 357 | + | func (t *manualSnapshotTimer) Stop() bool { return true } | |||
M internal/usecase/layout/layout_test.go +24 -0
| @@ -109,6 +109,30 @@ func TestNilTreeCloneIsSafe(t *testing.T) { | |||
| 109 | 109 | t.Parallel() | |
| 110 | 110 | var tr *Tree | |
| 111 | 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 | 138 | func TestTooSmallRefusalsDoNotMutate(t *testing.T) { | |
| @@ -109,6 +109,30 @@ func TestNilTreeCloneIsSafe(t *testing.T) { | |||||
| 109 | t.Parallel() | 109 | t.Parallel() | ||
| 110 | var tr *Tree | 110 | 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 { | |||
| 94 | 94 | } | |
| 95 | 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() } | |
| @@ -94,3 +94,6 @@ func (t *Tree) clone() *Tree { | |||||
| 94 | } | 94 | } | ||
| 95 | return &Tree{Root: t.Root.clone(), Focus: t.Focus} | 95 | return &Tree{Root: t.Root.clone(), Focus: t.Focus} | ||
| 96 | } | 96 | } | ||
| 97 | + | ||||
| 98 | + | // Clone returns an independent copy of the tree. | |||
| 99 | + | func (t *Tree) Clone() *Tree { return t.clone() } | |||
A internal/usecase/snapshot/codec.go +745 -0
| @@ -0,0 +1,745 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "compress/flate" | |
| 6 | + | "encoding/binary" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "hash/crc32" | |
| 10 | + | "io" | |
| 11 | + | "math" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 14 | + | "github.com/bnema/vev/pkg/renderer" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | var ( | |
| 18 | + | ErrBadMagic = errors.New("snapshot: bad magic") | |
| 19 | + | ErrBadVersion = errors.New("snapshot: bad version") | |
| 20 | + | ErrBadCRC = errors.New("snapshot: bad crc") | |
| 21 | + | ErrShortPayload = errors.New("snapshot: payload too short") | |
| 22 | + | ErrTrailingBytes = errors.New("snapshot: trailing bytes") | |
| 23 | + | ErrStyleOOB = errors.New("snapshot: style index out of range") | |
| 24 | + | ErrUnknownPane = errors.New("snapshot: tree references unknown pane") | |
| 25 | + | ErrInvalidData = errors.New("snapshot: invalid data") | |
| 26 | + | ) | |
| 27 | + | ||
| 28 | + | const ( | |
| 29 | + | magic = "VEVS" | |
| 30 | + | version = uint16(1) | |
| 31 | + | flagFlate = uint16(1 << 0) | |
| 32 | + | flateCutoff = 4 << 10 | |
| 33 | + | maxDecodedBodySize = 256 << 20 | |
| 34 | + | maxDecodedRows = 1 << 20 | |
| 35 | + | maxDecodedRuns = 16 << 20 | |
| 36 | + | maxDecodedCells = 16 << 20 | |
| 37 | + | ) | |
| 38 | + | ||
| 39 | + | // Marshal encodes a v1 durable snapshot. | |
| 40 | + | func Marshal(s Session) ([]byte, error) { | |
| 41 | + | var w payloadWriter | |
| 42 | + | if err := writeSession(&w, s); err != nil { | |
| 43 | + | return nil, err | |
| 44 | + | } | |
| 45 | + | body := w.b | |
| 46 | + | flags := uint16(0) | |
| 47 | + | if len(body) > flateCutoff { | |
| 48 | + | var compressed bytes.Buffer | |
| 49 | + | fw, err := flate.NewWriter(&compressed, flate.DefaultCompression) | |
| 50 | + | if err != nil { | |
| 51 | + | return nil, err | |
| 52 | + | } | |
| 53 | + | if _, err := fw.Write(body); err != nil { | |
| 54 | + | _ = fw.Close() | |
| 55 | + | return nil, err | |
| 56 | + | } | |
| 57 | + | if err := fw.Close(); err != nil { | |
| 58 | + | return nil, err | |
| 59 | + | } | |
| 60 | + | body = compressed.Bytes() | |
| 61 | + | flags |= flagFlate | |
| 62 | + | } | |
| 63 | + | if len(body) > math.MaxUint32 { | |
| 64 | + | return nil, fmt.Errorf("%w: body too large", ErrInvalidData) | |
| 65 | + | } | |
| 66 | + | out := make([]byte, 16+len(body)) | |
| 67 | + | copy(out[:4], magic) | |
| 68 | + | binary.BigEndian.PutUint16(out[4:6], version) | |
| 69 | + | binary.BigEndian.PutUint16(out[6:8], flags) | |
| 70 | + | binary.BigEndian.PutUint32(out[8:12], uint32(len(body))) | |
| 71 | + | binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body)) | |
| 72 | + | copy(out[16:], body) | |
| 73 | + | return out, nil | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // Unmarshal decodes a strict v1 durable snapshot. | |
| 77 | + | func Unmarshal(b []byte) (Session, error) { | |
| 78 | + | if len(b) < 16 { | |
| 79 | + | return Session{}, ErrShortPayload | |
| 80 | + | } | |
| 81 | + | if string(b[:4]) != magic { | |
| 82 | + | return Session{}, ErrBadMagic | |
| 83 | + | } | |
| 84 | + | if binary.BigEndian.Uint16(b[4:6]) != version { | |
| 85 | + | return Session{}, ErrBadVersion | |
| 86 | + | } | |
| 87 | + | flags := binary.BigEndian.Uint16(b[6:8]) | |
| 88 | + | if flags&^flagFlate != 0 { | |
| 89 | + | return Session{}, fmt.Errorf("%w: unknown flags", ErrInvalidData) | |
| 90 | + | } | |
| 91 | + | bodyLen := int(binary.BigEndian.Uint32(b[8:12])) | |
| 92 | + | if bodyLen > maxDecodedBodySize { | |
| 93 | + | return Session{}, fmt.Errorf("%w: body too large", ErrInvalidData) | |
| 94 | + | } | |
| 95 | + | if len(b) < 16+bodyLen { | |
| 96 | + | return Session{}, ErrShortPayload | |
| 97 | + | } | |
| 98 | + | if len(b) != 16+bodyLen { | |
| 99 | + | return Session{}, ErrTrailingBytes | |
| 100 | + | } | |
| 101 | + | body := b[16:] | |
| 102 | + | if crc32.ChecksumIEEE(body) != binary.BigEndian.Uint32(b[12:16]) { | |
| 103 | + | return Session{}, ErrBadCRC | |
| 104 | + | } | |
| 105 | + | if flags&flagFlate != 0 { | |
| 106 | + | fr := flate.NewReader(bytes.NewReader(body)) | |
| 107 | + | decompressed, err := io.ReadAll(io.LimitReader(fr, maxDecodedBodySize+1)) | |
| 108 | + | cerr := fr.Close() | |
| 109 | + | if err != nil { | |
| 110 | + | return Session{}, err | |
| 111 | + | } | |
| 112 | + | if cerr != nil { | |
| 113 | + | return Session{}, cerr | |
| 114 | + | } | |
| 115 | + | if len(decompressed) > maxDecodedBodySize { | |
| 116 | + | return Session{}, fmt.Errorf("%w: decompressed body too large", ErrInvalidData) | |
| 117 | + | } | |
| 118 | + | body = decompressed | |
| 119 | + | } | |
| 120 | + | r := payloadReader{b: body} | |
| 121 | + | s, err := readSession(&r) | |
| 122 | + | if err != nil { | |
| 123 | + | return Session{}, err | |
| 124 | + | } | |
| 125 | + | if err := r.done(); err != nil { | |
| 126 | + | return Session{}, err | |
| 127 | + | } | |
| 128 | + | for _, tab := range s.Tabs { | |
| 129 | + | ids := make(map[layout.PaneID]struct{}, len(tab.Panes)) | |
| 130 | + | for _, p := range tab.Panes { | |
| 131 | + | ids[p.ID] = struct{}{} | |
| 132 | + | } | |
| 133 | + | if _, ok := ids[tab.Focus]; tab.Focus != "" && !ok { | |
| 134 | + | return Session{}, ErrUnknownPane | |
| 135 | + | } | |
| 136 | + | if err := validateTree(tab.Tree, ids); err != nil { | |
| 137 | + | return Session{}, err | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | return s, nil | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | type payloadWriter struct{ b []byte } | |
| 144 | + | ||
| 145 | + | func (w *payloadWriter) putUint8(v uint8) { w.b = append(w.b, v) } | |
| 146 | + | func (w *payloadWriter) putUint16(v uint16) { | |
| 147 | + | var t [2]byte | |
| 148 | + | binary.BigEndian.PutUint16(t[:], v) | |
| 149 | + | w.b = append(w.b, t[:]...) | |
| 150 | + | } | |
| 151 | + | func (w *payloadWriter) putUint32(v uint32) { | |
| 152 | + | var t [4]byte | |
| 153 | + | binary.BigEndian.PutUint32(t[:], v) | |
| 154 | + | w.b = append(w.b, t[:]...) | |
| 155 | + | } | |
| 156 | + | func (w *payloadWriter) putUint64(v uint64) { | |
| 157 | + | var t [8]byte | |
| 158 | + | binary.BigEndian.PutUint64(t[:], v) | |
| 159 | + | w.b = append(w.b, t[:]...) | |
| 160 | + | } | |
| 161 | + | func (w *payloadWriter) putString(s string) error { | |
| 162 | + | if len(s) > math.MaxUint16 { | |
| 163 | + | return fmt.Errorf("%w: string too long", ErrInvalidData) | |
| 164 | + | } | |
| 165 | + | w.putUint16(uint16(len(s))) | |
| 166 | + | w.b = append(w.b, s...) | |
| 167 | + | return nil | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | type payloadReader struct{ b []byte } | |
| 171 | + | ||
| 172 | + | func (r *payloadReader) getUint8() (uint8, error) { | |
| 173 | + | if len(r.b) < 1 { | |
| 174 | + | return 0, ErrShortPayload | |
| 175 | + | } | |
| 176 | + | v := r.b[0] | |
| 177 | + | r.b = r.b[1:] | |
| 178 | + | return v, nil | |
| 179 | + | } | |
| 180 | + | func (r *payloadReader) getUint16() (uint16, error) { | |
| 181 | + | if len(r.b) < 2 { | |
| 182 | + | return 0, ErrShortPayload | |
| 183 | + | } | |
| 184 | + | v := binary.BigEndian.Uint16(r.b) | |
| 185 | + | r.b = r.b[2:] | |
| 186 | + | return v, nil | |
| 187 | + | } | |
| 188 | + | func (r *payloadReader) getUint32() (uint32, error) { | |
| 189 | + | if len(r.b) < 4 { | |
| 190 | + | return 0, ErrShortPayload | |
| 191 | + | } | |
| 192 | + | v := binary.BigEndian.Uint32(r.b) | |
| 193 | + | r.b = r.b[4:] | |
| 194 | + | return v, nil | |
| 195 | + | } | |
| 196 | + | func (r *payloadReader) getUint64() (uint64, error) { | |
| 197 | + | if len(r.b) < 8 { | |
| 198 | + | return 0, ErrShortPayload | |
| 199 | + | } | |
| 200 | + | v := binary.BigEndian.Uint64(r.b) | |
| 201 | + | r.b = r.b[8:] | |
| 202 | + | return v, nil | |
| 203 | + | } | |
| 204 | + | func (r *payloadReader) getString() (string, error) { | |
| 205 | + | n, err := r.getUint16() | |
| 206 | + | if err != nil { | |
| 207 | + | return "", err | |
| 208 | + | } | |
| 209 | + | if len(r.b) < int(n) { | |
| 210 | + | return "", ErrShortPayload | |
| 211 | + | } | |
| 212 | + | s := string(r.b[:n]) | |
| 213 | + | r.b = r.b[n:] | |
| 214 | + | return s, nil | |
| 215 | + | } | |
| 216 | + | func (r *payloadReader) done() error { | |
| 217 | + | if len(r.b) != 0 { | |
| 218 | + | return ErrTrailingBytes | |
| 219 | + | } | |
| 220 | + | return nil | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | func writeSession(w *payloadWriter, s Session) error { | |
| 224 | + | if err := w.putString(s.Name); err != nil { | |
| 225 | + | return err | |
| 226 | + | } | |
| 227 | + | w.putUint64(s.CreatedAt) | |
| 228 | + | w.putUint16(s.Active) | |
| 229 | + | if len(s.Tabs) > math.MaxUint16 { | |
| 230 | + | return fmt.Errorf("%w: too many tabs", ErrInvalidData) | |
| 231 | + | } | |
| 232 | + | w.putUint16(uint16(len(s.Tabs))) | |
| 233 | + | for _, t := range s.Tabs { | |
| 234 | + | if err := writeTab(w, t); err != nil { | |
| 235 | + | return err | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | return nil | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | func readSession(r *payloadReader) (Session, error) { | |
| 242 | + | name, err := r.getString() | |
| 243 | + | if err != nil { | |
| 244 | + | return Session{}, err | |
| 245 | + | } | |
| 246 | + | created, err := r.getUint64() | |
| 247 | + | if err != nil { | |
| 248 | + | return Session{}, err | |
| 249 | + | } | |
| 250 | + | active, err := r.getUint16() | |
| 251 | + | if err != nil { | |
| 252 | + | return Session{}, err | |
| 253 | + | } | |
| 254 | + | nt, err := r.getUint16() | |
| 255 | + | if err != nil { | |
| 256 | + | return Session{}, err | |
| 257 | + | } | |
| 258 | + | s := Session{Name: name, CreatedAt: created, Active: active, Tabs: make([]Tab, nt)} | |
| 259 | + | for i := range s.Tabs { | |
| 260 | + | t, err := readTab(r) | |
| 261 | + | if err != nil { | |
| 262 | + | return Session{}, err | |
| 263 | + | } | |
| 264 | + | s.Tabs[i] = t | |
| 265 | + | } | |
| 266 | + | return s, nil | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | func writeTab(w *payloadWriter, t Tab) error { | |
| 270 | + | w.putUint16(t.Cols) | |
| 271 | + | w.putUint16(t.Rows) | |
| 272 | + | w.putUint64(t.NextPaneID) | |
| 273 | + | if err := w.putString(string(t.Focus)); err != nil { | |
| 274 | + | return err | |
| 275 | + | } | |
| 276 | + | if t.Tree == nil { | |
| 277 | + | t.Tree = &layout.Tree{Focus: t.Focus} | |
| 278 | + | } | |
| 279 | + | if err := writeNode(w, t.Tree.Root); err != nil { | |
| 280 | + | return err | |
| 281 | + | } | |
| 282 | + | if len(t.Panes) > math.MaxUint16 { | |
| 283 | + | return fmt.Errorf("%w: too many panes", ErrInvalidData) | |
| 284 | + | } | |
| 285 | + | w.putUint16(uint16(len(t.Panes))) | |
| 286 | + | for _, p := range t.Panes { | |
| 287 | + | if err := writePane(w, p); err != nil { | |
| 288 | + | return err | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | return nil | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func readTab(r *payloadReader) (Tab, error) { | |
| 295 | + | cols, err := r.getUint16() | |
| 296 | + | if err != nil { | |
| 297 | + | return Tab{}, err | |
| 298 | + | } | |
| 299 | + | rows, err := r.getUint16() | |
| 300 | + | if err != nil { | |
| 301 | + | return Tab{}, err | |
| 302 | + | } | |
| 303 | + | next, err := r.getUint64() | |
| 304 | + | if err != nil { | |
| 305 | + | return Tab{}, err | |
| 306 | + | } | |
| 307 | + | focus, err := r.getString() | |
| 308 | + | if err != nil { | |
| 309 | + | return Tab{}, err | |
| 310 | + | } | |
| 311 | + | root, err := readNode(r) | |
| 312 | + | if err != nil { | |
| 313 | + | return Tab{}, err | |
| 314 | + | } | |
| 315 | + | np, err := r.getUint16() | |
| 316 | + | if err != nil { | |
| 317 | + | return Tab{}, err | |
| 318 | + | } | |
| 319 | + | t := Tab{Cols: cols, Rows: rows, NextPaneID: next, Focus: layout.PaneID(focus), Tree: &layout.Tree{Root: root, Focus: layout.PaneID(focus)}, Panes: make([]Pane, np)} | |
| 320 | + | for i := range t.Panes { | |
| 321 | + | p, err := readPane(r) | |
| 322 | + | if err != nil { | |
| 323 | + | return Tab{}, err | |
| 324 | + | } | |
| 325 | + | t.Panes[i] = p | |
| 326 | + | } | |
| 327 | + | return t, nil | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | func writeNode(w *payloadWriter, n *layout.Node) error { | |
| 331 | + | if n == nil { | |
| 332 | + | w.putUint8(0) | |
| 333 | + | return w.putString("") | |
| 334 | + | } | |
| 335 | + | switch n.Kind { | |
| 336 | + | case layout.Leaf: | |
| 337 | + | w.putUint8(0) | |
| 338 | + | return w.putString(string(n.Leaf)) | |
| 339 | + | case layout.Split: | |
| 340 | + | w.putUint8(1) | |
| 341 | + | w.putUint8(uint8(n.Dir)) | |
| 342 | + | return writeChildren(w, n.Children) | |
| 343 | + | case layout.Stack: | |
| 344 | + | w.putUint8(2) | |
| 345 | + | if err := w.putString(string(n.Expanded)); err != nil { | |
| 346 | + | return err | |
| 347 | + | } | |
| 348 | + | return writeChildren(w, n.Children) | |
| 349 | + | default: | |
| 350 | + | return fmt.Errorf("%w: unknown node kind", ErrInvalidData) | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | func writeChildren(w *payloadWriter, children []*layout.Node) error { | |
| 355 | + | if len(children) > math.MaxUint16 { | |
| 356 | + | return fmt.Errorf("%w: too many children", ErrInvalidData) | |
| 357 | + | } | |
| 358 | + | w.putUint16(uint16(len(children))) | |
| 359 | + | for _, c := range children { | |
| 360 | + | if err := writeNode(w, c); err != nil { | |
| 361 | + | return err | |
| 362 | + | } | |
| 363 | + | } | |
| 364 | + | return nil | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | func readNode(r *payloadReader) (*layout.Node, error) { | |
| 368 | + | kind, err := r.getUint8() | |
| 369 | + | if err != nil { | |
| 370 | + | return nil, err | |
| 371 | + | } | |
| 372 | + | switch kind { | |
| 373 | + | case 0: | |
| 374 | + | leaf, err := r.getString() | |
| 375 | + | if err != nil { | |
| 376 | + | return nil, err | |
| 377 | + | } | |
| 378 | + | return layout.NewLeaf(layout.PaneID(leaf)), nil | |
| 379 | + | case 1: | |
| 380 | + | dir, err := r.getUint8() | |
| 381 | + | if err != nil { | |
| 382 | + | return nil, err | |
| 383 | + | } | |
| 384 | + | children, err := readChildren(r) | |
| 385 | + | if err != nil { | |
| 386 | + | return nil, err | |
| 387 | + | } | |
| 388 | + | return &layout.Node{Kind: layout.Split, Dir: layout.SplitDir(dir), Children: children}, nil | |
| 389 | + | case 2: | |
| 390 | + | exp, err := r.getString() | |
| 391 | + | if err != nil { | |
| 392 | + | return nil, err | |
| 393 | + | } | |
| 394 | + | children, err := readChildren(r) | |
| 395 | + | if err != nil { | |
| 396 | + | return nil, err | |
| 397 | + | } | |
| 398 | + | return &layout.Node{Kind: layout.Stack, Expanded: layout.PaneID(exp), Children: children}, nil | |
| 399 | + | default: | |
| 400 | + | return nil, fmt.Errorf("%w: unknown node kind", ErrInvalidData) | |
| 401 | + | } | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | func readChildren(r *payloadReader) ([]*layout.Node, error) { | |
| 405 | + | n, err := r.getUint16() | |
| 406 | + | if err != nil { | |
| 407 | + | return nil, err | |
| 408 | + | } | |
| 409 | + | out := make([]*layout.Node, n) | |
| 410 | + | for i := range out { | |
| 411 | + | c, err := readNode(r) | |
| 412 | + | if err != nil { | |
| 413 | + | return nil, err | |
| 414 | + | } | |
| 415 | + | out[i] = c | |
| 416 | + | } | |
| 417 | + | return out, nil | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | func writePane(w *payloadWriter, p Pane) error { | |
| 421 | + | if err := w.putString(string(p.ID)); err != nil { | |
| 422 | + | return err | |
| 423 | + | } | |
| 424 | + | if err := w.putString(p.Cwd); err != nil { | |
| 425 | + | return err | |
| 426 | + | } | |
| 427 | + | styles := styleTable(p) | |
| 428 | + | if len(styles) > math.MaxUint16 { | |
| 429 | + | return fmt.Errorf("%w: too many styles", ErrInvalidData) | |
| 430 | + | } | |
| 431 | + | w.putUint16(uint16(len(styles))) | |
| 432 | + | for _, s := range styles { | |
| 433 | + | writeStyle(w, s) | |
| 434 | + | } | |
| 435 | + | idx := make(map[renderer.Style]uint16, len(styles)) | |
| 436 | + | for i, s := range styles { | |
| 437 | + | idx[s] = uint16(i) | |
| 438 | + | } | |
| 439 | + | if err := writeRows(w, p.Scrollback, idx, false); err != nil { | |
| 440 | + | return err | |
| 441 | + | } | |
| 442 | + | return writeRows(w, p.Visible, idx, true) | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | func readPane(r *payloadReader) (Pane, error) { | |
| 446 | + | id, err := r.getString() | |
| 447 | + | if err != nil { | |
| 448 | + | return Pane{}, err | |
| 449 | + | } | |
| 450 | + | cwd, err := r.getString() | |
| 451 | + | if err != nil { | |
| 452 | + | return Pane{}, err | |
| 453 | + | } | |
| 454 | + | ns, err := r.getUint16() | |
| 455 | + | if err != nil { | |
| 456 | + | return Pane{}, err | |
| 457 | + | } | |
| 458 | + | styles := make([]renderer.Style, ns) | |
| 459 | + | for i := range styles { | |
| 460 | + | s, err := readStyle(r) | |
| 461 | + | if err != nil { | |
| 462 | + | return Pane{}, err | |
| 463 | + | } | |
| 464 | + | styles[i] = s | |
| 465 | + | } | |
| 466 | + | budget := rowBudget{cells: maxDecodedCells, runs: maxDecodedRuns} | |
| 467 | + | sb, err := readRows(r, styles, &budget) | |
| 468 | + | if err != nil { | |
| 469 | + | return Pane{}, err | |
| 470 | + | } | |
| 471 | + | vis, err := readRows(r, styles, &budget) | |
| 472 | + | if err != nil { | |
| 473 | + | return Pane{}, err | |
| 474 | + | } | |
| 475 | + | return Pane{ID: layout.PaneID(id), Cwd: cwd, Scrollback: sb, Visible: vis}, nil | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | func writeRows(w *payloadWriter, rows [][]renderer.Cell, idx map[renderer.Style]uint16, trimBlankRows bool) error { | |
| 479 | + | clean := trimRows(rows, trimBlankRows) | |
| 480 | + | if len(clean) > math.MaxUint32 { | |
| 481 | + | return fmt.Errorf("%w: too many rows", ErrInvalidData) | |
| 482 | + | } | |
| 483 | + | w.putUint32(uint32(len(clean))) | |
| 484 | + | for _, row := range clean { | |
| 485 | + | runs := makeRuns(row) | |
| 486 | + | if len(runs) > math.MaxUint16 { | |
| 487 | + | return fmt.Errorf("%w: too many runs", ErrInvalidData) | |
| 488 | + | } | |
| 489 | + | w.putUint16(uint16(len(runs))) | |
| 490 | + | for _, run := range runs { | |
| 491 | + | w.putUint16(uint16(run.len)) | |
| 492 | + | w.putUint32(uint32(run.cell.Rune)) | |
| 493 | + | w.putUint16(idx[run.cell.Style]) | |
| 494 | + | var flags uint8 | |
| 495 | + | if run.cell.Continuation { | |
| 496 | + | flags |= 1 | |
| 497 | + | } | |
| 498 | + | w.putUint8(flags) | |
| 499 | + | } | |
| 500 | + | } | |
| 501 | + | return nil | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | type rowBudget struct { | |
| 505 | + | cells int | |
| 506 | + | runs int | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | func readRows(r *payloadReader, styles []renderer.Style, budget *rowBudget) ([][]renderer.Cell, error) { | |
| 510 | + | n, err := r.getUint32() | |
| 511 | + | if err != nil { | |
| 512 | + | return nil, err | |
| 513 | + | } | |
| 514 | + | if n > maxDecodedRows { | |
| 515 | + | return nil, fmt.Errorf("%w: too many rows", ErrInvalidData) | |
| 516 | + | } | |
| 517 | + | if uint64(n) > uint64(len(r.b))/2 { | |
| 518 | + | return nil, ErrShortPayload | |
| 519 | + | } | |
| 520 | + | rows := make([][]renderer.Cell, int(n)) | |
| 521 | + | for i := range rows { | |
| 522 | + | nr, err := r.getUint16() | |
| 523 | + | if err != nil { | |
| 524 | + | return nil, err | |
| 525 | + | } | |
| 526 | + | if budget != nil { | |
| 527 | + | if int(nr) > budget.runs { | |
| 528 | + | return nil, fmt.Errorf("%w: too many runs", ErrInvalidData) | |
| 529 | + | } | |
| 530 | + | budget.runs -= int(nr) | |
| 531 | + | } | |
| 532 | + | if uint64(nr) > uint64(len(r.b))/9 { | |
| 533 | + | return nil, ErrShortPayload | |
| 534 | + | } | |
| 535 | + | rowCells := 0 | |
| 536 | + | for j := 0; j < int(nr); j++ { | |
| 537 | + | ln, err := r.getUint16() | |
| 538 | + | if err != nil { | |
| 539 | + | return nil, err | |
| 540 | + | } | |
| 541 | + | rn, err := r.getUint32() | |
| 542 | + | if err != nil { | |
| 543 | + | return nil, err | |
| 544 | + | } | |
| 545 | + | si, err := r.getUint16() | |
| 546 | + | if err != nil { | |
| 547 | + | return nil, err | |
| 548 | + | } | |
| 549 | + | flags, err := r.getUint8() | |
| 550 | + | if err != nil { | |
| 551 | + | return nil, err | |
| 552 | + | } | |
| 553 | + | if int(si) >= len(styles) { | |
| 554 | + | return nil, ErrStyleOOB | |
| 555 | + | } | |
| 556 | + | if flags&^1 != 0 { | |
| 557 | + | return nil, fmt.Errorf("%w: unknown cell flags", ErrInvalidData) | |
| 558 | + | } | |
| 559 | + | if budget != nil { | |
| 560 | + | if int(ln) > budget.cells { | |
| 561 | + | return nil, fmt.Errorf("%w: too many cells", ErrInvalidData) | |
| 562 | + | } | |
| 563 | + | budget.cells -= int(ln) | |
| 564 | + | } | |
| 565 | + | cell := renderer.Cell{Rune: rune(rn), Style: styles[si], Continuation: flags&1 != 0} | |
| 566 | + | rowCells += int(ln) | |
| 567 | + | rows[i] = append(rows[i], make([]renderer.Cell, int(ln))...) | |
| 568 | + | for k := len(rows[i]) - int(ln); k < len(rows[i]); k++ { | |
| 569 | + | rows[i][k] = cell | |
| 570 | + | } | |
| 571 | + | } | |
| 572 | + | if rowCells == 0 { | |
| 573 | + | rows[i] = nil | |
| 574 | + | } | |
| 575 | + | } | |
| 576 | + | return rows, nil | |
| 577 | + | } | |
| 578 | + | ||
| 579 | + | type cellRun struct { | |
| 580 | + | len int | |
| 581 | + | cell renderer.Cell | |
| 582 | + | } | |
| 583 | + | ||
| 584 | + | func makeRuns(row []renderer.Cell) []cellRun { | |
| 585 | + | if len(row) == 0 { | |
| 586 | + | return nil | |
| 587 | + | } | |
| 588 | + | runs := []cellRun{{len: 1, cell: row[0]}} | |
| 589 | + | for _, c := range row[1:] { | |
| 590 | + | last := &runs[len(runs)-1] | |
| 591 | + | if last.cell.Equal(c) && last.len < math.MaxUint16 { | |
| 592 | + | last.len++ | |
| 593 | + | } else { | |
| 594 | + | runs = append(runs, cellRun{len: 1, cell: c}) | |
| 595 | + | } | |
| 596 | + | } | |
| 597 | + | return runs | |
| 598 | + | } | |
| 599 | + | ||
| 600 | + | func trimRows(rows [][]renderer.Cell, trimBlankRows bool) [][]renderer.Cell { | |
| 601 | + | out := make([][]renderer.Cell, len(rows)) | |
| 602 | + | for i, row := range rows { | |
| 603 | + | out[i] = trimTrailingBlankCells(row) | |
| 604 | + | } | |
| 605 | + | if trimBlankRows { | |
| 606 | + | for len(out) > 0 && len(out[len(out)-1]) == 0 { | |
| 607 | + | out = out[:len(out)-1] | |
| 608 | + | } | |
| 609 | + | } | |
| 610 | + | return out | |
| 611 | + | } | |
| 612 | + | ||
| 613 | + | func trimTrailingBlankCells(row []renderer.Cell) []renderer.Cell { | |
| 614 | + | end := len(row) | |
| 615 | + | for end > 0 && isBlank(row[end-1]) { | |
| 616 | + | end-- | |
| 617 | + | } | |
| 618 | + | return append([]renderer.Cell(nil), row[:end]...) | |
| 619 | + | } | |
| 620 | + | func isBlank(c renderer.Cell) bool { | |
| 621 | + | return !c.Continuation && c.Rune == ' ' && c.Style.Equal(renderer.DefaultStyle()) | |
| 622 | + | } | |
| 623 | + | ||
| 624 | + | func styleTable(p Pane) []renderer.Style { | |
| 625 | + | seen := map[renderer.Style]bool{} | |
| 626 | + | var styles []renderer.Style | |
| 627 | + | addRows := func(rows [][]renderer.Cell, trimBlankRows bool) { | |
| 628 | + | for _, row := range trimRows(rows, trimBlankRows) { | |
| 629 | + | for _, c := range row { | |
| 630 | + | if !seen[c.Style] { | |
| 631 | + | seen[c.Style] = true | |
| 632 | + | styles = append(styles, c.Style) | |
| 633 | + | } | |
| 634 | + | } | |
| 635 | + | } | |
| 636 | + | } | |
| 637 | + | addRows(p.Scrollback, false) | |
| 638 | + | addRows(p.Visible, true) | |
| 639 | + | return styles | |
| 640 | + | } | |
| 641 | + | ||
| 642 | + | func writeStyle(w *payloadWriter, s renderer.Style) { | |
| 643 | + | var flags uint8 | |
| 644 | + | if s.Bold { | |
| 645 | + | flags |= 1 | |
| 646 | + | } | |
| 647 | + | if s.Inverse { | |
| 648 | + | flags |= 2 | |
| 649 | + | } | |
| 650 | + | if s.HasForegroundRGB { | |
| 651 | + | flags |= 4 | |
| 652 | + | } | |
| 653 | + | if s.HasBackgroundRGB { | |
| 654 | + | flags |= 8 | |
| 655 | + | } | |
| 656 | + | w.putUint8(flags) | |
| 657 | + | w.putUint32(uint32(int32(s.Foreground))) | |
| 658 | + | w.putUint32(uint32(int32(s.Background))) | |
| 659 | + | w.putUint8(s.ForegroundRGB.R) | |
| 660 | + | w.putUint8(s.ForegroundRGB.G) | |
| 661 | + | w.putUint8(s.ForegroundRGB.B) | |
| 662 | + | w.putUint8(s.BackgroundRGB.R) | |
| 663 | + | w.putUint8(s.BackgroundRGB.G) | |
| 664 | + | w.putUint8(s.BackgroundRGB.B) | |
| 665 | + | } | |
| 666 | + | ||
| 667 | + | func readStyle(r *payloadReader) (renderer.Style, error) { | |
| 668 | + | flags, err := r.getUint8() | |
| 669 | + | if err != nil { | |
| 670 | + | return renderer.Style{}, err | |
| 671 | + | } | |
| 672 | + | fg, err := r.getUint32() | |
| 673 | + | if err != nil { | |
| 674 | + | return renderer.Style{}, err | |
| 675 | + | } | |
| 676 | + | bg, err := r.getUint32() | |
| 677 | + | if err != nil { | |
| 678 | + | return renderer.Style{}, err | |
| 679 | + | } | |
| 680 | + | fr, err := r.getUint8() | |
| 681 | + | if err != nil { | |
| 682 | + | return renderer.Style{}, err | |
| 683 | + | } | |
| 684 | + | fgc, err := r.getUint8() | |
| 685 | + | if err != nil { | |
| 686 | + | return renderer.Style{}, err | |
| 687 | + | } | |
| 688 | + | fb, err := r.getUint8() | |
| 689 | + | if err != nil { | |
| 690 | + | return renderer.Style{}, err | |
| 691 | + | } | |
| 692 | + | br, err := r.getUint8() | |
| 693 | + | if err != nil { | |
| 694 | + | return renderer.Style{}, err | |
| 695 | + | } | |
| 696 | + | bgc, err := r.getUint8() | |
| 697 | + | if err != nil { | |
| 698 | + | return renderer.Style{}, err | |
| 699 | + | } | |
| 700 | + | bb, err := r.getUint8() | |
| 701 | + | if err != nil { | |
| 702 | + | return renderer.Style{}, err | |
| 703 | + | } | |
| 704 | + | return renderer.Style{Bold: flags&1 != 0, Inverse: flags&2 != 0, HasForegroundRGB: flags&4 != 0, HasBackgroundRGB: flags&8 != 0, Foreground: int(int32(fg)), Background: int(int32(bg)), ForegroundRGB: renderer.RGB{R: fr, G: fgc, B: fb}, BackgroundRGB: renderer.RGB{R: br, G: bgc, B: bb}}, nil | |
| 705 | + | } | |
| 706 | + | ||
| 707 | + | func validateTree(t *layout.Tree, ids map[layout.PaneID]struct{}) error { | |
| 708 | + | if t == nil || t.Root == nil { | |
| 709 | + | return nil | |
| 710 | + | } | |
| 711 | + | return validateNode(t.Root, ids) | |
| 712 | + | } | |
| 713 | + | ||
| 714 | + | func validateNode(n *layout.Node, ids map[layout.PaneID]struct{}) error { | |
| 715 | + | if n == nil { | |
| 716 | + | return nil | |
| 717 | + | } | |
| 718 | + | switch n.Kind { | |
| 719 | + | case layout.Leaf: | |
| 720 | + | if _, ok := ids[n.Leaf]; !ok { | |
| 721 | + | return ErrUnknownPane | |
| 722 | + | } | |
| 723 | + | case layout.Split: | |
| 724 | + | if n.Dir != layout.Horizontal && n.Dir != layout.Vertical { | |
| 725 | + | return fmt.Errorf("%w: split dir", ErrInvalidData) | |
| 726 | + | } | |
| 727 | + | for _, c := range n.Children { | |
| 728 | + | if err := validateNode(c, ids); err != nil { | |
| 729 | + | return err | |
| 730 | + | } | |
| 731 | + | } | |
| 732 | + | case layout.Stack: | |
| 733 | + | if _, ok := ids[n.Expanded]; n.Expanded != "" && !ok { | |
| 734 | + | return ErrUnknownPane | |
| 735 | + | } | |
| 736 | + | for _, c := range n.Children { | |
| 737 | + | if err := validateNode(c, ids); err != nil { | |
| 738 | + | return err | |
| 739 | + | } | |
| 740 | + | } | |
| 741 | + | default: | |
| 742 | + | return fmt.Errorf("%w: node kind", ErrInvalidData) | |
| 743 | + | } | |
| 744 | + | return nil | |
| 745 | + | } | |
| @@ -0,0 +1,745 @@ | |||||
| 1 | + | package snapshot | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "compress/flate" | |||
| 6 | + | "encoding/binary" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "hash/crc32" | |||
| 10 | + | "io" | |||
| 11 | + | "math" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 14 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | var ( | |||
| 18 | + | ErrBadMagic = errors.New("snapshot: bad magic") | |||
| 19 | + | ErrBadVersion = errors.New("snapshot: bad version") | |||
| 20 | + | ErrBadCRC = errors.New("snapshot: bad crc") | |||
| 21 | + | ErrShortPayload = errors.New("snapshot: payload too short") | |||
| 22 | + | ErrTrailingBytes = errors.New("snapshot: trailing bytes") | |||
| 23 | + | ErrStyleOOB = errors.New("snapshot: style index out of range") | |||
| 24 | + | ErrUnknownPane = errors.New("snapshot: tree references unknown pane") | |||
| 25 | + | ErrInvalidData = errors.New("snapshot: invalid data") | |||
| 26 | + | ) | |||
| 27 | + | ||||
| 28 | + | const ( | |||
| 29 | + | magic = "VEVS" | |||
| 30 | + | version = uint16(1) | |||
| 31 | + | flagFlate = uint16(1 << 0) | |||
| 32 | + | flateCutoff = 4 << 10 | |||
| 33 | + | maxDecodedBodySize = 256 << 20 | |||
| 34 | + | maxDecodedRows = 1 << 20 | |||
| 35 | + | maxDecodedRuns = 16 << 20 | |||
| 36 | + | maxDecodedCells = 16 << 20 | |||
| 37 | + | ) | |||
| 38 | + | ||||
| 39 | + | // Marshal encodes a v1 durable snapshot. | |||
| 40 | + | func Marshal(s Session) ([]byte, error) { | |||
| 41 | + | var w payloadWriter | |||
| 42 | + | if err := writeSession(&w, s); err != nil { | |||
| 43 | + | return nil, err | |||
| 44 | + | } | |||
| 45 | + | body := w.b | |||
| 46 | + | flags := uint16(0) | |||
| 47 | + | if len(body) > flateCutoff { | |||
| 48 | + | var compressed bytes.Buffer | |||
| 49 | + | fw, err := flate.NewWriter(&compressed, flate.DefaultCompression) | |||
| 50 | + | if err != nil { | |||
| 51 | + | return nil, err | |||
| 52 | + | } | |||
| 53 | + | if _, err := fw.Write(body); err != nil { | |||
| 54 | + | _ = fw.Close() | |||
| 55 | + | return nil, err | |||
| 56 | + | } | |||
| 57 | + | if err := fw.Close(); err != nil { | |||
| 58 | + | return nil, err | |||
| 59 | + | } | |||
| 60 | + | body = compressed.Bytes() | |||
| 61 | + | flags |= flagFlate | |||
| 62 | + | } | |||
| 63 | + | if len(body) > math.MaxUint32 { | |||
| 64 | + | return nil, fmt.Errorf("%w: body too large", ErrInvalidData) | |||
| 65 | + | } | |||
| 66 | + | out := make([]byte, 16+len(body)) | |||
| 67 | + | copy(out[:4], magic) | |||
| 68 | + | binary.BigEndian.PutUint16(out[4:6], version) | |||
| 69 | + | binary.BigEndian.PutUint16(out[6:8], flags) | |||
| 70 | + | binary.BigEndian.PutUint32(out[8:12], uint32(len(body))) | |||
| 71 | + | binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body)) | |||
| 72 | + | copy(out[16:], body) | |||
| 73 | + | return out, nil | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // Unmarshal decodes a strict v1 durable snapshot. | |||
| 77 | + | func Unmarshal(b []byte) (Session, error) { | |||
| 78 | + | if len(b) < 16 { | |||
| 79 | + | return Session{}, ErrShortPayload | |||
| 80 | + | } | |||
| 81 | + | if string(b[:4]) != magic { | |||
| 82 | + | return Session{}, ErrBadMagic | |||
| 83 | + | } | |||
| 84 | + | if binary.BigEndian.Uint16(b[4:6]) != version { | |||
| 85 | + | return Session{}, ErrBadVersion | |||
| 86 | + | } | |||
| 87 | + | flags := binary.BigEndian.Uint16(b[6:8]) | |||
| 88 | + | if flags&^flagFlate != 0 { | |||
| 89 | + | return Session{}, fmt.Errorf("%w: unknown flags", ErrInvalidData) | |||
| 90 | + | } | |||
| 91 | + | bodyLen := int(binary.BigEndian.Uint32(b[8:12])) | |||
| 92 | + | if bodyLen > maxDecodedBodySize { | |||
| 93 | + | return Session{}, fmt.Errorf("%w: body too large", ErrInvalidData) | |||
| 94 | + | } | |||
| 95 | + | if len(b) < 16+bodyLen { | |||
| 96 | + | return Session{}, ErrShortPayload | |||
| 97 | + | } | |||
| 98 | + | if len(b) != 16+bodyLen { | |||
| 99 | + | return Session{}, ErrTrailingBytes | |||
| 100 | + | } | |||
| 101 | + | body := b[16:] | |||
| 102 | + | if crc32.ChecksumIEEE(body) != binary.BigEndian.Uint32(b[12:16]) { | |||
| 103 | + | return Session{}, ErrBadCRC | |||
| 104 | + | } | |||
| 105 | + | if flags&flagFlate != 0 { | |||
| 106 | + | fr := flate.NewReader(bytes.NewReader(body)) | |||
| 107 | + | decompressed, err := io.ReadAll(io.LimitReader(fr, maxDecodedBodySize+1)) | |||
| 108 | + | cerr := fr.Close() | |||
| 109 | + | if err != nil { | |||
| 110 | + | return Session{}, err | |||
| 111 | + | } | |||
| 112 | + | if cerr != nil { | |||
| 113 | + | return Session{}, cerr | |||
| 114 | + | } | |||
| 115 | + | if len(decompressed) > maxDecodedBodySize { | |||
| 116 | + | return Session{}, fmt.Errorf("%w: decompressed body too large", ErrInvalidData) | |||
| 117 | + | } | |||
| 118 | + | body = decompressed | |||
| 119 | + | } | |||
| 120 | + | r := payloadReader{b: body} | |||
| 121 | + | s, err := readSession(&r) | |||
| 122 | + | if err != nil { | |||
| 123 | + | return Session{}, err | |||
| 124 | + | } | |||
| 125 | + | if err := r.done(); err != nil { | |||
| 126 | + | return Session{}, err | |||
| 127 | + | } | |||
| 128 | + | for _, tab := range s.Tabs { | |||
| 129 | + | ids := make(map[layout.PaneID]struct{}, len(tab.Panes)) | |||
| 130 | + | for _, p := range tab.Panes { | |||
| 131 | + | ids[p.ID] = struct{}{} | |||
| 132 | + | } | |||
| 133 | + | if _, ok := ids[tab.Focus]; tab.Focus != "" && !ok { | |||
| 134 | + | return Session{}, ErrUnknownPane | |||
| 135 | + | } | |||
| 136 | + | if err := validateTree(tab.Tree, ids); err != nil { | |||
| 137 | + | return Session{}, err | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | return s, nil | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | type payloadWriter struct{ b []byte } | |||
| 144 | + | ||||
| 145 | + | func (w *payloadWriter) putUint8(v uint8) { w.b = append(w.b, v) } | |||
| 146 | + | func (w *payloadWriter) putUint16(v uint16) { | |||
| 147 | + | var t [2]byte | |||
| 148 | + | binary.BigEndian.PutUint16(t[:], v) | |||
| 149 | + | w.b = append(w.b, t[:]...) | |||
| 150 | + | } | |||
| 151 | + | func (w *payloadWriter) putUint32(v uint32) { | |||
| 152 | + | var t [4]byte | |||
| 153 | + | binary.BigEndian.PutUint32(t[:], v) | |||
| 154 | + | w.b = append(w.b, t[:]...) | |||
| 155 | + | } | |||
| 156 | + | func (w *payloadWriter) putUint64(v uint64) { | |||
| 157 | + | var t [8]byte | |||
| 158 | + | binary.BigEndian.PutUint64(t[:], v) | |||
| 159 | + | w.b = append(w.b, t[:]...) | |||
| 160 | + | } | |||
| 161 | + | func (w *payloadWriter) putString(s string) error { | |||
| 162 | + | if len(s) > math.MaxUint16 { | |||
| 163 | + | return fmt.Errorf("%w: string too long", ErrInvalidData) | |||
| 164 | + | } | |||
| 165 | + | w.putUint16(uint16(len(s))) | |||
| 166 | + | w.b = append(w.b, s...) | |||
| 167 | + | return nil | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | type payloadReader struct{ b []byte } | |||
| 171 | + | ||||
| 172 | + | func (r *payloadReader) getUint8() (uint8, error) { | |||
| 173 | + | if len(r.b) < 1 { | |||
| 174 | + | return 0, ErrShortPayload | |||
| 175 | + | } | |||
| 176 | + | v := r.b[0] | |||
| 177 | + | r.b = r.b[1:] | |||
| 178 | + | return v, nil | |||
| 179 | + | } | |||
| 180 | + | func (r *payloadReader) getUint16() (uint16, error) { | |||
| 181 | + | if len(r.b) < 2 { | |||
| 182 | + | return 0, ErrShortPayload | |||
| 183 | + | } | |||
| 184 | + | v := binary.BigEndian.Uint16(r.b) | |||
| 185 | + | r.b = r.b[2:] | |||
| 186 | + | return v, nil | |||
| 187 | + | } | |||
| 188 | + | func (r *payloadReader) getUint32() (uint32, error) { | |||
| 189 | + | if len(r.b) < 4 { | |||
| 190 | + | return 0, ErrShortPayload | |||
| 191 | + | } | |||
| 192 | + | v := binary.BigEndian.Uint32(r.b) | |||
| 193 | + | r.b = r.b[4:] | |||
| 194 | + | return v, nil | |||
| 195 | + | } | |||
| 196 | + | func (r *payloadReader) getUint64() (uint64, error) { | |||
| 197 | + | if len(r.b) < 8 { | |||
| 198 | + | return 0, ErrShortPayload | |||
| 199 | + | } | |||
| 200 | + | v := binary.BigEndian.Uint64(r.b) | |||
| 201 | + | r.b = r.b[8:] | |||
| 202 | + | return v, nil | |||
| 203 | + | } | |||
| 204 | + | func (r *payloadReader) getString() (string, error) { | |||
| 205 | + | n, err := r.getUint16() | |||
| 206 | + | if err != nil { | |||
| 207 | + | return "", err | |||
| 208 | + | } | |||
| 209 | + | if len(r.b) < int(n) { | |||
| 210 | + | return "", ErrShortPayload | |||
| 211 | + | } | |||
| 212 | + | s := string(r.b[:n]) | |||
| 213 | + | r.b = r.b[n:] | |||
| 214 | + | return s, nil | |||
| 215 | + | } | |||
| 216 | + | func (r *payloadReader) done() error { | |||
| 217 | + | if len(r.b) != 0 { | |||
| 218 | + | return ErrTrailingBytes | |||
| 219 | + | } | |||
| 220 | + | return nil | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | func writeSession(w *payloadWriter, s Session) error { | |||
| 224 | + | if err := w.putString(s.Name); err != nil { | |||
| 225 | + | return err | |||
| 226 | + | } | |||
| 227 | + | w.putUint64(s.CreatedAt) | |||
| 228 | + | w.putUint16(s.Active) | |||
| 229 | + | if len(s.Tabs) > math.MaxUint16 { | |||
| 230 | + | return fmt.Errorf("%w: too many tabs", ErrInvalidData) | |||
| 231 | + | } | |||
| 232 | + | w.putUint16(uint16(len(s.Tabs))) | |||
| 233 | + | for _, t := range s.Tabs { | |||
| 234 | + | if err := writeTab(w, t); err != nil { | |||
| 235 | + | return err | |||
| 236 | + | } | |||
| 237 | + | } | |||
| 238 | + | return nil | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | func readSession(r *payloadReader) (Session, error) { | |||
| 242 | + | name, err := r.getString() | |||
| 243 | + | if err != nil { | |||
| 244 | + | return Session{}, err | |||
| 245 | + | } | |||
| 246 | + | created, err := r.getUint64() | |||
| 247 | + | if err != nil { | |||
| 248 | + | return Session{}, err | |||
| 249 | + | } | |||
| 250 | + | active, err := r.getUint16() | |||
| 251 | + | if err != nil { | |||
| 252 | + | return Session{}, err | |||
| 253 | + | } | |||
| 254 | + | nt, err := r.getUint16() | |||
| 255 | + | if err != nil { | |||
| 256 | + | return Session{}, err | |||
| 257 | + | } | |||
| 258 | + | s := Session{Name: name, CreatedAt: created, Active: active, Tabs: make([]Tab, nt)} | |||
| 259 | + | for i := range s.Tabs { | |||
| 260 | + | t, err := readTab(r) | |||
| 261 | + | if err != nil { | |||
| 262 | + | return Session{}, err | |||
| 263 | + | } | |||
| 264 | + | s.Tabs[i] = t | |||
| 265 | + | } | |||
| 266 | + | return s, nil | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | func writeTab(w *payloadWriter, t Tab) error { | |||
| 270 | + | w.putUint16(t.Cols) | |||
| 271 | + | w.putUint16(t.Rows) | |||
| 272 | + | w.putUint64(t.NextPaneID) | |||
| 273 | + | if err := w.putString(string(t.Focus)); err != nil { | |||
| 274 | + | return err | |||
| 275 | + | } | |||
| 276 | + | if t.Tree == nil { | |||
| 277 | + | t.Tree = &layout.Tree{Focus: t.Focus} | |||
| 278 | + | } | |||
| 279 | + | if err := writeNode(w, t.Tree.Root); err != nil { | |||
| 280 | + | return err | |||
| 281 | + | } | |||
| 282 | + | if len(t.Panes) > math.MaxUint16 { | |||
| 283 | + | return fmt.Errorf("%w: too many panes", ErrInvalidData) | |||
| 284 | + | } | |||
| 285 | + | w.putUint16(uint16(len(t.Panes))) | |||
| 286 | + | for _, p := range t.Panes { | |||
| 287 | + | if err := writePane(w, p); err != nil { | |||
| 288 | + | return err | |||
| 289 | + | } | |||
| 290 | + | } | |||
| 291 | + | return nil | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | func readTab(r *payloadReader) (Tab, error) { | |||
| 295 | + | cols, err := r.getUint16() | |||
| 296 | + | if err != nil { | |||
| 297 | + | return Tab{}, err | |||
| 298 | + | } | |||
| 299 | + | rows, err := r.getUint16() | |||
| 300 | + | if err != nil { | |||
| 301 | + | return Tab{}, err | |||
| 302 | + | } | |||
| 303 | + | next, err := r.getUint64() | |||
| 304 | + | if err != nil { | |||
| 305 | + | return Tab{}, err | |||
| 306 | + | } | |||
| 307 | + | focus, err := r.getString() | |||
| 308 | + | if err != nil { | |||
| 309 | + | return Tab{}, err | |||
| 310 | + | } | |||
| 311 | + | root, err := readNode(r) | |||
| 312 | + | if err != nil { | |||
| 313 | + | return Tab{}, err | |||
| 314 | + | } | |||
| 315 | + | np, err := r.getUint16() | |||
| 316 | + | if err != nil { | |||
| 317 | + | return Tab{}, err | |||
| 318 | + | } | |||
| 319 | + | t := Tab{Cols: cols, Rows: rows, NextPaneID: next, Focus: layout.PaneID(focus), Tree: &layout.Tree{Root: root, Focus: layout.PaneID(focus)}, Panes: make([]Pane, np)} | |||
| 320 | + | for i := range t.Panes { | |||
| 321 | + | p, err := readPane(r) | |||
| 322 | + | if err != nil { | |||
| 323 | + | return Tab{}, err | |||
| 324 | + | } | |||
| 325 | + | t.Panes[i] = p | |||
| 326 | + | } | |||
| 327 | + | return t, nil | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | func writeNode(w *payloadWriter, n *layout.Node) error { | |||
| 331 | + | if n == nil { | |||
| 332 | + | w.putUint8(0) | |||
| 333 | + | return w.putString("") | |||
| 334 | + | } | |||
| 335 | + | switch n.Kind { | |||
| 336 | + | case layout.Leaf: | |||
| 337 | + | w.putUint8(0) | |||
| 338 | + | return w.putString(string(n.Leaf)) | |||
| 339 | + | case layout.Split: | |||
| 340 | + | w.putUint8(1) | |||
| 341 | + | w.putUint8(uint8(n.Dir)) | |||
| 342 | + | return writeChildren(w, n.Children) | |||
| 343 | + | case layout.Stack: | |||
| 344 | + | w.putUint8(2) | |||
| 345 | + | if err := w.putString(string(n.Expanded)); err != nil { | |||
| 346 | + | return err | |||
| 347 | + | } | |||
| 348 | + | return writeChildren(w, n.Children) | |||
| 349 | + | default: | |||
| 350 | + | return fmt.Errorf("%w: unknown node kind", ErrInvalidData) | |||
| 351 | + | } | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | func writeChildren(w *payloadWriter, children []*layout.Node) error { | |||
| 355 | + | if len(children) > math.MaxUint16 { | |||
| 356 | + | return fmt.Errorf("%w: too many children", ErrInvalidData) | |||
| 357 | + | } | |||
| 358 | + | w.putUint16(uint16(len(children))) | |||
| 359 | + | for _, c := range children { | |||
| 360 | + | if err := writeNode(w, c); err != nil { | |||
| 361 | + | return err | |||
| 362 | + | } | |||
| 363 | + | } | |||
| 364 | + | return nil | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | func readNode(r *payloadReader) (*layout.Node, error) { | |||
| 368 | + | kind, err := r.getUint8() | |||
| 369 | + | if err != nil { | |||
| 370 | + | return nil, err | |||
| 371 | + | } | |||
| 372 | + | switch kind { | |||
| 373 | + | case 0: | |||
| 374 | + | leaf, err := r.getString() | |||
| 375 | + | if err != nil { | |||
| 376 | + | return nil, err | |||
| 377 | + | } | |||
| 378 | + | return layout.NewLeaf(layout.PaneID(leaf)), nil | |||
| 379 | + | case 1: | |||
| 380 | + | dir, err := r.getUint8() | |||
| 381 | + | if err != nil { | |||
| 382 | + | return nil, err | |||
| 383 | + | } | |||
| 384 | + | children, err := readChildren(r) | |||
| 385 | + | if err != nil { | |||
| 386 | + | return nil, err | |||
| 387 | + | } | |||
| 388 | + | return &layout.Node{Kind: layout.Split, Dir: layout.SplitDir(dir), Children: children}, nil | |||
| 389 | + | case 2: | |||
| 390 | + | exp, err := r.getString() | |||
| 391 | + | if err != nil { | |||
| 392 | + | return nil, err | |||
| 393 | + | } | |||
| 394 | + | children, err := readChildren(r) | |||
| 395 | + | if err != nil { | |||
| 396 | + | return nil, err | |||
| 397 | + | } | |||
| 398 | + | return &layout.Node{Kind: layout.Stack, Expanded: layout.PaneID(exp), Children: children}, nil | |||
| 399 | + | default: | |||
| 400 | + | return nil, fmt.Errorf("%w: unknown node kind", ErrInvalidData) | |||
| 401 | + | } | |||
| 402 | + | } | |||
| 403 | + | ||||
| 404 | + | func readChildren(r *payloadReader) ([]*layout.Node, error) { | |||
| 405 | + | n, err := r.getUint16() | |||
| 406 | + | if err != nil { | |||
| 407 | + | return nil, err | |||
| 408 | + | } | |||
| 409 | + | out := make([]*layout.Node, n) | |||
| 410 | + | for i := range out { | |||
| 411 | + | c, err := readNode(r) | |||
| 412 | + | if err != nil { | |||
| 413 | + | return nil, err | |||
| 414 | + | } | |||
| 415 | + | out[i] = c | |||
| 416 | + | } | |||
| 417 | + | return out, nil | |||
| 418 | + | } | |||
| 419 | + | ||||
| 420 | + | func writePane(w *payloadWriter, p Pane) error { | |||
| 421 | + | if err := w.putString(string(p.ID)); err != nil { | |||
| 422 | + | return err | |||
| 423 | + | } | |||
| 424 | + | if err := w.putString(p.Cwd); err != nil { | |||
| 425 | + | return err | |||
| 426 | + | } | |||
| 427 | + | styles := styleTable(p) | |||
| 428 | + | if len(styles) > math.MaxUint16 { | |||
| 429 | + | return fmt.Errorf("%w: too many styles", ErrInvalidData) | |||
| 430 | + | } | |||
| 431 | + | w.putUint16(uint16(len(styles))) | |||
| 432 | + | for _, s := range styles { | |||
| 433 | + | writeStyle(w, s) | |||
| 434 | + | } | |||
| 435 | + | idx := make(map[renderer.Style]uint16, len(styles)) | |||
| 436 | + | for i, s := range styles { | |||
| 437 | + | idx[s] = uint16(i) | |||
| 438 | + | } | |||
| 439 | + | if err := writeRows(w, p.Scrollback, idx, false); err != nil { | |||
| 440 | + | return err | |||
| 441 | + | } | |||
| 442 | + | return writeRows(w, p.Visible, idx, true) | |||
| 443 | + | } | |||
| 444 | + | ||||
| 445 | + | func readPane(r *payloadReader) (Pane, error) { | |||
| 446 | + | id, err := r.getString() | |||
| 447 | + | if err != nil { | |||
| 448 | + | return Pane{}, err | |||
| 449 | + | } | |||
| 450 | + | cwd, err := r.getString() | |||
| 451 | + | if err != nil { | |||
| 452 | + | return Pane{}, err | |||
| 453 | + | } | |||
| 454 | + | ns, err := r.getUint16() | |||
| 455 | + | if err != nil { | |||
| 456 | + | return Pane{}, err | |||
| 457 | + | } | |||
| 458 | + | styles := make([]renderer.Style, ns) | |||
| 459 | + | for i := range styles { | |||
| 460 | + | s, err := readStyle(r) | |||
| 461 | + | if err != nil { | |||
| 462 | + | return Pane{}, err | |||
| 463 | + | } | |||
| 464 | + | styles[i] = s | |||
| 465 | + | } | |||
| 466 | + | budget := rowBudget{cells: maxDecodedCells, runs: maxDecodedRuns} | |||
| 467 | + | sb, err := readRows(r, styles, &budget) | |||
| 468 | + | if err != nil { | |||
| 469 | + | return Pane{}, err | |||
| 470 | + | } | |||
| 471 | + | vis, err := readRows(r, styles, &budget) | |||
| 472 | + | if err != nil { | |||
| 473 | + | return Pane{}, err | |||
| 474 | + | } | |||
| 475 | + | return Pane{ID: layout.PaneID(id), Cwd: cwd, Scrollback: sb, Visible: vis}, nil | |||
| 476 | + | } | |||
| 477 | + | ||||
| 478 | + | func writeRows(w *payloadWriter, rows [][]renderer.Cell, idx map[renderer.Style]uint16, trimBlankRows bool) error { | |||
| 479 | + | clean := trimRows(rows, trimBlankRows) | |||
| 480 | + | if len(clean) > math.MaxUint32 { | |||
| 481 | + | return fmt.Errorf("%w: too many rows", ErrInvalidData) | |||
| 482 | + | } | |||
| 483 | + | w.putUint32(uint32(len(clean))) | |||
| 484 | + | for _, row := range clean { | |||
| 485 | + | runs := makeRuns(row) | |||
| 486 | + | if len(runs) > math.MaxUint16 { | |||
| 487 | + | return fmt.Errorf("%w: too many runs", ErrInvalidData) | |||
| 488 | + | } | |||
| 489 | + | w.putUint16(uint16(len(runs))) | |||
| 490 | + | for _, run := range runs { | |||
| 491 | + | w.putUint16(uint16(run.len)) | |||
| 492 | + | w.putUint32(uint32(run.cell.Rune)) | |||
| 493 | + | w.putUint16(idx[run.cell.Style]) | |||
| 494 | + | var flags uint8 | |||
| 495 | + | if run.cell.Continuation { | |||
| 496 | + | flags |= 1 | |||
| 497 | + | } | |||
| 498 | + | w.putUint8(flags) | |||
| 499 | + | } | |||
| 500 | + | } | |||
| 501 | + | return nil | |||
| 502 | + | } | |||
| 503 | + | ||||
| 504 | + | type rowBudget struct { | |||
| 505 | + | cells int | |||
| 506 | + | runs int | |||
| 507 | + | } | |||
| 508 | + | ||||
| 509 | + | func readRows(r *payloadReader, styles []renderer.Style, budget *rowBudget) ([][]renderer.Cell, error) { | |||
| 510 | + | n, err := r.getUint32() | |||
| 511 | + | if err != nil { | |||
| 512 | + | return nil, err | |||
| 513 | + | } | |||
| 514 | + | if n > maxDecodedRows { | |||
| 515 | + | return nil, fmt.Errorf("%w: too many rows", ErrInvalidData) | |||
| 516 | + | } | |||
| 517 | + | if uint64(n) > uint64(len(r.b))/2 { | |||
| 518 | + | return nil, ErrShortPayload | |||
| 519 | + | } | |||
| 520 | + | rows := make([][]renderer.Cell, int(n)) | |||
| 521 | + | for i := range rows { | |||
| 522 | + | nr, err := r.getUint16() | |||
| 523 | + | if err != nil { | |||
| 524 | + | return nil, err | |||
| 525 | + | } | |||
| 526 | + | if budget != nil { | |||
| 527 | + | if int(nr) > budget.runs { | |||
| 528 | + | return nil, fmt.Errorf("%w: too many runs", ErrInvalidData) | |||
| 529 | + | } | |||
| 530 | + | budget.runs -= int(nr) | |||
| 531 | + | } | |||
| 532 | + | if uint64(nr) > uint64(len(r.b))/9 { | |||
| 533 | + | return nil, ErrShortPayload | |||
| 534 | + | } | |||
| 535 | + | rowCells := 0 | |||
| 536 | + | for j := 0; j < int(nr); j++ { | |||
| 537 | + | ln, err := r.getUint16() | |||
| 538 | + | if err != nil { | |||
| 539 | + | return nil, err | |||
| 540 | + | } | |||
| 541 | + | rn, err := r.getUint32() | |||
| 542 | + | if err != nil { | |||
| 543 | + | return nil, err | |||
| 544 | + | } | |||
| 545 | + | si, err := r.getUint16() | |||
| 546 | + | if err != nil { | |||
| 547 | + | return nil, err | |||
| 548 | + | } | |||
| 549 | + | flags, err := r.getUint8() | |||
| 550 | + | if err != nil { | |||
| 551 | + | return nil, err | |||
| 552 | + | } | |||
| 553 | + | if int(si) >= len(styles) { | |||
| 554 | + | return nil, ErrStyleOOB | |||
| 555 | + | } | |||
| 556 | + | if flags&^1 != 0 { | |||
| 557 | + | return nil, fmt.Errorf("%w: unknown cell flags", ErrInvalidData) | |||
| 558 | + | } | |||
| 559 | + | if budget != nil { | |||
| 560 | + | if int(ln) > budget.cells { | |||
| 561 | + | return nil, fmt.Errorf("%w: too many cells", ErrInvalidData) | |||
| 562 | + | } | |||
| 563 | + | budget.cells -= int(ln) | |||
| 564 | + | } | |||
| 565 | + | cell := renderer.Cell{Rune: rune(rn), Style: styles[si], Continuation: flags&1 != 0} | |||
| 566 | + | rowCells += int(ln) | |||
| 567 | + | rows[i] = append(rows[i], make([]renderer.Cell, int(ln))...) | |||
| 568 | + | for k := len(rows[i]) - int(ln); k < len(rows[i]); k++ { | |||
| 569 | + | rows[i][k] = cell | |||
| 570 | + | } | |||
| 571 | + | } | |||
| 572 | + | if rowCells == 0 { | |||
| 573 | + | rows[i] = nil | |||
| 574 | + | } | |||
| 575 | + | } | |||
| 576 | + | return rows, nil | |||
| 577 | + | } | |||
| 578 | + | ||||
| 579 | + | type cellRun struct { | |||
| 580 | + | len int | |||
| 581 | + | cell renderer.Cell | |||
| 582 | + | } | |||
| 583 | + | ||||
| 584 | + | func makeRuns(row []renderer.Cell) []cellRun { | |||
| 585 | + | if len(row) == 0 { | |||
| 586 | + | return nil | |||
| 587 | + | } | |||
| 588 | + | runs := []cellRun{{len: 1, cell: row[0]}} | |||
| 589 | + | for _, c := range row[1:] { | |||
| 590 | + | last := &runs[len(runs)-1] | |||
| 591 | + | if last.cell.Equal(c) && last.len < math.MaxUint16 { | |||
| 592 | + | last.len++ | |||
| 593 | + | } else { | |||
| 594 | + | runs = append(runs, cellRun{len: 1, cell: c}) | |||
| 595 | + | } | |||
| 596 | + | } | |||
| 597 | + | return runs | |||
| 598 | + | } | |||
| 599 | + | ||||
| 600 | + | func trimRows(rows [][]renderer.Cell, trimBlankRows bool) [][]renderer.Cell { | |||
| 601 | + | out := make([][]renderer.Cell, len(rows)) | |||
| 602 | + | for i, row := range rows { | |||
| 603 | + | out[i] = trimTrailingBlankCells(row) | |||
| 604 | + | } | |||
| 605 | + | if trimBlankRows { | |||
| 606 | + | for len(out) > 0 && len(out[len(out)-1]) == 0 { | |||
| 607 | + | out = out[:len(out)-1] | |||
| 608 | + | } | |||
| 609 | + | } | |||
| 610 | + | return out | |||
| 611 | + | } | |||
| 612 | + | ||||
| 613 | + | func trimTrailingBlankCells(row []renderer.Cell) []renderer.Cell { | |||
| 614 | + | end := len(row) | |||
| 615 | + | for end > 0 && isBlank(row[end-1]) { | |||
| 616 | + | end-- | |||
| 617 | + | } | |||
| 618 | + | return append([]renderer.Cell(nil), row[:end]...) | |||
| 619 | + | } | |||
| 620 | + | func isBlank(c renderer.Cell) bool { | |||
| 621 | + | return !c.Continuation && c.Rune == ' ' && c.Style.Equal(renderer.DefaultStyle()) | |||
| 622 | + | } | |||
| 623 | + | ||||
| 624 | + | func styleTable(p Pane) []renderer.Style { | |||
| 625 | + | seen := map[renderer.Style]bool{} | |||
| 626 | + | var styles []renderer.Style | |||
| 627 | + | addRows := func(rows [][]renderer.Cell, trimBlankRows bool) { | |||
| 628 | + | for _, row := range trimRows(rows, trimBlankRows) { | |||
| 629 | + | for _, c := range row { | |||
| 630 | + | if !seen[c.Style] { | |||
| 631 | + | seen[c.Style] = true | |||
| 632 | + | styles = append(styles, c.Style) | |||
| 633 | + | } | |||
| 634 | + | } | |||
| 635 | + | } | |||
| 636 | + | } | |||
| 637 | + | addRows(p.Scrollback, false) | |||
| 638 | + | addRows(p.Visible, true) | |||
| 639 | + | return styles | |||
| 640 | + | } | |||
| 641 | + | ||||
| 642 | + | func writeStyle(w *payloadWriter, s renderer.Style) { | |||
| 643 | + | var flags uint8 | |||
| 644 | + | if s.Bold { | |||
| 645 | + | flags |= 1 | |||
| 646 | + | } | |||
| 647 | + | if s.Inverse { | |||
| 648 | + | flags |= 2 | |||
| 649 | + | } | |||
| 650 | + | if s.HasForegroundRGB { | |||
| 651 | + | flags |= 4 | |||
| 652 | + | } | |||
| 653 | + | if s.HasBackgroundRGB { | |||
| 654 | + | flags |= 8 | |||
| 655 | + | } | |||
| 656 | + | w.putUint8(flags) | |||
| 657 | + | w.putUint32(uint32(int32(s.Foreground))) | |||
| 658 | + | w.putUint32(uint32(int32(s.Background))) | |||
| 659 | + | w.putUint8(s.ForegroundRGB.R) | |||
| 660 | + | w.putUint8(s.ForegroundRGB.G) | |||
| 661 | + | w.putUint8(s.ForegroundRGB.B) | |||
| 662 | + | w.putUint8(s.BackgroundRGB.R) | |||
| 663 | + | w.putUint8(s.BackgroundRGB.G) | |||
| 664 | + | w.putUint8(s.BackgroundRGB.B) | |||
| 665 | + | } | |||
| 666 | + | ||||
| 667 | + | func readStyle(r *payloadReader) (renderer.Style, error) { | |||
| 668 | + | flags, err := r.getUint8() | |||
| 669 | + | if err != nil { | |||
| 670 | + | return renderer.Style{}, err | |||
| 671 | + | } | |||
| 672 | + | fg, err := r.getUint32() | |||
| 673 | + | if err != nil { | |||
| 674 | + | return renderer.Style{}, err | |||
| 675 | + | } | |||
| 676 | + | bg, err := r.getUint32() | |||
| 677 | + | if err != nil { | |||
| 678 | + | return renderer.Style{}, err | |||
| 679 | + | } | |||
| 680 | + | fr, err := r.getUint8() | |||
| 681 | + | if err != nil { | |||
| 682 | + | return renderer.Style{}, err | |||
| 683 | + | } | |||
| 684 | + | fgc, err := r.getUint8() | |||
| 685 | + | if err != nil { | |||
| 686 | + | return renderer.Style{}, err | |||
| 687 | + | } | |||
| 688 | + | fb, err := r.getUint8() | |||
| 689 | + | if err != nil { | |||
| 690 | + | return renderer.Style{}, err | |||
| 691 | + | } | |||
| 692 | + | br, err := r.getUint8() | |||
| 693 | + | if err != nil { | |||
| 694 | + | return renderer.Style{}, err | |||
| 695 | + | } | |||
| 696 | + | bgc, err := r.getUint8() | |||
| 697 | + | if err != nil { | |||
| 698 | + | return renderer.Style{}, err | |||
| 699 | + | } | |||
| 700 | + | bb, err := r.getUint8() | |||
| 701 | + | if err != nil { | |||
| 702 | + | return renderer.Style{}, err | |||
| 703 | + | } | |||
| 704 | + | return renderer.Style{Bold: flags&1 != 0, Inverse: flags&2 != 0, HasForegroundRGB: flags&4 != 0, HasBackgroundRGB: flags&8 != 0, Foreground: int(int32(fg)), Background: int(int32(bg)), ForegroundRGB: renderer.RGB{R: fr, G: fgc, B: fb}, BackgroundRGB: renderer.RGB{R: br, G: bgc, B: bb}}, nil | |||
| 705 | + | } | |||
| 706 | + | ||||
| 707 | + | func validateTree(t *layout.Tree, ids map[layout.PaneID]struct{}) error { | |||
| 708 | + | if t == nil || t.Root == nil { | |||
| 709 | + | return nil | |||
| 710 | + | } | |||
| 711 | + | return validateNode(t.Root, ids) | |||
| 712 | + | } | |||
| 713 | + | ||||
| 714 | + | func validateNode(n *layout.Node, ids map[layout.PaneID]struct{}) error { | |||
| 715 | + | if n == nil { | |||
| 716 | + | return nil | |||
| 717 | + | } | |||
| 718 | + | switch n.Kind { | |||
| 719 | + | case layout.Leaf: | |||
| 720 | + | if _, ok := ids[n.Leaf]; !ok { | |||
| 721 | + | return ErrUnknownPane | |||
| 722 | + | } | |||
| 723 | + | case layout.Split: | |||
| 724 | + | if n.Dir != layout.Horizontal && n.Dir != layout.Vertical { | |||
| 725 | + | return fmt.Errorf("%w: split dir", ErrInvalidData) | |||
| 726 | + | } | |||
| 727 | + | for _, c := range n.Children { | |||
| 728 | + | if err := validateNode(c, ids); err != nil { | |||
| 729 | + | return err | |||
| 730 | + | } | |||
| 731 | + | } | |||
| 732 | + | case layout.Stack: | |||
| 733 | + | if _, ok := ids[n.Expanded]; n.Expanded != "" && !ok { | |||
| 734 | + | return ErrUnknownPane | |||
| 735 | + | } | |||
| 736 | + | for _, c := range n.Children { | |||
| 737 | + | if err := validateNode(c, ids); err != nil { | |||
| 738 | + | return err | |||
| 739 | + | } | |||
| 740 | + | } | |||
| 741 | + | default: | |||
| 742 | + | return fmt.Errorf("%w: node kind", ErrInvalidData) | |||
| 743 | + | } | |||
| 744 | + | return nil | |||
| 745 | + | } | |||
A internal/usecase/snapshot/codec_test.go +341 -0
| @@ -0,0 +1,341 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/binary" | |
| 6 | + | "errors" | |
| 7 | + | "hash/crc32" | |
| 8 | + | "math" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | + | "github.com/bnema/vev/pkg/renderer" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestMarshalMinimalGolden(t *testing.T) { | |
| 16 | + | got, err := Marshal(Session{Name: "s", CreatedAt: 7}) | |
| 17 | + | if err != nil { | |
| 18 | + | t.Fatalf("Marshal() error = %v", err) | |
| 19 | + | } | |
| 20 | + | want := []byte{ | |
| 21 | + | 'V', 'E', 'V', 'S', 0, 1, 0, 0, 0, 0, 0, 15, 0xff, 0xc2, 0x2e, 0xcb, | |
| 22 | + | 0, 1, 's', 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0, | |
| 23 | + | } | |
| 24 | + | if !bytes.Equal(got, want) { | |
| 25 | + | t.Fatalf("Marshal minimal bytes\ngot % x\nwant % x", got, want) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestRoundTripSessions(t *testing.T) { | |
| 30 | + | boldRGB := renderer.DefaultStyle() | |
| 31 | + | boldRGB.Bold = true | |
| 32 | + | boldRGB.HasForegroundRGB = true | |
| 33 | + | boldRGB.ForegroundRGB = renderer.RGB{R: 1, G: 2, B: 3} | |
| 34 | + | indexed := renderer.DefaultStyle() | |
| 35 | + | indexed.Foreground = 2 | |
| 36 | + | indexed.Background = 4 | |
| 37 | + | indexed.Inverse = true | |
| 38 | + | deepTree := &layout.Tree{Focus: "2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |
| 39 | + | layout.NewLeaf("1"), | |
| 40 | + | &layout.Node{Kind: layout.Stack, Expanded: "2", Children: []*layout.Node{layout.NewLeaf("2"), layout.NewLeaf("3")}}, | |
| 41 | + | }}} | |
| 42 | + | cjkVisible := [][]renderer.Cell{{ | |
| 43 | + | {Rune: '好', Style: boldRGB}, | |
| 44 | + | {Continuation: true, Style: boldRGB}, | |
| 45 | + | {Rune: 'x', Style: indexed}, | |
| 46 | + | }} | |
| 47 | + | multi := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{ | |
| 48 | + | {Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2", Tree: deepTree, Panes: []Pane{{ID: "1", Cwd: "/a", Scrollback: rows("abc"), Visible: rows("v")}, {ID: "2", Cwd: "/b", Visible: rows("focus")}, {ID: "3", Cwd: "/c"}}}, | |
| 49 | + | {Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", Visible: cjkVisible}}}, | |
| 50 | + | }} | |
| 51 | + | empty := Session{Name: "blank", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: nil, Visible: rows(" ", "")}}}}} | |
| 52 | + | large := Session{Name: "large", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: manyRows(6000), Visible: rows("tail")}}}}} | |
| 53 | + | cases := []struct { | |
| 54 | + | name string | |
| 55 | + | s Session | |
| 56 | + | }{ | |
| 57 | + | {name: "multi tab deep tree", s: multi}, | |
| 58 | + | {name: "empty and all blank", s: empty}, | |
| 59 | + | {name: "large scrollback", s: large}, | |
| 60 | + | } | |
| 61 | + | for _, tc := range cases { | |
| 62 | + | t.Run(tc.name, func(t *testing.T) { | |
| 63 | + | b, err := Marshal(tc.s) | |
| 64 | + | if err != nil { | |
| 65 | + | t.Fatalf("Marshal() error = %v", err) | |
| 66 | + | } | |
| 67 | + | got, err := Unmarshal(b) | |
| 68 | + | if err != nil { | |
| 69 | + | t.Fatalf("Unmarshal() error = %v", err) | |
| 70 | + | } | |
| 71 | + | if !equalSession(got, trimSession(tc.s)) { | |
| 72 | + | t.Fatalf("round trip mismatch\ngot %#v\nwant %#v", got, trimSession(tc.s)) | |
| 73 | + | } | |
| 74 | + | }) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func TestUnmarshalRejectsMalformedWithoutPanic(t *testing.T) { | |
| 79 | + | good, err := Marshal(Session{Name: "s", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p"}}}}}) | |
| 80 | + | if err != nil { | |
| 81 | + | t.Fatal(err) | |
| 82 | + | } | |
| 83 | + | cases := []struct { | |
| 84 | + | name string | |
| 85 | + | data []byte | |
| 86 | + | }{ | |
| 87 | + | {"bad magic", append([]byte("NOPE"), good[4:]...)}, | |
| 88 | + | {"bad version", replaceU16(good, 4, 2)}, | |
| 89 | + | {"bad crc", append([]byte(nil), good[:len(good)-1]...)}, | |
| 90 | + | {"trailing", append(append([]byte(nil), good...), 0)}, | |
| 91 | + | {"body len overrun", replaceU32(good, 8, 999999)}, | |
| 92 | + | {"prefix", good[:len(good)/2]}, | |
| 93 | + | {"style oob", styleOOBSnapshot(t)}, | |
| 94 | + | {"unknown pane in tree", unknownPaneSnapshot(t)}, | |
| 95 | + | } | |
| 96 | + | for _, tc := range cases { | |
| 97 | + | t.Run(tc.name, func(t *testing.T) { | |
| 98 | + | defer func() { | |
| 99 | + | if r := recover(); r != nil { | |
| 100 | + | t.Fatalf("Unmarshal panicked: %v", r) | |
| 101 | + | } | |
| 102 | + | }() | |
| 103 | + | if _, err := Unmarshal(tc.data); err == nil { | |
| 104 | + | t.Fatalf("Unmarshal() error = nil") | |
| 105 | + | } | |
| 106 | + | }) | |
| 107 | + | } | |
| 108 | + | for i := 0; i < len(good); i++ { | |
| 109 | + | if _, err := Unmarshal(good[:i]); err == nil { | |
| 110 | + | t.Fatalf("prefix length %d unexpectedly succeeded", i) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func TestUnmarshalRejectsOversizedBodiesBeforeAllocation(t *testing.T) { | |
| 116 | + | body := []byte{0} | |
| 117 | + | oversizedRaw := reheader(body, 0) | |
| 118 | + | binary.BigEndian.PutUint32(oversizedRaw[8:12], uint32(maxDecodedBodySize+1)) | |
| 119 | + | oversizedFlate := reheader(body, flagFlate) | |
| 120 | + | binary.BigEndian.PutUint32(oversizedFlate[8:12], uint32(maxDecodedBodySize+1)) | |
| 121 | + | cases := []struct { | |
| 122 | + | name string | |
| 123 | + | data []byte | |
| 124 | + | }{ | |
| 125 | + | {name: "raw", data: oversizedRaw}, | |
| 126 | + | {name: "flate", data: oversizedFlate}, | |
| 127 | + | } | |
| 128 | + | for _, tc := range cases { | |
| 129 | + | t.Run(tc.name, func(t *testing.T) { | |
| 130 | + | _, err := Unmarshal(tc.data) | |
| 131 | + | if !errors.Is(err, ErrInvalidData) { | |
| 132 | + | t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData) | |
| 133 | + | } | |
| 134 | + | }) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func TestUnmarshalRejectsMalformedRowCountsWithoutAllocation(t *testing.T) { | |
| 139 | + | cases := []struct { | |
| 140 | + | name string | |
| 141 | + | data []byte | |
| 142 | + | want error | |
| 143 | + | }{ | |
| 144 | + | {name: "huge row count", data: malformedRowsSnapshot(math.MaxUint32, 0), want: ErrInvalidData}, | |
| 145 | + | {name: "huge run count", data: malformedRowsSnapshot(1, math.MaxUint16), want: ErrShortPayload}, | |
| 146 | + | } | |
| 147 | + | for _, tc := range cases { | |
| 148 | + | t.Run(tc.name, func(t *testing.T) { | |
| 149 | + | defer func() { | |
| 150 | + | if r := recover(); r != nil { | |
| 151 | + | t.Fatalf("Unmarshal panicked: %v", r) | |
| 152 | + | } | |
| 153 | + | }() | |
| 154 | + | _, err := Unmarshal(tc.data) | |
| 155 | + | if !errors.Is(err, tc.want) { | |
| 156 | + | t.Fatalf("Unmarshal() error = %v, want %v", err, tc.want) | |
| 157 | + | } | |
| 158 | + | }) | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func TestFlateOnOff(t *testing.T) { | |
| 163 | + | small, err := Marshal(Session{Name: "small"}) | |
| 164 | + | if err != nil { | |
| 165 | + | t.Fatal(err) | |
| 166 | + | } | |
| 167 | + | if flags := binary.BigEndian.Uint16(small[6:8]); flags != 0 { | |
| 168 | + | t.Fatalf("small flags = %#x, want 0", flags) | |
| 169 | + | } | |
| 170 | + | large, err := Marshal(Session{Name: "large", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Scrollback: manyRows(5000)}}}}}) | |
| 171 | + | if err != nil { | |
| 172 | + | t.Fatal(err) | |
| 173 | + | } | |
| 174 | + | if flags := binary.BigEndian.Uint16(large[6:8]); flags&1 == 0 { | |
| 175 | + | t.Fatalf("large flags = %#x, want flate bit", flags) | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func BenchmarkMarshal10KRows(b *testing.B) { | |
| 180 | + | s := Session{Name: "bench", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/tmp", Scrollback: manyRows(10000), Visible: rows("visible")}}}}} | |
| 181 | + | b.ReportAllocs() | |
| 182 | + | for i := 0; i < b.N; i++ { | |
| 183 | + | if _, err := Marshal(s); err != nil { | |
| 184 | + | b.Fatal(err) | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func rows(lines ...string) [][]renderer.Cell { | |
| 190 | + | out := make([][]renderer.Cell, len(lines)) | |
| 191 | + | for i, line := range lines { | |
| 192 | + | for _, r := range line { | |
| 193 | + | out[i] = append(out[i], renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | return out | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func manyRows(n int) [][]renderer.Cell { | |
| 200 | + | out := make([][]renderer.Cell, n) | |
| 201 | + | for i := range out { | |
| 202 | + | out[i] = rows("row data row data row data")[0] | |
| 203 | + | } | |
| 204 | + | return out | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | func equalSession(a, b Session) bool { | |
| 208 | + | if a.Name != b.Name || a.CreatedAt != b.CreatedAt || a.Active != b.Active || len(a.Tabs) != len(b.Tabs) { | |
| 209 | + | return false | |
| 210 | + | } | |
| 211 | + | for i := range a.Tabs { | |
| 212 | + | if !equalTab(a.Tabs[i], b.Tabs[i]) { | |
| 213 | + | return false | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | return true | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func equalTab(a, b Tab) bool { | |
| 220 | + | if a.Cols != b.Cols || a.Rows != b.Rows || a.NextPaneID != b.NextPaneID || a.Focus != b.Focus || len(a.Panes) != len(b.Panes) || !equalNode(a.Tree.Root, b.Tree.Root) { | |
| 221 | + | return false | |
| 222 | + | } | |
| 223 | + | for i := range a.Panes { | |
| 224 | + | if a.Panes[i].ID != b.Panes[i].ID || a.Panes[i].Cwd != b.Panes[i].Cwd || !equalRows(a.Panes[i].Scrollback, b.Panes[i].Scrollback) || !equalRows(a.Panes[i].Visible, b.Panes[i].Visible) { | |
| 225 | + | return false | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | return true | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | func equalNode(a, b *layout.Node) bool { | |
| 232 | + | if a == nil || b == nil { | |
| 233 | + | return a == b | |
| 234 | + | } | |
| 235 | + | if a.Kind != b.Kind || a.Dir != b.Dir || a.Leaf != b.Leaf || a.Expanded != b.Expanded || len(a.Children) != len(b.Children) { | |
| 236 | + | return false | |
| 237 | + | } | |
| 238 | + | for i := range a.Children { | |
| 239 | + | if !equalNode(a.Children[i], b.Children[i]) { | |
| 240 | + | return false | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | return true | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | func equalRows(a, b [][]renderer.Cell) bool { | |
| 247 | + | if len(a) != len(b) { | |
| 248 | + | return false | |
| 249 | + | } | |
| 250 | + | for i := range a { | |
| 251 | + | if len(a[i]) != len(b[i]) { | |
| 252 | + | return false | |
| 253 | + | } | |
| 254 | + | for j := range a[i] { | |
| 255 | + | if !a[i][j].Equal(b[i][j]) { | |
| 256 | + | return false | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | return true | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | func trimSession(s Session) Session { | |
| 264 | + | for ti := range s.Tabs { | |
| 265 | + | for pi := range s.Tabs[ti].Panes { | |
| 266 | + | s.Tabs[ti].Panes[pi].Scrollback = trimRows(s.Tabs[ti].Panes[pi].Scrollback, false) | |
| 267 | + | s.Tabs[ti].Panes[pi].Visible = trimRows(s.Tabs[ti].Panes[pi].Visible, true) | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | return s | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | func reheader(body []byte, flags uint16) []byte { | |
| 274 | + | out := make([]byte, 16+len(body)) | |
| 275 | + | copy(out[:4], []byte("VEVS")) | |
| 276 | + | binary.BigEndian.PutUint16(out[4:6], 1) | |
| 277 | + | binary.BigEndian.PutUint16(out[6:8], flags) | |
| 278 | + | binary.BigEndian.PutUint32(out[8:12], uint32(len(body))) | |
| 279 | + | binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body)) | |
| 280 | + | copy(out[16:], body) | |
| 281 | + | return out | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | func replaceU16(b []byte, off int, v uint16) []byte { | |
| 285 | + | out := append([]byte(nil), b...) | |
| 286 | + | binary.BigEndian.PutUint16(out[off:], v) | |
| 287 | + | return out | |
| 288 | + | } | |
| 289 | + | func replaceU32(b []byte, off int, v uint32) []byte { | |
| 290 | + | out := append([]byte(nil), b...) | |
| 291 | + | binary.BigEndian.PutUint32(out[off:], v) | |
| 292 | + | return out | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | func malformedRowsSnapshot(rowCount uint32, runCount uint16) []byte { | |
| 296 | + | var w payloadWriter | |
| 297 | + | _ = w.putString("x") | |
| 298 | + | w.putUint64(0) | |
| 299 | + | w.putUint16(0) | |
| 300 | + | w.putUint16(1) | |
| 301 | + | w.putUint16(0) | |
| 302 | + | w.putUint16(0) | |
| 303 | + | w.putUint64(0) | |
| 304 | + | _ = w.putString("p") | |
| 305 | + | _ = writeNode(&w, layout.NewLeaf("p")) | |
| 306 | + | w.putUint16(1) | |
| 307 | + | _ = w.putString("p") | |
| 308 | + | _ = w.putString("") | |
| 309 | + | w.putUint16(0) | |
| 310 | + | w.putUint32(rowCount) | |
| 311 | + | if rowCount > 0 { | |
| 312 | + | w.putUint16(runCount) | |
| 313 | + | } | |
| 314 | + | return reheader(w.b, 0) | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | func styleOOBSnapshot(t *testing.T) []byte { | |
| 318 | + | b, err := Marshal(Session{Name: "x", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Visible: rows("x")}}}}}) | |
| 319 | + | if err != nil { | |
| 320 | + | t.Fatal(err) | |
| 321 | + | } | |
| 322 | + | bodyLen := int(binary.BigEndian.Uint32(b[8:12])) | |
| 323 | + | body := append([]byte(nil), b[16:16+bodyLen]...) | |
| 324 | + | for i := 0; i < len(body)-5; i++ { // run len=1,rune='x', style=0,cflags=0 | |
| 325 | + | if body[i] == 0 && body[i+1] == 1 && body[i+2] == 0 && body[i+3] == 0 && body[i+4] == 0 && body[i+5] == 'x' { | |
| 326 | + | body[i+6] = 0 | |
| 327 | + | body[i+7] = 2 | |
| 328 | + | return reheader(body, 0) | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | t.Fatal("run not found") | |
| 332 | + | return nil | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | func unknownPaneSnapshot(t *testing.T) []byte { | |
| 336 | + | b, err := Marshal(Session{Name: "x", Tabs: []Tab{{Tree: layout.NewTree("missing"), Focus: "missing", Panes: []Pane{{ID: "p"}}}}}) | |
| 337 | + | if err != nil { | |
| 338 | + | t.Fatal(err) | |
| 339 | + | } | |
| 340 | + | return b | |
| 341 | + | } | |
| @@ -0,0 +1,341 @@ | |||||
| 1 | + | package snapshot | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "encoding/binary" | |||
| 6 | + | "errors" | |||
| 7 | + | "hash/crc32" | |||
| 8 | + | "math" | |||
| 9 | + | "testing" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 12 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestMarshalMinimalGolden(t *testing.T) { | |||
| 16 | + | got, err := Marshal(Session{Name: "s", CreatedAt: 7}) | |||
| 17 | + | if err != nil { | |||
| 18 | + | t.Fatalf("Marshal() error = %v", err) | |||
| 19 | + | } | |||
| 20 | + | want := []byte{ | |||
| 21 | + | 'V', 'E', 'V', 'S', 0, 1, 0, 0, 0, 0, 0, 15, 0xff, 0xc2, 0x2e, 0xcb, | |||
| 22 | + | 0, 1, 's', 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0, | |||
| 23 | + | } | |||
| 24 | + | if !bytes.Equal(got, want) { | |||
| 25 | + | t.Fatalf("Marshal minimal bytes\ngot % x\nwant % x", got, want) | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestRoundTripSessions(t *testing.T) { | |||
| 30 | + | boldRGB := renderer.DefaultStyle() | |||
| 31 | + | boldRGB.Bold = true | |||
| 32 | + | boldRGB.HasForegroundRGB = true | |||
| 33 | + | boldRGB.ForegroundRGB = renderer.RGB{R: 1, G: 2, B: 3} | |||
| 34 | + | indexed := renderer.DefaultStyle() | |||
| 35 | + | indexed.Foreground = 2 | |||
| 36 | + | indexed.Background = 4 | |||
| 37 | + | indexed.Inverse = true | |||
| 38 | + | deepTree := &layout.Tree{Focus: "2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |||
| 39 | + | layout.NewLeaf("1"), | |||
| 40 | + | &layout.Node{Kind: layout.Stack, Expanded: "2", Children: []*layout.Node{layout.NewLeaf("2"), layout.NewLeaf("3")}}, | |||
| 41 | + | }}} | |||
| 42 | + | cjkVisible := [][]renderer.Cell{{ | |||
| 43 | + | {Rune: '好', Style: boldRGB}, | |||
| 44 | + | {Continuation: true, Style: boldRGB}, | |||
| 45 | + | {Rune: 'x', Style: indexed}, | |||
| 46 | + | }} | |||
| 47 | + | multi := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{ | |||
| 48 | + | {Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2", Tree: deepTree, Panes: []Pane{{ID: "1", Cwd: "/a", Scrollback: rows("abc"), Visible: rows("v")}, {ID: "2", Cwd: "/b", Visible: rows("focus")}, {ID: "3", Cwd: "/c"}}}, | |||
| 49 | + | {Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", Visible: cjkVisible}}}, | |||
| 50 | + | }} | |||
| 51 | + | empty := Session{Name: "blank", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: nil, Visible: rows(" ", "")}}}}} | |||
| 52 | + | large := Session{Name: "large", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: manyRows(6000), Visible: rows("tail")}}}}} | |||
| 53 | + | cases := []struct { | |||
| 54 | + | name string | |||
| 55 | + | s Session | |||
| 56 | + | }{ | |||
| 57 | + | {name: "multi tab deep tree", s: multi}, | |||
| 58 | + | {name: "empty and all blank", s: empty}, | |||
| 59 | + | {name: "large scrollback", s: large}, | |||
| 60 | + | } | |||
| 61 | + | for _, tc := range cases { | |||
| 62 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 63 | + | b, err := Marshal(tc.s) | |||
| 64 | + | if err != nil { | |||
| 65 | + | t.Fatalf("Marshal() error = %v", err) | |||
| 66 | + | } | |||
| 67 | + | got, err := Unmarshal(b) | |||
| 68 | + | if err != nil { | |||
| 69 | + | t.Fatalf("Unmarshal() error = %v", err) | |||
| 70 | + | } | |||
| 71 | + | if !equalSession(got, trimSession(tc.s)) { | |||
| 72 | + | t.Fatalf("round trip mismatch\ngot %#v\nwant %#v", got, trimSession(tc.s)) | |||
| 73 | + | } | |||
| 74 | + | }) | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func TestUnmarshalRejectsMalformedWithoutPanic(t *testing.T) { | |||
| 79 | + | good, err := Marshal(Session{Name: "s", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p"}}}}}) | |||
| 80 | + | if err != nil { | |||
| 81 | + | t.Fatal(err) | |||
| 82 | + | } | |||
| 83 | + | cases := []struct { | |||
| 84 | + | name string | |||
| 85 | + | data []byte | |||
| 86 | + | }{ | |||
| 87 | + | {"bad magic", append([]byte("NOPE"), good[4:]...)}, | |||
| 88 | + | {"bad version", replaceU16(good, 4, 2)}, | |||
| 89 | + | {"bad crc", append([]byte(nil), good[:len(good)-1]...)}, | |||
| 90 | + | {"trailing", append(append([]byte(nil), good...), 0)}, | |||
| 91 | + | {"body len overrun", replaceU32(good, 8, 999999)}, | |||
| 92 | + | {"prefix", good[:len(good)/2]}, | |||
| 93 | + | {"style oob", styleOOBSnapshot(t)}, | |||
| 94 | + | {"unknown pane in tree", unknownPaneSnapshot(t)}, | |||
| 95 | + | } | |||
| 96 | + | for _, tc := range cases { | |||
| 97 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 98 | + | defer func() { | |||
| 99 | + | if r := recover(); r != nil { | |||
| 100 | + | t.Fatalf("Unmarshal panicked: %v", r) | |||
| 101 | + | } | |||
| 102 | + | }() | |||
| 103 | + | if _, err := Unmarshal(tc.data); err == nil { | |||
| 104 | + | t.Fatalf("Unmarshal() error = nil") | |||
| 105 | + | } | |||
| 106 | + | }) | |||
| 107 | + | } | |||
| 108 | + | for i := 0; i < len(good); i++ { | |||
| 109 | + | if _, err := Unmarshal(good[:i]); err == nil { | |||
| 110 | + | t.Fatalf("prefix length %d unexpectedly succeeded", i) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func TestUnmarshalRejectsOversizedBodiesBeforeAllocation(t *testing.T) { | |||
| 116 | + | body := []byte{0} | |||
| 117 | + | oversizedRaw := reheader(body, 0) | |||
| 118 | + | binary.BigEndian.PutUint32(oversizedRaw[8:12], uint32(maxDecodedBodySize+1)) | |||
| 119 | + | oversizedFlate := reheader(body, flagFlate) | |||
| 120 | + | binary.BigEndian.PutUint32(oversizedFlate[8:12], uint32(maxDecodedBodySize+1)) | |||
| 121 | + | cases := []struct { | |||
| 122 | + | name string | |||
| 123 | + | data []byte | |||
| 124 | + | }{ | |||
| 125 | + | {name: "raw", data: oversizedRaw}, | |||
| 126 | + | {name: "flate", data: oversizedFlate}, | |||
| 127 | + | } | |||
| 128 | + | for _, tc := range cases { | |||
| 129 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 130 | + | _, err := Unmarshal(tc.data) | |||
| 131 | + | if !errors.Is(err, ErrInvalidData) { | |||
| 132 | + | t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData) | |||
| 133 | + | } | |||
| 134 | + | }) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func TestUnmarshalRejectsMalformedRowCountsWithoutAllocation(t *testing.T) { | |||
| 139 | + | cases := []struct { | |||
| 140 | + | name string | |||
| 141 | + | data []byte | |||
| 142 | + | want error | |||
| 143 | + | }{ | |||
| 144 | + | {name: "huge row count", data: malformedRowsSnapshot(math.MaxUint32, 0), want: ErrInvalidData}, | |||
| 145 | + | {name: "huge run count", data: malformedRowsSnapshot(1, math.MaxUint16), want: ErrShortPayload}, | |||
| 146 | + | } | |||
| 147 | + | for _, tc := range cases { | |||
| 148 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 149 | + | defer func() { | |||
| 150 | + | if r := recover(); r != nil { | |||
| 151 | + | t.Fatalf("Unmarshal panicked: %v", r) | |||
| 152 | + | } | |||
| 153 | + | }() | |||
| 154 | + | _, err := Unmarshal(tc.data) | |||
| 155 | + | if !errors.Is(err, tc.want) { | |||
| 156 | + | t.Fatalf("Unmarshal() error = %v, want %v", err, tc.want) | |||
| 157 | + | } | |||
| 158 | + | }) | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func TestFlateOnOff(t *testing.T) { | |||
| 163 | + | small, err := Marshal(Session{Name: "small"}) | |||
| 164 | + | if err != nil { | |||
| 165 | + | t.Fatal(err) | |||
| 166 | + | } | |||
| 167 | + | if flags := binary.BigEndian.Uint16(small[6:8]); flags != 0 { | |||
| 168 | + | t.Fatalf("small flags = %#x, want 0", flags) | |||
| 169 | + | } | |||
| 170 | + | large, err := Marshal(Session{Name: "large", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Scrollback: manyRows(5000)}}}}}) | |||
| 171 | + | if err != nil { | |||
| 172 | + | t.Fatal(err) | |||
| 173 | + | } | |||
| 174 | + | if flags := binary.BigEndian.Uint16(large[6:8]); flags&1 == 0 { | |||
| 175 | + | t.Fatalf("large flags = %#x, want flate bit", flags) | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func BenchmarkMarshal10KRows(b *testing.B) { | |||
| 180 | + | s := Session{Name: "bench", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/tmp", Scrollback: manyRows(10000), Visible: rows("visible")}}}}} | |||
| 181 | + | b.ReportAllocs() | |||
| 182 | + | for i := 0; i < b.N; i++ { | |||
| 183 | + | if _, err := Marshal(s); err != nil { | |||
| 184 | + | b.Fatal(err) | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func rows(lines ...string) [][]renderer.Cell { | |||
| 190 | + | out := make([][]renderer.Cell, len(lines)) | |||
| 191 | + | for i, line := range lines { | |||
| 192 | + | for _, r := range line { | |||
| 193 | + | out[i] = append(out[i], renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |||
| 194 | + | } | |||
| 195 | + | } | |||
| 196 | + | return out | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | func manyRows(n int) [][]renderer.Cell { | |||
| 200 | + | out := make([][]renderer.Cell, n) | |||
| 201 | + | for i := range out { | |||
| 202 | + | out[i] = rows("row data row data row data")[0] | |||
| 203 | + | } | |||
| 204 | + | return out | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | func equalSession(a, b Session) bool { | |||
| 208 | + | if a.Name != b.Name || a.CreatedAt != b.CreatedAt || a.Active != b.Active || len(a.Tabs) != len(b.Tabs) { | |||
| 209 | + | return false | |||
| 210 | + | } | |||
| 211 | + | for i := range a.Tabs { | |||
| 212 | + | if !equalTab(a.Tabs[i], b.Tabs[i]) { | |||
| 213 | + | return false | |||
| 214 | + | } | |||
| 215 | + | } | |||
| 216 | + | return true | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | func equalTab(a, b Tab) bool { | |||
| 220 | + | if a.Cols != b.Cols || a.Rows != b.Rows || a.NextPaneID != b.NextPaneID || a.Focus != b.Focus || len(a.Panes) != len(b.Panes) || !equalNode(a.Tree.Root, b.Tree.Root) { | |||
| 221 | + | return false | |||
| 222 | + | } | |||
| 223 | + | for i := range a.Panes { | |||
| 224 | + | if a.Panes[i].ID != b.Panes[i].ID || a.Panes[i].Cwd != b.Panes[i].Cwd || !equalRows(a.Panes[i].Scrollback, b.Panes[i].Scrollback) || !equalRows(a.Panes[i].Visible, b.Panes[i].Visible) { | |||
| 225 | + | return false | |||
| 226 | + | } | |||
| 227 | + | } | |||
| 228 | + | return true | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | func equalNode(a, b *layout.Node) bool { | |||
| 232 | + | if a == nil || b == nil { | |||
| 233 | + | return a == b | |||
| 234 | + | } | |||
| 235 | + | if a.Kind != b.Kind || a.Dir != b.Dir || a.Leaf != b.Leaf || a.Expanded != b.Expanded || len(a.Children) != len(b.Children) { | |||
| 236 | + | return false | |||
| 237 | + | } | |||
| 238 | + | for i := range a.Children { | |||
| 239 | + | if !equalNode(a.Children[i], b.Children[i]) { | |||
| 240 | + | return false | |||
| 241 | + | } | |||
| 242 | + | } | |||
| 243 | + | return true | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | func equalRows(a, b [][]renderer.Cell) bool { | |||
| 247 | + | if len(a) != len(b) { | |||
| 248 | + | return false | |||
| 249 | + | } | |||
| 250 | + | for i := range a { | |||
| 251 | + | if len(a[i]) != len(b[i]) { | |||
| 252 | + | return false | |||
| 253 | + | } | |||
| 254 | + | for j := range a[i] { | |||
| 255 | + | if !a[i][j].Equal(b[i][j]) { | |||
| 256 | + | return false | |||
| 257 | + | } | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | return true | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | func trimSession(s Session) Session { | |||
| 264 | + | for ti := range s.Tabs { | |||
| 265 | + | for pi := range s.Tabs[ti].Panes { | |||
| 266 | + | s.Tabs[ti].Panes[pi].Scrollback = trimRows(s.Tabs[ti].Panes[pi].Scrollback, false) | |||
| 267 | + | s.Tabs[ti].Panes[pi].Visible = trimRows(s.Tabs[ti].Panes[pi].Visible, true) | |||
| 268 | + | } | |||
| 269 | + | } | |||
| 270 | + | return s | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | func reheader(body []byte, flags uint16) []byte { | |||
| 274 | + | out := make([]byte, 16+len(body)) | |||
| 275 | + | copy(out[:4], []byte("VEVS")) | |||
| 276 | + | binary.BigEndian.PutUint16(out[4:6], 1) | |||
| 277 | + | binary.BigEndian.PutUint16(out[6:8], flags) | |||
| 278 | + | binary.BigEndian.PutUint32(out[8:12], uint32(len(body))) | |||
| 279 | + | binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body)) | |||
| 280 | + | copy(out[16:], body) | |||
| 281 | + | return out | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | func replaceU16(b []byte, off int, v uint16) []byte { | |||
| 285 | + | out := append([]byte(nil), b...) | |||
| 286 | + | binary.BigEndian.PutUint16(out[off:], v) | |||
| 287 | + | return out | |||
| 288 | + | } | |||
| 289 | + | func replaceU32(b []byte, off int, v uint32) []byte { | |||
| 290 | + | out := append([]byte(nil), b...) | |||
| 291 | + | binary.BigEndian.PutUint32(out[off:], v) | |||
| 292 | + | return out | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | func malformedRowsSnapshot(rowCount uint32, runCount uint16) []byte { | |||
| 296 | + | var w payloadWriter | |||
| 297 | + | _ = w.putString("x") | |||
| 298 | + | w.putUint64(0) | |||
| 299 | + | w.putUint16(0) | |||
| 300 | + | w.putUint16(1) | |||
| 301 | + | w.putUint16(0) | |||
| 302 | + | w.putUint16(0) | |||
| 303 | + | w.putUint64(0) | |||
| 304 | + | _ = w.putString("p") | |||
| 305 | + | _ = writeNode(&w, layout.NewLeaf("p")) | |||
| 306 | + | w.putUint16(1) | |||
| 307 | + | _ = w.putString("p") | |||
| 308 | + | _ = w.putString("") | |||
| 309 | + | w.putUint16(0) | |||
| 310 | + | w.putUint32(rowCount) | |||
| 311 | + | if rowCount > 0 { | |||
| 312 | + | w.putUint16(runCount) | |||
| 313 | + | } | |||
| 314 | + | return reheader(w.b, 0) | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | func styleOOBSnapshot(t *testing.T) []byte { | |||
| 318 | + | b, err := Marshal(Session{Name: "x", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Visible: rows("x")}}}}}) | |||
| 319 | + | if err != nil { | |||
| 320 | + | t.Fatal(err) | |||
| 321 | + | } | |||
| 322 | + | bodyLen := int(binary.BigEndian.Uint32(b[8:12])) | |||
| 323 | + | body := append([]byte(nil), b[16:16+bodyLen]...) | |||
| 324 | + | for i := 0; i < len(body)-5; i++ { // run len=1,rune='x', style=0,cflags=0 | |||
| 325 | + | if body[i] == 0 && body[i+1] == 1 && body[i+2] == 0 && body[i+3] == 0 && body[i+4] == 0 && body[i+5] == 'x' { | |||
| 326 | + | body[i+6] = 0 | |||
| 327 | + | body[i+7] = 2 | |||
| 328 | + | return reheader(body, 0) | |||
| 329 | + | } | |||
| 330 | + | } | |||
| 331 | + | t.Fatal("run not found") | |||
| 332 | + | return nil | |||
| 333 | + | } | |||
| 334 | + | ||||
| 335 | + | func unknownPaneSnapshot(t *testing.T) []byte { | |||
| 336 | + | b, err := Marshal(Session{Name: "x", Tabs: []Tab{{Tree: layout.NewTree("missing"), Focus: "missing", Panes: []Pane{{ID: "p"}}}}}) | |||
| 337 | + | if err != nil { | |||
| 338 | + | t.Fatal(err) | |||
| 339 | + | } | |||
| 340 | + | return b | |||
| 341 | + | } | |||
A internal/usecase/snapshot/snapshot.go +33 -0
| @@ -0,0 +1,33 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 5 | + | "github.com/bnema/vev/pkg/renderer" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // Session is the durable transfer representation for a named daemon session. | |
| 9 | + | type Session struct { | |
| 10 | + | Name string | |
| 11 | + | CreatedAt uint64 | |
| 12 | + | Active uint16 | |
| 13 | + | Tabs []Tab | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // Tab captures one tab's layout, dimensions, and pane snapshots. | |
| 17 | + | type Tab struct { | |
| 18 | + | Cols uint16 | |
| 19 | + | Rows uint16 | |
| 20 | + | NextPaneID uint64 | |
| 21 | + | Focus layout.PaneID | |
| 22 | + | Tree *layout.Tree | |
| 23 | + | Panes []Pane | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // Pane captures terminal state needed to restore a fresh shell in the same cwd | |
| 27 | + | // with its scrollback and final visible primary screen. | |
| 28 | + | type Pane struct { | |
| 29 | + | ID layout.PaneID | |
| 30 | + | Cwd string | |
| 31 | + | Scrollback [][]renderer.Cell | |
| 32 | + | Visible [][]renderer.Cell | |
| 33 | + | } | |
| @@ -0,0 +1,33 @@ | |||||
| 1 | + | package snapshot | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 5 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // Session is the durable transfer representation for a named daemon session. | |||
| 9 | + | type Session struct { | |||
| 10 | + | Name string | |||
| 11 | + | CreatedAt uint64 | |||
| 12 | + | Active uint16 | |||
| 13 | + | Tabs []Tab | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | // Tab captures one tab's layout, dimensions, and pane snapshots. | |||
| 17 | + | type Tab struct { | |||
| 18 | + | Cols uint16 | |||
| 19 | + | Rows uint16 | |||
| 20 | + | NextPaneID uint64 | |||
| 21 | + | Focus layout.PaneID | |||
| 22 | + | Tree *layout.Tree | |||
| 23 | + | Panes []Pane | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // Pane captures terminal state needed to restore a fresh shell in the same cwd | |||
| 27 | + | // with its scrollback and final visible primary screen. | |||
| 28 | + | type Pane struct { | |||
| 29 | + | ID layout.PaneID | |||
| 30 | + | Cwd string | |||
| 31 | + | Scrollback [][]renderer.Cell | |||
| 32 | + | Visible [][]renderer.Cell | |||
| 33 | + | } | |||
A pkg/vt/snapshot.go +18 -0
| @@ -0,0 +1,18 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/pkg/renderer" | |
| 4 | + | ||
| 5 | + | // PrimaryVisibleRows returns a deep copy of the visible primary-screen rows. | |
| 6 | + | // When the alternate screen is active, it snapshots the saved primary screen | |
| 7 | + | // rather than the currently displayed alternate frame. | |
| 8 | + | func (s *Screen) PrimaryVisibleRows() [][]renderer.Cell { | |
| 9 | + | frame := s.Frame | |
| 10 | + | if s.alternate != nil { | |
| 11 | + | frame = s.alternate.frame | |
| 12 | + | } | |
| 13 | + | rows := make([][]renderer.Cell, frame.Height) | |
| 14 | + | for y := range frame.Height { | |
| 15 | + | rows[y] = append([]renderer.Cell(nil), frame.Row(y)...) | |
| 16 | + | } | |
| 17 | + | return rows | |
| 18 | + | } | |
| @@ -0,0 +1,18 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/pkg/renderer" | |||
| 4 | + | ||||
| 5 | + | // PrimaryVisibleRows returns a deep copy of the visible primary-screen rows. | |||
| 6 | + | // When the alternate screen is active, it snapshots the saved primary screen | |||
| 7 | + | // rather than the currently displayed alternate frame. | |||
| 8 | + | func (s *Screen) PrimaryVisibleRows() [][]renderer.Cell { | |||
| 9 | + | frame := s.Frame | |||
| 10 | + | if s.alternate != nil { | |||
| 11 | + | frame = s.alternate.frame | |||
| 12 | + | } | |||
| 13 | + | rows := make([][]renderer.Cell, frame.Height) | |||
| 14 | + | for y := range frame.Height { | |||
| 15 | + | rows[y] = append([]renderer.Cell(nil), frame.Row(y)...) | |||
| 16 | + | } | |||
| 17 | + | return rows | |||
| 18 | + | } | |||
A pkg/vt/snapshot_test.go +39 -0
| @@ -0,0 +1,39 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestPrimaryVisibleRowsCopiesActivePrimaryScreen(t *testing.T) { | |
| 11 | + | s := NewScreen(6, 2) | |
| 12 | + | s.Write([]byte("hello")) | |
| 13 | + | ||
| 14 | + | rows := s.PrimaryVisibleRows() | |
| 15 | + | require.Len(t, rows, 2) | |
| 16 | + | require.Equal(t, "hello ", rowString(rows[0])) | |
| 17 | + | ||
| 18 | + | rows[0][0].Rune = 'X' | |
| 19 | + | rows[0] = nil | |
| 20 | + | ||
| 21 | + | require.Equal(t, "hello ", rowString(s.Frame.Row(0))) | |
| 22 | + | require.Equal(t, "hello ", rowString(s.PrimaryVisibleRows()[0])) | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func TestPrimaryVisibleRowsUsesSavedPrimaryScreenWhenAlternateActive(t *testing.T) { | |
| 26 | + | s := NewScreen(8, 2) | |
| 27 | + | s.Write([]byte("primary")) | |
| 28 | + | s.Write([]byte("\x1b[?1049h")) | |
| 29 | + | s.Write([]byte("alt")) | |
| 30 | + | ||
| 31 | + | rows := s.PrimaryVisibleRows() | |
| 32 | + | require.Len(t, rows, 2) | |
| 33 | + | require.Equal(t, "primary ", rowString(rows[0])) | |
| 34 | + | require.False(t, strings.Contains(rowString(rows[0]), "alt")) | |
| 35 | + | ||
| 36 | + | rows[0][0].Rune = 'X' | |
| 37 | + | s.Write([]byte("\x1b[?1049l")) | |
| 38 | + | require.Equal(t, "primary ", rowString(s.Frame.Row(0))) | |
| 39 | + | } | |
| @@ -0,0 +1,39 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestPrimaryVisibleRowsCopiesActivePrimaryScreen(t *testing.T) { | |||
| 11 | + | s := NewScreen(6, 2) | |||
| 12 | + | s.Write([]byte("hello")) | |||
| 13 | + | ||||
| 14 | + | rows := s.PrimaryVisibleRows() | |||
| 15 | + | require.Len(t, rows, 2) | |||
| 16 | + | require.Equal(t, "hello ", rowString(rows[0])) | |||
| 17 | + | ||||
| 18 | + | rows[0][0].Rune = 'X' | |||
| 19 | + | rows[0] = nil | |||
| 20 | + | ||||
| 21 | + | require.Equal(t, "hello ", rowString(s.Frame.Row(0))) | |||
| 22 | + | require.Equal(t, "hello ", rowString(s.PrimaryVisibleRows()[0])) | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func TestPrimaryVisibleRowsUsesSavedPrimaryScreenWhenAlternateActive(t *testing.T) { | |||
| 26 | + | s := NewScreen(8, 2) | |||
| 27 | + | s.Write([]byte("primary")) | |||
| 28 | + | s.Write([]byte("\x1b[?1049h")) | |||
| 29 | + | s.Write([]byte("alt")) | |||
| 30 | + | ||||
| 31 | + | rows := s.PrimaryVisibleRows() | |||
| 32 | + | require.Len(t, rows, 2) | |||
| 33 | + | require.Equal(t, "primary ", rowString(rows[0])) | |||
| 34 | + | require.False(t, strings.Contains(rowString(rows[0]), "alt")) | |||
| 35 | + | ||||
| 36 | + | rows[0][0].Rune = 'X' | |||
| 37 | + | s.Write([]byte("\x1b[?1049l")) | |||
| 38 | + | require.Equal(t, "primary ", rowString(s.Frame.Row(0))) | |||
| 39 | + | } | |||