refactor(snapshot): remove monolithic persistence contract
- commit
- 73a5ab3e0608c3343d0c6340b69bfba23daeb1ec
- author
- brice · · 2026-07-21 22:11 UTC
- parent
- 8aaf1e5
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
21 files · +101 -3152
M internal/adapters/snapshot/legacy.go +2 -3
| @@ -13,9 +13,8 @@ import ( | |||
| 13 | 13 | "github.com/bnema/vev/internal/ports" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | - | // LoadLegacy reads only pre-incremental root .snap files. Store remains the | |
| 17 | - | // legacy writer until the migration cutover, but is deliberately not used by | |
| 18 | - | // this read path so incremental repository directories are never traversed. | |
| 16 | + | // LoadLegacy reads only pre-incremental root .snap files. It is deliberately | |
| 17 | + | // isolated from repository reads so incremental directories are never traversed. | |
| 19 | 18 | func (r *Repository) LoadLegacy(ctx context.Context) ([]ports.LegacySnapshot, error) { | |
| 20 | 19 | if err := ctx.Err(); err != nil { | |
| 21 | 20 | return nil, err | |
| @@ -13,9 +13,8 @@ import ( | |||||
| 13 | "github.com/bnema/vev/internal/ports" | 13 | "github.com/bnema/vev/internal/ports" | ||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | - | // LoadLegacy reads only pre-incremental root .snap files. Store remains the | 16 | + | // LoadLegacy reads only pre-incremental root .snap files. It is deliberately |
| 17 | - | // legacy writer until the migration cutover, but is deliberately not used by | 17 | + | // isolated from repository reads so incremental directories are never traversed. |
| 18 | - | // this read path so incremental repository directories are never traversed. | |||
| 19 | func (r *Repository) LoadLegacy(ctx context.Context) ([]ports.LegacySnapshot, error) { | 18 | func (r *Repository) LoadLegacy(ctx context.Context) ([]ports.LegacySnapshot, error) { | ||
| 20 | if err := ctx.Err(); err != nil { | 19 | if err := ctx.Err(); err != nil { | ||
| 21 | return nil, err | 20 | return nil, err | ||
M internal/adapters/snapshot/path.go +15 -0
| @@ -5,12 +5,27 @@ import ( | |||
| 5 | 5 | "encoding/hex" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "path/filepath" | |
| 8 | + | "regexp" | |
| 8 | 9 | "strconv" | |
| 9 | 10 | "strings" | |
| 10 | 11 | ||
| 11 | 12 | "github.com/bnema/vev/internal/ports" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| 15 | + | var safeNameRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,200}$`) | |
| 16 | + | ||
| 17 | + | const maxSnapshotFileSize = 16 + (256 << 20) | |
| 18 | + | ||
| 19 | + | // filenameForName maps a session name to its deterministic legacy filename. | |
| 20 | + | // It remains only for the one-way v3 import path. | |
| 21 | + | func filenameForName(name string) string { | |
| 22 | + | if safeNameRE.MatchString(name) { | |
| 23 | + | return name + ".snap" | |
| 24 | + | } | |
| 25 | + | sum := sha256.Sum256([]byte(name)) | |
| 26 | + | return "@" + hex.EncodeToString(sum[:])[:40] + ".snap" | |
| 27 | + | } | |
| 28 | + | ||
| 14 | 29 | func (r *Repository) sessionPath(key string) string { | |
| 15 | 30 | return filepath.Join(r.dir, repositorySessionsDir, key) | |
| 16 | 31 | } | |
| @@ -5,12 +5,27 @@ import ( | |||||
| 5 | "encoding/hex" | 5 | "encoding/hex" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "path/filepath" | 7 | "path/filepath" | ||
| 8 | + | "regexp" | |||
| 8 | "strconv" | 9 | "strconv" | ||
| 9 | "strings" | 10 | "strings" | ||
| 10 | 11 | ||||
| 11 | "github.com/bnema/vev/internal/ports" | 12 | "github.com/bnema/vev/internal/ports" | ||
| 12 | ) | 13 | ) | ||
| 13 | 14 | ||||
| 15 | + | var safeNameRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,200}$`) | |||
| 16 | + | ||||
| 17 | + | const maxSnapshotFileSize = 16 + (256 << 20) | |||
| 18 | + | ||||
| 19 | + | // filenameForName maps a session name to its deterministic legacy filename. | |||
| 20 | + | // It remains only for the one-way v3 import path. | |||
| 21 | + | func filenameForName(name string) string { | |||
| 22 | + | if safeNameRE.MatchString(name) { | |||
| 23 | + | return name + ".snap" | |||
| 24 | + | } | |||
| 25 | + | sum := sha256.Sum256([]byte(name)) | |||
| 26 | + | return "@" + hex.EncodeToString(sum[:])[:40] + ".snap" | |||
| 27 | + | } | |||
| 28 | + | ||||
| 14 | func (r *Repository) sessionPath(key string) string { | 29 | func (r *Repository) sessionPath(key string) string { | ||
| 15 | return filepath.Join(r.dir, repositorySessionsDir, key) | 30 | return filepath.Join(r.dir, repositorySessionsDir, key) | ||
| 16 | } | 31 | } | ||
D internal/adapters/snapshot/store.go +0 -139
| @@ -1,139 +0,0 @@ | |||
| 1 | - | package snapshot | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "crypto/sha256" | |
| 5 | - | "encoding/hex" | |
| 6 | - | "errors" | |
| 7 | - | "fmt" | |
| 8 | - | "log/slog" | |
| 9 | - | "os" | |
| 10 | - | "path/filepath" | |
| 11 | - | "regexp" | |
| 12 | - | "strings" | |
| 13 | - | ||
| 14 | - | "github.com/bnema/vev/internal/ports" | |
| 15 | - | "github.com/bnema/vev/pkg/safedir" | |
| 16 | - | ) | |
| 17 | - | ||
| 18 | - | var safeNameRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,200}$`) | |
| 19 | - | ||
| 20 | - | const maxSnapshotFileSize = 16 + (256 << 20) | |
| 21 | - | ||
| 22 | - | // Store persists raw session snapshot blobs on disk. | |
| 23 | - | type Store struct { | |
| 24 | - | dir string | |
| 25 | - | log *slog.Logger | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | var _ ports.SnapshotStore = (*Store)(nil) | |
| 29 | - | ||
| 30 | - | // NewStore creates a file-backed snapshot store rooted at dir. | |
| 31 | - | func NewStore(dir string) *Store { | |
| 32 | - | return &Store{dir: dir, log: slog.Default()} | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | // Write atomically writes data for name. The previous snapshot remains in place | |
| 36 | - | // until the final rename succeeds. | |
| 37 | - | func (s *Store) Write(name string, data []byte) error { | |
| 38 | - | if err := safedir.EnsurePrivate(s.dir); err != nil { | |
| 39 | - | return fmt.Errorf("create snapshot dir: %w", err) | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | tmp, err := os.CreateTemp(s.dir, ".tmp-*") | |
| 43 | - | if err != nil { | |
| 44 | - | return fmt.Errorf("create temp snapshot: %w", err) | |
| 45 | - | } | |
| 46 | - | tmpName := tmp.Name() | |
| 47 | - | keepTmp := false | |
| 48 | - | defer func() { | |
| 49 | - | if !keepTmp { | |
| 50 | - | _ = os.Remove(tmpName) | |
| 51 | - | } | |
| 52 | - | }() | |
| 53 | - | ||
| 54 | - | if err := tmp.Chmod(0o600); err != nil { | |
| 55 | - | _ = tmp.Close() | |
| 56 | - | return fmt.Errorf("chmod temp snapshot: %w", err) | |
| 57 | - | } | |
| 58 | - | if _, err := tmp.Write(data); err != nil { | |
| 59 | - | _ = tmp.Close() | |
| 60 | - | return fmt.Errorf("write temp snapshot: %w", err) | |
| 61 | - | } | |
| 62 | - | if err := tmp.Sync(); err != nil { | |
| 63 | - | _ = tmp.Close() | |
| 64 | - | return fmt.Errorf("sync temp snapshot: %w", err) | |
| 65 | - | } | |
| 66 | - | if err := tmp.Close(); err != nil { | |
| 67 | - | return fmt.Errorf("close temp snapshot: %w", err) | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | path := filepath.Join(s.dir, filenameForName(name)) | |
| 71 | - | if err := os.Rename(tmpName, path); err != nil { | |
| 72 | - | return fmt.Errorf("rename snapshot: %w", err) | |
| 73 | - | } | |
| 74 | - | keepTmp = true | |
| 75 | - | if err := syncDirectory(s.dir); err != nil { | |
| 76 | - | return fmt.Errorf("sync snapshot dir: %w", err) | |
| 77 | - | } | |
| 78 | - | return nil | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | // Load returns all raw .snap blobs. It does not validate the blob format. | |
| 82 | - | func (s *Store) Load() ([]ports.SnapshotBlob, error) { | |
| 83 | - | entries, err := os.ReadDir(s.dir) | |
| 84 | - | if errors.Is(err, os.ErrNotExist) { | |
| 85 | - | return nil, nil | |
| 86 | - | } | |
| 87 | - | if err != nil { | |
| 88 | - | return nil, fmt.Errorf("read snapshot dir: %w", err) | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | blobs := make([]ports.SnapshotBlob, 0, len(entries)) | |
| 92 | - | for _, entry := range entries { | |
| 93 | - | base := entry.Name() | |
| 94 | - | path := filepath.Join(s.dir, base) | |
| 95 | - | if strings.HasPrefix(base, ".tmp-") { | |
| 96 | - | if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 97 | - | s.log.Warn("remove stale snapshot temp", "path", path, "err", err) | |
| 98 | - | } | |
| 99 | - | continue | |
| 100 | - | } | |
| 101 | - | if entry.IsDir() || !strings.HasSuffix(base, ".snap") { | |
| 102 | - | continue | |
| 103 | - | } | |
| 104 | - | data, err := readBounded(path) | |
| 105 | - | if err != nil { | |
| 106 | - | s.log.Warn("read snapshot", "path", path, "err", err) | |
| 107 | - | continue | |
| 108 | - | } | |
| 109 | - | name := strings.TrimSuffix(base, ".snap") | |
| 110 | - | if strings.HasPrefix(name, "@") || !safeNameRE.MatchString(name) { | |
| 111 | - | name = "" | |
| 112 | - | } | |
| 113 | - | blobs = append(blobs, ports.SnapshotBlob{Name: name, Data: data}) | |
| 114 | - | } | |
| 115 | - | return blobs, nil | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | // Delete removes the deterministic snapshot file for name. Missing files are ignored. | |
| 119 | - | func (s *Store) Delete(name string) error { | |
| 120 | - | err := os.Remove(filepath.Join(s.dir, filenameForName(name))) | |
| 121 | - | if errors.Is(err, os.ErrNotExist) { | |
| 122 | - | return nil | |
| 123 | - | } | |
| 124 | - | if err != nil { | |
| 125 | - | return fmt.Errorf("delete snapshot: %w", err) | |
| 126 | - | } | |
| 127 | - | if err := syncDirectory(s.dir); err != nil { | |
| 128 | - | return fmt.Errorf("sync snapshot dir: %w", err) | |
| 129 | - | } | |
| 130 | - | return nil | |
| 131 | - | } | |
| 132 | - | ||
| 133 | - | func filenameForName(name string) string { | |
| 134 | - | if safeNameRE.MatchString(name) { | |
| 135 | - | return name + ".snap" | |
| 136 | - | } | |
| 137 | - | sum := sha256.Sum256([]byte(name)) | |
| 138 | - | return "@" + hex.EncodeToString(sum[:])[:40] + ".snap" | |
| 139 | - | } | |
| @@ -1,139 +0,0 @@ | |||||
| 1 | - | package snapshot | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "crypto/sha256" | |||
| 5 | - | "encoding/hex" | |||
| 6 | - | "errors" | |||
| 7 | - | "fmt" | |||
| 8 | - | "log/slog" | |||
| 9 | - | "os" | |||
| 10 | - | "path/filepath" | |||
| 11 | - | "regexp" | |||
| 12 | - | "strings" | |||
| 13 | - | ||||
| 14 | - | "github.com/bnema/vev/internal/ports" | |||
| 15 | - | "github.com/bnema/vev/pkg/safedir" | |||
| 16 | - | ) | |||
| 17 | - | ||||
| 18 | - | var safeNameRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,200}$`) | |||
| 19 | - | ||||
| 20 | - | const maxSnapshotFileSize = 16 + (256 << 20) | |||
| 21 | - | ||||
| 22 | - | // Store persists raw session snapshot blobs on disk. | |||
| 23 | - | type Store struct { | |||
| 24 | - | dir string | |||
| 25 | - | log *slog.Logger | |||
| 26 | - | } | |||
| 27 | - | ||||
| 28 | - | var _ ports.SnapshotStore = (*Store)(nil) | |||
| 29 | - | ||||
| 30 | - | // NewStore creates a file-backed snapshot store rooted at dir. | |||
| 31 | - | func NewStore(dir string) *Store { | |||
| 32 | - | return &Store{dir: dir, log: slog.Default()} | |||
| 33 | - | } | |||
| 34 | - | ||||
| 35 | - | // Write atomically writes data for name. The previous snapshot remains in place | |||
| 36 | - | // until the final rename succeeds. | |||
| 37 | - | func (s *Store) Write(name string, data []byte) error { | |||
| 38 | - | if err := safedir.EnsurePrivate(s.dir); err != nil { | |||
| 39 | - | return fmt.Errorf("create snapshot dir: %w", err) | |||
| 40 | - | } | |||
| 41 | - | ||||
| 42 | - | tmp, err := os.CreateTemp(s.dir, ".tmp-*") | |||
| 43 | - | if err != nil { | |||
| 44 | - | return fmt.Errorf("create temp snapshot: %w", err) | |||
| 45 | - | } | |||
| 46 | - | tmpName := tmp.Name() | |||
| 47 | - | keepTmp := false | |||
| 48 | - | defer func() { | |||
| 49 | - | if !keepTmp { | |||
| 50 | - | _ = os.Remove(tmpName) | |||
| 51 | - | } | |||
| 52 | - | }() | |||
| 53 | - | ||||
| 54 | - | if err := tmp.Chmod(0o600); err != nil { | |||
| 55 | - | _ = tmp.Close() | |||
| 56 | - | return fmt.Errorf("chmod temp snapshot: %w", err) | |||
| 57 | - | } | |||
| 58 | - | if _, err := tmp.Write(data); err != nil { | |||
| 59 | - | _ = tmp.Close() | |||
| 60 | - | return fmt.Errorf("write temp snapshot: %w", err) | |||
| 61 | - | } | |||
| 62 | - | if err := tmp.Sync(); err != nil { | |||
| 63 | - | _ = tmp.Close() | |||
| 64 | - | return fmt.Errorf("sync temp snapshot: %w", err) | |||
| 65 | - | } | |||
| 66 | - | if err := tmp.Close(); err != nil { | |||
| 67 | - | return fmt.Errorf("close temp snapshot: %w", err) | |||
| 68 | - | } | |||
| 69 | - | ||||
| 70 | - | path := filepath.Join(s.dir, filenameForName(name)) | |||
| 71 | - | if err := os.Rename(tmpName, path); err != nil { | |||
| 72 | - | return fmt.Errorf("rename snapshot: %w", err) | |||
| 73 | - | } | |||
| 74 | - | keepTmp = true | |||
| 75 | - | if err := syncDirectory(s.dir); err != nil { | |||
| 76 | - | return fmt.Errorf("sync snapshot dir: %w", err) | |||
| 77 | - | } | |||
| 78 | - | return nil | |||
| 79 | - | } | |||
| 80 | - | ||||
| 81 | - | // Load returns all raw .snap blobs. It does not validate the blob format. | |||
| 82 | - | func (s *Store) Load() ([]ports.SnapshotBlob, error) { | |||
| 83 | - | entries, err := os.ReadDir(s.dir) | |||
| 84 | - | if errors.Is(err, os.ErrNotExist) { | |||
| 85 | - | return nil, nil | |||
| 86 | - | } | |||
| 87 | - | if err != nil { | |||
| 88 | - | return nil, fmt.Errorf("read snapshot dir: %w", err) | |||
| 89 | - | } | |||
| 90 | - | ||||
| 91 | - | blobs := make([]ports.SnapshotBlob, 0, len(entries)) | |||
| 92 | - | for _, entry := range entries { | |||
| 93 | - | base := entry.Name() | |||
| 94 | - | path := filepath.Join(s.dir, base) | |||
| 95 | - | if strings.HasPrefix(base, ".tmp-") { | |||
| 96 | - | if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |||
| 97 | - | s.log.Warn("remove stale snapshot temp", "path", path, "err", err) | |||
| 98 | - | } | |||
| 99 | - | continue | |||
| 100 | - | } | |||
| 101 | - | if entry.IsDir() || !strings.HasSuffix(base, ".snap") { | |||
| 102 | - | continue | |||
| 103 | - | } | |||
| 104 | - | data, err := readBounded(path) | |||
| 105 | - | if err != nil { | |||
| 106 | - | s.log.Warn("read snapshot", "path", path, "err", err) | |||
| 107 | - | continue | |||
| 108 | - | } | |||
| 109 | - | name := strings.TrimSuffix(base, ".snap") | |||
| 110 | - | if strings.HasPrefix(name, "@") || !safeNameRE.MatchString(name) { | |||
| 111 | - | name = "" | |||
| 112 | - | } | |||
| 113 | - | blobs = append(blobs, ports.SnapshotBlob{Name: name, Data: data}) | |||
| 114 | - | } | |||
| 115 | - | return blobs, nil | |||
| 116 | - | } | |||
| 117 | - | ||||
| 118 | - | // Delete removes the deterministic snapshot file for name. Missing files are ignored. | |||
| 119 | - | func (s *Store) Delete(name string) error { | |||
| 120 | - | err := os.Remove(filepath.Join(s.dir, filenameForName(name))) | |||
| 121 | - | if errors.Is(err, os.ErrNotExist) { | |||
| 122 | - | return nil | |||
| 123 | - | } | |||
| 124 | - | if err != nil { | |||
| 125 | - | return fmt.Errorf("delete snapshot: %w", err) | |||
| 126 | - | } | |||
| 127 | - | if err := syncDirectory(s.dir); err != nil { | |||
| 128 | - | return fmt.Errorf("sync snapshot dir: %w", err) | |||
| 129 | - | } | |||
| 130 | - | return nil | |||
| 131 | - | } | |||
| 132 | - | ||||
| 133 | - | func filenameForName(name string) string { | |||
| 134 | - | if safeNameRE.MatchString(name) { | |||
| 135 | - | return name + ".snap" | |||
| 136 | - | } | |||
| 137 | - | sum := sha256.Sum256([]byte(name)) | |||
| 138 | - | return "@" + hex.EncodeToString(sum[:])[:40] + ".snap" | |||
| 139 | - | } | |||
D internal/adapters/snapshot/store_test.go +0 -192
| @@ -1,192 +0,0 @@ | |||
| 1 | - | package snapshot | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "crypto/sha256" | |
| 5 | - | "encoding/hex" | |
| 6 | - | "errors" | |
| 7 | - | "os" | |
| 8 | - | "path/filepath" | |
| 9 | - | "runtime" | |
| 10 | - | "sort" | |
| 11 | - | "strings" | |
| 12 | - | "testing" | |
| 13 | - | ) | |
| 14 | - | ||
| 15 | - | func privateDir(t *testing.T) string { | |
| 16 | - | t.Helper() | |
| 17 | - | return filepath.Join(t.TempDir(), "vev") | |
| 18 | - | } | |
| 19 | - | ||
| 20 | - | func TestStoreWriteLoadRoundTripAndSupersede(t *testing.T) { | |
| 21 | - | t.Parallel() | |
| 22 | - | dir := privateDir(t) | |
| 23 | - | store := NewStore(dir) | |
| 24 | - | ||
| 25 | - | if err := store.Write("alpha", []byte("first")); err != nil { | |
| 26 | - | t.Fatalf("Write first: %v", err) | |
| 27 | - | } | |
| 28 | - | if err := store.Write("alpha", []byte("second")); err != nil { | |
| 29 | - | t.Fatalf("Write second: %v", err) | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | got := loadMap(t, store) | |
| 33 | - | if string(got["alpha"]) != "second" { | |
| 34 | - | t.Fatalf("loaded alpha = %q, want second", got["alpha"]) | |
| 35 | - | } | |
| 36 | - | info, err := os.Stat(filepath.Join(dir, "alpha.snap")) | |
| 37 | - | if err != nil { | |
| 38 | - | t.Fatalf("stat snapshot: %v", err) | |
| 39 | - | } | |
| 40 | - | if gotMode := info.Mode().Perm(); gotMode != 0o600 { | |
| 41 | - | t.Fatalf("snapshot mode = %v, want 0600", gotMode) | |
| 42 | - | } | |
| 43 | - | if dirInfo, err := os.Stat(dir); err != nil { | |
| 44 | - | t.Fatalf("stat dir: %v", err) | |
| 45 | - | } else if gotMode := dirInfo.Mode().Perm(); gotMode != 0o700 { | |
| 46 | - | t.Fatalf("dir mode = %v, want 0700", gotMode) | |
| 47 | - | } | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | func TestStoreDelete(t *testing.T) { | |
| 51 | - | t.Parallel() | |
| 52 | - | dir := privateDir(t) | |
| 53 | - | store := NewStore(dir) | |
| 54 | - | ||
| 55 | - | if err := store.Write("gone", []byte("data")); err != nil { | |
| 56 | - | t.Fatalf("Write: %v", err) | |
| 57 | - | } | |
| 58 | - | if err := store.Delete("gone"); err != nil { | |
| 59 | - | t.Fatalf("Delete existing: %v", err) | |
| 60 | - | } | |
| 61 | - | if err := store.Delete("gone"); err != nil { | |
| 62 | - | t.Fatalf("Delete missing: %v", err) | |
| 63 | - | } | |
| 64 | - | if got := loadMap(t, store); len(got) != 0 { | |
| 65 | - | t.Fatalf("Load after delete = %#v, want empty", got) | |
| 66 | - | } | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | func TestStoreUnsafeNameUsesDeterministicHash(t *testing.T) { | |
| 70 | - | t.Parallel() | |
| 71 | - | dir := privateDir(t) | |
| 72 | - | store := NewStore(dir) | |
| 73 | - | name := "../unsafe/session name" | |
| 74 | - | ||
| 75 | - | if err := store.Write(name, []byte("raw")); err != nil { | |
| 76 | - | t.Fatalf("Write unsafe: %v", err) | |
| 77 | - | } | |
| 78 | - | sum := sha256.Sum256([]byte(name)) | |
| 79 | - | wantBase := "@" + hex.EncodeToString(sum[:])[:40] + ".snap" | |
| 80 | - | if _, err := os.Stat(filepath.Join(dir, wantBase)); err != nil { | |
| 81 | - | t.Fatalf("stat hashed snapshot %q: %v", wantBase, err) | |
| 82 | - | } | |
| 83 | - | loaded, err := store.Load() | |
| 84 | - | if err != nil { | |
| 85 | - | t.Fatalf("Load: %v", err) | |
| 86 | - | } | |
| 87 | - | if len(loaded) != 1 || loaded[0].Name != "" || string(loaded[0].Data) != "raw" { | |
| 88 | - | t.Fatalf("Load unsafe = %#v, want one nameless raw blob", loaded) | |
| 89 | - | } | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | func TestStoreMissingDirAndStaleTmpCleanup(t *testing.T) { | |
| 93 | - | t.Parallel() | |
| 94 | - | dir := filepath.Join(t.TempDir(), "missing") | |
| 95 | - | store := NewStore(dir) | |
| 96 | - | ||
| 97 | - | loaded, err := store.Load() | |
| 98 | - | if err != nil { | |
| 99 | - | t.Fatalf("Load missing dir: %v", err) | |
| 100 | - | } | |
| 101 | - | if len(loaded) != 0 { | |
| 102 | - | t.Fatalf("Load missing dir len = %d, want 0", len(loaded)) | |
| 103 | - | } | |
| 104 | - | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 105 | - | t.Fatalf("MkdirAll: %v", err) | |
| 106 | - | } | |
| 107 | - | if err := os.WriteFile(filepath.Join(dir, ".tmp-stale"), []byte("tmp"), 0o600); err != nil { | |
| 108 | - | t.Fatalf("write stale tmp: %v", err) | |
| 109 | - | } | |
| 110 | - | if err := os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("ignore"), 0o600); err != nil { | |
| 111 | - | t.Fatalf("write ignored: %v", err) | |
| 112 | - | } | |
| 113 | - | if err := os.WriteFile(filepath.Join(dir, "beta.snap"), []byte("beta"), 0o600); err != nil { | |
| 114 | - | t.Fatalf("write snap: %v", err) | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | got := loadMap(t, store) | |
| 118 | - | if string(got["beta"]) != "beta" || len(got) != 1 { | |
| 119 | - | t.Fatalf("Load = %#v, want only beta", got) | |
| 120 | - | } | |
| 121 | - | if _, err := os.Stat(filepath.Join(dir, ".tmp-stale")); !errors.Is(err, os.ErrNotExist) { | |
| 122 | - | t.Fatalf("stale tmp stat err = %v, want not exist", err) | |
| 123 | - | } | |
| 124 | - | } | |
| 125 | - | ||
| 126 | - | func TestStoreLoadRejectsOversizedFileBeforeRead(t *testing.T) { | |
| 127 | - | t.Parallel() | |
| 128 | - | dir := privateDir(t) | |
| 129 | - | store := NewStore(dir) | |
| 130 | - | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 131 | - | t.Fatalf("MkdirAll: %v", err) | |
| 132 | - | } | |
| 133 | - | if err := os.WriteFile(filepath.Join(dir, "ok.snap"), []byte("ok"), 0o600); err != nil { | |
| 134 | - | t.Fatalf("write ok: %v", err) | |
| 135 | - | } | |
| 136 | - | tooLarge := filepath.Join(dir, "huge.snap") | |
| 137 | - | f, err := os.Create(tooLarge) | |
| 138 | - | if err != nil { | |
| 139 | - | t.Fatalf("create huge: %v", err) | |
| 140 | - | } | |
| 141 | - | if err := f.Truncate(maxSnapshotFileSize + 1); err != nil { | |
| 142 | - | _ = f.Close() | |
| 143 | - | t.Fatalf("truncate huge: %v", err) | |
| 144 | - | } | |
| 145 | - | if err := f.Close(); err != nil { | |
| 146 | - | t.Fatalf("close huge: %v", err) | |
| 147 | - | } | |
| 148 | - | ||
| 149 | - | got := loadMap(t, store) | |
| 150 | - | if len(got) != 1 || string(got["ok"]) != "ok" { | |
| 151 | - | t.Fatalf("Load = %#v, want only ok", got) | |
| 152 | - | } | |
| 153 | - | } | |
| 154 | - | ||
| 155 | - | func TestStoreLoadSkipsUnreadableFile(t *testing.T) { | |
| 156 | - | if runtime.GOOS == "windows" || os.Geteuid() == 0 { | |
| 157 | - | t.Skip("permission bits do not make file unreadable on this platform/user") | |
| 158 | - | } | |
| 159 | - | t.Parallel() | |
| 160 | - | dir := privateDir(t) | |
| 161 | - | store := NewStore(dir) | |
| 162 | - | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 163 | - | t.Fatalf("MkdirAll: %v", err) | |
| 164 | - | } | |
| 165 | - | if err := os.WriteFile(filepath.Join(dir, "ok.snap"), []byte("ok"), 0o600); err != nil { | |
| 166 | - | t.Fatalf("write ok: %v", err) | |
| 167 | - | } | |
| 168 | - | bad := filepath.Join(dir, "bad.snap") | |
| 169 | - | if err := os.WriteFile(bad, []byte("bad"), 0o000); err != nil { | |
| 170 | - | t.Fatalf("write bad: %v", err) | |
| 171 | - | } | |
| 172 | - | t.Cleanup(func() { _ = os.Chmod(bad, 0o600) }) | |
| 173 | - | ||
| 174 | - | got := loadMap(t, store) | |
| 175 | - | if len(got) != 1 || string(got["ok"]) != "ok" { | |
| 176 | - | t.Fatalf("Load = %#v, want only ok", got) | |
| 177 | - | } | |
| 178 | - | } | |
| 179 | - | ||
| 180 | - | func loadMap(t *testing.T, store *Store) map[string][]byte { | |
| 181 | - | t.Helper() | |
| 182 | - | loaded, err := store.Load() | |
| 183 | - | if err != nil { | |
| 184 | - | t.Fatalf("Load: %v", err) | |
| 185 | - | } | |
| 186 | - | sort.Slice(loaded, func(i, j int) bool { return strings.Compare(loaded[i].Name, loaded[j].Name) < 0 }) | |
| 187 | - | out := make(map[string][]byte, len(loaded)) | |
| 188 | - | for _, blob := range loaded { | |
| 189 | - | out[blob.Name] = blob.Data | |
| 190 | - | } | |
| 191 | - | return out | |
| 192 | - | } | |
| @@ -1,192 +0,0 @@ | |||||
| 1 | - | package snapshot | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "crypto/sha256" | |||
| 5 | - | "encoding/hex" | |||
| 6 | - | "errors" | |||
| 7 | - | "os" | |||
| 8 | - | "path/filepath" | |||
| 9 | - | "runtime" | |||
| 10 | - | "sort" | |||
| 11 | - | "strings" | |||
| 12 | - | "testing" | |||
| 13 | - | ) | |||
| 14 | - | ||||
| 15 | - | func privateDir(t *testing.T) string { | |||
| 16 | - | t.Helper() | |||
| 17 | - | return filepath.Join(t.TempDir(), "vev") | |||
| 18 | - | } | |||
| 19 | - | ||||
| 20 | - | func TestStoreWriteLoadRoundTripAndSupersede(t *testing.T) { | |||
| 21 | - | t.Parallel() | |||
| 22 | - | dir := privateDir(t) | |||
| 23 | - | store := NewStore(dir) | |||
| 24 | - | ||||
| 25 | - | if err := store.Write("alpha", []byte("first")); err != nil { | |||
| 26 | - | t.Fatalf("Write first: %v", err) | |||
| 27 | - | } | |||
| 28 | - | if err := store.Write("alpha", []byte("second")); err != nil { | |||
| 29 | - | t.Fatalf("Write second: %v", err) | |||
| 30 | - | } | |||
| 31 | - | ||||
| 32 | - | got := loadMap(t, store) | |||
| 33 | - | if string(got["alpha"]) != "second" { | |||
| 34 | - | t.Fatalf("loaded alpha = %q, want second", got["alpha"]) | |||
| 35 | - | } | |||
| 36 | - | info, err := os.Stat(filepath.Join(dir, "alpha.snap")) | |||
| 37 | - | if err != nil { | |||
| 38 | - | t.Fatalf("stat snapshot: %v", err) | |||
| 39 | - | } | |||
| 40 | - | if gotMode := info.Mode().Perm(); gotMode != 0o600 { | |||
| 41 | - | t.Fatalf("snapshot mode = %v, want 0600", gotMode) | |||
| 42 | - | } | |||
| 43 | - | if dirInfo, err := os.Stat(dir); err != nil { | |||
| 44 | - | t.Fatalf("stat dir: %v", err) | |||
| 45 | - | } else if gotMode := dirInfo.Mode().Perm(); gotMode != 0o700 { | |||
| 46 | - | t.Fatalf("dir mode = %v, want 0700", gotMode) | |||
| 47 | - | } | |||
| 48 | - | } | |||
| 49 | - | ||||
| 50 | - | func TestStoreDelete(t *testing.T) { | |||
| 51 | - | t.Parallel() | |||
| 52 | - | dir := privateDir(t) | |||
| 53 | - | store := NewStore(dir) | |||
| 54 | - | ||||
| 55 | - | if err := store.Write("gone", []byte("data")); err != nil { | |||
| 56 | - | t.Fatalf("Write: %v", err) | |||
| 57 | - | } | |||
| 58 | - | if err := store.Delete("gone"); err != nil { | |||
| 59 | - | t.Fatalf("Delete existing: %v", err) | |||
| 60 | - | } | |||
| 61 | - | if err := store.Delete("gone"); err != nil { | |||
| 62 | - | t.Fatalf("Delete missing: %v", err) | |||
| 63 | - | } | |||
| 64 | - | if got := loadMap(t, store); len(got) != 0 { | |||
| 65 | - | t.Fatalf("Load after delete = %#v, want empty", got) | |||
| 66 | - | } | |||
| 67 | - | } | |||
| 68 | - | ||||
| 69 | - | func TestStoreUnsafeNameUsesDeterministicHash(t *testing.T) { | |||
| 70 | - | t.Parallel() | |||
| 71 | - | dir := privateDir(t) | |||
| 72 | - | store := NewStore(dir) | |||
| 73 | - | name := "../unsafe/session name" | |||
| 74 | - | ||||
| 75 | - | if err := store.Write(name, []byte("raw")); err != nil { | |||
| 76 | - | t.Fatalf("Write unsafe: %v", err) | |||
| 77 | - | } | |||
| 78 | - | sum := sha256.Sum256([]byte(name)) | |||
| 79 | - | wantBase := "@" + hex.EncodeToString(sum[:])[:40] + ".snap" | |||
| 80 | - | if _, err := os.Stat(filepath.Join(dir, wantBase)); err != nil { | |||
| 81 | - | t.Fatalf("stat hashed snapshot %q: %v", wantBase, err) | |||
| 82 | - | } | |||
| 83 | - | loaded, err := store.Load() | |||
| 84 | - | if err != nil { | |||
| 85 | - | t.Fatalf("Load: %v", err) | |||
| 86 | - | } | |||
| 87 | - | if len(loaded) != 1 || loaded[0].Name != "" || string(loaded[0].Data) != "raw" { | |||
| 88 | - | t.Fatalf("Load unsafe = %#v, want one nameless raw blob", loaded) | |||
| 89 | - | } | |||
| 90 | - | } | |||
| 91 | - | ||||
| 92 | - | func TestStoreMissingDirAndStaleTmpCleanup(t *testing.T) { | |||
| 93 | - | t.Parallel() | |||
| 94 | - | dir := filepath.Join(t.TempDir(), "missing") | |||
| 95 | - | store := NewStore(dir) | |||
| 96 | - | ||||
| 97 | - | loaded, err := store.Load() | |||
| 98 | - | if err != nil { | |||
| 99 | - | t.Fatalf("Load missing dir: %v", err) | |||
| 100 | - | } | |||
| 101 | - | if len(loaded) != 0 { | |||
| 102 | - | t.Fatalf("Load missing dir len = %d, want 0", len(loaded)) | |||
| 103 | - | } | |||
| 104 | - | if err := os.MkdirAll(dir, 0o700); err != nil { | |||
| 105 | - | t.Fatalf("MkdirAll: %v", err) | |||
| 106 | - | } | |||
| 107 | - | if err := os.WriteFile(filepath.Join(dir, ".tmp-stale"), []byte("tmp"), 0o600); err != nil { | |||
| 108 | - | t.Fatalf("write stale tmp: %v", err) | |||
| 109 | - | } | |||
| 110 | - | if err := os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("ignore"), 0o600); err != nil { | |||
| 111 | - | t.Fatalf("write ignored: %v", err) | |||
| 112 | - | } | |||
| 113 | - | if err := os.WriteFile(filepath.Join(dir, "beta.snap"), []byte("beta"), 0o600); err != nil { | |||
| 114 | - | t.Fatalf("write snap: %v", err) | |||
| 115 | - | } | |||
| 116 | - | ||||
| 117 | - | got := loadMap(t, store) | |||
| 118 | - | if string(got["beta"]) != "beta" || len(got) != 1 { | |||
| 119 | - | t.Fatalf("Load = %#v, want only beta", got) | |||
| 120 | - | } | |||
| 121 | - | if _, err := os.Stat(filepath.Join(dir, ".tmp-stale")); !errors.Is(err, os.ErrNotExist) { | |||
| 122 | - | t.Fatalf("stale tmp stat err = %v, want not exist", err) | |||
| 123 | - | } | |||
| 124 | - | } | |||
| 125 | - | ||||
| 126 | - | func TestStoreLoadRejectsOversizedFileBeforeRead(t *testing.T) { | |||
| 127 | - | t.Parallel() | |||
| 128 | - | dir := privateDir(t) | |||
| 129 | - | store := NewStore(dir) | |||
| 130 | - | if err := os.MkdirAll(dir, 0o700); err != nil { | |||
| 131 | - | t.Fatalf("MkdirAll: %v", err) | |||
| 132 | - | } | |||
| 133 | - | if err := os.WriteFile(filepath.Join(dir, "ok.snap"), []byte("ok"), 0o600); err != nil { | |||
| 134 | - | t.Fatalf("write ok: %v", err) | |||
| 135 | - | } | |||
| 136 | - | tooLarge := filepath.Join(dir, "huge.snap") | |||
| 137 | - | f, err := os.Create(tooLarge) | |||
| 138 | - | if err != nil { | |||
| 139 | - | t.Fatalf("create huge: %v", err) | |||
| 140 | - | } | |||
| 141 | - | if err := f.Truncate(maxSnapshotFileSize + 1); err != nil { | |||
| 142 | - | _ = f.Close() | |||
| 143 | - | t.Fatalf("truncate huge: %v", err) | |||
| 144 | - | } | |||
| 145 | - | if err := f.Close(); err != nil { | |||
| 146 | - | t.Fatalf("close huge: %v", err) | |||
| 147 | - | } | |||
| 148 | - | ||||
| 149 | - | got := loadMap(t, store) | |||
| 150 | - | if len(got) != 1 || string(got["ok"]) != "ok" { | |||
| 151 | - | t.Fatalf("Load = %#v, want only ok", got) | |||
| 152 | - | } | |||
| 153 | - | } | |||
| 154 | - | ||||
| 155 | - | func TestStoreLoadSkipsUnreadableFile(t *testing.T) { | |||
| 156 | - | if runtime.GOOS == "windows" || os.Geteuid() == 0 { | |||
| 157 | - | t.Skip("permission bits do not make file unreadable on this platform/user") | |||
| 158 | - | } | |||
| 159 | - | t.Parallel() | |||
| 160 | - | dir := privateDir(t) | |||
| 161 | - | store := NewStore(dir) | |||
| 162 | - | if err := os.MkdirAll(dir, 0o700); err != nil { | |||
| 163 | - | t.Fatalf("MkdirAll: %v", err) | |||
| 164 | - | } | |||
| 165 | - | if err := os.WriteFile(filepath.Join(dir, "ok.snap"), []byte("ok"), 0o600); err != nil { | |||
| 166 | - | t.Fatalf("write ok: %v", err) | |||
| 167 | - | } | |||
| 168 | - | bad := filepath.Join(dir, "bad.snap") | |||
| 169 | - | if err := os.WriteFile(bad, []byte("bad"), 0o000); err != nil { | |||
| 170 | - | t.Fatalf("write bad: %v", err) | |||
| 171 | - | } | |||
| 172 | - | t.Cleanup(func() { _ = os.Chmod(bad, 0o600) }) | |||
| 173 | - | ||||
| 174 | - | got := loadMap(t, store) | |||
| 175 | - | if len(got) != 1 || string(got["ok"]) != "ok" { | |||
| 176 | - | t.Fatalf("Load = %#v, want only ok", got) | |||
| 177 | - | } | |||
| 178 | - | } | |||
| 179 | - | ||||
| 180 | - | func loadMap(t *testing.T, store *Store) map[string][]byte { | |||
| 181 | - | t.Helper() | |||
| 182 | - | loaded, err := store.Load() | |||
| 183 | - | if err != nil { | |||
| 184 | - | t.Fatalf("Load: %v", err) | |||
| 185 | - | } | |||
| 186 | - | sort.Slice(loaded, func(i, j int) bool { return strings.Compare(loaded[i].Name, loaded[j].Name) < 0 }) | |||
| 187 | - | out := make(map[string][]byte, len(loaded)) | |||
| 188 | - | for _, blob := range loaded { | |||
| 189 | - | out[blob.Name] = blob.Data | |||
| 190 | - | } | |||
| 191 | - | return out | |||
| 192 | - | } | |||
A internal/adapters/snapshot/test_helpers_test.go +11 -0
| @@ -0,0 +1,11 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "path/filepath" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func privateDir(t *testing.T) string { | |
| 9 | + | t.Helper() | |
| 10 | + | return filepath.Join(t.TempDir(), "vev") | |
| 11 | + | } | |
| @@ -0,0 +1,11 @@ | |||||
| 1 | + | package snapshot | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "path/filepath" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func privateDir(t *testing.T) string { | |||
| 9 | + | t.Helper() | |||
| 10 | + | return filepath.Join(t.TempDir(), "vev") | |||
| 11 | + | } | |||
M internal/app/run_test.go +5 -8
| @@ -352,10 +352,7 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) { | |||
| 352 | 352 | if err := p.Close(); err != nil { | |
| 353 | 353 | t.Fatalf("Close error = %v", err) | |
| 354 | 354 | } | |
| 355 | - | snapshots := snapshot.NewStore(filepath.Join(stateRoot, "vev", "snapshots")) | |
| 356 | - | if err := snapshots.Write("stored", []byte("snapshot bytes")); err != nil { | |
| 357 | - | t.Fatalf("snapshot Write error = %v", err) | |
| 358 | - | } | |
| 355 | + | snapshots := snapshot.NewRepository(filepath.Join(stateRoot, "vev", "snapshots")) | |
| 359 | 356 | ||
| 360 | 357 | got := captureStdout(t, func() { | |
| 361 | 358 | if err := runKill(context.Background(), "stored", false, false); err != nil { | |
| @@ -372,12 +369,12 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) { | |||
| 372 | 369 | if len(records) != 0 { | |
| 373 | 370 | t.Fatalf("records after kill = %#v, want none", records) | |
| 374 | 371 | } | |
| 375 | - | blobs, err := snapshots.Load() | |
| 372 | + | names, err := snapshots.List(context.Background()) | |
| 376 | 373 | if err != nil { | |
| 377 | - | t.Fatalf("snapshot Load error = %v", err) | |
| 374 | + | t.Fatalf("snapshot List error = %v", err) | |
| 378 | 375 | } | |
| 379 | - | if len(blobs) != 0 { | |
| 380 | - | t.Fatalf("snapshots after kill = %#v, want none", blobs) | |
| 376 | + | if len(names) != 0 { | |
| 377 | + | t.Fatalf("snapshots after kill = %#v, want none", names) | |
| 381 | 378 | } | |
| 382 | 379 | } | |
| 383 | 380 | ||
| @@ -352,10 +352,7 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) { | |||||
| 352 | if err := p.Close(); err != nil { | 352 | if err := p.Close(); err != nil { | ||
| 353 | t.Fatalf("Close error = %v", err) | 353 | t.Fatalf("Close error = %v", err) | ||
| 354 | } | 354 | } | ||
| 355 | - | snapshots := snapshot.NewStore(filepath.Join(stateRoot, "vev", "snapshots")) | 355 | + | snapshots := snapshot.NewRepository(filepath.Join(stateRoot, "vev", "snapshots")) |
| 356 | - | if err := snapshots.Write("stored", []byte("snapshot bytes")); err != nil { | |||
| 357 | - | t.Fatalf("snapshot Write error = %v", err) | |||
| 358 | - | } | |||
| 359 | 356 | ||||
| 360 | got := captureStdout(t, func() { | 357 | got := captureStdout(t, func() { | ||
| 361 | if err := runKill(context.Background(), "stored", false, false); err != nil { | 358 | if err := runKill(context.Background(), "stored", false, false); err != nil { | ||
| @@ -372,12 +369,12 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) { | |||||
| 372 | if len(records) != 0 { | 369 | if len(records) != 0 { | ||
| 373 | t.Fatalf("records after kill = %#v, want none", records) | 370 | t.Fatalf("records after kill = %#v, want none", records) | ||
| 374 | } | 371 | } | ||
| 375 | - | blobs, err := snapshots.Load() | 372 | + | names, err := snapshots.List(context.Background()) |
| 376 | if err != nil { | 373 | if err != nil { | ||
| 377 | - | t.Fatalf("snapshot Load error = %v", err) | 374 | + | t.Fatalf("snapshot List error = %v", err) |
| 378 | } | 375 | } | ||
| 379 | - | if len(blobs) != 0 { | 376 | + | if len(names) != 0 { |
| 380 | - | t.Fatalf("snapshots after kill = %#v, want none", blobs) | 377 | + | t.Fatalf("snapshots after kill = %#v, want none", names) |
| 381 | } | 378 | } | ||
| 382 | } | 379 | } | ||
| 383 | 380 | ||||
M internal/ports/mocks/store_mock.go +0 -190
| @@ -336,196 +336,6 @@ func (_c *MockStore_Sync_Call) RunAndReturn(run func() error) *MockStore_Sync_Ca | |||
| 336 | 336 | return _c | |
| 337 | 337 | } | |
| 338 | 338 | ||
| 339 | - | // NewMockSnapshotStore creates a new instance of MockSnapshotStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 340 | - | // The first argument is typically a *testing.T value. | |
| 341 | - | func NewMockSnapshotStore(t interface { | |
| 342 | - | mock.TestingT | |
| 343 | - | Cleanup(func()) | |
| 344 | - | }) *MockSnapshotStore { | |
| 345 | - | mock := &MockSnapshotStore{} | |
| 346 | - | mock.Mock.Test(t) | |
| 347 | - | ||
| 348 | - | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 349 | - | ||
| 350 | - | return mock | |
| 351 | - | } | |
| 352 | - | ||
| 353 | - | // MockSnapshotStore is an autogenerated mock type for the SnapshotStore type | |
| 354 | - | type MockSnapshotStore struct { | |
| 355 | - | mock.Mock | |
| 356 | - | } | |
| 357 | - | ||
| 358 | - | type MockSnapshotStore_Expecter struct { | |
| 359 | - | mock *mock.Mock | |
| 360 | - | } | |
| 361 | - | ||
| 362 | - | func (_m *MockSnapshotStore) EXPECT() *MockSnapshotStore_Expecter { | |
| 363 | - | return &MockSnapshotStore_Expecter{mock: &_m.Mock} | |
| 364 | - | } | |
| 365 | - | ||
| 366 | - | // Delete provides a mock function for the type MockSnapshotStore | |
| 367 | - | func (_mock *MockSnapshotStore) Delete(name string) error { | |
| 368 | - | ret := _mock.Called(name) | |
| 369 | - | ||
| 370 | - | if len(ret) == 0 { | |
| 371 | - | panic("no return value specified for Delete") | |
| 372 | - | } | |
| 373 | - | ||
| 374 | - | var r0 error | |
| 375 | - | if returnFunc, ok := ret.Get(0).(func(string) error); ok { | |
| 376 | - | r0 = returnFunc(name) | |
| 377 | - | } else { | |
| 378 | - | r0 = ret.Error(0) | |
| 379 | - | } | |
| 380 | - | return r0 | |
| 381 | - | } | |
| 382 | - | ||
| 383 | - | // MockSnapshotStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' | |
| 384 | - | type MockSnapshotStore_Delete_Call struct { | |
| 385 | - | *mock.Call | |
| 386 | - | } | |
| 387 | - | ||
| 388 | - | // Delete is a helper method to define mock.On call | |
| 389 | - | // - name string | |
| 390 | - | func (_e *MockSnapshotStore_Expecter) Delete(name any) *MockSnapshotStore_Delete_Call { | |
| 391 | - | return &MockSnapshotStore_Delete_Call{Call: _e.mock.On("Delete", name)} | |
| 392 | - | } | |
| 393 | - | ||
| 394 | - | func (_c *MockSnapshotStore_Delete_Call) Run(run func(name string)) *MockSnapshotStore_Delete_Call { | |
| 395 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 396 | - | var arg0 string | |
| 397 | - | if args[0] != nil { | |
| 398 | - | arg0 = args[0].(string) | |
| 399 | - | } | |
| 400 | - | run( | |
| 401 | - | arg0, | |
| 402 | - | ) | |
| 403 | - | }) | |
| 404 | - | return _c | |
| 405 | - | } | |
| 406 | - | ||
| 407 | - | func (_c *MockSnapshotStore_Delete_Call) Return(err error) *MockSnapshotStore_Delete_Call { | |
| 408 | - | _c.Call.Return(err) | |
| 409 | - | return _c | |
| 410 | - | } | |
| 411 | - | ||
| 412 | - | func (_c *MockSnapshotStore_Delete_Call) RunAndReturn(run func(name string) error) *MockSnapshotStore_Delete_Call { | |
| 413 | - | _c.Call.Return(run) | |
| 414 | - | return _c | |
| 415 | - | } | |
| 416 | - | ||
| 417 | - | // Load provides a mock function for the type MockSnapshotStore | |
| 418 | - | func (_mock *MockSnapshotStore) Load() ([]ports.SnapshotBlob, error) { | |
| 419 | - | ret := _mock.Called() | |
| 420 | - | ||
| 421 | - | if len(ret) == 0 { | |
| 422 | - | panic("no return value specified for Load") | |
| 423 | - | } | |
| 424 | - | ||
| 425 | - | var r0 []ports.SnapshotBlob | |
| 426 | - | var r1 error | |
| 427 | - | if returnFunc, ok := ret.Get(0).(func() ([]ports.SnapshotBlob, error)); ok { | |
| 428 | - | return returnFunc() | |
| 429 | - | } | |
| 430 | - | if returnFunc, ok := ret.Get(0).(func() []ports.SnapshotBlob); ok { | |
| 431 | - | r0 = returnFunc() | |
| 432 | - | } else { | |
| 433 | - | if ret.Get(0) != nil { | |
| 434 | - | r0 = ret.Get(0).([]ports.SnapshotBlob) | |
| 435 | - | } | |
| 436 | - | } | |
| 437 | - | if returnFunc, ok := ret.Get(1).(func() error); ok { | |
| 438 | - | r1 = returnFunc() | |
| 439 | - | } else { | |
| 440 | - | r1 = ret.Error(1) | |
| 441 | - | } | |
| 442 | - | return r0, r1 | |
| 443 | - | } | |
| 444 | - | ||
| 445 | - | // MockSnapshotStore_Load_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Load' | |
| 446 | - | type MockSnapshotStore_Load_Call struct { | |
| 447 | - | *mock.Call | |
| 448 | - | } | |
| 449 | - | ||
| 450 | - | // Load is a helper method to define mock.On call | |
| 451 | - | func (_e *MockSnapshotStore_Expecter) Load() *MockSnapshotStore_Load_Call { | |
| 452 | - | return &MockSnapshotStore_Load_Call{Call: _e.mock.On("Load")} | |
| 453 | - | } | |
| 454 | - | ||
| 455 | - | func (_c *MockSnapshotStore_Load_Call) Run(run func()) *MockSnapshotStore_Load_Call { | |
| 456 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 457 | - | run() | |
| 458 | - | }) | |
| 459 | - | return _c | |
| 460 | - | } | |
| 461 | - | ||
| 462 | - | func (_c *MockSnapshotStore_Load_Call) Return(snapshotBlobs []ports.SnapshotBlob, err error) *MockSnapshotStore_Load_Call { | |
| 463 | - | _c.Call.Return(snapshotBlobs, err) | |
| 464 | - | return _c | |
| 465 | - | } | |
| 466 | - | ||
| 467 | - | func (_c *MockSnapshotStore_Load_Call) RunAndReturn(run func() ([]ports.SnapshotBlob, error)) *MockSnapshotStore_Load_Call { | |
| 468 | - | _c.Call.Return(run) | |
| 469 | - | return _c | |
| 470 | - | } | |
| 471 | - | ||
| 472 | - | // Write provides a mock function for the type MockSnapshotStore | |
| 473 | - | func (_mock *MockSnapshotStore) Write(name string, data []byte) error { | |
| 474 | - | ret := _mock.Called(name, data) | |
| 475 | - | ||
| 476 | - | if len(ret) == 0 { | |
| 477 | - | panic("no return value specified for Write") | |
| 478 | - | } | |
| 479 | - | ||
| 480 | - | var r0 error | |
| 481 | - | if returnFunc, ok := ret.Get(0).(func(string, []byte) error); ok { | |
| 482 | - | r0 = returnFunc(name, data) | |
| 483 | - | } else { | |
| 484 | - | r0 = ret.Error(0) | |
| 485 | - | } | |
| 486 | - | return r0 | |
| 487 | - | } | |
| 488 | - | ||
| 489 | - | // MockSnapshotStore_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write' | |
| 490 | - | type MockSnapshotStore_Write_Call struct { | |
| 491 | - | *mock.Call | |
| 492 | - | } | |
| 493 | - | ||
| 494 | - | // Write is a helper method to define mock.On call | |
| 495 | - | // - name string | |
| 496 | - | // - data []byte | |
| 497 | - | func (_e *MockSnapshotStore_Expecter) Write(name any, data any) *MockSnapshotStore_Write_Call { | |
| 498 | - | return &MockSnapshotStore_Write_Call{Call: _e.mock.On("Write", name, data)} | |
| 499 | - | } | |
| 500 | - | ||
| 501 | - | func (_c *MockSnapshotStore_Write_Call) Run(run func(name string, data []byte)) *MockSnapshotStore_Write_Call { | |
| 502 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 503 | - | var arg0 string | |
| 504 | - | if args[0] != nil { | |
| 505 | - | arg0 = args[0].(string) | |
| 506 | - | } | |
| 507 | - | var arg1 []byte | |
| 508 | - | if args[1] != nil { | |
| 509 | - | arg1 = args[1].([]byte) | |
| 510 | - | } | |
| 511 | - | run( | |
| 512 | - | arg0, | |
| 513 | - | arg1, | |
| 514 | - | ) | |
| 515 | - | }) | |
| 516 | - | return _c | |
| 517 | - | } | |
| 518 | - | ||
| 519 | - | func (_c *MockSnapshotStore_Write_Call) Return(err error) *MockSnapshotStore_Write_Call { | |
| 520 | - | _c.Call.Return(err) | |
| 521 | - | return _c | |
| 522 | - | } | |
| 523 | - | ||
| 524 | - | func (_c *MockSnapshotStore_Write_Call) RunAndReturn(run func(name string, data []byte) error) *MockSnapshotStore_Write_Call { | |
| 525 | - | _c.Call.Return(run) | |
| 526 | - | return _c | |
| 527 | - | } | |
| 528 | - | ||
| 529 | 339 | // NewMockSnapshotRepository creates a new instance of MockSnapshotRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 530 | 340 | // The first argument is typically a *testing.T value. | |
| 531 | 341 | func NewMockSnapshotRepository(t interface { | |
| @@ -336,196 +336,6 @@ func (_c *MockStore_Sync_Call) RunAndReturn(run func() error) *MockStore_Sync_Ca | |||||
| 336 | return _c | 336 | return _c | ||
| 337 | } | 337 | } | ||
| 338 | 338 | ||||
| 339 | - | // NewMockSnapshotStore creates a new instance of MockSnapshotStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 340 | - | // The first argument is typically a *testing.T value. | |||
| 341 | - | func NewMockSnapshotStore(t interface { | |||
| 342 | - | mock.TestingT | |||
| 343 | - | Cleanup(func()) | |||
| 344 | - | }) *MockSnapshotStore { | |||
| 345 | - | mock := &MockSnapshotStore{} | |||
| 346 | - | mock.Mock.Test(t) | |||
| 347 | - | ||||
| 348 | - | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 349 | - | ||||
| 350 | - | return mock | |||
| 351 | - | } | |||
| 352 | - | ||||
| 353 | - | // MockSnapshotStore is an autogenerated mock type for the SnapshotStore type | |||
| 354 | - | type MockSnapshotStore struct { | |||
| 355 | - | mock.Mock | |||
| 356 | - | } | |||
| 357 | - | ||||
| 358 | - | type MockSnapshotStore_Expecter struct { | |||
| 359 | - | mock *mock.Mock | |||
| 360 | - | } | |||
| 361 | - | ||||
| 362 | - | func (_m *MockSnapshotStore) EXPECT() *MockSnapshotStore_Expecter { | |||
| 363 | - | return &MockSnapshotStore_Expecter{mock: &_m.Mock} | |||
| 364 | - | } | |||
| 365 | - | ||||
| 366 | - | // Delete provides a mock function for the type MockSnapshotStore | |||
| 367 | - | func (_mock *MockSnapshotStore) Delete(name string) error { | |||
| 368 | - | ret := _mock.Called(name) | |||
| 369 | - | ||||
| 370 | - | if len(ret) == 0 { | |||
| 371 | - | panic("no return value specified for Delete") | |||
| 372 | - | } | |||
| 373 | - | ||||
| 374 | - | var r0 error | |||
| 375 | - | if returnFunc, ok := ret.Get(0).(func(string) error); ok { | |||
| 376 | - | r0 = returnFunc(name) | |||
| 377 | - | } else { | |||
| 378 | - | r0 = ret.Error(0) | |||
| 379 | - | } | |||
| 380 | - | return r0 | |||
| 381 | - | } | |||
| 382 | - | ||||
| 383 | - | // MockSnapshotStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' | |||
| 384 | - | type MockSnapshotStore_Delete_Call struct { | |||
| 385 | - | *mock.Call | |||
| 386 | - | } | |||
| 387 | - | ||||
| 388 | - | // Delete is a helper method to define mock.On call | |||
| 389 | - | // - name string | |||
| 390 | - | func (_e *MockSnapshotStore_Expecter) Delete(name any) *MockSnapshotStore_Delete_Call { | |||
| 391 | - | return &MockSnapshotStore_Delete_Call{Call: _e.mock.On("Delete", name)} | |||
| 392 | - | } | |||
| 393 | - | ||||
| 394 | - | func (_c *MockSnapshotStore_Delete_Call) Run(run func(name string)) *MockSnapshotStore_Delete_Call { | |||
| 395 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 396 | - | var arg0 string | |||
| 397 | - | if args[0] != nil { | |||
| 398 | - | arg0 = args[0].(string) | |||
| 399 | - | } | |||
| 400 | - | run( | |||
| 401 | - | arg0, | |||
| 402 | - | ) | |||
| 403 | - | }) | |||
| 404 | - | return _c | |||
| 405 | - | } | |||
| 406 | - | ||||
| 407 | - | func (_c *MockSnapshotStore_Delete_Call) Return(err error) *MockSnapshotStore_Delete_Call { | |||
| 408 | - | _c.Call.Return(err) | |||
| 409 | - | return _c | |||
| 410 | - | } | |||
| 411 | - | ||||
| 412 | - | func (_c *MockSnapshotStore_Delete_Call) RunAndReturn(run func(name string) error) *MockSnapshotStore_Delete_Call { | |||
| 413 | - | _c.Call.Return(run) | |||
| 414 | - | return _c | |||
| 415 | - | } | |||
| 416 | - | ||||
| 417 | - | // Load provides a mock function for the type MockSnapshotStore | |||
| 418 | - | func (_mock *MockSnapshotStore) Load() ([]ports.SnapshotBlob, error) { | |||
| 419 | - | ret := _mock.Called() | |||
| 420 | - | ||||
| 421 | - | if len(ret) == 0 { | |||
| 422 | - | panic("no return value specified for Load") | |||
| 423 | - | } | |||
| 424 | - | ||||
| 425 | - | var r0 []ports.SnapshotBlob | |||
| 426 | - | var r1 error | |||
| 427 | - | if returnFunc, ok := ret.Get(0).(func() ([]ports.SnapshotBlob, error)); ok { | |||
| 428 | - | return returnFunc() | |||
| 429 | - | } | |||
| 430 | - | if returnFunc, ok := ret.Get(0).(func() []ports.SnapshotBlob); ok { | |||
| 431 | - | r0 = returnFunc() | |||
| 432 | - | } else { | |||
| 433 | - | if ret.Get(0) != nil { | |||
| 434 | - | r0 = ret.Get(0).([]ports.SnapshotBlob) | |||
| 435 | - | } | |||
| 436 | - | } | |||
| 437 | - | if returnFunc, ok := ret.Get(1).(func() error); ok { | |||
| 438 | - | r1 = returnFunc() | |||
| 439 | - | } else { | |||
| 440 | - | r1 = ret.Error(1) | |||
| 441 | - | } | |||
| 442 | - | return r0, r1 | |||
| 443 | - | } | |||
| 444 | - | ||||
| 445 | - | // MockSnapshotStore_Load_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Load' | |||
| 446 | - | type MockSnapshotStore_Load_Call struct { | |||
| 447 | - | *mock.Call | |||
| 448 | - | } | |||
| 449 | - | ||||
| 450 | - | // Load is a helper method to define mock.On call | |||
| 451 | - | func (_e *MockSnapshotStore_Expecter) Load() *MockSnapshotStore_Load_Call { | |||
| 452 | - | return &MockSnapshotStore_Load_Call{Call: _e.mock.On("Load")} | |||
| 453 | - | } | |||
| 454 | - | ||||
| 455 | - | func (_c *MockSnapshotStore_Load_Call) Run(run func()) *MockSnapshotStore_Load_Call { | |||
| 456 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 457 | - | run() | |||
| 458 | - | }) | |||
| 459 | - | return _c | |||
| 460 | - | } | |||
| 461 | - | ||||
| 462 | - | func (_c *MockSnapshotStore_Load_Call) Return(snapshotBlobs []ports.SnapshotBlob, err error) *MockSnapshotStore_Load_Call { | |||
| 463 | - | _c.Call.Return(snapshotBlobs, err) | |||
| 464 | - | return _c | |||
| 465 | - | } | |||
| 466 | - | ||||
| 467 | - | func (_c *MockSnapshotStore_Load_Call) RunAndReturn(run func() ([]ports.SnapshotBlob, error)) *MockSnapshotStore_Load_Call { | |||
| 468 | - | _c.Call.Return(run) | |||
| 469 | - | return _c | |||
| 470 | - | } | |||
| 471 | - | ||||
| 472 | - | // Write provides a mock function for the type MockSnapshotStore | |||
| 473 | - | func (_mock *MockSnapshotStore) Write(name string, data []byte) error { | |||
| 474 | - | ret := _mock.Called(name, data) | |||
| 475 | - | ||||
| 476 | - | if len(ret) == 0 { | |||
| 477 | - | panic("no return value specified for Write") | |||
| 478 | - | } | |||
| 479 | - | ||||
| 480 | - | var r0 error | |||
| 481 | - | if returnFunc, ok := ret.Get(0).(func(string, []byte) error); ok { | |||
| 482 | - | r0 = returnFunc(name, data) | |||
| 483 | - | } else { | |||
| 484 | - | r0 = ret.Error(0) | |||
| 485 | - | } | |||
| 486 | - | return r0 | |||
| 487 | - | } | |||
| 488 | - | ||||
| 489 | - | // MockSnapshotStore_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write' | |||
| 490 | - | type MockSnapshotStore_Write_Call struct { | |||
| 491 | - | *mock.Call | |||
| 492 | - | } | |||
| 493 | - | ||||
| 494 | - | // Write is a helper method to define mock.On call | |||
| 495 | - | // - name string | |||
| 496 | - | // - data []byte | |||
| 497 | - | func (_e *MockSnapshotStore_Expecter) Write(name any, data any) *MockSnapshotStore_Write_Call { | |||
| 498 | - | return &MockSnapshotStore_Write_Call{Call: _e.mock.On("Write", name, data)} | |||
| 499 | - | } | |||
| 500 | - | ||||
| 501 | - | func (_c *MockSnapshotStore_Write_Call) Run(run func(name string, data []byte)) *MockSnapshotStore_Write_Call { | |||
| 502 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 503 | - | var arg0 string | |||
| 504 | - | if args[0] != nil { | |||
| 505 | - | arg0 = args[0].(string) | |||
| 506 | - | } | |||
| 507 | - | var arg1 []byte | |||
| 508 | - | if args[1] != nil { | |||
| 509 | - | arg1 = args[1].([]byte) | |||
| 510 | - | } | |||
| 511 | - | run( | |||
| 512 | - | arg0, | |||
| 513 | - | arg1, | |||
| 514 | - | ) | |||
| 515 | - | }) | |||
| 516 | - | return _c | |||
| 517 | - | } | |||
| 518 | - | ||||
| 519 | - | func (_c *MockSnapshotStore_Write_Call) Return(err error) *MockSnapshotStore_Write_Call { | |||
| 520 | - | _c.Call.Return(err) | |||
| 521 | - | return _c | |||
| 522 | - | } | |||
| 523 | - | ||||
| 524 | - | func (_c *MockSnapshotStore_Write_Call) RunAndReturn(run func(name string, data []byte) error) *MockSnapshotStore_Write_Call { | |||
| 525 | - | _c.Call.Return(run) | |||
| 526 | - | return _c | |||
| 527 | - | } | |||
| 528 | - | ||||
| 529 | // NewMockSnapshotRepository creates a new instance of MockSnapshotRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 339 | // NewMockSnapshotRepository creates a new instance of MockSnapshotRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 530 | // The first argument is typically a *testing.T value. | 340 | // The first argument is typically a *testing.T value. | ||
| 531 | func NewMockSnapshotRepository(t interface { | 341 | func NewMockSnapshotRepository(t interface { | ||
D internal/ports/snapshot_test.go +0 -59
| @@ -1,59 +0,0 @@ | |||
| 1 | - | package ports | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "crypto/sha256" | |
| 6 | - | "testing" | |
| 7 | - | ) | |
| 8 | - | ||
| 9 | - | type snapshotRepositoryContract struct{} | |
| 10 | - | ||
| 11 | - | func (snapshotRepositoryContract) Publish(context.Context, SnapshotPublication) error { return nil } | |
| 12 | - | func (snapshotRepositoryContract) List(context.Context) ([]string, error) { return nil, nil } | |
| 13 | - | func (snapshotRepositoryContract) Load(context.Context, string) (SnapshotGeneration, error) { | |
| 14 | - | return SnapshotGeneration{}, nil | |
| 15 | - | } | |
| 16 | - | func (snapshotRepositoryContract) Delete(context.Context, string) error { return nil } | |
| 17 | - | func (snapshotRepositoryContract) Maintain(context.Context) error { return nil } | |
| 18 | - | ||
| 19 | - | type legacySnapshotSourceContract struct{} | |
| 20 | - | ||
| 21 | - | func (legacySnapshotSourceContract) LoadLegacy(context.Context) ([]LegacySnapshot, error) { | |
| 22 | - | return nil, nil | |
| 23 | - | } | |
| 24 | - | func (legacySnapshotSourceContract) DeleteLegacy(context.Context, string) error { return nil } | |
| 25 | - | ||
| 26 | - | func TestSnapshotRepositoryContracts(t *testing.T) { | |
| 27 | - | var _ SnapshotRepository = snapshotRepositoryContract{} | |
| 28 | - | var _ LegacySnapshotSource = legacySnapshotSourceContract{} | |
| 29 | - | ||
| 30 | - | publication := SnapshotPublication{ | |
| 31 | - | Name: "named-session", | |
| 32 | - | Generation: 7, | |
| 33 | - | Manifest: []byte("VEVM"), | |
| 34 | - | Objects: []SnapshotObject{{Digest: SnapshotDigest{1}, Data: []byte("VEVO")}}, | |
| 35 | - | } | |
| 36 | - | generation := SnapshotGeneration{ | |
| 37 | - | Name: publication.Name, | |
| 38 | - | Generation: publication.Generation, | |
| 39 | - | Manifest: publication.Manifest, | |
| 40 | - | Objects: map[SnapshotDigest][]byte{publication.Objects[0].Digest: publication.Objects[0].Data}, | |
| 41 | - | Fallback: true, | |
| 42 | - | } | |
| 43 | - | if publication.Name != "named-session" || generation.Generation != 7 || !generation.Fallback { | |
| 44 | - | t.Fatalf("snapshot publication/generation = %#v", generation) | |
| 45 | - | } | |
| 46 | - | if len(SnapshotDigest{}) != sha256.Size { | |
| 47 | - | t.Fatalf("SnapshotDigest has length %d, want %d", len(SnapshotDigest{}), sha256.Size) | |
| 48 | - | } | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | type snapshotStoreContract struct{} | |
| 52 | - | ||
| 53 | - | func (snapshotStoreContract) Write(name string, data []byte) error { return nil } | |
| 54 | - | func (snapshotStoreContract) Load() ([]SnapshotBlob, error) { return nil, nil } | |
| 55 | - | func (snapshotStoreContract) Delete(name string) error { return nil } | |
| 56 | - | ||
| 57 | - | func TestSnapshotStoreContract(t *testing.T) { | |
| 58 | - | var _ SnapshotStore = snapshotStoreContract{} | |
| 59 | - | } | |
| @@ -1,59 +0,0 @@ | |||||
| 1 | - | package ports | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "context" | |||
| 5 | - | "crypto/sha256" | |||
| 6 | - | "testing" | |||
| 7 | - | ) | |||
| 8 | - | ||||
| 9 | - | type snapshotRepositoryContract struct{} | |||
| 10 | - | ||||
| 11 | - | func (snapshotRepositoryContract) Publish(context.Context, SnapshotPublication) error { return nil } | |||
| 12 | - | func (snapshotRepositoryContract) List(context.Context) ([]string, error) { return nil, nil } | |||
| 13 | - | func (snapshotRepositoryContract) Load(context.Context, string) (SnapshotGeneration, error) { | |||
| 14 | - | return SnapshotGeneration{}, nil | |||
| 15 | - | } | |||
| 16 | - | func (snapshotRepositoryContract) Delete(context.Context, string) error { return nil } | |||
| 17 | - | func (snapshotRepositoryContract) Maintain(context.Context) error { return nil } | |||
| 18 | - | ||||
| 19 | - | type legacySnapshotSourceContract struct{} | |||
| 20 | - | ||||
| 21 | - | func (legacySnapshotSourceContract) LoadLegacy(context.Context) ([]LegacySnapshot, error) { | |||
| 22 | - | return nil, nil | |||
| 23 | - | } | |||
| 24 | - | func (legacySnapshotSourceContract) DeleteLegacy(context.Context, string) error { return nil } | |||
| 25 | - | ||||
| 26 | - | func TestSnapshotRepositoryContracts(t *testing.T) { | |||
| 27 | - | var _ SnapshotRepository = snapshotRepositoryContract{} | |||
| 28 | - | var _ LegacySnapshotSource = legacySnapshotSourceContract{} | |||
| 29 | - | ||||
| 30 | - | publication := SnapshotPublication{ | |||
| 31 | - | Name: "named-session", | |||
| 32 | - | Generation: 7, | |||
| 33 | - | Manifest: []byte("VEVM"), | |||
| 34 | - | Objects: []SnapshotObject{{Digest: SnapshotDigest{1}, Data: []byte("VEVO")}}, | |||
| 35 | - | } | |||
| 36 | - | generation := SnapshotGeneration{ | |||
| 37 | - | Name: publication.Name, | |||
| 38 | - | Generation: publication.Generation, | |||
| 39 | - | Manifest: publication.Manifest, | |||
| 40 | - | Objects: map[SnapshotDigest][]byte{publication.Objects[0].Digest: publication.Objects[0].Data}, | |||
| 41 | - | Fallback: true, | |||
| 42 | - | } | |||
| 43 | - | if publication.Name != "named-session" || generation.Generation != 7 || !generation.Fallback { | |||
| 44 | - | t.Fatalf("snapshot publication/generation = %#v", generation) | |||
| 45 | - | } | |||
| 46 | - | if len(SnapshotDigest{}) != sha256.Size { | |||
| 47 | - | t.Fatalf("SnapshotDigest has length %d, want %d", len(SnapshotDigest{}), sha256.Size) | |||
| 48 | - | } | |||
| 49 | - | } | |||
| 50 | - | ||||
| 51 | - | type snapshotStoreContract struct{} | |||
| 52 | - | ||||
| 53 | - | func (snapshotStoreContract) Write(name string, data []byte) error { return nil } | |||
| 54 | - | func (snapshotStoreContract) Load() ([]SnapshotBlob, error) { return nil, nil } | |||
| 55 | - | func (snapshotStoreContract) Delete(name string) error { return nil } | |||
| 56 | - | ||||
| 57 | - | func TestSnapshotStoreContract(t *testing.T) { | |||
| 58 | - | var _ SnapshotStore = snapshotStoreContract{} | |||
| 59 | - | } | |||
M internal/ports/store.go +0 -13
| @@ -22,19 +22,6 @@ type Store interface { | |||
| 22 | 22 | Close() error | |
| 23 | 23 | } | |
| 24 | 24 | ||
| 25 | - | // SnapshotBlob is a durable named session snapshot payload. | |
| 26 | - | type SnapshotBlob struct { | |
| 27 | - | Name string | |
| 28 | - | Data []byte | |
| 29 | - | } | |
| 30 | - | ||
| 31 | - | // SnapshotStore persists encoded session snapshots by name. | |
| 32 | - | type SnapshotStore interface { | |
| 33 | - | Write(name string, data []byte) error | |
| 34 | - | Load() ([]SnapshotBlob, error) | |
| 35 | - | Delete(name string) error | |
| 36 | - | } | |
| 37 | - | ||
| 38 | 25 | // SnapshotDigest is the SHA-256 content address of a complete snapshot object. | |
| 39 | 26 | type SnapshotDigest [sha256.Size]byte | |
| 40 | 27 | ||
| @@ -22,19 +22,6 @@ type Store interface { | |||||
| 22 | Close() error | 22 | Close() error | ||
| 23 | } | 23 | } | ||
| 24 | 24 | ||||
| 25 | - | // SnapshotBlob is a durable named session snapshot payload. | |||
| 26 | - | type SnapshotBlob struct { | |||
| 27 | - | Name string | |||
| 28 | - | Data []byte | |||
| 29 | - | } | |||
| 30 | - | ||||
| 31 | - | // SnapshotStore persists encoded session snapshots by name. | |||
| 32 | - | type SnapshotStore interface { | |||
| 33 | - | Write(name string, data []byte) error | |||
| 34 | - | Load() ([]SnapshotBlob, error) | |||
| 35 | - | Delete(name string) error | |||
| 36 | - | } | |||
| 37 | - | ||||
| 38 | // SnapshotDigest is the SHA-256 content address of a complete snapshot object. | 25 | // SnapshotDigest is the SHA-256 content address of a complete snapshot object. | ||
| 39 | type SnapshotDigest [sha256.Size]byte | 26 | type SnapshotDigest [sha256.Size]byte | ||
| 40 | 27 | ||||
M internal/usecase/daemon/daemon.go +3 -16
| @@ -117,12 +117,10 @@ type Daemon struct { | |||
| 117 | 117 | shellOverride bool | |
| 118 | 118 | persist *persist.Persister | |
| 119 | 119 | persistEnabled bool | |
| 120 | - | // snapshotRepository is the sole production checkpoint contract. snaps is | |
| 121 | - | // retained temporarily only for the v3 test bridge; application wiring | |
| 122 | - | // always installs snapshotRepository. | |
| 120 | + | // snapshotRepository is the sole checkpoint contract. legacySnapshots is | |
| 121 | + | // read-only migration input and is never used for new writes. | |
| 123 | 122 | snapshotRepository ports.SnapshotRepository | |
| 124 | 123 | legacySnapshots ports.LegacySnapshotSource | |
| 125 | - | snaps ports.SnapshotStore | |
| 126 | 124 | snapsEnabled bool | |
| 127 | 125 | noticeStore ports.NoticeStore | |
| 128 | 126 | snapshotMarshal func(snapcodec.Session) ([]byte, error) | |
| @@ -265,15 +263,6 @@ func WithSnapshotRepository(repository ports.SnapshotRepository, legacy ports.Le | |||
| 265 | 263 | } | |
| 266 | 264 | } | |
| 267 | 265 | ||
| 268 | - | // WithSnapshotStore is the v3 compatibility seam for existing embedders. New | |
| 269 | - | // application wiring must use WithSnapshotRepository. | |
| 270 | - | func WithSnapshotStore(store ports.SnapshotStore) Option { | |
| 271 | - | return func(d *Daemon) { | |
| 272 | - | d.snaps = store | |
| 273 | - | d.snapsEnabled = store != nil | |
| 274 | - | } | |
| 275 | - | } | |
| 276 | - | ||
| 277 | 266 | // WithNoticeStore enables persisting undeliverable notices across daemon | |
| 278 | 267 | // restarts. A nil store keeps the daemon in no-op notice-persistence mode. | |
| 279 | 268 | func WithNoticeStore(store ports.NoticeStore) Option { | |
| @@ -478,7 +467,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||
| 478 | 467 | if d.snapsEnabled { | |
| 479 | 468 | d.startSnapshotEncodeWorker() | |
| 480 | 469 | d.sessWg.Go(func() { | |
| 481 | - | d.snapshotSaver(d.serveCtx) | |
| 470 | + | d.snapshotRepositorySaver(d.serveCtx) | |
| 482 | 471 | }) | |
| 483 | 472 | d.sessWg.Go(func() { | |
| 484 | 473 | d.restoreSnapshots(d.serveCtx) | |
| @@ -661,8 +650,6 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) { | |||
| 661 | 650 | var deleteErr error | |
| 662 | 651 | if d.snapshotRepository != nil { | |
| 663 | 652 | deleteErr = d.snapshotRepository.Delete(context.Background(), k.Name) | |
| 664 | - | } else if d.snaps != nil { | |
| 665 | - | deleteErr = d.snaps.Delete(k.Name) | |
| 666 | 653 | } | |
| 667 | 654 | if deleteErr != nil { | |
| 668 | 655 | d.log.Warn("deleting stopped session snapshot failed", "err", deleteErr, "session", k.Name) | |
| @@ -117,12 +117,10 @@ type Daemon struct { | |||||
| 117 | shellOverride bool | 117 | shellOverride bool | ||
| 118 | persist *persist.Persister | 118 | persist *persist.Persister | ||
| 119 | persistEnabled bool | 119 | persistEnabled bool | ||
| 120 | - | // snapshotRepository is the sole production checkpoint contract. snaps is | 120 | + | // snapshotRepository is the sole checkpoint contract. legacySnapshots is |
| 121 | - | // retained temporarily only for the v3 test bridge; application wiring | 121 | + | // read-only migration input and is never used for new writes. |
| 122 | - | // always installs snapshotRepository. | |||
| 123 | snapshotRepository ports.SnapshotRepository | 122 | snapshotRepository ports.SnapshotRepository | ||
| 124 | legacySnapshots ports.LegacySnapshotSource | 123 | legacySnapshots ports.LegacySnapshotSource | ||
| 125 | - | snaps ports.SnapshotStore | |||
| 126 | snapsEnabled bool | 124 | snapsEnabled bool | ||
| 127 | noticeStore ports.NoticeStore | 125 | noticeStore ports.NoticeStore | ||
| 128 | snapshotMarshal func(snapcodec.Session) ([]byte, error) | 126 | snapshotMarshal func(snapcodec.Session) ([]byte, error) | ||
| @@ -265,15 +263,6 @@ func WithSnapshotRepository(repository ports.SnapshotRepository, legacy ports.Le | |||||
| 265 | } | 263 | } | ||
| 266 | } | 264 | } | ||
| 267 | 265 | ||||
| 268 | - | // WithSnapshotStore is the v3 compatibility seam for existing embedders. New | |||
| 269 | - | // application wiring must use WithSnapshotRepository. | |||
| 270 | - | func WithSnapshotStore(store ports.SnapshotStore) Option { | |||
| 271 | - | return func(d *Daemon) { | |||
| 272 | - | d.snaps = store | |||
| 273 | - | d.snapsEnabled = store != nil | |||
| 274 | - | } | |||
| 275 | - | } | |||
| 276 | - | ||||
| 277 | // WithNoticeStore enables persisting undeliverable notices across daemon | 266 | // WithNoticeStore enables persisting undeliverable notices across daemon | ||
| 278 | // restarts. A nil store keeps the daemon in no-op notice-persistence mode. | 267 | // restarts. A nil store keeps the daemon in no-op notice-persistence mode. | ||
| 279 | func WithNoticeStore(store ports.NoticeStore) Option { | 268 | func WithNoticeStore(store ports.NoticeStore) Option { | ||
| @@ -478,7 +467,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||||
| 478 | if d.snapsEnabled { | 467 | if d.snapsEnabled { | ||
| 479 | d.startSnapshotEncodeWorker() | 468 | d.startSnapshotEncodeWorker() | ||
| 480 | d.sessWg.Go(func() { | 469 | d.sessWg.Go(func() { | ||
| 481 | - | d.snapshotSaver(d.serveCtx) | 470 | + | d.snapshotRepositorySaver(d.serveCtx) |
| 482 | }) | 471 | }) | ||
| 483 | d.sessWg.Go(func() { | 472 | d.sessWg.Go(func() { | ||
| 484 | d.restoreSnapshots(d.serveCtx) | 473 | d.restoreSnapshots(d.serveCtx) | ||
| @@ -661,8 +650,6 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) { | |||||
| 661 | var deleteErr error | 650 | var deleteErr error | ||
| 662 | if d.snapshotRepository != nil { | 651 | if d.snapshotRepository != nil { | ||
| 663 | deleteErr = d.snapshotRepository.Delete(context.Background(), k.Name) | 652 | deleteErr = d.snapshotRepository.Delete(context.Background(), k.Name) | ||
| 664 | - | } else if d.snaps != nil { | |||
| 665 | - | deleteErr = d.snaps.Delete(k.Name) | |||
| 666 | } | 653 | } | ||
| 667 | if deleteErr != nil { | 654 | if deleteErr != nil { | ||
| 668 | d.log.Warn("deleting stopped session snapshot failed", "err", deleteErr, "session", k.Name) | 655 | d.log.Warn("deleting stopped session snapshot failed", "err", deleteErr, "session", k.Name) | ||
M internal/usecase/daemon/notice_persist_test.go +0 -90
| @@ -1,17 +1,13 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "sync/atomic" | |
| 6 | 4 | "testing" | |
| 7 | 5 | ||
| 8 | - | "github.com/stretchr/testify/mock" | |
| 9 | 6 | "github.com/stretchr/testify/require" | |
| 10 | 7 | ||
| 11 | 8 | "github.com/bnema/vev/internal/domain" | |
| 12 | 9 | "github.com/bnema/vev/internal/ports" | |
| 13 | 10 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 14 | - | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 15 | 11 | ) | |
| 16 | 12 | ||
| 17 | 13 | // TestKillSessionServerShutdownSaturatedAppendsNotice proves that when the | |
| @@ -19,92 +15,6 @@ import ( | |||
| 19 | 15 | // no way to retain the session's terminal state and no attached client to | |
| 20 | 16 | // toast, because the daemon is dying — the failure is handed to the | |
| 21 | 17 | // NoticeStore instead of being silently lost. | |
| 22 | - | func TestKillSessionServerShutdownSaturatedAppendsNotice(t *testing.T) { | |
| 23 | - | store := discardSnapshotStore{} | |
| 24 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 25 | - | WithSnapshotStore(store)(d) | |
| 26 | - | startSnapshotEncodeWorker(t, d) | |
| 27 | - | ||
| 28 | - | entered := make(chan struct{}) | |
| 29 | - | release := make(chan struct{}) | |
| 30 | - | defer close(release) | |
| 31 | - | var calls atomic.Int32 | |
| 32 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |
| 33 | - | if calls.Add(1) == 1 { | |
| 34 | - | close(entered) | |
| 35 | - | <-release | |
| 36 | - | } | |
| 37 | - | return snapcodec.Marshal(s) | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | active := newSnapshotTestSession(t, "active", false, "/work") | |
| 41 | - | queued := newSnapshotTestSession(t, "queued", false, "/work") | |
| 42 | - | markSnapshotDirty(active) | |
| 43 | - | markSnapshotDirty(queued) | |
| 44 | - | require.True(t, d.scheduleSnapshot(active)) | |
| 45 | - | <-entered // the worker is now parked inside marshal for active | |
| 46 | - | require.True(t, d.scheduleSnapshot(queued), "one capture waits in the bounded queue") | |
| 47 | - | for i := range snapshotFinalQueueCapacity { | |
| 48 | - | s := newSnapshotTestSession(t, fmt.Sprintf("terminal-%d", i), false, "/work") | |
| 49 | - | markSnapshotDirty(s) | |
| 50 | - | require.True(t, d.scheduleFinalSnapshot(s), "fill the terminal-retention fallback to capacity") | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | target := newSnapshotTestSession(t, "target", false, "/work") | |
| 54 | - | targetPTY, ok := target.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |
| 55 | - | require.True(t, ok, "target pane PTY must be a MockPTY") | |
| 56 | - | targetPTY.EXPECT().Close().Return(nil).Maybe() | |
| 57 | - | d.mu.Lock() | |
| 58 | - | d.sessions[target.id] = target | |
| 59 | - | d.mu.Unlock() | |
| 60 | - | markSnapshotDirty(target) | |
| 61 | - | ||
| 62 | - | notices := portsmocks.NewMockNoticeStore(t) | |
| 63 | - | var appended domain.Notification | |
| 64 | - | notices.EXPECT().Append(mock.Anything).RunAndReturn(func(n domain.Notification) error { | |
| 65 | - | appended = n | |
| 66 | - | return nil | |
| 67 | - | }).Once() | |
| 68 | - | d.noticeStore = notices | |
| 69 | - | ||
| 70 | - | err := d.killSession(target, ports.ReasonServerShutdown, false) | |
| 71 | - | require.ErrorContains(t, err, "snapshot worker unavailable or saturated") | |
| 72 | - | ||
| 73 | - | d.mu.Lock() | |
| 74 | - | _, stillPresent := d.sessions[target.id] | |
| 75 | - | d.mu.Unlock() | |
| 76 | - | require.False(t, stillPresent, "the session must be torn down even though its state could not be saved") | |
| 77 | - | ||
| 78 | - | require.Equal(t, domain.NoticeSnapshotWrite, appended.Code) | |
| 79 | - | require.Equal(t, domain.NoticeError, appended.Severity) | |
| 80 | - | require.Equal(t, "session target shut down without saving terminal state", appended.Message) | |
| 81 | - | require.NotEmpty(t, appended.Details) | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | // TestKillSessionServerShutdownNotSaturatedSkipsAppend proves the notice store | |
| 85 | - | // is left untouched when the final snapshot is retained normally. | |
| 86 | - | func TestKillSessionServerShutdownNotSaturatedSkipsAppend(t *testing.T) { | |
| 87 | - | store := discardSnapshotStore{} | |
| 88 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 89 | - | WithSnapshotStore(store)(d) | |
| 90 | - | startSnapshotEncodeWorker(t, d) | |
| 91 | - | ||
| 92 | - | notices := portsmocks.NewMockNoticeStore(t) // no .EXPECT(): Append must not be called | |
| 93 | - | d.noticeStore = notices | |
| 94 | - | ||
| 95 | - | target := newSnapshotTestSession(t, "target", false, "/work") | |
| 96 | - | targetPTY, ok := target.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |
| 97 | - | require.True(t, ok, "target pane PTY must be a MockPTY") | |
| 98 | - | targetPTY.EXPECT().Close().Return(nil).Maybe() | |
| 99 | - | d.mu.Lock() | |
| 100 | - | d.sessions[target.id] = target | |
| 101 | - | d.mu.Unlock() | |
| 102 | - | markSnapshotDirty(target) | |
| 103 | - | ||
| 104 | - | err := d.killSession(target, ports.ReasonServerShutdown, false) | |
| 105 | - | require.NoError(t, err) | |
| 106 | - | } | |
| 107 | - | ||
| 108 | 18 | // TestStartupClaimRecordsAndToastsPendingNotices proves notices persisted by | |
| 109 | 19 | // a previous daemon are recorded to history and toasted to the first client | |
| 110 | 20 | // that attaches after this daemon starts before their claim is acknowledged. | |
| @@ -1,17 +1,13 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "fmt" | |||
| 5 | - | "sync/atomic" | |||
| 6 | "testing" | 4 | "testing" | ||
| 7 | 5 | ||||
| 8 | - | "github.com/stretchr/testify/mock" | |||
| 9 | "github.com/stretchr/testify/require" | 6 | "github.com/stretchr/testify/require" | ||
| 10 | 7 | ||||
| 11 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| 12 | "github.com/bnema/vev/internal/ports" | 9 | "github.com/bnema/vev/internal/ports" | ||
| 13 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 10 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 14 | - | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 15 | ) | 11 | ) | ||
| 16 | 12 | ||||
| 17 | // TestKillSessionServerShutdownSaturatedAppendsNotice proves that when the | 13 | // TestKillSessionServerShutdownSaturatedAppendsNotice proves that when the | ||
| @@ -19,92 +15,6 @@ import ( | |||||
| 19 | // no way to retain the session's terminal state and no attached client to | 15 | // no way to retain the session's terminal state and no attached client to | ||
| 20 | // toast, because the daemon is dying — the failure is handed to the | 16 | // toast, because the daemon is dying — the failure is handed to the | ||
| 21 | // NoticeStore instead of being silently lost. | 17 | // NoticeStore instead of being silently lost. | ||
| 22 | - | func TestKillSessionServerShutdownSaturatedAppendsNotice(t *testing.T) { | |||
| 23 | - | store := discardSnapshotStore{} | |||
| 24 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 25 | - | WithSnapshotStore(store)(d) | |||
| 26 | - | startSnapshotEncodeWorker(t, d) | |||
| 27 | - | ||||
| 28 | - | entered := make(chan struct{}) | |||
| 29 | - | release := make(chan struct{}) | |||
| 30 | - | defer close(release) | |||
| 31 | - | var calls atomic.Int32 | |||
| 32 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |||
| 33 | - | if calls.Add(1) == 1 { | |||
| 34 | - | close(entered) | |||
| 35 | - | <-release | |||
| 36 | - | } | |||
| 37 | - | return snapcodec.Marshal(s) | |||
| 38 | - | } | |||
| 39 | - | ||||
| 40 | - | active := newSnapshotTestSession(t, "active", false, "/work") | |||
| 41 | - | queued := newSnapshotTestSession(t, "queued", false, "/work") | |||
| 42 | - | markSnapshotDirty(active) | |||
| 43 | - | markSnapshotDirty(queued) | |||
| 44 | - | require.True(t, d.scheduleSnapshot(active)) | |||
| 45 | - | <-entered // the worker is now parked inside marshal for active | |||
| 46 | - | require.True(t, d.scheduleSnapshot(queued), "one capture waits in the bounded queue") | |||
| 47 | - | for i := range snapshotFinalQueueCapacity { | |||
| 48 | - | s := newSnapshotTestSession(t, fmt.Sprintf("terminal-%d", i), false, "/work") | |||
| 49 | - | markSnapshotDirty(s) | |||
| 50 | - | require.True(t, d.scheduleFinalSnapshot(s), "fill the terminal-retention fallback to capacity") | |||
| 51 | - | } | |||
| 52 | - | ||||
| 53 | - | target := newSnapshotTestSession(t, "target", false, "/work") | |||
| 54 | - | targetPTY, ok := target.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |||
| 55 | - | require.True(t, ok, "target pane PTY must be a MockPTY") | |||
| 56 | - | targetPTY.EXPECT().Close().Return(nil).Maybe() | |||
| 57 | - | d.mu.Lock() | |||
| 58 | - | d.sessions[target.id] = target | |||
| 59 | - | d.mu.Unlock() | |||
| 60 | - | markSnapshotDirty(target) | |||
| 61 | - | ||||
| 62 | - | notices := portsmocks.NewMockNoticeStore(t) | |||
| 63 | - | var appended domain.Notification | |||
| 64 | - | notices.EXPECT().Append(mock.Anything).RunAndReturn(func(n domain.Notification) error { | |||
| 65 | - | appended = n | |||
| 66 | - | return nil | |||
| 67 | - | }).Once() | |||
| 68 | - | d.noticeStore = notices | |||
| 69 | - | ||||
| 70 | - | err := d.killSession(target, ports.ReasonServerShutdown, false) | |||
| 71 | - | require.ErrorContains(t, err, "snapshot worker unavailable or saturated") | |||
| 72 | - | ||||
| 73 | - | d.mu.Lock() | |||
| 74 | - | _, stillPresent := d.sessions[target.id] | |||
| 75 | - | d.mu.Unlock() | |||
| 76 | - | require.False(t, stillPresent, "the session must be torn down even though its state could not be saved") | |||
| 77 | - | ||||
| 78 | - | require.Equal(t, domain.NoticeSnapshotWrite, appended.Code) | |||
| 79 | - | require.Equal(t, domain.NoticeError, appended.Severity) | |||
| 80 | - | require.Equal(t, "session target shut down without saving terminal state", appended.Message) | |||
| 81 | - | require.NotEmpty(t, appended.Details) | |||
| 82 | - | } | |||
| 83 | - | ||||
| 84 | - | // TestKillSessionServerShutdownNotSaturatedSkipsAppend proves the notice store | |||
| 85 | - | // is left untouched when the final snapshot is retained normally. | |||
| 86 | - | func TestKillSessionServerShutdownNotSaturatedSkipsAppend(t *testing.T) { | |||
| 87 | - | store := discardSnapshotStore{} | |||
| 88 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 89 | - | WithSnapshotStore(store)(d) | |||
| 90 | - | startSnapshotEncodeWorker(t, d) | |||
| 91 | - | ||||
| 92 | - | notices := portsmocks.NewMockNoticeStore(t) // no .EXPECT(): Append must not be called | |||
| 93 | - | d.noticeStore = notices | |||
| 94 | - | ||||
| 95 | - | target := newSnapshotTestSession(t, "target", false, "/work") | |||
| 96 | - | targetPTY, ok := target.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |||
| 97 | - | require.True(t, ok, "target pane PTY must be a MockPTY") | |||
| 98 | - | targetPTY.EXPECT().Close().Return(nil).Maybe() | |||
| 99 | - | d.mu.Lock() | |||
| 100 | - | d.sessions[target.id] = target | |||
| 101 | - | d.mu.Unlock() | |||
| 102 | - | markSnapshotDirty(target) | |||
| 103 | - | ||||
| 104 | - | err := d.killSession(target, ports.ReasonServerShutdown, false) | |||
| 105 | - | require.NoError(t, err) | |||
| 106 | - | } | |||
| 107 | - | ||||
| 108 | // TestStartupClaimRecordsAndToastsPendingNotices proves notices persisted by | 18 | // TestStartupClaimRecordsAndToastsPendingNotices proves notices persisted by | ||
| 109 | // a previous daemon are recorded to history and toasted to the first client | 19 | // a previous daemon are recorded to history and toasted to the first client | ||
| 110 | // that attaches after this daemon starts before their claim is acknowledged. | 20 | // that attaches after this daemon starts before their claim is acknowledged. | ||
M internal/usecase/daemon/performance_benchmark_test.go +31 -23
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "fmt" | |
| 5 | 6 | "io" | |
| 6 | 7 | "sync" | |
| @@ -15,16 +16,16 @@ import ( | |||
| 15 | 16 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| 18 | - | func TestCountingSnapshotStoreWriteCountsAndRetainsOpaquePayload(t *testing.T) { | |
| 19 | - | store := &countingSnapshotStore{} | |
| 19 | + | func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) { | |
| 20 | + | repository := &countingSnapshotRepository{} | |
| 20 | 21 | payload := []byte{0xff, 0x00, 0xfe} | |
| 21 | 22 | ||
| 22 | - | require.NoError(t, store.Write("session", payload)) | |
| 23 | - | require.Equal(t, countingSnapshotMetrics{writes: 1, bytes: uint64(len(payload))}, store.metrics()) | |
| 24 | - | require.Equal(t, payload, store.lastPayload()) | |
| 23 | + | require.NoError(t, repository.Publish(context.Background(), ports.SnapshotPublication{Name: "session", Manifest: payload})) | |
| 24 | + | require.Equal(t, countingSnapshotMetrics{writes: 1, bytes: uint64(len(payload))}, repository.metrics()) | |
| 25 | + | require.Equal(t, payload, repository.lastPayload()) | |
| 25 | 26 | ||
| 26 | 27 | payload[0] = 0x01 | |
| 27 | - | require.Equal(t, payload, store.lastPayload(), "the counting sink must retain the payload slice header, not copy it") | |
| 28 | + | require.Equal(t, payload, repository.lastPayload(), "the counting sink must retain the payload slice header, not copy it") | |
| 28 | 29 | } | |
| 29 | 30 | ||
| 30 | 31 | func TestCountingOutputTransportCountsOpaquePayloadAndRejectsShortPayload(t *testing.T) { | |
| @@ -157,13 +158,13 @@ func TestPerformanceFixtureSnapshotCaptureRetainsSealedHistory(t *testing.T) { | |||
| 157 | 158 | require.True(t, ok) | |
| 158 | 159 | require.Len(t, first.tabs, 1) | |
| 159 | 160 | require.Len(t, first.tabs[0].panes, 2) | |
| 160 | - | require.Equal(t, 10_000, first.tabs[0].panes[0].history.Len()) | |
| 161 | - | require.Equal(t, 40, first.tabs[0].panes[0].history.ChunkCount()) | |
| 161 | + | require.Equal(t, 10_000, first.tabs[0].panes[0].sealed.Len()) | |
| 162 | + | require.Positive(t, first.tabs[0].panes[0].sealed.ChunkCount()) | |
| 162 | 163 | ||
| 163 | 164 | second, ok := fixture.d.captureSnapshotState(fixture.sess, 2) | |
| 164 | 165 | require.True(t, ok) | |
| 165 | 166 | secondPane := capturePaneByID(second, first.tabs[0].panes[0].id) | |
| 166 | - | if secondPane == nil || first.tabs[0].panes[0].history.Chunk(0) != secondPane.history.Chunk(0) { | |
| 167 | + | if secondPane == nil || first.tabs[0].panes[0].sealed.Chunk(0) != secondPane.sealed.Chunk(0) { | |
| 167 | 168 | t.Fatal("unchanged sealed history chunk was not reused across captures") | |
| 168 | 169 | } | |
| 169 | 170 | } | |
| @@ -674,7 +675,7 @@ type performanceFixture struct { | |||
| 674 | 675 | sess *session | |
| 675 | 676 | ac *attachedClient | |
| 676 | 677 | output *countingOutputTransport | |
| 677 | - | snaps *countingSnapshotStore | |
| 678 | + | snapshots *countingSnapshotRepository | |
| 678 | 679 | activePane *pane | |
| 679 | 680 | liveWrites [][]byte | |
| 680 | 681 | paints int | |
| @@ -727,13 +728,13 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF | |||
| 727 | 728 | sess: sess, | |
| 728 | 729 | ac: ac, | |
| 729 | 730 | output: output, | |
| 730 | - | snaps: &countingSnapshotStore{}, | |
| 731 | + | snapshots: &countingSnapshotRepository{}, | |
| 731 | 732 | pty: pty, | |
| 732 | 733 | clock: clock, | |
| 733 | 734 | liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")}, | |
| 734 | 735 | resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size}, | |
| 735 | 736 | } | |
| 736 | - | WithSnapshotStore(fixture.snaps)(d) | |
| 737 | + | WithSnapshotRepository(fixture.snapshots, nil)(d) | |
| 737 | 738 | d.startSnapshotEncodeWorker() | |
| 738 | 739 | t.Cleanup(d.stopSnapshotEncodeWorker) | |
| 739 | 740 | for tabIndex, tb := range sess.tabs { | |
| @@ -927,7 +928,7 @@ func (f *performanceFixture) resized() bool { | |||
| 927 | 928 | ||
| 928 | 929 | func (f *performanceFixture) resetMetrics() { | |
| 929 | 930 | f.output.reset() | |
| 930 | - | f.snaps.reset() | |
| 931 | + | f.snapshots.reset() | |
| 931 | 932 | f.renderCaptures = 0 | |
| 932 | 933 | f.renderCompositions = 0 | |
| 933 | 934 | f.renderEmissions = 0 | |
| @@ -943,7 +944,7 @@ func (f *performanceFixture) resetMetrics() { | |||
| 943 | 944 | ||
| 944 | 945 | func (f *performanceFixture) metrics() performanceMetrics { | |
| 945 | 946 | output := f.output.metrics() | |
| 946 | - | snapshots := f.snaps.metrics() | |
| 947 | + | snapshots := f.snapshots.metrics() | |
| 947 | 948 | metrics := performanceMetrics{ | |
| 948 | 949 | outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes, | |
| 949 | 950 | snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes, | |
| @@ -1011,34 +1012,41 @@ func (t *countingOutputTransport) lastPayload() []byte { | |||
| 1011 | 1012 | } | |
| 1012 | 1013 | ||
| 1013 | 1014 | type countingSnapshotMetrics struct{ writes, bytes uint64 } | |
| 1014 | - | type countingSnapshotStore struct { | |
| 1015 | + | type countingSnapshotRepository struct { | |
| 1015 | 1016 | mu sync.Mutex | |
| 1016 | 1017 | countingSnapshotMetrics | |
| 1017 | 1018 | last []byte | |
| 1018 | 1019 | } | |
| 1019 | 1020 | ||
| 1020 | - | func (s *countingSnapshotStore) Write(_ string, data []byte) error { | |
| 1021 | + | func (s *countingSnapshotRepository) Publish(_ context.Context, publication ports.SnapshotPublication) error { | |
| 1021 | 1022 | s.mu.Lock() | |
| 1022 | 1023 | s.writes++ | |
| 1023 | - | s.bytes += uint64(len(data)) | |
| 1024 | - | s.last = data | |
| 1024 | + | s.bytes += uint64(len(publication.Manifest)) | |
| 1025 | + | for _, object := range publication.Objects { | |
| 1026 | + | s.bytes += uint64(len(object.Data)) | |
| 1027 | + | } | |
| 1028 | + | s.last = publication.Manifest | |
| 1025 | 1029 | s.mu.Unlock() | |
| 1026 | 1030 | return nil | |
| 1027 | 1031 | } | |
| 1028 | - | func (*countingSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil } | |
| 1029 | - | func (*countingSnapshotStore) Delete(string) error { return nil } | |
| 1030 | - | func (s *countingSnapshotStore) reset() { | |
| 1032 | + | func (*countingSnapshotRepository) List(context.Context) ([]string, error) { return nil, nil } | |
| 1033 | + | func (*countingSnapshotRepository) Load(context.Context, string) (ports.SnapshotGeneration, error) { | |
| 1034 | + | return ports.SnapshotGeneration{}, nil | |
| 1035 | + | } | |
| 1036 | + | func (*countingSnapshotRepository) Delete(context.Context, string) error { return nil } | |
| 1037 | + | func (*countingSnapshotRepository) Maintain(context.Context) error { return nil } | |
| 1038 | + | func (s *countingSnapshotRepository) reset() { | |
| 1031 | 1039 | s.mu.Lock() | |
| 1032 | 1040 | s.countingSnapshotMetrics = countingSnapshotMetrics{} | |
| 1033 | 1041 | s.last = nil | |
| 1034 | 1042 | s.mu.Unlock() | |
| 1035 | 1043 | } | |
| 1036 | - | func (s *countingSnapshotStore) metrics() countingSnapshotMetrics { | |
| 1044 | + | func (s *countingSnapshotRepository) metrics() countingSnapshotMetrics { | |
| 1037 | 1045 | s.mu.Lock() | |
| 1038 | 1046 | defer s.mu.Unlock() | |
| 1039 | 1047 | return s.countingSnapshotMetrics | |
| 1040 | 1048 | } | |
| 1041 | - | func (s *countingSnapshotStore) lastPayload() []byte { | |
| 1049 | + | func (s *countingSnapshotRepository) lastPayload() []byte { | |
| 1042 | 1050 | s.mu.Lock() | |
| 1043 | 1051 | defer s.mu.Unlock() | |
| 1044 | 1052 | return s.last | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "fmt" | 5 | "fmt" | ||
| 5 | "io" | 6 | "io" | ||
| 6 | "sync" | 7 | "sync" | ||
| @@ -15,16 +16,16 @@ import ( | |||||
| 15 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | 16 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | ||
| 16 | ) | 17 | ) | ||
| 17 | 18 | ||||
| 18 | - | func TestCountingSnapshotStoreWriteCountsAndRetainsOpaquePayload(t *testing.T) { | 19 | + | func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) { |
| 19 | - | store := &countingSnapshotStore{} | 20 | + | repository := &countingSnapshotRepository{} |
| 20 | payload := []byte{0xff, 0x00, 0xfe} | 21 | payload := []byte{0xff, 0x00, 0xfe} | ||
| 21 | 22 | ||||
| 22 | - | require.NoError(t, store.Write("session", payload)) | 23 | + | require.NoError(t, repository.Publish(context.Background(), ports.SnapshotPublication{Name: "session", Manifest: payload})) |
| 23 | - | require.Equal(t, countingSnapshotMetrics{writes: 1, bytes: uint64(len(payload))}, store.metrics()) | 24 | + | require.Equal(t, countingSnapshotMetrics{writes: 1, bytes: uint64(len(payload))}, repository.metrics()) |
| 24 | - | require.Equal(t, payload, store.lastPayload()) | 25 | + | require.Equal(t, payload, repository.lastPayload()) |
| 25 | 26 | ||||
| 26 | payload[0] = 0x01 | 27 | payload[0] = 0x01 | ||
| 27 | - | require.Equal(t, payload, store.lastPayload(), "the counting sink must retain the payload slice header, not copy it") | 28 | + | require.Equal(t, payload, repository.lastPayload(), "the counting sink must retain the payload slice header, not copy it") |
| 28 | } | 29 | } | ||
| 29 | 30 | ||||
| 30 | func TestCountingOutputTransportCountsOpaquePayloadAndRejectsShortPayload(t *testing.T) { | 31 | func TestCountingOutputTransportCountsOpaquePayloadAndRejectsShortPayload(t *testing.T) { | ||
| @@ -157,13 +158,13 @@ func TestPerformanceFixtureSnapshotCaptureRetainsSealedHistory(t *testing.T) { | |||||
| 157 | require.True(t, ok) | 158 | require.True(t, ok) | ||
| 158 | require.Len(t, first.tabs, 1) | 159 | require.Len(t, first.tabs, 1) | ||
| 159 | require.Len(t, first.tabs[0].panes, 2) | 160 | require.Len(t, first.tabs[0].panes, 2) | ||
| 160 | - | require.Equal(t, 10_000, first.tabs[0].panes[0].history.Len()) | 161 | + | require.Equal(t, 10_000, first.tabs[0].panes[0].sealed.Len()) |
| 161 | - | require.Equal(t, 40, first.tabs[0].panes[0].history.ChunkCount()) | 162 | + | require.Positive(t, first.tabs[0].panes[0].sealed.ChunkCount()) |
| 162 | 163 | ||||
| 163 | second, ok := fixture.d.captureSnapshotState(fixture.sess, 2) | 164 | second, ok := fixture.d.captureSnapshotState(fixture.sess, 2) | ||
| 164 | require.True(t, ok) | 165 | require.True(t, ok) | ||
| 165 | secondPane := capturePaneByID(second, first.tabs[0].panes[0].id) | 166 | secondPane := capturePaneByID(second, first.tabs[0].panes[0].id) | ||
| 166 | - | if secondPane == nil || first.tabs[0].panes[0].history.Chunk(0) != secondPane.history.Chunk(0) { | 167 | + | if secondPane == nil || first.tabs[0].panes[0].sealed.Chunk(0) != secondPane.sealed.Chunk(0) { |
| 167 | t.Fatal("unchanged sealed history chunk was not reused across captures") | 168 | t.Fatal("unchanged sealed history chunk was not reused across captures") | ||
| 168 | } | 169 | } | ||
| 169 | } | 170 | } | ||
| @@ -674,7 +675,7 @@ type performanceFixture struct { | |||||
| 674 | sess *session | 675 | sess *session | ||
| 675 | ac *attachedClient | 676 | ac *attachedClient | ||
| 676 | output *countingOutputTransport | 677 | output *countingOutputTransport | ||
| 677 | - | snaps *countingSnapshotStore | 678 | + | snapshots *countingSnapshotRepository |
| 678 | activePane *pane | 679 | activePane *pane | ||
| 679 | liveWrites [][]byte | 680 | liveWrites [][]byte | ||
| 680 | paints int | 681 | paints int | ||
| @@ -727,13 +728,13 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF | |||||
| 727 | sess: sess, | 728 | sess: sess, | ||
| 728 | ac: ac, | 729 | ac: ac, | ||
| 729 | output: output, | 730 | output: output, | ||
| 730 | - | snaps: &countingSnapshotStore{}, | 731 | + | snapshots: &countingSnapshotRepository{}, |
| 731 | pty: pty, | 732 | pty: pty, | ||
| 732 | clock: clock, | 733 | clock: clock, | ||
| 733 | liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")}, | 734 | liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")}, | ||
| 734 | resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size}, | 735 | resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size}, | ||
| 735 | } | 736 | } | ||
| 736 | - | WithSnapshotStore(fixture.snaps)(d) | 737 | + | WithSnapshotRepository(fixture.snapshots, nil)(d) |
| 737 | d.startSnapshotEncodeWorker() | 738 | d.startSnapshotEncodeWorker() | ||
| 738 | t.Cleanup(d.stopSnapshotEncodeWorker) | 739 | t.Cleanup(d.stopSnapshotEncodeWorker) | ||
| 739 | for tabIndex, tb := range sess.tabs { | 740 | for tabIndex, tb := range sess.tabs { | ||
| @@ -927,7 +928,7 @@ func (f *performanceFixture) resized() bool { | |||||
| 927 | 928 | ||||
| 928 | func (f *performanceFixture) resetMetrics() { | 929 | func (f *performanceFixture) resetMetrics() { | ||
| 929 | f.output.reset() | 930 | f.output.reset() | ||
| 930 | - | f.snaps.reset() | 931 | + | f.snapshots.reset() |
| 931 | f.renderCaptures = 0 | 932 | f.renderCaptures = 0 | ||
| 932 | f.renderCompositions = 0 | 933 | f.renderCompositions = 0 | ||
| 933 | f.renderEmissions = 0 | 934 | f.renderEmissions = 0 | ||
| @@ -943,7 +944,7 @@ func (f *performanceFixture) resetMetrics() { | |||||
| 943 | 944 | ||||
| 944 | func (f *performanceFixture) metrics() performanceMetrics { | 945 | func (f *performanceFixture) metrics() performanceMetrics { | ||
| 945 | output := f.output.metrics() | 946 | output := f.output.metrics() | ||
| 946 | - | snapshots := f.snaps.metrics() | 947 | + | snapshots := f.snapshots.metrics() |
| 947 | metrics := performanceMetrics{ | 948 | metrics := performanceMetrics{ | ||
| 948 | outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes, | 949 | outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes, | ||
| 949 | snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes, | 950 | snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes, | ||
| @@ -1011,34 +1012,41 @@ func (t *countingOutputTransport) lastPayload() []byte { | |||||
| 1011 | } | 1012 | } | ||
| 1012 | 1013 | ||||
| 1013 | type countingSnapshotMetrics struct{ writes, bytes uint64 } | 1014 | type countingSnapshotMetrics struct{ writes, bytes uint64 } | ||
| 1014 | - | type countingSnapshotStore struct { | 1015 | + | type countingSnapshotRepository struct { |
| 1015 | mu sync.Mutex | 1016 | mu sync.Mutex | ||
| 1016 | countingSnapshotMetrics | 1017 | countingSnapshotMetrics | ||
| 1017 | last []byte | 1018 | last []byte | ||
| 1018 | } | 1019 | } | ||
| 1019 | 1020 | ||||
| 1020 | - | func (s *countingSnapshotStore) Write(_ string, data []byte) error { | 1021 | + | func (s *countingSnapshotRepository) Publish(_ context.Context, publication ports.SnapshotPublication) error { |
| 1021 | s.mu.Lock() | 1022 | s.mu.Lock() | ||
| 1022 | s.writes++ | 1023 | s.writes++ | ||
| 1023 | - | s.bytes += uint64(len(data)) | 1024 | + | s.bytes += uint64(len(publication.Manifest)) |
| 1024 | - | s.last = data | 1025 | + | for _, object := range publication.Objects { |
| 1026 | + | s.bytes += uint64(len(object.Data)) | |||
| 1027 | + | } | |||
| 1028 | + | s.last = publication.Manifest | |||
| 1025 | s.mu.Unlock() | 1029 | s.mu.Unlock() | ||
| 1026 | return nil | 1030 | return nil | ||
| 1027 | } | 1031 | } | ||
| 1028 | - | func (*countingSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil } | 1032 | + | func (*countingSnapshotRepository) List(context.Context) ([]string, error) { return nil, nil } |
| 1029 | - | func (*countingSnapshotStore) Delete(string) error { return nil } | 1033 | + | func (*countingSnapshotRepository) Load(context.Context, string) (ports.SnapshotGeneration, error) { |
| 1030 | - | func (s *countingSnapshotStore) reset() { | 1034 | + | return ports.SnapshotGeneration{}, nil |
| 1035 | + | } | |||
| 1036 | + | func (*countingSnapshotRepository) Delete(context.Context, string) error { return nil } | |||
| 1037 | + | func (*countingSnapshotRepository) Maintain(context.Context) error { return nil } | |||
| 1038 | + | func (s *countingSnapshotRepository) reset() { | |||
| 1031 | s.mu.Lock() | 1039 | s.mu.Lock() | ||
| 1032 | s.countingSnapshotMetrics = countingSnapshotMetrics{} | 1040 | s.countingSnapshotMetrics = countingSnapshotMetrics{} | ||
| 1033 | s.last = nil | 1041 | s.last = nil | ||
| 1034 | s.mu.Unlock() | 1042 | s.mu.Unlock() | ||
| 1035 | } | 1043 | } | ||
| 1036 | - | func (s *countingSnapshotStore) metrics() countingSnapshotMetrics { | 1044 | + | func (s *countingSnapshotRepository) metrics() countingSnapshotMetrics { |
| 1037 | s.mu.Lock() | 1045 | s.mu.Lock() | ||
| 1038 | defer s.mu.Unlock() | 1046 | defer s.mu.Unlock() | ||
| 1039 | return s.countingSnapshotMetrics | 1047 | return s.countingSnapshotMetrics | ||
| 1040 | } | 1048 | } | ||
| 1041 | - | func (s *countingSnapshotStore) lastPayload() []byte { | 1049 | + | func (s *countingSnapshotRepository) lastPayload() []byte { |
| 1042 | s.mu.Lock() | 1050 | s.mu.Lock() | ||
| 1043 | defer s.mu.Unlock() | 1051 | defer s.mu.Unlock() | ||
| 1044 | return s.last | 1052 | return s.last | ||
M internal/usecase/daemon/picker_test.go +11 -5
| @@ -35,11 +35,17 @@ func (failingDeleteStore) Range(func(k, v []byte) bool) {} | |||
| 35 | 35 | func (failingDeleteStore) Sync() error { return nil } | |
| 36 | 36 | func (failingDeleteStore) Close() error { return nil } | |
| 37 | 37 | ||
| 38 | - | type refusingSnapshotDeleteStore struct{ err error } | |
| 38 | + | type refusingSnapshotDeleteRepository struct{ err error } | |
| 39 | 39 | ||
| 40 | - | func (refusingSnapshotDeleteStore) Write(string, []byte) error { return nil } | |
| 41 | - | func (refusingSnapshotDeleteStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil } | |
| 42 | - | func (s refusingSnapshotDeleteStore) Delete(string) error { return s.err } | |
| 40 | + | func (refusingSnapshotDeleteRepository) Publish(context.Context, ports.SnapshotPublication) error { | |
| 41 | + | return nil | |
| 42 | + | } | |
| 43 | + | func (refusingSnapshotDeleteRepository) List(context.Context) ([]string, error) { return nil, nil } | |
| 44 | + | func (refusingSnapshotDeleteRepository) Load(context.Context, string) (ports.SnapshotGeneration, error) { | |
| 45 | + | return ports.SnapshotGeneration{}, nil | |
| 46 | + | } | |
| 47 | + | func (s refusingSnapshotDeleteRepository) Delete(context.Context, string) error { return s.err } | |
| 48 | + | func (refusingSnapshotDeleteRepository) Maintain(context.Context) error { return nil } | |
| 43 | 49 | ||
| 44 | 50 | func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab { | |
| 45 | 51 | tb := newTab(p, domain.Size{Cols: 80, Rows: 23}) | |
| @@ -998,7 +1004,7 @@ func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t | |||
| 998 | 1004 | d, from, ac, sends, releases := newRecentNavigationTestSessions(t) | |
| 999 | 1005 | defer releaseAll(releases) | |
| 1000 | 1006 | cause := errors.New("snapshot delete refused") | |
| 1001 | - | WithSnapshotStore(refusingSnapshotDeleteStore{err: cause})(d) | |
| 1007 | + | WithSnapshotRepository(refusingSnapshotDeleteRepository{err: cause}, nil)(d) | |
| 1002 | 1008 | target := d.sessions[domain.SessionID("recent")] | |
| 1003 | 1009 | ||
| 1004 | 1010 | d.enterPicker(from, ac) | |
| @@ -35,11 +35,17 @@ func (failingDeleteStore) Range(func(k, v []byte) bool) {} | |||||
| 35 | func (failingDeleteStore) Sync() error { return nil } | 35 | func (failingDeleteStore) Sync() error { return nil } | ||
| 36 | func (failingDeleteStore) Close() error { return nil } | 36 | func (failingDeleteStore) Close() error { return nil } | ||
| 37 | 37 | ||||
| 38 | - | type refusingSnapshotDeleteStore struct{ err error } | 38 | + | type refusingSnapshotDeleteRepository struct{ err error } |
| 39 | 39 | ||||
| 40 | - | func (refusingSnapshotDeleteStore) Write(string, []byte) error { return nil } | 40 | + | func (refusingSnapshotDeleteRepository) Publish(context.Context, ports.SnapshotPublication) error { |
| 41 | - | func (refusingSnapshotDeleteStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil } | 41 | + | return nil |
| 42 | - | func (s refusingSnapshotDeleteStore) Delete(string) error { return s.err } | 42 | + | } |
| 43 | + | func (refusingSnapshotDeleteRepository) List(context.Context) ([]string, error) { return nil, nil } | |||
| 44 | + | func (refusingSnapshotDeleteRepository) Load(context.Context, string) (ports.SnapshotGeneration, error) { | |||
| 45 | + | return ports.SnapshotGeneration{}, nil | |||
| 46 | + | } | |||
| 47 | + | func (s refusingSnapshotDeleteRepository) Delete(context.Context, string) error { return s.err } | |||
| 48 | + | func (refusingSnapshotDeleteRepository) Maintain(context.Context) error { return nil } | |||
| 43 | 49 | ||||
| 44 | func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab { | 50 | func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab { | ||
| 45 | tb := newTab(p, domain.Size{Cols: 80, Rows: 23}) | 51 | tb := newTab(p, domain.Size{Cols: 80, Rows: 23}) | ||
| @@ -998,7 +1004,7 @@ func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t | |||||
| 998 | d, from, ac, sends, releases := newRecentNavigationTestSessions(t) | 1004 | d, from, ac, sends, releases := newRecentNavigationTestSessions(t) | ||
| 999 | defer releaseAll(releases) | 1005 | defer releaseAll(releases) | ||
| 1000 | cause := errors.New("snapshot delete refused") | 1006 | cause := errors.New("snapshot delete refused") | ||
| 1001 | - | WithSnapshotStore(refusingSnapshotDeleteStore{err: cause})(d) | 1007 | + | WithSnapshotRepository(refusingSnapshotDeleteRepository{err: cause}, nil)(d) |
| 1002 | target := d.sessions[domain.SessionID("recent")] | 1008 | target := d.sessions[domain.SessionID("recent")] | ||
| 1003 | 1009 | ||||
| 1004 | d.enterPicker(from, ac) | 1010 | d.enterPicker(from, ac) | ||
M internal/usecase/daemon/session.go +0 -16
| @@ -570,14 +570,6 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||
| 570 | 570 | sess.mu.Unlock() | |
| 571 | 571 | return err | |
| 572 | 572 | } | |
| 573 | - | } else if d.snapsEnabled && d.snaps != nil { | |
| 574 | - | if err := d.snaps.Delete(oldName); err != nil { | |
| 575 | - | if cleanupErr := d.persist.Delete(name); cleanupErr != nil { | |
| 576 | - | d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name) | |
| 577 | - | } | |
| 578 | - | sess.mu.Unlock() | |
| 579 | - | return err | |
| 580 | - | } | |
| 581 | 573 | } | |
| 582 | 574 | if err := d.persist.Delete(oldName); err != nil { | |
| 583 | 575 | if cleanupErr := d.persist.Delete(name); cleanupErr != nil { | |
| @@ -745,18 +737,10 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||
| 745 | 737 | var snapshotDeleteErr, terminalSnapshotErr error | |
| 746 | 738 | if d.snapsEnabled && !isEphemeral { | |
| 747 | 739 | if purge { | |
| 748 | - | sess.mu.Lock() | |
| 749 | - | name := sess.name | |
| 750 | - | sess.mu.Unlock() | |
| 751 | 740 | if d.snapshotRepository != nil { | |
| 752 | 741 | // Repository deletion is intentionally deferred until the render | |
| 753 | 742 | // coordinator is cancelled and joined below. See client.go's lock | |
| 754 | 743 | // ordering notes: no coordinator callback may race a purge. | |
| 755 | - | } else if d.snaps != nil { | |
| 756 | - | if err := d.snaps.Delete(name); err != nil { | |
| 757 | - | snapshotDeleteErr = err | |
| 758 | - | d.log.Warn("deleting session snapshot failed", "err", err, "session", name) | |
| 759 | - | } | |
| 760 | 744 | } | |
| 761 | 745 | } else { | |
| 762 | 746 | markSnapshotDirty(sess) | |
| @@ -570,14 +570,6 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||||
| 570 | sess.mu.Unlock() | 570 | sess.mu.Unlock() | ||
| 571 | return err | 571 | return err | ||
| 572 | } | 572 | } | ||
| 573 | - | } else if d.snapsEnabled && d.snaps != nil { | |||
| 574 | - | if err := d.snaps.Delete(oldName); err != nil { | |||
| 575 | - | if cleanupErr := d.persist.Delete(name); cleanupErr != nil { | |||
| 576 | - | d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name) | |||
| 577 | - | } | |||
| 578 | - | sess.mu.Unlock() | |||
| 579 | - | return err | |||
| 580 | - | } | |||
| 581 | } | 573 | } | ||
| 582 | if err := d.persist.Delete(oldName); err != nil { | 574 | if err := d.persist.Delete(oldName); err != nil { | ||
| 583 | if cleanupErr := d.persist.Delete(name); cleanupErr != nil { | 575 | if cleanupErr := d.persist.Delete(name); cleanupErr != nil { | ||
| @@ -745,18 +737,10 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||||
| 745 | var snapshotDeleteErr, terminalSnapshotErr error | 737 | var snapshotDeleteErr, terminalSnapshotErr error | ||
| 746 | if d.snapsEnabled && !isEphemeral { | 738 | if d.snapsEnabled && !isEphemeral { | ||
| 747 | if purge { | 739 | if purge { | ||
| 748 | - | sess.mu.Lock() | |||
| 749 | - | name := sess.name | |||
| 750 | - | sess.mu.Unlock() | |||
| 751 | if d.snapshotRepository != nil { | 740 | if d.snapshotRepository != nil { | ||
| 752 | // Repository deletion is intentionally deferred until the render | 741 | // Repository deletion is intentionally deferred until the render | ||
| 753 | // coordinator is cancelled and joined below. See client.go's lock | 742 | // coordinator is cancelled and joined below. See client.go's lock | ||
| 754 | // ordering notes: no coordinator callback may race a purge. | 743 | // ordering notes: no coordinator callback may race a purge. | ||
| 755 | - | } else if d.snaps != nil { | |||
| 756 | - | if err := d.snaps.Delete(name); err != nil { | |||
| 757 | - | snapshotDeleteErr = err | |||
| 758 | - | d.log.Warn("deleting session snapshot failed", "err", err, "session", name) | |||
| 759 | - | } | |||
| 760 | } | 744 | } | ||
| 761 | } else { | 745 | } else { | ||
| 762 | markSnapshotDirty(sess) | 746 | markSnapshotDirty(sess) | ||
M internal/usecase/daemon/snapshot.go +8 -86
| @@ -124,39 +124,8 @@ func (d *Daemon) restoreSnapshots(ctx context.Context) { | |||
| 124 | 124 | } | |
| 125 | 125 | if d.snapshotRepository != nil { | |
| 126 | 126 | d.restoreIncrementalSnapshots(ctx) | |
| 127 | - | return | |
| 128 | - | } | |
| 129 | - | if d.snaps == nil { | |
| 130 | - | return | |
| 131 | - | } | |
| 132 | - | blobs, err := d.snaps.Load() | |
| 133 | - | if err != nil { | |
| 134 | - | d.log.Warn("loading session snapshots failed", "err", err) | |
| 135 | - | d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore, | |
| 136 | - | "couldn't load saved sessions after restart", err) | |
| 137 | - | return | |
| 138 | - | } | |
| 139 | - | for _, blob := range blobs { | |
| 140 | - | select { | |
| 141 | - | case <-ctx.Done(): | |
| 142 | - | return | |
| 143 | - | default: | |
| 144 | - | } | |
| 145 | - | snap, err := snapcodec.Unmarshal(blob.Data) | |
| 146 | - | if err != nil { | |
| 147 | - | d.log.Warn("decoding session snapshot failed", "err", err, "session", blob.Name) | |
| 148 | - | d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore, | |
| 149 | - | "couldn't restore session "+blob.Name+" after restart", err) | |
| 150 | - | continue | |
| 151 | - | } | |
| 152 | - | if err := d.restoreSession(ctx, snap); err != nil { | |
| 153 | - | d.log.Warn("restoring session snapshot failed", "err", err, "session", snap.Name) | |
| 154 | - | if ctx.Err() == nil { | |
| 155 | - | d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore, | |
| 156 | - | "couldn't restore session "+snap.Name+" after restart", err) | |
| 157 | - | } | |
| 158 | - | } | |
| 159 | 127 | } | |
| 128 | + | ||
| 160 | 129 | } | |
| 161 | 130 | ||
| 162 | 131 | func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) error { | |
| @@ -410,14 +379,14 @@ func (d *Daemon) scheduleSnapshot(sess *session) bool { | |||
| 410 | 379 | } | |
| 411 | 380 | ||
| 412 | 381 | // scheduleFinalSnapshot captures the terminal state even when an older capture | |
| 413 | - | // is pending. A removed session cannot be retried by snapshotSaver, so the | |
| 382 | + | // is pending. A removed session cannot be retried by the repository scheduler, so the | |
| 414 | 383 | // worker drains its retained terminal state before it stops. | |
| 415 | 384 | func (d *Daemon) scheduleFinalSnapshot(sess *session) bool { | |
| 416 | 385 | return d.scheduleSnapshotWithFinalFallback(sess, true) | |
| 417 | 386 | } | |
| 418 | 387 | ||
| 419 | 388 | func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bool { | |
| 420 | - | if d == nil || sess == nil || !d.snapsEnabled || (d.snapshotRepository == nil && d.snaps == nil) || !sess.snapEligible.Load() { | |
| 389 | + | if d == nil || sess == nil || !d.snapsEnabled || d.snapshotRepository == nil || !sess.snapEligible.Load() { | |
| 421 | 390 | return true | |
| 422 | 391 | } | |
| 423 | 392 | kind := snapshotAttemptRoutine | |
| @@ -565,12 +534,8 @@ func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapsh | |||
| 565 | 534 | visible: visible, | |
| 566 | 535 | visibleErr: visibleErr, | |
| 567 | 536 | } | |
| 568 | - | if d.snapshotRepository != nil { | |
| 569 | - | paneCapture.sealed = p.history.SnapshotView() | |
| 570 | - | paneCapture.tail = paneCapture.sealed.Tail() | |
| 571 | - | } else { | |
| 572 | - | paneCapture.history = p.history.SealAndView() | |
| 573 | - | } | |
| 537 | + | paneCapture.sealed = p.history.SnapshotView() | |
| 538 | + | paneCapture.tail = paneCapture.sealed.Tail() | |
| 574 | 539 | p.mu.Unlock() | |
| 575 | 540 | paneCapture.cwd = fallbackCwd | |
| 576 | 541 | if d.procCwd != nil && pid > 0 { | |
| @@ -734,20 +699,9 @@ func (d *Daemon) startSnapshotEncodeWorker() { | |||
| 734 | 699 | d.finishSnapshotCapture(capture, false) | |
| 735 | 700 | return false | |
| 736 | 701 | } | |
| 737 | - | var err error | |
| 738 | - | if d.snapshotRepository != nil { | |
| 739 | - | publication, publicationErr := d.incrementalPublication(capture) | |
| 740 | - | if publicationErr != nil { | |
| 741 | - | err = publicationErr | |
| 742 | - | } else if workerCtx.Err() == nil { | |
| 743 | - | err = d.snapshotRepository.Publish(workerCtx, publication) | |
| 744 | - | } | |
| 745 | - | } else { | |
| 746 | - | data, encodeErr := d.encodeSnapshotCapture(capture) | |
| 747 | - | err = encodeErr | |
| 748 | - | if err == nil && workerCtx.Err() == nil { | |
| 749 | - | err = d.snaps.Write(capture.name, data) | |
| 750 | - | } | |
| 702 | + | publication, err := d.incrementalPublication(capture) | |
| 703 | + | if err == nil && workerCtx.Err() == nil { | |
| 704 | + | err = d.snapshotRepository.Publish(workerCtx, publication) | |
| 751 | 705 | } | |
| 752 | 706 | d.clearSnapshotWorkerInFlight(workerID, capture) | |
| 753 | 707 | if err != nil && workerCtx.Err() == nil { | |
| @@ -922,38 +876,6 @@ func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) | |||
| 922 | 876 | } | |
| 923 | 877 | } | |
| 924 | 878 | ||
| 925 | - | func (d *Daemon) snapshotSaver(ctx context.Context) { | |
| 926 | - | if d.snapshotRepository != nil { | |
| 927 | - | d.snapshotRepositorySaver(ctx) | |
| 928 | - | return | |
| 929 | - | } | |
| 930 | - | checkpoint := func() { | |
| 931 | - | d.mu.Lock() | |
| 932 | - | sessions := d.sessionsSnapshotLocked() | |
| 933 | - | d.mu.Unlock() | |
| 934 | - | for _, sess := range sessions { | |
| 935 | - | if sess.snapEligible.Load() && sess.snapDirty.Load() { | |
| 936 | - | d.scheduleSnapshot(sess) | |
| 937 | - | } | |
| 938 | - | } | |
| 939 | - | } | |
| 940 | - | // A session born dirty must not wait through a full interval before its | |
| 941 | - | // first crash-safe checkpoint. Producers remain non-blocking because each | |
| 942 | - | // schedule call only attempts the bounded handoff. | |
| 943 | - | checkpoint() | |
| 944 | - | t := d.clock.NewTimer(snapshotInterval) | |
| 945 | - | defer t.Stop() | |
| 946 | - | for { | |
| 947 | - | select { | |
| 948 | - | case <-ctx.Done(): | |
| 949 | - | return | |
| 950 | - | case <-t.C(): | |
| 951 | - | checkpoint() | |
| 952 | - | t.Reset(snapshotInterval) | |
| 953 | - | } | |
| 954 | - | } | |
| 955 | - | } | |
| 956 | - | ||
| 957 | 879 | // snapshotRepositorySaver waits until the earliest completion-derived routine | |
| 958 | 880 | // eligibility. State changes wake it immediately, so a newly dirty session is | |
| 959 | 881 | // captured without waiting for an unrelated session's interval. | |
| @@ -124,39 +124,8 @@ func (d *Daemon) restoreSnapshots(ctx context.Context) { | |||||
| 124 | } | 124 | } | ||
| 125 | if d.snapshotRepository != nil { | 125 | if d.snapshotRepository != nil { | ||
| 126 | d.restoreIncrementalSnapshots(ctx) | 126 | d.restoreIncrementalSnapshots(ctx) | ||
| 127 | - | return | |||
| 128 | - | } | |||
| 129 | - | if d.snaps == nil { | |||
| 130 | - | return | |||
| 131 | - | } | |||
| 132 | - | blobs, err := d.snaps.Load() | |||
| 133 | - | if err != nil { | |||
| 134 | - | d.log.Warn("loading session snapshots failed", "err", err) | |||
| 135 | - | d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore, | |||
| 136 | - | "couldn't load saved sessions after restart", err) | |||
| 137 | - | return | |||
| 138 | - | } | |||
| 139 | - | for _, blob := range blobs { | |||
| 140 | - | select { | |||
| 141 | - | case <-ctx.Done(): | |||
| 142 | - | return | |||
| 143 | - | default: | |||
| 144 | - | } | |||
| 145 | - | snap, err := snapcodec.Unmarshal(blob.Data) | |||
| 146 | - | if err != nil { | |||
| 147 | - | d.log.Warn("decoding session snapshot failed", "err", err, "session", blob.Name) | |||
| 148 | - | d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore, | |||
| 149 | - | "couldn't restore session "+blob.Name+" after restart", err) | |||
| 150 | - | continue | |||
| 151 | - | } | |||
| 152 | - | if err := d.restoreSession(ctx, snap); err != nil { | |||
| 153 | - | d.log.Warn("restoring session snapshot failed", "err", err, "session", snap.Name) | |||
| 154 | - | if ctx.Err() == nil { | |||
| 155 | - | d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore, | |||
| 156 | - | "couldn't restore session "+snap.Name+" after restart", err) | |||
| 157 | - | } | |||
| 158 | - | } | |||
| 159 | } | 127 | } | ||
| 128 | + | ||||
| 160 | } | 129 | } | ||
| 161 | 130 | ||||
| 162 | func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) error { | 131 | func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) error { | ||
| @@ -410,14 +379,14 @@ func (d *Daemon) scheduleSnapshot(sess *session) bool { | |||||
| 410 | } | 379 | } | ||
| 411 | 380 | ||||
| 412 | // scheduleFinalSnapshot captures the terminal state even when an older capture | 381 | // scheduleFinalSnapshot captures the terminal state even when an older capture | ||
| 413 | - | // is pending. A removed session cannot be retried by snapshotSaver, so the | 382 | + | // is pending. A removed session cannot be retried by the repository scheduler, so the |
| 414 | // worker drains its retained terminal state before it stops. | 383 | // worker drains its retained terminal state before it stops. | ||
| 415 | func (d *Daemon) scheduleFinalSnapshot(sess *session) bool { | 384 | func (d *Daemon) scheduleFinalSnapshot(sess *session) bool { | ||
| 416 | return d.scheduleSnapshotWithFinalFallback(sess, true) | 385 | return d.scheduleSnapshotWithFinalFallback(sess, true) | ||
| 417 | } | 386 | } | ||
| 418 | 387 | ||||
| 419 | func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bool { | 388 | func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bool { | ||
| 420 | - | if d == nil || sess == nil || !d.snapsEnabled || (d.snapshotRepository == nil && d.snaps == nil) || !sess.snapEligible.Load() { | 389 | + | if d == nil || sess == nil || !d.snapsEnabled || d.snapshotRepository == nil || !sess.snapEligible.Load() { |
| 421 | return true | 390 | return true | ||
| 422 | } | 391 | } | ||
| 423 | kind := snapshotAttemptRoutine | 392 | kind := snapshotAttemptRoutine | ||
| @@ -565,12 +534,8 @@ func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapsh | |||||
| 565 | visible: visible, | 534 | visible: visible, | ||
| 566 | visibleErr: visibleErr, | 535 | visibleErr: visibleErr, | ||
| 567 | } | 536 | } | ||
| 568 | - | if d.snapshotRepository != nil { | 537 | + | paneCapture.sealed = p.history.SnapshotView() |
| 569 | - | paneCapture.sealed = p.history.SnapshotView() | 538 | + | paneCapture.tail = paneCapture.sealed.Tail() |
| 570 | - | paneCapture.tail = paneCapture.sealed.Tail() | |||
| 571 | - | } else { | |||
| 572 | - | paneCapture.history = p.history.SealAndView() | |||
| 573 | - | } | |||
| 574 | p.mu.Unlock() | 539 | p.mu.Unlock() | ||
| 575 | paneCapture.cwd = fallbackCwd | 540 | paneCapture.cwd = fallbackCwd | ||
| 576 | if d.procCwd != nil && pid > 0 { | 541 | if d.procCwd != nil && pid > 0 { | ||
| @@ -734,20 +699,9 @@ func (d *Daemon) startSnapshotEncodeWorker() { | |||||
| 734 | d.finishSnapshotCapture(capture, false) | 699 | d.finishSnapshotCapture(capture, false) | ||
| 735 | return false | 700 | return false | ||
| 736 | } | 701 | } | ||
| 737 | - | var err error | 702 | + | publication, err := d.incrementalPublication(capture) |
| 738 | - | if d.snapshotRepository != nil { | 703 | + | if err == nil && workerCtx.Err() == nil { |
| 739 | - | publication, publicationErr := d.incrementalPublication(capture) | 704 | + | err = d.snapshotRepository.Publish(workerCtx, publication) |
| 740 | - | if publicationErr != nil { | |||
| 741 | - | err = publicationErr | |||
| 742 | - | } else if workerCtx.Err() == nil { | |||
| 743 | - | err = d.snapshotRepository.Publish(workerCtx, publication) | |||
| 744 | - | } | |||
| 745 | - | } else { | |||
| 746 | - | data, encodeErr := d.encodeSnapshotCapture(capture) | |||
| 747 | - | err = encodeErr | |||
| 748 | - | if err == nil && workerCtx.Err() == nil { | |||
| 749 | - | err = d.snaps.Write(capture.name, data) | |||
| 750 | - | } | |||
| 751 | } | 705 | } | ||
| 752 | d.clearSnapshotWorkerInFlight(workerID, capture) | 706 | d.clearSnapshotWorkerInFlight(workerID, capture) | ||
| 753 | if err != nil && workerCtx.Err() == nil { | 707 | if err != nil && workerCtx.Err() == nil { | ||
| @@ -922,38 +876,6 @@ func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) | |||||
| 922 | } | 876 | } | ||
| 923 | } | 877 | } | ||
| 924 | 878 | ||||
| 925 | - | func (d *Daemon) snapshotSaver(ctx context.Context) { | |||
| 926 | - | if d.snapshotRepository != nil { | |||
| 927 | - | d.snapshotRepositorySaver(ctx) | |||
| 928 | - | return | |||
| 929 | - | } | |||
| 930 | - | checkpoint := func() { | |||
| 931 | - | d.mu.Lock() | |||
| 932 | - | sessions := d.sessionsSnapshotLocked() | |||
| 933 | - | d.mu.Unlock() | |||
| 934 | - | for _, sess := range sessions { | |||
| 935 | - | if sess.snapEligible.Load() && sess.snapDirty.Load() { | |||
| 936 | - | d.scheduleSnapshot(sess) | |||
| 937 | - | } | |||
| 938 | - | } | |||
| 939 | - | } | |||
| 940 | - | // A session born dirty must not wait through a full interval before its | |||
| 941 | - | // first crash-safe checkpoint. Producers remain non-blocking because each | |||
| 942 | - | // schedule call only attempts the bounded handoff. | |||
| 943 | - | checkpoint() | |||
| 944 | - | t := d.clock.NewTimer(snapshotInterval) | |||
| 945 | - | defer t.Stop() | |||
| 946 | - | for { | |||
| 947 | - | select { | |||
| 948 | - | case <-ctx.Done(): | |||
| 949 | - | return | |||
| 950 | - | case <-t.C(): | |||
| 951 | - | checkpoint() | |||
| 952 | - | t.Reset(snapshotInterval) | |||
| 953 | - | } | |||
| 954 | - | } | |||
| 955 | - | } | |||
| 956 | - | ||||
| 957 | // snapshotRepositorySaver waits until the earliest completion-derived routine | 879 | // snapshotRepositorySaver waits until the earliest completion-derived routine | ||
| 958 | // eligibility. State changes wake it immediately, so a newly dirty session is | 880 | // eligibility. State changes wake it immediately, so a newly dirty session is | ||
| 959 | // captured without waiting for an unrelated session's interval. | 881 | // captured without waiting for an unrelated session's interval. | ||
D internal/usecase/daemon/snapshot_acceptance_test.go +0 -249
| @@ -1,249 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "testing" | |
| 5 | - | ||
| 6 | - | "github.com/stretchr/testify/require" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/vev/internal/domain" | |
| 9 | - | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 10 | - | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 11 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | - | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 13 | - | "github.com/bnema/vev/internal/usecase/visualsearch" | |
| 14 | - | "github.com/bnema/vev/pkg/renderer" | |
| 15 | - | "github.com/bnema/vev/pkg/vt" | |
| 16 | - | ) | |
| 17 | - | ||
| 18 | - | func TestSnapshotCaptureMarshalUnmarshalRestorePreservesTopologyAndTerminalState(t *testing.T) { | |
| 19 | - | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | |
| 20 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 21 | - | WithSnapshotStore(store)(d) | |
| 22 | - | startSnapshotEncodeWorker(t, d) | |
| 23 | - | ||
| 24 | - | sess := newSnapshotTestSession(t, "acceptance", false, "/work") | |
| 25 | - | first := sess.tabs[0] | |
| 26 | - | first.size = domain.Size{Cols: 80, Rows: 24} | |
| 27 | - | first.stableID = "tab-one" | |
| 28 | - | first.nextPaneID = 3 | |
| 29 | - | first.panes["pane-1"].stableID = "pane-one" | |
| 30 | - | secondPane := newPaneWithStableID("pane-2", "pane-two", snapshotAcceptancePTY(t), domain.Size{Cols: 40, Rows: 24}) | |
| 31 | - | require.NoError(t, secondPane.history.Append([]renderer.Cell{{Rune: 't'}, {Rune: 'w'}, {Rune: 'o'}})) | |
| 32 | - | secondPane.screen.Write([]byte("two")) | |
| 33 | - | first.panes["pane-2"] = secondPane | |
| 34 | - | first.tree = &layout.Tree{Focus: "pane-2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |
| 35 | - | layout.NewLeaf("pane-1"), | |
| 36 | - | layout.NewLeaf("pane-2"), | |
| 37 | - | }}} | |
| 38 | - | ||
| 39 | - | second := newTab(snapshotAcceptancePTY(t), domain.Size{Cols: 90, Rows: 30}) | |
| 40 | - | second.stableID = "tab-two" | |
| 41 | - | second.nextPaneID = 2 | |
| 42 | - | second.panes["pane-1"].stableID = "pane-three" | |
| 43 | - | require.NoError(t, second.panes["pane-1"].history.Append([]renderer.Cell{{Rune: 't'}, {Rune: 'h'}, {Rune: 'r'}, {Rune: 'e'}, {Rune: 'e'}})) | |
| 44 | - | second.panes["pane-1"].screen.Write([]byte("three")) | |
| 45 | - | sess.tabs = []*tab{first, second} | |
| 46 | - | sess.active = 1 | |
| 47 | - | ||
| 48 | - | require.True(t, d.captureSession(sess)) | |
| 49 | - | encoded := <-store.writes | |
| 50 | - | captured, err := snapcodec.Unmarshal(encoded) | |
| 51 | - | require.NoError(t, err) | |
| 52 | - | assertCapturedSessionTopology(t, captured) | |
| 53 | - | ||
| 54 | - | restoreFactory := &restorePTYFactory{} | |
| 55 | - | restoredDaemon := newTestDaemon(t, restoreFactory, stubClock{}) | |
| 56 | - | require.NoError(t, restoredDaemon.restoreSession(t.Context(), captured)) | |
| 57 | - | restoredDaemon.mu.Lock() | |
| 58 | - | restored := restoredDaemon.findByNameLocked("acceptance") | |
| 59 | - | restoredDaemon.mu.Unlock() | |
| 60 | - | require.NotNil(t, restored) | |
| 61 | - | require.Equal(t, 1, restored.active) | |
| 62 | - | require.Len(t, restored.tabs, 2) | |
| 63 | - | ||
| 64 | - | for _, tt := range []struct { | |
| 65 | - | name string | |
| 66 | - | tab int | |
| 67 | - | pane layout.PaneID | |
| 68 | - | wantRow string | |
| 69 | - | wantFocus layout.PaneID | |
| 70 | - | }{ | |
| 71 | - | {name: "first tab focused second pane", tab: 0, pane: "pane-2", wantRow: "two", wantFocus: "pane-2"}, | |
| 72 | - | {name: "second tab focused only pane", tab: 1, pane: "pane-1", wantRow: "three", wantFocus: "pane-1"}, | |
| 73 | - | } { | |
| 74 | - | t.Run(tt.name, func(t *testing.T) { | |
| 75 | - | tab := restored.tabs[tt.tab] | |
| 76 | - | tab.mu.Lock() | |
| 77 | - | defer tab.mu.Unlock() | |
| 78 | - | require.Equal(t, tt.wantFocus, tab.tree.Focus) | |
| 79 | - | pane := tab.panes[tt.pane] | |
| 80 | - | require.NotNil(t, pane) | |
| 81 | - | pane.mu.Lock() | |
| 82 | - | defer pane.mu.Unlock() | |
| 83 | - | require.Equal(t, tt.wantRow, rowText(pane.history.View().Row(0))) | |
| 84 | - | }) | |
| 85 | - | } | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | func TestSnapshotCaptureRetainsImmutableValuesAfterTailMutationAndEviction(t *testing.T) { | |
| 89 | - | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | |
| 90 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 91 | - | WithSnapshotStore(store)(d) | |
| 92 | - | startSnapshotEncodeWorker(t, d) | |
| 93 | - | ||
| 94 | - | entered := make(chan struct{}) | |
| 95 | - | release := make(chan struct{}) | |
| 96 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |
| 97 | - | close(entered) | |
| 98 | - | <-release | |
| 99 | - | return snapcodec.Marshal(s) | |
| 100 | - | } | |
| 101 | - | sess := newSnapshotTestSession(t, "frozen", false, "/work") | |
| 102 | - | pane := sess.tabs[0].panes["pane-1"] | |
| 103 | - | pane.screen = vt.NewScreenWithHistory(8, 3, vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}) | |
| 104 | - | pane.history = pane.screen.History() | |
| 105 | - | require.NoError(t, pane.history.Append([]renderer.Cell{{Rune: 'o'}, {Rune: 'l'}, {Rune: 'd'}})) | |
| 106 | - | pane.screen.Write([]byte("before")) | |
| 107 | - | ||
| 108 | - | require.True(t, d.captureSession(sess)) | |
| 109 | - | <-entered | |
| 110 | - | pane.mu.Lock() | |
| 111 | - | require.NoError(t, pane.history.Append([]renderer.Cell{{Rune: 'n'}, {Rune: 'e'}, {Rune: 'w'}})) | |
| 112 | - | require.NoError(t, pane.history.Append([]renderer.Cell{{Rune: 'e'}, {Rune: 'v'}, {Rune: 'i'}, {Rune: 'c'}, {Rune: 't'}})) | |
| 113 | - | pane.screen.Write([]byte("\rafter")) | |
| 114 | - | pane.mu.Unlock() | |
| 115 | - | close(release) | |
| 116 | - | ||
| 117 | - | captured, err := snapcodec.Unmarshal(<-store.writes) | |
| 118 | - | require.NoError(t, err) | |
| 119 | - | restoredHistory, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}, captured.Tabs[0].Panes[0].SealedChunks, captured.Tabs[0].Panes[0].Tail) | |
| 120 | - | require.NoError(t, err) | |
| 121 | - | require.Equal(t, "old", rowText(restoredHistory.View().Row(0))) | |
| 122 | - | frame, err := vt.UnmarshalVisible(captured.Tabs[0].Panes[0].Visible) | |
| 123 | - | require.NoError(t, err) | |
| 124 | - | require.Contains(t, rowText(frame.Row(0)), "before") | |
| 125 | - | } | |
| 126 | - | ||
| 127 | - | func TestDaemonHistoryEvictionLeavesCopyAndSearchSnapshotsUsable(t *testing.T) { | |
| 128 | - | sess := newSnapshotTestSession(t, "copy-lifetime", false, "/work") | |
| 129 | - | pane := sess.tabs[0].panes["pane-1"] | |
| 130 | - | pane.screen = vt.NewScreenWithHistory(8, 1, vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}) | |
| 131 | - | pane.history = pane.screen.History() | |
| 132 | - | for _, text := range []string{"first", "second"} { | |
| 133 | - | require.NoError(t, pane.history.Append(cells(text))) | |
| 134 | - | } | |
| 135 | - | pane.screen.Write([]byte("screen")) | |
| 136 | - | ||
| 137 | - | document := scopy.NewSnapshot(pane.history, pane.screen.Frame) | |
| 138 | - | for _, text := range []string{"third", "fourth"} { | |
| 139 | - | require.NoError(t, pane.history.Append(cells(text))) | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | for _, tt := range []struct { | |
| 143 | - | name string | |
| 144 | - | rows func() [][]renderer.Cell | |
| 145 | - | want []string | |
| 146 | - | }{ | |
| 147 | - | {name: "bounded live history is oldest first after eviction", rows: func() [][]renderer.Cell { | |
| 148 | - | view := pane.history.View() | |
| 149 | - | rows := make([][]renderer.Cell, view.Len()) | |
| 150 | - | for i := range rows { | |
| 151 | - | rows[i] = view.Row(i) | |
| 152 | - | } | |
| 153 | - | return rows | |
| 154 | - | }, want: []string{"third", "fourth"}}, | |
| 155 | - | {name: "captured document retains evicted rows", rows: func() [][]renderer.Cell { | |
| 156 | - | rows := make([][]renderer.Cell, document.Len()-document.Height) | |
| 157 | - | for i := range rows { | |
| 158 | - | rows[i] = document.Row(i) | |
| 159 | - | } | |
| 160 | - | return rows | |
| 161 | - | }, want: []string{"first", "second"}}, | |
| 162 | - | } { | |
| 163 | - | t.Run(tt.name, func(t *testing.T) { | |
| 164 | - | rows := tt.rows() | |
| 165 | - | got := make([]string, len(rows)) | |
| 166 | - | for i, row := range rows { | |
| 167 | - | got[i] = rowText(row) | |
| 168 | - | } | |
| 169 | - | require.Equal(t, tt.want, got) | |
| 170 | - | }) | |
| 171 | - | } | |
| 172 | - | ||
| 173 | - | search := visualsearch.New(document) | |
| 174 | - | for _, r := range "first" { | |
| 175 | - | search.Insert(r) | |
| 176 | - | } | |
| 177 | - | match, ok := search.Selected() | |
| 178 | - | require.True(t, ok) | |
| 179 | - | require.Equal(t, "first", match.Text) | |
| 180 | - | } | |
| 181 | - | ||
| 182 | - | func snapshotAcceptancePTY(t *testing.T) *portsmocks.MockPTY { | |
| 183 | - | t.Helper() | |
| 184 | - | pty := portsmocks.NewMockPTY(t) | |
| 185 | - | pty.EXPECT().Pid().Return(0).Maybe() | |
| 186 | - | pty.EXPECT().ForegroundPgid().Return(0, nil).Maybe() | |
| 187 | - | return pty | |
| 188 | - | } | |
| 189 | - | ||
| 190 | - | func assertCapturedSessionTopology(t *testing.T, got snapcodec.Session) { | |
| 191 | - | t.Helper() | |
| 192 | - | require.Equal(t, "acceptance", got.Name) | |
| 193 | - | require.Equal(t, uint16(1), got.Active) | |
| 194 | - | require.Len(t, got.Tabs, 2) | |
| 195 | - | for _, tt := range []struct { | |
| 196 | - | name string | |
| 197 | - | tab int | |
| 198 | - | stableID string | |
| 199 | - | focus layout.PaneID | |
| 200 | - | nextPaneID uint64 | |
| 201 | - | paneIDs []layout.PaneID | |
| 202 | - | wantSplit bool | |
| 203 | - | }{ | |
| 204 | - | {name: "first split tab", tab: 0, stableID: "tab-one", focus: "pane-2", nextPaneID: 3, paneIDs: []layout.PaneID{"pane-1", "pane-2"}, wantSplit: true}, | |
| 205 | - | {name: "second leaf tab", tab: 1, stableID: "tab-two", focus: "pane-1", nextPaneID: 2, paneIDs: []layout.PaneID{"pane-1"}}, | |
| 206 | - | } { | |
| 207 | - | t.Run(tt.name, func(t *testing.T) { | |
| 208 | - | tab := got.Tabs[tt.tab] | |
| 209 | - | require.Equal(t, tt.stableID, tab.StableID) | |
| 210 | - | require.Equal(t, tt.focus, tab.Focus) | |
| 211 | - | require.Equal(t, tt.nextPaneID, tab.NextPaneID) | |
| 212 | - | require.NotNil(t, tab.Tree) | |
| 213 | - | require.Equal(t, tt.focus, tab.Tree.Focus) | |
| 214 | - | if tt.wantSplit { | |
| 215 | - | require.Equal(t, layout.Split, tab.Tree.Root.Kind) | |
| 216 | - | } | |
| 217 | - | ids := make(map[layout.PaneID]struct{}, len(tab.Panes)) | |
| 218 | - | for _, pane := range tab.Panes { | |
| 219 | - | ids[pane.ID] = struct{}{} | |
| 220 | - | } | |
| 221 | - | for _, id := range tt.paneIDs { | |
| 222 | - | _, ok := ids[id] | |
| 223 | - | require.True(t, ok, "missing pane %q", id) | |
| 224 | - | } | |
| 225 | - | _, focused := ids[tab.Focus] | |
| 226 | - | require.True(t, focused, "focus %q is dangling", tab.Focus) | |
| 227 | - | assertTreeReferencesCapturedPanes(t, tab.Tree.Root, ids) | |
| 228 | - | }) | |
| 229 | - | } | |
| 230 | - | } | |
| 231 | - | ||
| 232 | - | func assertTreeReferencesCapturedPanes(t *testing.T, node *layout.Node, panes map[layout.PaneID]struct{}) { | |
| 233 | - | t.Helper() | |
| 234 | - | if node == nil { | |
| 235 | - | return | |
| 236 | - | } | |
| 237 | - | if node.Kind == layout.Leaf { | |
| 238 | - | _, ok := panes[node.Leaf] | |
| 239 | - | require.True(t, ok, "tree leaf %q is dangling", node.Leaf) | |
| 240 | - | return | |
| 241 | - | } | |
| 242 | - | if node.Kind == layout.Stack && node.Expanded != "" { | |
| 243 | - | _, ok := panes[node.Expanded] | |
| 244 | - | require.True(t, ok, "stack expanded pane %q is dangling", node.Expanded) | |
| 245 | - | } | |
| 246 | - | for _, child := range node.Children { | |
| 247 | - | assertTreeReferencesCapturedPanes(t, child, panes) | |
| 248 | - | } | |
| 249 | - | } | |
| @@ -1,249 +0,0 @@ | |||||
| 1 | - | package daemon | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "testing" | |||
| 5 | - | ||||
| 6 | - | "github.com/stretchr/testify/require" | |||
| 7 | - | ||||
| 8 | - | "github.com/bnema/vev/internal/domain" | |||
| 9 | - | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 10 | - | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 11 | - | "github.com/bnema/vev/internal/usecase/layout" | |||
| 12 | - | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 13 | - | "github.com/bnema/vev/internal/usecase/visualsearch" | |||
| 14 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 15 | - | "github.com/bnema/vev/pkg/vt" | |||
| 16 | - | ) | |||
| 17 | - | ||||
| 18 | - | func TestSnapshotCaptureMarshalUnmarshalRestorePreservesTopologyAndTerminalState(t *testing.T) { | |||
| 19 | - | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | |||
| 20 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 21 | - | WithSnapshotStore(store)(d) | |||
| 22 | - | startSnapshotEncodeWorker(t, d) | |||
| 23 | - | ||||
| 24 | - | sess := newSnapshotTestSession(t, "acceptance", false, "/work") | |||
| 25 | - | first := sess.tabs[0] | |||
| 26 | - | first.size = domain.Size{Cols: 80, Rows: 24} | |||
| 27 | - | first.stableID = "tab-one" | |||
| 28 | - | first.nextPaneID = 3 | |||
| 29 | - | first.panes["pane-1"].stableID = "pane-one" | |||
| 30 | - | secondPane := newPaneWithStableID("pane-2", "pane-two", snapshotAcceptancePTY(t), domain.Size{Cols: 40, Rows: 24}) | |||
| 31 | - | require.NoError(t, secondPane.history.Append([]renderer.Cell{{Rune: 't'}, {Rune: 'w'}, {Rune: 'o'}})) | |||
| 32 | - | secondPane.screen.Write([]byte("two")) | |||
| 33 | - | first.panes["pane-2"] = secondPane | |||
| 34 | - | first.tree = &layout.Tree{Focus: "pane-2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |||
| 35 | - | layout.NewLeaf("pane-1"), | |||
| 36 | - | layout.NewLeaf("pane-2"), | |||
| 37 | - | }}} | |||
| 38 | - | ||||
| 39 | - | second := newTab(snapshotAcceptancePTY(t), domain.Size{Cols: 90, Rows: 30}) | |||
| 40 | - | second.stableID = "tab-two" | |||
| 41 | - | second.nextPaneID = 2 | |||
| 42 | - | second.panes["pane-1"].stableID = "pane-three" | |||
| 43 | - | require.NoError(t, second.panes["pane-1"].history.Append([]renderer.Cell{{Rune: 't'}, {Rune: 'h'}, {Rune: 'r'}, {Rune: 'e'}, {Rune: 'e'}})) | |||
| 44 | - | second.panes["pane-1"].screen.Write([]byte("three")) | |||
| 45 | - | sess.tabs = []*tab{first, second} | |||
| 46 | - | sess.active = 1 | |||
| 47 | - | ||||
| 48 | - | require.True(t, d.captureSession(sess)) | |||
| 49 | - | encoded := <-store.writes | |||
| 50 | - | captured, err := snapcodec.Unmarshal(encoded) | |||
| 51 | - | require.NoError(t, err) | |||
| 52 | - | assertCapturedSessionTopology(t, captured) | |||
| 53 | - | ||||
| 54 | - | restoreFactory := &restorePTYFactory{} | |||
| 55 | - | restoredDaemon := newTestDaemon(t, restoreFactory, stubClock{}) | |||
| 56 | - | require.NoError(t, restoredDaemon.restoreSession(t.Context(), captured)) | |||
| 57 | - | restoredDaemon.mu.Lock() | |||
| 58 | - | restored := restoredDaemon.findByNameLocked("acceptance") | |||
| 59 | - | restoredDaemon.mu.Unlock() | |||
| 60 | - | require.NotNil(t, restored) | |||
| 61 | - | require.Equal(t, 1, restored.active) | |||
| 62 | - | require.Len(t, restored.tabs, 2) | |||
| 63 | - | ||||
| 64 | - | for _, tt := range []struct { | |||
| 65 | - | name string | |||
| 66 | - | tab int | |||
| 67 | - | pane layout.PaneID | |||
| 68 | - | wantRow string | |||
| 69 | - | wantFocus layout.PaneID | |||
| 70 | - | }{ | |||
| 71 | - | {name: "first tab focused second pane", tab: 0, pane: "pane-2", wantRow: "two", wantFocus: "pane-2"}, | |||
| 72 | - | {name: "second tab focused only pane", tab: 1, pane: "pane-1", wantRow: "three", wantFocus: "pane-1"}, | |||
| 73 | - | } { | |||
| 74 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 75 | - | tab := restored.tabs[tt.tab] | |||
| 76 | - | tab.mu.Lock() | |||
| 77 | - | defer tab.mu.Unlock() | |||
| 78 | - | require.Equal(t, tt.wantFocus, tab.tree.Focus) | |||
| 79 | - | pane := tab.panes[tt.pane] | |||
| 80 | - | require.NotNil(t, pane) | |||
| 81 | - | pane.mu.Lock() | |||
| 82 | - | defer pane.mu.Unlock() | |||
| 83 | - | require.Equal(t, tt.wantRow, rowText(pane.history.View().Row(0))) | |||
| 84 | - | }) | |||
| 85 | - | } | |||
| 86 | - | } | |||
| 87 | - | ||||
| 88 | - | func TestSnapshotCaptureRetainsImmutableValuesAfterTailMutationAndEviction(t *testing.T) { | |||
| 89 | - | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | |||
| 90 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 91 | - | WithSnapshotStore(store)(d) | |||
| 92 | - | startSnapshotEncodeWorker(t, d) | |||
| 93 | - | ||||
| 94 | - | entered := make(chan struct{}) | |||
| 95 | - | release := make(chan struct{}) | |||
| 96 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |||
| 97 | - | close(entered) | |||
| 98 | - | <-release | |||
| 99 | - | return snapcodec.Marshal(s) | |||
| 100 | - | } | |||
| 101 | - | sess := newSnapshotTestSession(t, "frozen", false, "/work") | |||
| 102 | - | pane := sess.tabs[0].panes["pane-1"] | |||
| 103 | - | pane.screen = vt.NewScreenWithHistory(8, 3, vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}) | |||
| 104 | - | pane.history = pane.screen.History() | |||
| 105 | - | require.NoError(t, pane.history.Append([]renderer.Cell{{Rune: 'o'}, {Rune: 'l'}, {Rune: 'd'}})) | |||
| 106 | - | pane.screen.Write([]byte("before")) | |||
| 107 | - | ||||
| 108 | - | require.True(t, d.captureSession(sess)) | |||
| 109 | - | <-entered | |||
| 110 | - | pane.mu.Lock() | |||
| 111 | - | require.NoError(t, pane.history.Append([]renderer.Cell{{Rune: 'n'}, {Rune: 'e'}, {Rune: 'w'}})) | |||
| 112 | - | require.NoError(t, pane.history.Append([]renderer.Cell{{Rune: 'e'}, {Rune: 'v'}, {Rune: 'i'}, {Rune: 'c'}, {Rune: 't'}})) | |||
| 113 | - | pane.screen.Write([]byte("\rafter")) | |||
| 114 | - | pane.mu.Unlock() | |||
| 115 | - | close(release) | |||
| 116 | - | ||||
| 117 | - | captured, err := snapcodec.Unmarshal(<-store.writes) | |||
| 118 | - | require.NoError(t, err) | |||
| 119 | - | restoredHistory, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}, captured.Tabs[0].Panes[0].SealedChunks, captured.Tabs[0].Panes[0].Tail) | |||
| 120 | - | require.NoError(t, err) | |||
| 121 | - | require.Equal(t, "old", rowText(restoredHistory.View().Row(0))) | |||
| 122 | - | frame, err := vt.UnmarshalVisible(captured.Tabs[0].Panes[0].Visible) | |||
| 123 | - | require.NoError(t, err) | |||
| 124 | - | require.Contains(t, rowText(frame.Row(0)), "before") | |||
| 125 | - | } | |||
| 126 | - | ||||
| 127 | - | func TestDaemonHistoryEvictionLeavesCopyAndSearchSnapshotsUsable(t *testing.T) { | |||
| 128 | - | sess := newSnapshotTestSession(t, "copy-lifetime", false, "/work") | |||
| 129 | - | pane := sess.tabs[0].panes["pane-1"] | |||
| 130 | - | pane.screen = vt.NewScreenWithHistory(8, 1, vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}) | |||
| 131 | - | pane.history = pane.screen.History() | |||
| 132 | - | for _, text := range []string{"first", "second"} { | |||
| 133 | - | require.NoError(t, pane.history.Append(cells(text))) | |||
| 134 | - | } | |||
| 135 | - | pane.screen.Write([]byte("screen")) | |||
| 136 | - | ||||
| 137 | - | document := scopy.NewSnapshot(pane.history, pane.screen.Frame) | |||
| 138 | - | for _, text := range []string{"third", "fourth"} { | |||
| 139 | - | require.NoError(t, pane.history.Append(cells(text))) | |||
| 140 | - | } | |||
| 141 | - | ||||
| 142 | - | for _, tt := range []struct { | |||
| 143 | - | name string | |||
| 144 | - | rows func() [][]renderer.Cell | |||
| 145 | - | want []string | |||
| 146 | - | }{ | |||
| 147 | - | {name: "bounded live history is oldest first after eviction", rows: func() [][]renderer.Cell { | |||
| 148 | - | view := pane.history.View() | |||
| 149 | - | rows := make([][]renderer.Cell, view.Len()) | |||
| 150 | - | for i := range rows { | |||
| 151 | - | rows[i] = view.Row(i) | |||
| 152 | - | } | |||
| 153 | - | return rows | |||
| 154 | - | }, want: []string{"third", "fourth"}}, | |||
| 155 | - | {name: "captured document retains evicted rows", rows: func() [][]renderer.Cell { | |||
| 156 | - | rows := make([][]renderer.Cell, document.Len()-document.Height) | |||
| 157 | - | for i := range rows { | |||
| 158 | - | rows[i] = document.Row(i) | |||
| 159 | - | } | |||
| 160 | - | return rows | |||
| 161 | - | }, want: []string{"first", "second"}}, | |||
| 162 | - | } { | |||
| 163 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 164 | - | rows := tt.rows() | |||
| 165 | - | got := make([]string, len(rows)) | |||
| 166 | - | for i, row := range rows { | |||
| 167 | - | got[i] = rowText(row) | |||
| 168 | - | } | |||
| 169 | - | require.Equal(t, tt.want, got) | |||
| 170 | - | }) | |||
| 171 | - | } | |||
| 172 | - | ||||
| 173 | - | search := visualsearch.New(document) | |||
| 174 | - | for _, r := range "first" { | |||
| 175 | - | search.Insert(r) | |||
| 176 | - | } | |||
| 177 | - | match, ok := search.Selected() | |||
| 178 | - | require.True(t, ok) | |||
| 179 | - | require.Equal(t, "first", match.Text) | |||
| 180 | - | } | |||
| 181 | - | ||||
| 182 | - | func snapshotAcceptancePTY(t *testing.T) *portsmocks.MockPTY { | |||
| 183 | - | t.Helper() | |||
| 184 | - | pty := portsmocks.NewMockPTY(t) | |||
| 185 | - | pty.EXPECT().Pid().Return(0).Maybe() | |||
| 186 | - | pty.EXPECT().ForegroundPgid().Return(0, nil).Maybe() | |||
| 187 | - | return pty | |||
| 188 | - | } | |||
| 189 | - | ||||
| 190 | - | func assertCapturedSessionTopology(t *testing.T, got snapcodec.Session) { | |||
| 191 | - | t.Helper() | |||
| 192 | - | require.Equal(t, "acceptance", got.Name) | |||
| 193 | - | require.Equal(t, uint16(1), got.Active) | |||
| 194 | - | require.Len(t, got.Tabs, 2) | |||
| 195 | - | for _, tt := range []struct { | |||
| 196 | - | name string | |||
| 197 | - | tab int | |||
| 198 | - | stableID string | |||
| 199 | - | focus layout.PaneID | |||
| 200 | - | nextPaneID uint64 | |||
| 201 | - | paneIDs []layout.PaneID | |||
| 202 | - | wantSplit bool | |||
| 203 | - | }{ | |||
| 204 | - | {name: "first split tab", tab: 0, stableID: "tab-one", focus: "pane-2", nextPaneID: 3, paneIDs: []layout.PaneID{"pane-1", "pane-2"}, wantSplit: true}, | |||
| 205 | - | {name: "second leaf tab", tab: 1, stableID: "tab-two", focus: "pane-1", nextPaneID: 2, paneIDs: []layout.PaneID{"pane-1"}}, | |||
| 206 | - | } { | |||
| 207 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 208 | - | tab := got.Tabs[tt.tab] | |||
| 209 | - | require.Equal(t, tt.stableID, tab.StableID) | |||
| 210 | - | require.Equal(t, tt.focus, tab.Focus) | |||
| 211 | - | require.Equal(t, tt.nextPaneID, tab.NextPaneID) | |||
| 212 | - | require.NotNil(t, tab.Tree) | |||
| 213 | - | require.Equal(t, tt.focus, tab.Tree.Focus) | |||
| 214 | - | if tt.wantSplit { | |||
| 215 | - | require.Equal(t, layout.Split, tab.Tree.Root.Kind) | |||
| 216 | - | } | |||
| 217 | - | ids := make(map[layout.PaneID]struct{}, len(tab.Panes)) | |||
| 218 | - | for _, pane := range tab.Panes { | |||
| 219 | - | ids[pane.ID] = struct{}{} | |||
| 220 | - | } | |||
| 221 | - | for _, id := range tt.paneIDs { | |||
| 222 | - | _, ok := ids[id] | |||
| 223 | - | require.True(t, ok, "missing pane %q", id) | |||
| 224 | - | } | |||
| 225 | - | _, focused := ids[tab.Focus] | |||
| 226 | - | require.True(t, focused, "focus %q is dangling", tab.Focus) | |||
| 227 | - | assertTreeReferencesCapturedPanes(t, tab.Tree.Root, ids) | |||
| 228 | - | }) | |||
| 229 | - | } | |||
| 230 | - | } | |||
| 231 | - | ||||
| 232 | - | func assertTreeReferencesCapturedPanes(t *testing.T, node *layout.Node, panes map[layout.PaneID]struct{}) { | |||
| 233 | - | t.Helper() | |||
| 234 | - | if node == nil { | |||
| 235 | - | return | |||
| 236 | - | } | |||
| 237 | - | if node.Kind == layout.Leaf { | |||
| 238 | - | _, ok := panes[node.Leaf] | |||
| 239 | - | require.True(t, ok, "tree leaf %q is dangling", node.Leaf) | |||
| 240 | - | return | |||
| 241 | - | } | |||
| 242 | - | if node.Kind == layout.Stack && node.Expanded != "" { | |||
| 243 | - | _, ok := panes[node.Expanded] | |||
| 244 | - | require.True(t, ok, "stack expanded pane %q is dangling", node.Expanded) | |||
| 245 | - | } | |||
| 246 | - | for _, child := range node.Children { | |||
| 247 | - | assertTreeReferencesCapturedPanes(t, child, panes) | |||
| 248 | - | } | |||
| 249 | - | } | |||
D internal/usecase/daemon/snapshot_notice_test.go +0 -121
| @@ -1,121 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | "fmt" | |
| 6 | - | "sync/atomic" | |
| 7 | - | "testing" | |
| 8 | - | ||
| 9 | - | "github.com/stretchr/testify/mock" | |
| 10 | - | "github.com/stretchr/testify/require" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/vev/internal/domain" | |
| 13 | - | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 14 | - | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 15 | - | ) | |
| 16 | - | ||
| 17 | - | // TestSnapshotWriteFailureNotifiesGlobally proves a failed snapshot write surfaces | |
| 18 | - | // as a GLOBAL notice: the failing session is never registered (it stands in for a | |
| 19 | - | // session already torn down by the time its async write fails), yet a client | |
| 20 | - | // attached to a DIFFERENT, surviving session still receives the toast. This is the | |
| 21 | - | // whole point of routing through NotifyGlobal instead of a session-scoped notice. | |
| 22 | - | func TestSnapshotWriteFailureNotifiesGlobally(t *testing.T) { | |
| 23 | - | // newNoticeFixture registers a survivor session "work"/"manual" owned by ac. | |
| 24 | - | d, _, survivorClient, _ := newNoticeFixture(t, newNoticeClock()) | |
| 25 | - | store := portsmocks.NewMockSnapshotStore(t) | |
| 26 | - | WithSnapshotStore(store)(d) | |
| 27 | - | d.procCwd = func(int) (string, error) { return "/pane", nil } | |
| 28 | - | startSnapshotEncodeWorker(t, d) | |
| 29 | - | ||
| 30 | - | // The failing session is deliberately NOT added to d.sessions. | |
| 31 | - | failing := newSnapshotTestSession(t, "failing", false, "/fallback") | |
| 32 | - | ||
| 33 | - | store.EXPECT().Write("failing", mock.Anything).RunAndReturn(func(string, []byte) error { | |
| 34 | - | return errors.New("disk full") | |
| 35 | - | }).Once() | |
| 36 | - | ||
| 37 | - | require.True(t, d.captureSession(failing)) | |
| 38 | - | ||
| 39 | - | // The write fails on the worker goroutine; awaitToastCount is the bounded, | |
| 40 | - | // poll-only completion signal. No require runs on the worker goroutine. | |
| 41 | - | toasts := awaitToastCount(t, survivorClient, 1) | |
| 42 | - | require.Equal(t, domain.NoticeSnapshotWrite, toasts[0].Code) | |
| 43 | - | require.Equal(t, "couldn't save session state; recent state may be lost on restart", toasts[0].Message) | |
| 44 | - | require.Equal(t, domain.NoticeError, toasts[0].Severity) | |
| 45 | - | require.Equal(t, domain.SessionID(""), toasts[0].SessionID, | |
| 46 | - | "snapshot write failures are global, never scoped to the (possibly gone) failing session") | |
| 47 | - | require.Equal(t, "publish:*errors.errorString", toasts[0].Details) | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | // TestCloseLastTabSaturatedSnapshotSurfacesNotice proves that when closing the | |
| 51 | - | // last tab of a named session is refused because the snapshot worker cannot | |
| 52 | - | // retain the final state, the session survives for a retry and the still | |
| 53 | - | // attached client is told why, instead of the close silently no-oping. | |
| 54 | - | // | |
| 55 | - | // The snapshot worker is saturated deterministically with the same fixture as | |
| 56 | - | // the kill-session ordering tests: one capture blocked in marshal, one in the | |
| 57 | - | // bounded queue, and the terminal-retention fallback filled to capacity. No | |
| 58 | - | // assertion runs on the worker goroutine; the marshal gate is released only on | |
| 59 | - | // cleanup, after every assertion has run on the test goroutine. | |
| 60 | - | func TestCloseLastTabSaturatedSnapshotSurfacesNotice(t *testing.T) { | |
| 61 | - | store := discardSnapshotStore{} | |
| 62 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 63 | - | WithSnapshotStore(store)(d) | |
| 64 | - | startSnapshotEncodeWorker(t, d) | |
| 65 | - | ||
| 66 | - | entered := make(chan struct{}) | |
| 67 | - | release := make(chan struct{}) | |
| 68 | - | // Release the sole blocked marshal on the test goroutine before the worker | |
| 69 | - | // is stopped in cleanup, so teardown never waits on the gate. | |
| 70 | - | defer close(release) | |
| 71 | - | var calls atomic.Int32 | |
| 72 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |
| 73 | - | if calls.Add(1) == 1 { | |
| 74 | - | close(entered) | |
| 75 | - | <-release | |
| 76 | - | } | |
| 77 | - | return snapcodec.Marshal(s) | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | active := newSnapshotTestSession(t, "active", false, "/work") | |
| 81 | - | queued := newSnapshotTestSession(t, "queued", false, "/work") | |
| 82 | - | markSnapshotDirty(active) | |
| 83 | - | markSnapshotDirty(queued) | |
| 84 | - | require.True(t, d.scheduleSnapshot(active)) | |
| 85 | - | <-entered // the worker is now parked inside marshal for active | |
| 86 | - | require.True(t, d.scheduleSnapshot(queued), "one capture waits in the bounded queue") | |
| 87 | - | for i := range snapshotFinalQueueCapacity { | |
| 88 | - | s := newSnapshotTestSession(t, fmt.Sprintf("terminal-%d", i), false, "/work") | |
| 89 | - | markSnapshotDirty(s) | |
| 90 | - | require.True(t, d.scheduleFinalSnapshot(s), "fill the terminal-retention fallback to capacity") | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | // A single-tab named session whose client is still attached. | |
| 94 | - | target := newSnapshotTestSession(t, "target", false, "/work") | |
| 95 | - | tr, _ := newCapturingTransport(t) | |
| 96 | - | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 97 | - | ac.initOverlays() | |
| 98 | - | ac.setSession(target) | |
| 99 | - | target.client = ac | |
| 100 | - | d.mu.Lock() | |
| 101 | - | d.sessions[target.id] = target | |
| 102 | - | d.mu.Unlock() | |
| 103 | - | require.Len(t, target.tabs, 1) | |
| 104 | - | ||
| 105 | - | d.closeTab(target, target.tabs[0], false) | |
| 106 | - | ||
| 107 | - | // The refused close retains the session for a retry. | |
| 108 | - | d.mu.Lock() | |
| 109 | - | require.Same(t, target, d.sessions[target.id], "a refused close of the last tab must retain the session") | |
| 110 | - | d.mu.Unlock() | |
| 111 | - | ||
| 112 | - | // The refusal is recorded in history and shown on the still-attached client. | |
| 113 | - | hist := d.notices.history() | |
| 114 | - | require.NotEmpty(t, hist, "the refused close must be recorded as a notice") | |
| 115 | - | require.Equal(t, domain.NoticeSnapshotSaturated, hist[0].Code) | |
| 116 | - | require.Equal(t, target.id, hist[0].SessionID) | |
| 117 | - | ||
| 118 | - | toasts := awaitToastCount(t, ac, 1) | |
| 119 | - | require.Equal(t, domain.NoticeSnapshotSaturated, toasts[0].Code) | |
| 120 | - | require.Equal(t, "couldn't close tab: session state not yet saved; try again", toasts[0].Message) | |
| 121 | - | } | |
| @@ -1,121 +0,0 @@ | |||||
| 1 | - | package daemon | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "errors" | |||
| 5 | - | "fmt" | |||
| 6 | - | "sync/atomic" | |||
| 7 | - | "testing" | |||
| 8 | - | ||||
| 9 | - | "github.com/stretchr/testify/mock" | |||
| 10 | - | "github.com/stretchr/testify/require" | |||
| 11 | - | ||||
| 12 | - | "github.com/bnema/vev/internal/domain" | |||
| 13 | - | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 14 | - | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 15 | - | ) | |||
| 16 | - | ||||
| 17 | - | // TestSnapshotWriteFailureNotifiesGlobally proves a failed snapshot write surfaces | |||
| 18 | - | // as a GLOBAL notice: the failing session is never registered (it stands in for a | |||
| 19 | - | // session already torn down by the time its async write fails), yet a client | |||
| 20 | - | // attached to a DIFFERENT, surviving session still receives the toast. This is the | |||
| 21 | - | // whole point of routing through NotifyGlobal instead of a session-scoped notice. | |||
| 22 | - | func TestSnapshotWriteFailureNotifiesGlobally(t *testing.T) { | |||
| 23 | - | // newNoticeFixture registers a survivor session "work"/"manual" owned by ac. | |||
| 24 | - | d, _, survivorClient, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 25 | - | store := portsmocks.NewMockSnapshotStore(t) | |||
| 26 | - | WithSnapshotStore(store)(d) | |||
| 27 | - | d.procCwd = func(int) (string, error) { return "/pane", nil } | |||
| 28 | - | startSnapshotEncodeWorker(t, d) | |||
| 29 | - | ||||
| 30 | - | // The failing session is deliberately NOT added to d.sessions. | |||
| 31 | - | failing := newSnapshotTestSession(t, "failing", false, "/fallback") | |||
| 32 | - | ||||
| 33 | - | store.EXPECT().Write("failing", mock.Anything).RunAndReturn(func(string, []byte) error { | |||
| 34 | - | return errors.New("disk full") | |||
| 35 | - | }).Once() | |||
| 36 | - | ||||
| 37 | - | require.True(t, d.captureSession(failing)) | |||
| 38 | - | ||||
| 39 | - | // The write fails on the worker goroutine; awaitToastCount is the bounded, | |||
| 40 | - | // poll-only completion signal. No require runs on the worker goroutine. | |||
| 41 | - | toasts := awaitToastCount(t, survivorClient, 1) | |||
| 42 | - | require.Equal(t, domain.NoticeSnapshotWrite, toasts[0].Code) | |||
| 43 | - | require.Equal(t, "couldn't save session state; recent state may be lost on restart", toasts[0].Message) | |||
| 44 | - | require.Equal(t, domain.NoticeError, toasts[0].Severity) | |||
| 45 | - | require.Equal(t, domain.SessionID(""), toasts[0].SessionID, | |||
| 46 | - | "snapshot write failures are global, never scoped to the (possibly gone) failing session") | |||
| 47 | - | require.Equal(t, "publish:*errors.errorString", toasts[0].Details) | |||
| 48 | - | } | |||
| 49 | - | ||||
| 50 | - | // TestCloseLastTabSaturatedSnapshotSurfacesNotice proves that when closing the | |||
| 51 | - | // last tab of a named session is refused because the snapshot worker cannot | |||
| 52 | - | // retain the final state, the session survives for a retry and the still | |||
| 53 | - | // attached client is told why, instead of the close silently no-oping. | |||
| 54 | - | // | |||
| 55 | - | // The snapshot worker is saturated deterministically with the same fixture as | |||
| 56 | - | // the kill-session ordering tests: one capture blocked in marshal, one in the | |||
| 57 | - | // bounded queue, and the terminal-retention fallback filled to capacity. No | |||
| 58 | - | // assertion runs on the worker goroutine; the marshal gate is released only on | |||
| 59 | - | // cleanup, after every assertion has run on the test goroutine. | |||
| 60 | - | func TestCloseLastTabSaturatedSnapshotSurfacesNotice(t *testing.T) { | |||
| 61 | - | store := discardSnapshotStore{} | |||
| 62 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 63 | - | WithSnapshotStore(store)(d) | |||
| 64 | - | startSnapshotEncodeWorker(t, d) | |||
| 65 | - | ||||
| 66 | - | entered := make(chan struct{}) | |||
| 67 | - | release := make(chan struct{}) | |||
| 68 | - | // Release the sole blocked marshal on the test goroutine before the worker | |||
| 69 | - | // is stopped in cleanup, so teardown never waits on the gate. | |||
| 70 | - | defer close(release) | |||
| 71 | - | var calls atomic.Int32 | |||
| 72 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |||
| 73 | - | if calls.Add(1) == 1 { | |||
| 74 | - | close(entered) | |||
| 75 | - | <-release | |||
| 76 | - | } | |||
| 77 | - | return snapcodec.Marshal(s) | |||
| 78 | - | } | |||
| 79 | - | ||||
| 80 | - | active := newSnapshotTestSession(t, "active", false, "/work") | |||
| 81 | - | queued := newSnapshotTestSession(t, "queued", false, "/work") | |||
| 82 | - | markSnapshotDirty(active) | |||
| 83 | - | markSnapshotDirty(queued) | |||
| 84 | - | require.True(t, d.scheduleSnapshot(active)) | |||
| 85 | - | <-entered // the worker is now parked inside marshal for active | |||
| 86 | - | require.True(t, d.scheduleSnapshot(queued), "one capture waits in the bounded queue") | |||
| 87 | - | for i := range snapshotFinalQueueCapacity { | |||
| 88 | - | s := newSnapshotTestSession(t, fmt.Sprintf("terminal-%d", i), false, "/work") | |||
| 89 | - | markSnapshotDirty(s) | |||
| 90 | - | require.True(t, d.scheduleFinalSnapshot(s), "fill the terminal-retention fallback to capacity") | |||
| 91 | - | } | |||
| 92 | - | ||||
| 93 | - | // A single-tab named session whose client is still attached. | |||
| 94 | - | target := newSnapshotTestSession(t, "target", false, "/work") | |||
| 95 | - | tr, _ := newCapturingTransport(t) | |||
| 96 | - | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |||
| 97 | - | ac.initOverlays() | |||
| 98 | - | ac.setSession(target) | |||
| 99 | - | target.client = ac | |||
| 100 | - | d.mu.Lock() | |||
| 101 | - | d.sessions[target.id] = target | |||
| 102 | - | d.mu.Unlock() | |||
| 103 | - | require.Len(t, target.tabs, 1) | |||
| 104 | - | ||||
| 105 | - | d.closeTab(target, target.tabs[0], false) | |||
| 106 | - | ||||
| 107 | - | // The refused close retains the session for a retry. | |||
| 108 | - | d.mu.Lock() | |||
| 109 | - | require.Same(t, target, d.sessions[target.id], "a refused close of the last tab must retain the session") | |||
| 110 | - | d.mu.Unlock() | |||
| 111 | - | ||||
| 112 | - | // The refusal is recorded in history and shown on the still-attached client. | |||
| 113 | - | hist := d.notices.history() | |||
| 114 | - | require.NotEmpty(t, hist, "the refused close must be recorded as a notice") | |||
| 115 | - | require.Equal(t, domain.NoticeSnapshotSaturated, hist[0].Code) | |||
| 116 | - | require.Equal(t, target.id, hist[0].SessionID) | |||
| 117 | - | ||||
| 118 | - | toasts := awaitToastCount(t, ac, 1) | |||
| 119 | - | require.Equal(t, domain.NoticeSnapshotSaturated, toasts[0].Code) | |||
| 120 | - | require.Equal(t, "couldn't close tab: session state not yet saved; try again", toasts[0].Message) | |||
| 121 | - | } | |||
D internal/usecase/daemon/snapshot_restore_test.go +0 -795
| @@ -1,795 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "encoding/binary" | |
| 6 | - | "errors" | |
| 7 | - | "io" | |
| 8 | - | "math" | |
| 9 | - | "sync" | |
| 10 | - | "testing" | |
| 11 | - | "time" | |
| 12 | - | ||
| 13 | - | "github.com/stretchr/testify/require" | |
| 14 | - | ||
| 15 | - | "github.com/bnema/vev/internal/domain" | |
| 16 | - | "github.com/bnema/vev/internal/ports" | |
| 17 | - | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 18 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 19 | - | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 20 | - | "github.com/bnema/vev/pkg/renderer" | |
| 21 | - | "github.com/bnema/vev/pkg/vt" | |
| 22 | - | ) | |
| 23 | - | ||
| 24 | - | func terminalPane(t *testing.T, id, stableID, cwd string, historyRows, visibleRows [][]renderer.Cell) snapcodec.Pane { | |
| 25 | - | t.Helper() | |
| 26 | - | h := vt.NewHistory(vt.HistoryConfig{MaxRows: defaultScrollbackRows}) | |
| 27 | - | for _, row := range historyRows { | |
| 28 | - | require.NoError(t, h.Append(row)) | |
| 29 | - | } | |
| 30 | - | sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView()) | |
| 31 | - | require.NoError(t, err) | |
| 32 | - | frame := renderer.NewFrame(40, 24) | |
| 33 | - | for y, row := range visibleRows { | |
| 34 | - | copy(frame.Row(y), row) | |
| 35 | - | } | |
| 36 | - | visible, err := vt.MarshalVisible(frame) | |
| 37 | - | require.NoError(t, err) | |
| 38 | - | return snapcodec.Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible} | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | func TestRestorePaneTerminalRejectsMissingOrMalformedCanonicalBlobs(t *testing.T) { | |
| 42 | - | valid := terminalPane(t, "pane", "p", "/work", nil, nil) | |
| 43 | - | tests := []struct { | |
| 44 | - | name string | |
| 45 | - | mutate func(*snapcodec.Pane) | |
| 46 | - | }{ | |
| 47 | - | {name: "missing tail", mutate: func(p *snapcodec.Pane) { p.Tail = nil }}, | |
| 48 | - | {name: "missing visible", mutate: func(p *snapcodec.Pane) { p.Visible = nil }}, | |
| 49 | - | {name: "malformed tail", mutate: func(p *snapcodec.Pane) { p.Tail = []byte("bad") }}, | |
| 50 | - | {name: "malformed visible", mutate: func(p *snapcodec.Pane) { p.Visible = []byte("bad") }}, | |
| 51 | - | } | |
| 52 | - | for _, tt := range tests { | |
| 53 | - | t.Run(tt.name, func(t *testing.T) { | |
| 54 | - | snap := valid | |
| 55 | - | tt.mutate(&snap) | |
| 56 | - | p := newPane("pane", newRestorePTY(), domain.Size{Cols: 40, Rows: 24}) | |
| 57 | - | require.Error(t, restorePaneTerminal(p, snap)) | |
| 58 | - | }) | |
| 59 | - | } | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | func TestRestoreSnapshotsNilDaemonDoesNotPanic(t *testing.T) { | |
| 63 | - | var d *Daemon | |
| 64 | - | require.NotPanics(t, func() { d.restoreSnapshots(t.Context()) }) | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | func TestRestoreSessionRejectsInvalidActiveTab(t *testing.T) { | |
| 68 | - | factory := &restorePTYFactory{} | |
| 69 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 70 | - | ||
| 71 | - | err := d.restoreSession(context.Background(), snapcodec.Session{Name: "bad-active", Active: 1, Tabs: []snapcodec.Tab{{ | |
| 72 | - | Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/tmp")}, | |
| 73 | - | }}}) | |
| 74 | - | ||
| 75 | - | require.Error(t, err) | |
| 76 | - | require.Empty(t, factory.opens) | |
| 77 | - | } | |
| 78 | - | ||
| 79 | - | func TestRestoreSessionClosesOpenedPTYWhenTerminalRestoreFails(t *testing.T) { | |
| 80 | - | factory := &restorePTYFactory{} | |
| 81 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 82 | - | pane := emptyTerminalPane(t, "pane-1", "/tmp") | |
| 83 | - | pane.Tail = []byte("malformed") | |
| 84 | - | ||
| 85 | - | err := d.restoreSession(context.Background(), snapcodec.Session{Name: "bad", Tabs: []snapcodec.Tab{{ | |
| 86 | - | Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{pane}, | |
| 87 | - | }}}) | |
| 88 | - | ||
| 89 | - | require.Error(t, err) | |
| 90 | - | require.Len(t, factory.opens, 1) | |
| 91 | - | require.Equal(t, 1, factory.opens[0].pty.closeCount()) | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | func TestRestoreSessionUsesDaemonFallbackEnvironmentAndShellBeforeAttach(t *testing.T) { | |
| 95 | - | factory := &restorePTYFactory{} | |
| 96 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 97 | - | d.baseEnv = []string{"ORDINARY=preserved", "SHELL=/usr/local/bin/daemon-shell", "TERM=stale", "RAW"} | |
| 98 | - | ||
| 99 | - | require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "work", Tabs: []snapcodec.Tab{{ | |
| 100 | - | StableID: "tab", Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{terminalPane(t, "pane-1", "pane", "/tmp", nil, nil)}, | |
| 101 | - | }}})) | |
| 102 | - | initialOpens := factory.snapshotOpens() | |
| 103 | - | require.Len(t, initialOpens, 1) | |
| 104 | - | require.Equal(t, "/usr/local/bin/daemon-shell", initialOpens[0].command) | |
| 105 | - | require.Equal(t, []string{ | |
| 106 | - | "ORDINARY=preserved", "SHELL=/usr/local/bin/daemon-shell", "RAW", | |
| 107 | - | "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=tab,pane=pane", | |
| 108 | - | }, initialOpens[0].env) | |
| 109 | - | ||
| 110 | - | d.baseEnv[0] = "ORDINARY=changed" | |
| 111 | - | d.mu.Lock() | |
| 112 | - | restored := d.findByNameLocked("work") | |
| 113 | - | d.mu.Unlock() | |
| 114 | - | require.NotNil(t, restored) | |
| 115 | - | restored.mu.Lock() | |
| 116 | - | require.Equal(t, []string{"ORDINARY=preserved", "SHELL=/usr/local/bin/daemon-shell", "TERM=stale", "RAW"}, restored.env) | |
| 117 | - | restored.mu.Unlock() | |
| 118 | - | ||
| 119 | - | tr, _ := newCapturingTransport(t) | |
| 120 | - | _, ac, err := d.route(ports.Hello{ | |
| 121 | - | Version: ports.ProtocolVersion, | |
| 122 | - | Intent: ports.IntentAttach, | |
| 123 | - | Name: "work", | |
| 124 | - | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 125 | - | Env: []string{"ORDINARY=attached", "SHELL=/bin/attached-shell"}, | |
| 126 | - | }, tr) | |
| 127 | - | require.NoError(t, err) | |
| 128 | - | require.NoError(t, d.createTab(restored, ac.size)) | |
| 129 | - | ||
| 130 | - | restored.mu.Lock() | |
| 131 | - | attachedTab := restored.tabs[1] | |
| 132 | - | restored.mu.Unlock() | |
| 133 | - | attachedTab.mu.Lock() | |
| 134 | - | attachedPane := attachedTab.panes["pane-1"] | |
| 135 | - | attachedTabStableID := attachedTab.stableID | |
| 136 | - | attachedPaneStableID := attachedPane.stableID | |
| 137 | - | attachedTab.mu.Unlock() | |
| 138 | - | expectedVEV := "VEV=session=work,tab=" + attachedTabStableID + ",pane=" + attachedPaneStableID | |
| 139 | - | var attachedOpens []restorePTYOpen | |
| 140 | - | for _, open := range factory.snapshotOpens() { | |
| 141 | - | if containsEnv(open.env, expectedVEV) { | |
| 142 | - | attachedOpens = append(attachedOpens, open) | |
| 143 | - | } | |
| 144 | - | } | |
| 145 | - | require.Len(t, attachedOpens, 1) | |
| 146 | - | require.Equal(t, "/bin/attached-shell", attachedOpens[0].command) | |
| 147 | - | require.Equal(t, []string{ | |
| 148 | - | "ORDINARY=attached", "SHELL=/bin/attached-shell", | |
| 149 | - | "TERM=xterm-256color", "TERM_PROGRAM=vev", expectedVEV, | |
| 150 | - | }, attachedOpens[0].env) | |
| 151 | - | require.NoError(t, d.killSession(restored, ports.ReasonSessionKilled, false)) | |
| 152 | - | d.sessWg.Wait() | |
| 153 | - | } | |
| 154 | - | ||
| 155 | - | func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |
| 156 | - | store := &restoreSnapshotStore{} | |
| 157 | - | store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| 158 | - | Name: "work", | |
| 159 | - | CreatedAt: 99, | |
| 160 | - | Active: 0, | |
| 161 | - | Tabs: []snapcodec.Tab{{ | |
| 162 | - | StableID: "t_saved", | |
| 163 | - | Cols: 80, | |
| 164 | - | Rows: 24, | |
| 165 | - | NextPaneID: 3, | |
| 166 | - | Focus: "pane-2", | |
| 167 | - | Tree: &layout.Tree{Focus: "pane-2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |
| 168 | - | layout.NewLeaf("pane-1"), | |
| 169 | - | layout.NewLeaf("pane-2"), | |
| 170 | - | }}}, | |
| 171 | - | Panes: []snapcodec.Pane{ | |
| 172 | - | terminalPane(t, "pane-1", "p_saved_1", "/one", [][]renderer.Cell{cells("old1")}, [][]renderer.Cell{cells("vis1")}), | |
| 173 | - | terminalPane(t, "pane-2", "p_saved_2", "/two", [][]renderer.Cell{cells("old2")}, [][]renderer.Cell{cells("vis2")}), | |
| 174 | - | }, | |
| 175 | - | }}, | |
| 176 | - | })}} | |
| 177 | - | factory := &restorePTYFactory{} | |
| 178 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 179 | - | persistStore, persistState := newMockStore(t) | |
| 180 | - | WithStore(persistStore)(d) | |
| 181 | - | WithSnapshotStore(store)(d) | |
| 182 | - | d.stopped["work"] = stoppedSession{name: "work", createdAt: 99, tabNames: []string{"logs"}} | |
| 183 | - | ||
| 184 | - | d.restoreSnapshots(context.Background()) | |
| 185 | - | ||
| 186 | - | require.Len(t, factory.opens, 2) | |
| 187 | - | require.Equal(t, "/one", factory.opens[0].dir) | |
| 188 | - | require.Equal(t, "/two", factory.opens[1].dir) | |
| 189 | - | require.Equal(t, domain.Size{Cols: 40, Rows: 24}, factory.opens[0].size) | |
| 190 | - | ||
| 191 | - | d.mu.Lock() | |
| 192 | - | restored := d.findByNameLocked("work") | |
| 193 | - | d.mu.Unlock() | |
| 194 | - | require.NotNil(t, restored) | |
| 195 | - | require.False(t, restored.snapDirty.Load()) | |
| 196 | - | require.Equal(t, domain.SessionID("sess-0"), restored.id) | |
| 197 | - | require.Equal(t, int64(99), restored.createdAt) | |
| 198 | - | require.Equal(t, "/one", restored.cwd) | |
| 199 | - | ||
| 200 | - | restored.mu.Lock() | |
| 201 | - | require.Len(t, restored.tabs, 1) | |
| 202 | - | tb := restored.tabs[0] | |
| 203 | - | restored.mu.Unlock() | |
| 204 | - | require.Equal(t, "logs", tb.name) | |
| 205 | - | require.Equal(t, []string{"logs"}, persistState.record(t, "work").TabNames) | |
| 206 | - | tb.mu.Lock() | |
| 207 | - | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |
| 208 | - | require.Equal(t, 3, tb.nextPaneID) | |
| 209 | - | p := tb.panes["pane-2"] | |
| 210 | - | tb.mu.Unlock() | |
| 211 | - | p.mu.Lock() | |
| 212 | - | require.Same(t, p.screen.History(), p.history) | |
| 213 | - | require.Equal(t, "old2", rowText(p.history.View().Row(0))) | |
| 214 | - | require.Equal(t, 1, p.history.Len()) | |
| 215 | - | require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4])) | |
| 216 | - | copySnap := scopy.NewSnapshot(p.history, p.screen.Frame) | |
| 217 | - | require.Equal(t, "old2", rowText(copySnap.Row(0)[:4])) | |
| 218 | - | require.Equal(t, "vis2", rowText(copySnap.Row(1)[:4])) | |
| 219 | - | p.mu.Unlock() | |
| 220 | - | ||
| 221 | - | tb.mu.Lock() | |
| 222 | - | tabStableID := tb.stableID | |
| 223 | - | pane1StableID := tb.panes["pane-1"].stableID | |
| 224 | - | pane2StableID := tb.panes["pane-2"].stableID | |
| 225 | - | tb.mu.Unlock() | |
| 226 | - | require.Equal(t, "t_saved", tabStableID) | |
| 227 | - | require.Equal(t, "p_saved_1", pane1StableID) | |
| 228 | - | require.Equal(t, "p_saved_2", pane2StableID) | |
| 229 | - | require.Contains(t, factory.opens[0].env, "TERM=xterm-256color") | |
| 230 | - | require.NotContains(t, factory.opens[0].env, "TERM=xterm-direct") | |
| 231 | - | require.NotContains(t, factory.opens[0].env, "COLORTERM=truecolor") | |
| 232 | - | require.Contains(t, factory.opens[0].env, "TERM_PROGRAM=vev") | |
| 233 | - | require.Contains(t, factory.opens[0].env, "VEV=session=work,tab="+tabStableID+",pane="+pane1StableID) | |
| 234 | - | require.Contains(t, factory.opens[1].env, "TERM=xterm-256color") | |
| 235 | - | require.NotContains(t, factory.opens[1].env, "TERM=xterm-direct") | |
| 236 | - | require.NotContains(t, factory.opens[1].env, "COLORTERM=truecolor") | |
| 237 | - | require.Contains(t, factory.opens[1].env, "TERM_PROGRAM=vev") | |
| 238 | - | require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID) | |
| 239 | - | } | |
| 240 | - | ||
| 241 | - | func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testing.T) { | |
| 242 | - | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| 243 | - | Name: "work", Active: 0, Tabs: []snapcodec.Tab{ | |
| 244 | - | {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/one")}}, | |
| 245 | - | {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")}}, | |
| 246 | - | }, | |
| 247 | - | })}}} | |
| 248 | - | factory := &restorePTYFactory{} | |
| 249 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 250 | - | d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Command: "btop", Width: 80, Height: 80}}) | |
| 251 | - | WithSnapshotStore(store)(d) | |
| 252 | - | d.restoreSnapshots(context.Background()) | |
| 253 | - | // Only the persisted normal panes may be restored. Floating runtimes are | |
| 254 | - | // deliberately excluded and must wait for a Phase-5 activation path. | |
| 255 | - | require.Len(t, factory.opens, 2) | |
| 256 | - | d.mu.Lock() | |
| 257 | - | restored := d.findByNameLocked("work") | |
| 258 | - | d.mu.Unlock() | |
| 259 | - | require.NotNil(t, restored) | |
| 260 | - | restored.mu.Lock() | |
| 261 | - | tabs := append([]*tab(nil), restored.tabs...) | |
| 262 | - | restored.mu.Unlock() | |
| 263 | - | require.Len(t, tabs, 2) | |
| 264 | - | for _, tb := range tabs { | |
| 265 | - | tb.mu.Lock() | |
| 266 | - | require.Equal(t, floatingUninitialized, tb.floating.state) | |
| 267 | - | require.Nil(t, tb.floating.pane) | |
| 268 | - | tb.mu.Unlock() | |
| 269 | - | } | |
| 270 | - | } | |
| 271 | - | ||
| 272 | - | func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore { | |
| 273 | - | t.Helper() | |
| 274 | - | pane := emptyTerminalPane(t, "pane-1", "/tmp") | |
| 275 | - | pane.Process = proc | |
| 276 | - | return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| 277 | - | Name: name, | |
| 278 | - | Tabs: []snapcodec.Tab{{ | |
| 279 | - | Cols: 80, | |
| 280 | - | Rows: 24, | |
| 281 | - | Tree: layout.NewTree("pane-1"), | |
| 282 | - | Panes: []snapcodec.Pane{pane}, | |
| 283 | - | }}, | |
| 284 | - | })}}} | |
| 285 | - | } | |
| 286 | - | ||
| 287 | - | func TestRestoreSnapshotsProcessRestoreCommands(t *testing.T) { | |
| 288 | - | lessProc := &snapcodec.Process{Argv: []string{"less", "README.md"}, Strategy: processStrategyGeneric} | |
| 289 | - | agentProc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}} | |
| 290 | - | controlAgentProc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc\nmalicious"}} | |
| 291 | - | ||
| 292 | - | tests := []struct { | |
| 293 | - | name string | |
| 294 | - | store *restoreSnapshotStore | |
| 295 | - | allow []string | |
| 296 | - | wantWrites [][]string | |
| 297 | - | }{ | |
| 298 | - | { | |
| 299 | - | name: "allowlisted generic command", | |
| 300 | - | store: processRestoreTestStore(t, "allowlisted", lessProc), | |
| 301 | - | allow: []string{"less"}, | |
| 302 | - | wantWrites: [][]string{{"less README.md\n"}}, | |
| 303 | - | }, | |
| 304 | - | { | |
| 305 | - | name: "denied generic command", | |
| 306 | - | store: processRestoreTestStore(t, "denied", lessProc), | |
| 307 | - | allow: []string{"vim"}, | |
| 308 | - | wantWrites: [][]string{{}}, | |
| 309 | - | }, | |
| 310 | - | { | |
| 311 | - | name: "pane scoped command", | |
| 312 | - | store: &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "scoped", Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| 313 | - | Name: "scoped", | |
| 314 | - | Tabs: []snapcodec.Tab{ | |
| 315 | - | { | |
| 316 | - | Cols: 80, | |
| 317 | - | Rows: 24, | |
| 318 | - | Tree: layout.NewTree("pane-1"), | |
| 319 | - | Panes: func() []snapcodec.Pane { | |
| 320 | - | pane := emptyTerminalPane(t, "pane-1", "/one") | |
| 321 | - | pane.Process = lessProc | |
| 322 | - | return []snapcodec.Pane{pane} | |
| 323 | - | }(), | |
| 324 | - | }, | |
| 325 | - | { | |
| 326 | - | Cols: 80, | |
| 327 | - | Rows: 24, | |
| 328 | - | Tree: layout.NewTree("pane-1"), | |
| 329 | - | Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")}, | |
| 330 | - | }, | |
| 331 | - | }, | |
| 332 | - | })}}}, | |
| 333 | - | allow: []string{"less"}, | |
| 334 | - | wantWrites: [][]string{{"less README.md\n"}, {}}, | |
| 335 | - | }, | |
| 336 | - | { | |
| 337 | - | name: "agent ID command", | |
| 338 | - | store: processRestoreTestStore(t, "agent", agentProc), | |
| 339 | - | allow: []string{"pi"}, | |
| 340 | - | wantWrites: [][]string{{"pi --resume abc123\n"}}, | |
| 341 | - | }, | |
| 342 | - | { | |
| 343 | - | name: "agent ID with control byte rejected", | |
| 344 | - | store: processRestoreTestStore(t, "agent-control", controlAgentProc), | |
| 345 | - | allow: []string{"pi"}, | |
| 346 | - | wantWrites: [][]string{{}}, | |
| 347 | - | }, | |
| 348 | - | } | |
| 349 | - | ||
| 350 | - | for _, tt := range tests { | |
| 351 | - | t.Run(tt.name, func(t *testing.T) { | |
| 352 | - | factory := &restorePTYFactory{} | |
| 353 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 354 | - | WithSnapshotStore(tt.store)(d) | |
| 355 | - | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: tt.allow}}) | |
| 356 | - | ||
| 357 | - | d.restoreSnapshots(context.Background()) | |
| 358 | - | ||
| 359 | - | require.Len(t, factory.opens, len(tt.wantWrites)) | |
| 360 | - | for i, want := range tt.wantWrites { | |
| 361 | - | if len(want) == 0 { | |
| 362 | - | require.Empty(t, factory.opens[i].pty.writes) | |
| 363 | - | continue | |
| 364 | - | } | |
| 365 | - | require.Equal(t, want, factory.opens[i].pty.writes) | |
| 366 | - | } | |
| 367 | - | }) | |
| 368 | - | } | |
| 369 | - | } | |
| 370 | - | ||
| 371 | - | func TestRestoreSnapshotsProcessCommandWriteFailureIsNonFatal(t *testing.T) { | |
| 372 | - | proc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}} | |
| 373 | - | factory := &restorePTYFactory{writeErr: errors.New("boom")} | |
| 374 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 375 | - | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}}) | |
| 376 | - | ||
| 377 | - | pane := emptyTerminalPane(t, "pane-1", "/tmp") | |
| 378 | - | pane.Process = proc | |
| 379 | - | require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "agent", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{pane}}}})) | |
| 380 | - | require.Len(t, factory.opens, 1) | |
| 381 | - | require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes) | |
| 382 | - | } | |
| 383 | - | ||
| 384 | - | func TestRestoreSessionPassesRestoreContextToPTYOpen(t *testing.T) { | |
| 385 | - | factory := &restorePTYFactory{} | |
| 386 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 387 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 388 | - | defer cancel() | |
| 389 | - | ||
| 390 | - | require.NoError(t, d.restoreSession(ctx, snapcodec.Session{Name: "restore-context", Tabs: []snapcodec.Tab{{ | |
| 391 | - | Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/tmp")}, | |
| 392 | - | }}})) | |
| 393 | - | require.Len(t, factory.opens, 1) | |
| 394 | - | require.Same(t, ctx, factory.opens[0].ctx) | |
| 395 | - | } | |
| 396 | - | ||
| 397 | - | func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) { | |
| 398 | - | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| 399 | - | Name: "stacked", | |
| 400 | - | Tabs: []snapcodec.Tab{{ | |
| 401 | - | Cols: 20, | |
| 402 | - | Rows: 4, | |
| 403 | - | Tree: &layout.Tree{Focus: "b", Root: &layout.Node{Kind: layout.Stack, Expanded: "b", Children: []*layout.Node{ | |
| 404 | - | layout.NewLeaf("a"), layout.NewLeaf("b"), layout.NewLeaf("c"), | |
| 405 | - | }}}, | |
| 406 | - | Panes: []snapcodec.Pane{ | |
| 407 | - | emptyTerminalPane(t, "a", "/a"), | |
| 408 | - | emptyTerminalPane(t, "b", "/b"), | |
| 409 | - | emptyTerminalPane(t, "c", "/c"), | |
| 410 | - | }, | |
| 411 | - | }}, | |
| 412 | - | })}}} | |
| 413 | - | factory := &restorePTYFactory{} | |
| 414 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 415 | - | WithSnapshotStore(store)(d) | |
| 416 | - | ||
| 417 | - | d.restoreSnapshots(context.Background()) | |
| 418 | - | ||
| 419 | - | require.Len(t, factory.opens, 3) | |
| 420 | - | for _, open := range factory.opens { | |
| 421 | - | require.True(t, open.size.Valid(), "open size for %s was %#v", open.dir, open.size) | |
| 422 | - | require.GreaterOrEqual(t, open.size.Cols, layout.MinPaneCols) | |
| 423 | - | require.GreaterOrEqual(t, open.size.Rows, layout.MinPaneRows) | |
| 424 | - | } | |
| 425 | - | d.mu.Lock() | |
| 426 | - | restored := d.findByNameLocked("stacked") | |
| 427 | - | d.mu.Unlock() | |
| 428 | - | require.NotNil(t, restored) | |
| 429 | - | restored.mu.Lock() | |
| 430 | - | tb := restored.tabs[0] | |
| 431 | - | restored.mu.Unlock() | |
| 432 | - | tb.mu.Lock() | |
| 433 | - | require.Equal(t, layout.Stack, tb.tree.Root.Kind) | |
| 434 | - | require.Equal(t, layout.PaneID("b"), tb.tree.Root.Expanded) | |
| 435 | - | require.Len(t, tb.panes, 3) | |
| 436 | - | tb.mu.Unlock() | |
| 437 | - | } | |
| 438 | - | ||
| 439 | - | func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) { | |
| 440 | - | valid := mustSnapshotBytes(t, snapcodec.Session{ | |
| 441 | - | Name: "live", | |
| 442 | - | CreatedAt: 1, | |
| 443 | - | Tabs: []snapcodec.Tab{{ | |
| 444 | - | Cols: 80, | |
| 445 | - | Rows: 24, | |
| 446 | - | Tree: layout.NewTree("pane-1"), | |
| 447 | - | Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/ok")}, | |
| 448 | - | }}, | |
| 449 | - | }) | |
| 450 | - | badVersion := append([]byte(nil), valid...) | |
| 451 | - | binary.BigEndian.PutUint16(badVersion[4:6], 1) | |
| 452 | - | tests := []struct { | |
| 453 | - | name string | |
| 454 | - | blobs []ports.SnapshotBlob | |
| 455 | - | liveName string | |
| 456 | - | wantOpen int | |
| 457 | - | }{ | |
| 458 | - | {name: "empty store"}, | |
| 459 | - | {name: "corrupt blob", blobs: []ports.SnapshotBlob{{Name: "bad", Data: []byte("not a snapshot")}}}, | |
| 460 | - | {name: "old v1 blob", blobs: []ports.SnapshotBlob{{Name: "old", Data: badVersion}}}, | |
| 461 | - | {name: "live name", blobs: []ports.SnapshotBlob{{Name: "live", Data: valid}}, liveName: "live"}, | |
| 462 | - | } | |
| 463 | - | for _, tt := range tests { | |
| 464 | - | t.Run(tt.name, func(t *testing.T) { | |
| 465 | - | factory := &restorePTYFactory{} | |
| 466 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 467 | - | WithSnapshotStore(&restoreSnapshotStore{blobs: tt.blobs})(d) | |
| 468 | - | if tt.liveName != "" { | |
| 469 | - | d.sessions["existing"] = &session{id: "existing", name: tt.liveName} | |
| 470 | - | } | |
| 471 | - | ||
| 472 | - | require.NotPanics(t, func() { d.restoreSnapshots(context.Background()) }) | |
| 473 | - | require.Len(t, factory.opens, tt.wantOpen) | |
| 474 | - | }) | |
| 475 | - | } | |
| 476 | - | } | |
| 477 | - | ||
| 478 | - | func TestRestoreSnapshotsSkipsCreatedAtOverflow(t *testing.T) { | |
| 479 | - | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "overflow", Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| 480 | - | Name: "overflow", | |
| 481 | - | CreatedAt: uint64(math.MaxInt64) + 1, | |
| 482 | - | Tabs: []snapcodec.Tab{{ | |
| 483 | - | Cols: 80, | |
| 484 | - | Rows: 24, | |
| 485 | - | Tree: layout.NewTree("pane-1"), | |
| 486 | - | Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/bad")}, | |
| 487 | - | }}, | |
| 488 | - | })}}} | |
| 489 | - | factory := &restorePTYFactory{} | |
| 490 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 491 | - | WithSnapshotStore(store)(d) | |
| 492 | - | ||
| 493 | - | d.restoreSnapshots(context.Background()) | |
| 494 | - | ||
| 495 | - | records, err := d.persist.LoadAll() | |
| 496 | - | require.NoError(t, err) | |
| 497 | - | require.Empty(t, records) | |
| 498 | - | require.Len(t, factory.opens, 0) | |
| 499 | - | d.mu.Lock() | |
| 500 | - | restored := d.findByNameLocked("overflow") | |
| 501 | - | d.mu.Unlock() | |
| 502 | - | require.Nil(t, restored) | |
| 503 | - | } | |
| 504 | - | ||
| 505 | - | // TestRestoreSnapshotsNotifiesGlobalOnUnrestorableSnapshots proves each | |
| 506 | - | // unrestorable snapshot is recorded to history individually, while the | |
| 507 | - | // pending-global queue dedups them by code into a single toast with the | |
| 508 | - | // combined Count. Both blobs decode fine (valid snapcodec bytes, Marshal | |
| 509 | - | // itself validates Active/tab range) but fail in restoreSession itself: an | |
| 510 | - | // empty embedded session name is rejected deterministically, before any lock | |
| 511 | - | // is taken or PTY is opened. | |
| 512 | - | func TestRestoreSnapshotsNotifiesGlobalOnUnrestorableSnapshots(t *testing.T) { | |
| 513 | - | badSnap := mustSnapshotBytes(t, snapcodec.Session{Name: ""}) | |
| 514 | - | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{ | |
| 515 | - | {Name: "bad-1", Data: badSnap}, | |
| 516 | - | {Name: "bad-2", Data: badSnap}, | |
| 517 | - | }} | |
| 518 | - | factory := &restorePTYFactory{} | |
| 519 | - | d := newTestDaemon(t, factory, newNoticeClock()) | |
| 520 | - | WithSnapshotStore(store)(d) | |
| 521 | - | ||
| 522 | - | // Mirrors real startup (daemon.go's Serve): restoreSnapshots runs off the | |
| 523 | - | // caller goroutine and signals completion by closing d.restoreDone. | |
| 524 | - | d.sessWg.Go(func() { d.restoreSnapshots(d.serveCtx) }) | |
| 525 | - | ||
| 526 | - | select { | |
| 527 | - | case <-d.restoreDone: | |
| 528 | - | case <-time.After(2 * time.Second): | |
| 529 | - | t.Fatal("restoreSnapshots did not close restoreDone in time") | |
| 530 | - | } | |
| 531 | - | ||
| 532 | - | require.Empty(t, factory.opens, "both snapshots must fail before any PTY is opened") | |
| 533 | - | require.Len(t, d.notices.history(), 2, "each failure is recorded individually") | |
| 534 | - | for _, n := range d.notices.history() { | |
| 535 | - | require.Equal(t, domain.NoticeSnapshotRestore, n.Code) | |
| 536 | - | require.Equal(t, domain.NoticeError, n.Severity) | |
| 537 | - | } | |
| 538 | - | ||
| 539 | - | tr, _ := newCapturingTransport(t) | |
| 540 | - | sess, ac, err := d.route(ports.Hello{ | |
| 541 | - | Version: ports.ProtocolVersion, | |
| 542 | - | Intent: ports.IntentEphemeral, | |
| 543 | - | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 544 | - | }, tr) | |
| 545 | - | require.NoError(t, err) | |
| 546 | - | d.firstPaint(sess, ac, ac.size) | |
| 547 | - | ||
| 548 | - | toasts := awaitToastCount(t, ac, 1) | |
| 549 | - | require.Equal(t, domain.NoticeSnapshotRestore, toasts[0].Code) | |
| 550 | - | require.Equal(t, 2, toasts[0].Count, "two same-code failures dedup into one toast counted twice") | |
| 551 | - | require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue") | |
| 552 | - | } | |
| 553 | - | ||
| 554 | - | func TestRestoreSnapshotsDoesNotNotifyForCancelledRestore(t *testing.T) { | |
| 555 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 556 | - | factory := &restorePTYFactory{onOpen: cancel} | |
| 557 | - | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "cancelled", Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| 558 | - | Name: "cancelled", | |
| 559 | - | Tabs: []snapcodec.Tab{{ | |
| 560 | - | Cols: 80, Rows: 24, | |
| 561 | - | Tree: &layout.Tree{Focus: "one", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |
| 562 | - | layout.NewLeaf("one"), layout.NewLeaf("two"), | |
| 563 | - | }}}, | |
| 564 | - | Panes: []snapcodec.Pane{ | |
| 565 | - | emptyTerminalPane(t, "one", "/one"), | |
| 566 | - | emptyTerminalPane(t, "two", "/two"), | |
| 567 | - | }, | |
| 568 | - | }}, | |
| 569 | - | })}}} | |
| 570 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 571 | - | WithSnapshotStore(store)(d) | |
| 572 | - | ||
| 573 | - | d.restoreSnapshots(ctx) | |
| 574 | - | ||
| 575 | - | require.Len(t, factory.snapshotOpens(), 1, "cancellation stops restore before the next pane") | |
| 576 | - | require.Empty(t, d.notices.history(), "shutdown cancellation is not a user-visible restore failure") | |
| 577 | - | } | |
| 578 | - | ||
| 579 | - | func TestRestoreSnapshotsNotifiesGlobalOnResumeCommandWriteFailure(t *testing.T) { | |
| 580 | - | proc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}} | |
| 581 | - | store := processRestoreTestStore(t, "agent-session", proc) | |
| 582 | - | factory := &restorePTYFactory{writeErr: errors.New("boom")} | |
| 583 | - | d := newTestDaemon(t, factory, newNoticeClock()) | |
| 584 | - | WithSnapshotStore(store)(d) | |
| 585 | - | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}}) | |
| 586 | - | ||
| 587 | - | // Mirrors real startup (daemon.go's Serve): restoreSnapshots runs off the | |
| 588 | - | // caller goroutine and signals completion by closing d.restoreDone. | |
| 589 | - | d.sessWg.Go(func() { d.restoreSnapshots(d.serveCtx) }) | |
| 590 | - | ||
| 591 | - | select { | |
| 592 | - | case <-d.restoreDone: | |
| 593 | - | case <-time.After(2 * time.Second): | |
| 594 | - | t.Fatal("restoreSnapshots did not close restoreDone in time") | |
| 595 | - | } | |
| 596 | - | ||
| 597 | - | require.Len(t, factory.opens, 1, "the pane still opens even though the resume write fails") | |
| 598 | - | require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes) | |
| 599 | - | ||
| 600 | - | history := d.notices.history() | |
| 601 | - | require.Len(t, history, 1) | |
| 602 | - | require.Equal(t, domain.NoticeAutoResume, history[0].Code) | |
| 603 | - | require.Equal(t, domain.NoticeWarn, history[0].Severity) | |
| 604 | - | require.Equal(t, "couldn't restore the running program in session agent-session", history[0].Message) | |
| 605 | - | ||
| 606 | - | tr, _ := newCapturingTransport(t) | |
| 607 | - | sess, ac, err := d.route(ports.Hello{ | |
| 608 | - | Version: ports.ProtocolVersion, | |
| 609 | - | Intent: ports.IntentEphemeral, | |
| 610 | - | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 611 | - | }, tr) | |
| 612 | - | require.NoError(t, err) | |
| 613 | - | d.firstPaint(sess, ac, ac.size) | |
| 614 | - | ||
| 615 | - | toasts := awaitToastCount(t, ac, 1) | |
| 616 | - | require.Equal(t, domain.NoticeAutoResume, toasts[0].Code) | |
| 617 | - | require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue") | |
| 618 | - | } | |
| 619 | - | ||
| 620 | - | func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) { | |
| 621 | - | releaseLoad := make(chan struct{}) | |
| 622 | - | loaded := make(chan struct{}) | |
| 623 | - | store := &restoreSnapshotStore{ | |
| 624 | - | loadFn: func() ([]ports.SnapshotBlob, error) { | |
| 625 | - | close(loaded) | |
| 626 | - | <-releaseLoad | |
| 627 | - | return []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| 628 | - | Name: "work", | |
| 629 | - | Tabs: []snapcodec.Tab{{ | |
| 630 | - | Cols: 80, | |
| 631 | - | Rows: 24, | |
| 632 | - | Tree: layout.NewTree("pane-1"), | |
| 633 | - | Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/restored")}, | |
| 634 | - | }}, | |
| 635 | - | })}}, nil | |
| 636 | - | }, | |
| 637 | - | } | |
| 638 | - | factory := &restorePTYFactory{} | |
| 639 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 640 | - | WithSnapshotStore(store)(d) | |
| 641 | - | ||
| 642 | - | go d.restoreSnapshots(context.Background()) | |
| 643 | - | <-loaded | |
| 644 | - | ||
| 645 | - | routed := make(chan *session, 1) | |
| 646 | - | go func() { | |
| 647 | - | tr, _ := newCapturingTransport(t) | |
| 648 | - | sess, _, err := d.route(ports.Hello{Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 26}}, tr) | |
| 649 | - | require.NoError(t, err) | |
| 650 | - | routed <- sess | |
| 651 | - | }() | |
| 652 | - | ||
| 653 | - | select { | |
| 654 | - | case <-routed: | |
| 655 | - | t.Fatal("named attach routed before restore barrier closed") | |
| 656 | - | case <-time.After(25 * time.Millisecond): | |
| 657 | - | } | |
| 658 | - | close(releaseLoad) | |
| 659 | - | select { | |
| 660 | - | case sess := <-routed: | |
| 661 | - | require.Equal(t, "work", sess.name) | |
| 662 | - | require.Len(t, factory.opens, 1) | |
| 663 | - | require.Equal(t, "/restored", factory.opens[0].dir) | |
| 664 | - | case <-time.After(time.Second): | |
| 665 | - | t.Fatal("timed out waiting for named attach after restore") | |
| 666 | - | } | |
| 667 | - | } | |
| 668 | - | ||
| 669 | - | func TestNamedRouteRestoreBarrierReturnsShutdown(t *testing.T) { | |
| 670 | - | d := newTestDaemon(t, &restorePTYFactory{}, stubClock{}) | |
| 671 | - | WithSnapshotStore(&restoreSnapshotStore{})(d) | |
| 672 | - | d.serveCancel() | |
| 673 | - | ||
| 674 | - | tr, _ := newCapturingTransport(t) | |
| 675 | - | sess, ac, err := d.route(ports.Hello{Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}}, tr) | |
| 676 | - | ||
| 677 | - | require.Nil(t, sess) | |
| 678 | - | require.Nil(t, ac) | |
| 679 | - | var perr *protoErr | |
| 680 | - | require.ErrorAs(t, err, &perr) | |
| 681 | - | require.Equal(t, ports.ErrServerShutdown, perr.code) | |
| 682 | - | } | |
| 683 | - | ||
| 684 | - | func emptyTerminalPane(t *testing.T, id, cwd string) snapcodec.Pane { | |
| 685 | - | t.Helper() | |
| 686 | - | return terminalPane(t, id, "", cwd, nil, nil) | |
| 687 | - | } | |
| 688 | - | ||
| 689 | - | func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte { | |
| 690 | - | t.Helper() | |
| 691 | - | b, err := snapcodec.Marshal(s) | |
| 692 | - | require.NoError(t, err) | |
| 693 | - | return b | |
| 694 | - | } | |
| 695 | - | ||
| 696 | - | func cells(s string) []renderer.Cell { | |
| 697 | - | out := make([]renderer.Cell, 0, len(s)) | |
| 698 | - | for _, r := range s { | |
| 699 | - | out = append(out, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |
| 700 | - | } | |
| 701 | - | return out | |
| 702 | - | } | |
| 703 | - | ||
| 704 | - | type restoreSnapshotStore struct { | |
| 705 | - | blobs []ports.SnapshotBlob | |
| 706 | - | loadFn func() ([]ports.SnapshotBlob, error) | |
| 707 | - | } | |
| 708 | - | ||
| 709 | - | func (s *restoreSnapshotStore) Write(string, []byte) error { return nil } | |
| 710 | - | func (s *restoreSnapshotStore) Delete(string) error { return nil } | |
| 711 | - | func (s *restoreSnapshotStore) Load() ([]ports.SnapshotBlob, error) { | |
| 712 | - | if s.loadFn != nil { | |
| 713 | - | return s.loadFn() | |
| 714 | - | } | |
| 715 | - | return s.blobs, nil | |
| 716 | - | } | |
| 717 | - | ||
| 718 | - | type restorePTYFactory struct { | |
| 719 | - | mu sync.Mutex | |
| 720 | - | opens []restorePTYOpen | |
| 721 | - | writeErr error | |
| 722 | - | onOpen func() | |
| 723 | - | } | |
| 724 | - | ||
| 725 | - | type restorePTYOpen struct { | |
| 726 | - | ctx context.Context | |
| 727 | - | command string | |
| 728 | - | dir string | |
| 729 | - | size domain.Size | |
| 730 | - | env []string | |
| 731 | - | pty *restorePTY | |
| 732 | - | } | |
| 733 | - | ||
| 734 | - | func (f *restorePTYFactory) Open(ctx context.Context, command string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) { | |
| 735 | - | f.mu.Lock() | |
| 736 | - | pty := newRestorePTY() | |
| 737 | - | pty.writeErr = f.writeErr | |
| 738 | - | f.opens = append(f.opens, restorePTYOpen{ctx: ctx, command: command, dir: dir, size: sz, env: append([]string(nil), env...), pty: pty}) | |
| 739 | - | onOpen := f.onOpen | |
| 740 | - | f.mu.Unlock() | |
| 741 | - | if onOpen != nil { | |
| 742 | - | onOpen() | |
| 743 | - | } | |
| 744 | - | return pty, nil | |
| 745 | - | } | |
| 746 | - | ||
| 747 | - | func (f *restorePTYFactory) snapshotOpens() []restorePTYOpen { | |
| 748 | - | f.mu.Lock() | |
| 749 | - | defer f.mu.Unlock() | |
| 750 | - | opens := make([]restorePTYOpen, len(f.opens)) | |
| 751 | - | copy(opens, f.opens) | |
| 752 | - | for i := range opens { | |
| 753 | - | opens[i].env = append([]string(nil), opens[i].env...) | |
| 754 | - | } | |
| 755 | - | return opens | |
| 756 | - | } | |
| 757 | - | ||
| 758 | - | type restorePTY struct { | |
| 759 | - | mu sync.Mutex | |
| 760 | - | writes []string | |
| 761 | - | closes int | |
| 762 | - | writeErr error | |
| 763 | - | done chan struct{} | |
| 764 | - | once sync.Once | |
| 765 | - | } | |
| 766 | - | ||
| 767 | - | func newRestorePTY() *restorePTY { return &restorePTY{done: make(chan struct{})} } | |
| 768 | - | func (p *restorePTY) Read([]byte) (int, error) { | |
| 769 | - | <-p.done | |
| 770 | - | return 0, io.EOF | |
| 771 | - | } | |
| 772 | - | func (p *restorePTY) Write(b []byte) (int, error) { | |
| 773 | - | p.mu.Lock() | |
| 774 | - | defer p.mu.Unlock() | |
| 775 | - | p.writes = append(p.writes, string(b)) | |
| 776 | - | if p.writeErr != nil { | |
| 777 | - | return 0, p.writeErr | |
| 778 | - | } | |
| 779 | - | return len(b), nil | |
| 780 | - | } | |
| 781 | - | func (p *restorePTY) Close() error { | |
| 782 | - | p.mu.Lock() | |
| 783 | - | p.closes++ | |
| 784 | - | p.mu.Unlock() | |
| 785 | - | p.once.Do(func() { close(p.done) }) | |
| 786 | - | return nil | |
| 787 | - | } | |
| 788 | - | func (p *restorePTY) closeCount() int { | |
| 789 | - | p.mu.Lock() | |
| 790 | - | defer p.mu.Unlock() | |
| 791 | - | return p.closes | |
| 792 | - | } | |
| 793 | - | func (p *restorePTY) Resize(domain.Size) error { return nil } | |
| 794 | - | func (p *restorePTY) Pid() int { return 0 } | |
| 795 | - | func (p *restorePTY) ForegroundPgid() (int, error) { return 0, nil } | |
| @@ -1,795 +0,0 @@ | |||||
| 1 | - | package daemon | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "context" | |||
| 5 | - | "encoding/binary" | |||
| 6 | - | "errors" | |||
| 7 | - | "io" | |||
| 8 | - | "math" | |||
| 9 | - | "sync" | |||
| 10 | - | "testing" | |||
| 11 | - | "time" | |||
| 12 | - | ||||
| 13 | - | "github.com/stretchr/testify/require" | |||
| 14 | - | ||||
| 15 | - | "github.com/bnema/vev/internal/domain" | |||
| 16 | - | "github.com/bnema/vev/internal/ports" | |||
| 17 | - | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 18 | - | "github.com/bnema/vev/internal/usecase/layout" | |||
| 19 | - | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 20 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 21 | - | "github.com/bnema/vev/pkg/vt" | |||
| 22 | - | ) | |||
| 23 | - | ||||
| 24 | - | func terminalPane(t *testing.T, id, stableID, cwd string, historyRows, visibleRows [][]renderer.Cell) snapcodec.Pane { | |||
| 25 | - | t.Helper() | |||
| 26 | - | h := vt.NewHistory(vt.HistoryConfig{MaxRows: defaultScrollbackRows}) | |||
| 27 | - | for _, row := range historyRows { | |||
| 28 | - | require.NoError(t, h.Append(row)) | |||
| 29 | - | } | |||
| 30 | - | sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView()) | |||
| 31 | - | require.NoError(t, err) | |||
| 32 | - | frame := renderer.NewFrame(40, 24) | |||
| 33 | - | for y, row := range visibleRows { | |||
| 34 | - | copy(frame.Row(y), row) | |||
| 35 | - | } | |||
| 36 | - | visible, err := vt.MarshalVisible(frame) | |||
| 37 | - | require.NoError(t, err) | |||
| 38 | - | return snapcodec.Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible} | |||
| 39 | - | } | |||
| 40 | - | ||||
| 41 | - | func TestRestorePaneTerminalRejectsMissingOrMalformedCanonicalBlobs(t *testing.T) { | |||
| 42 | - | valid := terminalPane(t, "pane", "p", "/work", nil, nil) | |||
| 43 | - | tests := []struct { | |||
| 44 | - | name string | |||
| 45 | - | mutate func(*snapcodec.Pane) | |||
| 46 | - | }{ | |||
| 47 | - | {name: "missing tail", mutate: func(p *snapcodec.Pane) { p.Tail = nil }}, | |||
| 48 | - | {name: "missing visible", mutate: func(p *snapcodec.Pane) { p.Visible = nil }}, | |||
| 49 | - | {name: "malformed tail", mutate: func(p *snapcodec.Pane) { p.Tail = []byte("bad") }}, | |||
| 50 | - | {name: "malformed visible", mutate: func(p *snapcodec.Pane) { p.Visible = []byte("bad") }}, | |||
| 51 | - | } | |||
| 52 | - | for _, tt := range tests { | |||
| 53 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 54 | - | snap := valid | |||
| 55 | - | tt.mutate(&snap) | |||
| 56 | - | p := newPane("pane", newRestorePTY(), domain.Size{Cols: 40, Rows: 24}) | |||
| 57 | - | require.Error(t, restorePaneTerminal(p, snap)) | |||
| 58 | - | }) | |||
| 59 | - | } | |||
| 60 | - | } | |||
| 61 | - | ||||
| 62 | - | func TestRestoreSnapshotsNilDaemonDoesNotPanic(t *testing.T) { | |||
| 63 | - | var d *Daemon | |||
| 64 | - | require.NotPanics(t, func() { d.restoreSnapshots(t.Context()) }) | |||
| 65 | - | } | |||
| 66 | - | ||||
| 67 | - | func TestRestoreSessionRejectsInvalidActiveTab(t *testing.T) { | |||
| 68 | - | factory := &restorePTYFactory{} | |||
| 69 | - | d := newTestDaemon(t, factory, stubClock{}) | |||
| 70 | - | ||||
| 71 | - | err := d.restoreSession(context.Background(), snapcodec.Session{Name: "bad-active", Active: 1, Tabs: []snapcodec.Tab{{ | |||
| 72 | - | Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/tmp")}, | |||
| 73 | - | }}}) | |||
| 74 | - | ||||
| 75 | - | require.Error(t, err) | |||
| 76 | - | require.Empty(t, factory.opens) | |||
| 77 | - | } | |||
| 78 | - | ||||
| 79 | - | func TestRestoreSessionClosesOpenedPTYWhenTerminalRestoreFails(t *testing.T) { | |||
| 80 | - | factory := &restorePTYFactory{} | |||
| 81 | - | d := newTestDaemon(t, factory, stubClock{}) | |||
| 82 | - | pane := emptyTerminalPane(t, "pane-1", "/tmp") | |||
| 83 | - | pane.Tail = []byte("malformed") | |||
| 84 | - | ||||
| 85 | - | err := d.restoreSession(context.Background(), snapcodec.Session{Name: "bad", Tabs: []snapcodec.Tab{{ | |||
| 86 | - | Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{pane}, | |||
| 87 | - | }}}) | |||
| 88 | - | ||||
| 89 | - | require.Error(t, err) | |||
| 90 | - | require.Len(t, factory.opens, 1) | |||
| 91 | - | require.Equal(t, 1, factory.opens[0].pty.closeCount()) | |||
| 92 | - | } | |||
| 93 | - | ||||
| 94 | - | func TestRestoreSessionUsesDaemonFallbackEnvironmentAndShellBeforeAttach(t *testing.T) { | |||
| 95 | - | factory := &restorePTYFactory{} | |||
| 96 | - | d := newTestDaemon(t, factory, stubClock{}) | |||
| 97 | - | d.baseEnv = []string{"ORDINARY=preserved", "SHELL=/usr/local/bin/daemon-shell", "TERM=stale", "RAW"} | |||
| 98 | - | ||||
| 99 | - | require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "work", Tabs: []snapcodec.Tab{{ | |||
| 100 | - | StableID: "tab", Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{terminalPane(t, "pane-1", "pane", "/tmp", nil, nil)}, | |||
| 101 | - | }}})) | |||
| 102 | - | initialOpens := factory.snapshotOpens() | |||
| 103 | - | require.Len(t, initialOpens, 1) | |||
| 104 | - | require.Equal(t, "/usr/local/bin/daemon-shell", initialOpens[0].command) | |||
| 105 | - | require.Equal(t, []string{ | |||
| 106 | - | "ORDINARY=preserved", "SHELL=/usr/local/bin/daemon-shell", "RAW", | |||
| 107 | - | "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=tab,pane=pane", | |||
| 108 | - | }, initialOpens[0].env) | |||
| 109 | - | ||||
| 110 | - | d.baseEnv[0] = "ORDINARY=changed" | |||
| 111 | - | d.mu.Lock() | |||
| 112 | - | restored := d.findByNameLocked("work") | |||
| 113 | - | d.mu.Unlock() | |||
| 114 | - | require.NotNil(t, restored) | |||
| 115 | - | restored.mu.Lock() | |||
| 116 | - | require.Equal(t, []string{"ORDINARY=preserved", "SHELL=/usr/local/bin/daemon-shell", "TERM=stale", "RAW"}, restored.env) | |||
| 117 | - | restored.mu.Unlock() | |||
| 118 | - | ||||
| 119 | - | tr, _ := newCapturingTransport(t) | |||
| 120 | - | _, ac, err := d.route(ports.Hello{ | |||
| 121 | - | Version: ports.ProtocolVersion, | |||
| 122 | - | Intent: ports.IntentAttach, | |||
| 123 | - | Name: "work", | |||
| 124 | - | Size: domain.Size{Cols: 80, Rows: 24}, | |||
| 125 | - | Env: []string{"ORDINARY=attached", "SHELL=/bin/attached-shell"}, | |||
| 126 | - | }, tr) | |||
| 127 | - | require.NoError(t, err) | |||
| 128 | - | require.NoError(t, d.createTab(restored, ac.size)) | |||
| 129 | - | ||||
| 130 | - | restored.mu.Lock() | |||
| 131 | - | attachedTab := restored.tabs[1] | |||
| 132 | - | restored.mu.Unlock() | |||
| 133 | - | attachedTab.mu.Lock() | |||
| 134 | - | attachedPane := attachedTab.panes["pane-1"] | |||
| 135 | - | attachedTabStableID := attachedTab.stableID | |||
| 136 | - | attachedPaneStableID := attachedPane.stableID | |||
| 137 | - | attachedTab.mu.Unlock() | |||
| 138 | - | expectedVEV := "VEV=session=work,tab=" + attachedTabStableID + ",pane=" + attachedPaneStableID | |||
| 139 | - | var attachedOpens []restorePTYOpen | |||
| 140 | - | for _, open := range factory.snapshotOpens() { | |||
| 141 | - | if containsEnv(open.env, expectedVEV) { | |||
| 142 | - | attachedOpens = append(attachedOpens, open) | |||
| 143 | - | } | |||
| 144 | - | } | |||
| 145 | - | require.Len(t, attachedOpens, 1) | |||
| 146 | - | require.Equal(t, "/bin/attached-shell", attachedOpens[0].command) | |||
| 147 | - | require.Equal(t, []string{ | |||
| 148 | - | "ORDINARY=attached", "SHELL=/bin/attached-shell", | |||
| 149 | - | "TERM=xterm-256color", "TERM_PROGRAM=vev", expectedVEV, | |||
| 150 | - | }, attachedOpens[0].env) | |||
| 151 | - | require.NoError(t, d.killSession(restored, ports.ReasonSessionKilled, false)) | |||
| 152 | - | d.sessWg.Wait() | |||
| 153 | - | } | |||
| 154 | - | ||||
| 155 | - | func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||
| 156 | - | store := &restoreSnapshotStore{} | |||
| 157 | - | store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | |||
| 158 | - | Name: "work", | |||
| 159 | - | CreatedAt: 99, | |||
| 160 | - | Active: 0, | |||
| 161 | - | Tabs: []snapcodec.Tab{{ | |||
| 162 | - | StableID: "t_saved", | |||
| 163 | - | Cols: 80, | |||
| 164 | - | Rows: 24, | |||
| 165 | - | NextPaneID: 3, | |||
| 166 | - | Focus: "pane-2", | |||
| 167 | - | Tree: &layout.Tree{Focus: "pane-2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |||
| 168 | - | layout.NewLeaf("pane-1"), | |||
| 169 | - | layout.NewLeaf("pane-2"), | |||
| 170 | - | }}}, | |||
| 171 | - | Panes: []snapcodec.Pane{ | |||
| 172 | - | terminalPane(t, "pane-1", "p_saved_1", "/one", [][]renderer.Cell{cells("old1")}, [][]renderer.Cell{cells("vis1")}), | |||
| 173 | - | terminalPane(t, "pane-2", "p_saved_2", "/two", [][]renderer.Cell{cells("old2")}, [][]renderer.Cell{cells("vis2")}), | |||
| 174 | - | }, | |||
| 175 | - | }}, | |||
| 176 | - | })}} | |||
| 177 | - | factory := &restorePTYFactory{} | |||
| 178 | - | d := newTestDaemon(t, factory, stubClock{}) | |||
| 179 | - | persistStore, persistState := newMockStore(t) | |||
| 180 | - | WithStore(persistStore)(d) | |||
| 181 | - | WithSnapshotStore(store)(d) | |||
| 182 | - | d.stopped["work"] = stoppedSession{name: "work", createdAt: 99, tabNames: []string{"logs"}} | |||
| 183 | - | ||||
| 184 | - | d.restoreSnapshots(context.Background()) | |||
| 185 | - | ||||
| 186 | - | require.Len(t, factory.opens, 2) | |||
| 187 | - | require.Equal(t, "/one", factory.opens[0].dir) | |||
| 188 | - | require.Equal(t, "/two", factory.opens[1].dir) | |||
| 189 | - | require.Equal(t, domain.Size{Cols: 40, Rows: 24}, factory.opens[0].size) | |||
| 190 | - | ||||
| 191 | - | d.mu.Lock() | |||
| 192 | - | restored := d.findByNameLocked("work") | |||
| 193 | - | d.mu.Unlock() | |||
| 194 | - | require.NotNil(t, restored) | |||
| 195 | - | require.False(t, restored.snapDirty.Load()) | |||
| 196 | - | require.Equal(t, domain.SessionID("sess-0"), restored.id) | |||
| 197 | - | require.Equal(t, int64(99), restored.createdAt) | |||
| 198 | - | require.Equal(t, "/one", restored.cwd) | |||
| 199 | - | ||||
| 200 | - | restored.mu.Lock() | |||
| 201 | - | require.Len(t, restored.tabs, 1) | |||
| 202 | - | tb := restored.tabs[0] | |||
| 203 | - | restored.mu.Unlock() | |||
| 204 | - | require.Equal(t, "logs", tb.name) | |||
| 205 | - | require.Equal(t, []string{"logs"}, persistState.record(t, "work").TabNames) | |||
| 206 | - | tb.mu.Lock() | |||
| 207 | - | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |||
| 208 | - | require.Equal(t, 3, tb.nextPaneID) | |||
| 209 | - | p := tb.panes["pane-2"] | |||
| 210 | - | tb.mu.Unlock() | |||
| 211 | - | p.mu.Lock() | |||
| 212 | - | require.Same(t, p.screen.History(), p.history) | |||
| 213 | - | require.Equal(t, "old2", rowText(p.history.View().Row(0))) | |||
| 214 | - | require.Equal(t, 1, p.history.Len()) | |||
| 215 | - | require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4])) | |||
| 216 | - | copySnap := scopy.NewSnapshot(p.history, p.screen.Frame) | |||
| 217 | - | require.Equal(t, "old2", rowText(copySnap.Row(0)[:4])) | |||
| 218 | - | require.Equal(t, "vis2", rowText(copySnap.Row(1)[:4])) | |||
| 219 | - | p.mu.Unlock() | |||
| 220 | - | ||||
| 221 | - | tb.mu.Lock() | |||
| 222 | - | tabStableID := tb.stableID | |||
| 223 | - | pane1StableID := tb.panes["pane-1"].stableID | |||
| 224 | - | pane2StableID := tb.panes["pane-2"].stableID | |||
| 225 | - | tb.mu.Unlock() | |||
| 226 | - | require.Equal(t, "t_saved", tabStableID) | |||
| 227 | - | require.Equal(t, "p_saved_1", pane1StableID) | |||
| 228 | - | require.Equal(t, "p_saved_2", pane2StableID) | |||
| 229 | - | require.Contains(t, factory.opens[0].env, "TERM=xterm-256color") | |||
| 230 | - | require.NotContains(t, factory.opens[0].env, "TERM=xterm-direct") | |||
| 231 | - | require.NotContains(t, factory.opens[0].env, "COLORTERM=truecolor") | |||
| 232 | - | require.Contains(t, factory.opens[0].env, "TERM_PROGRAM=vev") | |||
| 233 | - | require.Contains(t, factory.opens[0].env, "VEV=session=work,tab="+tabStableID+",pane="+pane1StableID) | |||
| 234 | - | require.Contains(t, factory.opens[1].env, "TERM=xterm-256color") | |||
| 235 | - | require.NotContains(t, factory.opens[1].env, "TERM=xterm-direct") | |||
| 236 | - | require.NotContains(t, factory.opens[1].env, "COLORTERM=truecolor") | |||
| 237 | - | require.Contains(t, factory.opens[1].env, "TERM_PROGRAM=vev") | |||
| 238 | - | require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID) | |||
| 239 | - | } | |||
| 240 | - | ||||
| 241 | - | func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testing.T) { | |||
| 242 | - | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | |||
| 243 | - | Name: "work", Active: 0, Tabs: []snapcodec.Tab{ | |||
| 244 | - | {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/one")}}, | |||
| 245 | - | {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")}}, | |||
| 246 | - | }, | |||
| 247 | - | })}}} | |||
| 248 | - | factory := &restorePTYFactory{} | |||
| 249 | - | d := newTestDaemon(t, factory, stubClock{}) | |||
| 250 | - | d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Command: "btop", Width: 80, Height: 80}}) | |||
| 251 | - | WithSnapshotStore(store)(d) | |||
| 252 | - | d.restoreSnapshots(context.Background()) | |||
| 253 | - | // Only the persisted normal panes may be restored. Floating runtimes are | |||
| 254 | - | // deliberately excluded and must wait for a Phase-5 activation path. | |||
| 255 | - | require.Len(t, factory.opens, 2) | |||
| 256 | - | d.mu.Lock() | |||
| 257 | - | restored := d.findByNameLocked("work") | |||
| 258 | - | d.mu.Unlock() | |||
| 259 | - | require.NotNil(t, restored) | |||
| 260 | - | restored.mu.Lock() | |||
| 261 | - | tabs := append([]*tab(nil), restored.tabs...) | |||
| 262 | - | restored.mu.Unlock() | |||
| 263 | - | require.Len(t, tabs, 2) | |||
| 264 | - | for _, tb := range tabs { | |||
| 265 | - | tb.mu.Lock() | |||
| 266 | - | require.Equal(t, floatingUninitialized, tb.floating.state) | |||
| 267 | - | require.Nil(t, tb.floating.pane) | |||
| 268 | - | tb.mu.Unlock() | |||
| 269 | - | } | |||
| 270 | - | } | |||
| 271 | - | ||||
| 272 | - | func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore { | |||
| 273 | - | t.Helper() | |||
| 274 | - | pane := emptyTerminalPane(t, "pane-1", "/tmp") | |||
| 275 | - | pane.Process = proc | |||
| 276 | - | return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{ | |||
| 277 | - | Name: name, | |||
| 278 | - | Tabs: []snapcodec.Tab{{ | |||
| 279 | - | Cols: 80, | |||
| 280 | - | Rows: 24, | |||
| 281 | - | Tree: layout.NewTree("pane-1"), | |||
| 282 | - | Panes: []snapcodec.Pane{pane}, | |||
| 283 | - | }}, | |||
| 284 | - | })}}} | |||
| 285 | - | } | |||
| 286 | - | ||||
| 287 | - | func TestRestoreSnapshotsProcessRestoreCommands(t *testing.T) { | |||
| 288 | - | lessProc := &snapcodec.Process{Argv: []string{"less", "README.md"}, Strategy: processStrategyGeneric} | |||
| 289 | - | agentProc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}} | |||
| 290 | - | controlAgentProc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc\nmalicious"}} | |||
| 291 | - | ||||
| 292 | - | tests := []struct { | |||
| 293 | - | name string | |||
| 294 | - | store *restoreSnapshotStore | |||
| 295 | - | allow []string | |||
| 296 | - | wantWrites [][]string | |||
| 297 | - | }{ | |||
| 298 | - | { | |||
| 299 | - | name: "allowlisted generic command", | |||
| 300 | - | store: processRestoreTestStore(t, "allowlisted", lessProc), | |||
| 301 | - | allow: []string{"less"}, | |||
| 302 | - | wantWrites: [][]string{{"less README.md\n"}}, | |||
| 303 | - | }, | |||
| 304 | - | { | |||
| 305 | - | name: "denied generic command", | |||
| 306 | - | store: processRestoreTestStore(t, "denied", lessProc), | |||
| 307 | - | allow: []string{"vim"}, | |||
| 308 | - | wantWrites: [][]string{{}}, | |||
| 309 | - | }, | |||
| 310 | - | { | |||
| 311 | - | name: "pane scoped command", | |||
| 312 | - | store: &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "scoped", Data: mustSnapshotBytes(t, snapcodec.Session{ | |||
| 313 | - | Name: "scoped", | |||
| 314 | - | Tabs: []snapcodec.Tab{ | |||
| 315 | - | { | |||
| 316 | - | Cols: 80, | |||
| 317 | - | Rows: 24, | |||
| 318 | - | Tree: layout.NewTree("pane-1"), | |||
| 319 | - | Panes: func() []snapcodec.Pane { | |||
| 320 | - | pane := emptyTerminalPane(t, "pane-1", "/one") | |||
| 321 | - | pane.Process = lessProc | |||
| 322 | - | return []snapcodec.Pane{pane} | |||
| 323 | - | }(), | |||
| 324 | - | }, | |||
| 325 | - | { | |||
| 326 | - | Cols: 80, | |||
| 327 | - | Rows: 24, | |||
| 328 | - | Tree: layout.NewTree("pane-1"), | |||
| 329 | - | Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")}, | |||
| 330 | - | }, | |||
| 331 | - | }, | |||
| 332 | - | })}}}, | |||
| 333 | - | allow: []string{"less"}, | |||
| 334 | - | wantWrites: [][]string{{"less README.md\n"}, {}}, | |||
| 335 | - | }, | |||
| 336 | - | { | |||
| 337 | - | name: "agent ID command", | |||
| 338 | - | store: processRestoreTestStore(t, "agent", agentProc), | |||
| 339 | - | allow: []string{"pi"}, | |||
| 340 | - | wantWrites: [][]string{{"pi --resume abc123\n"}}, | |||
| 341 | - | }, | |||
| 342 | - | { | |||
| 343 | - | name: "agent ID with control byte rejected", | |||
| 344 | - | store: processRestoreTestStore(t, "agent-control", controlAgentProc), | |||
| 345 | - | allow: []string{"pi"}, | |||
| 346 | - | wantWrites: [][]string{{}}, | |||
| 347 | - | }, | |||
| 348 | - | } | |||
| 349 | - | ||||
| 350 | - | for _, tt := range tests { | |||
| 351 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 352 | - | factory := &restorePTYFactory{} | |||
| 353 | - | d := newTestDaemon(t, factory, stubClock{}) | |||
| 354 | - | WithSnapshotStore(tt.store)(d) | |||
| 355 | - | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: tt.allow}}) | |||
| 356 | - | ||||
| 357 | - | d.restoreSnapshots(context.Background()) | |||
| 358 | - | ||||
| 359 | - | require.Len(t, factory.opens, len(tt.wantWrites)) | |||
| 360 | - | for i, want := range tt.wantWrites { | |||
| 361 | - | if len(want) == 0 { | |||
| 362 | - | require.Empty(t, factory.opens[i].pty.writes) | |||
| 363 | - | continue | |||
| 364 | - | } | |||
| 365 | - | require.Equal(t, want, factory.opens[i].pty.writes) | |||
| 366 | - | } | |||
| 367 | - | }) | |||
| 368 | - | } | |||
| 369 | - | } | |||
| 370 | - | ||||
| 371 | - | func TestRestoreSnapshotsProcessCommandWriteFailureIsNonFatal(t *testing.T) { | |||
| 372 | - | proc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}} | |||
| 373 | - | factory := &restorePTYFactory{writeErr: errors.New("boom")} | |||
| 374 | - | d := newTestDaemon(t, factory, stubClock{}) | |||
| 375 | - | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}}) | |||
| 376 | - | ||||
| 377 | - | pane := emptyTerminalPane(t, "pane-1", "/tmp") | |||
| 378 | - | pane.Process = proc | |||
| 379 | - | require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "agent", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{pane}}}})) | |||
| 380 | - | require.Len(t, factory.opens, 1) | |||
| 381 | - | require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes) | |||
| 382 | - | } | |||
| 383 | - | ||||
| 384 | - | func TestRestoreSessionPassesRestoreContextToPTYOpen(t *testing.T) { | |||
| 385 | - | factory := &restorePTYFactory{} | |||
| 386 | - | d := newTestDaemon(t, factory, stubClock{}) | |||
| 387 | - | ctx, cancel := context.WithCancel(context.Background()) | |||
| 388 | - | defer cancel() | |||
| 389 | - | ||||
| 390 | - | require.NoError(t, d.restoreSession(ctx, snapcodec.Session{Name: "restore-context", Tabs: []snapcodec.Tab{{ | |||
| 391 | - | Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/tmp")}, | |||
| 392 | - | }}})) | |||
| 393 | - | require.Len(t, factory.opens, 1) | |||
| 394 | - | require.Same(t, ctx, factory.opens[0].ctx) | |||
| 395 | - | } | |||
| 396 | - | ||||
| 397 | - | func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) { | |||
| 398 | - | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{ | |||
| 399 | - | Name: "stacked", | |||
| 400 | - | Tabs: []snapcodec.Tab{{ | |||
| 401 | - | Cols: 20, | |||
| 402 | - | Rows: 4, | |||
| 403 | - | Tree: &layout.Tree{Focus: "b", Root: &layout.Node{Kind: layout.Stack, Expanded: "b", Children: []*layout.Node{ | |||
| 404 | - | layout.NewLeaf("a"), layout.NewLeaf("b"), layout.NewLeaf("c"), | |||
| 405 | - | }}}, | |||
| 406 | - | Panes: []snapcodec.Pane{ | |||
| 407 | - | emptyTerminalPane(t, "a", "/a"), | |||
| 408 | - | emptyTerminalPane(t, "b", "/b"), | |||
| 409 | - | emptyTerminalPane(t, "c", "/c"), | |||
| 410 | - | }, | |||
| 411 | - | }}, | |||
| 412 | - | })}}} | |||
| 413 | - | factory := &restorePTYFactory{} | |||
| 414 | - | d := newTestDaemon(t, factory, stubClock{}) | |||
| 415 | - | WithSnapshotStore(store)(d) | |||
| 416 | - | ||||
| 417 | - | d.restoreSnapshots(context.Background()) | |||
| 418 | - | ||||
| 419 | - | require.Len(t, factory.opens, 3) | |||
| 420 | - | for _, open := range factory.opens { | |||
| 421 | - | require.True(t, open.size.Valid(), "open size for %s was %#v", open.dir, open.size) | |||
| 422 | - | require.GreaterOrEqual(t, open.size.Cols, layout.MinPaneCols) | |||
| 423 | - | require.GreaterOrEqual(t, open.size.Rows, layout.MinPaneRows) | |||
| 424 | - | } | |||
| 425 | - | d.mu.Lock() | |||
| 426 | - | restored := d.findByNameLocked("stacked") | |||
| 427 | - | d.mu.Unlock() | |||
| 428 | - | require.NotNil(t, restored) | |||
| 429 | - | restored.mu.Lock() | |||
| 430 | - | tb := restored.tabs[0] | |||
| 431 | - | restored.mu.Unlock() | |||
| 432 | - | tb.mu.Lock() | |||
| 433 | - | require.Equal(t, layout.Stack, tb.tree.Root.Kind) | |||
| 434 | - | require.Equal(t, layout.PaneID("b"), tb.tree.Root.Expanded) | |||
| 435 | - | require.Len(t, tb.panes, 3) | |||
| 436 | - | tb.mu.Unlock() | |||
| 437 | - | } | |||
| 438 | - | ||||
| 439 | - | func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) { | |||
| 440 | - | valid := mustSnapshotBytes(t, snapcodec.Session{ | |||
| 441 | - | Name: "live", | |||
| 442 | - | CreatedAt: 1, | |||
| 443 | - | Tabs: []snapcodec.Tab{{ | |||
| 444 | - | Cols: 80, | |||
| 445 | - | Rows: 24, | |||
| 446 | - | Tree: layout.NewTree("pane-1"), | |||
| 447 | - | Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/ok")}, | |||
| 448 | - | }}, | |||
| 449 | - | }) | |||
| 450 | - | badVersion := append([]byte(nil), valid...) | |||
| 451 | - | binary.BigEndian.PutUint16(badVersion[4:6], 1) | |||
| 452 | - | tests := []struct { | |||
| 453 | - | name string | |||
| 454 | - | blobs []ports.SnapshotBlob | |||
| 455 | - | liveName string | |||
| 456 | - | wantOpen int | |||
| 457 | - | }{ | |||
| 458 | - | {name: "empty store"}, | |||
| 459 | - | {name: "corrupt blob", blobs: []ports.SnapshotBlob{{Name: "bad", Data: []byte("not a snapshot")}}}, | |||
| 460 | - | {name: "old v1 blob", blobs: []ports.SnapshotBlob{{Name: "old", Data: badVersion}}}, | |||
| 461 | - | {name: "live name", blobs: []ports.SnapshotBlob{{Name: "live", Data: valid}}, liveName: "live"}, | |||
| 462 | - | } | |||
| 463 | - | for _, tt := range tests { | |||
| 464 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 465 | - | factory := &restorePTYFactory{} | |||
| 466 | - | d := newTestDaemon(t, factory, stubClock{}) | |||
| 467 | - | WithSnapshotStore(&restoreSnapshotStore{blobs: tt.blobs})(d) | |||
| 468 | - | if tt.liveName != "" { | |||
| 469 | - | d.sessions["existing"] = &session{id: "existing", name: tt.liveName} | |||
| 470 | - | } | |||
| 471 | - | ||||
| 472 | - | require.NotPanics(t, func() { d.restoreSnapshots(context.Background()) }) | |||
| 473 | - | require.Len(t, factory.opens, tt.wantOpen) | |||
| 474 | - | }) | |||
| 475 | - | } | |||
| 476 | - | } | |||
| 477 | - | ||||
| 478 | - | func TestRestoreSnapshotsSkipsCreatedAtOverflow(t *testing.T) { | |||
| 479 | - | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "overflow", Data: mustSnapshotBytes(t, snapcodec.Session{ | |||
| 480 | - | Name: "overflow", | |||
| 481 | - | CreatedAt: uint64(math.MaxInt64) + 1, | |||
| 482 | - | Tabs: []snapcodec.Tab{{ | |||
| 483 | - | Cols: 80, | |||
| 484 | - | Rows: 24, | |||
| 485 | - | Tree: layout.NewTree("pane-1"), | |||
| 486 | - | Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/bad")}, | |||
| 487 | - | }}, | |||
| 488 | - | })}}} | |||
| 489 | - | factory := &restorePTYFactory{} | |||
| 490 | - | d := newTestDaemon(t, factory, stubClock{}) | |||
| 491 | - | WithSnapshotStore(store)(d) | |||
| 492 | - | ||||
| 493 | - | d.restoreSnapshots(context.Background()) | |||
| 494 | - | ||||
| 495 | - | records, err := d.persist.LoadAll() | |||
| 496 | - | require.NoError(t, err) | |||
| 497 | - | require.Empty(t, records) | |||
| 498 | - | require.Len(t, factory.opens, 0) | |||
| 499 | - | d.mu.Lock() | |||
| 500 | - | restored := d.findByNameLocked("overflow") | |||
| 501 | - | d.mu.Unlock() | |||
| 502 | - | require.Nil(t, restored) | |||
| 503 | - | } | |||
| 504 | - | ||||
| 505 | - | // TestRestoreSnapshotsNotifiesGlobalOnUnrestorableSnapshots proves each | |||
| 506 | - | // unrestorable snapshot is recorded to history individually, while the | |||
| 507 | - | // pending-global queue dedups them by code into a single toast with the | |||
| 508 | - | // combined Count. Both blobs decode fine (valid snapcodec bytes, Marshal | |||
| 509 | - | // itself validates Active/tab range) but fail in restoreSession itself: an | |||
| 510 | - | // empty embedded session name is rejected deterministically, before any lock | |||
| 511 | - | // is taken or PTY is opened. | |||
| 512 | - | func TestRestoreSnapshotsNotifiesGlobalOnUnrestorableSnapshots(t *testing.T) { | |||
| 513 | - | badSnap := mustSnapshotBytes(t, snapcodec.Session{Name: ""}) | |||
| 514 | - | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{ | |||
| 515 | - | {Name: "bad-1", Data: badSnap}, | |||
| 516 | - | {Name: "bad-2", Data: badSnap}, | |||
| 517 | - | }} | |||
| 518 | - | factory := &restorePTYFactory{} | |||
| 519 | - | d := newTestDaemon(t, factory, newNoticeClock()) | |||
| 520 | - | WithSnapshotStore(store)(d) | |||
| 521 | - | ||||
| 522 | - | // Mirrors real startup (daemon.go's Serve): restoreSnapshots runs off the | |||
| 523 | - | // caller goroutine and signals completion by closing d.restoreDone. | |||
| 524 | - | d.sessWg.Go(func() { d.restoreSnapshots(d.serveCtx) }) | |||
| 525 | - | ||||
| 526 | - | select { | |||
| 527 | - | case <-d.restoreDone: | |||
| 528 | - | case <-time.After(2 * time.Second): | |||
| 529 | - | t.Fatal("restoreSnapshots did not close restoreDone in time") | |||
| 530 | - | } | |||
| 531 | - | ||||
| 532 | - | require.Empty(t, factory.opens, "both snapshots must fail before any PTY is opened") | |||
| 533 | - | require.Len(t, d.notices.history(), 2, "each failure is recorded individually") | |||
| 534 | - | for _, n := range d.notices.history() { | |||
| 535 | - | require.Equal(t, domain.NoticeSnapshotRestore, n.Code) | |||
| 536 | - | require.Equal(t, domain.NoticeError, n.Severity) | |||
| 537 | - | } | |||
| 538 | - | ||||
| 539 | - | tr, _ := newCapturingTransport(t) | |||
| 540 | - | sess, ac, err := d.route(ports.Hello{ | |||
| 541 | - | Version: ports.ProtocolVersion, | |||
| 542 | - | Intent: ports.IntentEphemeral, | |||
| 543 | - | Size: domain.Size{Cols: 80, Rows: 24}, | |||
| 544 | - | }, tr) | |||
| 545 | - | require.NoError(t, err) | |||
| 546 | - | d.firstPaint(sess, ac, ac.size) | |||
| 547 | - | ||||
| 548 | - | toasts := awaitToastCount(t, ac, 1) | |||
| 549 | - | require.Equal(t, domain.NoticeSnapshotRestore, toasts[0].Code) | |||
| 550 | - | require.Equal(t, 2, toasts[0].Count, "two same-code failures dedup into one toast counted twice") | |||
| 551 | - | require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue") | |||
| 552 | - | } | |||
| 553 | - | ||||
| 554 | - | func TestRestoreSnapshotsDoesNotNotifyForCancelledRestore(t *testing.T) { | |||
| 555 | - | ctx, cancel := context.WithCancel(context.Background()) | |||
| 556 | - | factory := &restorePTYFactory{onOpen: cancel} | |||
| 557 | - | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "cancelled", Data: mustSnapshotBytes(t, snapcodec.Session{ | |||
| 558 | - | Name: "cancelled", | |||
| 559 | - | Tabs: []snapcodec.Tab{{ | |||
| 560 | - | Cols: 80, Rows: 24, | |||
| 561 | - | Tree: &layout.Tree{Focus: "one", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |||
| 562 | - | layout.NewLeaf("one"), layout.NewLeaf("two"), | |||
| 563 | - | }}}, | |||
| 564 | - | Panes: []snapcodec.Pane{ | |||
| 565 | - | emptyTerminalPane(t, "one", "/one"), | |||
| 566 | - | emptyTerminalPane(t, "two", "/two"), | |||
| 567 | - | }, | |||
| 568 | - | }}, | |||
| 569 | - | })}}} | |||
| 570 | - | d := newTestDaemon(t, factory, stubClock{}) | |||
| 571 | - | WithSnapshotStore(store)(d) | |||
| 572 | - | ||||
| 573 | - | d.restoreSnapshots(ctx) | |||
| 574 | - | ||||
| 575 | - | require.Len(t, factory.snapshotOpens(), 1, "cancellation stops restore before the next pane") | |||
| 576 | - | require.Empty(t, d.notices.history(), "shutdown cancellation is not a user-visible restore failure") | |||
| 577 | - | } | |||
| 578 | - | ||||
| 579 | - | func TestRestoreSnapshotsNotifiesGlobalOnResumeCommandWriteFailure(t *testing.T) { | |||
| 580 | - | proc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}} | |||
| 581 | - | store := processRestoreTestStore(t, "agent-session", proc) | |||
| 582 | - | factory := &restorePTYFactory{writeErr: errors.New("boom")} | |||
| 583 | - | d := newTestDaemon(t, factory, newNoticeClock()) | |||
| 584 | - | WithSnapshotStore(store)(d) | |||
| 585 | - | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}}) | |||
| 586 | - | ||||
| 587 | - | // Mirrors real startup (daemon.go's Serve): restoreSnapshots runs off the | |||
| 588 | - | // caller goroutine and signals completion by closing d.restoreDone. | |||
| 589 | - | d.sessWg.Go(func() { d.restoreSnapshots(d.serveCtx) }) | |||
| 590 | - | ||||
| 591 | - | select { | |||
| 592 | - | case <-d.restoreDone: | |||
| 593 | - | case <-time.After(2 * time.Second): | |||
| 594 | - | t.Fatal("restoreSnapshots did not close restoreDone in time") | |||
| 595 | - | } | |||
| 596 | - | ||||
| 597 | - | require.Len(t, factory.opens, 1, "the pane still opens even though the resume write fails") | |||
| 598 | - | require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes) | |||
| 599 | - | ||||
| 600 | - | history := d.notices.history() | |||
| 601 | - | require.Len(t, history, 1) | |||
| 602 | - | require.Equal(t, domain.NoticeAutoResume, history[0].Code) | |||
| 603 | - | require.Equal(t, domain.NoticeWarn, history[0].Severity) | |||
| 604 | - | require.Equal(t, "couldn't restore the running program in session agent-session", history[0].Message) | |||
| 605 | - | ||||
| 606 | - | tr, _ := newCapturingTransport(t) | |||
| 607 | - | sess, ac, err := d.route(ports.Hello{ | |||
| 608 | - | Version: ports.ProtocolVersion, | |||
| 609 | - | Intent: ports.IntentEphemeral, | |||
| 610 | - | Size: domain.Size{Cols: 80, Rows: 24}, | |||
| 611 | - | }, tr) | |||
| 612 | - | require.NoError(t, err) | |||
| 613 | - | d.firstPaint(sess, ac, ac.size) | |||
| 614 | - | ||||
| 615 | - | toasts := awaitToastCount(t, ac, 1) | |||
| 616 | - | require.Equal(t, domain.NoticeAutoResume, toasts[0].Code) | |||
| 617 | - | require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue") | |||
| 618 | - | } | |||
| 619 | - | ||||
| 620 | - | func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) { | |||
| 621 | - | releaseLoad := make(chan struct{}) | |||
| 622 | - | loaded := make(chan struct{}) | |||
| 623 | - | store := &restoreSnapshotStore{ | |||
| 624 | - | loadFn: func() ([]ports.SnapshotBlob, error) { | |||
| 625 | - | close(loaded) | |||
| 626 | - | <-releaseLoad | |||
| 627 | - | return []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | |||
| 628 | - | Name: "work", | |||
| 629 | - | Tabs: []snapcodec.Tab{{ | |||
| 630 | - | Cols: 80, | |||
| 631 | - | Rows: 24, | |||
| 632 | - | Tree: layout.NewTree("pane-1"), | |||
| 633 | - | Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/restored")}, | |||
| 634 | - | }}, | |||
| 635 | - | })}}, nil | |||
| 636 | - | }, | |||
| 637 | - | } | |||
| 638 | - | factory := &restorePTYFactory{} | |||
| 639 | - | d := newTestDaemon(t, factory, stubClock{}) | |||
| 640 | - | WithSnapshotStore(store)(d) | |||
| 641 | - | ||||
| 642 | - | go d.restoreSnapshots(context.Background()) | |||
| 643 | - | <-loaded | |||
| 644 | - | ||||
| 645 | - | routed := make(chan *session, 1) | |||
| 646 | - | go func() { | |||
| 647 | - | tr, _ := newCapturingTransport(t) | |||
| 648 | - | sess, _, err := d.route(ports.Hello{Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 26}}, tr) | |||
| 649 | - | require.NoError(t, err) | |||
| 650 | - | routed <- sess | |||
| 651 | - | }() | |||
| 652 | - | ||||
| 653 | - | select { | |||
| 654 | - | case <-routed: | |||
| 655 | - | t.Fatal("named attach routed before restore barrier closed") | |||
| 656 | - | case <-time.After(25 * time.Millisecond): | |||
| 657 | - | } | |||
| 658 | - | close(releaseLoad) | |||
| 659 | - | select { | |||
| 660 | - | case sess := <-routed: | |||
| 661 | - | require.Equal(t, "work", sess.name) | |||
| 662 | - | require.Len(t, factory.opens, 1) | |||
| 663 | - | require.Equal(t, "/restored", factory.opens[0].dir) | |||
| 664 | - | case <-time.After(time.Second): | |||
| 665 | - | t.Fatal("timed out waiting for named attach after restore") | |||
| 666 | - | } | |||
| 667 | - | } | |||
| 668 | - | ||||
| 669 | - | func TestNamedRouteRestoreBarrierReturnsShutdown(t *testing.T) { | |||
| 670 | - | d := newTestDaemon(t, &restorePTYFactory{}, stubClock{}) | |||
| 671 | - | WithSnapshotStore(&restoreSnapshotStore{})(d) | |||
| 672 | - | d.serveCancel() | |||
| 673 | - | ||||
| 674 | - | tr, _ := newCapturingTransport(t) | |||
| 675 | - | sess, ac, err := d.route(ports.Hello{Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}}, tr) | |||
| 676 | - | ||||
| 677 | - | require.Nil(t, sess) | |||
| 678 | - | require.Nil(t, ac) | |||
| 679 | - | var perr *protoErr | |||
| 680 | - | require.ErrorAs(t, err, &perr) | |||
| 681 | - | require.Equal(t, ports.ErrServerShutdown, perr.code) | |||
| 682 | - | } | |||
| 683 | - | ||||
| 684 | - | func emptyTerminalPane(t *testing.T, id, cwd string) snapcodec.Pane { | |||
| 685 | - | t.Helper() | |||
| 686 | - | return terminalPane(t, id, "", cwd, nil, nil) | |||
| 687 | - | } | |||
| 688 | - | ||||
| 689 | - | func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte { | |||
| 690 | - | t.Helper() | |||
| 691 | - | b, err := snapcodec.Marshal(s) | |||
| 692 | - | require.NoError(t, err) | |||
| 693 | - | return b | |||
| 694 | - | } | |||
| 695 | - | ||||
| 696 | - | func cells(s string) []renderer.Cell { | |||
| 697 | - | out := make([]renderer.Cell, 0, len(s)) | |||
| 698 | - | for _, r := range s { | |||
| 699 | - | out = append(out, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |||
| 700 | - | } | |||
| 701 | - | return out | |||
| 702 | - | } | |||
| 703 | - | ||||
| 704 | - | type restoreSnapshotStore struct { | |||
| 705 | - | blobs []ports.SnapshotBlob | |||
| 706 | - | loadFn func() ([]ports.SnapshotBlob, error) | |||
| 707 | - | } | |||
| 708 | - | ||||
| 709 | - | func (s *restoreSnapshotStore) Write(string, []byte) error { return nil } | |||
| 710 | - | func (s *restoreSnapshotStore) Delete(string) error { return nil } | |||
| 711 | - | func (s *restoreSnapshotStore) Load() ([]ports.SnapshotBlob, error) { | |||
| 712 | - | if s.loadFn != nil { | |||
| 713 | - | return s.loadFn() | |||
| 714 | - | } | |||
| 715 | - | return s.blobs, nil | |||
| 716 | - | } | |||
| 717 | - | ||||
| 718 | - | type restorePTYFactory struct { | |||
| 719 | - | mu sync.Mutex | |||
| 720 | - | opens []restorePTYOpen | |||
| 721 | - | writeErr error | |||
| 722 | - | onOpen func() | |||
| 723 | - | } | |||
| 724 | - | ||||
| 725 | - | type restorePTYOpen struct { | |||
| 726 | - | ctx context.Context | |||
| 727 | - | command string | |||
| 728 | - | dir string | |||
| 729 | - | size domain.Size | |||
| 730 | - | env []string | |||
| 731 | - | pty *restorePTY | |||
| 732 | - | } | |||
| 733 | - | ||||
| 734 | - | func (f *restorePTYFactory) Open(ctx context.Context, command string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) { | |||
| 735 | - | f.mu.Lock() | |||
| 736 | - | pty := newRestorePTY() | |||
| 737 | - | pty.writeErr = f.writeErr | |||
| 738 | - | f.opens = append(f.opens, restorePTYOpen{ctx: ctx, command: command, dir: dir, size: sz, env: append([]string(nil), env...), pty: pty}) | |||
| 739 | - | onOpen := f.onOpen | |||
| 740 | - | f.mu.Unlock() | |||
| 741 | - | if onOpen != nil { | |||
| 742 | - | onOpen() | |||
| 743 | - | } | |||
| 744 | - | return pty, nil | |||
| 745 | - | } | |||
| 746 | - | ||||
| 747 | - | func (f *restorePTYFactory) snapshotOpens() []restorePTYOpen { | |||
| 748 | - | f.mu.Lock() | |||
| 749 | - | defer f.mu.Unlock() | |||
| 750 | - | opens := make([]restorePTYOpen, len(f.opens)) | |||
| 751 | - | copy(opens, f.opens) | |||
| 752 | - | for i := range opens { | |||
| 753 | - | opens[i].env = append([]string(nil), opens[i].env...) | |||
| 754 | - | } | |||
| 755 | - | return opens | |||
| 756 | - | } | |||
| 757 | - | ||||
| 758 | - | type restorePTY struct { | |||
| 759 | - | mu sync.Mutex | |||
| 760 | - | writes []string | |||
| 761 | - | closes int | |||
| 762 | - | writeErr error | |||
| 763 | - | done chan struct{} | |||
| 764 | - | once sync.Once | |||
| 765 | - | } | |||
| 766 | - | ||||
| 767 | - | func newRestorePTY() *restorePTY { return &restorePTY{done: make(chan struct{})} } | |||
| 768 | - | func (p *restorePTY) Read([]byte) (int, error) { | |||
| 769 | - | <-p.done | |||
| 770 | - | return 0, io.EOF | |||
| 771 | - | } | |||
| 772 | - | func (p *restorePTY) Write(b []byte) (int, error) { | |||
| 773 | - | p.mu.Lock() | |||
| 774 | - | defer p.mu.Unlock() | |||
| 775 | - | p.writes = append(p.writes, string(b)) | |||
| 776 | - | if p.writeErr != nil { | |||
| 777 | - | return 0, p.writeErr | |||
| 778 | - | } | |||
| 779 | - | return len(b), nil | |||
| 780 | - | } | |||
| 781 | - | func (p *restorePTY) Close() error { | |||
| 782 | - | p.mu.Lock() | |||
| 783 | - | p.closes++ | |||
| 784 | - | p.mu.Unlock() | |||
| 785 | - | p.once.Do(func() { close(p.done) }) | |||
| 786 | - | return nil | |||
| 787 | - | } | |||
| 788 | - | func (p *restorePTY) closeCount() int { | |||
| 789 | - | p.mu.Lock() | |||
| 790 | - | defer p.mu.Unlock() | |||
| 791 | - | return p.closes | |||
| 792 | - | } | |||
| 793 | - | func (p *restorePTY) Resize(domain.Size) error { return nil } | |||
| 794 | - | func (p *restorePTY) Pid() int { return 0 } | |||
| 795 | - | func (p *restorePTY) ForegroundPgid() (int, error) { return 0, nil } | |||
M internal/usecase/daemon/snapshot_test.go +10 -1142
| @@ -3,10 +3,6 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | - | "fmt" | |
| 7 | - | "io" | |
| 8 | - | "sync" | |
| 9 | - | "sync/atomic" | |
| 10 | 6 | "testing" | |
| 11 | 7 | "time" | |
| 12 | 8 | ||
| @@ -16,1012 +12,26 @@ import ( | |||
| 16 | 12 | "github.com/bnema/vev/internal/domain" | |
| 17 | 13 | "github.com/bnema/vev/internal/ports" | |
| 18 | 14 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 19 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 20 | - | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 21 | 15 | "github.com/bnema/vev/pkg/renderer" | |
| 22 | - | "github.com/bnema/vev/pkg/vt" | |
| 23 | 16 | ) | |
| 24 | 17 | ||
| 25 | - | func TestSnapshotEncodeWorkerDefersMarshalUntilAfterCapture(t *testing.T) { | |
| 26 | - | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | |
| 18 | + | // TestSnapshotWorkerPublishesContentAddressedCapture verifies that all new | |
| 19 | + | // checkpoints use the repository publication contract. | |
| 20 | + | func TestSnapshotWorkerPublishesContentAddressedCapture(t *testing.T) { | |
| 27 | 21 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 28 | - | WithSnapshotStore(store)(d) | |
| 29 | - | t.Cleanup(d.stopSnapshotEncodeWorker) | |
| 30 | - | d.startSnapshotEncodeWorker() | |
| 31 | - | ||
| 32 | - | entered := make(chan struct{}) | |
| 33 | - | release := make(chan struct{}) | |
| 34 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |
| 35 | - | close(entered) | |
| 36 | - | <-release | |
| 37 | - | return snapcodec.Marshal(s) | |
| 38 | - | } | |
| 39 | - | sess := newSnapshotTestSession(t, "work", false, "/work") | |
| 40 | - | markSnapshotDirty(sess) | |
| 41 | - | require.True(t, d.scheduleSnapshot(sess)) | |
| 42 | - | <-entered | |
| 43 | - | ||
| 44 | - | paneLocked := make(chan struct{}) | |
| 45 | - | releasePane := make(chan struct{}) | |
| 46 | - | go func() { | |
| 47 | - | p := sess.tabs[0].panes["pane-1"] | |
| 48 | - | p.mu.Lock() | |
| 49 | - | close(paneLocked) | |
| 50 | - | <-releasePane | |
| 51 | - | p.mu.Unlock() | |
| 52 | - | }() | |
| 53 | - | <-paneLocked | |
| 54 | - | close(releasePane) | |
| 55 | - | close(release) | |
| 56 | - | <-store.writes | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | func TestSnapshotEncodeWorkerDoesNotBlockPTYOrCoordinatorRender(t *testing.T) { | |
| 60 | - | exerciseBlockedSnapshotWorker(t, true) | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | func TestSnapshotStoreWorkerDoesNotBlockPTYOrCoordinatorRender(t *testing.T) { | |
| 64 | - | exerciseBlockedSnapshotWorker(t, false) | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | func exerciseBlockedSnapshotWorker(t *testing.T, blockEncode bool) { | |
| 68 | - | t.Helper() | |
| 69 | - | store := newGatedSnapshotStore() | |
| 70 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 71 | - | WithSnapshotStore(store)(d) | |
| 72 | - | d.startSnapshotEncodeWorker() | |
| 73 | - | t.Cleanup(func() { | |
| 74 | - | store.unblock() | |
| 75 | - | d.stopSnapshotEncodeWorker() | |
| 76 | - | }) | |
| 77 | - | if blockEncode { | |
| 78 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |
| 79 | - | store.enter() | |
| 80 | - | <-store.release | |
| 81 | - | return snapcodec.Marshal(s) | |
| 82 | - | } | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | sess := newSnapshotTestSession(t, "live", false, "/live") | |
| 86 | - | d.sessions[sess.id] = sess | |
| 87 | - | sess.tabs[0].size = domain.Size{Cols: 80, Rows: 24} | |
| 88 | - | p := sess.tabs[0].panes["pane-1"] | |
| 89 | - | tr, sends, _ := newConn(t, ports.Frame{}) | |
| 90 | - | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 8, Rows: 3}} | |
| 91 | - | ac.setSession(sess) | |
| 92 | - | sess.mu.Lock() | |
| 93 | - | sess.client = ac | |
| 94 | - | sess.mu.Unlock() | |
| 95 | - | rendered := make(chan struct{}, 1) | |
| 96 | - | rc := newRenderCoordinator(renderCoordinatorOptions{ | |
| 97 | - | clock: stubClock{}, | |
| 98 | - | onInvalidate: func(renderInvalidation) { | |
| 99 | - | d.paint(sess, ac, true, nil) | |
| 100 | - | rendered <- struct{}{} | |
| 101 | - | }, | |
| 102 | - | }) | |
| 103 | - | sess.installRenderCoordinator(rc) | |
| 104 | - | rc.attach(ac) | |
| 105 | - | require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac))) | |
| 106 | - | t.Cleanup(func() { rc.beginSessionTeardown().finish(); rc.waitForTimerWorkers() }) | |
| 107 | - | ||
| 108 | - | markSnapshotDirty(sess) | |
| 109 | - | require.True(t, d.scheduleSnapshot(sess)) | |
| 110 | - | <-store.entered | |
| 111 | - | ||
| 112 | - | paneLocked := make(chan struct{}) | |
| 113 | - | releasePane := make(chan struct{}) | |
| 114 | - | go func() { | |
| 115 | - | p.mu.Lock() | |
| 116 | - | close(paneLocked) | |
| 117 | - | <-releasePane | |
| 118 | - | p.mu.Unlock() | |
| 119 | - | }() | |
| 120 | - | <-paneLocked | |
| 121 | - | close(releasePane) | |
| 122 | - | ||
| 123 | - | reader := portsmocks.NewMockPTY(t) | |
| 124 | - | var reads atomic.Int32 | |
| 125 | - | reader.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) { | |
| 126 | - | if reads.Add(1) == 1 { | |
| 127 | - | return copy(buf, []byte("ok")), nil | |
| 128 | - | } | |
| 129 | - | return 0, io.EOF | |
| 130 | - | }).Twice() | |
| 131 | - | reader.EXPECT().Write(mock.Anything).Return(0, nil).Maybe() | |
| 132 | - | p.mu.Lock() | |
| 133 | - | p.pty = reader | |
| 134 | - | p.onExit = func() {} | |
| 135 | - | p.mu.Unlock() | |
| 136 | - | d.sessWg.Add(1) | |
| 137 | - | go d.ptyReader(sess, sess.tabs[0], p) | |
| 138 | - | d.sessWg.Wait() | |
| 139 | - | <-rendered | |
| 140 | - | _ = awaitFrame(t, sends, ports.MsgOutput) | |
| 141 | - | p.mu.Lock() | |
| 142 | - | require.Contains(t, screenLineText(p.screen, 0), "ok") | |
| 143 | - | p.mu.Unlock() | |
| 144 | - | ||
| 145 | - | store.unblock() | |
| 146 | - | awaitSnapshotIdle(t, sess) | |
| 147 | - | require.True(t, sess.snapDirty.Load(), "PTY output published while blocked must remain retryable") | |
| 148 | - | } | |
| 149 | - | ||
| 150 | - | func TestFinalSnapshotFlushPersistsNewestNamedSessionBeforeWorkerTeardown(t *testing.T) { | |
| 151 | - | clock := newFinalFlushClock() | |
| 152 | - | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | |
| 153 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | |
| 154 | - | WithSnapshotStore(store)(d) | |
| 155 | - | d.startSnapshotEncodeWorker() | |
| 156 | - | ||
| 157 | - | entered := make(chan struct{}) | |
| 158 | - | release := make(chan struct{}) | |
| 159 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |
| 160 | - | close(entered) | |
| 161 | - | <-release | |
| 162 | - | return snapcodec.Marshal(s) | |
| 163 | - | } | |
| 164 | - | sess := newSnapshotTestSession(t, "final", false, "/work") | |
| 165 | - | pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |
| 166 | - | require.True(t, ok) | |
| 167 | - | pty.EXPECT().Close().Return(nil).Once() | |
| 168 | - | sess.tabs[0].panes["pane-1"].screen.Write([]byte("\rnewest")) | |
| 169 | - | d.sessions[sess.id] = sess | |
| 170 | - | ||
| 171 | - | require.NoError(t, d.killSession(sess, 0, false)) | |
| 172 | - | <-entered | |
| 173 | - | stopped := make(chan struct{}) | |
| 174 | - | go func() { | |
| 175 | - | d.stopSnapshotEncodeWorker() | |
| 176 | - | close(stopped) | |
| 177 | - | }() | |
| 178 | - | close(release) | |
| 179 | - | <-stopped | |
| 180 | - | awaitSnapshotIdle(t, sess) | |
| 181 | - | ||
| 182 | - | select { | |
| 183 | - | case data := <-store.writes: | |
| 184 | - | snap, err := snapcodec.Unmarshal(data) | |
| 185 | - | require.NoError(t, err) | |
| 186 | - | frame, err := vt.UnmarshalVisible(snap.Tabs[0].Panes[0].Visible) | |
| 187 | - | require.NoError(t, err) | |
| 188 | - | require.Contains(t, rowText(frame.Row(0)), "newest") | |
| 189 | - | default: | |
| 190 | - | t.Fatal("final snapshot was dropped during worker teardown") | |
| 191 | - | } | |
| 192 | - | } | |
| 193 | - | ||
| 194 | - | func TestFinalSnapshotSupersedesPendingCaptureBeforeWorkerTeardown(t *testing.T) { | |
| 195 | - | clock := newFinalFlushClock() | |
| 196 | - | store := &channelSnapshotStore{writes: make(chan []byte, 2)} | |
| 197 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | |
| 198 | - | WithSnapshotStore(store)(d) | |
| 199 | - | d.startSnapshotEncodeWorker() | |
| 200 | - | ||
| 201 | - | entered := make(chan struct{}) | |
| 202 | - | release := make(chan struct{}) | |
| 203 | - | var calls atomic.Int32 | |
| 204 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |
| 205 | - | if calls.Add(1) == 1 { | |
| 206 | - | close(entered) | |
| 207 | - | <-release | |
| 208 | - | } | |
| 209 | - | return snapcodec.Marshal(s) | |
| 210 | - | } | |
| 211 | - | sess := newSnapshotTestSession(t, "pending-final", false, "/work") | |
| 212 | - | markSnapshotDirty(sess) | |
| 213 | - | require.True(t, d.scheduleSnapshot(sess)) | |
| 214 | - | <-entered | |
| 215 | - | ||
| 216 | - | pane := sess.tabs[0].panes["pane-1"] | |
| 217 | - | pane.mu.Lock() | |
| 218 | - | pane.screen.Write([]byte("\rterminal")) | |
| 219 | - | pane.mu.Unlock() | |
| 220 | - | markSnapshotDirty(sess) | |
| 221 | - | require.True(t, d.scheduleFinalSnapshot(sess)) | |
| 222 | - | ||
| 223 | - | stopped := make(chan struct{}) | |
| 224 | - | go func() { | |
| 225 | - | d.stopSnapshotEncodeWorker() | |
| 226 | - | close(stopped) | |
| 227 | - | }() | |
| 228 | - | close(release) | |
| 229 | - | <-stopped | |
| 230 | - | ||
| 231 | - | require.Len(t, store.writes, 2, "teardown must flush the newer terminal capture") | |
| 232 | - | var latest snapcodec.Session | |
| 233 | - | for range 2 { | |
| 234 | - | snap, err := snapcodec.Unmarshal(<-store.writes) | |
| 235 | - | require.NoError(t, err) | |
| 236 | - | latest = snap | |
| 237 | - | } | |
| 238 | - | frame, err := vt.UnmarshalVisible(latest.Tabs[0].Panes[0].Visible) | |
| 239 | - | require.NoError(t, err) | |
| 240 | - | require.Contains(t, rowText(frame.Row(0)), "terminal") | |
| 241 | - | } | |
| 242 | - | ||
| 243 | - | func TestFinalSnapshotFallbackCoalescesRepeatedTerminalCaptures(t *testing.T) { | |
| 244 | - | store := discardSnapshotStore{} | |
| 245 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 246 | - | WithSnapshotStore(store)(d) | |
| 247 | - | d.startSnapshotEncodeWorker() | |
| 248 | - | ||
| 249 | - | entered := make(chan struct{}) | |
| 250 | - | release := make(chan struct{}) | |
| 251 | - | var releaseOnce sync.Once | |
| 252 | - | releaseWorker := func() { releaseOnce.Do(func() { close(release) }) } | |
| 253 | - | defer func() { | |
| 254 | - | releaseWorker() | |
| 255 | - | d.stopSnapshotEncodeWorker() | |
| 256 | - | }() | |
| 257 | - | var calls atomic.Int32 | |
| 258 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |
| 259 | - | if calls.Add(1) == 1 { | |
| 260 | - | close(entered) | |
| 261 | - | <-release | |
| 262 | - | } | |
| 263 | - | return snapcodec.Marshal(s) | |
| 264 | - | } | |
| 265 | - | sess := newSnapshotTestSession(t, "coalesced-final", false, "/work") | |
| 266 | - | markSnapshotDirty(sess) | |
| 267 | - | require.True(t, d.scheduleSnapshot(sess)) | |
| 268 | - | <-entered | |
| 269 | - | ||
| 270 | - | for generation := uint64(1); generation <= 32; generation++ { | |
| 271 | - | capture, ok := d.captureSnapshotState(sess, generation) | |
| 272 | - | require.True(t, ok) | |
| 273 | - | require.True(t, d.enqueueFinalSnapshotCapture(capture)) | |
| 274 | - | } | |
| 275 | - | ||
| 276 | - | d.snapshotWorkerMu.Lock() | |
| 277 | - | captures := make([]*snapshotCapture, 0, len(d.snapshotFinalJobs)) | |
| 278 | - | for _, capture := range d.snapshotFinalJobs { | |
| 279 | - | captures = append(captures, capture) | |
| 280 | - | } | |
| 281 | - | d.snapshotWorkerMu.Unlock() | |
| 282 | - | require.Len(t, captures, 1, "blocked workers retain only the latest terminal capture per session") | |
| 283 | - | require.Equal(t, uint64(32), captures[0].generation) | |
| 284 | - | releaseWorker() | |
| 285 | - | } | |
| 286 | - | ||
| 287 | - | func TestFinalSnapshotFallbackBoundsDistinctTerminalSessions(t *testing.T) { | |
| 288 | - | store := discardSnapshotStore{} | |
| 289 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 290 | - | WithSnapshotStore(store)(d) | |
| 291 | - | d.startSnapshotEncodeWorker() | |
| 292 | - | ||
| 293 | - | entered := make(chan struct{}) | |
| 294 | - | release := make(chan struct{}) | |
| 295 | - | var calls atomic.Int32 | |
| 296 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |
| 297 | - | if calls.Add(1) == 1 { | |
| 298 | - | close(entered) | |
| 299 | - | <-release | |
| 300 | - | } | |
| 301 | - | return snapcodec.Marshal(s) | |
| 302 | - | } | |
| 303 | - | defer func() { | |
| 304 | - | close(release) | |
| 305 | - | d.stopSnapshotEncodeWorker() | |
| 306 | - | }() | |
| 307 | - | ||
| 308 | - | active := newSnapshotTestSession(t, "active", false, "/work") | |
| 309 | - | queued := newSnapshotTestSession(t, "queued", false, "/work") | |
| 310 | - | markSnapshotDirty(active) | |
| 311 | - | markSnapshotDirty(queued) | |
| 312 | - | require.True(t, d.scheduleSnapshot(active)) | |
| 313 | - | <-entered | |
| 314 | - | require.True(t, d.scheduleSnapshot(queued), "fill the regular bounded queue before terminal retirement") | |
| 315 | - | ||
| 316 | - | var superseded *session | |
| 317 | - | for i := range snapshotFinalQueueCapacity { | |
| 318 | - | sess := newSnapshotTestSession(t, fmt.Sprintf("terminal-%d", i), false, "/work") | |
| 319 | - | markSnapshotDirty(sess) | |
| 320 | - | require.True(t, d.scheduleFinalSnapshot(sess)) | |
| 321 | - | if i == 0 { | |
| 322 | - | superseded = sess | |
| 323 | - | } | |
| 324 | - | } | |
| 325 | - | markSnapshotDirty(superseded) | |
| 326 | - | require.True(t, d.scheduleFinalSnapshot(superseded), "a newer generation for a retained session must supersede at capacity") | |
| 327 | - | ||
| 328 | - | rejected := newSnapshotTestSession(t, "terminal-rejected", false, "/work") | |
| 329 | - | d.mu.Lock() | |
| 330 | - | d.sessions[rejected.id] = rejected | |
| 331 | - | d.mu.Unlock() | |
| 332 | - | markSnapshotDirty(rejected) | |
| 333 | - | err := d.killSession(rejected, ports.ReasonSessionKilled, false) | |
| 334 | - | require.ErrorContains(t, err, "snapshot worker unavailable or saturated") | |
| 335 | - | d.mu.Lock() | |
| 336 | - | require.Same(t, rejected, d.sessions[rejected.id], "rejected terminal state must remain registered for retry") | |
| 337 | - | d.mu.Unlock() | |
| 338 | - | ||
| 339 | - | d.snapshotWorkerMu.Lock() | |
| 340 | - | retained := len(d.snapshotFinalJobs) | |
| 341 | - | owners := len(d.snapshotFinalOrder) | |
| 342 | - | supersedingCapture := d.snapshotFinalJobs[superseded] | |
| 343 | - | d.snapshotWorkerMu.Unlock() | |
| 344 | - | require.Equal(t, snapshotFinalQueueCapacity, retained, "distinct terminal captures must have a global bound") | |
| 345 | - | require.Equal(t, snapshotFinalQueueCapacity, owners, "retention order must not retain extra session owners") | |
| 346 | - | require.NotNil(t, supersedingCapture) | |
| 347 | - | require.Equal(t, uint64(2), supersedingCapture.generation, "retained session must keep its newest terminal generation") | |
| 348 | - | require.NotNil(t, rejected) | |
| 349 | - | require.True(t, rejected.snapEligible.Load()) | |
| 350 | - | require.True(t, rejected.snapDirty.Load(), "a rejected terminal capture must remain retryable") | |
| 351 | - | awaitSnapshotIdle(t, rejected) | |
| 352 | - | } | |
| 353 | - | ||
| 354 | - | func TestStopSnapshotEncodeWorkerDoesNotWaitForUncancellableWrite(t *testing.T) { | |
| 355 | - | store := newNeverReturningSnapshotStore() | |
| 356 | - | clock := newFinalFlushClock() | |
| 357 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | |
| 358 | - | WithSnapshotStore(store)(d) | |
| 359 | - | d.startSnapshotEncodeWorker() | |
| 360 | - | ||
| 361 | - | sess := newSnapshotTestSession(t, "stuck-write", false, "/work") | |
| 362 | - | require.True(t, d.captureSession(sess)) | |
| 363 | - | <-store.entered | |
| 364 | - | ||
| 365 | - | stopped := make(chan struct{}) | |
| 366 | - | go func() { | |
| 367 | - | d.stopSnapshotEncodeWorker() | |
| 368 | - | close(stopped) | |
| 369 | - | }() | |
| 370 | - | timer := <-clock.timers | |
| 371 | - | timer.ch <- time.Time{} | |
| 372 | - | <-stopped | |
| 373 | - | awaitSnapshotIdle(t, sess) | |
| 374 | - | require.True(t, sess.snapDirty.Load(), "an abandoned write must remain retryable") | |
| 375 | - | require.NotPanics(t, func() { require.False(t, d.scheduleSnapshot(sess)) }) | |
| 376 | - | } | |
| 377 | - | ||
| 378 | - | func TestRejectedSnapshotCaptureLeavesSessionRetryable(t *testing.T) { | |
| 379 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 380 | - | WithSnapshotStore(&channelSnapshotStore{writes: make(chan []byte, 1)})(d) | |
| 381 | - | sess := newSnapshotTestSession(t, "rejected", false, "/work") | |
| 382 | - | sess.name = "" | |
| 383 | - | sess.snapEligible.Store(true) | |
| 384 | - | ||
| 385 | - | markSnapshotDirty(sess) | |
| 386 | - | require.False(t, d.scheduleSnapshot(sess)) | |
| 387 | - | awaitSnapshotIdle(t, sess) | |
| 388 | - | require.True(t, sess.snapDirty.Load()) | |
| 389 | - | } | |
| 390 | - | ||
| 391 | - | func TestStoppedSnapshotWorkerLeavesCaptureRetryable(t *testing.T) { | |
| 392 | - | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | |
| 393 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 394 | - | WithSnapshotStore(store)(d) | |
| 395 | - | d.startSnapshotEncodeWorker() | |
| 396 | - | d.stopSnapshotEncodeWorker() | |
| 397 | - | ||
| 398 | - | sess := newSnapshotTestSession(t, "stopped", false, "/work") | |
| 399 | - | markSnapshotDirty(sess) | |
| 400 | - | require.False(t, d.scheduleSnapshot(sess), "a stopped worker must not accept a capture") | |
| 401 | - | awaitSnapshotIdle(t, sess) | |
| 402 | - | require.True(t, sess.snapDirty.Load(), "an unsubmitted capture must remain retryable") | |
| 403 | - | } | |
| 404 | - | ||
| 405 | - | func TestSnapshotQueueSaturationLeavesCaptureRetryable(t *testing.T) { | |
| 406 | - | store := &channelSnapshotStore{writes: make(chan []byte, 3)} | |
| 407 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 408 | - | WithSnapshotStore(store)(d) | |
| 409 | - | startSnapshotEncodeWorker(t, d) | |
| 410 | - | entered := make(chan struct{}) | |
| 411 | - | release := make(chan struct{}) | |
| 412 | - | var calls atomic.Int32 | |
| 413 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |
| 414 | - | if calls.Add(1) == 1 { | |
| 415 | - | close(entered) | |
| 416 | - | <-release | |
| 417 | - | } | |
| 418 | - | return snapcodec.Marshal(s) | |
| 419 | - | } | |
| 420 | - | first := newSnapshotTestSession(t, "first", false, "/work") | |
| 421 | - | second := newSnapshotTestSession(t, "second", false, "/work") | |
| 422 | - | third := newSnapshotTestSession(t, "third", false, "/work") | |
| 423 | - | markSnapshotDirty(first) | |
| 424 | - | markSnapshotDirty(second) | |
| 425 | - | markSnapshotDirty(third) | |
| 426 | - | require.True(t, d.scheduleSnapshot(first)) | |
| 427 | - | <-entered | |
| 428 | - | require.True(t, d.scheduleSnapshot(second), "one capture may wait in the bounded queue") | |
| 429 | - | require.False(t, d.scheduleSnapshot(third), "full queue must not block the producer") | |
| 430 | - | require.True(t, third.snapDirty.Load()) | |
| 431 | - | ||
| 432 | - | close(release) | |
| 433 | - | awaitSnapshotClean(t, first) | |
| 434 | - | awaitSnapshotClean(t, second) | |
| 435 | - | require.True(t, d.scheduleSnapshot(third)) | |
| 436 | - | <-store.writes | |
| 437 | - | awaitSnapshotClean(t, third) | |
| 438 | - | } | |
| 439 | - | ||
| 440 | - | func TestNamedFinalSnapshotSurvivesSaturatedQueue(t *testing.T) { | |
| 441 | - | for _, tc := range []struct { | |
| 442 | - | name string | |
| 443 | - | stop func(t *testing.T, d *Daemon, sess *session) | |
| 444 | - | }{ | |
| 445 | - | { | |
| 446 | - | name: "kill session", | |
| 447 | - | stop: func(t *testing.T, d *Daemon, sess *session) { | |
| 448 | - | t.Helper() | |
| 449 | - | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |
| 450 | - | }, | |
| 451 | - | }, | |
| 452 | - | { | |
| 453 | - | name: "shutdown all", | |
| 454 | - | stop: func(t *testing.T, d *Daemon, _ *session) { | |
| 455 | - | t.Helper() | |
| 456 | - | d.shutdownAll(ports.ReasonServerShutdown) | |
| 457 | - | }, | |
| 458 | - | }, | |
| 459 | - | } { | |
| 460 | - | t.Run(tc.name, func(t *testing.T) { | |
| 461 | - | store := &channelSnapshotStore{writes: make(chan []byte, 3)} | |
| 462 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 463 | - | WithSnapshotStore(store)(d) | |
| 464 | - | startSnapshotEncodeWorker(t, d) | |
| 465 | - | ||
| 466 | - | entered := make(chan struct{}) | |
| 467 | - | release := make(chan struct{}) | |
| 468 | - | var calls atomic.Int32 | |
| 469 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |
| 470 | - | if calls.Add(1) == 1 { | |
| 471 | - | close(entered) | |
| 472 | - | <-release | |
| 473 | - | } | |
| 474 | - | return snapcodec.Marshal(s) | |
| 475 | - | } | |
| 476 | - | first := newSnapshotTestSession(t, "first", false, "/work") | |
| 477 | - | second := newSnapshotTestSession(t, "second", false, "/work") | |
| 478 | - | final := newSnapshotTestSession(t, "final", false, "/work") | |
| 479 | - | finalPTY, ok := final.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |
| 480 | - | require.True(t, ok) | |
| 481 | - | finalPTY.EXPECT().Close().Return(nil).Once() | |
| 482 | - | markSnapshotDirty(first) | |
| 483 | - | markSnapshotDirty(second) | |
| 484 | - | require.True(t, d.scheduleSnapshot(first)) | |
| 485 | - | <-entered | |
| 486 | - | require.True(t, d.scheduleSnapshot(second)) | |
| 487 | - | d.sessions[final.id] = final | |
| 488 | - | ||
| 489 | - | tc.stop(t, d, final) | |
| 490 | - | require.NotContains(t, d.sessions, final.id, "final capture must survive session removal") | |
| 491 | - | close(release) | |
| 492 | - | ||
| 493 | - | written := make(map[string]bool, 3) | |
| 494 | - | deadline := time.NewTimer(time.Second) | |
| 495 | - | defer deadline.Stop() | |
| 496 | - | for len(written) < 3 { | |
| 497 | - | select { | |
| 498 | - | case data := <-store.writes: | |
| 499 | - | snap, err := snapcodec.Unmarshal(data) | |
| 500 | - | require.NoError(t, err) | |
| 501 | - | written[snap.Name] = true | |
| 502 | - | case <-deadline.C: | |
| 503 | - | t.Fatalf("final snapshot was dropped: wrote %v", written) | |
| 504 | - | } | |
| 505 | - | } | |
| 506 | - | require.True(t, written[final.name]) | |
| 507 | - | }) | |
| 508 | - | } | |
| 509 | - | } | |
| 510 | - | ||
| 511 | - | func TestSnapshotCompletionSignalsWaiters(t *testing.T) { | |
| 512 | - | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | |
| 513 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 514 | - | WithSnapshotStore(store)(d) | |
| 515 | - | startSnapshotEncodeWorker(t, d) | |
| 516 | - | ||
| 517 | - | entered := make(chan struct{}) | |
| 518 | - | release := make(chan struct{}) | |
| 519 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |
| 520 | - | close(entered) | |
| 521 | - | <-release | |
| 522 | - | return snapcodec.Marshal(s) | |
| 523 | - | } | |
| 524 | - | sess := newSnapshotTestSession(t, "completion", false, "/work") | |
| 525 | - | require.True(t, d.captureSession(sess)) | |
| 526 | - | <-entered | |
| 527 | - | ||
| 528 | - | sess.snapshotMu.Lock() | |
| 529 | - | changed := sess.snapshotChangeLocked() | |
| 530 | - | sess.snapshotMu.Unlock() | |
| 531 | - | close(release) | |
| 532 | - | <-changed | |
| 533 | - | awaitSnapshotClean(t, sess) | |
| 534 | - | } | |
| 535 | - | ||
| 536 | - | func TestSnapshotSuccessDoesNotClearNewerGeneration(t *testing.T) { | |
| 537 | - | store := &channelSnapshotStore{writes: make(chan []byte, 2)} | |
| 538 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 539 | - | WithSnapshotStore(store)(d) | |
| 22 | + | repository := portsmocks.NewMockSnapshotRepository(t) | |
| 23 | + | WithSnapshotRepository(repository, nil)(d) | |
| 540 | 24 | startSnapshotEncodeWorker(t, d) | |
| 541 | - | entered := make(chan struct{}) | |
| 542 | - | release := make(chan struct{}) | |
| 543 | - | var calls atomic.Int32 | |
| 544 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |
| 545 | - | if calls.Add(1) == 1 { | |
| 546 | - | close(entered) | |
| 547 | - | <-release | |
| 548 | - | } | |
| 549 | - | return snapcodec.Marshal(s) | |
| 550 | - | } | |
| 551 | - | sess := newSnapshotTestSession(t, "generation", false, "/work") | |
| 552 | 25 | ||
| 553 | - | markSnapshotDirty(sess) | |
| 554 | - | require.True(t, d.scheduleSnapshot(sess)) | |
| 555 | - | <-entered | |
| 556 | - | markSnapshotDirty(sess) | |
| 557 | - | close(release) | |
| 558 | - | <-store.writes | |
| 559 | - | awaitSnapshotIdle(t, sess) | |
| 560 | - | require.True(t, sess.snapDirty.Load(), "a stale successful capture must not clear newer state") | |
| 561 | - | ||
| 562 | - | require.True(t, d.scheduleSnapshot(sess)) | |
| 563 | - | <-store.writes | |
| 564 | - | awaitSnapshotClean(t, sess) | |
| 565 | - | } | |
| 566 | - | ||
| 567 | - | func TestSnapshotEncodeFailureLeavesSessionDirtyAndRetries(t *testing.T) { | |
| 568 | - | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | |
| 569 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 570 | - | WithSnapshotStore(store)(d) | |
| 571 | - | startSnapshotEncodeWorker(t, d) | |
| 572 | - | sess := newSnapshotTestSession(t, "retry-encode", false, "/work") | |
| 573 | - | failed := make(chan struct{}, 1) | |
| 574 | - | var calls atomic.Int32 | |
| 575 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |
| 576 | - | if calls.Add(1) == 1 { | |
| 577 | - | failed <- struct{}{} | |
| 578 | - | return nil, errors.New("encode failed") | |
| 579 | - | } | |
| 580 | - | return snapcodec.Marshal(s) | |
| 581 | - | } | |
| 582 | - | ||
| 583 | - | require.True(t, d.captureSession(sess)) | |
| 584 | - | <-failed | |
| 585 | - | awaitSnapshotIdle(t, sess) | |
| 586 | - | require.True(t, sess.snapDirty.Load()) | |
| 587 | - | require.True(t, d.scheduleSnapshot(sess)) | |
| 588 | - | <-store.writes | |
| 589 | - | awaitSnapshotClean(t, sess) | |
| 590 | - | } | |
| 591 | - | ||
| 592 | - | func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) { | |
| 593 | - | clock := newManualSnapshotClock() | |
| 594 | - | store := portsmocks.NewMockSnapshotStore(t) | |
| 595 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | |
| 596 | - | WithSnapshotStore(store)(d) | |
| 597 | - | d.procCwd = func(int) (string, error) { return "/pane", nil } | |
| 598 | - | startSnapshotEncodeWorker(t, d) | |
| 599 | - | ||
| 600 | - | dirty := newSnapshotTestSession(t, "dirty", false, "/fallback") | |
| 601 | - | clean := newSnapshotTestSession(t, "clean", false, "/fallback") | |
| 602 | - | ephemeral := newSnapshotTestSession(t, "0", true, "/fallback") | |
| 603 | - | dirty.snapDirty.Store(true) | |
| 604 | - | ephemeral.snapDirty.Store(true) | |
| 605 | - | d.sessions[dirty.id] = dirty | |
| 606 | - | d.sessions[clean.id] = clean | |
| 607 | - | d.sessions[ephemeral.id] = ephemeral | |
| 608 | - | ||
| 609 | - | wrote := make(chan struct{}, 1) | |
| 610 | - | store.EXPECT().Write("dirty", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |
| 611 | - | snap, err := snapcodec.Unmarshal(data) | |
| 612 | - | require.NoError(t, err) | |
| 613 | - | require.Equal(t, "dirty", snap.Name) | |
| 614 | - | wrote <- struct{}{} | |
| 615 | - | return nil | |
| 616 | - | }).Once() | |
| 617 | - | ||
| 618 | - | ctx, cancel := context.WithCancel(t.Context()) | |
| 619 | - | defer cancel() | |
| 620 | - | go d.snapshotSaver(ctx) | |
| 621 | - | clock.fireNext(t) | |
| 622 | - | select { | |
| 623 | - | case <-wrote: | |
| 624 | - | case <-time.After(time.Second): | |
| 625 | - | t.Fatal("timed out waiting for dirty snapshot write") | |
| 626 | - | } | |
| 627 | - | awaitSnapshotClean(t, dirty) | |
| 628 | - | ||
| 629 | - | clock.fireNext(t) | |
| 630 | - | select { | |
| 631 | - | case <-wrote: | |
| 632 | - | t.Fatal("clean second interval wrote unexpectedly") | |
| 633 | - | case <-time.After(25 * time.Millisecond): | |
| 634 | - | } | |
| 635 | - | } | |
| 636 | - | ||
| 637 | - | func TestSnapshotCaptureExcludesFloatingRuntime(t *testing.T) { | |
| 638 | - | store := portsmocks.NewMockSnapshotStore(t) | |
| 639 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 640 | - | WithSnapshotStore(store)(d) | |
| 641 | - | startSnapshotEncodeWorker(t, d) | |
| 642 | - | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |
| 643 | - | tb := sess.tabs[0] | |
| 644 | - | floatingPTY, _ := newBlockingPTY(t) | |
| 645 | - | floating := newPane(layout.PaneID("floating"), floatingPTY, domain.Size{Cols: 20, Rows: 8}) | |
| 646 | - | tb.mu.Lock() | |
| 647 | - | generation := tb.beginFloatingWarmLocked(false) | |
| 648 | - | require.True(t, tb.installFloatingLocked(floating, generation)) | |
| 649 | - | tb.mu.Unlock() | |
| 650 | - | ||
| 651 | - | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |
| 652 | - | snap, err := snapcodec.Unmarshal(data) | |
| 653 | - | require.NoError(t, err) | |
| 654 | - | require.Len(t, snap.Tabs, 1) | |
| 655 | - | require.Len(t, snap.Tabs[0].Panes, 1, "floating runtime must not be persisted") | |
| 656 | - | require.Equal(t, layout.PaneID("pane-1"), snap.Tabs[0].Panes[0].ID) | |
| 657 | - | return nil | |
| 658 | - | }).Once() | |
| 659 | - | require.True(t, d.captureSession(sess)) | |
| 660 | - | awaitSnapshotClean(t, sess) | |
| 661 | - | } | |
| 662 | - | ||
| 663 | - | func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) { | |
| 664 | - | store := portsmocks.NewMockSnapshotStore(t) | |
| 665 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 666 | - | WithSnapshotStore(store)(d) | |
| 667 | - | startSnapshotEncodeWorker(t, d) | |
| 668 | - | d.procGroupArgv = func(pgid int, shellPid int) ([]string, error) { | |
| 669 | - | require.Equal(t, 4321, pgid) | |
| 670 | - | require.Equal(t, 1234, shellPid) | |
| 671 | - | return []string{"claude", "--session-id", "agent-123"}, nil | |
| 672 | - | } | |
| 673 | - | ||
| 674 | - | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |
| 675 | - | pty := snapshotProcessPTY(t, 4321) | |
| 676 | - | sess.tabs[0].panes["pane-1"].pty = pty | |
| 677 | - | ||
| 678 | - | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |
| 679 | - | snap, err := snapcodec.Unmarshal(data) | |
| 680 | - | require.NoError(t, err) | |
| 681 | - | proc := snap.Tabs[0].Panes[0].Process | |
| 682 | - | require.NotNil(t, proc) | |
| 683 | - | require.Equal(t, []string{"claude", "--session-id", "agent-123"}, proc.Argv) | |
| 684 | - | require.Equal(t, processStrategyClaude, proc.Strategy) | |
| 685 | - | require.Equal(t, "agent-123", proc.Opts.AgentSessionID) | |
| 686 | - | return nil | |
| 687 | - | }).Once() | |
| 688 | - | ||
| 689 | - | require.True(t, d.captureSession(sess)) | |
| 690 | - | awaitSnapshotClean(t, sess) | |
| 691 | - | } | |
| 692 | - | ||
| 693 | - | func TestSnapshotCaptureProcessFailureDoesNotBlockSnapshot(t *testing.T) { | |
| 694 | - | store := portsmocks.NewMockSnapshotStore(t) | |
| 695 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 696 | - | WithSnapshotStore(store)(d) | |
| 697 | - | startSnapshotEncodeWorker(t, d) | |
| 698 | - | d.procGroupArgv = func(int, int) ([]string, error) { return nil, errors.New("inspect failed") } | |
| 699 | - | ||
| 700 | - | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |
| 701 | - | pty := snapshotProcessPTY(t, 4321) | |
| 702 | - | sess.tabs[0].panes["pane-1"].pty = pty | |
| 703 | - | ||
| 704 | - | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |
| 705 | - | snap, err := snapcodec.Unmarshal(data) | |
| 706 | - | require.NoError(t, err) | |
| 707 | - | require.Nil(t, snap.Tabs[0].Panes[0].Process) | |
| 708 | - | return nil | |
| 709 | - | }).Once() | |
| 710 | - | ||
| 711 | - | require.True(t, d.captureSession(sess)) | |
| 712 | - | awaitSnapshotClean(t, sess) | |
| 713 | - | } | |
| 714 | - | ||
| 715 | - | func TestSnapshotCaptureSkipsBareShellProcess(t *testing.T) { | |
| 716 | - | store := portsmocks.NewMockSnapshotStore(t) | |
| 717 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 718 | - | WithSnapshotStore(store)(d) | |
| 719 | - | startSnapshotEncodeWorker(t, d) | |
| 720 | - | d.procGroupArgv = func(int, int) ([]string, error) { | |
| 721 | - | t.Fatal("bare shell should not inspect argv") | |
| 722 | - | return nil, nil | |
| 723 | - | } | |
| 724 | - | ||
| 725 | - | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |
| 726 | - | pty := snapshotProcessPTY(t, 1234) | |
| 727 | - | sess.tabs[0].panes["pane-1"].pty = pty | |
| 728 | - | ||
| 729 | - | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |
| 730 | - | snap, err := snapcodec.Unmarshal(data) | |
| 731 | - | require.NoError(t, err) | |
| 732 | - | require.Nil(t, snap.Tabs[0].Panes[0].Process) | |
| 733 | - | return nil | |
| 734 | - | }).Once() | |
| 26 | + | repository.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(p ports.SnapshotPublication) bool { | |
| 27 | + | return p.Name == "work" && p.Generation == 1 && len(p.Manifest) > 0 && len(p.Objects) > 0 | |
| 28 | + | })).Return(nil).Once() | |
| 735 | 29 | ||
| 30 | + | sess := newSnapshotTestSession(t, "work", false, "/work") | |
| 736 | 31 | require.True(t, d.captureSession(sess)) | |
| 737 | 32 | awaitSnapshotClean(t, sess) | |
| 738 | 33 | } | |
| 739 | 34 | ||
| 740 | - | func TestKillSessionSnapshotsNamedSessionBeforeClosingPanes(t *testing.T) { | |
| 741 | - | store := newGatedSnapshotStore() | |
| 742 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 743 | - | WithSnapshotStore(store)(d) | |
| 744 | - | d.startSnapshotEncodeWorker() | |
| 745 | - | t.Cleanup(func() { | |
| 746 | - | store.unblock() | |
| 747 | - | d.stopSnapshotEncodeWorker() | |
| 748 | - | }) | |
| 749 | - | d.procCwd = func(int) (string, error) { return "/live", nil } | |
| 750 | - | ||
| 751 | - | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |
| 752 | - | d.sessions[sess.id] = sess | |
| 753 | - | ||
| 754 | - | var closed atomic.Bool | |
| 755 | - | mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |
| 756 | - | require.True(t, ok) | |
| 757 | - | mockPTY.EXPECT().Close().RunAndReturn(func() error { | |
| 758 | - | closed.Store(true) | |
| 759 | - | return nil | |
| 760 | - | }).Once() | |
| 761 | - | ||
| 762 | - | require.NoError(t, d.killSession(sess, 0, false)) | |
| 763 | - | // closeAllPanes runs synchronously inside killSession, which has already | |
| 764 | - | // returned here. The store's write gate is still held below, so | |
| 765 | - | // persistence cannot have completed yet: this proves pane close did not | |
| 766 | - | // wait on it, with zero timing dependence on the encode worker. | |
| 767 | - | require.True(t, closed.Load(), "snapshot persistence must not delay pane close") | |
| 768 | - | ||
| 769 | - | store.unblock() | |
| 770 | - | awaitSnapshotClean(t, sess) | |
| 771 | - | ||
| 772 | - | writes, name, data := store.recorded() | |
| 773 | - | require.Equal(t, 1, writes) | |
| 774 | - | require.Equal(t, "work", name) | |
| 775 | - | snap, err := snapcodec.Unmarshal(data) | |
| 776 | - | require.NoError(t, err) | |
| 777 | - | require.Equal(t, "work", snap.Name) | |
| 778 | - | require.Equal(t, "/live", snap.Tabs[0].Panes[0].Cwd) | |
| 779 | - | } | |
| 780 | - | ||
| 781 | - | func TestPTYReaderSynchronizedUpdateFlushMarksSnapshotDirty(t *testing.T) { | |
| 782 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 783 | - | p := portsmocks.NewMockPTY(t) | |
| 784 | - | chunks := [][]byte{[]byte("\x1b[?2026hhello\x1b[?2026l")} | |
| 785 | - | p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) { | |
| 786 | - | if len(chunks) == 0 { | |
| 787 | - | return 0, io.EOF | |
| 788 | - | } | |
| 789 | - | n := copy(buf, chunks[0]) | |
| 790 | - | chunks = chunks[1:] | |
| 791 | - | return n, nil | |
| 792 | - | }) | |
| 793 | - | p.EXPECT().Close().Return(nil).Maybe() | |
| 794 | - | ||
| 795 | - | sctx, cancel := context.WithCancel(context.Background()) | |
| 796 | - | defer cancel() | |
| 797 | - | tb := newTestTabWithContext(p, sctx, cancel) | |
| 798 | - | sess := &session{id: "sync", name: "sync", tabs: []*tab{tb}, ctx: sctx, cancel: cancel} | |
| 799 | - | sess.snapEligible.Store(true) | |
| 800 | - | d.sessions[sess.id] = sess | |
| 801 | - | d.sessWg.Add(1) | |
| 802 | - | d.ptyReader(sess, tb, tb.focusedPane()) | |
| 803 | - | ||
| 804 | - | require.True(t, sess.snapDirty.Load()) | |
| 805 | - | } | |
| 806 | - | ||
| 807 | - | func TestPTYReaderSameReadSynchronizedOutputUsesUrgentCoordinatorDeadline(t *testing.T) { | |
| 808 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 809 | - | p := chunkReadPTY(t, []byte("\x1b[?2026hcomplete batch\x1b[?2026l")) | |
| 810 | - | sctx, cancel := context.WithCancel(context.Background()) | |
| 811 | - | defer cancel() | |
| 812 | - | tb := newTestTabWithContext(p, sctx, cancel) | |
| 813 | - | sess := &session{id: "sync-urgent", name: "sync-urgent", tabs: []*tab{tb}, client: &attachedClient{}, ctx: sctx, cancel: cancel} | |
| 814 | - | invs := make(chan renderInvalidation, 1) | |
| 815 | - | clock := newCoordinatorMockClock(t, 2) | |
| 816 | - | sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{ | |
| 817 | - | clock: clock.clock, | |
| 818 | - | onInvalidate: func(inv renderInvalidation) { invs <- inv }, | |
| 819 | - | })) | |
| 820 | - | d.sessions[sess.id] = sess | |
| 821 | - | d.sessWg.Add(1) | |
| 822 | - | d.ptyReader(sess, tb, tb.focusedPane()) | |
| 823 | - | ||
| 824 | - | inv := awaitInvalidation(t, invs) | |
| 825 | - | require.Equal(t, invalidateUrgent, inv.class, "a complete same-read synchronized batch must flush urgently") | |
| 826 | - | timer := awaitCoordinatorScheduledTimer(t, clock) | |
| 827 | - | require.Equal(t, urgentRenderDeadline, timer.duration) | |
| 828 | - | } | |
| 829 | - | ||
| 830 | - | func TestSnapshotSaverKeepsDirtyWhenWriteFails(t *testing.T) { | |
| 831 | - | clock := newManualSnapshotClock() | |
| 832 | - | store := portsmocks.NewMockSnapshotStore(t) | |
| 833 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | |
| 834 | - | WithSnapshotStore(store)(d) | |
| 835 | - | startSnapshotEncodeWorker(t, d) | |
| 836 | - | ||
| 837 | - | sess := newSnapshotTestSession(t, "retry", false, "/fallback") | |
| 838 | - | sess.snapDirty.Store(true) | |
| 839 | - | d.sessions[sess.id] = sess | |
| 840 | - | ||
| 841 | - | failed := make(chan struct{}, 1) | |
| 842 | - | store.EXPECT().Write("retry", mock.Anything).RunAndReturn(func(string, []byte) error { | |
| 843 | - | failed <- struct{}{} | |
| 844 | - | return errors.New("boom") | |
| 845 | - | }).Once() | |
| 846 | - | wrote := make(chan struct{}, 1) | |
| 847 | - | store.EXPECT().Write("retry", mock.Anything).RunAndReturn(func(string, []byte) error { | |
| 848 | - | wrote <- struct{}{} | |
| 849 | - | return nil | |
| 850 | - | }).Once() | |
| 851 | - | ||
| 852 | - | ctx, cancel := context.WithCancel(t.Context()) | |
| 853 | - | defer cancel() | |
| 854 | - | go d.snapshotSaver(ctx) | |
| 855 | - | clock.fireNext(t) | |
| 856 | - | <-failed | |
| 857 | - | awaitSnapshotIdle(t, sess) | |
| 858 | - | require.True(t, sess.snapDirty.Load()) | |
| 859 | - | clock.fireNext(t) | |
| 860 | - | select { | |
| 861 | - | case <-wrote: | |
| 862 | - | case <-time.After(time.Second): | |
| 863 | - | t.Fatal("timed out waiting for retry snapshot write") | |
| 864 | - | } | |
| 865 | - | awaitSnapshotClean(t, sess) | |
| 866 | - | require.False(t, sess.snapDirty.Load()) | |
| 867 | - | } | |
| 868 | - | ||
| 869 | - | func TestKillSessionPurgeDeletesSnapshot(t *testing.T) { | |
| 870 | - | store := portsmocks.NewMockSnapshotStore(t) | |
| 871 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 872 | - | WithSnapshotStore(store)(d) | |
| 873 | - | ||
| 874 | - | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |
| 875 | - | d.sessions[sess.id] = sess | |
| 876 | - | ||
| 877 | - | store.EXPECT().Delete("work").Return(nil).Once() | |
| 878 | - | mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |
| 879 | - | require.True(t, ok) | |
| 880 | - | mockPTY.EXPECT().Close().Return(nil).Once() | |
| 881 | - | ||
| 882 | - | require.NoError(t, d.killSession(sess, 0, true)) | |
| 883 | - | } | |
| 884 | - | ||
| 885 | - | func TestKillSessionPurgeReturnsSnapshotDeleteError(t *testing.T) { | |
| 886 | - | store := portsmocks.NewMockSnapshotStore(t) | |
| 887 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 888 | - | WithSnapshotStore(store)(d) | |
| 889 | - | ||
| 890 | - | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |
| 891 | - | d.sessions[sess.id] = sess | |
| 892 | - | ||
| 893 | - | store.EXPECT().Delete("work").Return(errors.New("delete failed")).Once() | |
| 894 | - | mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |
| 895 | - | require.True(t, ok) | |
| 896 | - | mockPTY.EXPECT().Close().Return(nil).Once() | |
| 897 | - | ||
| 898 | - | err := d.killSession(sess, 0, true) | |
| 899 | - | require.ErrorContains(t, err, "delete failed") | |
| 900 | - | require.NotContains(t, d.sessions, sess.id) | |
| 901 | - | require.NotContains(t, d.stopped, "work") | |
| 902 | - | } | |
| 903 | - | ||
| 904 | - | func TestRefreshSessionCwdMarksSnapshotDirty(t *testing.T) { | |
| 905 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 906 | - | d.procCwd = func(int) (string, error) { return "/new", nil } | |
| 907 | - | sess := newSnapshotTestSession(t, "work", false, "/old") | |
| 908 | - | d.sessions[sess.id] = sess | |
| 909 | - | ||
| 910 | - | d.refreshSessionCwd(sess) | |
| 911 | - | require.Equal(t, "/new", sess.cwd) | |
| 912 | - | require.True(t, sess.snapDirty.Load()) | |
| 913 | - | } | |
| 914 | - | ||
| 915 | - | func TestStoppedSessionKillPurgesSnapshot(t *testing.T) { | |
| 916 | - | store := portsmocks.NewMockSnapshotStore(t) | |
| 917 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 918 | - | WithSnapshotStore(store)(d) | |
| 919 | - | d.stopped["work"] = stoppedSession{name: "work", cwd: "/work", createdAt: 1} | |
| 920 | - | ||
| 921 | - | store.EXPECT().Delete("work").Return(nil).Once() | |
| 922 | - | tr, _, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})}) | |
| 923 | - | ||
| 924 | - | d.handleKill(tr, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})}) | |
| 925 | - | ||
| 926 | - | require.NotContains(t, d.stopped, "work") | |
| 927 | - | } | |
| 928 | - | ||
| 929 | - | func TestStoppedSessionKillSnapshotDeleteFailureKeepsMetadata(t *testing.T) { | |
| 930 | - | store := portsmocks.NewMockSnapshotStore(t) | |
| 931 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 932 | - | WithSnapshotStore(store)(d) | |
| 933 | - | d.stopped["work"] = stoppedSession{name: "work", cwd: "/work", createdAt: 1} | |
| 934 | - | ||
| 935 | - | store.EXPECT().Delete("work").Return(errors.New("delete failed")).Once() | |
| 936 | - | tr, sends, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})}) | |
| 937 | - | ||
| 938 | - | d.handleKill(tr, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})}) | |
| 939 | - | ||
| 940 | - | require.Contains(t, d.stopped, "work") | |
| 941 | - | frame := awaitFrame(t, sends, ports.MsgError) | |
| 942 | - | msg, err := ports.UnmarshalErrorMsg(frame.Payload) | |
| 943 | - | require.NoError(t, err) | |
| 944 | - | require.Equal(t, ports.ErrInternal, msg.Code) | |
| 945 | - | } | |
| 946 | - | ||
| 947 | - | func TestRenameSessionDeletesOldSnapshotAndMarksDirty(t *testing.T) { | |
| 948 | - | store := portsmocks.NewMockSnapshotStore(t) | |
| 949 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 950 | - | WithSnapshotStore(store)(d) | |
| 951 | - | sess := newSnapshotTestSession(t, "old", false, "/fallback") | |
| 952 | - | d.sessions[sess.id] = sess | |
| 953 | - | ||
| 954 | - | store.EXPECT().Delete("old").Return(nil).Once() | |
| 955 | - | require.NoError(t, d.renameSession(sess, "new")) | |
| 956 | - | ||
| 957 | - | require.True(t, sess.snapDirty.Load()) | |
| 958 | - | require.Nil(t, d.findByNameLocked("old")) | |
| 959 | - | } | |
| 960 | - | ||
| 961 | - | func TestSnapshotSaverCapturesLayoutOnlyDirtySave(t *testing.T) { | |
| 962 | - | clock := newManualSnapshotClock() | |
| 963 | - | store := portsmocks.NewMockSnapshotStore(t) | |
| 964 | - | factory := portsmocks.NewMockPTYFactory(t) | |
| 965 | - | d := newTestDaemon(t, factory, clock) | |
| 966 | - | WithSnapshotStore(store)(d) | |
| 967 | - | startSnapshotEncodeWorker(t, d) | |
| 968 | - | ||
| 969 | - | sess := newSnapshotTestSession(t, "work", false, "/work") | |
| 970 | - | tctx, tcancel := context.WithCancel(context.Background()) | |
| 971 | - | t.Cleanup(tcancel) | |
| 972 | - | sess.ctx = tctx | |
| 973 | - | sess.cancel = tcancel | |
| 974 | - | sess.tabs[0].ctx = tctx | |
| 975 | - | sess.tabs[0].cancel = tcancel | |
| 976 | - | for _, p := range sess.tabs[0].panes { | |
| 977 | - | p.ctx = tctx | |
| 978 | - | p.cancel = tcancel | |
| 979 | - | } | |
| 980 | - | sess.tabs[0].size = domain.Size{Cols: 41, Rows: 10} | |
| 981 | - | d.sessions[sess.id] = sess | |
| 982 | - | newPTY := portsmocks.NewMockPTY(t) | |
| 983 | - | newPTY.EXPECT().Pid().Return(2345).Maybe() | |
| 984 | - | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | |
| 985 | - | newPTY.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe() | |
| 986 | - | newPTY.EXPECT().Resize(mock.Anything).Return(nil).Maybe() | |
| 987 | - | newPTY.EXPECT().ForegroundPgid().Return(0, nil).Maybe() | |
| 988 | - | factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once() | |
| 989 | - | ||
| 990 | - | require.NoError(t, d.splitPane(sess, nil, layout.Right)) | |
| 991 | - | require.True(t, sess.snapDirty.Load()) | |
| 992 | - | ||
| 993 | - | wrote := make(chan struct{}, 1) | |
| 994 | - | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |
| 995 | - | snap, err := snapcodec.Unmarshal(data) | |
| 996 | - | require.NoError(t, err) | |
| 997 | - | require.Len(t, snap.Tabs, 1) | |
| 998 | - | require.Len(t, snap.Tabs[0].Panes, 2) | |
| 999 | - | wrote <- struct{}{} | |
| 1000 | - | return nil | |
| 1001 | - | }).Once() | |
| 1002 | - | ||
| 1003 | - | ctx, cancel := context.WithCancel(t.Context()) | |
| 1004 | - | defer cancel() | |
| 1005 | - | go d.snapshotSaver(ctx) | |
| 1006 | - | clock.fireNext(t) | |
| 1007 | - | select { | |
| 1008 | - | case <-wrote: | |
| 1009 | - | case <-time.After(time.Second): | |
| 1010 | - | t.Fatal("timed out waiting for layout-only dirty snapshot write") | |
| 1011 | - | } | |
| 1012 | - | } | |
| 1013 | - | ||
| 1014 | - | func snapshotProcessPTY(t *testing.T, pgid int) *portsmocks.MockPTY { | |
| 1015 | - | t.Helper() | |
| 1016 | - | pty := portsmocks.NewMockPTY(t) | |
| 1017 | - | pty.EXPECT().Pid().Return(1234).Maybe() | |
| 1018 | - | pty.EXPECT().Read(mock.Anything).Return(0, errors.New("unused")).Maybe() | |
| 1019 | - | pty.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe() | |
| 1020 | - | pty.EXPECT().Resize(mock.Anything).Return(nil).Maybe() | |
| 1021 | - | pty.EXPECT().ForegroundPgid().Return(pgid, nil).Once() | |
| 1022 | - | return pty | |
| 1023 | - | } | |
| 1024 | - | ||
| 1025 | 35 | func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd string) *session { | |
| 1026 | 36 | t.Helper() | |
| 1027 | 37 | pty := portsmocks.NewMockPTY(t) | |
| @@ -1035,17 +45,7 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin | |||
| 1035 | 45 | p := tb.panes["pane-1"] | |
| 1036 | 46 | p.screen.Write([]byte("hello")) | |
| 1037 | 47 | require.NoError(t, p.history.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})) | |
| 1038 | - | sess := &session{ | |
| 1039 | - | id: domain.SessionID("sess-" + name), | |
| 1040 | - | name: name, | |
| 1041 | - | ephemeral: ephemeral, | |
| 1042 | - | ctx: context.Background(), | |
| 1043 | - | cancel: func() {}, | |
| 1044 | - | tabs: []*tab{tb}, | |
| 1045 | - | active: 0, | |
| 1046 | - | cwd: cwd, | |
| 1047 | - | createdAt: 42, | |
| 1048 | - | } | |
| 48 | + | sess := &session{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, active: 0, cwd: cwd, createdAt: 42} | |
| 1049 | 49 | sess.snapEligible.Store(!ephemeral && name != "") | |
| 1050 | 50 | return sess | |
| 1051 | 51 | } | |
| @@ -1095,135 +95,3 @@ func startSnapshotEncodeWorker(t *testing.T, d *Daemon) { | |||
| 1095 | 95 | d.startSnapshotEncodeWorker() | |
| 1096 | 96 | t.Cleanup(d.stopSnapshotEncodeWorker) | |
| 1097 | 97 | } | |
| 1098 | - | ||
| 1099 | - | type discardSnapshotStore struct{} | |
| 1100 | - | ||
| 1101 | - | func (discardSnapshotStore) Write(string, []byte) error { return nil } | |
| 1102 | - | func (discardSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil } | |
| 1103 | - | func (discardSnapshotStore) Delete(string) error { return nil } | |
| 1104 | - | ||
| 1105 | - | type gatedSnapshotStore struct { | |
| 1106 | - | entered chan struct{} | |
| 1107 | - | release chan struct{} | |
| 1108 | - | once sync.Once | |
| 1109 | - | releaseOnce sync.Once | |
| 1110 | - | ||
| 1111 | - | mu sync.Mutex | |
| 1112 | - | writes int | |
| 1113 | - | name string | |
| 1114 | - | data []byte | |
| 1115 | - | } | |
| 1116 | - | ||
| 1117 | - | func newGatedSnapshotStore() *gatedSnapshotStore { | |
| 1118 | - | return &gatedSnapshotStore{entered: make(chan struct{}), release: make(chan struct{})} | |
| 1119 | - | } | |
| 1120 | - | ||
| 1121 | - | func (s *gatedSnapshotStore) enter() { s.once.Do(func() { close(s.entered) }) } | |
| 1122 | - | func (s *gatedSnapshotStore) unblock() { s.releaseOnce.Do(func() { close(s.release) }) } | |
| 1123 | - | ||
| 1124 | - | func (s *gatedSnapshotStore) Write(name string, data []byte) error { | |
| 1125 | - | s.enter() | |
| 1126 | - | <-s.release | |
| 1127 | - | s.mu.Lock() | |
| 1128 | - | s.writes++ | |
| 1129 | - | s.name = name | |
| 1130 | - | s.data = append([]byte(nil), data...) | |
| 1131 | - | s.mu.Unlock() | |
| 1132 | - | return nil | |
| 1133 | - | } | |
| 1134 | - | func (*gatedSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil } | |
| 1135 | - | func (*gatedSnapshotStore) Delete(string) error { return nil } | |
| 1136 | - | ||
| 1137 | - | // recorded returns the write count and the most recently written name/data, | |
| 1138 | - | // captured under the store's own lock so it is safe to call from the test | |
| 1139 | - | // goroutine after the write has happened-before via awaitSnapshotClean. | |
| 1140 | - | func (s *gatedSnapshotStore) recorded() (int, string, []byte) { | |
| 1141 | - | s.mu.Lock() | |
| 1142 | - | defer s.mu.Unlock() | |
| 1143 | - | return s.writes, s.name, s.data | |
| 1144 | - | } | |
| 1145 | - | ||
| 1146 | - | type neverReturningSnapshotStore struct { | |
| 1147 | - | entered chan struct{} | |
| 1148 | - | once sync.Once | |
| 1149 | - | block chan struct{} | |
| 1150 | - | } | |
| 1151 | - | ||
| 1152 | - | func newNeverReturningSnapshotStore() *neverReturningSnapshotStore { | |
| 1153 | - | return &neverReturningSnapshotStore{entered: make(chan struct{}), block: make(chan struct{})} | |
| 1154 | - | } | |
| 1155 | - | ||
| 1156 | - | func (s *neverReturningSnapshotStore) Write(string, []byte) error { | |
| 1157 | - | s.once.Do(func() { close(s.entered) }) | |
| 1158 | - | <-s.block | |
| 1159 | - | return nil | |
| 1160 | - | } | |
| 1161 | - | func (*neverReturningSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil } | |
| 1162 | - | func (*neverReturningSnapshotStore) Delete(string) error { return nil } | |
| 1163 | - | ||
| 1164 | - | type channelSnapshotStore struct { | |
| 1165 | - | writes chan []byte | |
| 1166 | - | } | |
| 1167 | - | ||
| 1168 | - | func (s *channelSnapshotStore) Write(_ string, data []byte) error { | |
| 1169 | - | s.writes <- append([]byte(nil), data...) | |
| 1170 | - | return nil | |
| 1171 | - | } | |
| 1172 | - | func (*channelSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil } | |
| 1173 | - | func (*channelSnapshotStore) Delete(string) error { return nil } | |
| 1174 | - | ||
| 1175 | - | type manualSnapshotClock struct { | |
| 1176 | - | mu sync.Mutex | |
| 1177 | - | timers []*manualSnapshotTimer | |
| 1178 | - | } | |
| 1179 | - | ||
| 1180 | - | func newManualSnapshotClock() *manualSnapshotClock { return &manualSnapshotClock{} } | |
| 1181 | - | ||
| 1182 | - | func (c *manualSnapshotClock) Now() time.Time { return time.Unix(0, 0) } | |
| 1183 | - | ||
| 1184 | - | func (c *manualSnapshotClock) NewTimer(time.Duration) ports.Timer { | |
| 1185 | - | t := &manualSnapshotTimer{ch: make(chan time.Time, 1)} | |
| 1186 | - | c.mu.Lock() | |
| 1187 | - | c.timers = append(c.timers, t) | |
| 1188 | - | c.mu.Unlock() | |
| 1189 | - | return t | |
| 1190 | - | } | |
| 1191 | - | ||
| 1192 | - | func (c *manualSnapshotClock) fireNext(t *testing.T) { | |
| 1193 | - | t.Helper() | |
| 1194 | - | deadline := time.After(time.Second) | |
| 1195 | - | for { | |
| 1196 | - | c.mu.Lock() | |
| 1197 | - | if len(c.timers) > 0 { | |
| 1198 | - | timer := c.timers[0] | |
| 1199 | - | c.mu.Unlock() | |
| 1200 | - | timer.ch <- time.Unix(0, 0) | |
| 1201 | - | return | |
| 1202 | - | } | |
| 1203 | - | c.mu.Unlock() | |
| 1204 | - | select { | |
| 1205 | - | case <-deadline: | |
| 1206 | - | t.Fatal("timed out waiting for snapshot timer") | |
| 1207 | - | case <-time.After(time.Millisecond): | |
| 1208 | - | } | |
| 1209 | - | } | |
| 1210 | - | } | |
| 1211 | - | ||
| 1212 | - | type finalFlushClock struct{ timers chan *manualSnapshotTimer } | |
| 1213 | - | ||
| 1214 | - | func newFinalFlushClock() *finalFlushClock { | |
| 1215 | - | return &finalFlushClock{timers: make(chan *manualSnapshotTimer, 1)} | |
| 1216 | - | } | |
| 1217 | - | ||
| 1218 | - | func (c *finalFlushClock) Now() time.Time { return time.Unix(0, 0) } | |
| 1219 | - | func (c *finalFlushClock) NewTimer(time.Duration) ports.Timer { | |
| 1220 | - | t := &manualSnapshotTimer{ch: make(chan time.Time, 1)} | |
| 1221 | - | c.timers <- t | |
| 1222 | - | return t | |
| 1223 | - | } | |
| 1224 | - | ||
| 1225 | - | type manualSnapshotTimer struct{ ch chan time.Time } | |
| 1226 | - | ||
| 1227 | - | func (t *manualSnapshotTimer) C() <-chan time.Time { return t.ch } | |
| 1228 | - | func (t *manualSnapshotTimer) Reset(time.Duration) bool { return true } | |
| 1229 | - | func (t *manualSnapshotTimer) Stop() bool { return true } | |
| @@ -3,10 +3,6 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | - | "fmt" | |||
| 7 | - | "io" | |||
| 8 | - | "sync" | |||
| 9 | - | "sync/atomic" | |||
| 10 | "testing" | 6 | "testing" | ||
| 11 | "time" | 7 | "time" | ||
| 12 | 8 | ||||
| @@ -16,1012 +12,26 @@ import ( | |||||
| 16 | "github.com/bnema/vev/internal/domain" | 12 | "github.com/bnema/vev/internal/domain" | ||
| 17 | "github.com/bnema/vev/internal/ports" | 13 | "github.com/bnema/vev/internal/ports" | ||
| 18 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 14 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 19 | - | "github.com/bnema/vev/internal/usecase/layout" | |||
| 20 | - | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 21 | "github.com/bnema/vev/pkg/renderer" | 15 | "github.com/bnema/vev/pkg/renderer" | ||
| 22 | - | "github.com/bnema/vev/pkg/vt" | |||
| 23 | ) | 16 | ) | ||
| 24 | 17 | ||||
| 25 | - | func TestSnapshotEncodeWorkerDefersMarshalUntilAfterCapture(t *testing.T) { | 18 | + | // TestSnapshotWorkerPublishesContentAddressedCapture verifies that all new |
| 26 | - | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | 19 | + | // checkpoints use the repository publication contract. |
| 20 | + | func TestSnapshotWorkerPublishesContentAddressedCapture(t *testing.T) { | |||
| 27 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | 21 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | ||
| 28 | - | WithSnapshotStore(store)(d) | 22 | + | repository := portsmocks.NewMockSnapshotRepository(t) |
| 29 | - | t.Cleanup(d.stopSnapshotEncodeWorker) | 23 | + | WithSnapshotRepository(repository, nil)(d) |
| 30 | - | d.startSnapshotEncodeWorker() | |||
| 31 | - | ||||
| 32 | - | entered := make(chan struct{}) | |||
| 33 | - | release := make(chan struct{}) | |||
| 34 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |||
| 35 | - | close(entered) | |||
| 36 | - | <-release | |||
| 37 | - | return snapcodec.Marshal(s) | |||
| 38 | - | } | |||
| 39 | - | sess := newSnapshotTestSession(t, "work", false, "/work") | |||
| 40 | - | markSnapshotDirty(sess) | |||
| 41 | - | require.True(t, d.scheduleSnapshot(sess)) | |||
| 42 | - | <-entered | |||
| 43 | - | ||||
| 44 | - | paneLocked := make(chan struct{}) | |||
| 45 | - | releasePane := make(chan struct{}) | |||
| 46 | - | go func() { | |||
| 47 | - | p := sess.tabs[0].panes["pane-1"] | |||
| 48 | - | p.mu.Lock() | |||
| 49 | - | close(paneLocked) | |||
| 50 | - | <-releasePane | |||
| 51 | - | p.mu.Unlock() | |||
| 52 | - | }() | |||
| 53 | - | <-paneLocked | |||
| 54 | - | close(releasePane) | |||
| 55 | - | close(release) | |||
| 56 | - | <-store.writes | |||
| 57 | - | } | |||
| 58 | - | ||||
| 59 | - | func TestSnapshotEncodeWorkerDoesNotBlockPTYOrCoordinatorRender(t *testing.T) { | |||
| 60 | - | exerciseBlockedSnapshotWorker(t, true) | |||
| 61 | - | } | |||
| 62 | - | ||||
| 63 | - | func TestSnapshotStoreWorkerDoesNotBlockPTYOrCoordinatorRender(t *testing.T) { | |||
| 64 | - | exerciseBlockedSnapshotWorker(t, false) | |||
| 65 | - | } | |||
| 66 | - | ||||
| 67 | - | func exerciseBlockedSnapshotWorker(t *testing.T, blockEncode bool) { | |||
| 68 | - | t.Helper() | |||
| 69 | - | store := newGatedSnapshotStore() | |||
| 70 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 71 | - | WithSnapshotStore(store)(d) | |||
| 72 | - | d.startSnapshotEncodeWorker() | |||
| 73 | - | t.Cleanup(func() { | |||
| 74 | - | store.unblock() | |||
| 75 | - | d.stopSnapshotEncodeWorker() | |||
| 76 | - | }) | |||
| 77 | - | if blockEncode { | |||
| 78 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |||
| 79 | - | store.enter() | |||
| 80 | - | <-store.release | |||
| 81 | - | return snapcodec.Marshal(s) | |||
| 82 | - | } | |||
| 83 | - | } | |||
| 84 | - | ||||
| 85 | - | sess := newSnapshotTestSession(t, "live", false, "/live") | |||
| 86 | - | d.sessions[sess.id] = sess | |||
| 87 | - | sess.tabs[0].size = domain.Size{Cols: 80, Rows: 24} | |||
| 88 | - | p := sess.tabs[0].panes["pane-1"] | |||
| 89 | - | tr, sends, _ := newConn(t, ports.Frame{}) | |||
| 90 | - | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 8, Rows: 3}} | |||
| 91 | - | ac.setSession(sess) | |||
| 92 | - | sess.mu.Lock() | |||
| 93 | - | sess.client = ac | |||
| 94 | - | sess.mu.Unlock() | |||
| 95 | - | rendered := make(chan struct{}, 1) | |||
| 96 | - | rc := newRenderCoordinator(renderCoordinatorOptions{ | |||
| 97 | - | clock: stubClock{}, | |||
| 98 | - | onInvalidate: func(renderInvalidation) { | |||
| 99 | - | d.paint(sess, ac, true, nil) | |||
| 100 | - | rendered <- struct{}{} | |||
| 101 | - | }, | |||
| 102 | - | }) | |||
| 103 | - | sess.installRenderCoordinator(rc) | |||
| 104 | - | rc.attach(ac) | |||
| 105 | - | require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac))) | |||
| 106 | - | t.Cleanup(func() { rc.beginSessionTeardown().finish(); rc.waitForTimerWorkers() }) | |||
| 107 | - | ||||
| 108 | - | markSnapshotDirty(sess) | |||
| 109 | - | require.True(t, d.scheduleSnapshot(sess)) | |||
| 110 | - | <-store.entered | |||
| 111 | - | ||||
| 112 | - | paneLocked := make(chan struct{}) | |||
| 113 | - | releasePane := make(chan struct{}) | |||
| 114 | - | go func() { | |||
| 115 | - | p.mu.Lock() | |||
| 116 | - | close(paneLocked) | |||
| 117 | - | <-releasePane | |||
| 118 | - | p.mu.Unlock() | |||
| 119 | - | }() | |||
| 120 | - | <-paneLocked | |||
| 121 | - | close(releasePane) | |||
| 122 | - | ||||
| 123 | - | reader := portsmocks.NewMockPTY(t) | |||
| 124 | - | var reads atomic.Int32 | |||
| 125 | - | reader.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) { | |||
| 126 | - | if reads.Add(1) == 1 { | |||
| 127 | - | return copy(buf, []byte("ok")), nil | |||
| 128 | - | } | |||
| 129 | - | return 0, io.EOF | |||
| 130 | - | }).Twice() | |||
| 131 | - | reader.EXPECT().Write(mock.Anything).Return(0, nil).Maybe() | |||
| 132 | - | p.mu.Lock() | |||
| 133 | - | p.pty = reader | |||
| 134 | - | p.onExit = func() {} | |||
| 135 | - | p.mu.Unlock() | |||
| 136 | - | d.sessWg.Add(1) | |||
| 137 | - | go d.ptyReader(sess, sess.tabs[0], p) | |||
| 138 | - | d.sessWg.Wait() | |||
| 139 | - | <-rendered | |||
| 140 | - | _ = awaitFrame(t, sends, ports.MsgOutput) | |||
| 141 | - | p.mu.Lock() | |||
| 142 | - | require.Contains(t, screenLineText(p.screen, 0), "ok") | |||
| 143 | - | p.mu.Unlock() | |||
| 144 | - | ||||
| 145 | - | store.unblock() | |||
| 146 | - | awaitSnapshotIdle(t, sess) | |||
| 147 | - | require.True(t, sess.snapDirty.Load(), "PTY output published while blocked must remain retryable") | |||
| 148 | - | } | |||
| 149 | - | ||||
| 150 | - | func TestFinalSnapshotFlushPersistsNewestNamedSessionBeforeWorkerTeardown(t *testing.T) { | |||
| 151 | - | clock := newFinalFlushClock() | |||
| 152 | - | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | |||
| 153 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | |||
| 154 | - | WithSnapshotStore(store)(d) | |||
| 155 | - | d.startSnapshotEncodeWorker() | |||
| 156 | - | ||||
| 157 | - | entered := make(chan struct{}) | |||
| 158 | - | release := make(chan struct{}) | |||
| 159 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |||
| 160 | - | close(entered) | |||
| 161 | - | <-release | |||
| 162 | - | return snapcodec.Marshal(s) | |||
| 163 | - | } | |||
| 164 | - | sess := newSnapshotTestSession(t, "final", false, "/work") | |||
| 165 | - | pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |||
| 166 | - | require.True(t, ok) | |||
| 167 | - | pty.EXPECT().Close().Return(nil).Once() | |||
| 168 | - | sess.tabs[0].panes["pane-1"].screen.Write([]byte("\rnewest")) | |||
| 169 | - | d.sessions[sess.id] = sess | |||
| 170 | - | ||||
| 171 | - | require.NoError(t, d.killSession(sess, 0, false)) | |||
| 172 | - | <-entered | |||
| 173 | - | stopped := make(chan struct{}) | |||
| 174 | - | go func() { | |||
| 175 | - | d.stopSnapshotEncodeWorker() | |||
| 176 | - | close(stopped) | |||
| 177 | - | }() | |||
| 178 | - | close(release) | |||
| 179 | - | <-stopped | |||
| 180 | - | awaitSnapshotIdle(t, sess) | |||
| 181 | - | ||||
| 182 | - | select { | |||
| 183 | - | case data := <-store.writes: | |||
| 184 | - | snap, err := snapcodec.Unmarshal(data) | |||
| 185 | - | require.NoError(t, err) | |||
| 186 | - | frame, err := vt.UnmarshalVisible(snap.Tabs[0].Panes[0].Visible) | |||
| 187 | - | require.NoError(t, err) | |||
| 188 | - | require.Contains(t, rowText(frame.Row(0)), "newest") | |||
| 189 | - | default: | |||
| 190 | - | t.Fatal("final snapshot was dropped during worker teardown") | |||
| 191 | - | } | |||
| 192 | - | } | |||
| 193 | - | ||||
| 194 | - | func TestFinalSnapshotSupersedesPendingCaptureBeforeWorkerTeardown(t *testing.T) { | |||
| 195 | - | clock := newFinalFlushClock() | |||
| 196 | - | store := &channelSnapshotStore{writes: make(chan []byte, 2)} | |||
| 197 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | |||
| 198 | - | WithSnapshotStore(store)(d) | |||
| 199 | - | d.startSnapshotEncodeWorker() | |||
| 200 | - | ||||
| 201 | - | entered := make(chan struct{}) | |||
| 202 | - | release := make(chan struct{}) | |||
| 203 | - | var calls atomic.Int32 | |||
| 204 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |||
| 205 | - | if calls.Add(1) == 1 { | |||
| 206 | - | close(entered) | |||
| 207 | - | <-release | |||
| 208 | - | } | |||
| 209 | - | return snapcodec.Marshal(s) | |||
| 210 | - | } | |||
| 211 | - | sess := newSnapshotTestSession(t, "pending-final", false, "/work") | |||
| 212 | - | markSnapshotDirty(sess) | |||
| 213 | - | require.True(t, d.scheduleSnapshot(sess)) | |||
| 214 | - | <-entered | |||
| 215 | - | ||||
| 216 | - | pane := sess.tabs[0].panes["pane-1"] | |||
| 217 | - | pane.mu.Lock() | |||
| 218 | - | pane.screen.Write([]byte("\rterminal")) | |||
| 219 | - | pane.mu.Unlock() | |||
| 220 | - | markSnapshotDirty(sess) | |||
| 221 | - | require.True(t, d.scheduleFinalSnapshot(sess)) | |||
| 222 | - | ||||
| 223 | - | stopped := make(chan struct{}) | |||
| 224 | - | go func() { | |||
| 225 | - | d.stopSnapshotEncodeWorker() | |||
| 226 | - | close(stopped) | |||
| 227 | - | }() | |||
| 228 | - | close(release) | |||
| 229 | - | <-stopped | |||
| 230 | - | ||||
| 231 | - | require.Len(t, store.writes, 2, "teardown must flush the newer terminal capture") | |||
| 232 | - | var latest snapcodec.Session | |||
| 233 | - | for range 2 { | |||
| 234 | - | snap, err := snapcodec.Unmarshal(<-store.writes) | |||
| 235 | - | require.NoError(t, err) | |||
| 236 | - | latest = snap | |||
| 237 | - | } | |||
| 238 | - | frame, err := vt.UnmarshalVisible(latest.Tabs[0].Panes[0].Visible) | |||
| 239 | - | require.NoError(t, err) | |||
| 240 | - | require.Contains(t, rowText(frame.Row(0)), "terminal") | |||
| 241 | - | } | |||
| 242 | - | ||||
| 243 | - | func TestFinalSnapshotFallbackCoalescesRepeatedTerminalCaptures(t *testing.T) { | |||
| 244 | - | store := discardSnapshotStore{} | |||
| 245 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 246 | - | WithSnapshotStore(store)(d) | |||
| 247 | - | d.startSnapshotEncodeWorker() | |||
| 248 | - | ||||
| 249 | - | entered := make(chan struct{}) | |||
| 250 | - | release := make(chan struct{}) | |||
| 251 | - | var releaseOnce sync.Once | |||
| 252 | - | releaseWorker := func() { releaseOnce.Do(func() { close(release) }) } | |||
| 253 | - | defer func() { | |||
| 254 | - | releaseWorker() | |||
| 255 | - | d.stopSnapshotEncodeWorker() | |||
| 256 | - | }() | |||
| 257 | - | var calls atomic.Int32 | |||
| 258 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |||
| 259 | - | if calls.Add(1) == 1 { | |||
| 260 | - | close(entered) | |||
| 261 | - | <-release | |||
| 262 | - | } | |||
| 263 | - | return snapcodec.Marshal(s) | |||
| 264 | - | } | |||
| 265 | - | sess := newSnapshotTestSession(t, "coalesced-final", false, "/work") | |||
| 266 | - | markSnapshotDirty(sess) | |||
| 267 | - | require.True(t, d.scheduleSnapshot(sess)) | |||
| 268 | - | <-entered | |||
| 269 | - | ||||
| 270 | - | for generation := uint64(1); generation <= 32; generation++ { | |||
| 271 | - | capture, ok := d.captureSnapshotState(sess, generation) | |||
| 272 | - | require.True(t, ok) | |||
| 273 | - | require.True(t, d.enqueueFinalSnapshotCapture(capture)) | |||
| 274 | - | } | |||
| 275 | - | ||||
| 276 | - | d.snapshotWorkerMu.Lock() | |||
| 277 | - | captures := make([]*snapshotCapture, 0, len(d.snapshotFinalJobs)) | |||
| 278 | - | for _, capture := range d.snapshotFinalJobs { | |||
| 279 | - | captures = append(captures, capture) | |||
| 280 | - | } | |||
| 281 | - | d.snapshotWorkerMu.Unlock() | |||
| 282 | - | require.Len(t, captures, 1, "blocked workers retain only the latest terminal capture per session") | |||
| 283 | - | require.Equal(t, uint64(32), captures[0].generation) | |||
| 284 | - | releaseWorker() | |||
| 285 | - | } | |||
| 286 | - | ||||
| 287 | - | func TestFinalSnapshotFallbackBoundsDistinctTerminalSessions(t *testing.T) { | |||
| 288 | - | store := discardSnapshotStore{} | |||
| 289 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 290 | - | WithSnapshotStore(store)(d) | |||
| 291 | - | d.startSnapshotEncodeWorker() | |||
| 292 | - | ||||
| 293 | - | entered := make(chan struct{}) | |||
| 294 | - | release := make(chan struct{}) | |||
| 295 | - | var calls atomic.Int32 | |||
| 296 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |||
| 297 | - | if calls.Add(1) == 1 { | |||
| 298 | - | close(entered) | |||
| 299 | - | <-release | |||
| 300 | - | } | |||
| 301 | - | return snapcodec.Marshal(s) | |||
| 302 | - | } | |||
| 303 | - | defer func() { | |||
| 304 | - | close(release) | |||
| 305 | - | d.stopSnapshotEncodeWorker() | |||
| 306 | - | }() | |||
| 307 | - | ||||
| 308 | - | active := newSnapshotTestSession(t, "active", false, "/work") | |||
| 309 | - | queued := newSnapshotTestSession(t, "queued", false, "/work") | |||
| 310 | - | markSnapshotDirty(active) | |||
| 311 | - | markSnapshotDirty(queued) | |||
| 312 | - | require.True(t, d.scheduleSnapshot(active)) | |||
| 313 | - | <-entered | |||
| 314 | - | require.True(t, d.scheduleSnapshot(queued), "fill the regular bounded queue before terminal retirement") | |||
| 315 | - | ||||
| 316 | - | var superseded *session | |||
| 317 | - | for i := range snapshotFinalQueueCapacity { | |||
| 318 | - | sess := newSnapshotTestSession(t, fmt.Sprintf("terminal-%d", i), false, "/work") | |||
| 319 | - | markSnapshotDirty(sess) | |||
| 320 | - | require.True(t, d.scheduleFinalSnapshot(sess)) | |||
| 321 | - | if i == 0 { | |||
| 322 | - | superseded = sess | |||
| 323 | - | } | |||
| 324 | - | } | |||
| 325 | - | markSnapshotDirty(superseded) | |||
| 326 | - | require.True(t, d.scheduleFinalSnapshot(superseded), "a newer generation for a retained session must supersede at capacity") | |||
| 327 | - | ||||
| 328 | - | rejected := newSnapshotTestSession(t, "terminal-rejected", false, "/work") | |||
| 329 | - | d.mu.Lock() | |||
| 330 | - | d.sessions[rejected.id] = rejected | |||
| 331 | - | d.mu.Unlock() | |||
| 332 | - | markSnapshotDirty(rejected) | |||
| 333 | - | err := d.killSession(rejected, ports.ReasonSessionKilled, false) | |||
| 334 | - | require.ErrorContains(t, err, "snapshot worker unavailable or saturated") | |||
| 335 | - | d.mu.Lock() | |||
| 336 | - | require.Same(t, rejected, d.sessions[rejected.id], "rejected terminal state must remain registered for retry") | |||
| 337 | - | d.mu.Unlock() | |||
| 338 | - | ||||
| 339 | - | d.snapshotWorkerMu.Lock() | |||
| 340 | - | retained := len(d.snapshotFinalJobs) | |||
| 341 | - | owners := len(d.snapshotFinalOrder) | |||
| 342 | - | supersedingCapture := d.snapshotFinalJobs[superseded] | |||
| 343 | - | d.snapshotWorkerMu.Unlock() | |||
| 344 | - | require.Equal(t, snapshotFinalQueueCapacity, retained, "distinct terminal captures must have a global bound") | |||
| 345 | - | require.Equal(t, snapshotFinalQueueCapacity, owners, "retention order must not retain extra session owners") | |||
| 346 | - | require.NotNil(t, supersedingCapture) | |||
| 347 | - | require.Equal(t, uint64(2), supersedingCapture.generation, "retained session must keep its newest terminal generation") | |||
| 348 | - | require.NotNil(t, rejected) | |||
| 349 | - | require.True(t, rejected.snapEligible.Load()) | |||
| 350 | - | require.True(t, rejected.snapDirty.Load(), "a rejected terminal capture must remain retryable") | |||
| 351 | - | awaitSnapshotIdle(t, rejected) | |||
| 352 | - | } | |||
| 353 | - | ||||
| 354 | - | func TestStopSnapshotEncodeWorkerDoesNotWaitForUncancellableWrite(t *testing.T) { | |||
| 355 | - | store := newNeverReturningSnapshotStore() | |||
| 356 | - | clock := newFinalFlushClock() | |||
| 357 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | |||
| 358 | - | WithSnapshotStore(store)(d) | |||
| 359 | - | d.startSnapshotEncodeWorker() | |||
| 360 | - | ||||
| 361 | - | sess := newSnapshotTestSession(t, "stuck-write", false, "/work") | |||
| 362 | - | require.True(t, d.captureSession(sess)) | |||
| 363 | - | <-store.entered | |||
| 364 | - | ||||
| 365 | - | stopped := make(chan struct{}) | |||
| 366 | - | go func() { | |||
| 367 | - | d.stopSnapshotEncodeWorker() | |||
| 368 | - | close(stopped) | |||
| 369 | - | }() | |||
| 370 | - | timer := <-clock.timers | |||
| 371 | - | timer.ch <- time.Time{} | |||
| 372 | - | <-stopped | |||
| 373 | - | awaitSnapshotIdle(t, sess) | |||
| 374 | - | require.True(t, sess.snapDirty.Load(), "an abandoned write must remain retryable") | |||
| 375 | - | require.NotPanics(t, func() { require.False(t, d.scheduleSnapshot(sess)) }) | |||
| 376 | - | } | |||
| 377 | - | ||||
| 378 | - | func TestRejectedSnapshotCaptureLeavesSessionRetryable(t *testing.T) { | |||
| 379 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 380 | - | WithSnapshotStore(&channelSnapshotStore{writes: make(chan []byte, 1)})(d) | |||
| 381 | - | sess := newSnapshotTestSession(t, "rejected", false, "/work") | |||
| 382 | - | sess.name = "" | |||
| 383 | - | sess.snapEligible.Store(true) | |||
| 384 | - | ||||
| 385 | - | markSnapshotDirty(sess) | |||
| 386 | - | require.False(t, d.scheduleSnapshot(sess)) | |||
| 387 | - | awaitSnapshotIdle(t, sess) | |||
| 388 | - | require.True(t, sess.snapDirty.Load()) | |||
| 389 | - | } | |||
| 390 | - | ||||
| 391 | - | func TestStoppedSnapshotWorkerLeavesCaptureRetryable(t *testing.T) { | |||
| 392 | - | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | |||
| 393 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 394 | - | WithSnapshotStore(store)(d) | |||
| 395 | - | d.startSnapshotEncodeWorker() | |||
| 396 | - | d.stopSnapshotEncodeWorker() | |||
| 397 | - | ||||
| 398 | - | sess := newSnapshotTestSession(t, "stopped", false, "/work") | |||
| 399 | - | markSnapshotDirty(sess) | |||
| 400 | - | require.False(t, d.scheduleSnapshot(sess), "a stopped worker must not accept a capture") | |||
| 401 | - | awaitSnapshotIdle(t, sess) | |||
| 402 | - | require.True(t, sess.snapDirty.Load(), "an unsubmitted capture must remain retryable") | |||
| 403 | - | } | |||
| 404 | - | ||||
| 405 | - | func TestSnapshotQueueSaturationLeavesCaptureRetryable(t *testing.T) { | |||
| 406 | - | store := &channelSnapshotStore{writes: make(chan []byte, 3)} | |||
| 407 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 408 | - | WithSnapshotStore(store)(d) | |||
| 409 | - | startSnapshotEncodeWorker(t, d) | |||
| 410 | - | entered := make(chan struct{}) | |||
| 411 | - | release := make(chan struct{}) | |||
| 412 | - | var calls atomic.Int32 | |||
| 413 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |||
| 414 | - | if calls.Add(1) == 1 { | |||
| 415 | - | close(entered) | |||
| 416 | - | <-release | |||
| 417 | - | } | |||
| 418 | - | return snapcodec.Marshal(s) | |||
| 419 | - | } | |||
| 420 | - | first := newSnapshotTestSession(t, "first", false, "/work") | |||
| 421 | - | second := newSnapshotTestSession(t, "second", false, "/work") | |||
| 422 | - | third := newSnapshotTestSession(t, "third", false, "/work") | |||
| 423 | - | markSnapshotDirty(first) | |||
| 424 | - | markSnapshotDirty(second) | |||
| 425 | - | markSnapshotDirty(third) | |||
| 426 | - | require.True(t, d.scheduleSnapshot(first)) | |||
| 427 | - | <-entered | |||
| 428 | - | require.True(t, d.scheduleSnapshot(second), "one capture may wait in the bounded queue") | |||
| 429 | - | require.False(t, d.scheduleSnapshot(third), "full queue must not block the producer") | |||
| 430 | - | require.True(t, third.snapDirty.Load()) | |||
| 431 | - | ||||
| 432 | - | close(release) | |||
| 433 | - | awaitSnapshotClean(t, first) | |||
| 434 | - | awaitSnapshotClean(t, second) | |||
| 435 | - | require.True(t, d.scheduleSnapshot(third)) | |||
| 436 | - | <-store.writes | |||
| 437 | - | awaitSnapshotClean(t, third) | |||
| 438 | - | } | |||
| 439 | - | ||||
| 440 | - | func TestNamedFinalSnapshotSurvivesSaturatedQueue(t *testing.T) { | |||
| 441 | - | for _, tc := range []struct { | |||
| 442 | - | name string | |||
| 443 | - | stop func(t *testing.T, d *Daemon, sess *session) | |||
| 444 | - | }{ | |||
| 445 | - | { | |||
| 446 | - | name: "kill session", | |||
| 447 | - | stop: func(t *testing.T, d *Daemon, sess *session) { | |||
| 448 | - | t.Helper() | |||
| 449 | - | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |||
| 450 | - | }, | |||
| 451 | - | }, | |||
| 452 | - | { | |||
| 453 | - | name: "shutdown all", | |||
| 454 | - | stop: func(t *testing.T, d *Daemon, _ *session) { | |||
| 455 | - | t.Helper() | |||
| 456 | - | d.shutdownAll(ports.ReasonServerShutdown) | |||
| 457 | - | }, | |||
| 458 | - | }, | |||
| 459 | - | } { | |||
| 460 | - | t.Run(tc.name, func(t *testing.T) { | |||
| 461 | - | store := &channelSnapshotStore{writes: make(chan []byte, 3)} | |||
| 462 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 463 | - | WithSnapshotStore(store)(d) | |||
| 464 | - | startSnapshotEncodeWorker(t, d) | |||
| 465 | - | ||||
| 466 | - | entered := make(chan struct{}) | |||
| 467 | - | release := make(chan struct{}) | |||
| 468 | - | var calls atomic.Int32 | |||
| 469 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |||
| 470 | - | if calls.Add(1) == 1 { | |||
| 471 | - | close(entered) | |||
| 472 | - | <-release | |||
| 473 | - | } | |||
| 474 | - | return snapcodec.Marshal(s) | |||
| 475 | - | } | |||
| 476 | - | first := newSnapshotTestSession(t, "first", false, "/work") | |||
| 477 | - | second := newSnapshotTestSession(t, "second", false, "/work") | |||
| 478 | - | final := newSnapshotTestSession(t, "final", false, "/work") | |||
| 479 | - | finalPTY, ok := final.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |||
| 480 | - | require.True(t, ok) | |||
| 481 | - | finalPTY.EXPECT().Close().Return(nil).Once() | |||
| 482 | - | markSnapshotDirty(first) | |||
| 483 | - | markSnapshotDirty(second) | |||
| 484 | - | require.True(t, d.scheduleSnapshot(first)) | |||
| 485 | - | <-entered | |||
| 486 | - | require.True(t, d.scheduleSnapshot(second)) | |||
| 487 | - | d.sessions[final.id] = final | |||
| 488 | - | ||||
| 489 | - | tc.stop(t, d, final) | |||
| 490 | - | require.NotContains(t, d.sessions, final.id, "final capture must survive session removal") | |||
| 491 | - | close(release) | |||
| 492 | - | ||||
| 493 | - | written := make(map[string]bool, 3) | |||
| 494 | - | deadline := time.NewTimer(time.Second) | |||
| 495 | - | defer deadline.Stop() | |||
| 496 | - | for len(written) < 3 { | |||
| 497 | - | select { | |||
| 498 | - | case data := <-store.writes: | |||
| 499 | - | snap, err := snapcodec.Unmarshal(data) | |||
| 500 | - | require.NoError(t, err) | |||
| 501 | - | written[snap.Name] = true | |||
| 502 | - | case <-deadline.C: | |||
| 503 | - | t.Fatalf("final snapshot was dropped: wrote %v", written) | |||
| 504 | - | } | |||
| 505 | - | } | |||
| 506 | - | require.True(t, written[final.name]) | |||
| 507 | - | }) | |||
| 508 | - | } | |||
| 509 | - | } | |||
| 510 | - | ||||
| 511 | - | func TestSnapshotCompletionSignalsWaiters(t *testing.T) { | |||
| 512 | - | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | |||
| 513 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 514 | - | WithSnapshotStore(store)(d) | |||
| 515 | - | startSnapshotEncodeWorker(t, d) | |||
| 516 | - | ||||
| 517 | - | entered := make(chan struct{}) | |||
| 518 | - | release := make(chan struct{}) | |||
| 519 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |||
| 520 | - | close(entered) | |||
| 521 | - | <-release | |||
| 522 | - | return snapcodec.Marshal(s) | |||
| 523 | - | } | |||
| 524 | - | sess := newSnapshotTestSession(t, "completion", false, "/work") | |||
| 525 | - | require.True(t, d.captureSession(sess)) | |||
| 526 | - | <-entered | |||
| 527 | - | ||||
| 528 | - | sess.snapshotMu.Lock() | |||
| 529 | - | changed := sess.snapshotChangeLocked() | |||
| 530 | - | sess.snapshotMu.Unlock() | |||
| 531 | - | close(release) | |||
| 532 | - | <-changed | |||
| 533 | - | awaitSnapshotClean(t, sess) | |||
| 534 | - | } | |||
| 535 | - | ||||
| 536 | - | func TestSnapshotSuccessDoesNotClearNewerGeneration(t *testing.T) { | |||
| 537 | - | store := &channelSnapshotStore{writes: make(chan []byte, 2)} | |||
| 538 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 539 | - | WithSnapshotStore(store)(d) | |||
| 540 | startSnapshotEncodeWorker(t, d) | 24 | startSnapshotEncodeWorker(t, d) | ||
| 541 | - | entered := make(chan struct{}) | |||
| 542 | - | release := make(chan struct{}) | |||
| 543 | - | var calls atomic.Int32 | |||
| 544 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |||
| 545 | - | if calls.Add(1) == 1 { | |||
| 546 | - | close(entered) | |||
| 547 | - | <-release | |||
| 548 | - | } | |||
| 549 | - | return snapcodec.Marshal(s) | |||
| 550 | - | } | |||
| 551 | - | sess := newSnapshotTestSession(t, "generation", false, "/work") | |||
| 552 | 25 | ||||
| 553 | - | markSnapshotDirty(sess) | 26 | + | repository.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(p ports.SnapshotPublication) bool { |
| 554 | - | require.True(t, d.scheduleSnapshot(sess)) | 27 | + | return p.Name == "work" && p.Generation == 1 && len(p.Manifest) > 0 && len(p.Objects) > 0 |
| 555 | - | <-entered | 28 | + | })).Return(nil).Once() |
| 556 | - | markSnapshotDirty(sess) | |||
| 557 | - | close(release) | |||
| 558 | - | <-store.writes | |||
| 559 | - | awaitSnapshotIdle(t, sess) | |||
| 560 | - | require.True(t, sess.snapDirty.Load(), "a stale successful capture must not clear newer state") | |||
| 561 | - | ||||
| 562 | - | require.True(t, d.scheduleSnapshot(sess)) | |||
| 563 | - | <-store.writes | |||
| 564 | - | awaitSnapshotClean(t, sess) | |||
| 565 | - | } | |||
| 566 | - | ||||
| 567 | - | func TestSnapshotEncodeFailureLeavesSessionDirtyAndRetries(t *testing.T) { | |||
| 568 | - | store := &channelSnapshotStore{writes: make(chan []byte, 1)} | |||
| 569 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 570 | - | WithSnapshotStore(store)(d) | |||
| 571 | - | startSnapshotEncodeWorker(t, d) | |||
| 572 | - | sess := newSnapshotTestSession(t, "retry-encode", false, "/work") | |||
| 573 | - | failed := make(chan struct{}, 1) | |||
| 574 | - | var calls atomic.Int32 | |||
| 575 | - | d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) { | |||
| 576 | - | if calls.Add(1) == 1 { | |||
| 577 | - | failed <- struct{}{} | |||
| 578 | - | return nil, errors.New("encode failed") | |||
| 579 | - | } | |||
| 580 | - | return snapcodec.Marshal(s) | |||
| 581 | - | } | |||
| 582 | - | ||||
| 583 | - | require.True(t, d.captureSession(sess)) | |||
| 584 | - | <-failed | |||
| 585 | - | awaitSnapshotIdle(t, sess) | |||
| 586 | - | require.True(t, sess.snapDirty.Load()) | |||
| 587 | - | require.True(t, d.scheduleSnapshot(sess)) | |||
| 588 | - | <-store.writes | |||
| 589 | - | awaitSnapshotClean(t, sess) | |||
| 590 | - | } | |||
| 591 | - | ||||
| 592 | - | func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) { | |||
| 593 | - | clock := newManualSnapshotClock() | |||
| 594 | - | store := portsmocks.NewMockSnapshotStore(t) | |||
| 595 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | |||
| 596 | - | WithSnapshotStore(store)(d) | |||
| 597 | - | d.procCwd = func(int) (string, error) { return "/pane", nil } | |||
| 598 | - | startSnapshotEncodeWorker(t, d) | |||
| 599 | - | ||||
| 600 | - | dirty := newSnapshotTestSession(t, "dirty", false, "/fallback") | |||
| 601 | - | clean := newSnapshotTestSession(t, "clean", false, "/fallback") | |||
| 602 | - | ephemeral := newSnapshotTestSession(t, "0", true, "/fallback") | |||
| 603 | - | dirty.snapDirty.Store(true) | |||
| 604 | - | ephemeral.snapDirty.Store(true) | |||
| 605 | - | d.sessions[dirty.id] = dirty | |||
| 606 | - | d.sessions[clean.id] = clean | |||
| 607 | - | d.sessions[ephemeral.id] = ephemeral | |||
| 608 | - | ||||
| 609 | - | wrote := make(chan struct{}, 1) | |||
| 610 | - | store.EXPECT().Write("dirty", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |||
| 611 | - | snap, err := snapcodec.Unmarshal(data) | |||
| 612 | - | require.NoError(t, err) | |||
| 613 | - | require.Equal(t, "dirty", snap.Name) | |||
| 614 | - | wrote <- struct{}{} | |||
| 615 | - | return nil | |||
| 616 | - | }).Once() | |||
| 617 | - | ||||
| 618 | - | ctx, cancel := context.WithCancel(t.Context()) | |||
| 619 | - | defer cancel() | |||
| 620 | - | go d.snapshotSaver(ctx) | |||
| 621 | - | clock.fireNext(t) | |||
| 622 | - | select { | |||
| 623 | - | case <-wrote: | |||
| 624 | - | case <-time.After(time.Second): | |||
| 625 | - | t.Fatal("timed out waiting for dirty snapshot write") | |||
| 626 | - | } | |||
| 627 | - | awaitSnapshotClean(t, dirty) | |||
| 628 | - | ||||
| 629 | - | clock.fireNext(t) | |||
| 630 | - | select { | |||
| 631 | - | case <-wrote: | |||
| 632 | - | t.Fatal("clean second interval wrote unexpectedly") | |||
| 633 | - | case <-time.After(25 * time.Millisecond): | |||
| 634 | - | } | |||
| 635 | - | } | |||
| 636 | - | ||||
| 637 | - | func TestSnapshotCaptureExcludesFloatingRuntime(t *testing.T) { | |||
| 638 | - | store := portsmocks.NewMockSnapshotStore(t) | |||
| 639 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 640 | - | WithSnapshotStore(store)(d) | |||
| 641 | - | startSnapshotEncodeWorker(t, d) | |||
| 642 | - | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |||
| 643 | - | tb := sess.tabs[0] | |||
| 644 | - | floatingPTY, _ := newBlockingPTY(t) | |||
| 645 | - | floating := newPane(layout.PaneID("floating"), floatingPTY, domain.Size{Cols: 20, Rows: 8}) | |||
| 646 | - | tb.mu.Lock() | |||
| 647 | - | generation := tb.beginFloatingWarmLocked(false) | |||
| 648 | - | require.True(t, tb.installFloatingLocked(floating, generation)) | |||
| 649 | - | tb.mu.Unlock() | |||
| 650 | - | ||||
| 651 | - | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |||
| 652 | - | snap, err := snapcodec.Unmarshal(data) | |||
| 653 | - | require.NoError(t, err) | |||
| 654 | - | require.Len(t, snap.Tabs, 1) | |||
| 655 | - | require.Len(t, snap.Tabs[0].Panes, 1, "floating runtime must not be persisted") | |||
| 656 | - | require.Equal(t, layout.PaneID("pane-1"), snap.Tabs[0].Panes[0].ID) | |||
| 657 | - | return nil | |||
| 658 | - | }).Once() | |||
| 659 | - | require.True(t, d.captureSession(sess)) | |||
| 660 | - | awaitSnapshotClean(t, sess) | |||
| 661 | - | } | |||
| 662 | - | ||||
| 663 | - | func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) { | |||
| 664 | - | store := portsmocks.NewMockSnapshotStore(t) | |||
| 665 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 666 | - | WithSnapshotStore(store)(d) | |||
| 667 | - | startSnapshotEncodeWorker(t, d) | |||
| 668 | - | d.procGroupArgv = func(pgid int, shellPid int) ([]string, error) { | |||
| 669 | - | require.Equal(t, 4321, pgid) | |||
| 670 | - | require.Equal(t, 1234, shellPid) | |||
| 671 | - | return []string{"claude", "--session-id", "agent-123"}, nil | |||
| 672 | - | } | |||
| 673 | - | ||||
| 674 | - | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |||
| 675 | - | pty := snapshotProcessPTY(t, 4321) | |||
| 676 | - | sess.tabs[0].panes["pane-1"].pty = pty | |||
| 677 | - | ||||
| 678 | - | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |||
| 679 | - | snap, err := snapcodec.Unmarshal(data) | |||
| 680 | - | require.NoError(t, err) | |||
| 681 | - | proc := snap.Tabs[0].Panes[0].Process | |||
| 682 | - | require.NotNil(t, proc) | |||
| 683 | - | require.Equal(t, []string{"claude", "--session-id", "agent-123"}, proc.Argv) | |||
| 684 | - | require.Equal(t, processStrategyClaude, proc.Strategy) | |||
| 685 | - | require.Equal(t, "agent-123", proc.Opts.AgentSessionID) | |||
| 686 | - | return nil | |||
| 687 | - | }).Once() | |||
| 688 | - | ||||
| 689 | - | require.True(t, d.captureSession(sess)) | |||
| 690 | - | awaitSnapshotClean(t, sess) | |||
| 691 | - | } | |||
| 692 | - | ||||
| 693 | - | func TestSnapshotCaptureProcessFailureDoesNotBlockSnapshot(t *testing.T) { | |||
| 694 | - | store := portsmocks.NewMockSnapshotStore(t) | |||
| 695 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 696 | - | WithSnapshotStore(store)(d) | |||
| 697 | - | startSnapshotEncodeWorker(t, d) | |||
| 698 | - | d.procGroupArgv = func(int, int) ([]string, error) { return nil, errors.New("inspect failed") } | |||
| 699 | - | ||||
| 700 | - | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |||
| 701 | - | pty := snapshotProcessPTY(t, 4321) | |||
| 702 | - | sess.tabs[0].panes["pane-1"].pty = pty | |||
| 703 | - | ||||
| 704 | - | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |||
| 705 | - | snap, err := snapcodec.Unmarshal(data) | |||
| 706 | - | require.NoError(t, err) | |||
| 707 | - | require.Nil(t, snap.Tabs[0].Panes[0].Process) | |||
| 708 | - | return nil | |||
| 709 | - | }).Once() | |||
| 710 | - | ||||
| 711 | - | require.True(t, d.captureSession(sess)) | |||
| 712 | - | awaitSnapshotClean(t, sess) | |||
| 713 | - | } | |||
| 714 | - | ||||
| 715 | - | func TestSnapshotCaptureSkipsBareShellProcess(t *testing.T) { | |||
| 716 | - | store := portsmocks.NewMockSnapshotStore(t) | |||
| 717 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 718 | - | WithSnapshotStore(store)(d) | |||
| 719 | - | startSnapshotEncodeWorker(t, d) | |||
| 720 | - | d.procGroupArgv = func(int, int) ([]string, error) { | |||
| 721 | - | t.Fatal("bare shell should not inspect argv") | |||
| 722 | - | return nil, nil | |||
| 723 | - | } | |||
| 724 | - | ||||
| 725 | - | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |||
| 726 | - | pty := snapshotProcessPTY(t, 1234) | |||
| 727 | - | sess.tabs[0].panes["pane-1"].pty = pty | |||
| 728 | - | ||||
| 729 | - | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |||
| 730 | - | snap, err := snapcodec.Unmarshal(data) | |||
| 731 | - | require.NoError(t, err) | |||
| 732 | - | require.Nil(t, snap.Tabs[0].Panes[0].Process) | |||
| 733 | - | return nil | |||
| 734 | - | }).Once() | |||
| 735 | 29 | ||||
| 30 | + | sess := newSnapshotTestSession(t, "work", false, "/work") | |||
| 736 | require.True(t, d.captureSession(sess)) | 31 | require.True(t, d.captureSession(sess)) | ||
| 737 | awaitSnapshotClean(t, sess) | 32 | awaitSnapshotClean(t, sess) | ||
| 738 | } | 33 | } | ||
| 739 | 34 | ||||
| 740 | - | func TestKillSessionSnapshotsNamedSessionBeforeClosingPanes(t *testing.T) { | |||
| 741 | - | store := newGatedSnapshotStore() | |||
| 742 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 743 | - | WithSnapshotStore(store)(d) | |||
| 744 | - | d.startSnapshotEncodeWorker() | |||
| 745 | - | t.Cleanup(func() { | |||
| 746 | - | store.unblock() | |||
| 747 | - | d.stopSnapshotEncodeWorker() | |||
| 748 | - | }) | |||
| 749 | - | d.procCwd = func(int) (string, error) { return "/live", nil } | |||
| 750 | - | ||||
| 751 | - | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |||
| 752 | - | d.sessions[sess.id] = sess | |||
| 753 | - | ||||
| 754 | - | var closed atomic.Bool | |||
| 755 | - | mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |||
| 756 | - | require.True(t, ok) | |||
| 757 | - | mockPTY.EXPECT().Close().RunAndReturn(func() error { | |||
| 758 | - | closed.Store(true) | |||
| 759 | - | return nil | |||
| 760 | - | }).Once() | |||
| 761 | - | ||||
| 762 | - | require.NoError(t, d.killSession(sess, 0, false)) | |||
| 763 | - | // closeAllPanes runs synchronously inside killSession, which has already | |||
| 764 | - | // returned here. The store's write gate is still held below, so | |||
| 765 | - | // persistence cannot have completed yet: this proves pane close did not | |||
| 766 | - | // wait on it, with zero timing dependence on the encode worker. | |||
| 767 | - | require.True(t, closed.Load(), "snapshot persistence must not delay pane close") | |||
| 768 | - | ||||
| 769 | - | store.unblock() | |||
| 770 | - | awaitSnapshotClean(t, sess) | |||
| 771 | - | ||||
| 772 | - | writes, name, data := store.recorded() | |||
| 773 | - | require.Equal(t, 1, writes) | |||
| 774 | - | require.Equal(t, "work", name) | |||
| 775 | - | snap, err := snapcodec.Unmarshal(data) | |||
| 776 | - | require.NoError(t, err) | |||
| 777 | - | require.Equal(t, "work", snap.Name) | |||
| 778 | - | require.Equal(t, "/live", snap.Tabs[0].Panes[0].Cwd) | |||
| 779 | - | } | |||
| 780 | - | ||||
| 781 | - | func TestPTYReaderSynchronizedUpdateFlushMarksSnapshotDirty(t *testing.T) { | |||
| 782 | - | d := newTestDaemon(t, nil, stubClock{}) | |||
| 783 | - | p := portsmocks.NewMockPTY(t) | |||
| 784 | - | chunks := [][]byte{[]byte("\x1b[?2026hhello\x1b[?2026l")} | |||
| 785 | - | p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) { | |||
| 786 | - | if len(chunks) == 0 { | |||
| 787 | - | return 0, io.EOF | |||
| 788 | - | } | |||
| 789 | - | n := copy(buf, chunks[0]) | |||
| 790 | - | chunks = chunks[1:] | |||
| 791 | - | return n, nil | |||
| 792 | - | }) | |||
| 793 | - | p.EXPECT().Close().Return(nil).Maybe() | |||
| 794 | - | ||||
| 795 | - | sctx, cancel := context.WithCancel(context.Background()) | |||
| 796 | - | defer cancel() | |||
| 797 | - | tb := newTestTabWithContext(p, sctx, cancel) | |||
| 798 | - | sess := &session{id: "sync", name: "sync", tabs: []*tab{tb}, ctx: sctx, cancel: cancel} | |||
| 799 | - | sess.snapEligible.Store(true) | |||
| 800 | - | d.sessions[sess.id] = sess | |||
| 801 | - | d.sessWg.Add(1) | |||
| 802 | - | d.ptyReader(sess, tb, tb.focusedPane()) | |||
| 803 | - | ||||
| 804 | - | require.True(t, sess.snapDirty.Load()) | |||
| 805 | - | } | |||
| 806 | - | ||||
| 807 | - | func TestPTYReaderSameReadSynchronizedOutputUsesUrgentCoordinatorDeadline(t *testing.T) { | |||
| 808 | - | d := newTestDaemon(t, nil, stubClock{}) | |||
| 809 | - | p := chunkReadPTY(t, []byte("\x1b[?2026hcomplete batch\x1b[?2026l")) | |||
| 810 | - | sctx, cancel := context.WithCancel(context.Background()) | |||
| 811 | - | defer cancel() | |||
| 812 | - | tb := newTestTabWithContext(p, sctx, cancel) | |||
| 813 | - | sess := &session{id: "sync-urgent", name: "sync-urgent", tabs: []*tab{tb}, client: &attachedClient{}, ctx: sctx, cancel: cancel} | |||
| 814 | - | invs := make(chan renderInvalidation, 1) | |||
| 815 | - | clock := newCoordinatorMockClock(t, 2) | |||
| 816 | - | sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{ | |||
| 817 | - | clock: clock.clock, | |||
| 818 | - | onInvalidate: func(inv renderInvalidation) { invs <- inv }, | |||
| 819 | - | })) | |||
| 820 | - | d.sessions[sess.id] = sess | |||
| 821 | - | d.sessWg.Add(1) | |||
| 822 | - | d.ptyReader(sess, tb, tb.focusedPane()) | |||
| 823 | - | ||||
| 824 | - | inv := awaitInvalidation(t, invs) | |||
| 825 | - | require.Equal(t, invalidateUrgent, inv.class, "a complete same-read synchronized batch must flush urgently") | |||
| 826 | - | timer := awaitCoordinatorScheduledTimer(t, clock) | |||
| 827 | - | require.Equal(t, urgentRenderDeadline, timer.duration) | |||
| 828 | - | } | |||
| 829 | - | ||||
| 830 | - | func TestSnapshotSaverKeepsDirtyWhenWriteFails(t *testing.T) { | |||
| 831 | - | clock := newManualSnapshotClock() | |||
| 832 | - | store := portsmocks.NewMockSnapshotStore(t) | |||
| 833 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock) | |||
| 834 | - | WithSnapshotStore(store)(d) | |||
| 835 | - | startSnapshotEncodeWorker(t, d) | |||
| 836 | - | ||||
| 837 | - | sess := newSnapshotTestSession(t, "retry", false, "/fallback") | |||
| 838 | - | sess.snapDirty.Store(true) | |||
| 839 | - | d.sessions[sess.id] = sess | |||
| 840 | - | ||||
| 841 | - | failed := make(chan struct{}, 1) | |||
| 842 | - | store.EXPECT().Write("retry", mock.Anything).RunAndReturn(func(string, []byte) error { | |||
| 843 | - | failed <- struct{}{} | |||
| 844 | - | return errors.New("boom") | |||
| 845 | - | }).Once() | |||
| 846 | - | wrote := make(chan struct{}, 1) | |||
| 847 | - | store.EXPECT().Write("retry", mock.Anything).RunAndReturn(func(string, []byte) error { | |||
| 848 | - | wrote <- struct{}{} | |||
| 849 | - | return nil | |||
| 850 | - | }).Once() | |||
| 851 | - | ||||
| 852 | - | ctx, cancel := context.WithCancel(t.Context()) | |||
| 853 | - | defer cancel() | |||
| 854 | - | go d.snapshotSaver(ctx) | |||
| 855 | - | clock.fireNext(t) | |||
| 856 | - | <-failed | |||
| 857 | - | awaitSnapshotIdle(t, sess) | |||
| 858 | - | require.True(t, sess.snapDirty.Load()) | |||
| 859 | - | clock.fireNext(t) | |||
| 860 | - | select { | |||
| 861 | - | case <-wrote: | |||
| 862 | - | case <-time.After(time.Second): | |||
| 863 | - | t.Fatal("timed out waiting for retry snapshot write") | |||
| 864 | - | } | |||
| 865 | - | awaitSnapshotClean(t, sess) | |||
| 866 | - | require.False(t, sess.snapDirty.Load()) | |||
| 867 | - | } | |||
| 868 | - | ||||
| 869 | - | func TestKillSessionPurgeDeletesSnapshot(t *testing.T) { | |||
| 870 | - | store := portsmocks.NewMockSnapshotStore(t) | |||
| 871 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 872 | - | WithSnapshotStore(store)(d) | |||
| 873 | - | ||||
| 874 | - | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |||
| 875 | - | d.sessions[sess.id] = sess | |||
| 876 | - | ||||
| 877 | - | store.EXPECT().Delete("work").Return(nil).Once() | |||
| 878 | - | mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |||
| 879 | - | require.True(t, ok) | |||
| 880 | - | mockPTY.EXPECT().Close().Return(nil).Once() | |||
| 881 | - | ||||
| 882 | - | require.NoError(t, d.killSession(sess, 0, true)) | |||
| 883 | - | } | |||
| 884 | - | ||||
| 885 | - | func TestKillSessionPurgeReturnsSnapshotDeleteError(t *testing.T) { | |||
| 886 | - | store := portsmocks.NewMockSnapshotStore(t) | |||
| 887 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 888 | - | WithSnapshotStore(store)(d) | |||
| 889 | - | ||||
| 890 | - | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |||
| 891 | - | d.sessions[sess.id] = sess | |||
| 892 | - | ||||
| 893 | - | store.EXPECT().Delete("work").Return(errors.New("delete failed")).Once() | |||
| 894 | - | mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |||
| 895 | - | require.True(t, ok) | |||
| 896 | - | mockPTY.EXPECT().Close().Return(nil).Once() | |||
| 897 | - | ||||
| 898 | - | err := d.killSession(sess, 0, true) | |||
| 899 | - | require.ErrorContains(t, err, "delete failed") | |||
| 900 | - | require.NotContains(t, d.sessions, sess.id) | |||
| 901 | - | require.NotContains(t, d.stopped, "work") | |||
| 902 | - | } | |||
| 903 | - | ||||
| 904 | - | func TestRefreshSessionCwdMarksSnapshotDirty(t *testing.T) { | |||
| 905 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 906 | - | d.procCwd = func(int) (string, error) { return "/new", nil } | |||
| 907 | - | sess := newSnapshotTestSession(t, "work", false, "/old") | |||
| 908 | - | d.sessions[sess.id] = sess | |||
| 909 | - | ||||
| 910 | - | d.refreshSessionCwd(sess) | |||
| 911 | - | require.Equal(t, "/new", sess.cwd) | |||
| 912 | - | require.True(t, sess.snapDirty.Load()) | |||
| 913 | - | } | |||
| 914 | - | ||||
| 915 | - | func TestStoppedSessionKillPurgesSnapshot(t *testing.T) { | |||
| 916 | - | store := portsmocks.NewMockSnapshotStore(t) | |||
| 917 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 918 | - | WithSnapshotStore(store)(d) | |||
| 919 | - | d.stopped["work"] = stoppedSession{name: "work", cwd: "/work", createdAt: 1} | |||
| 920 | - | ||||
| 921 | - | store.EXPECT().Delete("work").Return(nil).Once() | |||
| 922 | - | tr, _, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})}) | |||
| 923 | - | ||||
| 924 | - | d.handleKill(tr, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})}) | |||
| 925 | - | ||||
| 926 | - | require.NotContains(t, d.stopped, "work") | |||
| 927 | - | } | |||
| 928 | - | ||||
| 929 | - | func TestStoppedSessionKillSnapshotDeleteFailureKeepsMetadata(t *testing.T) { | |||
| 930 | - | store := portsmocks.NewMockSnapshotStore(t) | |||
| 931 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 932 | - | WithSnapshotStore(store)(d) | |||
| 933 | - | d.stopped["work"] = stoppedSession{name: "work", cwd: "/work", createdAt: 1} | |||
| 934 | - | ||||
| 935 | - | store.EXPECT().Delete("work").Return(errors.New("delete failed")).Once() | |||
| 936 | - | tr, sends, _ := newConn(t, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})}) | |||
| 937 | - | ||||
| 938 | - | d.handleKill(tr, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})}) | |||
| 939 | - | ||||
| 940 | - | require.Contains(t, d.stopped, "work") | |||
| 941 | - | frame := awaitFrame(t, sends, ports.MsgError) | |||
| 942 | - | msg, err := ports.UnmarshalErrorMsg(frame.Payload) | |||
| 943 | - | require.NoError(t, err) | |||
| 944 | - | require.Equal(t, ports.ErrInternal, msg.Code) | |||
| 945 | - | } | |||
| 946 | - | ||||
| 947 | - | func TestRenameSessionDeletesOldSnapshotAndMarksDirty(t *testing.T) { | |||
| 948 | - | store := portsmocks.NewMockSnapshotStore(t) | |||
| 949 | - | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 950 | - | WithSnapshotStore(store)(d) | |||
| 951 | - | sess := newSnapshotTestSession(t, "old", false, "/fallback") | |||
| 952 | - | d.sessions[sess.id] = sess | |||
| 953 | - | ||||
| 954 | - | store.EXPECT().Delete("old").Return(nil).Once() | |||
| 955 | - | require.NoError(t, d.renameSession(sess, "new")) | |||
| 956 | - | ||||
| 957 | - | require.True(t, sess.snapDirty.Load()) | |||
| 958 | - | require.Nil(t, d.findByNameLocked("old")) | |||
| 959 | - | } | |||
| 960 | - | ||||
| 961 | - | func TestSnapshotSaverCapturesLayoutOnlyDirtySave(t *testing.T) { | |||
| 962 | - | clock := newManualSnapshotClock() | |||
| 963 | - | store := portsmocks.NewMockSnapshotStore(t) | |||
| 964 | - | factory := portsmocks.NewMockPTYFactory(t) | |||
| 965 | - | d := newTestDaemon(t, factory, clock) | |||
| 966 | - | WithSnapshotStore(store)(d) | |||
| 967 | - | startSnapshotEncodeWorker(t, d) | |||
| 968 | - | ||||
| 969 | - | sess := newSnapshotTestSession(t, "work", false, "/work") | |||
| 970 | - | tctx, tcancel := context.WithCancel(context.Background()) | |||
| 971 | - | t.Cleanup(tcancel) | |||
| 972 | - | sess.ctx = tctx | |||
| 973 | - | sess.cancel = tcancel | |||
| 974 | - | sess.tabs[0].ctx = tctx | |||
| 975 | - | sess.tabs[0].cancel = tcancel | |||
| 976 | - | for _, p := range sess.tabs[0].panes { | |||
| 977 | - | p.ctx = tctx | |||
| 978 | - | p.cancel = tcancel | |||
| 979 | - | } | |||
| 980 | - | sess.tabs[0].size = domain.Size{Cols: 41, Rows: 10} | |||
| 981 | - | d.sessions[sess.id] = sess | |||
| 982 | - | newPTY := portsmocks.NewMockPTY(t) | |||
| 983 | - | newPTY.EXPECT().Pid().Return(2345).Maybe() | |||
| 984 | - | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | |||
| 985 | - | newPTY.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe() | |||
| 986 | - | newPTY.EXPECT().Resize(mock.Anything).Return(nil).Maybe() | |||
| 987 | - | newPTY.EXPECT().ForegroundPgid().Return(0, nil).Maybe() | |||
| 988 | - | factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once() | |||
| 989 | - | ||||
| 990 | - | require.NoError(t, d.splitPane(sess, nil, layout.Right)) | |||
| 991 | - | require.True(t, sess.snapDirty.Load()) | |||
| 992 | - | ||||
| 993 | - | wrote := make(chan struct{}, 1) | |||
| 994 | - | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |||
| 995 | - | snap, err := snapcodec.Unmarshal(data) | |||
| 996 | - | require.NoError(t, err) | |||
| 997 | - | require.Len(t, snap.Tabs, 1) | |||
| 998 | - | require.Len(t, snap.Tabs[0].Panes, 2) | |||
| 999 | - | wrote <- struct{}{} | |||
| 1000 | - | return nil | |||
| 1001 | - | }).Once() | |||
| 1002 | - | ||||
| 1003 | - | ctx, cancel := context.WithCancel(t.Context()) | |||
| 1004 | - | defer cancel() | |||
| 1005 | - | go d.snapshotSaver(ctx) | |||
| 1006 | - | clock.fireNext(t) | |||
| 1007 | - | select { | |||
| 1008 | - | case <-wrote: | |||
| 1009 | - | case <-time.After(time.Second): | |||
| 1010 | - | t.Fatal("timed out waiting for layout-only dirty snapshot write") | |||
| 1011 | - | } | |||
| 1012 | - | } | |||
| 1013 | - | ||||
| 1014 | - | func snapshotProcessPTY(t *testing.T, pgid int) *portsmocks.MockPTY { | |||
| 1015 | - | t.Helper() | |||
| 1016 | - | pty := portsmocks.NewMockPTY(t) | |||
| 1017 | - | pty.EXPECT().Pid().Return(1234).Maybe() | |||
| 1018 | - | pty.EXPECT().Read(mock.Anything).Return(0, errors.New("unused")).Maybe() | |||
| 1019 | - | pty.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe() | |||
| 1020 | - | pty.EXPECT().Resize(mock.Anything).Return(nil).Maybe() | |||
| 1021 | - | pty.EXPECT().ForegroundPgid().Return(pgid, nil).Once() | |||
| 1022 | - | return pty | |||
| 1023 | - | } | |||
| 1024 | - | ||||
| 1025 | func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd string) *session { | 35 | func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd string) *session { | ||
| 1026 | t.Helper() | 36 | t.Helper() | ||
| 1027 | pty := portsmocks.NewMockPTY(t) | 37 | pty := portsmocks.NewMockPTY(t) | ||
| @@ -1035,17 +45,7 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin | |||||
| 1035 | p := tb.panes["pane-1"] | 45 | p := tb.panes["pane-1"] | ||
| 1036 | p.screen.Write([]byte("hello")) | 46 | p.screen.Write([]byte("hello")) | ||
| 1037 | require.NoError(t, p.history.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})) | 47 | require.NoError(t, p.history.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})) | ||
| 1038 | - | sess := &session{ | 48 | + | sess := &session{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, active: 0, cwd: cwd, createdAt: 42} |
| 1039 | - | id: domain.SessionID("sess-" + name), | |||
| 1040 | - | name: name, | |||
| 1041 | - | ephemeral: ephemeral, | |||
| 1042 | - | ctx: context.Background(), | |||
| 1043 | - | cancel: func() {}, | |||
| 1044 | - | tabs: []*tab{tb}, | |||
| 1045 | - | active: 0, | |||
| 1046 | - | cwd: cwd, | |||
| 1047 | - | createdAt: 42, | |||
| 1048 | - | } | |||
| 1049 | sess.snapEligible.Store(!ephemeral && name != "") | 49 | sess.snapEligible.Store(!ephemeral && name != "") | ||
| 1050 | return sess | 50 | return sess | ||
| 1051 | } | 51 | } | ||
| @@ -1095,135 +95,3 @@ func startSnapshotEncodeWorker(t *testing.T, d *Daemon) { | |||||
| 1095 | d.startSnapshotEncodeWorker() | 95 | d.startSnapshotEncodeWorker() | ||
| 1096 | t.Cleanup(d.stopSnapshotEncodeWorker) | 96 | t.Cleanup(d.stopSnapshotEncodeWorker) | ||
| 1097 | } | 97 | } | ||
| 1098 | - | ||||
| 1099 | - | type discardSnapshotStore struct{} | |||
| 1100 | - | ||||
| 1101 | - | func (discardSnapshotStore) Write(string, []byte) error { return nil } | |||
| 1102 | - | func (discardSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil } | |||
| 1103 | - | func (discardSnapshotStore) Delete(string) error { return nil } | |||
| 1104 | - | ||||
| 1105 | - | type gatedSnapshotStore struct { | |||
| 1106 | - | entered chan struct{} | |||
| 1107 | - | release chan struct{} | |||
| 1108 | - | once sync.Once | |||
| 1109 | - | releaseOnce sync.Once | |||
| 1110 | - | ||||
| 1111 | - | mu sync.Mutex | |||
| 1112 | - | writes int | |||
| 1113 | - | name string | |||
| 1114 | - | data []byte | |||
| 1115 | - | } | |||
| 1116 | - | ||||
| 1117 | - | func newGatedSnapshotStore() *gatedSnapshotStore { | |||
| 1118 | - | return &gatedSnapshotStore{entered: make(chan struct{}), release: make(chan struct{})} | |||
| 1119 | - | } | |||
| 1120 | - | ||||
| 1121 | - | func (s *gatedSnapshotStore) enter() { s.once.Do(func() { close(s.entered) }) } | |||
| 1122 | - | func (s *gatedSnapshotStore) unblock() { s.releaseOnce.Do(func() { close(s.release) }) } | |||
| 1123 | - | ||||
| 1124 | - | func (s *gatedSnapshotStore) Write(name string, data []byte) error { | |||
| 1125 | - | s.enter() | |||
| 1126 | - | <-s.release | |||
| 1127 | - | s.mu.Lock() | |||
| 1128 | - | s.writes++ | |||
| 1129 | - | s.name = name | |||
| 1130 | - | s.data = append([]byte(nil), data...) | |||
| 1131 | - | s.mu.Unlock() | |||
| 1132 | - | return nil | |||
| 1133 | - | } | |||
| 1134 | - | func (*gatedSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil } | |||
| 1135 | - | func (*gatedSnapshotStore) Delete(string) error { return nil } | |||
| 1136 | - | ||||
| 1137 | - | // recorded returns the write count and the most recently written name/data, | |||
| 1138 | - | // captured under the store's own lock so it is safe to call from the test | |||
| 1139 | - | // goroutine after the write has happened-before via awaitSnapshotClean. | |||
| 1140 | - | func (s *gatedSnapshotStore) recorded() (int, string, []byte) { | |||
| 1141 | - | s.mu.Lock() | |||
| 1142 | - | defer s.mu.Unlock() | |||
| 1143 | - | return s.writes, s.name, s.data | |||
| 1144 | - | } | |||
| 1145 | - | ||||
| 1146 | - | type neverReturningSnapshotStore struct { | |||
| 1147 | - | entered chan struct{} | |||
| 1148 | - | once sync.Once | |||
| 1149 | - | block chan struct{} | |||
| 1150 | - | } | |||
| 1151 | - | ||||
| 1152 | - | func newNeverReturningSnapshotStore() *neverReturningSnapshotStore { | |||
| 1153 | - | return &neverReturningSnapshotStore{entered: make(chan struct{}), block: make(chan struct{})} | |||
| 1154 | - | } | |||
| 1155 | - | ||||
| 1156 | - | func (s *neverReturningSnapshotStore) Write(string, []byte) error { | |||
| 1157 | - | s.once.Do(func() { close(s.entered) }) | |||
| 1158 | - | <-s.block | |||
| 1159 | - | return nil | |||
| 1160 | - | } | |||
| 1161 | - | func (*neverReturningSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil } | |||
| 1162 | - | func (*neverReturningSnapshotStore) Delete(string) error { return nil } | |||
| 1163 | - | ||||
| 1164 | - | type channelSnapshotStore struct { | |||
| 1165 | - | writes chan []byte | |||
| 1166 | - | } | |||
| 1167 | - | ||||
| 1168 | - | func (s *channelSnapshotStore) Write(_ string, data []byte) error { | |||
| 1169 | - | s.writes <- append([]byte(nil), data...) | |||
| 1170 | - | return nil | |||
| 1171 | - | } | |||
| 1172 | - | func (*channelSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil } | |||
| 1173 | - | func (*channelSnapshotStore) Delete(string) error { return nil } | |||
| 1174 | - | ||||
| 1175 | - | type manualSnapshotClock struct { | |||
| 1176 | - | mu sync.Mutex | |||
| 1177 | - | timers []*manualSnapshotTimer | |||
| 1178 | - | } | |||
| 1179 | - | ||||
| 1180 | - | func newManualSnapshotClock() *manualSnapshotClock { return &manualSnapshotClock{} } | |||
| 1181 | - | ||||
| 1182 | - | func (c *manualSnapshotClock) Now() time.Time { return time.Unix(0, 0) } | |||
| 1183 | - | ||||
| 1184 | - | func (c *manualSnapshotClock) NewTimer(time.Duration) ports.Timer { | |||
| 1185 | - | t := &manualSnapshotTimer{ch: make(chan time.Time, 1)} | |||
| 1186 | - | c.mu.Lock() | |||
| 1187 | - | c.timers = append(c.timers, t) | |||
| 1188 | - | c.mu.Unlock() | |||
| 1189 | - | return t | |||
| 1190 | - | } | |||
| 1191 | - | ||||
| 1192 | - | func (c *manualSnapshotClock) fireNext(t *testing.T) { | |||
| 1193 | - | t.Helper() | |||
| 1194 | - | deadline := time.After(time.Second) | |||
| 1195 | - | for { | |||
| 1196 | - | c.mu.Lock() | |||
| 1197 | - | if len(c.timers) > 0 { | |||
| 1198 | - | timer := c.timers[0] | |||
| 1199 | - | c.mu.Unlock() | |||
| 1200 | - | timer.ch <- time.Unix(0, 0) | |||
| 1201 | - | return | |||
| 1202 | - | } | |||
| 1203 | - | c.mu.Unlock() | |||
| 1204 | - | select { | |||
| 1205 | - | case <-deadline: | |||
| 1206 | - | t.Fatal("timed out waiting for snapshot timer") | |||
| 1207 | - | case <-time.After(time.Millisecond): | |||
| 1208 | - | } | |||
| 1209 | - | } | |||
| 1210 | - | } | |||
| 1211 | - | ||||
| 1212 | - | type finalFlushClock struct{ timers chan *manualSnapshotTimer } | |||
| 1213 | - | ||||
| 1214 | - | func newFinalFlushClock() *finalFlushClock { | |||
| 1215 | - | return &finalFlushClock{timers: make(chan *manualSnapshotTimer, 1)} | |||
| 1216 | - | } | |||
| 1217 | - | ||||
| 1218 | - | func (c *finalFlushClock) Now() time.Time { return time.Unix(0, 0) } | |||
| 1219 | - | func (c *finalFlushClock) NewTimer(time.Duration) ports.Timer { | |||
| 1220 | - | t := &manualSnapshotTimer{ch: make(chan time.Time, 1)} | |||
| 1221 | - | c.timers <- t | |||
| 1222 | - | return t | |||
| 1223 | - | } | |||
| 1224 | - | ||||
| 1225 | - | type manualSnapshotTimer struct{ ch chan time.Time } | |||
| 1226 | - | ||||
| 1227 | - | func (t *manualSnapshotTimer) C() <-chan time.Time { return t.ch } | |||
| 1228 | - | func (t *manualSnapshotTimer) Reset(time.Duration) bool { return true } | |||
| 1229 | - | func (t *manualSnapshotTimer) Stop() bool { return true } | |||
M internal/usecase/snapshot/codec.go +3 -3
| @@ -24,7 +24,7 @@ const ( | |||
| 24 | 24 | magic = "VEVS" | |
| 25 | 25 | version = uint16(3) | |
| 26 | 26 | maxDecodedBodySize = 256 << 20 | |
| 27 | - | maxSnapshotBlobs = 1 << 16 | |
| 27 | + | maxSnapshotObjects = 1 << 16 | |
| 28 | 28 | maxSnapshotBytes = 256 << 20 | |
| 29 | 29 | maxSnapshotRows = 1 << 20 | |
| 30 | 30 | maxSnapshotCells = 16 << 20 | |
| @@ -449,7 +449,7 @@ func writePane(w *payloadWriter, p Pane) error { | |||
| 449 | 449 | if err := w.putString(p.Cwd); err != nil { | |
| 450 | 450 | return err | |
| 451 | 451 | } | |
| 452 | - | if len(p.SealedChunks) > maxSnapshotBlobs { | |
| 452 | + | if len(p.SealedChunks) > maxSnapshotObjects { | |
| 453 | 453 | return fmt.Errorf("%w: too many sealed chunks", ErrInvalidData) | |
| 454 | 454 | } | |
| 455 | 455 | w.putUint32(uint32(len(p.SealedChunks))) | |
| @@ -490,7 +490,7 @@ func readPane(r *payloadReader) (Pane, error) { | |||
| 490 | 490 | if err != nil { | |
| 491 | 491 | return Pane{}, err | |
| 492 | 492 | } | |
| 493 | - | if n > maxSnapshotBlobs || uint64(n) > uint64(len(r.b))/4 { | |
| 493 | + | if n > maxSnapshotObjects || uint64(n) > uint64(len(r.b))/4 { | |
| 494 | 494 | return Pane{}, fmt.Errorf("%w: sealed chunks", ErrInvalidData) | |
| 495 | 495 | } | |
| 496 | 496 | sealed := make([][]byte, n) | |
| @@ -24,7 +24,7 @@ const ( | |||||
| 24 | magic = "VEVS" | 24 | magic = "VEVS" | ||
| 25 | version = uint16(3) | 25 | version = uint16(3) | ||
| 26 | maxDecodedBodySize = 256 << 20 | 26 | maxDecodedBodySize = 256 << 20 | ||
| 27 | - | maxSnapshotBlobs = 1 << 16 | 27 | + | maxSnapshotObjects = 1 << 16 |
| 28 | maxSnapshotBytes = 256 << 20 | 28 | maxSnapshotBytes = 256 << 20 | ||
| 29 | maxSnapshotRows = 1 << 20 | 29 | maxSnapshotRows = 1 << 20 | ||
| 30 | maxSnapshotCells = 16 << 20 | 30 | maxSnapshotCells = 16 << 20 | ||
| @@ -449,7 +449,7 @@ func writePane(w *payloadWriter, p Pane) error { | |||||
| 449 | if err := w.putString(p.Cwd); err != nil { | 449 | if err := w.putString(p.Cwd); err != nil { | ||
| 450 | return err | 450 | return err | ||
| 451 | } | 451 | } | ||
| 452 | - | if len(p.SealedChunks) > maxSnapshotBlobs { | 452 | + | if len(p.SealedChunks) > maxSnapshotObjects { |
| 453 | return fmt.Errorf("%w: too many sealed chunks", ErrInvalidData) | 453 | return fmt.Errorf("%w: too many sealed chunks", ErrInvalidData) | ||
| 454 | } | 454 | } | ||
| 455 | w.putUint32(uint32(len(p.SealedChunks))) | 455 | w.putUint32(uint32(len(p.SealedChunks))) | ||
| @@ -490,7 +490,7 @@ func readPane(r *payloadReader) (Pane, error) { | |||||
| 490 | if err != nil { | 490 | if err != nil { | ||
| 491 | return Pane{}, err | 491 | return Pane{}, err | ||
| 492 | } | 492 | } | ||
| 493 | - | if n > maxSnapshotBlobs || uint64(n) > uint64(len(r.b))/4 { | 493 | + | if n > maxSnapshotObjects || uint64(n) > uint64(len(r.b))/4 { |
| 494 | return Pane{}, fmt.Errorf("%w: sealed chunks", ErrInvalidData) | 494 | return Pane{}, fmt.Errorf("%w: sealed chunks", ErrInvalidData) | ||
| 495 | } | 495 | } | ||
| 496 | sealed := make([][]byte, n) | 496 | sealed := make([][]byte, n) | ||
M internal/usecase/snapshot/preflight.go +2 -2
| @@ -198,7 +198,7 @@ func preflightPaneStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uin | |||
| 198 | 198 | if err != nil { | |
| 199 | 199 | return err | |
| 200 | 200 | } | |
| 201 | - | if n > maxSnapshotBlobs || uint64(n) > uint64(len(r.b))/4 { | |
| 201 | + | if n > maxSnapshotObjects || uint64(n) > uint64(len(r.b))/4 { | |
| 202 | 202 | return fmt.Errorf("%w: sealed chunks", ErrInvalidData) | |
| 203 | 203 | } | |
| 204 | 204 | for range n { | |
| @@ -235,7 +235,7 @@ func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, hist | |||
| 235 | 235 | if n == 0 || uint64(n) > uint64(len(r.b)) || uint64(n) > maxSnapshotBytes { | |
| 236 | 236 | return vt.DecodeStats{}, fmt.Errorf("%w: VT blob length", ErrInvalidData) | |
| 237 | 237 | } | |
| 238 | - | if *blobs >= maxSnapshotBlobs { | |
| 238 | + | if *blobs >= maxSnapshotObjects { | |
| 239 | 239 | return vt.DecodeStats{}, fmt.Errorf("%w: too many VT blobs", ErrInvalidData) | |
| 240 | 240 | } | |
| 241 | 241 | blob := r.b[:n] | |
| @@ -198,7 +198,7 @@ func preflightPaneStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uin | |||||
| 198 | if err != nil { | 198 | if err != nil { | ||
| 199 | return err | 199 | return err | ||
| 200 | } | 200 | } | ||
| 201 | - | if n > maxSnapshotBlobs || uint64(n) > uint64(len(r.b))/4 { | 201 | + | if n > maxSnapshotObjects || uint64(n) > uint64(len(r.b))/4 { |
| 202 | return fmt.Errorf("%w: sealed chunks", ErrInvalidData) | 202 | return fmt.Errorf("%w: sealed chunks", ErrInvalidData) | ||
| 203 | } | 203 | } | ||
| 204 | for range n { | 204 | for range n { | ||
| @@ -235,7 +235,7 @@ func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, hist | |||||
| 235 | if n == 0 || uint64(n) > uint64(len(r.b)) || uint64(n) > maxSnapshotBytes { | 235 | if n == 0 || uint64(n) > uint64(len(r.b)) || uint64(n) > maxSnapshotBytes { | ||
| 236 | return vt.DecodeStats{}, fmt.Errorf("%w: VT blob length", ErrInvalidData) | 236 | return vt.DecodeStats{}, fmt.Errorf("%w: VT blob length", ErrInvalidData) | ||
| 237 | } | 237 | } | ||
| 238 | - | if *blobs >= maxSnapshotBlobs { | 238 | + | if *blobs >= maxSnapshotObjects { |
| 239 | return vt.DecodeStats{}, fmt.Errorf("%w: too many VT blobs", ErrInvalidData) | 239 | return vt.DecodeStats{}, fmt.Errorf("%w: too many VT blobs", ErrInvalidData) | ||
| 240 | } | 240 | } | ||
| 241 | blob := r.b[:n] | 241 | blob := r.b[:n] | ||