Merge pull request #140 from bnema/feat/recovery-transcript
feat(recovery): persist canonical transcripts
47 files · +1744 -630
M README.md +1 -1
| @@ -36,7 +36,7 @@ vev ls list sessions | |||
| 36 | 36 | vev kill <name> kill a session (--all kills everything) | |
| 37 | 37 | ``` | |
| 38 | 38 | ||
| 39 | - | The daemon starts on first use and exits with the last session. Ephemeral sessions are numbered, survive detach, and disappear with the daemon. Named sessions persist across daemon restarts and come back with their layout, scrollback, and allowlisted processes. | |
| 39 | + | The daemon starts on first use and exits with the last session. Ephemeral sessions are numbered, survive detach, and disappear with the daemon. Named sessions persist across daemon restarts and come back with their layout, recovered terminal transcript, and allowlisted processes. | |
| 40 | 40 | ||
| 41 | 41 | ## Keys | |
| 42 | 42 | ||
| @@ -36,7 +36,7 @@ vev ls list sessions | |||||
| 36 | vev kill <name> kill a session (--all kills everything) | 36 | vev kill <name> kill a session (--all kills everything) | ||
| 37 | ``` | 37 | ``` | ||
| 38 | 38 | ||||
| 39 | - | The daemon starts on first use and exits with the last session. Ephemeral sessions are numbered, survive detach, and disappear with the daemon. Named sessions persist across daemon restarts and come back with their layout, scrollback, and allowlisted processes. | 39 | + | The daemon starts on first use and exits with the last session. Ephemeral sessions are numbered, survive detach, and disappear with the daemon. Named sessions persist across daemon restarts and come back with their layout, recovered terminal transcript, and allowlisted processes. |
| 40 | 40 | ||||
| 41 | ## Keys | 41 | ## Keys | ||
| 42 | 42 | ||||
M docs/durable-session-recovery.md +8 -2
| @@ -12,9 +12,15 @@ vev opens named-session state only while holding `$XDG_RUNTIME_DIR/vev/lifecycle | |||
| 12 | 12 | ||
| 13 | 13 | Discard creates a new incarnation and retains the old record and snapshots under `snapshots/quarantine/` until an explicit later purge. | |
| 14 | 14 | ||
| 15 | - | ## Migration and retention | |
| 15 | + | ## Incompatible checkpoints | |
| 16 | 16 | ||
| 17 | - | The v0.x migration is resumable, retains legacy sources, and validates each referenced HEAD. Healthy sessions retain the committed checkpoint and up to two direct fallbacks; degraded, deleting, migrating, and unresolved transactions remain pinned. | |
| 17 | + | After verifying a VEVM manifest's digest, vev treats any VEVM version mismatch as an incompatible healthy checkpoint. It atomically replaces only that named session's exact healthy checkpoint with a fresh incarnation. The replacement retains the session name and working directory, but has no checkpoint, tabs, terminal history, or recovery transcript. | |
| 18 | + | ||
| 19 | + | Digest mismatches, corruption, validation failures, I/O errors, and ambiguous failures are never reset or purged. The session remains degraded for explicit recovery. | |
| 20 | + | ||
| 21 | + | ## Retention | |
| 22 | + | ||
| 23 | + | Healthy sessions retain the committed checkpoint and up to two direct fallbacks. Degraded and unresolved state remains pinned. | |
| 18 | 24 | ||
| 19 | 25 | ## Fail-closed startup | |
| 20 | 26 | ||
| @@ -12,9 +12,15 @@ vev opens named-session state only while holding `$XDG_RUNTIME_DIR/vev/lifecycle | |||||
| 12 | 12 | ||||
| 13 | Discard creates a new incarnation and retains the old record and snapshots under `snapshots/quarantine/` until an explicit later purge. | 13 | Discard creates a new incarnation and retains the old record and snapshots under `snapshots/quarantine/` until an explicit later purge. | ||
| 14 | 14 | ||||
| 15 | - | ## Migration and retention | 15 | + | ## Incompatible checkpoints |
| 16 | 16 | ||||
| 17 | - | The v0.x migration is resumable, retains legacy sources, and validates each referenced HEAD. Healthy sessions retain the committed checkpoint and up to two direct fallbacks; degraded, deleting, migrating, and unresolved transactions remain pinned. | 17 | + | After verifying a VEVM manifest's digest, vev treats any VEVM version mismatch as an incompatible healthy checkpoint. It atomically replaces only that named session's exact healthy checkpoint with a fresh incarnation. The replacement retains the session name and working directory, but has no checkpoint, tabs, terminal history, or recovery transcript. |
| 18 | + | ||||
| 19 | + | Digest mismatches, corruption, validation failures, I/O errors, and ambiguous failures are never reset or purged. The session remains degraded for explicit recovery. | |||
| 20 | + | ||||
| 21 | + | ## Retention | |||
| 22 | + | ||||
| 23 | + | Healthy sessions retain the committed checkpoint and up to two direct fallbacks. Degraded and unresolved state remains pinned. | |||
| 18 | 24 | ||||
| 19 | ## Fail-closed startup | 25 | ## Fail-closed startup | ||
| 20 | 26 | ||||
M docs/terminal.md +6 -0
| @@ -14,6 +14,12 @@ For remote attaches, the remote proxy substitutes the remote host's exported env | |||
| 14 | 14 | ||
| 15 | 15 | vev keeps a server-side VT screen per pane: scroll regions (DECOM), insert mode, cursor and mode-query reports, alternate screen, bracketed paste, mouse tracking, synchronized updates, OSC 9/777 notifications, OSC 52 clipboard, and double-width cells. Resizes repair double-width cells so CJK and emoji halves are never left behind. | |
| 16 | 16 | ||
| 17 | + | ## Recovery | |
| 18 | + | ||
| 19 | + | Named-session checkpoints preserve bounded scrollback and a recovery transcript containing the primary viewport followed by the active alternate viewport, when present. Each viewport trims only trailing untouched blank rows; leading, internal, styled, and otherwise touched blank rows remain. | |
| 20 | + | ||
| 21 | + | On restore, vev appends the transcript after bounded scrollback as copy-mode history on a fresh blank VT screen. A resumed allowlisted process can redraw the live screen without erasing that recovered history. Recovery does not replay the PTY stream or restore the previous cursor or terminal modes. | |
| 22 | + | ||
| 17 | 23 | ## Alt+Space caveat | |
| 18 | 24 | ||
| 19 | 25 | Some terminals encode Alt+Space as a non-breaking space instead of `ESC Space`. If the palette does not open, rebind `open-palette`. | |
| @@ -14,6 +14,12 @@ For remote attaches, the remote proxy substitutes the remote host's exported env | |||||
| 14 | 14 | ||||
| 15 | vev keeps a server-side VT screen per pane: scroll regions (DECOM), insert mode, cursor and mode-query reports, alternate screen, bracketed paste, mouse tracking, synchronized updates, OSC 9/777 notifications, OSC 52 clipboard, and double-width cells. Resizes repair double-width cells so CJK and emoji halves are never left behind. | 15 | vev keeps a server-side VT screen per pane: scroll regions (DECOM), insert mode, cursor and mode-query reports, alternate screen, bracketed paste, mouse tracking, synchronized updates, OSC 9/777 notifications, OSC 52 clipboard, and double-width cells. Resizes repair double-width cells so CJK and emoji halves are never left behind. | ||
| 16 | 16 | ||||
| 17 | + | ## Recovery | |||
| 18 | + | ||||
| 19 | + | Named-session checkpoints preserve bounded scrollback and a recovery transcript containing the primary viewport followed by the active alternate viewport, when present. Each viewport trims only trailing untouched blank rows; leading, internal, styled, and otherwise touched blank rows remain. | |||
| 20 | + | ||||
| 21 | + | On restore, vev appends the transcript after bounded scrollback as copy-mode history on a fresh blank VT screen. A resumed allowlisted process can redraw the live screen without erasing that recovered history. Recovery does not replay the PTY stream or restore the previous cursor or terminal modes. | |||
| 22 | + | ||||
| 17 | ## Alt+Space caveat | 23 | ## Alt+Space caveat | ||
| 18 | 24 | ||||
| 19 | Some terminals encode Alt+Space as a non-breaking space instead of `ESC Space`. If the palette does not open, rebind `open-palette`. | 25 | Some terminals encode Alt+Space as a non-breaking space instead of `ESC Space`. If the palette does not open, rebind `open-palette`. | ||
M internal/adapters/snapshot/direct_load_test.go +100 -4
| @@ -3,6 +3,7 @@ package snapshot | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "crypto/sha256" | |
| 6 | + | "encoding/binary" | |
| 6 | 7 | "errors" | |
| 7 | 8 | "os" | |
| 8 | 9 | "path/filepath" | |
| @@ -61,6 +62,101 @@ func TestDirectCheckpointLoad(t *testing.T) { | |||
| 61 | 62 | } | |
| 62 | 63 | } | |
| 63 | 64 | ||
| 65 | + | func TestLoadCheckpointPreservesManifestVersionError(t *testing.T) { | |
| 66 | + | for _, tt := range []struct { | |
| 67 | + | name string | |
| 68 | + | version uint16 | |
| 69 | + | }{ | |
| 70 | + | {name: "old", version: codec.ManifestVersion - 1}, | |
| 71 | + | {name: "future", version: codec.ManifestVersion + 1}, | |
| 72 | + | } { | |
| 73 | + | t.Run(tt.name, func(t *testing.T) { | |
| 74 | + | repo := NewRepository(privateDir(t)) | |
| 75 | + | id := domain.IncarnationID{1} | |
| 76 | + | pub := incarnationPublication(t, id, "work", 1, nil) | |
| 77 | + | require.NoError(t, repo.Publish(t.Context(), pub)) | |
| 78 | + | ||
| 79 | + | manifest := append([]byte(nil), pub.Manifest...) | |
| 80 | + | binary.BigEndian.PutUint16(manifest[4:6], tt.version) | |
| 81 | + | require.NoError(t, os.WriteFile(repo.manifestPath(id, 1), manifest, 0o600)) | |
| 82 | + | ref := domain.CheckpointRef{Generation: 1, ManifestDigest: sha256.Sum256(manifest)} | |
| 83 | + | ||
| 84 | + | _, err := repo.LoadCheckpoint(t.Context(), id, "work", ref) | |
| 85 | + | require.ErrorIs(t, err, codec.ErrBadVersion) | |
| 86 | + | }) | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestLoadCheckpointDoesNotClassifyCorruptionAsManifestVersion(t *testing.T) { | |
| 91 | + | for _, tt := range []struct { | |
| 92 | + | name string | |
| 93 | + | mutate func(*testing.T, *Repository, ports.SnapshotPublication, *domain.CheckpointRef) | |
| 94 | + | }{ | |
| 95 | + | { | |
| 96 | + | name: "digest mismatch precedes version decode", | |
| 97 | + | mutate: func(t *testing.T, repo *Repository, pub ports.SnapshotPublication, _ *domain.CheckpointRef) { | |
| 98 | + | t.Helper() | |
| 99 | + | manifest := append([]byte(nil), pub.Manifest...) | |
| 100 | + | binary.BigEndian.PutUint16(manifest[4:6], codec.ManifestVersion-1) | |
| 101 | + | require.NoError(t, os.WriteFile(repo.manifestPath(pub.IncarnationID, pub.Generation), manifest, 0o600)) | |
| 102 | + | }, | |
| 103 | + | }, | |
| 104 | + | { | |
| 105 | + | name: "bad magic", | |
| 106 | + | mutate: func(t *testing.T, repo *Repository, pub ports.SnapshotPublication, ref *domain.CheckpointRef) { | |
| 107 | + | t.Helper() | |
| 108 | + | manifest := append([]byte(nil), pub.Manifest...) | |
| 109 | + | manifest[0] ^= 1 | |
| 110 | + | ref.ManifestDigest = sha256.Sum256(manifest) | |
| 111 | + | require.NoError(t, os.WriteFile(repo.manifestPath(pub.IncarnationID, pub.Generation), manifest, 0o600)) | |
| 112 | + | }, | |
| 113 | + | }, | |
| 114 | + | { | |
| 115 | + | name: "bad crc", | |
| 116 | + | mutate: func(t *testing.T, repo *Repository, pub ports.SnapshotPublication, ref *domain.CheckpointRef) { | |
| 117 | + | t.Helper() | |
| 118 | + | manifest := append([]byte(nil), pub.Manifest...) | |
| 119 | + | manifest[12] ^= 1 | |
| 120 | + | ref.ManifestDigest = sha256.Sum256(manifest) | |
| 121 | + | require.NoError(t, os.WriteFile(repo.manifestPath(pub.IncarnationID, pub.Generation), manifest, 0o600)) | |
| 122 | + | }, | |
| 123 | + | }, | |
| 124 | + | { | |
| 125 | + | name: "wrong identity", | |
| 126 | + | mutate: func(t *testing.T, repo *Repository, pub ports.SnapshotPublication, ref *domain.CheckpointRef) { | |
| 127 | + | t.Helper() | |
| 128 | + | manifest, err := codec.UnmarshalManifest(pub.Manifest) | |
| 129 | + | require.NoError(t, err) | |
| 130 | + | manifest.IncarnationID = domain.IncarnationID{2} | |
| 131 | + | encoded, err := codec.MarshalManifest(manifest) | |
| 132 | + | require.NoError(t, err) | |
| 133 | + | ref.ManifestDigest = sha256.Sum256(encoded) | |
| 134 | + | require.NoError(t, os.WriteFile(repo.manifestPath(pub.IncarnationID, pub.Generation), encoded, 0o600)) | |
| 135 | + | }, | |
| 136 | + | }, | |
| 137 | + | { | |
| 138 | + | name: "invalid object", | |
| 139 | + | mutate: func(t *testing.T, repo *Repository, pub ports.SnapshotPublication, _ *domain.CheckpointRef) { | |
| 140 | + | t.Helper() | |
| 141 | + | require.NoError(t, os.WriteFile(repo.objectPath(pub.IncarnationID, pub.Objects[0].Digest), []byte("corrupt"), 0o600)) | |
| 142 | + | }, | |
| 143 | + | }, | |
| 144 | + | } { | |
| 145 | + | t.Run(tt.name, func(t *testing.T) { | |
| 146 | + | repo := NewRepository(privateDir(t)) | |
| 147 | + | id := domain.IncarnationID{1} | |
| 148 | + | pub := incarnationPublication(t, id, "work", 1, nil) | |
| 149 | + | require.NoError(t, repo.Publish(t.Context(), pub)) | |
| 150 | + | ref := domain.CheckpointRef{Generation: 1, ManifestDigest: sha256.Sum256(pub.Manifest)} | |
| 151 | + | tt.mutate(t, repo, pub, &ref) | |
| 152 | + | ||
| 153 | + | _, err := repo.LoadCheckpoint(t.Context(), id, "work", ref) | |
| 154 | + | require.Error(t, err) | |
| 155 | + | require.NotErrorIs(t, err, codec.ErrBadVersion) | |
| 156 | + | }) | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | ||
| 64 | 160 | func TestReconcileCheckpoint(t *testing.T) { | |
| 65 | 161 | repo := NewRepository(privateDir(t)) | |
| 66 | 162 | id := domain.IncarnationID{1} | |
| @@ -185,20 +281,20 @@ func (c *forwardOrphanCatalogue) Replace(name string, record domain.CatalogueRec | |||
| 185 | 281 | ||
| 186 | 282 | func incarnationPublication(t *testing.T, id domain.IncarnationID, name string, generation uint64, parent *domain.CheckpointRef) ports.SnapshotPublication { | |
| 187 | 283 | t.Helper() | |
| 188 | - | tail, err := codec.MarshalObject(codec.HistoryTail, []byte("tail")) | |
| 284 | + | tail, err := codec.MarshalObject(codec.HistoryTail, canonicalHistoryBlob(t, "tail")) | |
| 189 | 285 | if err != nil { | |
| 190 | 286 | t.Fatal(err) | |
| 191 | 287 | } | |
| 192 | - | visible, err := codec.MarshalObject(codec.Visible, []byte("visible")) | |
| 288 | + | transcript, err := codec.MarshalObject(codec.RecoveryTranscript, canonicalHistoryBlob(t, "transcript")) | |
| 193 | 289 | if err != nil { | |
| 194 | 290 | t.Fatal(err) | |
| 195 | 291 | } | |
| 196 | 292 | manifest, err := codec.MarshalManifest(codec.Manifest{ | |
| 197 | 293 | Generation: generation, IncarnationID: id, ParentCheckpoint: parent, Name: name, | |
| 198 | - | Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Visible: codec.ObjectRef{Kind: codec.Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}}}}}, | |
| 294 | + | Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Transcript: codec.ObjectRef{Kind: codec.RecoveryTranscript, Digest: transcript.Digest, Size: uint32(len(transcript.Data))}}}}}, | |
| 199 | 295 | }) | |
| 200 | 296 | if err != nil { | |
| 201 | 297 | t.Fatal(err) | |
| 202 | 298 | } | |
| 203 | - | return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, ParentCheckpoint: parent, Manifest: manifest, Objects: []ports.SnapshotObject{tail, visible}} | |
| 299 | + | return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, ParentCheckpoint: parent, Manifest: manifest, Objects: []ports.SnapshotObject{tail, transcript}} | |
| 204 | 300 | } | |
| @@ -3,6 +3,7 @@ package snapshot | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "crypto/sha256" | 5 | "crypto/sha256" | ||
| 6 | + | "encoding/binary" | |||
| 6 | "errors" | 7 | "errors" | ||
| 7 | "os" | 8 | "os" | ||
| 8 | "path/filepath" | 9 | "path/filepath" | ||
| @@ -61,6 +62,101 @@ func TestDirectCheckpointLoad(t *testing.T) { | |||||
| 61 | } | 62 | } | ||
| 62 | } | 63 | } | ||
| 63 | 64 | ||||
| 65 | + | func TestLoadCheckpointPreservesManifestVersionError(t *testing.T) { | |||
| 66 | + | for _, tt := range []struct { | |||
| 67 | + | name string | |||
| 68 | + | version uint16 | |||
| 69 | + | }{ | |||
| 70 | + | {name: "old", version: codec.ManifestVersion - 1}, | |||
| 71 | + | {name: "future", version: codec.ManifestVersion + 1}, | |||
| 72 | + | } { | |||
| 73 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 74 | + | repo := NewRepository(privateDir(t)) | |||
| 75 | + | id := domain.IncarnationID{1} | |||
| 76 | + | pub := incarnationPublication(t, id, "work", 1, nil) | |||
| 77 | + | require.NoError(t, repo.Publish(t.Context(), pub)) | |||
| 78 | + | ||||
| 79 | + | manifest := append([]byte(nil), pub.Manifest...) | |||
| 80 | + | binary.BigEndian.PutUint16(manifest[4:6], tt.version) | |||
| 81 | + | require.NoError(t, os.WriteFile(repo.manifestPath(id, 1), manifest, 0o600)) | |||
| 82 | + | ref := domain.CheckpointRef{Generation: 1, ManifestDigest: sha256.Sum256(manifest)} | |||
| 83 | + | ||||
| 84 | + | _, err := repo.LoadCheckpoint(t.Context(), id, "work", ref) | |||
| 85 | + | require.ErrorIs(t, err, codec.ErrBadVersion) | |||
| 86 | + | }) | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestLoadCheckpointDoesNotClassifyCorruptionAsManifestVersion(t *testing.T) { | |||
| 91 | + | for _, tt := range []struct { | |||
| 92 | + | name string | |||
| 93 | + | mutate func(*testing.T, *Repository, ports.SnapshotPublication, *domain.CheckpointRef) | |||
| 94 | + | }{ | |||
| 95 | + | { | |||
| 96 | + | name: "digest mismatch precedes version decode", | |||
| 97 | + | mutate: func(t *testing.T, repo *Repository, pub ports.SnapshotPublication, _ *domain.CheckpointRef) { | |||
| 98 | + | t.Helper() | |||
| 99 | + | manifest := append([]byte(nil), pub.Manifest...) | |||
| 100 | + | binary.BigEndian.PutUint16(manifest[4:6], codec.ManifestVersion-1) | |||
| 101 | + | require.NoError(t, os.WriteFile(repo.manifestPath(pub.IncarnationID, pub.Generation), manifest, 0o600)) | |||
| 102 | + | }, | |||
| 103 | + | }, | |||
| 104 | + | { | |||
| 105 | + | name: "bad magic", | |||
| 106 | + | mutate: func(t *testing.T, repo *Repository, pub ports.SnapshotPublication, ref *domain.CheckpointRef) { | |||
| 107 | + | t.Helper() | |||
| 108 | + | manifest := append([]byte(nil), pub.Manifest...) | |||
| 109 | + | manifest[0] ^= 1 | |||
| 110 | + | ref.ManifestDigest = sha256.Sum256(manifest) | |||
| 111 | + | require.NoError(t, os.WriteFile(repo.manifestPath(pub.IncarnationID, pub.Generation), manifest, 0o600)) | |||
| 112 | + | }, | |||
| 113 | + | }, | |||
| 114 | + | { | |||
| 115 | + | name: "bad crc", | |||
| 116 | + | mutate: func(t *testing.T, repo *Repository, pub ports.SnapshotPublication, ref *domain.CheckpointRef) { | |||
| 117 | + | t.Helper() | |||
| 118 | + | manifest := append([]byte(nil), pub.Manifest...) | |||
| 119 | + | manifest[12] ^= 1 | |||
| 120 | + | ref.ManifestDigest = sha256.Sum256(manifest) | |||
| 121 | + | require.NoError(t, os.WriteFile(repo.manifestPath(pub.IncarnationID, pub.Generation), manifest, 0o600)) | |||
| 122 | + | }, | |||
| 123 | + | }, | |||
| 124 | + | { | |||
| 125 | + | name: "wrong identity", | |||
| 126 | + | mutate: func(t *testing.T, repo *Repository, pub ports.SnapshotPublication, ref *domain.CheckpointRef) { | |||
| 127 | + | t.Helper() | |||
| 128 | + | manifest, err := codec.UnmarshalManifest(pub.Manifest) | |||
| 129 | + | require.NoError(t, err) | |||
| 130 | + | manifest.IncarnationID = domain.IncarnationID{2} | |||
| 131 | + | encoded, err := codec.MarshalManifest(manifest) | |||
| 132 | + | require.NoError(t, err) | |||
| 133 | + | ref.ManifestDigest = sha256.Sum256(encoded) | |||
| 134 | + | require.NoError(t, os.WriteFile(repo.manifestPath(pub.IncarnationID, pub.Generation), encoded, 0o600)) | |||
| 135 | + | }, | |||
| 136 | + | }, | |||
| 137 | + | { | |||
| 138 | + | name: "invalid object", | |||
| 139 | + | mutate: func(t *testing.T, repo *Repository, pub ports.SnapshotPublication, _ *domain.CheckpointRef) { | |||
| 140 | + | t.Helper() | |||
| 141 | + | require.NoError(t, os.WriteFile(repo.objectPath(pub.IncarnationID, pub.Objects[0].Digest), []byte("corrupt"), 0o600)) | |||
| 142 | + | }, | |||
| 143 | + | }, | |||
| 144 | + | } { | |||
| 145 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 146 | + | repo := NewRepository(privateDir(t)) | |||
| 147 | + | id := domain.IncarnationID{1} | |||
| 148 | + | pub := incarnationPublication(t, id, "work", 1, nil) | |||
| 149 | + | require.NoError(t, repo.Publish(t.Context(), pub)) | |||
| 150 | + | ref := domain.CheckpointRef{Generation: 1, ManifestDigest: sha256.Sum256(pub.Manifest)} | |||
| 151 | + | tt.mutate(t, repo, pub, &ref) | |||
| 152 | + | ||||
| 153 | + | _, err := repo.LoadCheckpoint(t.Context(), id, "work", ref) | |||
| 154 | + | require.Error(t, err) | |||
| 155 | + | require.NotErrorIs(t, err, codec.ErrBadVersion) | |||
| 156 | + | }) | |||
| 157 | + | } | |||
| 158 | + | } | |||
| 159 | + | ||||
| 64 | func TestReconcileCheckpoint(t *testing.T) { | 160 | func TestReconcileCheckpoint(t *testing.T) { | ||
| 65 | repo := NewRepository(privateDir(t)) | 161 | repo := NewRepository(privateDir(t)) | ||
| 66 | id := domain.IncarnationID{1} | 162 | id := domain.IncarnationID{1} | ||
| @@ -185,20 +281,20 @@ func (c *forwardOrphanCatalogue) Replace(name string, record domain.CatalogueRec | |||||
| 185 | 281 | ||||
| 186 | func incarnationPublication(t *testing.T, id domain.IncarnationID, name string, generation uint64, parent *domain.CheckpointRef) ports.SnapshotPublication { | 282 | func incarnationPublication(t *testing.T, id domain.IncarnationID, name string, generation uint64, parent *domain.CheckpointRef) ports.SnapshotPublication { | ||
| 187 | t.Helper() | 283 | t.Helper() | ||
| 188 | - | tail, err := codec.MarshalObject(codec.HistoryTail, []byte("tail")) | 284 | + | tail, err := codec.MarshalObject(codec.HistoryTail, canonicalHistoryBlob(t, "tail")) |
| 189 | if err != nil { | 285 | if err != nil { | ||
| 190 | t.Fatal(err) | 286 | t.Fatal(err) | ||
| 191 | } | 287 | } | ||
| 192 | - | visible, err := codec.MarshalObject(codec.Visible, []byte("visible")) | 288 | + | transcript, err := codec.MarshalObject(codec.RecoveryTranscript, canonicalHistoryBlob(t, "transcript")) |
| 193 | if err != nil { | 289 | if err != nil { | ||
| 194 | t.Fatal(err) | 290 | t.Fatal(err) | ||
| 195 | } | 291 | } | ||
| 196 | manifest, err := codec.MarshalManifest(codec.Manifest{ | 292 | manifest, err := codec.MarshalManifest(codec.Manifest{ | ||
| 197 | Generation: generation, IncarnationID: id, ParentCheckpoint: parent, Name: name, | 293 | Generation: generation, IncarnationID: id, ParentCheckpoint: parent, Name: name, | ||
| 198 | - | Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Visible: codec.ObjectRef{Kind: codec.Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}}}}}, | 294 | + | Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Transcript: codec.ObjectRef{Kind: codec.RecoveryTranscript, Digest: transcript.Digest, Size: uint32(len(transcript.Data))}}}}}, |
| 199 | }) | 295 | }) | ||
| 200 | if err != nil { | 296 | if err != nil { | ||
| 201 | t.Fatal(err) | 297 | t.Fatal(err) | ||
| 202 | } | 298 | } | ||
| 203 | - | return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, ParentCheckpoint: parent, Manifest: manifest, Objects: []ports.SnapshotObject{tail, visible}} | 299 | + | return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, ParentCheckpoint: parent, Manifest: manifest, Objects: []ports.SnapshotObject{tail, transcript}} |
| 204 | } | 300 | } | ||
M internal/adapters/snapshot/load.go +4 -1
| @@ -41,7 +41,10 @@ func (r *Repository) loadCheckpointLocked(ctx context.Context, id domain.Incarna | |||
| 41 | 41 | return ports.SnapshotGeneration{}, codec.Manifest{}, fmt.Errorf("manifest digest mismatch") | |
| 42 | 42 | } | |
| 43 | 43 | manifest, err := codec.UnmarshalManifest(data) | |
| 44 | - | if err != nil || manifest.IncarnationID != id || manifest.Generation != ref.Generation { | |
| 44 | + | if err != nil { | |
| 45 | + | return ports.SnapshotGeneration{}, codec.Manifest{}, fmt.Errorf("invalid manifest: %w", err) | |
| 46 | + | } | |
| 47 | + | if manifest.IncarnationID != id || manifest.Generation != ref.Generation { | |
| 45 | 48 | return ports.SnapshotGeneration{}, codec.Manifest{}, fmt.Errorf("invalid manifest") | |
| 46 | 49 | } | |
| 47 | 50 | refs := manifestRefs(manifest) | |
| @@ -41,7 +41,10 @@ func (r *Repository) loadCheckpointLocked(ctx context.Context, id domain.Incarna | |||||
| 41 | return ports.SnapshotGeneration{}, codec.Manifest{}, fmt.Errorf("manifest digest mismatch") | 41 | return ports.SnapshotGeneration{}, codec.Manifest{}, fmt.Errorf("manifest digest mismatch") | ||
| 42 | } | 42 | } | ||
| 43 | manifest, err := codec.UnmarshalManifest(data) | 43 | manifest, err := codec.UnmarshalManifest(data) | ||
| 44 | - | if err != nil || manifest.IncarnationID != id || manifest.Generation != ref.Generation { | 44 | + | if err != nil { |
| 45 | + | return ports.SnapshotGeneration{}, codec.Manifest{}, fmt.Errorf("invalid manifest: %w", err) | |||
| 46 | + | } | |||
| 47 | + | if manifest.IncarnationID != id || manifest.Generation != ref.Generation { | |||
| 45 | return ports.SnapshotGeneration{}, codec.Manifest{}, fmt.Errorf("invalid manifest") | 48 | return ports.SnapshotGeneration{}, codec.Manifest{}, fmt.Errorf("invalid manifest") | ||
| 46 | } | 49 | } | ||
| 47 | refs := manifestRefs(manifest) | 50 | refs := manifestRefs(manifest) | ||
M internal/adapters/snapshot/publish.go +1 -1
| @@ -320,7 +320,7 @@ func manifestRefs(manifest codec.Manifest) map[ports.SnapshotDigest]codec.Object | |||
| 320 | 320 | return nil | |
| 321 | 321 | } | |
| 322 | 322 | } | |
| 323 | - | if !add(pane.Tail) || !add(pane.Visible) { | |
| 323 | + | if !add(pane.Tail) || !add(pane.Transcript) { | |
| 324 | 324 | return nil | |
| 325 | 325 | } | |
| 326 | 326 | } | |
| @@ -320,7 +320,7 @@ func manifestRefs(manifest codec.Manifest) map[ports.SnapshotDigest]codec.Object | |||||
| 320 | return nil | 320 | return nil | ||
| 321 | } | 321 | } | ||
| 322 | } | 322 | } | ||
| 323 | - | if !add(pane.Tail) || !add(pane.Visible) { | 323 | + | if !add(pane.Tail) || !add(pane.Transcript) { |
| 324 | return nil | 324 | return nil | ||
| 325 | } | 325 | } | ||
| 326 | } | 326 | } | ||
M internal/adapters/snapshot/publish_efficiency_test.go +8 -8
| @@ -61,32 +61,32 @@ func largeIncrementalPublications(t testing.TB, name string, count int) (ports.S | |||
| 61 | 61 | sealed := make([]codec.ObjectRef, 0, count) | |
| 62 | 62 | objects := make([]ports.SnapshotObject, 0, count+2) | |
| 63 | 63 | for i := range count { | |
| 64 | - | object, err := codec.MarshalObject(codec.HistoryChunk, fmt.Appendf(nil, "history-%d", i)) | |
| 64 | + | object, err := codec.MarshalObject(codec.HistoryChunk, canonicalHistoryBlob(t, fmt.Sprintf("history-%d", i))) | |
| 65 | 65 | if err != nil { | |
| 66 | 66 | t.Fatal(err) | |
| 67 | 67 | } | |
| 68 | 68 | sealed = append(sealed, codec.ObjectRef{Kind: codec.HistoryChunk, Digest: object.Digest, Size: uint32(len(object.Data))}) | |
| 69 | 69 | objects = append(objects, object) | |
| 70 | 70 | } | |
| 71 | - | makePublication := func(generation uint64, parent *domain.CheckpointRef, tailPayload, visiblePayload string) ports.SnapshotPublication { | |
| 71 | + | makePublication := func(generation uint64, parent *domain.CheckpointRef, tailPayload, transcriptPayload string) ports.SnapshotPublication { | |
| 72 | 72 | t.Helper() | |
| 73 | - | tail, err := codec.MarshalObject(codec.HistoryTail, []byte(tailPayload)) | |
| 73 | + | tail, err := codec.MarshalObject(codec.HistoryTail, canonicalHistoryBlob(t, tailPayload)) | |
| 74 | 74 | if err != nil { | |
| 75 | 75 | t.Fatal(err) | |
| 76 | 76 | } | |
| 77 | - | visible, err := codec.MarshalObject(codec.Visible, []byte(visiblePayload)) | |
| 77 | + | transcript, err := codec.MarshalObject(codec.RecoveryTranscript, canonicalHistoryBlob(t, transcriptPayload)) | |
| 78 | 78 | if err != nil { | |
| 79 | 79 | t.Fatal(err) | |
| 80 | 80 | } | |
| 81 | - | manifest, err := codec.MarshalManifest(codec.Manifest{Generation: generation, IncarnationID: id, ParentCheckpoint: parent, Name: name, Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Sealed: sealed, Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Visible: codec.ObjectRef{Kind: codec.Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}}}}}}) | |
| 81 | + | manifest, err := codec.MarshalManifest(codec.Manifest{Generation: generation, IncarnationID: id, ParentCheckpoint: parent, Name: name, Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Sealed: sealed, Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Transcript: codec.ObjectRef{Kind: codec.RecoveryTranscript, Digest: transcript.Digest, Size: uint32(len(transcript.Data))}}}}}}) | |
| 82 | 82 | if err != nil { | |
| 83 | 83 | t.Fatal(err) | |
| 84 | 84 | } | |
| 85 | - | return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, ParentCheckpoint: parent, Manifest: manifest, Objects: append(append([]ports.SnapshotObject(nil), objects...), tail, visible)} | |
| 85 | + | return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, ParentCheckpoint: parent, Manifest: manifest, Objects: append(append([]ports.SnapshotObject(nil), objects...), tail, transcript)} | |
| 86 | 86 | } | |
| 87 | - | first := makePublication(1, nil, "tail-1", "visible-1") | |
| 87 | + | first := makePublication(1, nil, "tail-1", "transcript-1") | |
| 88 | 88 | parent := &domain.CheckpointRef{Generation: 1, ManifestDigest: sha256.Sum256(first.Manifest)} | |
| 89 | - | return first, makePublication(2, parent, "tail-2", "visible-2") | |
| 89 | + | return first, makePublication(2, parent, "tail-2", "transcript-2") | |
| 90 | 90 | } | |
| 91 | 91 | ||
| 92 | 92 | func seedCompletePublication(t testing.TB, repo *Repository, publication ports.SnapshotPublication) { | |
| @@ -61,32 +61,32 @@ func largeIncrementalPublications(t testing.TB, name string, count int) (ports.S | |||||
| 61 | sealed := make([]codec.ObjectRef, 0, count) | 61 | sealed := make([]codec.ObjectRef, 0, count) | ||
| 62 | objects := make([]ports.SnapshotObject, 0, count+2) | 62 | objects := make([]ports.SnapshotObject, 0, count+2) | ||
| 63 | for i := range count { | 63 | for i := range count { | ||
| 64 | - | object, err := codec.MarshalObject(codec.HistoryChunk, fmt.Appendf(nil, "history-%d", i)) | 64 | + | object, err := codec.MarshalObject(codec.HistoryChunk, canonicalHistoryBlob(t, fmt.Sprintf("history-%d", i))) |
| 65 | if err != nil { | 65 | if err != nil { | ||
| 66 | t.Fatal(err) | 66 | t.Fatal(err) | ||
| 67 | } | 67 | } | ||
| 68 | sealed = append(sealed, codec.ObjectRef{Kind: codec.HistoryChunk, Digest: object.Digest, Size: uint32(len(object.Data))}) | 68 | sealed = append(sealed, codec.ObjectRef{Kind: codec.HistoryChunk, Digest: object.Digest, Size: uint32(len(object.Data))}) | ||
| 69 | objects = append(objects, object) | 69 | objects = append(objects, object) | ||
| 70 | } | 70 | } | ||
| 71 | - | makePublication := func(generation uint64, parent *domain.CheckpointRef, tailPayload, visiblePayload string) ports.SnapshotPublication { | 71 | + | makePublication := func(generation uint64, parent *domain.CheckpointRef, tailPayload, transcriptPayload string) ports.SnapshotPublication { |
| 72 | t.Helper() | 72 | t.Helper() | ||
| 73 | - | tail, err := codec.MarshalObject(codec.HistoryTail, []byte(tailPayload)) | 73 | + | tail, err := codec.MarshalObject(codec.HistoryTail, canonicalHistoryBlob(t, tailPayload)) |
| 74 | if err != nil { | 74 | if err != nil { | ||
| 75 | t.Fatal(err) | 75 | t.Fatal(err) | ||
| 76 | } | 76 | } | ||
| 77 | - | visible, err := codec.MarshalObject(codec.Visible, []byte(visiblePayload)) | 77 | + | transcript, err := codec.MarshalObject(codec.RecoveryTranscript, canonicalHistoryBlob(t, transcriptPayload)) |
| 78 | if err != nil { | 78 | if err != nil { | ||
| 79 | t.Fatal(err) | 79 | t.Fatal(err) | ||
| 80 | } | 80 | } | ||
| 81 | - | manifest, err := codec.MarshalManifest(codec.Manifest{Generation: generation, IncarnationID: id, ParentCheckpoint: parent, Name: name, Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Sealed: sealed, Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Visible: codec.ObjectRef{Kind: codec.Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}}}}}}) | 81 | + | manifest, err := codec.MarshalManifest(codec.Manifest{Generation: generation, IncarnationID: id, ParentCheckpoint: parent, Name: name, Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Sealed: sealed, Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Transcript: codec.ObjectRef{Kind: codec.RecoveryTranscript, Digest: transcript.Digest, Size: uint32(len(transcript.Data))}}}}}}) |
| 82 | if err != nil { | 82 | if err != nil { | ||
| 83 | t.Fatal(err) | 83 | t.Fatal(err) | ||
| 84 | } | 84 | } | ||
| 85 | - | return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, ParentCheckpoint: parent, Manifest: manifest, Objects: append(append([]ports.SnapshotObject(nil), objects...), tail, visible)} | 85 | + | return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, ParentCheckpoint: parent, Manifest: manifest, Objects: append(append([]ports.SnapshotObject(nil), objects...), tail, transcript)} |
| 86 | } | 86 | } | ||
| 87 | - | first := makePublication(1, nil, "tail-1", "visible-1") | 87 | + | first := makePublication(1, nil, "tail-1", "transcript-1") |
| 88 | parent := &domain.CheckpointRef{Generation: 1, ManifestDigest: sha256.Sum256(first.Manifest)} | 88 | parent := &domain.CheckpointRef{Generation: 1, ManifestDigest: sha256.Sum256(first.Manifest)} | ||
| 89 | - | return first, makePublication(2, parent, "tail-2", "visible-2") | 89 | + | return first, makePublication(2, parent, "tail-2", "transcript-2") |
| 90 | } | 90 | } | ||
| 91 | 91 | ||||
| 92 | func seedCompletePublication(t testing.TB, repo *Repository, publication ports.SnapshotPublication) { | 92 | func seedCompletePublication(t testing.TB, repo *Repository, publication ports.SnapshotPublication) { | ||
M internal/adapters/snapshot/repository_hardening_test.go +1 -1
| @@ -69,7 +69,7 @@ func TestValidatePublicationRejectsUnloadableAggregate(t *testing.T) { | |||
| 69 | 69 | t.Fatal(err) | |
| 70 | 70 | } | |
| 71 | 71 | manifest.Tabs[0].Panes[0].Tail.Size = uint32(maxRepositoryRead) | |
| 72 | - | manifest.Tabs[0].Panes[0].Visible.Size = uint32(maxRepositoryRead) | |
| 72 | + | manifest.Tabs[0].Panes[0].Transcript.Size = uint32(maxRepositoryRead) | |
| 73 | 73 | pub.Manifest, err = codec.MarshalManifest(manifest) | |
| 74 | 74 | if err != nil { | |
| 75 | 75 | t.Fatal(err) | |
| @@ -69,7 +69,7 @@ func TestValidatePublicationRejectsUnloadableAggregate(t *testing.T) { | |||||
| 69 | t.Fatal(err) | 69 | t.Fatal(err) | ||
| 70 | } | 70 | } | ||
| 71 | manifest.Tabs[0].Panes[0].Tail.Size = uint32(maxRepositoryRead) | 71 | manifest.Tabs[0].Panes[0].Tail.Size = uint32(maxRepositoryRead) | ||
| 72 | - | manifest.Tabs[0].Panes[0].Visible.Size = uint32(maxRepositoryRead) | 72 | + | manifest.Tabs[0].Panes[0].Transcript.Size = uint32(maxRepositoryRead) |
| 73 | pub.Manifest, err = codec.MarshalManifest(manifest) | 73 | pub.Manifest, err = codec.MarshalManifest(manifest) | ||
| 74 | if err != nil { | 74 | if err != nil { | ||
| 75 | t.Fatal(err) | 75 | t.Fatal(err) | ||
M internal/adapters/snapshot/repository_test.go +4 -4
| @@ -69,19 +69,19 @@ func publicationWithCurrentParent(t *testing.T, repo *Repository, publication po | |||
| 69 | 69 | func repositoryPublication(t *testing.T, name string, generation uint64, payload []byte) ports.SnapshotPublication { | |
| 70 | 70 | t.Helper() | |
| 71 | 71 | id := testIncarnationID(name) | |
| 72 | - | tail, err := codec.MarshalObject(codec.HistoryTail, payload) | |
| 72 | + | tail, err := codec.MarshalObject(codec.HistoryTail, canonicalHistoryBlob(t, string(payload))) | |
| 73 | 73 | if err != nil { | |
| 74 | 74 | t.Fatal(err) | |
| 75 | 75 | } | |
| 76 | - | visible, err := codec.MarshalObject(codec.Visible, []byte("visible")) | |
| 76 | + | transcript, err := codec.MarshalObject(codec.RecoveryTranscript, canonicalHistoryBlob(t, "transcript")) | |
| 77 | 77 | if err != nil { | |
| 78 | 78 | t.Fatal(err) | |
| 79 | 79 | } | |
| 80 | - | manifest, err := codec.MarshalManifest(codec.Manifest{Generation: generation, IncarnationID: id, Name: name, Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Visible: codec.ObjectRef{Kind: codec.Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}}}}}}) | |
| 80 | + | manifest, err := codec.MarshalManifest(codec.Manifest{Generation: generation, IncarnationID: id, Name: name, Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Transcript: codec.ObjectRef{Kind: codec.RecoveryTranscript, Digest: transcript.Digest, Size: uint32(len(transcript.Data))}}}}}}) | |
| 81 | 81 | if err != nil { | |
| 82 | 82 | t.Fatal(err) | |
| 83 | 83 | } | |
| 84 | - | return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, Manifest: manifest, Objects: []ports.SnapshotObject{tail, visible}} | |
| 84 | + | return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, Manifest: manifest, Objects: []ports.SnapshotObject{tail, transcript}} | |
| 85 | 85 | } | |
| 86 | 86 | ||
| 87 | 87 | func testIncarnationID(name string) domain.IncarnationID { | |
| @@ -69,19 +69,19 @@ func publicationWithCurrentParent(t *testing.T, repo *Repository, publication po | |||||
| 69 | func repositoryPublication(t *testing.T, name string, generation uint64, payload []byte) ports.SnapshotPublication { | 69 | func repositoryPublication(t *testing.T, name string, generation uint64, payload []byte) ports.SnapshotPublication { | ||
| 70 | t.Helper() | 70 | t.Helper() | ||
| 71 | id := testIncarnationID(name) | 71 | id := testIncarnationID(name) | ||
| 72 | - | tail, err := codec.MarshalObject(codec.HistoryTail, payload) | 72 | + | tail, err := codec.MarshalObject(codec.HistoryTail, canonicalHistoryBlob(t, string(payload))) |
| 73 | if err != nil { | 73 | if err != nil { | ||
| 74 | t.Fatal(err) | 74 | t.Fatal(err) | ||
| 75 | } | 75 | } | ||
| 76 | - | visible, err := codec.MarshalObject(codec.Visible, []byte("visible")) | 76 | + | transcript, err := codec.MarshalObject(codec.RecoveryTranscript, canonicalHistoryBlob(t, "transcript")) |
| 77 | if err != nil { | 77 | if err != nil { | ||
| 78 | t.Fatal(err) | 78 | t.Fatal(err) | ||
| 79 | } | 79 | } | ||
| 80 | - | manifest, err := codec.MarshalManifest(codec.Manifest{Generation: generation, IncarnationID: id, Name: name, Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Visible: codec.ObjectRef{Kind: codec.Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}}}}}}) | 80 | + | manifest, err := codec.MarshalManifest(codec.Manifest{Generation: generation, IncarnationID: id, Name: name, Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Transcript: codec.ObjectRef{Kind: codec.RecoveryTranscript, Digest: transcript.Digest, Size: uint32(len(transcript.Data))}}}}}}) |
| 81 | if err != nil { | 81 | if err != nil { | ||
| 82 | t.Fatal(err) | 82 | t.Fatal(err) | ||
| 83 | } | 83 | } | ||
| 84 | - | return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, Manifest: manifest, Objects: []ports.SnapshotObject{tail, visible}} | 84 | + | return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, Manifest: manifest, Objects: []ports.SnapshotObject{tail, transcript}} |
| 85 | } | 85 | } | ||
| 86 | 86 | ||||
| 87 | func testIncarnationID(name string) domain.IncarnationID { | 87 | func testIncarnationID(name string) domain.IncarnationID { | ||
M internal/adapters/snapshot/test_helpers_test.go +22 -0
| @@ -3,9 +3,31 @@ package snapshot | |||
| 3 | 3 | import ( | |
| 4 | 4 | "path/filepath" | |
| 5 | 5 | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | "github.com/bnema/vev/pkg/vt" | |
| 6 | 9 | ) | |
| 7 | 10 | ||
| 8 | 11 | func privateDir(t *testing.T) string { | |
| 9 | 12 | t.Helper() | |
| 10 | 13 | return filepath.Join(t.TempDir(), "vev") | |
| 11 | 14 | } | |
| 15 | + | ||
| 16 | + | func canonicalHistoryBlob(t testing.TB, text string) []byte { | |
| 17 | + | t.Helper() | |
| 18 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 1, ChunkRows: 1}) | |
| 19 | + | if text != "" { | |
| 20 | + | row := make([]renderer.Cell, 0, len(text)) | |
| 21 | + | for _, r := range text { | |
| 22 | + | row = append(row, renderer.Cell{Rune: r}) | |
| 23 | + | } | |
| 24 | + | if err := history.Append(row, vt.LineBound{End: len(row)}); err != nil { | |
| 25 | + | t.Fatal(err) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | blob, err := vt.MarshalHistory(history.SealAndView()) | |
| 29 | + | if err != nil { | |
| 30 | + | t.Fatal(err) | |
| 31 | + | } | |
| 32 | + | return blob | |
| 33 | + | } | |
| @@ -3,9 +3,31 @@ package snapshot | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "path/filepath" | 4 | "path/filepath" | ||
| 5 | "testing" | 5 | "testing" | ||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | + | "github.com/bnema/vev/pkg/vt" | |||
| 6 | ) | 9 | ) | ||
| 7 | 10 | ||||
| 8 | func privateDir(t *testing.T) string { | 11 | func privateDir(t *testing.T) string { | ||
| 9 | t.Helper() | 12 | t.Helper() | ||
| 10 | return filepath.Join(t.TempDir(), "vev") | 13 | return filepath.Join(t.TempDir(), "vev") | ||
| 11 | } | 14 | } | ||
| 15 | + | ||||
| 16 | + | func canonicalHistoryBlob(t testing.TB, text string) []byte { | |||
| 17 | + | t.Helper() | |||
| 18 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 1, ChunkRows: 1}) | |||
| 19 | + | if text != "" { | |||
| 20 | + | row := make([]renderer.Cell, 0, len(text)) | |||
| 21 | + | for _, r := range text { | |||
| 22 | + | row = append(row, renderer.Cell{Rune: r}) | |||
| 23 | + | } | |||
| 24 | + | if err := history.Append(row, vt.LineBound{End: len(row)}); err != nil { | |||
| 25 | + | t.Fatal(err) | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | blob, err := vt.MarshalHistory(history.SealAndView()) | |||
| 29 | + | if err != nil { | |||
| 30 | + | t.Fatal(err) | |||
| 31 | + | } | |||
| 32 | + | return blob | |||
| 33 | + | } | |||
M internal/usecase/daemon/coderabbit_regression_test.go +95 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | 6 | "errors" | |
| 7 | + | "log/slog" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "syscall" | |
| 9 | 10 | "testing" | |
| @@ -13,8 +14,34 @@ import ( | |||
| 13 | 14 | "github.com/bnema/vev/internal/domain" | |
| 14 | 15 | "github.com/bnema/vev/internal/ports" | |
| 15 | 16 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | |
| 17 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 16 | 18 | ) | |
| 17 | 19 | ||
| 20 | + | func TestRestoreAmbiguousBadVersionLoadFailurePreservesCheckpoint(t *testing.T) { | |
| 21 | + | record := durableRecoveryRecord(0) | |
| 22 | + | repository := &durableRecoveryRepository{ | |
| 23 | + | errors: map[string]error{record.Name: errors.Join(snapcodec.ErrBadVersion, syscall.ENOSPC)}, | |
| 24 | + | loads: make(map[string]int), | |
| 25 | + | repairs: make(map[string]domain.CheckpointRef), | |
| 26 | + | } | |
| 27 | + | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository) | |
| 28 | + | ||
| 29 | + | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | |
| 30 | + | ||
| 31 | + | persisted, ok, err := catalogue.Record(record.Name) | |
| 32 | + | require.NoError(t, err) | |
| 33 | + | require.True(t, ok) | |
| 34 | + | require.Equal(t, record.IncarnationID, persisted.IncarnationID) | |
| 35 | + | require.Equal(t, record.Committed, persisted.Committed) | |
| 36 | + | require.Equal(t, "checkpoint load failed", persisted.DegradedReason) | |
| 37 | + | require.Empty(t, repository.deleted) | |
| 38 | + | d.mu.Lock() | |
| 39 | + | entry := d.stopped[record.Name] | |
| 40 | + | d.mu.Unlock() | |
| 41 | + | require.Equal(t, ports.SessionBroken, entry.state) | |
| 42 | + | require.Equal(t, persisted, entry.record) | |
| 43 | + | } | |
| 44 | + | ||
| 18 | 45 | func TestRestoreTransientLoadFailureBecomesDiscardable(t *testing.T) { | |
| 19 | 46 | record := durableRecoveryRecord(0) | |
| 20 | 47 | repository := &durableRecoveryRepository{ | |
| @@ -96,6 +123,74 @@ func TestRestoreFailureCannotOverwriteDiscardReplacement(t *testing.T) { | |||
| 96 | 123 | require.Equal(t, ports.SessionStopped, entry.state) | |
| 97 | 124 | } | |
| 98 | 125 | ||
| 126 | + | func TestStaleIncompatibleRestoreCannotOverwriteNewerAuthority(t *testing.T) { | |
| 127 | + | tests := []struct { | |
| 128 | + | name string | |
| 129 | + | replacement func(domain.CatalogueRecord) domain.CatalogueRecord | |
| 130 | + | state ports.SessionState | |
| 131 | + | }{ | |
| 132 | + | { | |
| 133 | + | name: "newer checkpoint", | |
| 134 | + | replacement: func(record domain.CatalogueRecord) domain.CatalogueRecord { | |
| 135 | + | ref := *record.Committed | |
| 136 | + | ref.Generation++ | |
| 137 | + | ref.ManifestDigest[0]++ | |
| 138 | + | record.Committed = &ref | |
| 139 | + | return record | |
| 140 | + | }, | |
| 141 | + | state: ports.SessionStopped, | |
| 142 | + | }, | |
| 143 | + | { | |
| 144 | + | name: "newer incarnation", | |
| 145 | + | replacement: func(record domain.CatalogueRecord) domain.CatalogueRecord { | |
| 146 | + | record.IncarnationID = domain.IncarnationID{9} | |
| 147 | + | return record | |
| 148 | + | }, | |
| 149 | + | state: ports.SessionStopped, | |
| 150 | + | }, | |
| 151 | + | { | |
| 152 | + | name: "degraded authority", | |
| 153 | + | replacement: func(record domain.CatalogueRecord) domain.CatalogueRecord { | |
| 154 | + | record.DegradedReason = "newer restore decision" | |
| 155 | + | return record | |
| 156 | + | }, | |
| 157 | + | state: ports.SessionBroken, | |
| 158 | + | }, | |
| 159 | + | } | |
| 160 | + | for _, tt := range tests { | |
| 161 | + | t.Run(tt.name, func(t *testing.T) { | |
| 162 | + | record := durableRecoveryRecord(0) | |
| 163 | + | replacement := tt.replacement(record) | |
| 164 | + | repository := &durableRecoveryRepository{ | |
| 165 | + | errors: map[string]error{record.Name: snapcodec.ErrBadVersion}, | |
| 166 | + | loads: make(map[string]int), | |
| 167 | + | repairs: make(map[string]domain.CheckpointRef), | |
| 168 | + | } | |
| 169 | + | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository) | |
| 170 | + | var logs bytes.Buffer | |
| 171 | + | d.log = slog.New(slog.NewJSONHandler(&logs, nil)) | |
| 172 | + | repository.onLoad = func() { | |
| 173 | + | require.NoError(t, catalogue.Replace(record.Name, replacement)) | |
| 174 | + | d.setStoppedRecovery(replacement, tt.state) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | |
| 178 | + | ||
| 179 | + | persisted, ok, err := catalogue.Record(record.Name) | |
| 180 | + | require.NoError(t, err) | |
| 181 | + | require.True(t, ok) | |
| 182 | + | require.Equal(t, replacement, persisted) | |
| 183 | + | d.mu.Lock() | |
| 184 | + | entry := d.stopped[record.Name] | |
| 185 | + | d.mu.Unlock() | |
| 186 | + | require.Equal(t, tt.state, entry.state) | |
| 187 | + | require.Equal(t, replacement, entry.record) | |
| 188 | + | require.Empty(t, repository.deleted) | |
| 189 | + | daemonRequireEvent(t, daemonJSONLogs(t, logs.Bytes()), "snapshot_incompatible_reset_superseded") | |
| 190 | + | }) | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 99 | 194 | func TestFinishRecordRestoreAlwaysClosesProvidedBarrier(t *testing.T) { | |
| 100 | 195 | d := newTestDaemon(t, nil, stubClock{}) | |
| 101 | 196 | done := make(chan struct{}) | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | + | "log/slog" | |||
| 7 | "sync" | 8 | "sync" | ||
| 8 | "syscall" | 9 | "syscall" | ||
| 9 | "testing" | 10 | "testing" | ||
| @@ -13,8 +14,34 @@ import ( | |||||
| 13 | "github.com/bnema/vev/internal/domain" | 14 | "github.com/bnema/vev/internal/domain" | ||
| 14 | "github.com/bnema/vev/internal/ports" | 15 | "github.com/bnema/vev/internal/ports" | ||
| 15 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | 16 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | ||
| 17 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 16 | ) | 18 | ) | ||
| 17 | 19 | ||||
| 20 | + | func TestRestoreAmbiguousBadVersionLoadFailurePreservesCheckpoint(t *testing.T) { | |||
| 21 | + | record := durableRecoveryRecord(0) | |||
| 22 | + | repository := &durableRecoveryRepository{ | |||
| 23 | + | errors: map[string]error{record.Name: errors.Join(snapcodec.ErrBadVersion, syscall.ENOSPC)}, | |||
| 24 | + | loads: make(map[string]int), | |||
| 25 | + | repairs: make(map[string]domain.CheckpointRef), | |||
| 26 | + | } | |||
| 27 | + | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository) | |||
| 28 | + | ||||
| 29 | + | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | |||
| 30 | + | ||||
| 31 | + | persisted, ok, err := catalogue.Record(record.Name) | |||
| 32 | + | require.NoError(t, err) | |||
| 33 | + | require.True(t, ok) | |||
| 34 | + | require.Equal(t, record.IncarnationID, persisted.IncarnationID) | |||
| 35 | + | require.Equal(t, record.Committed, persisted.Committed) | |||
| 36 | + | require.Equal(t, "checkpoint load failed", persisted.DegradedReason) | |||
| 37 | + | require.Empty(t, repository.deleted) | |||
| 38 | + | d.mu.Lock() | |||
| 39 | + | entry := d.stopped[record.Name] | |||
| 40 | + | d.mu.Unlock() | |||
| 41 | + | require.Equal(t, ports.SessionBroken, entry.state) | |||
| 42 | + | require.Equal(t, persisted, entry.record) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 18 | func TestRestoreTransientLoadFailureBecomesDiscardable(t *testing.T) { | 45 | func TestRestoreTransientLoadFailureBecomesDiscardable(t *testing.T) { | ||
| 19 | record := durableRecoveryRecord(0) | 46 | record := durableRecoveryRecord(0) | ||
| 20 | repository := &durableRecoveryRepository{ | 47 | repository := &durableRecoveryRepository{ | ||
| @@ -96,6 +123,74 @@ func TestRestoreFailureCannotOverwriteDiscardReplacement(t *testing.T) { | |||||
| 96 | require.Equal(t, ports.SessionStopped, entry.state) | 123 | require.Equal(t, ports.SessionStopped, entry.state) | ||
| 97 | } | 124 | } | ||
| 98 | 125 | ||||
| 126 | + | func TestStaleIncompatibleRestoreCannotOverwriteNewerAuthority(t *testing.T) { | |||
| 127 | + | tests := []struct { | |||
| 128 | + | name string | |||
| 129 | + | replacement func(domain.CatalogueRecord) domain.CatalogueRecord | |||
| 130 | + | state ports.SessionState | |||
| 131 | + | }{ | |||
| 132 | + | { | |||
| 133 | + | name: "newer checkpoint", | |||
| 134 | + | replacement: func(record domain.CatalogueRecord) domain.CatalogueRecord { | |||
| 135 | + | ref := *record.Committed | |||
| 136 | + | ref.Generation++ | |||
| 137 | + | ref.ManifestDigest[0]++ | |||
| 138 | + | record.Committed = &ref | |||
| 139 | + | return record | |||
| 140 | + | }, | |||
| 141 | + | state: ports.SessionStopped, | |||
| 142 | + | }, | |||
| 143 | + | { | |||
| 144 | + | name: "newer incarnation", | |||
| 145 | + | replacement: func(record domain.CatalogueRecord) domain.CatalogueRecord { | |||
| 146 | + | record.IncarnationID = domain.IncarnationID{9} | |||
| 147 | + | return record | |||
| 148 | + | }, | |||
| 149 | + | state: ports.SessionStopped, | |||
| 150 | + | }, | |||
| 151 | + | { | |||
| 152 | + | name: "degraded authority", | |||
| 153 | + | replacement: func(record domain.CatalogueRecord) domain.CatalogueRecord { | |||
| 154 | + | record.DegradedReason = "newer restore decision" | |||
| 155 | + | return record | |||
| 156 | + | }, | |||
| 157 | + | state: ports.SessionBroken, | |||
| 158 | + | }, | |||
| 159 | + | } | |||
| 160 | + | for _, tt := range tests { | |||
| 161 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 162 | + | record := durableRecoveryRecord(0) | |||
| 163 | + | replacement := tt.replacement(record) | |||
| 164 | + | repository := &durableRecoveryRepository{ | |||
| 165 | + | errors: map[string]error{record.Name: snapcodec.ErrBadVersion}, | |||
| 166 | + | loads: make(map[string]int), | |||
| 167 | + | repairs: make(map[string]domain.CheckpointRef), | |||
| 168 | + | } | |||
| 169 | + | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository) | |||
| 170 | + | var logs bytes.Buffer | |||
| 171 | + | d.log = slog.New(slog.NewJSONHandler(&logs, nil)) | |||
| 172 | + | repository.onLoad = func() { | |||
| 173 | + | require.NoError(t, catalogue.Replace(record.Name, replacement)) | |||
| 174 | + | d.setStoppedRecovery(replacement, tt.state) | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | |||
| 178 | + | ||||
| 179 | + | persisted, ok, err := catalogue.Record(record.Name) | |||
| 180 | + | require.NoError(t, err) | |||
| 181 | + | require.True(t, ok) | |||
| 182 | + | require.Equal(t, replacement, persisted) | |||
| 183 | + | d.mu.Lock() | |||
| 184 | + | entry := d.stopped[record.Name] | |||
| 185 | + | d.mu.Unlock() | |||
| 186 | + | require.Equal(t, tt.state, entry.state) | |||
| 187 | + | require.Equal(t, replacement, entry.record) | |||
| 188 | + | require.Empty(t, repository.deleted) | |||
| 189 | + | daemonRequireEvent(t, daemonJSONLogs(t, logs.Bytes()), "snapshot_incompatible_reset_superseded") | |||
| 190 | + | }) | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | ||||
| 99 | func TestFinishRecordRestoreAlwaysClosesProvidedBarrier(t *testing.T) { | 194 | func TestFinishRecordRestoreAlwaysClosesProvidedBarrier(t *testing.T) { | ||
| 100 | d := newTestDaemon(t, nil, stubClock{}) | 195 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 101 | done := make(chan struct{}) | 196 | done := make(chan struct{}) | ||
M internal/usecase/daemon/durable_recovery_test.go +188 -1
| @@ -29,6 +29,7 @@ type durableRecoveryCatalogue struct { | |||
| 29 | 29 | mu sync.Mutex | |
| 30 | 30 | records map[string]domain.CatalogueRecord | |
| 31 | 31 | metadataUpdates []domain.CatalogueMetadataUpdate | |
| 32 | + | replaceErr error | |
| 32 | 33 | } | |
| 33 | 34 | ||
| 34 | 35 | type recordErrorCatalogue struct { | |
| @@ -75,6 +76,9 @@ func mustDurableRecords(t *testing.T, c *durableRecoveryCatalogue) []domain.Cata | |||
| 75 | 76 | func (c *durableRecoveryCatalogue) Replace(name string, record domain.CatalogueRecord) error { | |
| 76 | 77 | c.mu.Lock() | |
| 77 | 78 | defer c.mu.Unlock() | |
| 79 | + | if c.replaceErr != nil { | |
| 80 | + | return c.replaceErr | |
| 81 | + | } | |
| 78 | 82 | if name != record.Name { | |
| 79 | 83 | return errors.New("catalogue key mismatch") | |
| 80 | 84 | } | |
| @@ -125,7 +129,10 @@ type durableRecoveryRepository struct { | |||
| 125 | 129 | errors map[string]error | |
| 126 | 130 | loads map[string]int | |
| 127 | 131 | repairs map[string]domain.CheckpointRef | |
| 132 | + | deleted []domain.IncarnationID | |
| 133 | + | onLoad func() | |
| 128 | 134 | repairErr error | |
| 135 | + | deleteErr error | |
| 129 | 136 | repairCalls int | |
| 130 | 137 | } | |
| 131 | 138 | ||
| @@ -136,6 +143,9 @@ func (r *durableRecoveryRepository) LoadCheckpoint(_ context.Context, id domain. | |||
| 136 | 143 | r.loads = make(map[string]int) | |
| 137 | 144 | } | |
| 138 | 145 | r.loads[name]++ | |
| 146 | + | if r.onLoad != nil { | |
| 147 | + | r.onLoad() | |
| 148 | + | } | |
| 139 | 149 | if err := r.errors[name]; err != nil { | |
| 140 | 150 | return ports.SnapshotGeneration{}, err | |
| 141 | 151 | } | |
| @@ -160,6 +170,13 @@ func (r *durableRecoveryRepository) ReconcileCheckpoint(_ context.Context, id do | |||
| 160 | 170 | return nil | |
| 161 | 171 | } | |
| 162 | 172 | ||
| 173 | + | func (r *durableRecoveryRepository) DeleteIncarnation(_ context.Context, id domain.IncarnationID) error { | |
| 174 | + | r.mu.Lock() | |
| 175 | + | defer r.mu.Unlock() | |
| 176 | + | r.deleted = append(r.deleted, id) | |
| 177 | + | return r.deleteErr | |
| 178 | + | } | |
| 179 | + | ||
| 163 | 180 | func durableRecoveryRecord(index int) domain.CatalogueRecord { | |
| 164 | 181 | id := domain.IncarnationID{0: byte(index>>8 + 1), 1: byte(index + 1)} | |
| 165 | 182 | ref := domain.CheckpointRef{Generation: uint64(index + 1), ManifestDigest: [32]byte{byte(index%255 + 1)}} | |
| @@ -194,7 +211,7 @@ func validGeneration(t testing.TB, record domain.CatalogueRecord) ports.Snapshot | |||
| 194 | 211 | func newDurableRecoveryDaemon(t testing.TB, records []domain.CatalogueRecord, repository ports.SnapshotRepository) (*Daemon, *durableRecoveryCatalogue) { | |
| 195 | 212 | t.Helper() | |
| 196 | 213 | catalogue := newDurableRecoveryCatalogue(records) | |
| 197 | - | coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil) | |
| 214 | + | coordinator := recoveryusecase.NewCoordinator(catalogue, repository, bytes.NewReader(bytes.Repeat([]byte{0xa5}, 16))) | |
| 198 | 215 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(catalogue, records), WithSnapshotRepository(repository), WithRecoveryCoordinator(coordinator)) | |
| 199 | 216 | d.serveCtx, d.serveCancel = context.WithCancel(context.Background()) | |
| 200 | 217 | t.Cleanup(d.serveCancel) | |
| @@ -289,6 +306,141 @@ func TestCatalogueRestoreIndependent(t *testing.T) { | |||
| 289 | 306 | ||
| 290 | 307 | } | |
| 291 | 308 | ||
| 309 | + | func TestCatalogueRestoreResetsOnlyIncompatibleCheckpoint(t *testing.T) { | |
| 310 | + | incompatible := durableRecoveryRecord(0) | |
| 311 | + | healthy := durableRecoveryRecord(1) | |
| 312 | + | repository := &durableRecoveryRepository{ | |
| 313 | + | generations: map[string]ports.SnapshotGeneration{healthy.Name: validGeneration(t, healthy)}, | |
| 314 | + | errors: map[string]error{incompatible.Name: fmt.Errorf("load envelope: %w", snapcodec.ErrBadVersion)}, | |
| 315 | + | loads: make(map[string]int), | |
| 316 | + | repairs: make(map[string]domain.CheckpointRef), | |
| 317 | + | } | |
| 318 | + | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{incompatible, healthy}, repository) | |
| 319 | + | ||
| 320 | + | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | |
| 321 | + | ||
| 322 | + | fresh, ok, err := catalogue.Record(incompatible.Name) | |
| 323 | + | require.NoError(t, err) | |
| 324 | + | require.True(t, ok) | |
| 325 | + | require.Equal(t, domain.IncarnationID(bytes.Repeat([]byte{0xa5}, 16)), fresh.IncarnationID) | |
| 326 | + | require.Nil(t, fresh.Committed) | |
| 327 | + | require.Empty(t, fresh.DegradedReason) | |
| 328 | + | require.Empty(t, fresh.TabNames) | |
| 329 | + | require.Equal(t, []domain.IncarnationID{incompatible.IncarnationID}, repository.deleted) | |
| 330 | + | require.Equal(t, ports.SessionStopped, d.stopped[incompatible.Name].state) | |
| 331 | + | require.Equal(t, fresh, d.stopped[incompatible.Name].record) | |
| 332 | + | ||
| 333 | + | restoredSibling, ok, err := catalogue.Record(healthy.Name) | |
| 334 | + | require.NoError(t, err) | |
| 335 | + | require.True(t, ok) | |
| 336 | + | require.Equal(t, healthy, restoredSibling) | |
| 337 | + | require.Equal(t, ports.SessionStopped, d.stopped[healthy.Name].state) | |
| 338 | + | require.Equal(t, 1, repository.loads[healthy.Name]) | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | func TestIncompatibleCheckpointDeleteFailurePublishesFreshStoppedAuthority(t *testing.T) { | |
| 342 | + | record := durableRecoveryRecord(0) | |
| 343 | + | deleteErr := errors.New("delete old incarnation") | |
| 344 | + | repository := &durableRecoveryRepository{ | |
| 345 | + | errors: map[string]error{record.Name: snapcodec.ErrBadVersion}, | |
| 346 | + | loads: make(map[string]int), | |
| 347 | + | repairs: make(map[string]domain.CheckpointRef), | |
| 348 | + | deleteErr: deleteErr, | |
| 349 | + | } | |
| 350 | + | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository) | |
| 351 | + | d.mu.Lock() | |
| 352 | + | restoreDone := d.stopped[record.Name].restoreDone | |
| 353 | + | d.mu.Unlock() | |
| 354 | + | ||
| 355 | + | restoreErr := d.restoreRecord(context.Background(), record) | |
| 356 | + | require.NoError(t, restoreErr, "post-commit cleanup failure must not fail the authority transition") | |
| 357 | + | ||
| 358 | + | fresh, ok, err := catalogue.Record(record.Name) | |
| 359 | + | require.NoError(t, err) | |
| 360 | + | require.True(t, ok) | |
| 361 | + | require.NotEqual(t, record.IncarnationID, fresh.IncarnationID) | |
| 362 | + | require.Nil(t, fresh.Committed) | |
| 363 | + | require.Empty(t, fresh.DegradedReason) | |
| 364 | + | d.mu.Lock() | |
| 365 | + | entry := d.stopped[record.Name] | |
| 366 | + | d.mu.Unlock() | |
| 367 | + | require.Equal(t, ports.SessionStopped, entry.state) | |
| 368 | + | require.Equal(t, fresh, entry.record) | |
| 369 | + | require.Equal(t, restoreDone, entry.restoreDone) | |
| 370 | + | select { | |
| 371 | + | case <-entry.restoreDone: | |
| 372 | + | t.Fatal("direct restore closed its coordinator-owned completion barrier") | |
| 373 | + | default: | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | d.finishRecordRestore(record, restoreErr, restoreDone) | |
| 377 | + | d.mu.Lock() | |
| 378 | + | entry = d.stopped[record.Name] | |
| 379 | + | d.mu.Unlock() | |
| 380 | + | require.Equal(t, ports.SessionStopped, entry.state) | |
| 381 | + | require.Equal(t, fresh, entry.record) | |
| 382 | + | select { | |
| 383 | + | case <-entry.restoreDone: | |
| 384 | + | default: | |
| 385 | + | t.Fatal("restore completion was not signaled") | |
| 386 | + | } | |
| 387 | + | require.Equal(t, []domain.IncarnationID{record.IncarnationID}, repository.deleted) | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | func TestIncompatibleCheckpointPreCommitFailureReturnsError(t *testing.T) { | |
| 391 | + | record := durableRecoveryRecord(0) | |
| 392 | + | cause := errors.New("catalogue replace failed") | |
| 393 | + | repository := &durableRecoveryRepository{ | |
| 394 | + | errors: map[string]error{record.Name: snapcodec.ErrBadVersion}, | |
| 395 | + | loads: make(map[string]int), | |
| 396 | + | repairs: make(map[string]domain.CheckpointRef), | |
| 397 | + | } | |
| 398 | + | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository) | |
| 399 | + | catalogue.replaceErr = cause | |
| 400 | + | ||
| 401 | + | err := d.restoreRecord(context.Background(), record) | |
| 402 | + | ||
| 403 | + | require.ErrorIs(t, err, cause) | |
| 404 | + | persisted, ok, recordErr := catalogue.Record(record.Name) | |
| 405 | + | require.NoError(t, recordErr) | |
| 406 | + | require.True(t, ok) | |
| 407 | + | require.Equal(t, record, persisted) | |
| 408 | + | require.Empty(t, repository.deleted) | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | func TestRestoreLoadFailuresRetainCheckpointWithoutDeletion(t *testing.T) { | |
| 412 | + | tests := []struct { | |
| 413 | + | name string | |
| 414 | + | err error | |
| 415 | + | }{ | |
| 416 | + | {name: "corruption", err: errors.New("corrupt checkpoint")}, | |
| 417 | + | {name: "generic", err: errors.New("load failed")}, | |
| 418 | + | {name: "io", err: &os.PathError{Op: "open", Path: "/snapshot", Err: syscall.EACCES}}, | |
| 419 | + | } | |
| 420 | + | for _, tt := range tests { | |
| 421 | + | t.Run(tt.name, func(t *testing.T) { | |
| 422 | + | record := durableRecoveryRecord(0) | |
| 423 | + | repository := &durableRecoveryRepository{ | |
| 424 | + | errors: map[string]error{record.Name: tt.err}, | |
| 425 | + | loads: make(map[string]int), | |
| 426 | + | repairs: make(map[string]domain.CheckpointRef), | |
| 427 | + | } | |
| 428 | + | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository) | |
| 429 | + | ||
| 430 | + | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | |
| 431 | + | ||
| 432 | + | persisted, ok, err := catalogue.Record(record.Name) | |
| 433 | + | require.NoError(t, err) | |
| 434 | + | require.True(t, ok) | |
| 435 | + | require.Equal(t, record.IncarnationID, persisted.IncarnationID) | |
| 436 | + | require.Equal(t, record.Committed, persisted.Committed) | |
| 437 | + | require.Equal(t, "checkpoint validation failed", persisted.DegradedReason) | |
| 438 | + | require.Equal(t, ports.SessionBroken, d.stopped[record.Name].state) | |
| 439 | + | require.Empty(t, repository.deleted) | |
| 440 | + | }) | |
| 441 | + | } | |
| 442 | + | } | |
| 443 | + | ||
| 292 | 444 | func TestCatalogueRestoreAggregateOver4096(t *testing.T) { | |
| 293 | 445 | const count = 4200 | |
| 294 | 446 | records := make([]domain.CatalogueRecord, count) | |
| @@ -386,6 +538,41 @@ type temporaryRestoreError struct{} | |||
| 386 | 538 | func (temporaryRestoreError) Error() string { return "temporary restore failure" } | |
| 387 | 539 | func (temporaryRestoreError) Temporary() bool { return true } | |
| 388 | 540 | ||
| 541 | + | type badVersionLookalikeError struct{} | |
| 542 | + | ||
| 543 | + | func (badVersionLookalikeError) Error() string { return "bad version lookalike" } | |
| 544 | + | func (badVersionLookalikeError) Is(target error) bool { return target == snapcodec.ErrBadVersion } | |
| 545 | + | ||
| 546 | + | type cyclicUnwrapError struct{} | |
| 547 | + | ||
| 548 | + | func (*cyclicUnwrapError) Error() string { return "cyclic unwrap" } | |
| 549 | + | func (e *cyclicUnwrapError) Unwrap() error { | |
| 550 | + | return e | |
| 551 | + | } | |
| 552 | + | ||
| 553 | + | func TestUnambiguousBadVersionErrorClassification(t *testing.T) { | |
| 554 | + | tests := []struct { | |
| 555 | + | name string | |
| 556 | + | err error | |
| 557 | + | incompatible bool | |
| 558 | + | }{ | |
| 559 | + | {name: "nil"}, | |
| 560 | + | {name: "exact sentinel", err: snapcodec.ErrBadVersion, incompatible: true}, | |
| 561 | + | {name: "single cause chain", err: fmt.Errorf("load checkpoint: %w", fmt.Errorf("decode manifest: %w", snapcodec.ErrBadVersion)), incompatible: true}, | |
| 562 | + | {name: "single entry multi cause", err: errors.Join(snapcodec.ErrBadVersion)}, | |
| 563 | + | {name: "joined retryable branch", err: errors.Join(snapcodec.ErrBadVersion, syscall.ENOSPC)}, | |
| 564 | + | {name: "custom Is lookalike", err: badVersionLookalikeError{}}, | |
| 565 | + | {name: "wrapped custom Is lookalike", err: fmt.Errorf("load checkpoint: %w", badVersionLookalikeError{})}, | |
| 566 | + | {name: "chain ending elsewhere", err: fmt.Errorf("load checkpoint: %w", errors.New("corrupt checkpoint"))}, | |
| 567 | + | {name: "cyclic custom chain", err: &cyclicUnwrapError{}}, | |
| 568 | + | } | |
| 569 | + | for _, tt := range tests { | |
| 570 | + | t.Run(tt.name, func(t *testing.T) { | |
| 571 | + | require.Equal(t, tt.incompatible, isUnambiguousBadVersionError(tt.err)) | |
| 572 | + | }) | |
| 573 | + | } | |
| 574 | + | } | |
| 575 | + | ||
| 389 | 576 | func TestRetryableRestoreLoadErrorClassification(t *testing.T) { | |
| 390 | 577 | tests := []struct { | |
| 391 | 578 | name string | |
| @@ -29,6 +29,7 @@ type durableRecoveryCatalogue struct { | |||||
| 29 | mu sync.Mutex | 29 | mu sync.Mutex | ||
| 30 | records map[string]domain.CatalogueRecord | 30 | records map[string]domain.CatalogueRecord | ||
| 31 | metadataUpdates []domain.CatalogueMetadataUpdate | 31 | metadataUpdates []domain.CatalogueMetadataUpdate | ||
| 32 | + | replaceErr error | |||
| 32 | } | 33 | } | ||
| 33 | 34 | ||||
| 34 | type recordErrorCatalogue struct { | 35 | type recordErrorCatalogue struct { | ||
| @@ -75,6 +76,9 @@ func mustDurableRecords(t *testing.T, c *durableRecoveryCatalogue) []domain.Cata | |||||
| 75 | func (c *durableRecoveryCatalogue) Replace(name string, record domain.CatalogueRecord) error { | 76 | func (c *durableRecoveryCatalogue) Replace(name string, record domain.CatalogueRecord) error { | ||
| 76 | c.mu.Lock() | 77 | c.mu.Lock() | ||
| 77 | defer c.mu.Unlock() | 78 | defer c.mu.Unlock() | ||
| 79 | + | if c.replaceErr != nil { | |||
| 80 | + | return c.replaceErr | |||
| 81 | + | } | |||
| 78 | if name != record.Name { | 82 | if name != record.Name { | ||
| 79 | return errors.New("catalogue key mismatch") | 83 | return errors.New("catalogue key mismatch") | ||
| 80 | } | 84 | } | ||
| @@ -125,7 +129,10 @@ type durableRecoveryRepository struct { | |||||
| 125 | errors map[string]error | 129 | errors map[string]error | ||
| 126 | loads map[string]int | 130 | loads map[string]int | ||
| 127 | repairs map[string]domain.CheckpointRef | 131 | repairs map[string]domain.CheckpointRef | ||
| 132 | + | deleted []domain.IncarnationID | |||
| 133 | + | onLoad func() | |||
| 128 | repairErr error | 134 | repairErr error | ||
| 135 | + | deleteErr error | |||
| 129 | repairCalls int | 136 | repairCalls int | ||
| 130 | } | 137 | } | ||
| 131 | 138 | ||||
| @@ -136,6 +143,9 @@ func (r *durableRecoveryRepository) LoadCheckpoint(_ context.Context, id domain. | |||||
| 136 | r.loads = make(map[string]int) | 143 | r.loads = make(map[string]int) | ||
| 137 | } | 144 | } | ||
| 138 | r.loads[name]++ | 145 | r.loads[name]++ | ||
| 146 | + | if r.onLoad != nil { | |||
| 147 | + | r.onLoad() | |||
| 148 | + | } | |||
| 139 | if err := r.errors[name]; err != nil { | 149 | if err := r.errors[name]; err != nil { | ||
| 140 | return ports.SnapshotGeneration{}, err | 150 | return ports.SnapshotGeneration{}, err | ||
| 141 | } | 151 | } | ||
| @@ -160,6 +170,13 @@ func (r *durableRecoveryRepository) ReconcileCheckpoint(_ context.Context, id do | |||||
| 160 | return nil | 170 | return nil | ||
| 161 | } | 171 | } | ||
| 162 | 172 | ||||
| 173 | + | func (r *durableRecoveryRepository) DeleteIncarnation(_ context.Context, id domain.IncarnationID) error { | |||
| 174 | + | r.mu.Lock() | |||
| 175 | + | defer r.mu.Unlock() | |||
| 176 | + | r.deleted = append(r.deleted, id) | |||
| 177 | + | return r.deleteErr | |||
| 178 | + | } | |||
| 179 | + | ||||
| 163 | func durableRecoveryRecord(index int) domain.CatalogueRecord { | 180 | func durableRecoveryRecord(index int) domain.CatalogueRecord { | ||
| 164 | id := domain.IncarnationID{0: byte(index>>8 + 1), 1: byte(index + 1)} | 181 | id := domain.IncarnationID{0: byte(index>>8 + 1), 1: byte(index + 1)} | ||
| 165 | ref := domain.CheckpointRef{Generation: uint64(index + 1), ManifestDigest: [32]byte{byte(index%255 + 1)}} | 182 | ref := domain.CheckpointRef{Generation: uint64(index + 1), ManifestDigest: [32]byte{byte(index%255 + 1)}} | ||
| @@ -194,7 +211,7 @@ func validGeneration(t testing.TB, record domain.CatalogueRecord) ports.Snapshot | |||||
| 194 | func newDurableRecoveryDaemon(t testing.TB, records []domain.CatalogueRecord, repository ports.SnapshotRepository) (*Daemon, *durableRecoveryCatalogue) { | 211 | func newDurableRecoveryDaemon(t testing.TB, records []domain.CatalogueRecord, repository ports.SnapshotRepository) (*Daemon, *durableRecoveryCatalogue) { | ||
| 195 | t.Helper() | 212 | t.Helper() | ||
| 196 | catalogue := newDurableRecoveryCatalogue(records) | 213 | catalogue := newDurableRecoveryCatalogue(records) | ||
| 197 | - | coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil) | 214 | + | coordinator := recoveryusecase.NewCoordinator(catalogue, repository, bytes.NewReader(bytes.Repeat([]byte{0xa5}, 16))) |
| 198 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(catalogue, records), WithSnapshotRepository(repository), WithRecoveryCoordinator(coordinator)) | 215 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(catalogue, records), WithSnapshotRepository(repository), WithRecoveryCoordinator(coordinator)) | ||
| 199 | d.serveCtx, d.serveCancel = context.WithCancel(context.Background()) | 216 | d.serveCtx, d.serveCancel = context.WithCancel(context.Background()) | ||
| 200 | t.Cleanup(d.serveCancel) | 217 | t.Cleanup(d.serveCancel) | ||
| @@ -289,6 +306,141 @@ func TestCatalogueRestoreIndependent(t *testing.T) { | |||||
| 289 | 306 | ||||
| 290 | } | 307 | } | ||
| 291 | 308 | ||||
| 309 | + | func TestCatalogueRestoreResetsOnlyIncompatibleCheckpoint(t *testing.T) { | |||
| 310 | + | incompatible := durableRecoveryRecord(0) | |||
| 311 | + | healthy := durableRecoveryRecord(1) | |||
| 312 | + | repository := &durableRecoveryRepository{ | |||
| 313 | + | generations: map[string]ports.SnapshotGeneration{healthy.Name: validGeneration(t, healthy)}, | |||
| 314 | + | errors: map[string]error{incompatible.Name: fmt.Errorf("load envelope: %w", snapcodec.ErrBadVersion)}, | |||
| 315 | + | loads: make(map[string]int), | |||
| 316 | + | repairs: make(map[string]domain.CheckpointRef), | |||
| 317 | + | } | |||
| 318 | + | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{incompatible, healthy}, repository) | |||
| 319 | + | ||||
| 320 | + | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | |||
| 321 | + | ||||
| 322 | + | fresh, ok, err := catalogue.Record(incompatible.Name) | |||
| 323 | + | require.NoError(t, err) | |||
| 324 | + | require.True(t, ok) | |||
| 325 | + | require.Equal(t, domain.IncarnationID(bytes.Repeat([]byte{0xa5}, 16)), fresh.IncarnationID) | |||
| 326 | + | require.Nil(t, fresh.Committed) | |||
| 327 | + | require.Empty(t, fresh.DegradedReason) | |||
| 328 | + | require.Empty(t, fresh.TabNames) | |||
| 329 | + | require.Equal(t, []domain.IncarnationID{incompatible.IncarnationID}, repository.deleted) | |||
| 330 | + | require.Equal(t, ports.SessionStopped, d.stopped[incompatible.Name].state) | |||
| 331 | + | require.Equal(t, fresh, d.stopped[incompatible.Name].record) | |||
| 332 | + | ||||
| 333 | + | restoredSibling, ok, err := catalogue.Record(healthy.Name) | |||
| 334 | + | require.NoError(t, err) | |||
| 335 | + | require.True(t, ok) | |||
| 336 | + | require.Equal(t, healthy, restoredSibling) | |||
| 337 | + | require.Equal(t, ports.SessionStopped, d.stopped[healthy.Name].state) | |||
| 338 | + | require.Equal(t, 1, repository.loads[healthy.Name]) | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | func TestIncompatibleCheckpointDeleteFailurePublishesFreshStoppedAuthority(t *testing.T) { | |||
| 342 | + | record := durableRecoveryRecord(0) | |||
| 343 | + | deleteErr := errors.New("delete old incarnation") | |||
| 344 | + | repository := &durableRecoveryRepository{ | |||
| 345 | + | errors: map[string]error{record.Name: snapcodec.ErrBadVersion}, | |||
| 346 | + | loads: make(map[string]int), | |||
| 347 | + | repairs: make(map[string]domain.CheckpointRef), | |||
| 348 | + | deleteErr: deleteErr, | |||
| 349 | + | } | |||
| 350 | + | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository) | |||
| 351 | + | d.mu.Lock() | |||
| 352 | + | restoreDone := d.stopped[record.Name].restoreDone | |||
| 353 | + | d.mu.Unlock() | |||
| 354 | + | ||||
| 355 | + | restoreErr := d.restoreRecord(context.Background(), record) | |||
| 356 | + | require.NoError(t, restoreErr, "post-commit cleanup failure must not fail the authority transition") | |||
| 357 | + | ||||
| 358 | + | fresh, ok, err := catalogue.Record(record.Name) | |||
| 359 | + | require.NoError(t, err) | |||
| 360 | + | require.True(t, ok) | |||
| 361 | + | require.NotEqual(t, record.IncarnationID, fresh.IncarnationID) | |||
| 362 | + | require.Nil(t, fresh.Committed) | |||
| 363 | + | require.Empty(t, fresh.DegradedReason) | |||
| 364 | + | d.mu.Lock() | |||
| 365 | + | entry := d.stopped[record.Name] | |||
| 366 | + | d.mu.Unlock() | |||
| 367 | + | require.Equal(t, ports.SessionStopped, entry.state) | |||
| 368 | + | require.Equal(t, fresh, entry.record) | |||
| 369 | + | require.Equal(t, restoreDone, entry.restoreDone) | |||
| 370 | + | select { | |||
| 371 | + | case <-entry.restoreDone: | |||
| 372 | + | t.Fatal("direct restore closed its coordinator-owned completion barrier") | |||
| 373 | + | default: | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | d.finishRecordRestore(record, restoreErr, restoreDone) | |||
| 377 | + | d.mu.Lock() | |||
| 378 | + | entry = d.stopped[record.Name] | |||
| 379 | + | d.mu.Unlock() | |||
| 380 | + | require.Equal(t, ports.SessionStopped, entry.state) | |||
| 381 | + | require.Equal(t, fresh, entry.record) | |||
| 382 | + | select { | |||
| 383 | + | case <-entry.restoreDone: | |||
| 384 | + | default: | |||
| 385 | + | t.Fatal("restore completion was not signaled") | |||
| 386 | + | } | |||
| 387 | + | require.Equal(t, []domain.IncarnationID{record.IncarnationID}, repository.deleted) | |||
| 388 | + | } | |||
| 389 | + | ||||
| 390 | + | func TestIncompatibleCheckpointPreCommitFailureReturnsError(t *testing.T) { | |||
| 391 | + | record := durableRecoveryRecord(0) | |||
| 392 | + | cause := errors.New("catalogue replace failed") | |||
| 393 | + | repository := &durableRecoveryRepository{ | |||
| 394 | + | errors: map[string]error{record.Name: snapcodec.ErrBadVersion}, | |||
| 395 | + | loads: make(map[string]int), | |||
| 396 | + | repairs: make(map[string]domain.CheckpointRef), | |||
| 397 | + | } | |||
| 398 | + | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository) | |||
| 399 | + | catalogue.replaceErr = cause | |||
| 400 | + | ||||
| 401 | + | err := d.restoreRecord(context.Background(), record) | |||
| 402 | + | ||||
| 403 | + | require.ErrorIs(t, err, cause) | |||
| 404 | + | persisted, ok, recordErr := catalogue.Record(record.Name) | |||
| 405 | + | require.NoError(t, recordErr) | |||
| 406 | + | require.True(t, ok) | |||
| 407 | + | require.Equal(t, record, persisted) | |||
| 408 | + | require.Empty(t, repository.deleted) | |||
| 409 | + | } | |||
| 410 | + | ||||
| 411 | + | func TestRestoreLoadFailuresRetainCheckpointWithoutDeletion(t *testing.T) { | |||
| 412 | + | tests := []struct { | |||
| 413 | + | name string | |||
| 414 | + | err error | |||
| 415 | + | }{ | |||
| 416 | + | {name: "corruption", err: errors.New("corrupt checkpoint")}, | |||
| 417 | + | {name: "generic", err: errors.New("load failed")}, | |||
| 418 | + | {name: "io", err: &os.PathError{Op: "open", Path: "/snapshot", Err: syscall.EACCES}}, | |||
| 419 | + | } | |||
| 420 | + | for _, tt := range tests { | |||
| 421 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 422 | + | record := durableRecoveryRecord(0) | |||
| 423 | + | repository := &durableRecoveryRepository{ | |||
| 424 | + | errors: map[string]error{record.Name: tt.err}, | |||
| 425 | + | loads: make(map[string]int), | |||
| 426 | + | repairs: make(map[string]domain.CheckpointRef), | |||
| 427 | + | } | |||
| 428 | + | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository) | |||
| 429 | + | ||||
| 430 | + | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | |||
| 431 | + | ||||
| 432 | + | persisted, ok, err := catalogue.Record(record.Name) | |||
| 433 | + | require.NoError(t, err) | |||
| 434 | + | require.True(t, ok) | |||
| 435 | + | require.Equal(t, record.IncarnationID, persisted.IncarnationID) | |||
| 436 | + | require.Equal(t, record.Committed, persisted.Committed) | |||
| 437 | + | require.Equal(t, "checkpoint validation failed", persisted.DegradedReason) | |||
| 438 | + | require.Equal(t, ports.SessionBroken, d.stopped[record.Name].state) | |||
| 439 | + | require.Empty(t, repository.deleted) | |||
| 440 | + | }) | |||
| 441 | + | } | |||
| 442 | + | } | |||
| 443 | + | ||||
| 292 | func TestCatalogueRestoreAggregateOver4096(t *testing.T) { | 444 | func TestCatalogueRestoreAggregateOver4096(t *testing.T) { | ||
| 293 | const count = 4200 | 445 | const count = 4200 | ||
| 294 | records := make([]domain.CatalogueRecord, count) | 446 | records := make([]domain.CatalogueRecord, count) | ||
| @@ -386,6 +538,41 @@ type temporaryRestoreError struct{} | |||||
| 386 | func (temporaryRestoreError) Error() string { return "temporary restore failure" } | 538 | func (temporaryRestoreError) Error() string { return "temporary restore failure" } | ||
| 387 | func (temporaryRestoreError) Temporary() bool { return true } | 539 | func (temporaryRestoreError) Temporary() bool { return true } | ||
| 388 | 540 | ||||
| 541 | + | type badVersionLookalikeError struct{} | |||
| 542 | + | ||||
| 543 | + | func (badVersionLookalikeError) Error() string { return "bad version lookalike" } | |||
| 544 | + | func (badVersionLookalikeError) Is(target error) bool { return target == snapcodec.ErrBadVersion } | |||
| 545 | + | ||||
| 546 | + | type cyclicUnwrapError struct{} | |||
| 547 | + | ||||
| 548 | + | func (*cyclicUnwrapError) Error() string { return "cyclic unwrap" } | |||
| 549 | + | func (e *cyclicUnwrapError) Unwrap() error { | |||
| 550 | + | return e | |||
| 551 | + | } | |||
| 552 | + | ||||
| 553 | + | func TestUnambiguousBadVersionErrorClassification(t *testing.T) { | |||
| 554 | + | tests := []struct { | |||
| 555 | + | name string | |||
| 556 | + | err error | |||
| 557 | + | incompatible bool | |||
| 558 | + | }{ | |||
| 559 | + | {name: "nil"}, | |||
| 560 | + | {name: "exact sentinel", err: snapcodec.ErrBadVersion, incompatible: true}, | |||
| 561 | + | {name: "single cause chain", err: fmt.Errorf("load checkpoint: %w", fmt.Errorf("decode manifest: %w", snapcodec.ErrBadVersion)), incompatible: true}, | |||
| 562 | + | {name: "single entry multi cause", err: errors.Join(snapcodec.ErrBadVersion)}, | |||
| 563 | + | {name: "joined retryable branch", err: errors.Join(snapcodec.ErrBadVersion, syscall.ENOSPC)}, | |||
| 564 | + | {name: "custom Is lookalike", err: badVersionLookalikeError{}}, | |||
| 565 | + | {name: "wrapped custom Is lookalike", err: fmt.Errorf("load checkpoint: %w", badVersionLookalikeError{})}, | |||
| 566 | + | {name: "chain ending elsewhere", err: fmt.Errorf("load checkpoint: %w", errors.New("corrupt checkpoint"))}, | |||
| 567 | + | {name: "cyclic custom chain", err: &cyclicUnwrapError{}}, | |||
| 568 | + | } | |||
| 569 | + | for _, tt := range tests { | |||
| 570 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 571 | + | require.Equal(t, tt.incompatible, isUnambiguousBadVersionError(tt.err)) | |||
| 572 | + | }) | |||
| 573 | + | } | |||
| 574 | + | } | |||
| 575 | + | ||||
| 389 | func TestRetryableRestoreLoadErrorClassification(t *testing.T) { | 576 | func TestRetryableRestoreLoadErrorClassification(t *testing.T) { | ||
| 390 | tests := []struct { | 577 | tests := []struct { | ||
| 391 | name string | 578 | name string | ||
M internal/usecase/daemon/performance_benchmark_test.go +7 -7
| @@ -78,7 +78,7 @@ func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testin | |||
| 78 | 78 | fixture.snapshots.reset() | |
| 79 | 79 | fixture.activePane.mu.Lock() | |
| 80 | 80 | fixture.activePane.screen.Write([]byte("\x1b[1;1Hchanged")) | |
| 81 | - | frame := fixture.activePane.screen.PrimaryVisibleFrame() | |
| 81 | + | frame := fixture.activePane.screen.Frame.Clone() | |
| 82 | 82 | fixture.activePane.mu.Unlock() | |
| 83 | 83 | for i, r := range "changed" { | |
| 84 | 84 | require.Equalf(t, r, frame.At(i, 0).Rune, "fixture must overwrite at column %d", i) | |
| @@ -91,7 +91,7 @@ func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testin | |||
| 91 | 91 | require.Equal(t, uint64(1), metrics.writes) | |
| 92 | 92 | require.Zero(t, metrics.suppliedHistoryBytes, "retained sealed history must not be resupplied") | |
| 93 | 93 | require.Zero(t, copies.Load(), "retained sealed history must not be deep-copied") | |
| 94 | - | require.Positive(t, metrics.suppliedObjectBytes, "changed tail and visible state must still be supplied") | |
| 94 | + | require.Positive(t, metrics.suppliedObjectBytes, "changed tail and recovery transcript must still be supplied") | |
| 95 | 95 | requireCompleteSnapshotManifest(t, fixture.snapshots, fixture.sess.name) | |
| 96 | 96 | } | |
| 97 | 97 | ||
| @@ -105,7 +105,7 @@ func requireCompleteSnapshotManifest(t *testing.T, repository *countingSnapshotR | |||
| 105 | 105 | require.NoError(t, err) | |
| 106 | 106 | for _, tab := range manifest.Tabs { | |
| 107 | 107 | for _, pane := range tab.Panes { | |
| 108 | - | for _, ref := range append(append([]snapcodec.ObjectRef(nil), pane.Sealed...), pane.Tail, pane.Visible) { | |
| 108 | + | for _, ref := range append(append([]snapcodec.ObjectRef(nil), pane.Sealed...), pane.Tail, pane.Transcript) { | |
| 109 | 109 | _, ok := objects[ref.Digest] | |
| 110 | 110 | require.True(t, ok, "manifest reference %x is not retained", ref.Digest) | |
| 111 | 111 | } | |
| @@ -523,7 +523,7 @@ type snapshotBenchmarkScenario struct { | |||
| 523 | 523 | var snapshotBenchmarkScenarios = []snapshotBenchmarkScenario{ | |
| 524 | 524 | {name: "initial-10k-x-120", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000}, | |
| 525 | 525 | {name: "unchanged", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, unchanged: true}, | |
| 526 | - | {name: "visible-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, mutate: mutateBenchmarkVisible}, | |
| 526 | + | {name: "transcript-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, mutate: mutateBenchmarkTranscript}, | |
| 527 | 527 | // Leave one row below the retention cap so the mutation changes only the | |
| 528 | 528 | // mutable tail and cannot evict a sealed chunk. | |
| 529 | 529 | {name: "tail-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 9_999, baseline: true, mutate: mutateBenchmarkTail}, | |
| @@ -636,9 +636,9 @@ func publishBenchmarkSnapshot(b testing.TB, fixture *performanceFixture, generat | |||
| 636 | 636 | markSnapshotCaptureObjectsPublished(capture) | |
| 637 | 637 | } | |
| 638 | 638 | ||
| 639 | - | func mutateBenchmarkVisible(fixture *performanceFixture, operation int) { | |
| 639 | + | func mutateBenchmarkTranscript(fixture *performanceFixture, operation int) { | |
| 640 | 640 | fixture.activePane.mu.Lock() | |
| 641 | - | fixture.activePane.screen.Write(fmt.Appendf(nil, "\x1b[1;1Hvisible-%08d", operation)) | |
| 641 | + | fixture.activePane.screen.Write(fmt.Appendf(nil, "\x1b[1;1Htranscript-%08d", operation)) | |
| 642 | 642 | fixture.activePane.mu.Unlock() | |
| 643 | 643 | } | |
| 644 | 644 | ||
| @@ -822,7 +822,7 @@ func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations | |||
| 822 | 822 | } | |
| 823 | 823 | ||
| 824 | 824 | // Root-cause hypothesis (verified by this guard before the fix): S2 allocates | |
| 825 | - | // one visible VT-frame copy during capture plus a new composed frame and its | |
| 825 | + | // one pane-frame copy during capture plus a new composed frame and its | |
| 826 | 826 | // base-frame clone for every live paint. Those client-sized copies, rather | |
| 827 | 827 | // than history, account for the ~1 MiB/op regression in the pinned benchmark. | |
| 828 | 828 | func TestLivePaintAllocationBudget(t *testing.T) { | |
| @@ -78,7 +78,7 @@ func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testin | |||||
| 78 | fixture.snapshots.reset() | 78 | fixture.snapshots.reset() | ||
| 79 | fixture.activePane.mu.Lock() | 79 | fixture.activePane.mu.Lock() | ||
| 80 | fixture.activePane.screen.Write([]byte("\x1b[1;1Hchanged")) | 80 | fixture.activePane.screen.Write([]byte("\x1b[1;1Hchanged")) | ||
| 81 | - | frame := fixture.activePane.screen.PrimaryVisibleFrame() | 81 | + | frame := fixture.activePane.screen.Frame.Clone() |
| 82 | fixture.activePane.mu.Unlock() | 82 | fixture.activePane.mu.Unlock() | ||
| 83 | for i, r := range "changed" { | 83 | for i, r := range "changed" { | ||
| 84 | require.Equalf(t, r, frame.At(i, 0).Rune, "fixture must overwrite at column %d", i) | 84 | require.Equalf(t, r, frame.At(i, 0).Rune, "fixture must overwrite at column %d", i) | ||
| @@ -91,7 +91,7 @@ func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testin | |||||
| 91 | require.Equal(t, uint64(1), metrics.writes) | 91 | require.Equal(t, uint64(1), metrics.writes) | ||
| 92 | require.Zero(t, metrics.suppliedHistoryBytes, "retained sealed history must not be resupplied") | 92 | require.Zero(t, metrics.suppliedHistoryBytes, "retained sealed history must not be resupplied") | ||
| 93 | require.Zero(t, copies.Load(), "retained sealed history must not be deep-copied") | 93 | require.Zero(t, copies.Load(), "retained sealed history must not be deep-copied") | ||
| 94 | - | require.Positive(t, metrics.suppliedObjectBytes, "changed tail and visible state must still be supplied") | 94 | + | require.Positive(t, metrics.suppliedObjectBytes, "changed tail and recovery transcript must still be supplied") |
| 95 | requireCompleteSnapshotManifest(t, fixture.snapshots, fixture.sess.name) | 95 | requireCompleteSnapshotManifest(t, fixture.snapshots, fixture.sess.name) | ||
| 96 | } | 96 | } | ||
| 97 | 97 | ||||
| @@ -105,7 +105,7 @@ func requireCompleteSnapshotManifest(t *testing.T, repository *countingSnapshotR | |||||
| 105 | require.NoError(t, err) | 105 | require.NoError(t, err) | ||
| 106 | for _, tab := range manifest.Tabs { | 106 | for _, tab := range manifest.Tabs { | ||
| 107 | for _, pane := range tab.Panes { | 107 | for _, pane := range tab.Panes { | ||
| 108 | - | for _, ref := range append(append([]snapcodec.ObjectRef(nil), pane.Sealed...), pane.Tail, pane.Visible) { | 108 | + | for _, ref := range append(append([]snapcodec.ObjectRef(nil), pane.Sealed...), pane.Tail, pane.Transcript) { |
| 109 | _, ok := objects[ref.Digest] | 109 | _, ok := objects[ref.Digest] | ||
| 110 | require.True(t, ok, "manifest reference %x is not retained", ref.Digest) | 110 | require.True(t, ok, "manifest reference %x is not retained", ref.Digest) | ||
| 111 | } | 111 | } | ||
| @@ -523,7 +523,7 @@ type snapshotBenchmarkScenario struct { | |||||
| 523 | var snapshotBenchmarkScenarios = []snapshotBenchmarkScenario{ | 523 | var snapshotBenchmarkScenarios = []snapshotBenchmarkScenario{ | ||
| 524 | {name: "initial-10k-x-120", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000}, | 524 | {name: "initial-10k-x-120", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000}, | ||
| 525 | {name: "unchanged", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, unchanged: true}, | 525 | {name: "unchanged", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, unchanged: true}, | ||
| 526 | - | {name: "visible-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, mutate: mutateBenchmarkVisible}, | 526 | + | {name: "transcript-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, mutate: mutateBenchmarkTranscript}, |
| 527 | // Leave one row below the retention cap so the mutation changes only the | 527 | // Leave one row below the retention cap so the mutation changes only the | ||
| 528 | // mutable tail and cannot evict a sealed chunk. | 528 | // mutable tail and cannot evict a sealed chunk. | ||
| 529 | {name: "tail-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 9_999, baseline: true, mutate: mutateBenchmarkTail}, | 529 | {name: "tail-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 9_999, baseline: true, mutate: mutateBenchmarkTail}, | ||
| @@ -636,9 +636,9 @@ func publishBenchmarkSnapshot(b testing.TB, fixture *performanceFixture, generat | |||||
| 636 | markSnapshotCaptureObjectsPublished(capture) | 636 | markSnapshotCaptureObjectsPublished(capture) | ||
| 637 | } | 637 | } | ||
| 638 | 638 | ||||
| 639 | - | func mutateBenchmarkVisible(fixture *performanceFixture, operation int) { | 639 | + | func mutateBenchmarkTranscript(fixture *performanceFixture, operation int) { |
| 640 | fixture.activePane.mu.Lock() | 640 | fixture.activePane.mu.Lock() | ||
| 641 | - | fixture.activePane.screen.Write(fmt.Appendf(nil, "\x1b[1;1Hvisible-%08d", operation)) | 641 | + | fixture.activePane.screen.Write(fmt.Appendf(nil, "\x1b[1;1Htranscript-%08d", operation)) |
| 642 | fixture.activePane.mu.Unlock() | 642 | fixture.activePane.mu.Unlock() | ||
| 643 | } | 643 | } | ||
| 644 | 644 | ||||
| @@ -822,7 +822,7 @@ func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations | |||||
| 822 | } | 822 | } | ||
| 823 | 823 | ||||
| 824 | // Root-cause hypothesis (verified by this guard before the fix): S2 allocates | 824 | // Root-cause hypothesis (verified by this guard before the fix): S2 allocates | ||
| 825 | - | // one visible VT-frame copy during capture plus a new composed frame and its | 825 | + | // one pane-frame copy during capture plus a new composed frame and its |
| 826 | // base-frame clone for every live paint. Those client-sized copies, rather | 826 | // base-frame clone for every live paint. Those client-sized copies, rather | ||
| 827 | // than history, account for the ~1 MiB/op regression in the pinned benchmark. | 827 | // than history, account for the ~1 MiB/op regression in the pinned benchmark. | ||
| 828 | func TestLivePaintAllocationBudget(t *testing.T) { | 828 | func TestLivePaintAllocationBudget(t *testing.T) { | ||
M internal/usecase/daemon/snapshot.go +3 -3
| @@ -71,10 +71,10 @@ type snapshotCapturePane struct { | |||
| 71 | 71 | // capture never rotates a live mutable tail merely to persist it. | |
| 72 | 72 | sealed vt.HistorySnapshotView | |
| 73 | 73 | tail vt.HistoryView | |
| 74 | - | // visible is an owned copy taken while pane.mu is held. It must be | |
| 74 | + | // transcript is an owned copy taken while pane.mu is held. It must be | |
| 75 | 75 | // marshaled only after that lock has been released. | |
| 76 | - | visible vt.PrimaryVisibleSnapshot | |
| 77 | - | process *snapcodec.Process | |
| 76 | + | transcript vt.RecoveryTranscriptSnapshot | |
| 77 | + | process *snapcodec.Process | |
| 78 | 78 | } | |
| 79 | 79 | ||
| 80 | 80 | // snapshotFailureSignature classifies a persistence failure without retaining | |
| @@ -71,10 +71,10 @@ type snapshotCapturePane struct { | |||||
| 71 | // capture never rotates a live mutable tail merely to persist it. | 71 | // capture never rotates a live mutable tail merely to persist it. | ||
| 72 | sealed vt.HistorySnapshotView | 72 | sealed vt.HistorySnapshotView | ||
| 73 | tail vt.HistoryView | 73 | tail vt.HistoryView | ||
| 74 | - | // visible is an owned copy taken while pane.mu is held. It must be | 74 | + | // transcript is an owned copy taken while pane.mu is held. It must be |
| 75 | // marshaled only after that lock has been released. | 75 | // marshaled only after that lock has been released. | ||
| 76 | - | visible vt.PrimaryVisibleSnapshot | 76 | + | transcript vt.RecoveryTranscriptSnapshot |
| 77 | - | process *snapcodec.Process | 77 | + | process *snapcodec.Process |
| 78 | } | 78 | } | ||
| 79 | 79 | ||||
| 80 | // snapshotFailureSignature classifies a persistence failure without retaining | 80 | // snapshotFailureSignature classifies a persistence failure without retaining | ||
M internal/usecase/daemon/snapshot_acceptance_test.go +5 -5
| @@ -72,11 +72,11 @@ func snapshotCacheCapture(sess *session, history vt.HistorySnapshotView, generat | |||
| 72 | 72 | cols: 1, | |
| 73 | 73 | rows: 1, | |
| 74 | 74 | panes: []snapshotCapturePane{{ | |
| 75 | - | id: "pane", | |
| 76 | - | stableID: "pane", | |
| 77 | - | sealed: history, | |
| 78 | - | tail: history.Tail(), | |
| 79 | - | visible: vt.NewScreen(1, 1).PrimaryVisibleSnapshot(), | |
| 75 | + | id: "pane", | |
| 76 | + | stableID: "pane", | |
| 77 | + | sealed: history, | |
| 78 | + | tail: history.Tail(), | |
| 79 | + | transcript: vt.NewScreen(1, 1).RecoveryTranscriptSnapshot(), | |
| 80 | 80 | }}, | |
| 81 | 81 | }}, | |
| 82 | 82 | } | |
| @@ -72,11 +72,11 @@ func snapshotCacheCapture(sess *session, history vt.HistorySnapshotView, generat | |||||
| 72 | cols: 1, | 72 | cols: 1, | ||
| 73 | rows: 1, | 73 | rows: 1, | ||
| 74 | panes: []snapshotCapturePane{{ | 74 | panes: []snapshotCapturePane{{ | ||
| 75 | - | id: "pane", | 75 | + | id: "pane", |
| 76 | - | stableID: "pane", | 76 | + | stableID: "pane", |
| 77 | - | sealed: history, | 77 | + | sealed: history, |
| 78 | - | tail: history.Tail(), | 78 | + | tail: history.Tail(), |
| 79 | - | visible: vt.NewScreen(1, 1).PrimaryVisibleSnapshot(), | 79 | + | transcript: vt.NewScreen(1, 1).RecoveryTranscriptSnapshot(), |
| 80 | }}, | 80 | }}, | ||
| 81 | }}, | 81 | }}, | ||
| 82 | } | 82 | } | ||
M internal/usecase/daemon/snapshot_capture.go +4 -4
| @@ -2,7 +2,7 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 4 | 4 | ||
| 5 | - | // captureSession rotates history tails and clones visible frames while holding | |
| 5 | + | // captureSnapshotState copies history tails and recovery transcripts while holding | |
| 6 | 6 | // each pane lock. The returned capture contains only immutable state; encoding | |
| 7 | 7 | // and persistence are deliberately deferred to snapshotEncodeWorker. | |
| 8 | 8 | func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapshotCapture, bool) { | |
| @@ -51,9 +51,9 @@ func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapsh | |||
| 51 | 51 | pid = pty.Pid() | |
| 52 | 52 | } | |
| 53 | 53 | paneCapture := snapshotCapturePane{ | |
| 54 | - | id: p.id, | |
| 55 | - | stableID: p.stableID, | |
| 56 | - | visible: p.screen.PrimaryVisibleSnapshot(), | |
| 54 | + | id: p.id, | |
| 55 | + | stableID: p.stableID, | |
| 56 | + | transcript: p.screen.RecoveryTranscriptSnapshot(), | |
| 57 | 57 | } | |
| 58 | 58 | paneCapture.sealed = p.history.SnapshotView() | |
| 59 | 59 | paneCapture.tail = paneCapture.sealed.Tail() | |
| @@ -2,7 +2,7 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | 3 | import snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | ||
| 4 | 4 | ||||
| 5 | - | // captureSession rotates history tails and clones visible frames while holding | 5 | + | // captureSnapshotState copies history tails and recovery transcripts while holding |
| 6 | // each pane lock. The returned capture contains only immutable state; encoding | 6 | // each pane lock. The returned capture contains only immutable state; encoding | ||
| 7 | // and persistence are deliberately deferred to snapshotEncodeWorker. | 7 | // and persistence are deliberately deferred to snapshotEncodeWorker. | ||
| 8 | func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapshotCapture, bool) { | 8 | func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapshotCapture, bool) { | ||
| @@ -51,9 +51,9 @@ func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapsh | |||||
| 51 | pid = pty.Pid() | 51 | pid = pty.Pid() | ||
| 52 | } | 52 | } | ||
| 53 | paneCapture := snapshotCapturePane{ | 53 | paneCapture := snapshotCapturePane{ | ||
| 54 | - | id: p.id, | 54 | + | id: p.id, |
| 55 | - | stableID: p.stableID, | 55 | + | stableID: p.stableID, |
| 56 | - | visible: p.screen.PrimaryVisibleSnapshot(), | 56 | + | transcript: p.screen.RecoveryTranscriptSnapshot(), |
| 57 | } | 57 | } | ||
| 58 | paneCapture.sealed = p.history.SnapshotView() | 58 | paneCapture.sealed = p.history.SnapshotView() | ||
| 59 | paneCapture.tail = paneCapture.sealed.Tail() | 59 | paneCapture.tail = paneCapture.sealed.Tail() | ||
M internal/usecase/daemon/snapshot_incremental.go +19 -11
| @@ -104,9 +104,9 @@ func copySnapshotObject(object ports.SnapshotObject) ports.SnapshotObject { | |||
| 104 | 104 | ||
| 105 | 105 | // incrementalPublication converts one immutable capture into complete VEVO | |
| 106 | 106 | // objects plus its VEVM manifest. Encoding runs after pane locks are released. | |
| 107 | - | // Sealed history uses this capture's named-session pointer identity cache; | |
| 108 | - | // tails and visible frames are intentionally not cached because they are copied | |
| 109 | - | // mutable state. | |
| 107 | + | // Sealed history uses this capture's named-session pointer identity cache. | |
| 108 | + | // Tails and recovery transcripts are generation-specific copied mutable state | |
| 109 | + | // and are intentionally never admitted to that cache. | |
| 110 | 110 | func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.SnapshotPublication, error) { | |
| 111 | 111 | if capture == nil || capture.session == nil || capture.name == "" { | |
| 112 | 112 | return ports.SnapshotPublication{}, fmt.Errorf("snapshot: empty capture") | |
| @@ -120,11 +120,11 @@ func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.Snapsho | |||
| 120 | 120 | for _, tab := range capture.tabs { | |
| 121 | 121 | outTab := snapcodec.ManifestTab{StableID: tab.stableID, Cols: tab.cols, Rows: tab.rows, NextPaneID: tab.nextPaneID, Focus: tab.focus, Tree: tab.tree, Panes: make([]snapcodec.ManifestPane, 0, len(tab.panes))} | |
| 122 | 122 | for _, pane := range tab.panes { | |
| 123 | - | // visible was copied under pane.mu. Marshal it here in the worker, | |
| 123 | + | // transcript was copied under pane.mu. Marshal it here in the worker, | |
| 124 | 124 | // after all pane and session locks have been released. | |
| 125 | - | visible, err := pane.visible.Marshal() | |
| 125 | + | transcript, err := pane.transcript.Marshal() | |
| 126 | 126 | if err != nil { | |
| 127 | - | return ports.SnapshotPublication{}, fmt.Errorf("snapshot visible: %w", err) | |
| 127 | + | return ports.SnapshotPublication{}, fmt.Errorf("snapshot recovery transcript: %w", err) | |
| 128 | 128 | } | |
| 129 | 129 | outPane := snapcodec.ManifestPane{ID: pane.id, StableID: pane.stableID, Cwd: pane.cwd, Process: pane.process} | |
| 130 | 130 | for i := 0; i < pane.sealed.ChunkCount(); i++ { | |
| @@ -146,13 +146,13 @@ func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.Snapsho | |||
| 146 | 146 | if err != nil { | |
| 147 | 147 | return ports.SnapshotPublication{}, err | |
| 148 | 148 | } | |
| 149 | - | visibleObject, err := snapcodec.MarshalObject(snapcodec.Visible, visible) | |
| 149 | + | transcriptObject, err := snapcodec.MarshalObject(snapcodec.RecoveryTranscript, transcript) | |
| 150 | 150 | if err != nil { | |
| 151 | 151 | return ports.SnapshotPublication{}, err | |
| 152 | 152 | } | |
| 153 | 153 | outPane.Tail = objectRef(snapcodec.HistoryTail, tailObject) | |
| 154 | - | outPane.Visible = objectRef(snapcodec.Visible, visibleObject) | |
| 155 | - | objects = append(objects, tailObject, visibleObject) | |
| 154 | + | outPane.Transcript = objectRef(snapcodec.RecoveryTranscript, transcriptObject) | |
| 155 | + | objects = append(objects, tailObject, transcriptObject) | |
| 156 | 156 | outTab.Panes = append(outTab.Panes, outPane) | |
| 157 | 157 | } | |
| 158 | 158 | manifest.Tabs = append(manifest.Tabs, outTab) | |
| @@ -211,8 +211,16 @@ func markSnapshotCaptureObjectsPublished(capture *snapshotCapture) { | |||
| 211 | 211 | return | |
| 212 | 212 | } | |
| 213 | 213 | for chunk, ref := range capture.sealedRefs { | |
| 214 | - | if chunk != nil { | |
| 215 | - | cache.persisted[chunk] = ref | |
| 214 | + | if chunk == nil { | |
| 215 | + | continue | |
| 216 | + | } | |
| 217 | + | cache.persisted[chunk] = ref | |
| 218 | + | if entry, ok := cache.byPtr[chunk]; ok { | |
| 219 | + | delete(cache.byPtr, chunk) | |
| 220 | + | cache.used -= len(entry.object.Data) | |
| 221 | + | if cache.used < 0 { | |
| 222 | + | cache.used = 0 | |
| 223 | + | } | |
| 216 | 224 | } | |
| 217 | 225 | } | |
| 218 | 226 | } | |
| @@ -104,9 +104,9 @@ func copySnapshotObject(object ports.SnapshotObject) ports.SnapshotObject { | |||||
| 104 | 104 | ||||
| 105 | // incrementalPublication converts one immutable capture into complete VEVO | 105 | // incrementalPublication converts one immutable capture into complete VEVO | ||
| 106 | // objects plus its VEVM manifest. Encoding runs after pane locks are released. | 106 | // objects plus its VEVM manifest. Encoding runs after pane locks are released. | ||
| 107 | - | // Sealed history uses this capture's named-session pointer identity cache; | 107 | + | // Sealed history uses this capture's named-session pointer identity cache. |
| 108 | - | // tails and visible frames are intentionally not cached because they are copied | 108 | + | // Tails and recovery transcripts are generation-specific copied mutable state |
| 109 | - | // mutable state. | 109 | + | // and are intentionally never admitted to that cache. |
| 110 | func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.SnapshotPublication, error) { | 110 | func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.SnapshotPublication, error) { | ||
| 111 | if capture == nil || capture.session == nil || capture.name == "" { | 111 | if capture == nil || capture.session == nil || capture.name == "" { | ||
| 112 | return ports.SnapshotPublication{}, fmt.Errorf("snapshot: empty capture") | 112 | return ports.SnapshotPublication{}, fmt.Errorf("snapshot: empty capture") | ||
| @@ -120,11 +120,11 @@ func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.Snapsho | |||||
| 120 | for _, tab := range capture.tabs { | 120 | for _, tab := range capture.tabs { | ||
| 121 | outTab := snapcodec.ManifestTab{StableID: tab.stableID, Cols: tab.cols, Rows: tab.rows, NextPaneID: tab.nextPaneID, Focus: tab.focus, Tree: tab.tree, Panes: make([]snapcodec.ManifestPane, 0, len(tab.panes))} | 121 | outTab := snapcodec.ManifestTab{StableID: tab.stableID, Cols: tab.cols, Rows: tab.rows, NextPaneID: tab.nextPaneID, Focus: tab.focus, Tree: tab.tree, Panes: make([]snapcodec.ManifestPane, 0, len(tab.panes))} | ||
| 122 | for _, pane := range tab.panes { | 122 | for _, pane := range tab.panes { | ||
| 123 | - | // visible was copied under pane.mu. Marshal it here in the worker, | 123 | + | // transcript was copied under pane.mu. Marshal it here in the worker, |
| 124 | // after all pane and session locks have been released. | 124 | // after all pane and session locks have been released. | ||
| 125 | - | visible, err := pane.visible.Marshal() | 125 | + | transcript, err := pane.transcript.Marshal() |
| 126 | if err != nil { | 126 | if err != nil { | ||
| 127 | - | return ports.SnapshotPublication{}, fmt.Errorf("snapshot visible: %w", err) | 127 | + | return ports.SnapshotPublication{}, fmt.Errorf("snapshot recovery transcript: %w", err) |
| 128 | } | 128 | } | ||
| 129 | outPane := snapcodec.ManifestPane{ID: pane.id, StableID: pane.stableID, Cwd: pane.cwd, Process: pane.process} | 129 | outPane := snapcodec.ManifestPane{ID: pane.id, StableID: pane.stableID, Cwd: pane.cwd, Process: pane.process} | ||
| 130 | for i := 0; i < pane.sealed.ChunkCount(); i++ { | 130 | for i := 0; i < pane.sealed.ChunkCount(); i++ { | ||
| @@ -146,13 +146,13 @@ func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.Snapsho | |||||
| 146 | if err != nil { | 146 | if err != nil { | ||
| 147 | return ports.SnapshotPublication{}, err | 147 | return ports.SnapshotPublication{}, err | ||
| 148 | } | 148 | } | ||
| 149 | - | visibleObject, err := snapcodec.MarshalObject(snapcodec.Visible, visible) | 149 | + | transcriptObject, err := snapcodec.MarshalObject(snapcodec.RecoveryTranscript, transcript) |
| 150 | if err != nil { | 150 | if err != nil { | ||
| 151 | return ports.SnapshotPublication{}, err | 151 | return ports.SnapshotPublication{}, err | ||
| 152 | } | 152 | } | ||
| 153 | outPane.Tail = objectRef(snapcodec.HistoryTail, tailObject) | 153 | outPane.Tail = objectRef(snapcodec.HistoryTail, tailObject) | ||
| 154 | - | outPane.Visible = objectRef(snapcodec.Visible, visibleObject) | 154 | + | outPane.Transcript = objectRef(snapcodec.RecoveryTranscript, transcriptObject) |
| 155 | - | objects = append(objects, tailObject, visibleObject) | 155 | + | objects = append(objects, tailObject, transcriptObject) |
| 156 | outTab.Panes = append(outTab.Panes, outPane) | 156 | outTab.Panes = append(outTab.Panes, outPane) | ||
| 157 | } | 157 | } | ||
| 158 | manifest.Tabs = append(manifest.Tabs, outTab) | 158 | manifest.Tabs = append(manifest.Tabs, outTab) | ||
| @@ -211,8 +211,16 @@ func markSnapshotCaptureObjectsPublished(capture *snapshotCapture) { | |||||
| 211 | return | 211 | return | ||
| 212 | } | 212 | } | ||
| 213 | for chunk, ref := range capture.sealedRefs { | 213 | for chunk, ref := range capture.sealedRefs { | ||
| 214 | - | if chunk != nil { | 214 | + | if chunk == nil { |
| 215 | - | cache.persisted[chunk] = ref | 215 | + | continue |
| 216 | + | } | |||
| 217 | + | cache.persisted[chunk] = ref | |||
| 218 | + | if entry, ok := cache.byPtr[chunk]; ok { | |||
| 219 | + | delete(cache.byPtr, chunk) | |||
| 220 | + | cache.used -= len(entry.object.Data) | |||
| 221 | + | if cache.used < 0 { | |||
| 222 | + | cache.used = 0 | |||
| 223 | + | } | |||
| 216 | } | 224 | } | ||
| 217 | } | 225 | } | ||
| 218 | } | 226 | } | ||
M internal/usecase/daemon/snapshot_incremental_test.go +67 -3
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "strings" | |
| 4 | 5 | "testing" | |
| 5 | 6 | "time" | |
| 6 | 7 | ||
| @@ -64,11 +65,31 @@ func TestIncrementalPublicationEncodesAfterPaneUnlock(t *testing.T) { | |||
| 64 | 65 | } | |
| 65 | 66 | case <-time.After(testWaitTimeout): | |
| 66 | 67 | pane.mu.Unlock() | |
| 67 | - | t.Fatal("visible encoding waited for pane lock") | |
| 68 | + | t.Fatal("recovery transcript encoding waited for pane lock") | |
| 68 | 69 | } | |
| 69 | 70 | pane.mu.Unlock() | |
| 70 | 71 | } | |
| 71 | 72 | ||
| 73 | + | func TestSnapshotCaptureOwnsRecoveryTranscriptAcrossLiveMutations(t *testing.T) { | |
| 74 | + | d := New(nil, nil, nil) | |
| 75 | + | sess := newSnapshotTestSession(t, "work", false, "/work") | |
| 76 | + | capture, ok := d.captureSnapshotState(sess, 1) | |
| 77 | + | require.True(t, ok) | |
| 78 | + | ||
| 79 | + | pane := sess.tabs[0].panes["pane-1"] | |
| 80 | + | pane.mu.Lock() | |
| 81 | + | pane.screen.Write([]byte("\x1b[1;1Hchanged")) | |
| 82 | + | pane.mu.Unlock() | |
| 83 | + | ||
| 84 | + | publication, err := d.incrementalPublication(capture) | |
| 85 | + | require.NoError(t, err) | |
| 86 | + | restored, err := sessionFromGeneration(snapshotGeneration(publication)) | |
| 87 | + | require.NoError(t, err) | |
| 88 | + | view, err := vt.UnmarshalHistory(restored.Tabs[0].Panes[0].Transcript) | |
| 89 | + | require.NoError(t, err) | |
| 90 | + | require.Equal(t, "hello", strings.TrimRight(cellsString(view.Row(0)), " ")) | |
| 91 | + | } | |
| 92 | + | ||
| 72 | 93 | func TestSnapshotChunkCacheResistsWarmedOverCapacityHistoryScans(t *testing.T) { | |
| 73 | 94 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 6, ChunkRows: 1}) | |
| 74 | 95 | for i := range 6 { | |
| @@ -118,19 +139,54 @@ func TestSnapshotChunkCacheResistsWarmedOverCapacityHistoryScans(t *testing.T) { | |||
| 118 | 139 | require.Equal(t, 1, isolatedEncodes, "a session cache must not share encoded history with another session") | |
| 119 | 140 | } | |
| 120 | 141 | ||
| 142 | + | func TestMarkSnapshotCaptureObjectsPublishedReleasesCachedEncoding(t *testing.T) { | |
| 143 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}) | |
| 144 | + | appendHistoryRow(t, history, []renderer.Cell{{Rune: 'x'}}) | |
| 145 | + | view := history.SnapshotView() | |
| 146 | + | chunk := view.Chunk(0) | |
| 147 | + | transcript := vt.NewScreen(1, 1).RecoveryTranscriptSnapshot() | |
| 148 | + | d := New(nil, nil, nil) | |
| 149 | + | sess := newSnapshotTestSession(t, "work", false, "/work") | |
| 150 | + | capture := &snapshotCapture{session: sess, name: "work", incarnation: domain.IncarnationID{1}, generation: 1, tabs: []snapshotCaptureTab{{stableID: "t", cols: 1, rows: 1, panes: []snapshotCapturePane{{id: "p", stableID: "p", sealed: view, tail: view.Tail(), transcript: transcript}}}}} | |
| 151 | + | ||
| 152 | + | _, err := d.incrementalPublication(capture) | |
| 153 | + | require.NoError(t, err) | |
| 154 | + | entry, ok := sess.snapshotChunkCache.byPtr[chunk] | |
| 155 | + | require.True(t, ok) | |
| 156 | + | usedBefore := sess.snapshotChunkCache.used | |
| 157 | + | ref := capture.sealedRefs[chunk] | |
| 158 | + | ||
| 159 | + | markSnapshotCaptureObjectsPublished(capture) | |
| 160 | + | ||
| 161 | + | require.NotContains(t, sess.snapshotChunkCache.byPtr, chunk) | |
| 162 | + | require.Equal(t, usedBefore-len(entry.object.Data), sess.snapshotChunkCache.used) | |
| 163 | + | require.Equal(t, ref, sess.snapshotChunkCache.persisted[chunk]) | |
| 164 | + | gotRef, object, err := sess.snapshotChunkCache.objectLocked(chunk) | |
| 165 | + | require.NoError(t, err) | |
| 166 | + | require.Equal(t, ref, gotRef) | |
| 167 | + | require.Nil(t, object) | |
| 168 | + | ||
| 169 | + | // Repeated acknowledgement must not release the same bytes twice. | |
| 170 | + | markSnapshotCaptureObjectsPublished(capture) | |
| 171 | + | require.Equal(t, usedBefore-len(entry.object.Data), sess.snapshotChunkCache.used) | |
| 172 | + | require.Equal(t, ref, sess.snapshotChunkCache.persisted[chunk]) | |
| 173 | + | } | |
| 174 | + | ||
| 121 | 175 | func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) { | |
| 122 | 176 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}) | |
| 123 | 177 | appendHistoryRow(t, history, []renderer.Cell{renderer.BlankCell()}) | |
| 124 | 178 | view := history.SnapshotView() | |
| 125 | - | visible := vt.NewScreen(1, 1).PrimaryVisibleSnapshot() | |
| 179 | + | transcript := vt.NewScreen(1, 1).RecoveryTranscriptSnapshot() | |
| 126 | 180 | d := New(nil, nil, nil) | |
| 127 | 181 | sess := newSnapshotTestSession(t, "work", false, "/work") | |
| 128 | - | capture := &snapshotCapture{session: sess, name: "work", incarnation: domain.IncarnationID{1}, generation: 1, tabs: []snapshotCaptureTab{{stableID: "t", cols: 1, rows: 1, panes: []snapshotCapturePane{{id: "p", stableID: "p", sealed: view, tail: view.Tail(), visible: visible}}}}} | |
| 182 | + | capture := &snapshotCapture{session: sess, name: "work", incarnation: domain.IncarnationID{1}, generation: 1, tabs: []snapshotCaptureTab{{stableID: "t", cols: 1, rows: 1, panes: []snapshotCapturePane{{id: "p", stableID: "p", sealed: view, tail: view.Tail(), transcript: transcript}}}}} | |
| 129 | 183 | first, err := d.incrementalPublication(capture) | |
| 130 | 184 | if err != nil { | |
| 131 | 185 | t.Fatal(err) | |
| 132 | 186 | } | |
| 133 | 187 | firstRef := domain.CheckpointRef{Generation: first.Generation, ManifestDigest: snapcodec.ManifestDigest(first.Manifest)} | |
| 188 | + | capture.checkpoint = firstRef | |
| 189 | + | markSnapshotCaptureObjectsPublished(capture) | |
| 134 | 190 | capture.generation = 2 | |
| 135 | 191 | capture.parentCheckpoint = &firstRef | |
| 136 | 192 | second, err := d.incrementalPublication(capture) | |
| @@ -140,6 +196,14 @@ func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) { | |||
| 140 | 196 | if len(first.Objects) == 0 || len(second.Objects) == 0 { | |
| 141 | 197 | t.Fatal("publication omitted required objects") | |
| 142 | 198 | } | |
| 199 | + | require.Len(t, second.Objects, 2, "a generation must supply its tail and transcript once without resupplying persisted sealed chunks") | |
| 200 | + | kinds := make(map[snapcodec.ObjectKind]int) | |
| 201 | + | for _, object := range second.Objects { | |
| 202 | + | kind, _, err := snapcodec.PreflightObject(object.Data) | |
| 203 | + | require.NoError(t, err) | |
| 204 | + | kinds[kind]++ | |
| 205 | + | } | |
| 206 | + | require.Equal(t, map[snapcodec.ObjectKind]int{snapcodec.HistoryTail: 1, snapcodec.RecoveryTranscript: 1}, kinds) | |
| 143 | 207 | require.Equal(t, &firstRef, second.ParentCheckpoint) | |
| 144 | 208 | manifest, err := snapcodec.UnmarshalManifest(second.Manifest) | |
| 145 | 209 | require.NoError(t, err) | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "strings" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | "time" | 6 | "time" | ||
| 6 | 7 | ||||
| @@ -64,11 +65,31 @@ func TestIncrementalPublicationEncodesAfterPaneUnlock(t *testing.T) { | |||||
| 64 | } | 65 | } | ||
| 65 | case <-time.After(testWaitTimeout): | 66 | case <-time.After(testWaitTimeout): | ||
| 66 | pane.mu.Unlock() | 67 | pane.mu.Unlock() | ||
| 67 | - | t.Fatal("visible encoding waited for pane lock") | 68 | + | t.Fatal("recovery transcript encoding waited for pane lock") |
| 68 | } | 69 | } | ||
| 69 | pane.mu.Unlock() | 70 | pane.mu.Unlock() | ||
| 70 | } | 71 | } | ||
| 71 | 72 | ||||
| 73 | + | func TestSnapshotCaptureOwnsRecoveryTranscriptAcrossLiveMutations(t *testing.T) { | |||
| 74 | + | d := New(nil, nil, nil) | |||
| 75 | + | sess := newSnapshotTestSession(t, "work", false, "/work") | |||
| 76 | + | capture, ok := d.captureSnapshotState(sess, 1) | |||
| 77 | + | require.True(t, ok) | |||
| 78 | + | ||||
| 79 | + | pane := sess.tabs[0].panes["pane-1"] | |||
| 80 | + | pane.mu.Lock() | |||
| 81 | + | pane.screen.Write([]byte("\x1b[1;1Hchanged")) | |||
| 82 | + | pane.mu.Unlock() | |||
| 83 | + | ||||
| 84 | + | publication, err := d.incrementalPublication(capture) | |||
| 85 | + | require.NoError(t, err) | |||
| 86 | + | restored, err := sessionFromGeneration(snapshotGeneration(publication)) | |||
| 87 | + | require.NoError(t, err) | |||
| 88 | + | view, err := vt.UnmarshalHistory(restored.Tabs[0].Panes[0].Transcript) | |||
| 89 | + | require.NoError(t, err) | |||
| 90 | + | require.Equal(t, "hello", strings.TrimRight(cellsString(view.Row(0)), " ")) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 72 | func TestSnapshotChunkCacheResistsWarmedOverCapacityHistoryScans(t *testing.T) { | 93 | func TestSnapshotChunkCacheResistsWarmedOverCapacityHistoryScans(t *testing.T) { | ||
| 73 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 6, ChunkRows: 1}) | 94 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 6, ChunkRows: 1}) | ||
| 74 | for i := range 6 { | 95 | for i := range 6 { | ||
| @@ -118,19 +139,54 @@ func TestSnapshotChunkCacheResistsWarmedOverCapacityHistoryScans(t *testing.T) { | |||||
| 118 | require.Equal(t, 1, isolatedEncodes, "a session cache must not share encoded history with another session") | 139 | require.Equal(t, 1, isolatedEncodes, "a session cache must not share encoded history with another session") | ||
| 119 | } | 140 | } | ||
| 120 | 141 | ||||
| 142 | + | func TestMarkSnapshotCaptureObjectsPublishedReleasesCachedEncoding(t *testing.T) { | |||
| 143 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}) | |||
| 144 | + | appendHistoryRow(t, history, []renderer.Cell{{Rune: 'x'}}) | |||
| 145 | + | view := history.SnapshotView() | |||
| 146 | + | chunk := view.Chunk(0) | |||
| 147 | + | transcript := vt.NewScreen(1, 1).RecoveryTranscriptSnapshot() | |||
| 148 | + | d := New(nil, nil, nil) | |||
| 149 | + | sess := newSnapshotTestSession(t, "work", false, "/work") | |||
| 150 | + | capture := &snapshotCapture{session: sess, name: "work", incarnation: domain.IncarnationID{1}, generation: 1, tabs: []snapshotCaptureTab{{stableID: "t", cols: 1, rows: 1, panes: []snapshotCapturePane{{id: "p", stableID: "p", sealed: view, tail: view.Tail(), transcript: transcript}}}}} | |||
| 151 | + | ||||
| 152 | + | _, err := d.incrementalPublication(capture) | |||
| 153 | + | require.NoError(t, err) | |||
| 154 | + | entry, ok := sess.snapshotChunkCache.byPtr[chunk] | |||
| 155 | + | require.True(t, ok) | |||
| 156 | + | usedBefore := sess.snapshotChunkCache.used | |||
| 157 | + | ref := capture.sealedRefs[chunk] | |||
| 158 | + | ||||
| 159 | + | markSnapshotCaptureObjectsPublished(capture) | |||
| 160 | + | ||||
| 161 | + | require.NotContains(t, sess.snapshotChunkCache.byPtr, chunk) | |||
| 162 | + | require.Equal(t, usedBefore-len(entry.object.Data), sess.snapshotChunkCache.used) | |||
| 163 | + | require.Equal(t, ref, sess.snapshotChunkCache.persisted[chunk]) | |||
| 164 | + | gotRef, object, err := sess.snapshotChunkCache.objectLocked(chunk) | |||
| 165 | + | require.NoError(t, err) | |||
| 166 | + | require.Equal(t, ref, gotRef) | |||
| 167 | + | require.Nil(t, object) | |||
| 168 | + | ||||
| 169 | + | // Repeated acknowledgement must not release the same bytes twice. | |||
| 170 | + | markSnapshotCaptureObjectsPublished(capture) | |||
| 171 | + | require.Equal(t, usedBefore-len(entry.object.Data), sess.snapshotChunkCache.used) | |||
| 172 | + | require.Equal(t, ref, sess.snapshotChunkCache.persisted[chunk]) | |||
| 173 | + | } | |||
| 174 | + | ||||
| 121 | func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) { | 175 | func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) { | ||
| 122 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}) | 176 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}) | ||
| 123 | appendHistoryRow(t, history, []renderer.Cell{renderer.BlankCell()}) | 177 | appendHistoryRow(t, history, []renderer.Cell{renderer.BlankCell()}) | ||
| 124 | view := history.SnapshotView() | 178 | view := history.SnapshotView() | ||
| 125 | - | visible := vt.NewScreen(1, 1).PrimaryVisibleSnapshot() | 179 | + | transcript := vt.NewScreen(1, 1).RecoveryTranscriptSnapshot() |
| 126 | d := New(nil, nil, nil) | 180 | d := New(nil, nil, nil) | ||
| 127 | sess := newSnapshotTestSession(t, "work", false, "/work") | 181 | sess := newSnapshotTestSession(t, "work", false, "/work") | ||
| 128 | - | capture := &snapshotCapture{session: sess, name: "work", incarnation: domain.IncarnationID{1}, generation: 1, tabs: []snapshotCaptureTab{{stableID: "t", cols: 1, rows: 1, panes: []snapshotCapturePane{{id: "p", stableID: "p", sealed: view, tail: view.Tail(), visible: visible}}}}} | 182 | + | capture := &snapshotCapture{session: sess, name: "work", incarnation: domain.IncarnationID{1}, generation: 1, tabs: []snapshotCaptureTab{{stableID: "t", cols: 1, rows: 1, panes: []snapshotCapturePane{{id: "p", stableID: "p", sealed: view, tail: view.Tail(), transcript: transcript}}}}} |
| 129 | first, err := d.incrementalPublication(capture) | 183 | first, err := d.incrementalPublication(capture) | ||
| 130 | if err != nil { | 184 | if err != nil { | ||
| 131 | t.Fatal(err) | 185 | t.Fatal(err) | ||
| 132 | } | 186 | } | ||
| 133 | firstRef := domain.CheckpointRef{Generation: first.Generation, ManifestDigest: snapcodec.ManifestDigest(first.Manifest)} | 187 | firstRef := domain.CheckpointRef{Generation: first.Generation, ManifestDigest: snapcodec.ManifestDigest(first.Manifest)} | ||
| 188 | + | capture.checkpoint = firstRef | |||
| 189 | + | markSnapshotCaptureObjectsPublished(capture) | |||
| 134 | capture.generation = 2 | 190 | capture.generation = 2 | ||
| 135 | capture.parentCheckpoint = &firstRef | 191 | capture.parentCheckpoint = &firstRef | ||
| 136 | second, err := d.incrementalPublication(capture) | 192 | second, err := d.incrementalPublication(capture) | ||
| @@ -140,6 +196,14 @@ func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) { | |||||
| 140 | if len(first.Objects) == 0 || len(second.Objects) == 0 { | 196 | if len(first.Objects) == 0 || len(second.Objects) == 0 { | ||
| 141 | t.Fatal("publication omitted required objects") | 197 | t.Fatal("publication omitted required objects") | ||
| 142 | } | 198 | } | ||
| 199 | + | require.Len(t, second.Objects, 2, "a generation must supply its tail and transcript once without resupplying persisted sealed chunks") | |||
| 200 | + | kinds := make(map[snapcodec.ObjectKind]int) | |||
| 201 | + | for _, object := range second.Objects { | |||
| 202 | + | kind, _, err := snapcodec.PreflightObject(object.Data) | |||
| 203 | + | require.NoError(t, err) | |||
| 204 | + | kinds[kind]++ | |||
| 205 | + | } | |||
| 206 | + | require.Equal(t, map[snapcodec.ObjectKind]int{snapcodec.HistoryTail: 1, snapcodec.RecoveryTranscript: 1}, kinds) | |||
| 143 | require.Equal(t, &firstRef, second.ParentCheckpoint) | 207 | require.Equal(t, &firstRef, second.ParentCheckpoint) | ||
| 144 | manifest, err := snapcodec.UnmarshalManifest(second.Manifest) | 208 | manifest, err := snapcodec.UnmarshalManifest(second.Manifest) | ||
| 145 | require.NoError(t, err) | 209 | require.NoError(t, err) | ||
M internal/usecase/daemon/snapshot_restore.go +4 -7
| @@ -347,17 +347,14 @@ func restorePaneTerminal(p *pane, snap snapcodec.Pane) error { | |||
| 347 | 347 | if len(snap.Tail) == 0 { | |
| 348 | 348 | return fmt.Errorf("snapshot history: missing tail blob") | |
| 349 | 349 | } | |
| 350 | - | if len(snap.Visible) == 0 { | |
| 351 | - | return fmt.Errorf("snapshot visible: missing visible blob") | |
| 350 | + | if len(snap.Transcript) == 0 { | |
| 351 | + | return fmt.Errorf("snapshot recovery transcript: missing transcript blob") | |
| 352 | 352 | } | |
| 353 | 353 | p.mu.Lock() | |
| 354 | 354 | defer p.mu.Unlock() | |
| 355 | - | screen, err := vt.NewScreenWithRestoredHistory(p.screen.Frame.Width, p.screen.Frame.Height, vt.HistoryConfig{MaxRows: defaultScrollbackRows, MaxCells: defaultScrollbackCells}, snap.SealedChunks, snap.Tail) | |
| 355 | + | screen, err := vt.NewScreenWithRecoveryTranscript(p.screen.Frame.Width, p.screen.Frame.Height, vt.HistoryConfig{MaxRows: defaultScrollbackRows, MaxCells: defaultScrollbackCells}, snap.SealedChunks, snap.Tail, snap.Transcript) | |
| 356 | 356 | if err != nil { | |
| 357 | - | return fmt.Errorf("snapshot history: %w", err) | |
| 358 | - | } | |
| 359 | - | if err := screen.RestorePrimaryVisible(snap.Visible); err != nil { | |
| 360 | - | return fmt.Errorf("snapshot visible: %w", err) | |
| 357 | + | return fmt.Errorf("snapshot recovery: %w", err) | |
| 361 | 358 | } | |
| 362 | 359 | p.screen = screen | |
| 363 | 360 | p.history = screen.History() | |
| @@ -347,17 +347,14 @@ func restorePaneTerminal(p *pane, snap snapcodec.Pane) error { | |||||
| 347 | if len(snap.Tail) == 0 { | 347 | if len(snap.Tail) == 0 { | ||
| 348 | return fmt.Errorf("snapshot history: missing tail blob") | 348 | return fmt.Errorf("snapshot history: missing tail blob") | ||
| 349 | } | 349 | } | ||
| 350 | - | if len(snap.Visible) == 0 { | 350 | + | if len(snap.Transcript) == 0 { |
| 351 | - | return fmt.Errorf("snapshot visible: missing visible blob") | 351 | + | return fmt.Errorf("snapshot recovery transcript: missing transcript blob") |
| 352 | } | 352 | } | ||
| 353 | p.mu.Lock() | 353 | p.mu.Lock() | ||
| 354 | defer p.mu.Unlock() | 354 | defer p.mu.Unlock() | ||
| 355 | - | screen, err := vt.NewScreenWithRestoredHistory(p.screen.Frame.Width, p.screen.Frame.Height, vt.HistoryConfig{MaxRows: defaultScrollbackRows, MaxCells: defaultScrollbackCells}, snap.SealedChunks, snap.Tail) | 355 | + | screen, err := vt.NewScreenWithRecoveryTranscript(p.screen.Frame.Width, p.screen.Frame.Height, vt.HistoryConfig{MaxRows: defaultScrollbackRows, MaxCells: defaultScrollbackCells}, snap.SealedChunks, snap.Tail, snap.Transcript) |
| 356 | if err != nil { | 356 | if err != nil { | ||
| 357 | - | return fmt.Errorf("snapshot history: %w", err) | 357 | + | return fmt.Errorf("snapshot recovery: %w", err) |
| 358 | - | } | |||
| 359 | - | if err := screen.RestorePrimaryVisible(snap.Visible); err != nil { | |||
| 360 | - | return fmt.Errorf("snapshot visible: %w", err) | |||
| 361 | } | 358 | } | ||
| 362 | p.screen = screen | 359 | p.screen = screen | ||
| 363 | p.history = screen.History() | 360 | p.history = screen.History() | ||
M internal/usecase/daemon/snapshot_restore_fixture_test.go +4 -4
| @@ -29,12 +29,12 @@ func acceptancePublication(snapshot snapcodec.Session) (ports.SnapshotPublicatio | |||
| 29 | 29 | if err != nil { | |
| 30 | 30 | return ports.SnapshotPublication{}, fmt.Errorf("snapshot fixture conversion: tab %q pane %q tail: marshal object: %w", tab.StableID, pane.ID, err) | |
| 31 | 31 | } | |
| 32 | - | visible, err := snapcodec.MarshalObject(snapcodec.Visible, pane.Visible) | |
| 32 | + | transcript, err := snapcodec.MarshalObject(snapcodec.RecoveryTranscript, pane.Transcript) | |
| 33 | 33 | if err != nil { | |
| 34 | - | return ports.SnapshotPublication{}, fmt.Errorf("snapshot fixture conversion: tab %q pane %q visible: marshal object: %w", tab.StableID, pane.ID, err) | |
| 34 | + | return ports.SnapshotPublication{}, fmt.Errorf("snapshot fixture conversion: tab %q pane %q recovery transcript: marshal object: %w", tab.StableID, pane.ID, err) | |
| 35 | 35 | } | |
| 36 | - | outPane.Tail, outPane.Visible = objectRef(snapcodec.HistoryTail, tail), objectRef(snapcodec.Visible, visible) | |
| 37 | - | objects = append(objects, tail, visible) | |
| 36 | + | outPane.Tail, outPane.Transcript = objectRef(snapcodec.HistoryTail, tail), objectRef(snapcodec.RecoveryTranscript, transcript) | |
| 37 | + | objects = append(objects, tail, transcript) | |
| 38 | 38 | outTab.Panes = append(outTab.Panes, outPane) | |
| 39 | 39 | } | |
| 40 | 40 | manifest.Tabs = append(manifest.Tabs, outTab) | |
| @@ -29,12 +29,12 @@ func acceptancePublication(snapshot snapcodec.Session) (ports.SnapshotPublicatio | |||||
| 29 | if err != nil { | 29 | if err != nil { | ||
| 30 | return ports.SnapshotPublication{}, fmt.Errorf("snapshot fixture conversion: tab %q pane %q tail: marshal object: %w", tab.StableID, pane.ID, err) | 30 | return ports.SnapshotPublication{}, fmt.Errorf("snapshot fixture conversion: tab %q pane %q tail: marshal object: %w", tab.StableID, pane.ID, err) | ||
| 31 | } | 31 | } | ||
| 32 | - | visible, err := snapcodec.MarshalObject(snapcodec.Visible, pane.Visible) | 32 | + | transcript, err := snapcodec.MarshalObject(snapcodec.RecoveryTranscript, pane.Transcript) |
| 33 | if err != nil { | 33 | if err != nil { | ||
| 34 | - | return ports.SnapshotPublication{}, fmt.Errorf("snapshot fixture conversion: tab %q pane %q visible: marshal object: %w", tab.StableID, pane.ID, err) | 34 | + | return ports.SnapshotPublication{}, fmt.Errorf("snapshot fixture conversion: tab %q pane %q recovery transcript: marshal object: %w", tab.StableID, pane.ID, err) |
| 35 | } | 35 | } | ||
| 36 | - | outPane.Tail, outPane.Visible = objectRef(snapcodec.HistoryTail, tail), objectRef(snapcodec.Visible, visible) | 36 | + | outPane.Tail, outPane.Transcript = objectRef(snapcodec.HistoryTail, tail), objectRef(snapcodec.RecoveryTranscript, transcript) |
| 37 | - | objects = append(objects, tail, visible) | 37 | + | objects = append(objects, tail, transcript) |
| 38 | outTab.Panes = append(outTab.Panes, outPane) | 38 | outTab.Panes = append(outTab.Panes, outPane) | ||
| 39 | } | 39 | } | ||
| 40 | manifest.Tabs = append(manifest.Tabs, outTab) | 40 | manifest.Tabs = append(manifest.Tabs, outTab) | ||
M internal/usecase/daemon/snapshot_restore_incremental.go +69 -1
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "net" | |
| 9 | + | "reflect" | |
| 9 | 10 | "sync" | |
| 10 | 11 | "syscall" | |
| 11 | 12 | ||
| @@ -178,6 +179,9 @@ func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecor | |||
| 178 | 179 | if ctxErr := ctx.Err(); ctxErr != nil { | |
| 179 | 180 | return ctxErr | |
| 180 | 181 | } | |
| 182 | + | if isUnambiguousBadVersionError(err) { | |
| 183 | + | return d.resetIncompatibleCheckpoint(ctx, record, selected) | |
| 184 | + | } | |
| 181 | 185 | if isRetryableRestoreLoadError(err) { | |
| 182 | 186 | return fmt.Errorf("%w: %w", errRetryableRestoreLoad, err) | |
| 183 | 187 | } | |
| @@ -215,6 +219,70 @@ func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecor | |||
| 215 | 219 | return nil | |
| 216 | 220 | } | |
| 217 | 221 | ||
| 222 | + | func (d *Daemon) resetIncompatibleCheckpoint(ctx context.Context, record domain.CatalogueRecord, selected domain.CheckpointRef) error { | |
| 223 | + | fresh, committed, err := d.recovery.ResetIncompatible(ctx, record.Name, record.IncarnationID, selected) | |
| 224 | + | if !committed { | |
| 225 | + | if err != nil { | |
| 226 | + | return fmt.Errorf("snapshot: reset incompatible checkpoint: %w", err) | |
| 227 | + | } | |
| 228 | + | d.log.Info("snapshot_incompatible_reset_superseded", | |
| 229 | + | "session", record.Name, | |
| 230 | + | "incarnation", record.IncarnationID.String(), | |
| 231 | + | "generation", selected.Generation, | |
| 232 | + | ) | |
| 233 | + | return nil | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | d.mu.Lock() | |
| 237 | + | if entry, ok := d.stopped[record.Name]; ok { | |
| 238 | + | d.stopped[record.Name] = stoppedSessionFromRecord(fresh, ports.SessionStopped, entry.restoreDone) | |
| 239 | + | } | |
| 240 | + | d.mu.Unlock() | |
| 241 | + | ||
| 242 | + | if err != nil { | |
| 243 | + | d.log.Warn("snapshot_incompatible_reset_cleanup_pending", | |
| 244 | + | "session", fresh.Name, | |
| 245 | + | "incarnation", fresh.IncarnationID.String(), | |
| 246 | + | "replaced_incarnation", record.IncarnationID.String(), | |
| 247 | + | "generation", selected.Generation, | |
| 248 | + | "err", err, | |
| 249 | + | ) | |
| 250 | + | return nil | |
| 251 | + | } | |
| 252 | + | d.log.Info("snapshot_incompatible_reset_complete", | |
| 253 | + | "session", fresh.Name, | |
| 254 | + | "incarnation", fresh.IncarnationID.String(), | |
| 255 | + | "replaced_incarnation", record.IncarnationID.String(), | |
| 256 | + | "generation", selected.Generation, | |
| 257 | + | ) | |
| 258 | + | return nil | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | func isUnambiguousBadVersionError(err error) bool { | |
| 262 | + | seen := make(map[error]struct{}) | |
| 263 | + | for err != nil { | |
| 264 | + | if !reflect.TypeOf(err).Comparable() { | |
| 265 | + | return false | |
| 266 | + | } | |
| 267 | + | if _, duplicate := seen[err]; duplicate { | |
| 268 | + | return false | |
| 269 | + | } | |
| 270 | + | seen[err] = struct{}{} | |
| 271 | + | if err == snapcodec.ErrBadVersion { | |
| 272 | + | return true | |
| 273 | + | } | |
| 274 | + | if _, ambiguous := err.(interface{ Unwrap() []error }); ambiguous { | |
| 275 | + | return false | |
| 276 | + | } | |
| 277 | + | unwrapper, ok := err.(interface{ Unwrap() error }) | |
| 278 | + | if !ok { | |
| 279 | + | return false | |
| 280 | + | } | |
| 281 | + | err = unwrapper.Unwrap() | |
| 282 | + | } | |
| 283 | + | return false | |
| 284 | + | } | |
| 285 | + | ||
| 218 | 286 | func isRetryableRestoreLoadError(err error) bool { | |
| 219 | 287 | if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { | |
| 220 | 288 | return false | |
| @@ -274,7 +342,7 @@ func sessionFromGeneration(generation ports.SnapshotGeneration) (snapcodec.Sessi | |||
| 274 | 342 | if outPane.Tail, err = generationObject(generation, pane.Tail, snapcodec.HistoryTail); err != nil { | |
| 275 | 343 | return snapcodec.Session{}, err | |
| 276 | 344 | } | |
| 277 | - | if outPane.Visible, err = generationObject(generation, pane.Visible, snapcodec.Visible); err != nil { | |
| 345 | + | if outPane.Transcript, err = generationObject(generation, pane.Transcript, snapcodec.RecoveryTranscript); err != nil { | |
| 278 | 346 | return snapcodec.Session{}, err | |
| 279 | 347 | } | |
| 280 | 348 | outTab.Panes = append(outTab.Panes, outPane) | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "net" | 8 | "net" | ||
| 9 | + | "reflect" | |||
| 9 | "sync" | 10 | "sync" | ||
| 10 | "syscall" | 11 | "syscall" | ||
| 11 | 12 | ||||
| @@ -178,6 +179,9 @@ func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecor | |||||
| 178 | if ctxErr := ctx.Err(); ctxErr != nil { | 179 | if ctxErr := ctx.Err(); ctxErr != nil { | ||
| 179 | return ctxErr | 180 | return ctxErr | ||
| 180 | } | 181 | } | ||
| 182 | + | if isUnambiguousBadVersionError(err) { | |||
| 183 | + | return d.resetIncompatibleCheckpoint(ctx, record, selected) | |||
| 184 | + | } | |||
| 181 | if isRetryableRestoreLoadError(err) { | 185 | if isRetryableRestoreLoadError(err) { | ||
| 182 | return fmt.Errorf("%w: %w", errRetryableRestoreLoad, err) | 186 | return fmt.Errorf("%w: %w", errRetryableRestoreLoad, err) | ||
| 183 | } | 187 | } | ||
| @@ -215,6 +219,70 @@ func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecor | |||||
| 215 | return nil | 219 | return nil | ||
| 216 | } | 220 | } | ||
| 217 | 221 | ||||
| 222 | + | func (d *Daemon) resetIncompatibleCheckpoint(ctx context.Context, record domain.CatalogueRecord, selected domain.CheckpointRef) error { | |||
| 223 | + | fresh, committed, err := d.recovery.ResetIncompatible(ctx, record.Name, record.IncarnationID, selected) | |||
| 224 | + | if !committed { | |||
| 225 | + | if err != nil { | |||
| 226 | + | return fmt.Errorf("snapshot: reset incompatible checkpoint: %w", err) | |||
| 227 | + | } | |||
| 228 | + | d.log.Info("snapshot_incompatible_reset_superseded", | |||
| 229 | + | "session", record.Name, | |||
| 230 | + | "incarnation", record.IncarnationID.String(), | |||
| 231 | + | "generation", selected.Generation, | |||
| 232 | + | ) | |||
| 233 | + | return nil | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | d.mu.Lock() | |||
| 237 | + | if entry, ok := d.stopped[record.Name]; ok { | |||
| 238 | + | d.stopped[record.Name] = stoppedSessionFromRecord(fresh, ports.SessionStopped, entry.restoreDone) | |||
| 239 | + | } | |||
| 240 | + | d.mu.Unlock() | |||
| 241 | + | ||||
| 242 | + | if err != nil { | |||
| 243 | + | d.log.Warn("snapshot_incompatible_reset_cleanup_pending", | |||
| 244 | + | "session", fresh.Name, | |||
| 245 | + | "incarnation", fresh.IncarnationID.String(), | |||
| 246 | + | "replaced_incarnation", record.IncarnationID.String(), | |||
| 247 | + | "generation", selected.Generation, | |||
| 248 | + | "err", err, | |||
| 249 | + | ) | |||
| 250 | + | return nil | |||
| 251 | + | } | |||
| 252 | + | d.log.Info("snapshot_incompatible_reset_complete", | |||
| 253 | + | "session", fresh.Name, | |||
| 254 | + | "incarnation", fresh.IncarnationID.String(), | |||
| 255 | + | "replaced_incarnation", record.IncarnationID.String(), | |||
| 256 | + | "generation", selected.Generation, | |||
| 257 | + | ) | |||
| 258 | + | return nil | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | func isUnambiguousBadVersionError(err error) bool { | |||
| 262 | + | seen := make(map[error]struct{}) | |||
| 263 | + | for err != nil { | |||
| 264 | + | if !reflect.TypeOf(err).Comparable() { | |||
| 265 | + | return false | |||
| 266 | + | } | |||
| 267 | + | if _, duplicate := seen[err]; duplicate { | |||
| 268 | + | return false | |||
| 269 | + | } | |||
| 270 | + | seen[err] = struct{}{} | |||
| 271 | + | if err == snapcodec.ErrBadVersion { | |||
| 272 | + | return true | |||
| 273 | + | } | |||
| 274 | + | if _, ambiguous := err.(interface{ Unwrap() []error }); ambiguous { | |||
| 275 | + | return false | |||
| 276 | + | } | |||
| 277 | + | unwrapper, ok := err.(interface{ Unwrap() error }) | |||
| 278 | + | if !ok { | |||
| 279 | + | return false | |||
| 280 | + | } | |||
| 281 | + | err = unwrapper.Unwrap() | |||
| 282 | + | } | |||
| 283 | + | return false | |||
| 284 | + | } | |||
| 285 | + | ||||
| 218 | func isRetryableRestoreLoadError(err error) bool { | 286 | func isRetryableRestoreLoadError(err error) bool { | ||
| 219 | if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { | 287 | if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { | ||
| 220 | return false | 288 | return false | ||
| @@ -274,7 +342,7 @@ func sessionFromGeneration(generation ports.SnapshotGeneration) (snapcodec.Sessi | |||||
| 274 | if outPane.Tail, err = generationObject(generation, pane.Tail, snapcodec.HistoryTail); err != nil { | 342 | if outPane.Tail, err = generationObject(generation, pane.Tail, snapcodec.HistoryTail); err != nil { | ||
| 275 | return snapcodec.Session{}, err | 343 | return snapcodec.Session{}, err | ||
| 276 | } | 344 | } | ||
| 277 | - | if outPane.Visible, err = generationObject(generation, pane.Visible, snapcodec.Visible); err != nil { | 345 | + | if outPane.Transcript, err = generationObject(generation, pane.Transcript, snapcodec.RecoveryTranscript); err != nil { |
| 278 | return snapcodec.Session{}, err | 346 | return snapcodec.Session{}, err | ||
| 279 | } | 347 | } | ||
| 280 | outTab.Panes = append(outTab.Panes, outPane) | 348 | outTab.Panes = append(outTab.Panes, outPane) | ||
M internal/usecase/daemon/snapshot_restore_test.go +66 -17
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "math" | |
| 7 | + | "strings" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "testing" | |
| 9 | 10 | ||
| @@ -156,7 +157,7 @@ func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session { | |||
| 156 | 157 | screen.Write([]byte("primary-visible")) | |
| 157 | 158 | screen.Write([]byte("\x1b[?1049h")) | |
| 158 | 159 | screen.Write([]byte("alternate-active")) | |
| 159 | - | visible, err := screen.PrimaryVisibleSnapshot().Marshal() | |
| 160 | + | transcript, err := screen.RecoveryTranscriptSnapshot().Marshal() | |
| 160 | 161 | require.NoError(t, err) | |
| 161 | 162 | ||
| 162 | 163 | paneID := layout.PaneID("pane-7") | |
| @@ -166,7 +167,7 @@ func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session { | |||
| 166 | 167 | StableID: "tab-stable", Cols: 293, Rows: 4, NextPaneID: 8, Focus: paneID, | |
| 167 | 168 | Tree: &layout.Tree{Root: layout.NewLeaf(paneID), Focus: paneID}, | |
| 168 | 169 | Panes: []snapcodec.Pane{{ | |
| 169 | - | ID: paneID, StableID: "pane-stable", Cwd: "/snapshot/cwd", SealedChunks: sealed, Tail: tail, Visible: visible, | |
| 170 | + | ID: paneID, StableID: "pane-stable", Cwd: "/snapshot/cwd", SealedChunks: sealed, Tail: tail, Transcript: transcript, | |
| 170 | 171 | Process: &snapcodec.Process{Argv: []string{"sleep", "99"}, Strategy: "argv"}, | |
| 171 | 172 | }}, | |
| 172 | 173 | }}, | |
| @@ -242,11 +243,20 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) { | |||
| 242 | 243 | require.Equal(t, layout.PaneID("pane-7"), tab.tree.Focus) | |
| 243 | 244 | pane := tab.panes[layout.PaneID("pane-7")] | |
| 244 | 245 | pane.mu.Lock() | |
| 245 | - | require.Equal(t, "primary-visible", string([]rune{pane.screen.Frame.Row(0)[0].Rune, pane.screen.Frame.Row(0)[1].Rune, pane.screen.Frame.Row(0)[2].Rune, pane.screen.Frame.Row(0)[3].Rune, pane.screen.Frame.Row(0)[4].Rune, pane.screen.Frame.Row(0)[5].Rune, pane.screen.Frame.Row(0)[6].Rune, pane.screen.Frame.Row(0)[7].Rune, pane.screen.Frame.Row(0)[8].Rune, pane.screen.Frame.Row(0)[9].Rune, pane.screen.Frame.Row(0)[10].Rune, pane.screen.Frame.Row(0)[11].Rune, pane.screen.Frame.Row(0)[12].Rune, pane.screen.Frame.Row(0)[13].Rune, pane.screen.Frame.Row(0)[14].Rune})) | |
| 246 | - | require.Equal(t, 5, pane.history.Len()) | |
| 247 | - | require.Equal(t, 5*293, pane.history.Cells()) | |
| 246 | + | wantHistory := []string{strings.Repeat("a", 293), strings.Repeat("b", 293), strings.Repeat("c", 293), strings.Repeat("d", 293), strings.Repeat("e", 293), "primary-visible", "alternate-active"} | |
| 247 | + | require.Equal(t, wantHistory, snapshotHistoryTexts(pane.history.View()), "bounded history must precede primary and active alternate transcript rows") | |
| 248 | + | for y := range pane.screen.Frame.Height { | |
| 249 | + | for x := range pane.screen.Frame.Width { | |
| 250 | + | require.Truef(t, pane.screen.Frame.At(x, y).Equal(renderer.BlankCell()), "restored frame cell (%d,%d) must be blank", x, y) | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | require.Equal(t, 7, pane.history.Len()) | |
| 254 | + | require.Equal(t, 7*293, pane.history.Cells()) | |
| 248 | 255 | require.Equal(t, defaultScrollbackRows, pane.history.Cap()) | |
| 249 | 256 | require.Equal(t, defaultScrollbackCells, pane.history.CellCap()) | |
| 257 | + | pane.screen.Write([]byte("new-output")) | |
| 258 | + | pane.screen.Write([]byte("\x1b[?1049h\x1b[?1049l")) | |
| 259 | + | require.Equal(t, wantHistory, snapshotHistoryTexts(pane.history.View()), "new output and redraw must not remove recovered history") | |
| 250 | 260 | pane.mu.Unlock() | |
| 251 | 261 | ||
| 252 | 262 | startSnapshotEncodeWorker(t, d) | |
| @@ -311,6 +321,43 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) { | |||
| 311 | 321 | require.Equal(t, uint64(10), published.Committed.Generation) | |
| 312 | 322 | } | |
| 313 | 323 | ||
| 324 | + | func TestRestorePaneTerminalRequiresCompleteRecoveryAndInstallsAtomically(t *testing.T) { | |
| 325 | + | base := restoreAcceptanceSession(t, "restored").Tabs[0].Panes[0] | |
| 326 | + | for _, tt := range []struct { | |
| 327 | + | name string | |
| 328 | + | mutate func(*snapcodec.Pane) | |
| 329 | + | }{ | |
| 330 | + | {name: "missing tail", mutate: func(snap *snapcodec.Pane) { snap.Tail = nil }}, | |
| 331 | + | {name: "missing transcript", mutate: func(snap *snapcodec.Pane) { snap.Transcript = nil }}, | |
| 332 | + | {name: "invalid transcript", mutate: func(snap *snapcodec.Pane) { snap.Transcript = []byte("invalid") }}, | |
| 333 | + | } { | |
| 334 | + | t.Run(tt.name, func(t *testing.T) { | |
| 335 | + | snap := base | |
| 336 | + | tt.mutate(&snap) | |
| 337 | + | p := newPane("pane", nil, domain.Size{Cols: 293, Rows: 4}) | |
| 338 | + | p.screen.Write([]byte("old-frame")) | |
| 339 | + | require.NoError(t, p.history.Append(testRow("old-history"), vt.LineBound{End: len("old-history")})) | |
| 340 | + | oldScreen, oldHistory := p.screen, p.history | |
| 341 | + | ||
| 342 | + | err := restorePaneTerminal(p, snap) | |
| 343 | + | ||
| 344 | + | require.Error(t, err) | |
| 345 | + | require.Same(t, oldScreen, p.screen) | |
| 346 | + | require.Same(t, oldHistory, p.history) | |
| 347 | + | require.Equal(t, 'o', p.screen.Frame.At(0, 0).Rune, "a failed restore must never install a partial or old persisted frame") | |
| 348 | + | require.Equal(t, "old-history", cellsString(p.history.View().Row(0))) | |
| 349 | + | }) | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | func snapshotHistoryTexts(view vt.HistoryView) []string { | |
| 354 | + | rows := make([]string, view.Len()) | |
| 355 | + | for i := range rows { | |
| 356 | + | rows[i] = strings.TrimRight(cellsString(view.Row(i)), " ") | |
| 357 | + | } | |
| 358 | + | return rows | |
| 359 | + | } | |
| 360 | + | ||
| 314 | 361 | func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) { | |
| 315 | 362 | snapshot := restoreAcceptanceSession(t, "fallback") | |
| 316 | 363 | valid := acceptanceGeneration(t, snapshot, 3) | |
| @@ -325,18 +372,20 @@ func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) { | |||
| 325 | 372 | break | |
| 326 | 373 | } | |
| 327 | 374 | }}, | |
| 328 | - | {"wrong kind", func(g *ports.SnapshotGeneration) { | |
| 329 | - | for digest, data := range g.Objects { | |
| 330 | - | kind, payload, err := snapcodec.UnmarshalObject(data) | |
| 331 | - | require.NoError(t, err) | |
| 332 | - | if kind == snapcodec.HistoryTail { | |
| 333 | - | replacement, err := snapcodec.MarshalObject(snapcodec.Visible, payload) | |
| 334 | - | require.NoError(t, err) | |
| 335 | - | delete(g.Objects, digest) | |
| 336 | - | g.Objects[digest] = replacement.Data | |
| 337 | - | break | |
| 338 | - | } | |
| 339 | - | } | |
| 375 | + | {"wrong role after digest verification", func(g *ports.SnapshotGeneration) { | |
| 376 | + | manifest, err := snapcodec.UnmarshalManifest(g.Manifest) | |
| 377 | + | require.NoError(t, err) | |
| 378 | + | tailRef := manifest.Tabs[0].Panes[0].Tail | |
| 379 | + | _, payload, err := snapcodec.UnmarshalObject(g.Objects[tailRef.Digest]) | |
| 380 | + | require.NoError(t, err) | |
| 381 | + | replacement, err := snapcodec.MarshalObject(snapcodec.RecoveryTranscript, payload) | |
| 382 | + | require.NoError(t, err) | |
| 383 | + | manifest.Tabs[0].Panes[0].Tail.Digest = replacement.Digest | |
| 384 | + | manifest.Tabs[0].Panes[0].Tail.Size = uint32(len(replacement.Data)) | |
| 385 | + | g.Manifest, err = snapcodec.MarshalManifest(manifest) | |
| 386 | + | require.NoError(t, err) | |
| 387 | + | delete(g.Objects, tailRef.Digest) | |
| 388 | + | g.Objects[replacement.Digest] = replacement.Data | |
| 340 | 389 | }}, | |
| 341 | 390 | {"wrong digest", func(g *ports.SnapshotGeneration) { | |
| 342 | 391 | for digest, data := range g.Objects { | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "math" | 6 | "math" | ||
| 7 | + | "strings" | |||
| 7 | "sync" | 8 | "sync" | ||
| 8 | "testing" | 9 | "testing" | ||
| 9 | 10 | ||||
| @@ -156,7 +157,7 @@ func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session { | |||||
| 156 | screen.Write([]byte("primary-visible")) | 157 | screen.Write([]byte("primary-visible")) | ||
| 157 | screen.Write([]byte("\x1b[?1049h")) | 158 | screen.Write([]byte("\x1b[?1049h")) | ||
| 158 | screen.Write([]byte("alternate-active")) | 159 | screen.Write([]byte("alternate-active")) | ||
| 159 | - | visible, err := screen.PrimaryVisibleSnapshot().Marshal() | 160 | + | transcript, err := screen.RecoveryTranscriptSnapshot().Marshal() |
| 160 | require.NoError(t, err) | 161 | require.NoError(t, err) | ||
| 161 | 162 | ||||
| 162 | paneID := layout.PaneID("pane-7") | 163 | paneID := layout.PaneID("pane-7") | ||
| @@ -166,7 +167,7 @@ func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session { | |||||
| 166 | StableID: "tab-stable", Cols: 293, Rows: 4, NextPaneID: 8, Focus: paneID, | 167 | StableID: "tab-stable", Cols: 293, Rows: 4, NextPaneID: 8, Focus: paneID, | ||
| 167 | Tree: &layout.Tree{Root: layout.NewLeaf(paneID), Focus: paneID}, | 168 | Tree: &layout.Tree{Root: layout.NewLeaf(paneID), Focus: paneID}, | ||
| 168 | Panes: []snapcodec.Pane{{ | 169 | Panes: []snapcodec.Pane{{ | ||
| 169 | - | ID: paneID, StableID: "pane-stable", Cwd: "/snapshot/cwd", SealedChunks: sealed, Tail: tail, Visible: visible, | 170 | + | ID: paneID, StableID: "pane-stable", Cwd: "/snapshot/cwd", SealedChunks: sealed, Tail: tail, Transcript: transcript, |
| 170 | Process: &snapcodec.Process{Argv: []string{"sleep", "99"}, Strategy: "argv"}, | 171 | Process: &snapcodec.Process{Argv: []string{"sleep", "99"}, Strategy: "argv"}, | ||
| 171 | }}, | 172 | }}, | ||
| 172 | }}, | 173 | }}, | ||
| @@ -242,11 +243,20 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) { | |||||
| 242 | require.Equal(t, layout.PaneID("pane-7"), tab.tree.Focus) | 243 | require.Equal(t, layout.PaneID("pane-7"), tab.tree.Focus) | ||
| 243 | pane := tab.panes[layout.PaneID("pane-7")] | 244 | pane := tab.panes[layout.PaneID("pane-7")] | ||
| 244 | pane.mu.Lock() | 245 | pane.mu.Lock() | ||
| 245 | - | require.Equal(t, "primary-visible", string([]rune{pane.screen.Frame.Row(0)[0].Rune, pane.screen.Frame.Row(0)[1].Rune, pane.screen.Frame.Row(0)[2].Rune, pane.screen.Frame.Row(0)[3].Rune, pane.screen.Frame.Row(0)[4].Rune, pane.screen.Frame.Row(0)[5].Rune, pane.screen.Frame.Row(0)[6].Rune, pane.screen.Frame.Row(0)[7].Rune, pane.screen.Frame.Row(0)[8].Rune, pane.screen.Frame.Row(0)[9].Rune, pane.screen.Frame.Row(0)[10].Rune, pane.screen.Frame.Row(0)[11].Rune, pane.screen.Frame.Row(0)[12].Rune, pane.screen.Frame.Row(0)[13].Rune, pane.screen.Frame.Row(0)[14].Rune})) | 246 | + | wantHistory := []string{strings.Repeat("a", 293), strings.Repeat("b", 293), strings.Repeat("c", 293), strings.Repeat("d", 293), strings.Repeat("e", 293), "primary-visible", "alternate-active"} |
| 246 | - | require.Equal(t, 5, pane.history.Len()) | 247 | + | require.Equal(t, wantHistory, snapshotHistoryTexts(pane.history.View()), "bounded history must precede primary and active alternate transcript rows") |
| 247 | - | require.Equal(t, 5*293, pane.history.Cells()) | 248 | + | for y := range pane.screen.Frame.Height { |
| 249 | + | for x := range pane.screen.Frame.Width { | |||
| 250 | + | require.Truef(t, pane.screen.Frame.At(x, y).Equal(renderer.BlankCell()), "restored frame cell (%d,%d) must be blank", x, y) | |||
| 251 | + | } | |||
| 252 | + | } | |||
| 253 | + | require.Equal(t, 7, pane.history.Len()) | |||
| 254 | + | require.Equal(t, 7*293, pane.history.Cells()) | |||
| 248 | require.Equal(t, defaultScrollbackRows, pane.history.Cap()) | 255 | require.Equal(t, defaultScrollbackRows, pane.history.Cap()) | ||
| 249 | require.Equal(t, defaultScrollbackCells, pane.history.CellCap()) | 256 | require.Equal(t, defaultScrollbackCells, pane.history.CellCap()) | ||
| 257 | + | pane.screen.Write([]byte("new-output")) | |||
| 258 | + | pane.screen.Write([]byte("\x1b[?1049h\x1b[?1049l")) | |||
| 259 | + | require.Equal(t, wantHistory, snapshotHistoryTexts(pane.history.View()), "new output and redraw must not remove recovered history") | |||
| 250 | pane.mu.Unlock() | 260 | pane.mu.Unlock() | ||
| 251 | 261 | ||||
| 252 | startSnapshotEncodeWorker(t, d) | 262 | startSnapshotEncodeWorker(t, d) | ||
| @@ -311,6 +321,43 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) { | |||||
| 311 | require.Equal(t, uint64(10), published.Committed.Generation) | 321 | require.Equal(t, uint64(10), published.Committed.Generation) | ||
| 312 | } | 322 | } | ||
| 313 | 323 | ||||
| 324 | + | func TestRestorePaneTerminalRequiresCompleteRecoveryAndInstallsAtomically(t *testing.T) { | |||
| 325 | + | base := restoreAcceptanceSession(t, "restored").Tabs[0].Panes[0] | |||
| 326 | + | for _, tt := range []struct { | |||
| 327 | + | name string | |||
| 328 | + | mutate func(*snapcodec.Pane) | |||
| 329 | + | }{ | |||
| 330 | + | {name: "missing tail", mutate: func(snap *snapcodec.Pane) { snap.Tail = nil }}, | |||
| 331 | + | {name: "missing transcript", mutate: func(snap *snapcodec.Pane) { snap.Transcript = nil }}, | |||
| 332 | + | {name: "invalid transcript", mutate: func(snap *snapcodec.Pane) { snap.Transcript = []byte("invalid") }}, | |||
| 333 | + | } { | |||
| 334 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 335 | + | snap := base | |||
| 336 | + | tt.mutate(&snap) | |||
| 337 | + | p := newPane("pane", nil, domain.Size{Cols: 293, Rows: 4}) | |||
| 338 | + | p.screen.Write([]byte("old-frame")) | |||
| 339 | + | require.NoError(t, p.history.Append(testRow("old-history"), vt.LineBound{End: len("old-history")})) | |||
| 340 | + | oldScreen, oldHistory := p.screen, p.history | |||
| 341 | + | ||||
| 342 | + | err := restorePaneTerminal(p, snap) | |||
| 343 | + | ||||
| 344 | + | require.Error(t, err) | |||
| 345 | + | require.Same(t, oldScreen, p.screen) | |||
| 346 | + | require.Same(t, oldHistory, p.history) | |||
| 347 | + | require.Equal(t, 'o', p.screen.Frame.At(0, 0).Rune, "a failed restore must never install a partial or old persisted frame") | |||
| 348 | + | require.Equal(t, "old-history", cellsString(p.history.View().Row(0))) | |||
| 349 | + | }) | |||
| 350 | + | } | |||
| 351 | + | } | |||
| 352 | + | ||||
| 353 | + | func snapshotHistoryTexts(view vt.HistoryView) []string { | |||
| 354 | + | rows := make([]string, view.Len()) | |||
| 355 | + | for i := range rows { | |||
| 356 | + | rows[i] = strings.TrimRight(cellsString(view.Row(i)), " ") | |||
| 357 | + | } | |||
| 358 | + | return rows | |||
| 359 | + | } | |||
| 360 | + | ||||
| 314 | func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) { | 361 | func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) { | ||
| 315 | snapshot := restoreAcceptanceSession(t, "fallback") | 362 | snapshot := restoreAcceptanceSession(t, "fallback") | ||
| 316 | valid := acceptanceGeneration(t, snapshot, 3) | 363 | valid := acceptanceGeneration(t, snapshot, 3) | ||
| @@ -325,18 +372,20 @@ func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) { | |||||
| 325 | break | 372 | break | ||
| 326 | } | 373 | } | ||
| 327 | }}, | 374 | }}, | ||
| 328 | - | {"wrong kind", func(g *ports.SnapshotGeneration) { | 375 | + | {"wrong role after digest verification", func(g *ports.SnapshotGeneration) { |
| 329 | - | for digest, data := range g.Objects { | 376 | + | manifest, err := snapcodec.UnmarshalManifest(g.Manifest) |
| 330 | - | kind, payload, err := snapcodec.UnmarshalObject(data) | 377 | + | require.NoError(t, err) |
| 331 | - | require.NoError(t, err) | 378 | + | tailRef := manifest.Tabs[0].Panes[0].Tail |
| 332 | - | if kind == snapcodec.HistoryTail { | 379 | + | _, payload, err := snapcodec.UnmarshalObject(g.Objects[tailRef.Digest]) |
| 333 | - | replacement, err := snapcodec.MarshalObject(snapcodec.Visible, payload) | 380 | + | require.NoError(t, err) |
| 334 | - | require.NoError(t, err) | 381 | + | replacement, err := snapcodec.MarshalObject(snapcodec.RecoveryTranscript, payload) |
| 335 | - | delete(g.Objects, digest) | 382 | + | require.NoError(t, err) |
| 336 | - | g.Objects[digest] = replacement.Data | 383 | + | manifest.Tabs[0].Panes[0].Tail.Digest = replacement.Digest |
| 337 | - | break | 384 | + | manifest.Tabs[0].Panes[0].Tail.Size = uint32(len(replacement.Data)) |
| 338 | - | } | 385 | + | g.Manifest, err = snapcodec.MarshalManifest(manifest) |
| 339 | - | } | 386 | + | require.NoError(t, err) |
| 387 | + | delete(g.Objects, tailRef.Digest) | |||
| 388 | + | g.Objects[replacement.Digest] = replacement.Data | |||
| 340 | }}, | 389 | }}, | ||
| 341 | {"wrong digest", func(g *ports.SnapshotGeneration) { | 390 | {"wrong digest", func(g *ports.SnapshotGeneration) { | ||
| 342 | for digest, data := range g.Objects { | 391 | for digest, data := range g.Objects { | ||
M internal/usecase/recovery/discard.go +2 -18
| @@ -4,8 +4,6 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/vev/internal/domain" | |
| 9 | 7 | ) | |
| 10 | 8 | ||
| 11 | 9 | var ( | |
| @@ -43,20 +41,6 @@ func (c *Coordinator) Discard(ctx context.Context, name string) error { | |||
| 43 | 41 | return ErrSessionNotBroken | |
| 44 | 42 | } | |
| 45 | 43 | ||
| 46 | - | old := record.IncarnationID | |
| 47 | - | next, err := domain.NewIncarnationID(c.random) | |
| 48 | - | if err != nil { | |
| 49 | - | return fmt.Errorf("recovery: generate replacement incarnation: %w", err) | |
| 50 | - | } | |
| 51 | - | fresh := record | |
| 52 | - | fresh.IncarnationID = next | |
| 53 | - | fresh.Committed = nil | |
| 54 | - | fresh.DegradedReason = "" | |
| 55 | - | if err := fresh.Validate(); err != nil { | |
| 56 | - | return fmt.Errorf("recovery: invalid discard replacement: %w", err) | |
| 57 | - | } | |
| 58 | - | if err := c.catalogue.Replace(name, fresh); err != nil { | |
| 59 | - | return err | |
| 60 | - | } | |
| 61 | - | return c.repository.DeleteIncarnation(ctx, old) | |
| 44 | + | _, _, err = c.replaceIncarnationLocked(ctx, record, false, "discard") | |
| 45 | + | return err | |
| 62 | 46 | } | |
| @@ -4,8 +4,6 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | - | ||||
| 8 | - | "github.com/bnema/vev/internal/domain" | |||
| 9 | ) | 7 | ) | ||
| 10 | 8 | ||||
| 11 | var ( | 9 | var ( | ||
| @@ -43,20 +41,6 @@ func (c *Coordinator) Discard(ctx context.Context, name string) error { | |||||
| 43 | return ErrSessionNotBroken | 41 | return ErrSessionNotBroken | ||
| 44 | } | 42 | } | ||
| 45 | 43 | ||||
| 46 | - | old := record.IncarnationID | 44 | + | _, _, err = c.replaceIncarnationLocked(ctx, record, false, "discard") |
| 47 | - | next, err := domain.NewIncarnationID(c.random) | 45 | + | return err |
| 48 | - | if err != nil { | |||
| 49 | - | return fmt.Errorf("recovery: generate replacement incarnation: %w", err) | |||
| 50 | - | } | |||
| 51 | - | fresh := record | |||
| 52 | - | fresh.IncarnationID = next | |||
| 53 | - | fresh.Committed = nil | |||
| 54 | - | fresh.DegradedReason = "" | |||
| 55 | - | if err := fresh.Validate(); err != nil { | |||
| 56 | - | return fmt.Errorf("recovery: invalid discard replacement: %w", err) | |||
| 57 | - | } | |||
| 58 | - | if err := c.catalogue.Replace(name, fresh); err != nil { | |||
| 59 | - | return err | |||
| 60 | - | } | |||
| 61 | - | return c.repository.DeleteIncarnation(ctx, old) | |||
| 62 | } | 46 | } | ||
A internal/usecase/recovery/reset.go +62 -0
| @@ -0,0 +1,62 @@ | |||
| 1 | + | package recovery | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // ResetIncompatible replaces an exact healthy checkpoint authority with a | |
| 12 | + | // fresh incarnation. Stale observations are harmless no-ops. | |
| 13 | + | func (c *Coordinator) ResetIncompatible(ctx context.Context, name string, expectedIncarnation domain.IncarnationID, expectedCheckpoint domain.CheckpointRef) (domain.CatalogueRecord, bool, error) { | |
| 14 | + | if c == nil || c.catalogue == nil || c.repository == nil || c.locks == nil || c.random == nil { | |
| 15 | + | return domain.CatalogueRecord{}, false, errors.New("recovery: incomplete incompatible-reset dependencies") | |
| 16 | + | } | |
| 17 | + | if err := ctx.Err(); err != nil { | |
| 18 | + | return domain.CatalogueRecord{}, false, err | |
| 19 | + | } | |
| 20 | + | c.mutationMu.Lock() | |
| 21 | + | defer c.mutationMu.Unlock() | |
| 22 | + | unlock := c.locks.Lock([]string{name}) | |
| 23 | + | defer unlock() | |
| 24 | + | ||
| 25 | + | record, ok, err := c.catalogue.Record(name) | |
| 26 | + | if err != nil { | |
| 27 | + | return domain.CatalogueRecord{}, false, err | |
| 28 | + | } | |
| 29 | + | if !ok || record.IncarnationID != expectedIncarnation || record.DegradedReason != "" || | |
| 30 | + | record.Committed == nil || *record.Committed != expectedCheckpoint { | |
| 31 | + | return domain.CatalogueRecord{}, false, nil | |
| 32 | + | } | |
| 33 | + | return c.replaceIncarnationLocked(ctx, record, true, "reset") | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // replaceIncarnationLocked commits a fresh catalogue authority before removing | |
| 37 | + | // the old repository incarnation. The caller must hold mutationMu and the | |
| 38 | + | // record name's KeyLock. | |
| 39 | + | func (c *Coordinator) replaceIncarnationLocked(ctx context.Context, record domain.CatalogueRecord, clearTabNames bool, operation string) (domain.CatalogueRecord, bool, error) { | |
| 40 | + | old := record.IncarnationID | |
| 41 | + | next, err := domain.NewIncarnationID(c.random) | |
| 42 | + | if err != nil { | |
| 43 | + | return domain.CatalogueRecord{}, false, fmt.Errorf("recovery: generate replacement incarnation: %w", err) | |
| 44 | + | } | |
| 45 | + | fresh := record | |
| 46 | + | fresh.IncarnationID = next | |
| 47 | + | fresh.Committed = nil | |
| 48 | + | fresh.DegradedReason = "" | |
| 49 | + | if clearTabNames { | |
| 50 | + | fresh.TabNames = nil | |
| 51 | + | } | |
| 52 | + | if err := fresh.Validate(); err != nil { | |
| 53 | + | return domain.CatalogueRecord{}, false, fmt.Errorf("recovery: invalid %s replacement: %w", operation, err) | |
| 54 | + | } | |
| 55 | + | if err := c.catalogue.Replace(record.Name, fresh); err != nil { | |
| 56 | + | return domain.CatalogueRecord{}, false, err | |
| 57 | + | } | |
| 58 | + | if err := c.repository.DeleteIncarnation(ctx, old); err != nil { | |
| 59 | + | return fresh, true, err | |
| 60 | + | } | |
| 61 | + | return fresh, true, nil | |
| 62 | + | } | |
| @@ -0,0 +1,62 @@ | |||||
| 1 | + | package recovery | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // ResetIncompatible replaces an exact healthy checkpoint authority with a | |||
| 12 | + | // fresh incarnation. Stale observations are harmless no-ops. | |||
| 13 | + | func (c *Coordinator) ResetIncompatible(ctx context.Context, name string, expectedIncarnation domain.IncarnationID, expectedCheckpoint domain.CheckpointRef) (domain.CatalogueRecord, bool, error) { | |||
| 14 | + | if c == nil || c.catalogue == nil || c.repository == nil || c.locks == nil || c.random == nil { | |||
| 15 | + | return domain.CatalogueRecord{}, false, errors.New("recovery: incomplete incompatible-reset dependencies") | |||
| 16 | + | } | |||
| 17 | + | if err := ctx.Err(); err != nil { | |||
| 18 | + | return domain.CatalogueRecord{}, false, err | |||
| 19 | + | } | |||
| 20 | + | c.mutationMu.Lock() | |||
| 21 | + | defer c.mutationMu.Unlock() | |||
| 22 | + | unlock := c.locks.Lock([]string{name}) | |||
| 23 | + | defer unlock() | |||
| 24 | + | ||||
| 25 | + | record, ok, err := c.catalogue.Record(name) | |||
| 26 | + | if err != nil { | |||
| 27 | + | return domain.CatalogueRecord{}, false, err | |||
| 28 | + | } | |||
| 29 | + | if !ok || record.IncarnationID != expectedIncarnation || record.DegradedReason != "" || | |||
| 30 | + | record.Committed == nil || *record.Committed != expectedCheckpoint { | |||
| 31 | + | return domain.CatalogueRecord{}, false, nil | |||
| 32 | + | } | |||
| 33 | + | return c.replaceIncarnationLocked(ctx, record, true, "reset") | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // replaceIncarnationLocked commits a fresh catalogue authority before removing | |||
| 37 | + | // the old repository incarnation. The caller must hold mutationMu and the | |||
| 38 | + | // record name's KeyLock. | |||
| 39 | + | func (c *Coordinator) replaceIncarnationLocked(ctx context.Context, record domain.CatalogueRecord, clearTabNames bool, operation string) (domain.CatalogueRecord, bool, error) { | |||
| 40 | + | old := record.IncarnationID | |||
| 41 | + | next, err := domain.NewIncarnationID(c.random) | |||
| 42 | + | if err != nil { | |||
| 43 | + | return domain.CatalogueRecord{}, false, fmt.Errorf("recovery: generate replacement incarnation: %w", err) | |||
| 44 | + | } | |||
| 45 | + | fresh := record | |||
| 46 | + | fresh.IncarnationID = next | |||
| 47 | + | fresh.Committed = nil | |||
| 48 | + | fresh.DegradedReason = "" | |||
| 49 | + | if clearTabNames { | |||
| 50 | + | fresh.TabNames = nil | |||
| 51 | + | } | |||
| 52 | + | if err := fresh.Validate(); err != nil { | |||
| 53 | + | return domain.CatalogueRecord{}, false, fmt.Errorf("recovery: invalid %s replacement: %w", operation, err) | |||
| 54 | + | } | |||
| 55 | + | if err := c.catalogue.Replace(record.Name, fresh); err != nil { | |||
| 56 | + | return domain.CatalogueRecord{}, false, err | |||
| 57 | + | } | |||
| 58 | + | if err := c.repository.DeleteIncarnation(ctx, old); err != nil { | |||
| 59 | + | return fresh, true, err | |||
| 60 | + | } | |||
| 61 | + | return fresh, true, nil | |||
| 62 | + | } | |||
M internal/usecase/recovery/transactions_test.go +195 -0
| @@ -107,6 +107,7 @@ type transactionRepository struct { | |||
| 107 | 107 | ports.SnapshotRepository | |
| 108 | 108 | mu sync.Mutex | |
| 109 | 109 | deleteErrOnce error | |
| 110 | + | deleteCalls []domain.IncarnationID | |
| 110 | 111 | deletedIDs []domain.IncarnationID | |
| 111 | 112 | deleteEntered chan struct{} | |
| 112 | 113 | releaseDelete chan struct{} | |
| @@ -204,6 +205,7 @@ func (r *transactionRepository) DeleteIncarnation(_ context.Context, id domain.I | |||
| 204 | 205 | } | |
| 205 | 206 | r.mu.Lock() | |
| 206 | 207 | defer r.mu.Unlock() | |
| 208 | + | r.deleteCalls = append(r.deleteCalls, id) | |
| 207 | 209 | if r.deleteErrOnce != nil { | |
| 208 | 210 | err := r.deleteErrOnce | |
| 209 | 211 | r.deleteErrOnce = nil | |
| @@ -220,6 +222,137 @@ func degradedTransactionRecord() domain.CatalogueRecord { | |||
| 220 | 222 | } | |
| 221 | 223 | } | |
| 222 | 224 | ||
| 225 | + | func healthyTransactionRecord() domain.CatalogueRecord { | |
| 226 | + | return domain.CatalogueRecord{ | |
| 227 | + | Name: "work", | |
| 228 | + | IncarnationID: domain.IncarnationID{1}, | |
| 229 | + | Cwd: "/workspace", | |
| 230 | + | CreatedAt: 11, | |
| 231 | + | UpdatedAt: 22, | |
| 232 | + | LastUsedSeq: 33, | |
| 233 | + | TabNames: []string{"shell", "logs"}, | |
| 234 | + | Committed: &domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{3}}, | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | func TestResetIncompatibleReplacesExactHealthyCheckpoint(t *testing.T) { | |
| 239 | + | t.Parallel() | |
| 240 | + | old := healthyTransactionRecord() | |
| 241 | + | catalogue := newTransactionCatalogue(old) | |
| 242 | + | repository := &transactionRepository{} | |
| 243 | + | coordinator := NewCoordinator(catalogue, repository, bytes.NewReader(bytes.Repeat([]byte{2}, 16))) | |
| 244 | + | ||
| 245 | + | fresh, committed, err := coordinator.ResetIncompatible(context.Background(), old.Name, old.IncarnationID, *old.Committed) | |
| 246 | + | ||
| 247 | + | require.NoError(t, err) | |
| 248 | + | require.True(t, committed) | |
| 249 | + | require.NotZero(t, fresh.IncarnationID) | |
| 250 | + | require.NotEqual(t, old.IncarnationID, fresh.IncarnationID) | |
| 251 | + | require.Equal(t, old.Name, fresh.Name) | |
| 252 | + | require.Equal(t, old.Cwd, fresh.Cwd) | |
| 253 | + | require.Equal(t, old.CreatedAt, fresh.CreatedAt) | |
| 254 | + | require.Equal(t, old.UpdatedAt, fresh.UpdatedAt) | |
| 255 | + | require.Equal(t, old.LastUsedSeq, fresh.LastUsedSeq) | |
| 256 | + | require.Empty(t, fresh.TabNames) | |
| 257 | + | require.Nil(t, fresh.Committed) | |
| 258 | + | require.Empty(t, fresh.DegradedReason) | |
| 259 | + | stored, ok, recordErr := catalogue.Record(old.Name) | |
| 260 | + | require.NoError(t, recordErr) | |
| 261 | + | require.True(t, ok) | |
| 262 | + | require.True(t, fresh.Equal(stored)) | |
| 263 | + | require.Equal(t, []domain.IncarnationID{old.IncarnationID}, repository.deleteCalls) | |
| 264 | + | require.Equal(t, []domain.IncarnationID{old.IncarnationID}, repository.deletedIDs) | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | func TestResetIncompatibleDeleteFailureReturnsCommittedFreshAuthority(t *testing.T) { | |
| 268 | + | t.Parallel() | |
| 269 | + | old := healthyTransactionRecord() | |
| 270 | + | cause := errors.New("repository delete failed") | |
| 271 | + | catalogue := newTransactionCatalogue(old) | |
| 272 | + | repository := &transactionRepository{deleteErrOnce: cause} | |
| 273 | + | coordinator := NewCoordinator(catalogue, repository, bytes.NewReader(bytes.Repeat([]byte{2}, 16))) | |
| 274 | + | ||
| 275 | + | fresh, committed, err := coordinator.ResetIncompatible(context.Background(), old.Name, old.IncarnationID, *old.Committed) | |
| 276 | + | ||
| 277 | + | require.ErrorIs(t, err, cause) | |
| 278 | + | require.True(t, committed) | |
| 279 | + | require.NotEqual(t, old.IncarnationID, fresh.IncarnationID) | |
| 280 | + | stored, ok, recordErr := catalogue.Record(old.Name) | |
| 281 | + | require.NoError(t, recordErr) | |
| 282 | + | require.True(t, ok) | |
| 283 | + | require.True(t, fresh.Equal(stored)) | |
| 284 | + | require.Equal(t, []domain.IncarnationID{old.IncarnationID}, repository.deleteCalls) | |
| 285 | + | require.Empty(t, repository.deletedIDs) | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func TestResetIncompatibleReplaceFailurePreservesOldAuthorityAndRepository(t *testing.T) { | |
| 289 | + | t.Parallel() | |
| 290 | + | old := healthyTransactionRecord() | |
| 291 | + | cause := errors.New("catalogue replace failed") | |
| 292 | + | catalogue := newTransactionCatalogue(old) | |
| 293 | + | catalogue.replaceErrOnce = cause | |
| 294 | + | repository := &transactionRepository{} | |
| 295 | + | coordinator := NewCoordinator(catalogue, repository, bytes.NewReader(bytes.Repeat([]byte{2}, 16))) | |
| 296 | + | ||
| 297 | + | fresh, committed, err := coordinator.ResetIncompatible(context.Background(), old.Name, old.IncarnationID, *old.Committed) | |
| 298 | + | ||
| 299 | + | require.ErrorIs(t, err, cause) | |
| 300 | + | require.False(t, committed) | |
| 301 | + | require.Equal(t, domain.CatalogueRecord{}, fresh) | |
| 302 | + | stored, ok, recordErr := catalogue.Record(old.Name) | |
| 303 | + | require.NoError(t, recordErr) | |
| 304 | + | require.True(t, ok) | |
| 305 | + | require.True(t, old.Equal(stored)) | |
| 306 | + | require.Empty(t, repository.deleteCalls) | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | func TestResetIncompatibleStaleAuthorityIsNoOp(t *testing.T) { | |
| 310 | + | t.Parallel() | |
| 311 | + | old := healthyTransactionRecord() | |
| 312 | + | otherGeneration := *old.Committed | |
| 313 | + | otherGeneration.Generation++ | |
| 314 | + | otherDigest := *old.Committed | |
| 315 | + | otherDigest.ManifestDigest[0]++ | |
| 316 | + | uncommitted := old | |
| 317 | + | uncommitted.Committed = nil | |
| 318 | + | tests := []struct { | |
| 319 | + | name string | |
| 320 | + | record *domain.CatalogueRecord | |
| 321 | + | expected domain.IncarnationID | |
| 322 | + | ref domain.CheckpointRef | |
| 323 | + | }{ | |
| 324 | + | {name: "missing record", expected: old.IncarnationID, ref: *old.Committed}, | |
| 325 | + | {name: "different incarnation", record: &old, expected: domain.IncarnationID{9}, ref: *old.Committed}, | |
| 326 | + | {name: "missing checkpoint", record: &uncommitted, expected: old.IncarnationID, ref: *old.Committed}, | |
| 327 | + | {name: "different checkpoint generation", record: &old, expected: old.IncarnationID, ref: otherGeneration}, | |
| 328 | + | {name: "different checkpoint digest", record: &old, expected: old.IncarnationID, ref: otherDigest}, | |
| 329 | + | {name: "degraded record", record: func() *domain.CatalogueRecord { | |
| 330 | + | degraded := old | |
| 331 | + | degraded.DegradedReason = "restore failed" | |
| 332 | + | return °raded | |
| 333 | + | }(), expected: old.IncarnationID, ref: *old.Committed}, | |
| 334 | + | } | |
| 335 | + | for _, tt := range tests { | |
| 336 | + | t.Run(tt.name, func(t *testing.T) { | |
| 337 | + | t.Parallel() | |
| 338 | + | catalogue := newTransactionCatalogue() | |
| 339 | + | if tt.record != nil { | |
| 340 | + | catalogue.records[tt.record.Name] = *tt.record | |
| 341 | + | } | |
| 342 | + | repository := &transactionRepository{} | |
| 343 | + | coordinator := NewCoordinator(catalogue, repository, bytes.NewReader(bytes.Repeat([]byte{2}, 16))) | |
| 344 | + | ||
| 345 | + | fresh, committed, err := coordinator.ResetIncompatible(context.Background(), old.Name, tt.expected, tt.ref) | |
| 346 | + | ||
| 347 | + | require.NoError(t, err) | |
| 348 | + | require.False(t, committed) | |
| 349 | + | require.Equal(t, domain.CatalogueRecord{}, fresh) | |
| 350 | + | require.Empty(t, catalogue.events) | |
| 351 | + | require.Empty(t, repository.deleteCalls) | |
| 352 | + | }) | |
| 353 | + | } | |
| 354 | + | } | |
| 355 | + | ||
| 223 | 356 | func TestStartupGarbageCollectionFencesNewIncarnationsAndCheckpointPublications(t *testing.T) { | |
| 224 | 357 | t.Parallel() | |
| 225 | 358 | ||
| @@ -338,6 +471,66 @@ func TestMarkBrokenCannotOverwriteDiscardReplacement(t *testing.T) { | |||
| 338 | 471 | require.Empty(t, got.DegradedReason) | |
| 339 | 472 | } | |
| 340 | 473 | ||
| 474 | + | func TestDiscardPublicPreconditions(t *testing.T) { | |
| 475 | + | t.Parallel() | |
| 476 | + | uncommitted := degradedTransactionRecord() | |
| 477 | + | uncommitted.Name = "work" | |
| 478 | + | uncommitted.Committed = nil | |
| 479 | + | uncommitted.DegradedReason = "" | |
| 480 | + | healthy := healthyTransactionRecord() | |
| 481 | + | tests := []struct { | |
| 482 | + | name string | |
| 483 | + | record *domain.CatalogueRecord | |
| 484 | + | incomplete bool | |
| 485 | + | cancelled bool | |
| 486 | + | wantErr error | |
| 487 | + | }{ | |
| 488 | + | {name: "incomplete dependencies", incomplete: true, wantErr: errors.New("recovery: incomplete discard dependencies")}, | |
| 489 | + | {name: "cancelled context", record: &healthy, cancelled: true, wantErr: context.Canceled}, | |
| 490 | + | {name: "missing record", wantErr: ErrRecoveryRecordNotFound}, | |
| 491 | + | {name: "already fresh incarnation", record: &uncommitted}, | |
| 492 | + | {name: "healthy checkpoint", record: &healthy, wantErr: ErrSessionNotBroken}, | |
| 493 | + | } | |
| 494 | + | for _, tt := range tests { | |
| 495 | + | t.Run(tt.name, func(t *testing.T) { | |
| 496 | + | t.Parallel() | |
| 497 | + | ctx := context.Background() | |
| 498 | + | if tt.cancelled { | |
| 499 | + | cancelled, cancel := context.WithCancel(ctx) | |
| 500 | + | cancel() | |
| 501 | + | ctx = cancelled | |
| 502 | + | } | |
| 503 | + | var coordinator *Coordinator | |
| 504 | + | var catalogue *transactionCatalogue | |
| 505 | + | var repository *transactionRepository | |
| 506 | + | if tt.incomplete { | |
| 507 | + | coordinator = &Coordinator{} | |
| 508 | + | } else { | |
| 509 | + | catalogue = newTransactionCatalogue() | |
| 510 | + | if tt.record != nil { | |
| 511 | + | catalogue.records[tt.record.Name] = *tt.record | |
| 512 | + | } | |
| 513 | + | repository = &transactionRepository{} | |
| 514 | + | coordinator = NewCoordinator(catalogue, repository, bytes.NewReader(bytes.Repeat([]byte{2}, 16))) | |
| 515 | + | } | |
| 516 | + | ||
| 517 | + | err := coordinator.Discard(ctx, "work") | |
| 518 | + | ||
| 519 | + | if tt.wantErr == nil { | |
| 520 | + | require.NoError(t, err) | |
| 521 | + | } else if tt.incomplete { | |
| 522 | + | require.EqualError(t, err, tt.wantErr.Error()) | |
| 523 | + | } else { | |
| 524 | + | require.ErrorIs(t, err, tt.wantErr) | |
| 525 | + | } | |
| 526 | + | if !tt.incomplete { | |
| 527 | + | require.Empty(t, catalogue.events) | |
| 528 | + | require.Empty(t, repository.deleteCalls) | |
| 529 | + | } | |
| 530 | + | }) | |
| 531 | + | } | |
| 532 | + | } | |
| 533 | + | ||
| 341 | 534 | func TestDiscardIsRetryIdempotent(t *testing.T) { | |
| 342 | 535 | t.Parallel() | |
| 343 | 536 | failure := errors.New("injected discard failure") | |
| @@ -352,6 +545,7 @@ func TestDiscardIsRetryIdempotent(t *testing.T) { | |||
| 352 | 545 | t.Run(tt.name, func(t *testing.T) { | |
| 353 | 546 | t.Parallel() | |
| 354 | 547 | old := degradedTransactionRecord() | |
| 548 | + | old.TabNames = []string{"shell"} | |
| 355 | 549 | catalogue := newTransactionCatalogue(old) | |
| 356 | 550 | repository := &transactionRepository{} | |
| 357 | 551 | if tt.failBefore { | |
| @@ -371,6 +565,7 @@ func TestDiscardIsRetryIdempotent(t *testing.T) { | |||
| 371 | 565 | require.True(t, ok) | |
| 372 | 566 | require.Nil(t, got.Committed) | |
| 373 | 567 | require.NotEqual(t, old.IncarnationID, got.IncarnationID) | |
| 568 | + | require.Equal(t, old.TabNames, got.TabNames) | |
| 374 | 569 | if tt.failBefore { | |
| 375 | 570 | require.Contains(t, repository.deletedIDs, old.IncarnationID) | |
| 376 | 571 | } else { | |
| @@ -107,6 +107,7 @@ type transactionRepository struct { | |||||
| 107 | ports.SnapshotRepository | 107 | ports.SnapshotRepository | ||
| 108 | mu sync.Mutex | 108 | mu sync.Mutex | ||
| 109 | deleteErrOnce error | 109 | deleteErrOnce error | ||
| 110 | + | deleteCalls []domain.IncarnationID | |||
| 110 | deletedIDs []domain.IncarnationID | 111 | deletedIDs []domain.IncarnationID | ||
| 111 | deleteEntered chan struct{} | 112 | deleteEntered chan struct{} | ||
| 112 | releaseDelete chan struct{} | 113 | releaseDelete chan struct{} | ||
| @@ -204,6 +205,7 @@ func (r *transactionRepository) DeleteIncarnation(_ context.Context, id domain.I | |||||
| 204 | } | 205 | } | ||
| 205 | r.mu.Lock() | 206 | r.mu.Lock() | ||
| 206 | defer r.mu.Unlock() | 207 | defer r.mu.Unlock() | ||
| 208 | + | r.deleteCalls = append(r.deleteCalls, id) | |||
| 207 | if r.deleteErrOnce != nil { | 209 | if r.deleteErrOnce != nil { | ||
| 208 | err := r.deleteErrOnce | 210 | err := r.deleteErrOnce | ||
| 209 | r.deleteErrOnce = nil | 211 | r.deleteErrOnce = nil | ||
| @@ -220,6 +222,137 @@ func degradedTransactionRecord() domain.CatalogueRecord { | |||||
| 220 | } | 222 | } | ||
| 221 | } | 223 | } | ||
| 222 | 224 | ||||
| 225 | + | func healthyTransactionRecord() domain.CatalogueRecord { | |||
| 226 | + | return domain.CatalogueRecord{ | |||
| 227 | + | Name: "work", | |||
| 228 | + | IncarnationID: domain.IncarnationID{1}, | |||
| 229 | + | Cwd: "/workspace", | |||
| 230 | + | CreatedAt: 11, | |||
| 231 | + | UpdatedAt: 22, | |||
| 232 | + | LastUsedSeq: 33, | |||
| 233 | + | TabNames: []string{"shell", "logs"}, | |||
| 234 | + | Committed: &domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{3}}, | |||
| 235 | + | } | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | func TestResetIncompatibleReplacesExactHealthyCheckpoint(t *testing.T) { | |||
| 239 | + | t.Parallel() | |||
| 240 | + | old := healthyTransactionRecord() | |||
| 241 | + | catalogue := newTransactionCatalogue(old) | |||
| 242 | + | repository := &transactionRepository{} | |||
| 243 | + | coordinator := NewCoordinator(catalogue, repository, bytes.NewReader(bytes.Repeat([]byte{2}, 16))) | |||
| 244 | + | ||||
| 245 | + | fresh, committed, err := coordinator.ResetIncompatible(context.Background(), old.Name, old.IncarnationID, *old.Committed) | |||
| 246 | + | ||||
| 247 | + | require.NoError(t, err) | |||
| 248 | + | require.True(t, committed) | |||
| 249 | + | require.NotZero(t, fresh.IncarnationID) | |||
| 250 | + | require.NotEqual(t, old.IncarnationID, fresh.IncarnationID) | |||
| 251 | + | require.Equal(t, old.Name, fresh.Name) | |||
| 252 | + | require.Equal(t, old.Cwd, fresh.Cwd) | |||
| 253 | + | require.Equal(t, old.CreatedAt, fresh.CreatedAt) | |||
| 254 | + | require.Equal(t, old.UpdatedAt, fresh.UpdatedAt) | |||
| 255 | + | require.Equal(t, old.LastUsedSeq, fresh.LastUsedSeq) | |||
| 256 | + | require.Empty(t, fresh.TabNames) | |||
| 257 | + | require.Nil(t, fresh.Committed) | |||
| 258 | + | require.Empty(t, fresh.DegradedReason) | |||
| 259 | + | stored, ok, recordErr := catalogue.Record(old.Name) | |||
| 260 | + | require.NoError(t, recordErr) | |||
| 261 | + | require.True(t, ok) | |||
| 262 | + | require.True(t, fresh.Equal(stored)) | |||
| 263 | + | require.Equal(t, []domain.IncarnationID{old.IncarnationID}, repository.deleteCalls) | |||
| 264 | + | require.Equal(t, []domain.IncarnationID{old.IncarnationID}, repository.deletedIDs) | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | func TestResetIncompatibleDeleteFailureReturnsCommittedFreshAuthority(t *testing.T) { | |||
| 268 | + | t.Parallel() | |||
| 269 | + | old := healthyTransactionRecord() | |||
| 270 | + | cause := errors.New("repository delete failed") | |||
| 271 | + | catalogue := newTransactionCatalogue(old) | |||
| 272 | + | repository := &transactionRepository{deleteErrOnce: cause} | |||
| 273 | + | coordinator := NewCoordinator(catalogue, repository, bytes.NewReader(bytes.Repeat([]byte{2}, 16))) | |||
| 274 | + | ||||
| 275 | + | fresh, committed, err := coordinator.ResetIncompatible(context.Background(), old.Name, old.IncarnationID, *old.Committed) | |||
| 276 | + | ||||
| 277 | + | require.ErrorIs(t, err, cause) | |||
| 278 | + | require.True(t, committed) | |||
| 279 | + | require.NotEqual(t, old.IncarnationID, fresh.IncarnationID) | |||
| 280 | + | stored, ok, recordErr := catalogue.Record(old.Name) | |||
| 281 | + | require.NoError(t, recordErr) | |||
| 282 | + | require.True(t, ok) | |||
| 283 | + | require.True(t, fresh.Equal(stored)) | |||
| 284 | + | require.Equal(t, []domain.IncarnationID{old.IncarnationID}, repository.deleteCalls) | |||
| 285 | + | require.Empty(t, repository.deletedIDs) | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | func TestResetIncompatibleReplaceFailurePreservesOldAuthorityAndRepository(t *testing.T) { | |||
| 289 | + | t.Parallel() | |||
| 290 | + | old := healthyTransactionRecord() | |||
| 291 | + | cause := errors.New("catalogue replace failed") | |||
| 292 | + | catalogue := newTransactionCatalogue(old) | |||
| 293 | + | catalogue.replaceErrOnce = cause | |||
| 294 | + | repository := &transactionRepository{} | |||
| 295 | + | coordinator := NewCoordinator(catalogue, repository, bytes.NewReader(bytes.Repeat([]byte{2}, 16))) | |||
| 296 | + | ||||
| 297 | + | fresh, committed, err := coordinator.ResetIncompatible(context.Background(), old.Name, old.IncarnationID, *old.Committed) | |||
| 298 | + | ||||
| 299 | + | require.ErrorIs(t, err, cause) | |||
| 300 | + | require.False(t, committed) | |||
| 301 | + | require.Equal(t, domain.CatalogueRecord{}, fresh) | |||
| 302 | + | stored, ok, recordErr := catalogue.Record(old.Name) | |||
| 303 | + | require.NoError(t, recordErr) | |||
| 304 | + | require.True(t, ok) | |||
| 305 | + | require.True(t, old.Equal(stored)) | |||
| 306 | + | require.Empty(t, repository.deleteCalls) | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | func TestResetIncompatibleStaleAuthorityIsNoOp(t *testing.T) { | |||
| 310 | + | t.Parallel() | |||
| 311 | + | old := healthyTransactionRecord() | |||
| 312 | + | otherGeneration := *old.Committed | |||
| 313 | + | otherGeneration.Generation++ | |||
| 314 | + | otherDigest := *old.Committed | |||
| 315 | + | otherDigest.ManifestDigest[0]++ | |||
| 316 | + | uncommitted := old | |||
| 317 | + | uncommitted.Committed = nil | |||
| 318 | + | tests := []struct { | |||
| 319 | + | name string | |||
| 320 | + | record *domain.CatalogueRecord | |||
| 321 | + | expected domain.IncarnationID | |||
| 322 | + | ref domain.CheckpointRef | |||
| 323 | + | }{ | |||
| 324 | + | {name: "missing record", expected: old.IncarnationID, ref: *old.Committed}, | |||
| 325 | + | {name: "different incarnation", record: &old, expected: domain.IncarnationID{9}, ref: *old.Committed}, | |||
| 326 | + | {name: "missing checkpoint", record: &uncommitted, expected: old.IncarnationID, ref: *old.Committed}, | |||
| 327 | + | {name: "different checkpoint generation", record: &old, expected: old.IncarnationID, ref: otherGeneration}, | |||
| 328 | + | {name: "different checkpoint digest", record: &old, expected: old.IncarnationID, ref: otherDigest}, | |||
| 329 | + | {name: "degraded record", record: func() *domain.CatalogueRecord { | |||
| 330 | + | degraded := old | |||
| 331 | + | degraded.DegradedReason = "restore failed" | |||
| 332 | + | return °raded | |||
| 333 | + | }(), expected: old.IncarnationID, ref: *old.Committed}, | |||
| 334 | + | } | |||
| 335 | + | for _, tt := range tests { | |||
| 336 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 337 | + | t.Parallel() | |||
| 338 | + | catalogue := newTransactionCatalogue() | |||
| 339 | + | if tt.record != nil { | |||
| 340 | + | catalogue.records[tt.record.Name] = *tt.record | |||
| 341 | + | } | |||
| 342 | + | repository := &transactionRepository{} | |||
| 343 | + | coordinator := NewCoordinator(catalogue, repository, bytes.NewReader(bytes.Repeat([]byte{2}, 16))) | |||
| 344 | + | ||||
| 345 | + | fresh, committed, err := coordinator.ResetIncompatible(context.Background(), old.Name, tt.expected, tt.ref) | |||
| 346 | + | ||||
| 347 | + | require.NoError(t, err) | |||
| 348 | + | require.False(t, committed) | |||
| 349 | + | require.Equal(t, domain.CatalogueRecord{}, fresh) | |||
| 350 | + | require.Empty(t, catalogue.events) | |||
| 351 | + | require.Empty(t, repository.deleteCalls) | |||
| 352 | + | }) | |||
| 353 | + | } | |||
| 354 | + | } | |||
| 355 | + | ||||
| 223 | func TestStartupGarbageCollectionFencesNewIncarnationsAndCheckpointPublications(t *testing.T) { | 356 | func TestStartupGarbageCollectionFencesNewIncarnationsAndCheckpointPublications(t *testing.T) { | ||
| 224 | t.Parallel() | 357 | t.Parallel() | ||
| 225 | 358 | ||||
| @@ -338,6 +471,66 @@ func TestMarkBrokenCannotOverwriteDiscardReplacement(t *testing.T) { | |||||
| 338 | require.Empty(t, got.DegradedReason) | 471 | require.Empty(t, got.DegradedReason) | ||
| 339 | } | 472 | } | ||
| 340 | 473 | ||||
| 474 | + | func TestDiscardPublicPreconditions(t *testing.T) { | |||
| 475 | + | t.Parallel() | |||
| 476 | + | uncommitted := degradedTransactionRecord() | |||
| 477 | + | uncommitted.Name = "work" | |||
| 478 | + | uncommitted.Committed = nil | |||
| 479 | + | uncommitted.DegradedReason = "" | |||
| 480 | + | healthy := healthyTransactionRecord() | |||
| 481 | + | tests := []struct { | |||
| 482 | + | name string | |||
| 483 | + | record *domain.CatalogueRecord | |||
| 484 | + | incomplete bool | |||
| 485 | + | cancelled bool | |||
| 486 | + | wantErr error | |||
| 487 | + | }{ | |||
| 488 | + | {name: "incomplete dependencies", incomplete: true, wantErr: errors.New("recovery: incomplete discard dependencies")}, | |||
| 489 | + | {name: "cancelled context", record: &healthy, cancelled: true, wantErr: context.Canceled}, | |||
| 490 | + | {name: "missing record", wantErr: ErrRecoveryRecordNotFound}, | |||
| 491 | + | {name: "already fresh incarnation", record: &uncommitted}, | |||
| 492 | + | {name: "healthy checkpoint", record: &healthy, wantErr: ErrSessionNotBroken}, | |||
| 493 | + | } | |||
| 494 | + | for _, tt := range tests { | |||
| 495 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 496 | + | t.Parallel() | |||
| 497 | + | ctx := context.Background() | |||
| 498 | + | if tt.cancelled { | |||
| 499 | + | cancelled, cancel := context.WithCancel(ctx) | |||
| 500 | + | cancel() | |||
| 501 | + | ctx = cancelled | |||
| 502 | + | } | |||
| 503 | + | var coordinator *Coordinator | |||
| 504 | + | var catalogue *transactionCatalogue | |||
| 505 | + | var repository *transactionRepository | |||
| 506 | + | if tt.incomplete { | |||
| 507 | + | coordinator = &Coordinator{} | |||
| 508 | + | } else { | |||
| 509 | + | catalogue = newTransactionCatalogue() | |||
| 510 | + | if tt.record != nil { | |||
| 511 | + | catalogue.records[tt.record.Name] = *tt.record | |||
| 512 | + | } | |||
| 513 | + | repository = &transactionRepository{} | |||
| 514 | + | coordinator = NewCoordinator(catalogue, repository, bytes.NewReader(bytes.Repeat([]byte{2}, 16))) | |||
| 515 | + | } | |||
| 516 | + | ||||
| 517 | + | err := coordinator.Discard(ctx, "work") | |||
| 518 | + | ||||
| 519 | + | if tt.wantErr == nil { | |||
| 520 | + | require.NoError(t, err) | |||
| 521 | + | } else if tt.incomplete { | |||
| 522 | + | require.EqualError(t, err, tt.wantErr.Error()) | |||
| 523 | + | } else { | |||
| 524 | + | require.ErrorIs(t, err, tt.wantErr) | |||
| 525 | + | } | |||
| 526 | + | if !tt.incomplete { | |||
| 527 | + | require.Empty(t, catalogue.events) | |||
| 528 | + | require.Empty(t, repository.deleteCalls) | |||
| 529 | + | } | |||
| 530 | + | }) | |||
| 531 | + | } | |||
| 532 | + | } | |||
| 533 | + | ||||
| 341 | func TestDiscardIsRetryIdempotent(t *testing.T) { | 534 | func TestDiscardIsRetryIdempotent(t *testing.T) { | ||
| 342 | t.Parallel() | 535 | t.Parallel() | ||
| 343 | failure := errors.New("injected discard failure") | 536 | failure := errors.New("injected discard failure") | ||
| @@ -352,6 +545,7 @@ func TestDiscardIsRetryIdempotent(t *testing.T) { | |||||
| 352 | t.Run(tt.name, func(t *testing.T) { | 545 | t.Run(tt.name, func(t *testing.T) { | ||
| 353 | t.Parallel() | 546 | t.Parallel() | ||
| 354 | old := degradedTransactionRecord() | 547 | old := degradedTransactionRecord() | ||
| 548 | + | old.TabNames = []string{"shell"} | |||
| 355 | catalogue := newTransactionCatalogue(old) | 549 | catalogue := newTransactionCatalogue(old) | ||
| 356 | repository := &transactionRepository{} | 550 | repository := &transactionRepository{} | ||
| 357 | if tt.failBefore { | 551 | if tt.failBefore { | ||
| @@ -371,6 +565,7 @@ func TestDiscardIsRetryIdempotent(t *testing.T) { | |||||
| 371 | require.True(t, ok) | 565 | require.True(t, ok) | ||
| 372 | require.Nil(t, got.Committed) | 566 | require.Nil(t, got.Committed) | ||
| 373 | require.NotEqual(t, old.IncarnationID, got.IncarnationID) | 567 | require.NotEqual(t, old.IncarnationID, got.IncarnationID) | ||
| 568 | + | require.Equal(t, old.TabNames, got.TabNames) | |||
| 374 | if tt.failBefore { | 569 | if tt.failBefore { | ||
| 375 | require.Contains(t, repository.deletedIDs, old.IncarnationID) | 570 | require.Contains(t, repository.deletedIDs, old.IncarnationID) | ||
| 376 | } else { | 571 | } else { | ||
M internal/usecase/snapshot/codec.go +3 -3
| @@ -22,7 +22,7 @@ var ( | |||
| 22 | 22 | ||
| 23 | 23 | const ( | |
| 24 | 24 | magic = "VEVS" | |
| 25 | - | version = uint16(3) | |
| 25 | + | version = uint16(4) | |
| 26 | 26 | maxDecodedBodySize = 256 << 20 | |
| 27 | 27 | maxSnapshotObjects = 1 << 16 | |
| 28 | 28 | maxSnapshotBytes = 256 << 20 | |
| @@ -428,7 +428,7 @@ func readPane(r *payloadReader) (Pane, error) { | |||
| 428 | 428 | if err != nil { | |
| 429 | 429 | return Pane{}, err | |
| 430 | 430 | } | |
| 431 | - | visible, err := r.getBlob() | |
| 431 | + | transcript, err := r.getBlob() | |
| 432 | 432 | if err != nil { | |
| 433 | 433 | return Pane{}, err | |
| 434 | 434 | } | |
| @@ -436,7 +436,7 @@ func readPane(r *payloadReader) (Pane, error) { | |||
| 436 | 436 | if err != nil { | |
| 437 | 437 | return Pane{}, err | |
| 438 | 438 | } | |
| 439 | - | return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: proc}, nil | |
| 439 | + | return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Transcript: transcript, Process: proc}, nil | |
| 440 | 440 | } | |
| 441 | 441 | ||
| 442 | 442 | func readProcess(r *payloadReader) (*Process, error) { | |
| @@ -22,7 +22,7 @@ var ( | |||||
| 22 | 22 | ||||
| 23 | const ( | 23 | const ( | ||
| 24 | magic = "VEVS" | 24 | magic = "VEVS" | ||
| 25 | - | version = uint16(3) | 25 | + | version = uint16(4) |
| 26 | maxDecodedBodySize = 256 << 20 | 26 | maxDecodedBodySize = 256 << 20 | ||
| 27 | maxSnapshotObjects = 1 << 16 | 27 | maxSnapshotObjects = 1 << 16 | ||
| 28 | maxSnapshotBytes = 256 << 20 | 28 | maxSnapshotBytes = 256 << 20 | ||
| @@ -428,7 +428,7 @@ func readPane(r *payloadReader) (Pane, error) { | |||||
| 428 | if err != nil { | 428 | if err != nil { | ||
| 429 | return Pane{}, err | 429 | return Pane{}, err | ||
| 430 | } | 430 | } | ||
| 431 | - | visible, err := r.getBlob() | 431 | + | transcript, err := r.getBlob() |
| 432 | if err != nil { | 432 | if err != nil { | ||
| 433 | return Pane{}, err | 433 | return Pane{}, err | ||
| 434 | } | 434 | } | ||
| @@ -436,7 +436,7 @@ func readPane(r *payloadReader) (Pane, error) { | |||||
| 436 | if err != nil { | 436 | if err != nil { | ||
| 437 | return Pane{}, err | 437 | return Pane{}, err | ||
| 438 | } | 438 | } | ||
| 439 | - | return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: proc}, nil | 439 | + | return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Transcript: transcript, Process: proc}, nil |
| 440 | } | 440 | } | ||
| 441 | 441 | ||||
| 442 | func readProcess(r *payloadReader) (*Process, error) { | 442 | func readProcess(r *payloadReader) (*Process, error) { | ||
M internal/usecase/snapshot/codec_encoder_test.go +6 -6
| @@ -9,8 +9,8 @@ import ( | |||
| 9 | 9 | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | - | // marshalTest is a test-only v3 encoder. Production retains only the decoder | |
| 13 | - | // required by the one-time legacy import. | |
| 12 | + | // marshalTest is a test-only v4 encoder. Production retains only the decoder | |
| 13 | + | // required by the one-time import. | |
| 14 | 14 | func marshalTest(s Session) ([]byte, error) { | |
| 15 | 15 | var w payloadWriter | |
| 16 | 16 | if err := writeSession(&w, s); err != nil { | |
| @@ -104,10 +104,10 @@ func writePane(w *payloadWriter, p Pane) error { | |||
| 104 | 104 | } | |
| 105 | 105 | w.putUint32(uint32(len(p.Tail))) | |
| 106 | 106 | w.b = append(w.b, p.Tail...) | |
| 107 | - | if len(p.Visible) == 0 || len(p.Visible) > math.MaxUint32 { | |
| 108 | - | return fmt.Errorf("%w: visible length", ErrInvalidData) | |
| 107 | + | if len(p.Transcript) == 0 || len(p.Transcript) > math.MaxUint32 { | |
| 108 | + | return fmt.Errorf("%w: transcript length", ErrInvalidData) | |
| 109 | 109 | } | |
| 110 | - | w.putUint32(uint32(len(p.Visible))) | |
| 111 | - | w.b = append(w.b, p.Visible...) | |
| 110 | + | w.putUint32(uint32(len(p.Transcript))) | |
| 111 | + | w.b = append(w.b, p.Transcript...) | |
| 112 | 112 | return writeProcess(w, p.Process) | |
| 113 | 113 | } | |
| @@ -9,8 +9,8 @@ import ( | |||||
| 9 | "github.com/bnema/vev/internal/usecase/layout" | 9 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | - | // marshalTest is a test-only v3 encoder. Production retains only the decoder | 12 | + | // marshalTest is a test-only v4 encoder. Production retains only the decoder |
| 13 | - | // required by the one-time legacy import. | 13 | + | // required by the one-time import. |
| 14 | func marshalTest(s Session) ([]byte, error) { | 14 | func marshalTest(s Session) ([]byte, error) { | ||
| 15 | var w payloadWriter | 15 | var w payloadWriter | ||
| 16 | if err := writeSession(&w, s); err != nil { | 16 | if err := writeSession(&w, s); err != nil { | ||
| @@ -104,10 +104,10 @@ func writePane(w *payloadWriter, p Pane) error { | |||||
| 104 | } | 104 | } | ||
| 105 | w.putUint32(uint32(len(p.Tail))) | 105 | w.putUint32(uint32(len(p.Tail))) | ||
| 106 | w.b = append(w.b, p.Tail...) | 106 | w.b = append(w.b, p.Tail...) | ||
| 107 | - | if len(p.Visible) == 0 || len(p.Visible) > math.MaxUint32 { | 107 | + | if len(p.Transcript) == 0 || len(p.Transcript) > math.MaxUint32 { |
| 108 | - | return fmt.Errorf("%w: visible length", ErrInvalidData) | 108 | + | return fmt.Errorf("%w: transcript length", ErrInvalidData) |
| 109 | } | 109 | } | ||
| 110 | - | w.putUint32(uint32(len(p.Visible))) | 110 | + | w.putUint32(uint32(len(p.Transcript))) |
| 111 | - | w.b = append(w.b, p.Visible...) | 111 | + | w.b = append(w.b, p.Transcript...) |
| 112 | return writeProcess(w, p.Process) | 112 | return writeProcess(w, p.Process) | ||
| 113 | } | 113 | } | ||
M internal/usecase/snapshot/codec_test.go +106 -91
| @@ -43,7 +43,7 @@ func TestPayloadReaderGetBytes(t *testing.T) { | |||
| 43 | 43 | } | |
| 44 | 44 | } | |
| 45 | 45 | ||
| 46 | - | func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) { | |
| 46 | + | func TestV4SnapshotRoundTripPreservesCanonicalTerminalData(t *testing.T) { | |
| 47 | 47 | indexed := renderer.DefaultStyle() | |
| 48 | 48 | indexed.Bold, indexed.Inverse = true, true | |
| 49 | 49 | indexed.Attrs = renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough | |
| @@ -54,18 +54,18 @@ func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) { | |||
| 54 | 54 | rgb.HasBackgroundRGB, rgb.BackgroundRGB = true, renderer.RGB{R: 4, G: 5, B: 6} | |
| 55 | 55 | rgb.UnderlineStyle, rgb.HasUnderlineColorRGB, rgb.UnderlineColorRGB = renderer.UnderlineDashed, true, renderer.RGB{R: 7, G: 8, B: 9} | |
| 56 | 56 | sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'I', Style: indexed}, {Rune: '好', Style: rgb}, {Continuation: true, Style: rgb}}}) | |
| 57 | - | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'V', Style: rgb}, {Rune: '界', Style: indexed}, {Continuation: true, Style: indexed}}}) | |
| 57 | + | transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'V', Style: rgb}, {Rune: '界', Style: indexed}, {Continuation: true, Style: indexed}}}) | |
| 58 | 58 | sealed2, tail2 := historyBlobs(t, [][]renderer.Cell{{{Rune: 'S', Style: rgb}}, {{Rune: '2', Style: indexed}}}) | |
| 59 | - | visible2 := visibleBlob(t, [][]renderer.Cell{{{Rune: 'P', Style: indexed}, {Rune: '2', Style: rgb}}}) | |
| 60 | - | want := Session{Name: "v3 exact", Tabs: []Tab{{Focus: "p", Tree: &layout.Tree{Focus: "p", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("p"), layout.NewLeaf("p2")}}}, Panes: []Pane{ | |
| 61 | - | {ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}, | |
| 62 | - | {ID: "p2", SealedChunks: sealed2, Tail: tail2, Visible: visible2}, | |
| 59 | + | transcript2 := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'P', Style: indexed}, {Rune: '2', Style: rgb}}}) | |
| 60 | + | want := Session{Name: "v4 exact", Tabs: []Tab{{Focus: "p", Tree: &layout.Tree{Focus: "p", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("p"), layout.NewLeaf("p2")}}}, Panes: []Pane{ | |
| 61 | + | {ID: "p", SealedChunks: sealed, Tail: tail, Transcript: transcript}, | |
| 62 | + | {ID: "p2", SealedChunks: sealed2, Tail: tail2, Transcript: transcript2}, | |
| 63 | 63 | }}}} | |
| 64 | 64 | encoded, err := marshalTest(want) | |
| 65 | 65 | if err != nil { | |
| 66 | 66 | t.Fatal(err) | |
| 67 | 67 | } | |
| 68 | - | requireV3Envelope(t, encoded) | |
| 68 | + | requireV4Envelope(t, encoded) | |
| 69 | 69 | got, err := Unmarshal(encoded) | |
| 70 | 70 | if err != nil { | |
| 71 | 71 | t.Fatal(err) | |
| @@ -86,19 +86,19 @@ func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) { | |||
| 86 | 86 | if !bytes.Equal(gotPane.Tail, wantPane.Tail) { | |
| 87 | 87 | t.Fatalf("pane %q tail changed during round trip", wantPane.ID) | |
| 88 | 88 | } | |
| 89 | - | if !bytes.Equal(gotPane.Visible, wantPane.Visible) { | |
| 90 | - | t.Fatalf("pane %q visible blob changed during round trip", wantPane.ID) | |
| 89 | + | if !bytes.Equal(gotPane.Transcript, wantPane.Transcript) { | |
| 90 | + | t.Fatalf("pane %q transcript blob changed during round trip", wantPane.ID) | |
| 91 | 91 | } | |
| 92 | 92 | } | |
| 93 | 93 | if _, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2}, got.Tabs[0].Panes[0].SealedChunks, got.Tabs[0].Panes[0].Tail); err != nil { | |
| 94 | 94 | t.Fatalf("history decode: %v", err) | |
| 95 | 95 | } | |
| 96 | - | frame, err := vt.UnmarshalVisible(got.Tabs[0].Panes[0].Visible) | |
| 96 | + | view, err := vt.UnmarshalHistory(got.Tabs[0].Panes[0].Transcript) | |
| 97 | 97 | if err != nil { | |
| 98 | 98 | t.Fatal(err) | |
| 99 | 99 | } | |
| 100 | - | if !frame.Row(0)[0].Equal(renderer.Cell{Rune: 'V', Style: rgb}) || !frame.Row(0)[2].Continuation { | |
| 101 | - | t.Fatalf("visible terminal data lost: %#v", frame.Row(0)) | |
| 100 | + | if !view.Row(0)[0].Equal(renderer.Cell{Rune: 'V', Style: rgb}) || !view.Row(0)[2].Continuation { | |
| 101 | + | t.Fatalf("transcript terminal data lost: %#v", view.Row(0)) | |
| 102 | 102 | } | |
| 103 | 103 | } | |
| 104 | 104 | ||
| @@ -130,26 +130,26 @@ func TestActiveTabReferenceMustBeExact(t *testing.T) { | |||
| 130 | 130 | name string | |
| 131 | 131 | data []byte | |
| 132 | 132 | }{ | |
| 133 | - | {name: "zero tabs active nonzero", data: v3ActiveManifest(1, 0)}, | |
| 134 | - | {name: "nonempty tabs active past end", data: v3ActiveManifest(1, 1)}, | |
| 133 | + | {name: "zero tabs active nonzero", data: v4ActiveManifest(1, 0)}, | |
| 134 | + | {name: "nonempty tabs active past end", data: v4ActiveManifest(1, 1)}, | |
| 135 | 135 | } { | |
| 136 | 136 | t.Run("unmarshal "+tc.name, func(t *testing.T) { | |
| 137 | - | reject(t, v3Envelope(version, 0, tc.data), ErrInvalidData) | |
| 137 | + | reject(t, v4Envelope(version, 0, tc.data), ErrInvalidData) | |
| 138 | 138 | }) | |
| 139 | 139 | } | |
| 140 | 140 | } | |
| 141 | 141 | ||
| 142 | - | func TestUnmarshalRejectsV3LegacyVersions(t *testing.T) { | |
| 143 | - | for _, version := range []uint16{1, 2} { | |
| 144 | - | if _, err := Unmarshal(v3Envelope(version, 0, nil)); !errors.Is(err, ErrBadVersion) { | |
| 142 | + | func TestUnmarshalRejectsV4LegacyVersions(t *testing.T) { | |
| 143 | + | for _, version := range []uint16{1, 2, 3} { | |
| 144 | + | if _, err := Unmarshal(v4Envelope(version, 0, nil)); !errors.Is(err, ErrBadVersion) { | |
| 145 | 145 | t.Fatalf("version %d: %v", version, err) | |
| 146 | 146 | } | |
| 147 | 147 | } | |
| 148 | 148 | } | |
| 149 | 149 | ||
| 150 | - | func TestUnmarshalRejectsMalformedV3EnvelopeBeforeAllocation(t *testing.T) { | |
| 151 | - | body := v3Manifest(0, nil, nil, 0, nil, 0, nil) | |
| 152 | - | valid := v3Envelope(version, 0, body) | |
| 150 | + | func TestUnmarshalRejectsMalformedV4EnvelopeBeforeAllocation(t *testing.T) { | |
| 151 | + | body := v4Manifest(0, nil, nil, 0, nil, 0, nil) | |
| 152 | + | valid := v4Envelope(version, 0, body) | |
| 153 | 153 | badCRC := append([]byte(nil), valid...) | |
| 154 | 154 | badCRC[15] ^= 1 | |
| 155 | 155 | for _, tc := range []struct { | |
| @@ -158,31 +158,31 @@ func TestUnmarshalRejectsMalformedV3EnvelopeBeforeAllocation(t *testing.T) { | |||
| 158 | 158 | want error | |
| 159 | 159 | }{ | |
| 160 | 160 | {"short", valid[:15], ErrShortPayload}, {"magic", append([]byte("NOPE"), valid[4:]...), ErrBadMagic}, | |
| 161 | - | {"future", v3Envelope(version+1, 0, body), ErrBadVersion}, {"flags", v3Envelope(version, 1, body), ErrInvalidData}, | |
| 162 | - | {"crc", badCRC, ErrBadCRC}, {"body", v3EnvelopeWithLength(version, 0, nil, math.MaxUint32), ErrInvalidData}, | |
| 161 | + | {"future", v4Envelope(version+1, 0, body), ErrBadVersion}, {"flags", v4Envelope(version, 1, body), ErrInvalidData}, | |
| 162 | + | {"crc", badCRC, ErrBadCRC}, {"body", v4EnvelopeWithLength(version, 0, nil, math.MaxUint32), ErrInvalidData}, | |
| 163 | 163 | } { | |
| 164 | 164 | t.Run(tc.name, func(t *testing.T) { reject(t, tc.data, tc.want) }) | |
| 165 | 165 | } | |
| 166 | 166 | } | |
| 167 | 167 | ||
| 168 | - | func TestUnmarshalRejectsHostileV3ManifestDeclarationsBeforeAllocation(t *testing.T) { | |
| 168 | + | func TestUnmarshalRejectsHostileV4ManifestDeclarationsBeforeAllocation(t *testing.T) { | |
| 169 | 169 | for _, tc := range []struct { | |
| 170 | 170 | name string | |
| 171 | 171 | body []byte | |
| 172 | 172 | }{ | |
| 173 | - | {"count", v3Manifest(math.MaxUint32, nil, nil, 0, nil, 0, nil)}, | |
| 174 | - | {"sealed", v3Manifest(1, []uint32{math.MaxUint32}, nil, 0, nil, 0, nil)}, | |
| 175 | - | {"tail", v3Manifest(0, nil, nil, math.MaxUint32, nil, 0, nil)}, | |
| 176 | - | {"visible", v3Manifest(0, nil, nil, 0, nil, math.MaxUint32, nil)}, | |
| 173 | + | {"count", v4Manifest(math.MaxUint32, nil, nil, 0, nil, 0, nil)}, | |
| 174 | + | {"sealed", v4Manifest(1, []uint32{math.MaxUint32}, nil, 0, nil, 0, nil)}, | |
| 175 | + | {"tail", v4Manifest(0, nil, nil, math.MaxUint32, nil, 0, nil)}, | |
| 176 | + | {"transcript", v4Manifest(0, nil, nil, 0, nil, math.MaxUint32, nil)}, | |
| 177 | 177 | } { | |
| 178 | - | t.Run(tc.name, func(t *testing.T) { reject(t, v3Envelope(version, 0, tc.body), ErrInvalidData) }) | |
| 178 | + | t.Run(tc.name, func(t *testing.T) { reject(t, v4Envelope(version, 0, tc.body), ErrInvalidData) }) | |
| 179 | 179 | } | |
| 180 | 180 | } | |
| 181 | 181 | ||
| 182 | - | func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) { | |
| 182 | + | func TestUnmarshalRejectsEveryV4PrefixAndTrailingGarbage(t *testing.T) { | |
| 183 | 183 | sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}}) | |
| 184 | - | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |
| 185 | - | encoded, err := marshalTest(Session{Name: "p", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}}) | |
| 184 | + | transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |
| 185 | + | encoded, err := marshalTest(Session{Name: "p", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Transcript: transcript}}}}}) | |
| 186 | 186 | if err != nil { | |
| 187 | 187 | t.Fatal(err) | |
| 188 | 188 | } | |
| @@ -194,13 +194,13 @@ func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) { | |||
| 194 | 194 | ||
| 195 | 195 | func TestUnmarshalRejectsEmptyAndDuplicatePaneIDsDuringPreflight(t *testing.T) { | |
| 196 | 196 | sealed, tail := historyBlobs(t, nil) | |
| 197 | - | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |
| 197 | + | transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |
| 198 | 198 | for _, tc := range []struct { | |
| 199 | 199 | name string | |
| 200 | 200 | panes []Pane | |
| 201 | 201 | }{ | |
| 202 | - | {name: "empty", panes: []Pane{{ID: "", Tail: tail, Visible: visible}}}, | |
| 203 | - | {name: "duplicate", panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}, {ID: "p", Tail: tail, Visible: visible}}}, | |
| 202 | + | {name: "empty", panes: []Pane{{ID: "", Tail: tail, Transcript: transcript}}}, | |
| 203 | + | {name: "duplicate", panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Transcript: transcript}, {ID: "p", Tail: tail, Transcript: transcript}}}, | |
| 204 | 204 | } { | |
| 205 | 205 | t.Run(tc.name, func(t *testing.T) { | |
| 206 | 206 | data, err := marshalTest(Session{Name: "s", Tabs: []Tab{{Panes: tc.panes}}}) | |
| @@ -212,8 +212,8 @@ func TestUnmarshalRejectsEmptyAndDuplicatePaneIDsDuringPreflight(t *testing.T) { | |||
| 212 | 212 | ||
| 213 | 213 | func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) { | |
| 214 | 214 | sealed, tail := historyBlobs(t, nil) | |
| 215 | - | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |
| 216 | - | encoded, err := marshalTest(Session{Name: "p", Tabs: []Tab{{Focus: "missing", Tree: layout.NewTree("missing"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}}) | |
| 215 | + | transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |
| 216 | + | encoded, err := marshalTest(Session{Name: "p", Tabs: []Tab{{Focus: "missing", Tree: layout.NewTree("missing"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Transcript: transcript}}}}}) | |
| 217 | 217 | if err != nil { | |
| 218 | 218 | t.Fatal(err) | |
| 219 | 219 | } | |
| @@ -231,12 +231,12 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) { | |||
| 231 | 231 | multiChunk = binary.BigEndian.AppendUint32(multiChunk, 2) | |
| 232 | 232 | multiChunk = append(multiChunk, sealed[0][9:]...) | |
| 233 | 233 | multiChunk = append(multiChunk, sealed[0][9:]...) | |
| 234 | - | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |
| 235 | - | emptyVisible := visibleBlob(t, nil) | |
| 234 | + | transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |
| 235 | + | emptyTranscript := transcriptBlob(t, nil) | |
| 236 | 236 | ||
| 237 | 237 | // Incremental captures retain one copied mutable tail chunk instead of | |
| 238 | 238 | // rotating it into the sealed history set. That one chunk is canonical and | |
| 239 | - | // must remain a valid v3 import payload. | |
| 239 | + | // must remain a valid v4 import payload. | |
| 240 | 240 | mutableHistory := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |
| 241 | 241 | for _, row := range [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}} { | |
| 242 | 242 | requireNoError(t, mutableHistory.Append(row, vt.LineBound{End: len(row)})) | |
| @@ -245,22 +245,32 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) { | |||
| 245 | 245 | requireNoError(t, err) | |
| 246 | 246 | fullHistory, err := vt.MarshalHistory(mutableHistory.SealAndView()) | |
| 247 | 247 | requireNoError(t, err) | |
| 248 | - | valid, err := marshalTest(Session{Name: "s", Tabs: []Tab{{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: sealed[:1], Tail: mutableTail, Visible: visible}}}}}) | |
| 249 | - | requireNoError(t, err) | |
| 250 | - | roundTrip, err := Unmarshal(valid) | |
| 251 | - | requireNoError(t, err) | |
| 252 | - | if got := roundTrip.Tabs[0].Panes[0].Tail; !bytes.Equal(got, mutableTail) { | |
| 253 | - | t.Fatal("mutable tail did not round trip") | |
| 248 | + | for _, tc := range []struct { | |
| 249 | + | name string | |
| 250 | + | transcript []byte | |
| 251 | + | }{ | |
| 252 | + | {name: "single chunk", transcript: transcript}, | |
| 253 | + | {name: "empty", transcript: emptyTranscript}, | |
| 254 | + | {name: "multiple chunks", transcript: multiChunk}, | |
| 255 | + | } { | |
| 256 | + | t.Run("valid "+tc.name, func(t *testing.T) { | |
| 257 | + | data, err := marshalTest(Session{Name: "s", Tabs: []Tab{{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: sealed[:1], Tail: mutableTail, Transcript: tc.transcript}}}}}) | |
| 258 | + | requireNoError(t, err) | |
| 259 | + | roundTrip, err := Unmarshal(data) | |
| 260 | + | requireNoError(t, err) | |
| 261 | + | if got := roundTrip.Tabs[0].Panes[0].Tail; !bytes.Equal(got, mutableTail) { | |
| 262 | + | t.Fatal("mutable tail did not round trip") | |
| 263 | + | } | |
| 264 | + | }) | |
| 254 | 265 | } | |
| 255 | 266 | ||
| 256 | 267 | for _, tc := range []struct { | |
| 257 | 268 | name string | |
| 258 | 269 | tab Tab | |
| 259 | 270 | }{ | |
| 260 | - | {name: "sealed multi-chunk", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: [][]byte{multiChunk}, Tail: emptyTail, Visible: visible}}}}, | |
| 261 | - | {name: "tail has multiple chunks", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: multiChunk, Visible: visible}}}}, | |
| 262 | - | {name: "noncanonical full history tail", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: fullHistory, Visible: visible}}}}, | |
| 263 | - | {name: "empty visible geometry", tab: Tab{Panes: []Pane{{ID: "p", Tail: emptyTail, Visible: emptyVisible}}}}, | |
| 271 | + | {name: "sealed multi-chunk", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: [][]byte{multiChunk}, Tail: emptyTail, Transcript: transcript}}}}, | |
| 272 | + | {name: "tail has multiple chunks", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: multiChunk, Transcript: transcript}}}}, | |
| 273 | + | {name: "noncanonical full history tail", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: fullHistory, Transcript: transcript}}}}, | |
| 264 | 274 | } { | |
| 265 | 275 | t.Run(tc.name, func(t *testing.T) { | |
| 266 | 276 | data, err := marshalTest(Session{Name: "s", Tabs: []Tab{tc.tab}}) | |
| @@ -270,13 +280,22 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) { | |||
| 270 | 280 | } | |
| 271 | 281 | } | |
| 272 | 282 | ||
| 273 | - | func TestUnmarshalRejectsMalformedTerminalBlobWithoutPanic(t *testing.T) { | |
| 283 | + | func TestUnmarshalRejectsMalformedCanonicalVTBlobsWithoutPanic(t *testing.T) { | |
| 274 | 284 | tail := mustHistoryTail(t) | |
| 275 | - | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |
| 276 | - | // The visible cell's underline-style byte is outside the renderer enum. | |
| 277 | - | visible[13+29] = 0xff | |
| 278 | - | body := v3Manifest(0, nil, nil, uint32(len(tail)), tail, uint32(len(visible)), visible) | |
| 279 | - | reject(t, v3Envelope(version, 0, body), ErrInvalidData) | |
| 285 | + | transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |
| 286 | + | for _, tc := range []struct { | |
| 287 | + | name string | |
| 288 | + | blob []byte | |
| 289 | + | }{ | |
| 290 | + | {name: "malformed", blob: append([]byte("NOPE"), transcript[4:]...)}, | |
| 291 | + | {name: "truncated", blob: transcript[:len(transcript)-1]}, | |
| 292 | + | {name: "trailing", blob: append(append([]byte(nil), transcript...), 0)}, | |
| 293 | + | } { | |
| 294 | + | t.Run(tc.name, func(t *testing.T) { | |
| 295 | + | body := v4Manifest(0, nil, nil, uint32(len(tail)), tail, uint32(len(tc.blob)), tc.blob) | |
| 296 | + | reject(t, v4Envelope(version, 0, body), ErrInvalidData) | |
| 297 | + | }) | |
| 298 | + | } | |
| 280 | 299 | } | |
| 281 | 300 | ||
| 282 | 301 | func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) { | |
| @@ -301,7 +320,7 @@ func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) { | |||
| 301 | 320 | ||
| 302 | 321 | func TestRoundTripSessionMetadataAndProcess(t *testing.T) { | |
| 303 | 322 | sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}, {{Rune: 'i'}}}) | |
| 304 | - | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |
| 323 | + | transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |
| 305 | 324 | want := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{ | |
| 306 | 325 | { | |
| 307 | 326 | StableID: "t1", Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2", | |
| @@ -309,11 +328,11 @@ func TestRoundTripSessionMetadataAndProcess(t *testing.T) { | |||
| 309 | 328 | layout.NewLeaf("1"), layout.NewLeaf("2"), | |
| 310 | 329 | }}}, | |
| 311 | 330 | Panes: []Pane{ | |
| 312 | - | {ID: "1", StableID: "p1", Cwd: "/one", SealedChunks: sealed, Tail: tail, Visible: visible, Process: &Process{Argv: []string{"pi", "--resume"}, Strategy: "pi", Opts: ProcessOpts{AgentSessionID: "agent-123"}}}, | |
| 313 | - | {ID: "2", StableID: "p2", Cwd: "/two", SealedChunks: sealed, Tail: tail, Visible: visible}, | |
| 331 | + | {ID: "1", StableID: "p1", Cwd: "/one", SealedChunks: sealed, Tail: tail, Transcript: transcript, Process: &Process{Argv: []string{"pi", "--resume"}, Strategy: "pi", Opts: ProcessOpts{AgentSessionID: "agent-123"}}}, | |
| 332 | + | {ID: "2", StableID: "p2", Cwd: "/two", SealedChunks: sealed, Tail: tail, Transcript: transcript}, | |
| 314 | 333 | }, | |
| 315 | 334 | }, | |
| 316 | - | {StableID: "t2", Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", SealedChunks: sealed, Tail: tail, Visible: visible}}}, | |
| 335 | + | {StableID: "t2", Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", SealedChunks: sealed, Tail: tail, Transcript: transcript}}}, | |
| 317 | 336 | }} | |
| 318 | 337 | data, err := marshalTest(want) | |
| 319 | 338 | requireNoError(t, err) | |
| @@ -339,7 +358,7 @@ func TestMarshalRejectsProcessWithoutArgv(t *testing.T) { | |||
| 339 | 358 | ||
| 340 | 359 | func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) { | |
| 341 | 360 | tail := mustHistoryTail(t) | |
| 342 | - | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |
| 361 | + | transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |
| 343 | 362 | var w payloadWriter | |
| 344 | 363 | _ = w.putString("s") | |
| 345 | 364 | w.putUint64(0) | |
| @@ -358,13 +377,13 @@ func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) { | |||
| 358 | 377 | w.putUint32(0) | |
| 359 | 378 | w.putUint32(uint32(len(tail))) | |
| 360 | 379 | w.b = append(w.b, tail...) | |
| 361 | - | w.putUint32(uint32(len(visible))) | |
| 362 | - | w.b = append(w.b, visible...) | |
| 380 | + | w.putUint32(uint32(len(transcript))) | |
| 381 | + | w.b = append(w.b, transcript...) | |
| 363 | 382 | w.putUint8(1) | |
| 364 | 383 | w.putUint16(0) // argv count | |
| 365 | 384 | _ = w.putString("generic") | |
| 366 | 385 | _ = w.putString("") | |
| 367 | - | if _, err := Unmarshal(v3Envelope(version, 0, w.b)); !errors.Is(err, ErrInvalidData) { | |
| 386 | + | if _, err := Unmarshal(v4Envelope(version, 0, w.b)); !errors.Is(err, ErrInvalidData) { | |
| 368 | 387 | t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData) | |
| 369 | 388 | } | |
| 370 | 389 | } | |
| @@ -376,12 +395,12 @@ func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) { | |||
| 376 | 395 | } | |
| 377 | 396 | sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView()) | |
| 378 | 397 | requireNoError(t, err) | |
| 379 | - | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |
| 398 | + | transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |
| 380 | 399 | ||
| 381 | 400 | const paneCount = maxSnapshotRows/256 + 1 | |
| 382 | 401 | tabs := make([]Tab, paneCount) | |
| 383 | 402 | for i := range tabs { | |
| 384 | - | tabs[i] = Tab{Panes: []Pane{{ID: layout.PaneID(strconv.Itoa(i)), SealedChunks: sealed, Tail: tail, Visible: visible}}} | |
| 403 | + | tabs[i] = Tab{Panes: []Pane{{ID: layout.PaneID(strconv.Itoa(i)), SealedChunks: sealed, Tail: tail, Transcript: transcript}}} | |
| 385 | 404 | } | |
| 386 | 405 | data, err := marshalTest(Session{Name: "over-budget", Tabs: tabs}) | |
| 387 | 406 | requireNoError(t, err) | |
| @@ -407,15 +426,15 @@ func TestPreflightBudgetRejectsAggregateDecodedAllocation(t *testing.T) { | |||
| 407 | 426 | } | |
| 408 | 427 | ||
| 409 | 428 | func TestPreflightRejectsBroadTreeBeforeSecondPassAllocation(t *testing.T) { | |
| 410 | - | body := v3WideTreeManifest(32_769) | |
| 429 | + | body := v4WideTreeManifest(32_769) | |
| 411 | 430 | if err := preflightSession(body); !errors.Is(err, ErrInvalidData) { | |
| 412 | 431 | t.Fatalf("preflightSession() error = %v, want %v", err, ErrInvalidData) | |
| 413 | 432 | } | |
| 414 | 433 | } | |
| 415 | 434 | ||
| 416 | 435 | func TestUnmarshalRejectsAggregateOpaqueBlobDeclarations(t *testing.T) { | |
| 417 | - | body := v3Manifest(math.MaxUint32, nil, nil, math.MaxUint32, nil, math.MaxUint32, nil) | |
| 418 | - | reject(t, v3Envelope(version, 0, body), ErrInvalidData) | |
| 436 | + | body := v4Manifest(math.MaxUint32, nil, nil, math.MaxUint32, nil, math.MaxUint32, nil) | |
| 437 | + | reject(t, v4Envelope(version, 0, body), ErrInvalidData) | |
| 419 | 438 | } | |
| 420 | 439 | ||
| 421 | 440 | func mustHistoryTail(t *testing.T) []byte { | |
| @@ -443,26 +462,22 @@ func historyBlobs(t *testing.T, rows [][]renderer.Cell) ([][]byte, []byte) { | |||
| 443 | 462 | } | |
| 444 | 463 | return sealed, tail | |
| 445 | 464 | } | |
| 446 | - | func visibleBlob(t *testing.T, rows [][]renderer.Cell) []byte { | |
| 465 | + | func transcriptBlob(t *testing.T, rows [][]renderer.Cell) []byte { | |
| 447 | 466 | t.Helper() | |
| 448 | - | width := 0 | |
| 467 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: max(1, len(rows)), ChunkRows: 2}) | |
| 449 | 468 | for _, row := range rows { | |
| 450 | - | width = max(width, len(row)) | |
| 469 | + | requireNoError(t, history.Append(row, vt.LineBound{End: len(row)})) | |
| 451 | 470 | } | |
| 452 | - | f := renderer.NewFrame(width, len(rows)) | |
| 453 | - | for y, row := range rows { | |
| 454 | - | copy(f.Row(y), row) | |
| 455 | - | } | |
| 456 | - | b, err := vt.MarshalVisible(f) | |
| 471 | + | blob, err := vt.MarshalHistory(history.SealAndView()) | |
| 457 | 472 | if err != nil { | |
| 458 | 473 | t.Fatal(err) | |
| 459 | 474 | } | |
| 460 | - | return b | |
| 475 | + | return blob | |
| 461 | 476 | } | |
| 462 | - | func requireV3Envelope(t *testing.T, data []byte) { | |
| 477 | + | func requireV4Envelope(t *testing.T, data []byte) { | |
| 463 | 478 | t.Helper() | |
| 464 | - | if len(data) < 16 || binary.BigEndian.Uint16(data[4:6]) != version || binary.BigEndian.Uint16(data[6:8]) != 0 { | |
| 465 | - | t.Fatalf("not v3 envelope: % x", data) | |
| 479 | + | if version != 4 || len(data) < 16 || binary.BigEndian.Uint16(data[4:6]) != 4 || binary.BigEndian.Uint16(data[6:8]) != 0 { | |
| 480 | + | t.Fatalf("not v4 envelope: % x", data) | |
| 466 | 481 | } | |
| 467 | 482 | } | |
| 468 | 483 | func reject(t *testing.T, data []byte, want error) { | |
| @@ -480,10 +495,10 @@ func reject(t *testing.T, data []byte, want error) { | |||
| 480 | 495 | t.Fatalf("error = %v, want %v", err, want) | |
| 481 | 496 | } | |
| 482 | 497 | } | |
| 483 | - | func v3Envelope(v, flags uint16, body []byte) []byte { | |
| 484 | - | return v3EnvelopeWithLength(v, flags, body, uint32(len(body))) | |
| 498 | + | func v4Envelope(v, flags uint16, body []byte) []byte { | |
| 499 | + | return v4EnvelopeWithLength(v, flags, body, uint32(len(body))) | |
| 485 | 500 | } | |
| 486 | - | func v3EnvelopeWithLength(v, flags uint16, body []byte, length uint32) []byte { | |
| 501 | + | func v4EnvelopeWithLength(v, flags uint16, body []byte, length uint32) []byte { | |
| 487 | 502 | out := make([]byte, 16+len(body)) | |
| 488 | 503 | copy(out, magic) | |
| 489 | 504 | binary.BigEndian.PutUint16(out[4:6], v) | |
| @@ -494,7 +509,7 @@ func v3EnvelopeWithLength(v, flags uint16, body []byte, length uint32) []byte { | |||
| 494 | 509 | return out | |
| 495 | 510 | } | |
| 496 | 511 | ||
| 497 | - | func v3ActiveManifest(active, tabs uint16) []byte { | |
| 512 | + | func v4ActiveManifest(active, tabs uint16) []byte { | |
| 498 | 513 | var b []byte | |
| 499 | 514 | putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) } | |
| 500 | 515 | putS("s") | |
| @@ -511,10 +526,10 @@ func v3ActiveManifest(active, tabs uint16) []byte { | |||
| 511 | 526 | return b | |
| 512 | 527 | } | |
| 513 | 528 | ||
| 514 | - | // v3Manifest creates just enough session/tab/pane framing for hostile declarations. | |
| 515 | - | // v3WideTreeManifest declares a broad but compact tree without allocating a | |
| 529 | + | // v4Manifest creates just enough session/tab/pane framing for hostile declarations. | |
| 530 | + | // v4WideTreeManifest declares a broad but compact tree without allocating a | |
| 516 | 531 | // matching layout.Node graph. It exercises preflight's manifest budget. | |
| 517 | - | func v3WideTreeManifest(children uint16) []byte { | |
| 532 | + | func v4WideTreeManifest(children uint16) []byte { | |
| 518 | 533 | var b []byte | |
| 519 | 534 | putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) } | |
| 520 | 535 | putS("s") | |
| @@ -533,7 +548,7 @@ func v3WideTreeManifest(children uint16) []byte { | |||
| 533 | 548 | return b | |
| 534 | 549 | } | |
| 535 | 550 | ||
| 536 | - | func v3Manifest(sealed uint32, sealedLens []uint32, sealedData []byte, tailLen uint32, tailData []byte, visibleLen uint32, visibleData []byte) []byte { | |
| 551 | + | func v4Manifest(sealed uint32, sealedLens []uint32, sealedData []byte, tailLen uint32, tailData []byte, transcriptLen uint32, transcriptData []byte) []byte { | |
| 537 | 552 | var b []byte | |
| 538 | 553 | putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) } | |
| 539 | 554 | putS("s") | |
| @@ -555,7 +570,7 @@ func v3Manifest(sealed uint32, sealedLens []uint32, sealedData []byte, tailLen u | |||
| 555 | 570 | b = append(b, sealedData...) | |
| 556 | 571 | b = binary.BigEndian.AppendUint32(b, tailLen) | |
| 557 | 572 | b = append(b, tailData...) | |
| 558 | - | b = binary.BigEndian.AppendUint32(b, visibleLen) | |
| 559 | - | b = append(b, visibleData...) | |
| 573 | + | b = binary.BigEndian.AppendUint32(b, transcriptLen) | |
| 574 | + | b = append(b, transcriptData...) | |
| 560 | 575 | return b | |
| 561 | 576 | } | |
| @@ -43,7 +43,7 @@ func TestPayloadReaderGetBytes(t *testing.T) { | |||||
| 43 | } | 43 | } | ||
| 44 | } | 44 | } | ||
| 45 | 45 | ||||
| 46 | - | func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) { | 46 | + | func TestV4SnapshotRoundTripPreservesCanonicalTerminalData(t *testing.T) { |
| 47 | indexed := renderer.DefaultStyle() | 47 | indexed := renderer.DefaultStyle() | ||
| 48 | indexed.Bold, indexed.Inverse = true, true | 48 | indexed.Bold, indexed.Inverse = true, true | ||
| 49 | indexed.Attrs = renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough | 49 | indexed.Attrs = renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough | ||
| @@ -54,18 +54,18 @@ func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) { | |||||
| 54 | rgb.HasBackgroundRGB, rgb.BackgroundRGB = true, renderer.RGB{R: 4, G: 5, B: 6} | 54 | rgb.HasBackgroundRGB, rgb.BackgroundRGB = true, renderer.RGB{R: 4, G: 5, B: 6} | ||
| 55 | rgb.UnderlineStyle, rgb.HasUnderlineColorRGB, rgb.UnderlineColorRGB = renderer.UnderlineDashed, true, renderer.RGB{R: 7, G: 8, B: 9} | 55 | rgb.UnderlineStyle, rgb.HasUnderlineColorRGB, rgb.UnderlineColorRGB = renderer.UnderlineDashed, true, renderer.RGB{R: 7, G: 8, B: 9} | ||
| 56 | sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'I', Style: indexed}, {Rune: '好', Style: rgb}, {Continuation: true, Style: rgb}}}) | 56 | sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'I', Style: indexed}, {Rune: '好', Style: rgb}, {Continuation: true, Style: rgb}}}) | ||
| 57 | - | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'V', Style: rgb}, {Rune: '界', Style: indexed}, {Continuation: true, Style: indexed}}}) | 57 | + | transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'V', Style: rgb}, {Rune: '界', Style: indexed}, {Continuation: true, Style: indexed}}}) |
| 58 | sealed2, tail2 := historyBlobs(t, [][]renderer.Cell{{{Rune: 'S', Style: rgb}}, {{Rune: '2', Style: indexed}}}) | 58 | sealed2, tail2 := historyBlobs(t, [][]renderer.Cell{{{Rune: 'S', Style: rgb}}, {{Rune: '2', Style: indexed}}}) | ||
| 59 | - | visible2 := visibleBlob(t, [][]renderer.Cell{{{Rune: 'P', Style: indexed}, {Rune: '2', Style: rgb}}}) | 59 | + | transcript2 := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'P', Style: indexed}, {Rune: '2', Style: rgb}}}) |
| 60 | - | want := Session{Name: "v3 exact", Tabs: []Tab{{Focus: "p", Tree: &layout.Tree{Focus: "p", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("p"), layout.NewLeaf("p2")}}}, Panes: []Pane{ | 60 | + | want := Session{Name: "v4 exact", Tabs: []Tab{{Focus: "p", Tree: &layout.Tree{Focus: "p", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("p"), layout.NewLeaf("p2")}}}, Panes: []Pane{ |
| 61 | - | {ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}, | 61 | + | {ID: "p", SealedChunks: sealed, Tail: tail, Transcript: transcript}, |
| 62 | - | {ID: "p2", SealedChunks: sealed2, Tail: tail2, Visible: visible2}, | 62 | + | {ID: "p2", SealedChunks: sealed2, Tail: tail2, Transcript: transcript2}, |
| 63 | }}}} | 63 | }}}} | ||
| 64 | encoded, err := marshalTest(want) | 64 | encoded, err := marshalTest(want) | ||
| 65 | if err != nil { | 65 | if err != nil { | ||
| 66 | t.Fatal(err) | 66 | t.Fatal(err) | ||
| 67 | } | 67 | } | ||
| 68 | - | requireV3Envelope(t, encoded) | 68 | + | requireV4Envelope(t, encoded) |
| 69 | got, err := Unmarshal(encoded) | 69 | got, err := Unmarshal(encoded) | ||
| 70 | if err != nil { | 70 | if err != nil { | ||
| 71 | t.Fatal(err) | 71 | t.Fatal(err) | ||
| @@ -86,19 +86,19 @@ func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) { | |||||
| 86 | if !bytes.Equal(gotPane.Tail, wantPane.Tail) { | 86 | if !bytes.Equal(gotPane.Tail, wantPane.Tail) { | ||
| 87 | t.Fatalf("pane %q tail changed during round trip", wantPane.ID) | 87 | t.Fatalf("pane %q tail changed during round trip", wantPane.ID) | ||
| 88 | } | 88 | } | ||
| 89 | - | if !bytes.Equal(gotPane.Visible, wantPane.Visible) { | 89 | + | if !bytes.Equal(gotPane.Transcript, wantPane.Transcript) { |
| 90 | - | t.Fatalf("pane %q visible blob changed during round trip", wantPane.ID) | 90 | + | t.Fatalf("pane %q transcript blob changed during round trip", wantPane.ID) |
| 91 | } | 91 | } | ||
| 92 | } | 92 | } | ||
| 93 | if _, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2}, got.Tabs[0].Panes[0].SealedChunks, got.Tabs[0].Panes[0].Tail); err != nil { | 93 | if _, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2}, got.Tabs[0].Panes[0].SealedChunks, got.Tabs[0].Panes[0].Tail); err != nil { | ||
| 94 | t.Fatalf("history decode: %v", err) | 94 | t.Fatalf("history decode: %v", err) | ||
| 95 | } | 95 | } | ||
| 96 | - | frame, err := vt.UnmarshalVisible(got.Tabs[0].Panes[0].Visible) | 96 | + | view, err := vt.UnmarshalHistory(got.Tabs[0].Panes[0].Transcript) |
| 97 | if err != nil { | 97 | if err != nil { | ||
| 98 | t.Fatal(err) | 98 | t.Fatal(err) | ||
| 99 | } | 99 | } | ||
| 100 | - | if !frame.Row(0)[0].Equal(renderer.Cell{Rune: 'V', Style: rgb}) || !frame.Row(0)[2].Continuation { | 100 | + | if !view.Row(0)[0].Equal(renderer.Cell{Rune: 'V', Style: rgb}) || !view.Row(0)[2].Continuation { |
| 101 | - | t.Fatalf("visible terminal data lost: %#v", frame.Row(0)) | 101 | + | t.Fatalf("transcript terminal data lost: %#v", view.Row(0)) |
| 102 | } | 102 | } | ||
| 103 | } | 103 | } | ||
| 104 | 104 | ||||
| @@ -130,26 +130,26 @@ func TestActiveTabReferenceMustBeExact(t *testing.T) { | |||||
| 130 | name string | 130 | name string | ||
| 131 | data []byte | 131 | data []byte | ||
| 132 | }{ | 132 | }{ | ||
| 133 | - | {name: "zero tabs active nonzero", data: v3ActiveManifest(1, 0)}, | 133 | + | {name: "zero tabs active nonzero", data: v4ActiveManifest(1, 0)}, |
| 134 | - | {name: "nonempty tabs active past end", data: v3ActiveManifest(1, 1)}, | 134 | + | {name: "nonempty tabs active past end", data: v4ActiveManifest(1, 1)}, |
| 135 | } { | 135 | } { | ||
| 136 | t.Run("unmarshal "+tc.name, func(t *testing.T) { | 136 | t.Run("unmarshal "+tc.name, func(t *testing.T) { | ||
| 137 | - | reject(t, v3Envelope(version, 0, tc.data), ErrInvalidData) | 137 | + | reject(t, v4Envelope(version, 0, tc.data), ErrInvalidData) |
| 138 | }) | 138 | }) | ||
| 139 | } | 139 | } | ||
| 140 | } | 140 | } | ||
| 141 | 141 | ||||
| 142 | - | func TestUnmarshalRejectsV3LegacyVersions(t *testing.T) { | 142 | + | func TestUnmarshalRejectsV4LegacyVersions(t *testing.T) { |
| 143 | - | for _, version := range []uint16{1, 2} { | 143 | + | for _, version := range []uint16{1, 2, 3} { |
| 144 | - | if _, err := Unmarshal(v3Envelope(version, 0, nil)); !errors.Is(err, ErrBadVersion) { | 144 | + | if _, err := Unmarshal(v4Envelope(version, 0, nil)); !errors.Is(err, ErrBadVersion) { |
| 145 | t.Fatalf("version %d: %v", version, err) | 145 | t.Fatalf("version %d: %v", version, err) | ||
| 146 | } | 146 | } | ||
| 147 | } | 147 | } | ||
| 148 | } | 148 | } | ||
| 149 | 149 | ||||
| 150 | - | func TestUnmarshalRejectsMalformedV3EnvelopeBeforeAllocation(t *testing.T) { | 150 | + | func TestUnmarshalRejectsMalformedV4EnvelopeBeforeAllocation(t *testing.T) { |
| 151 | - | body := v3Manifest(0, nil, nil, 0, nil, 0, nil) | 151 | + | body := v4Manifest(0, nil, nil, 0, nil, 0, nil) |
| 152 | - | valid := v3Envelope(version, 0, body) | 152 | + | valid := v4Envelope(version, 0, body) |
| 153 | badCRC := append([]byte(nil), valid...) | 153 | badCRC := append([]byte(nil), valid...) | ||
| 154 | badCRC[15] ^= 1 | 154 | badCRC[15] ^= 1 | ||
| 155 | for _, tc := range []struct { | 155 | for _, tc := range []struct { | ||
| @@ -158,31 +158,31 @@ func TestUnmarshalRejectsMalformedV3EnvelopeBeforeAllocation(t *testing.T) { | |||||
| 158 | want error | 158 | want error | ||
| 159 | }{ | 159 | }{ | ||
| 160 | {"short", valid[:15], ErrShortPayload}, {"magic", append([]byte("NOPE"), valid[4:]...), ErrBadMagic}, | 160 | {"short", valid[:15], ErrShortPayload}, {"magic", append([]byte("NOPE"), valid[4:]...), ErrBadMagic}, | ||
| 161 | - | {"future", v3Envelope(version+1, 0, body), ErrBadVersion}, {"flags", v3Envelope(version, 1, body), ErrInvalidData}, | 161 | + | {"future", v4Envelope(version+1, 0, body), ErrBadVersion}, {"flags", v4Envelope(version, 1, body), ErrInvalidData}, |
| 162 | - | {"crc", badCRC, ErrBadCRC}, {"body", v3EnvelopeWithLength(version, 0, nil, math.MaxUint32), ErrInvalidData}, | 162 | + | {"crc", badCRC, ErrBadCRC}, {"body", v4EnvelopeWithLength(version, 0, nil, math.MaxUint32), ErrInvalidData}, |
| 163 | } { | 163 | } { | ||
| 164 | t.Run(tc.name, func(t *testing.T) { reject(t, tc.data, tc.want) }) | 164 | t.Run(tc.name, func(t *testing.T) { reject(t, tc.data, tc.want) }) | ||
| 165 | } | 165 | } | ||
| 166 | } | 166 | } | ||
| 167 | 167 | ||||
| 168 | - | func TestUnmarshalRejectsHostileV3ManifestDeclarationsBeforeAllocation(t *testing.T) { | 168 | + | func TestUnmarshalRejectsHostileV4ManifestDeclarationsBeforeAllocation(t *testing.T) { |
| 169 | for _, tc := range []struct { | 169 | for _, tc := range []struct { | ||
| 170 | name string | 170 | name string | ||
| 171 | body []byte | 171 | body []byte | ||
| 172 | }{ | 172 | }{ | ||
| 173 | - | {"count", v3Manifest(math.MaxUint32, nil, nil, 0, nil, 0, nil)}, | 173 | + | {"count", v4Manifest(math.MaxUint32, nil, nil, 0, nil, 0, nil)}, |
| 174 | - | {"sealed", v3Manifest(1, []uint32{math.MaxUint32}, nil, 0, nil, 0, nil)}, | 174 | + | {"sealed", v4Manifest(1, []uint32{math.MaxUint32}, nil, 0, nil, 0, nil)}, |
| 175 | - | {"tail", v3Manifest(0, nil, nil, math.MaxUint32, nil, 0, nil)}, | 175 | + | {"tail", v4Manifest(0, nil, nil, math.MaxUint32, nil, 0, nil)}, |
| 176 | - | {"visible", v3Manifest(0, nil, nil, 0, nil, math.MaxUint32, nil)}, | 176 | + | {"transcript", v4Manifest(0, nil, nil, 0, nil, math.MaxUint32, nil)}, |
| 177 | } { | 177 | } { | ||
| 178 | - | t.Run(tc.name, func(t *testing.T) { reject(t, v3Envelope(version, 0, tc.body), ErrInvalidData) }) | 178 | + | t.Run(tc.name, func(t *testing.T) { reject(t, v4Envelope(version, 0, tc.body), ErrInvalidData) }) |
| 179 | } | 179 | } | ||
| 180 | } | 180 | } | ||
| 181 | 181 | ||||
| 182 | - | func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) { | 182 | + | func TestUnmarshalRejectsEveryV4PrefixAndTrailingGarbage(t *testing.T) { |
| 183 | sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}}) | 183 | sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}}) | ||
| 184 | - | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | 184 | + | transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) |
| 185 | - | encoded, err := marshalTest(Session{Name: "p", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}}) | 185 | + | encoded, err := marshalTest(Session{Name: "p", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Transcript: transcript}}}}}) |
| 186 | if err != nil { | 186 | if err != nil { | ||
| 187 | t.Fatal(err) | 187 | t.Fatal(err) | ||
| 188 | } | 188 | } | ||
| @@ -194,13 +194,13 @@ func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) { | |||||
| 194 | 194 | ||||
| 195 | func TestUnmarshalRejectsEmptyAndDuplicatePaneIDsDuringPreflight(t *testing.T) { | 195 | func TestUnmarshalRejectsEmptyAndDuplicatePaneIDsDuringPreflight(t *testing.T) { | ||
| 196 | sealed, tail := historyBlobs(t, nil) | 196 | sealed, tail := historyBlobs(t, nil) | ||
| 197 | - | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | 197 | + | transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) |
| 198 | for _, tc := range []struct { | 198 | for _, tc := range []struct { | ||
| 199 | name string | 199 | name string | ||
| 200 | panes []Pane | 200 | panes []Pane | ||
| 201 | }{ | 201 | }{ | ||
| 202 | - | {name: "empty", panes: []Pane{{ID: "", Tail: tail, Visible: visible}}}, | 202 | + | {name: "empty", panes: []Pane{{ID: "", Tail: tail, Transcript: transcript}}}, |
| 203 | - | {name: "duplicate", panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}, {ID: "p", Tail: tail, Visible: visible}}}, | 203 | + | {name: "duplicate", panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Transcript: transcript}, {ID: "p", Tail: tail, Transcript: transcript}}}, |
| 204 | } { | 204 | } { | ||
| 205 | t.Run(tc.name, func(t *testing.T) { | 205 | t.Run(tc.name, func(t *testing.T) { | ||
| 206 | data, err := marshalTest(Session{Name: "s", Tabs: []Tab{{Panes: tc.panes}}}) | 206 | data, err := marshalTest(Session{Name: "s", Tabs: []Tab{{Panes: tc.panes}}}) | ||
| @@ -212,8 +212,8 @@ func TestUnmarshalRejectsEmptyAndDuplicatePaneIDsDuringPreflight(t *testing.T) { | |||||
| 212 | 212 | ||||
| 213 | func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) { | 213 | func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) { | ||
| 214 | sealed, tail := historyBlobs(t, nil) | 214 | sealed, tail := historyBlobs(t, nil) | ||
| 215 | - | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | 215 | + | transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) |
| 216 | - | encoded, err := marshalTest(Session{Name: "p", Tabs: []Tab{{Focus: "missing", Tree: layout.NewTree("missing"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}}) | 216 | + | encoded, err := marshalTest(Session{Name: "p", Tabs: []Tab{{Focus: "missing", Tree: layout.NewTree("missing"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Transcript: transcript}}}}}) |
| 217 | if err != nil { | 217 | if err != nil { | ||
| 218 | t.Fatal(err) | 218 | t.Fatal(err) | ||
| 219 | } | 219 | } | ||
| @@ -231,12 +231,12 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) { | |||||
| 231 | multiChunk = binary.BigEndian.AppendUint32(multiChunk, 2) | 231 | multiChunk = binary.BigEndian.AppendUint32(multiChunk, 2) | ||
| 232 | multiChunk = append(multiChunk, sealed[0][9:]...) | 232 | multiChunk = append(multiChunk, sealed[0][9:]...) | ||
| 233 | multiChunk = append(multiChunk, sealed[0][9:]...) | 233 | multiChunk = append(multiChunk, sealed[0][9:]...) | ||
| 234 | - | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | 234 | + | transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) |
| 235 | - | emptyVisible := visibleBlob(t, nil) | 235 | + | emptyTranscript := transcriptBlob(t, nil) |
| 236 | 236 | ||||
| 237 | // Incremental captures retain one copied mutable tail chunk instead of | 237 | // Incremental captures retain one copied mutable tail chunk instead of | ||
| 238 | // rotating it into the sealed history set. That one chunk is canonical and | 238 | // rotating it into the sealed history set. That one chunk is canonical and | ||
| 239 | - | // must remain a valid v3 import payload. | 239 | + | // must remain a valid v4 import payload. |
| 240 | mutableHistory := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2}) | 240 | mutableHistory := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2}) | ||
| 241 | for _, row := range [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}} { | 241 | for _, row := range [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}} { | ||
| 242 | requireNoError(t, mutableHistory.Append(row, vt.LineBound{End: len(row)})) | 242 | requireNoError(t, mutableHistory.Append(row, vt.LineBound{End: len(row)})) | ||
| @@ -245,22 +245,32 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) { | |||||
| 245 | requireNoError(t, err) | 245 | requireNoError(t, err) | ||
| 246 | fullHistory, err := vt.MarshalHistory(mutableHistory.SealAndView()) | 246 | fullHistory, err := vt.MarshalHistory(mutableHistory.SealAndView()) | ||
| 247 | requireNoError(t, err) | 247 | requireNoError(t, err) | ||
| 248 | - | valid, err := marshalTest(Session{Name: "s", Tabs: []Tab{{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: sealed[:1], Tail: mutableTail, Visible: visible}}}}}) | 248 | + | for _, tc := range []struct { |
| 249 | - | requireNoError(t, err) | 249 | + | name string |
| 250 | - | roundTrip, err := Unmarshal(valid) | 250 | + | transcript []byte |
| 251 | - | requireNoError(t, err) | 251 | + | }{ |
| 252 | - | if got := roundTrip.Tabs[0].Panes[0].Tail; !bytes.Equal(got, mutableTail) { | 252 | + | {name: "single chunk", transcript: transcript}, |
| 253 | - | t.Fatal("mutable tail did not round trip") | 253 | + | {name: "empty", transcript: emptyTranscript}, |
| 254 | + | {name: "multiple chunks", transcript: multiChunk}, | |||
| 255 | + | } { | |||
| 256 | + | t.Run("valid "+tc.name, func(t *testing.T) { | |||
| 257 | + | data, err := marshalTest(Session{Name: "s", Tabs: []Tab{{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: sealed[:1], Tail: mutableTail, Transcript: tc.transcript}}}}}) | |||
| 258 | + | requireNoError(t, err) | |||
| 259 | + | roundTrip, err := Unmarshal(data) | |||
| 260 | + | requireNoError(t, err) | |||
| 261 | + | if got := roundTrip.Tabs[0].Panes[0].Tail; !bytes.Equal(got, mutableTail) { | |||
| 262 | + | t.Fatal("mutable tail did not round trip") | |||
| 263 | + | } | |||
| 264 | + | }) | |||
| 254 | } | 265 | } | ||
| 255 | 266 | ||||
| 256 | for _, tc := range []struct { | 267 | for _, tc := range []struct { | ||
| 257 | name string | 268 | name string | ||
| 258 | tab Tab | 269 | tab Tab | ||
| 259 | }{ | 270 | }{ | ||
| 260 | - | {name: "sealed multi-chunk", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: [][]byte{multiChunk}, Tail: emptyTail, Visible: visible}}}}, | 271 | + | {name: "sealed multi-chunk", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: [][]byte{multiChunk}, Tail: emptyTail, Transcript: transcript}}}}, |
| 261 | - | {name: "tail has multiple chunks", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: multiChunk, Visible: visible}}}}, | 272 | + | {name: "tail has multiple chunks", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: multiChunk, Transcript: transcript}}}}, |
| 262 | - | {name: "noncanonical full history tail", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: fullHistory, Visible: visible}}}}, | 273 | + | {name: "noncanonical full history tail", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: fullHistory, Transcript: transcript}}}}, |
| 263 | - | {name: "empty visible geometry", tab: Tab{Panes: []Pane{{ID: "p", Tail: emptyTail, Visible: emptyVisible}}}}, | |||
| 264 | } { | 274 | } { | ||
| 265 | t.Run(tc.name, func(t *testing.T) { | 275 | t.Run(tc.name, func(t *testing.T) { | ||
| 266 | data, err := marshalTest(Session{Name: "s", Tabs: []Tab{tc.tab}}) | 276 | data, err := marshalTest(Session{Name: "s", Tabs: []Tab{tc.tab}}) | ||
| @@ -270,13 +280,22 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) { | |||||
| 270 | } | 280 | } | ||
| 271 | } | 281 | } | ||
| 272 | 282 | ||||
| 273 | - | func TestUnmarshalRejectsMalformedTerminalBlobWithoutPanic(t *testing.T) { | 283 | + | func TestUnmarshalRejectsMalformedCanonicalVTBlobsWithoutPanic(t *testing.T) { |
| 274 | tail := mustHistoryTail(t) | 284 | tail := mustHistoryTail(t) | ||
| 275 | - | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | 285 | + | transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) |
| 276 | - | // The visible cell's underline-style byte is outside the renderer enum. | 286 | + | for _, tc := range []struct { |
| 277 | - | visible[13+29] = 0xff | 287 | + | name string |
| 278 | - | body := v3Manifest(0, nil, nil, uint32(len(tail)), tail, uint32(len(visible)), visible) | 288 | + | blob []byte |
| 279 | - | reject(t, v3Envelope(version, 0, body), ErrInvalidData) | 289 | + | }{ |
| 290 | + | {name: "malformed", blob: append([]byte("NOPE"), transcript[4:]...)}, | |||
| 291 | + | {name: "truncated", blob: transcript[:len(transcript)-1]}, | |||
| 292 | + | {name: "trailing", blob: append(append([]byte(nil), transcript...), 0)}, | |||
| 293 | + | } { | |||
| 294 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 295 | + | body := v4Manifest(0, nil, nil, uint32(len(tail)), tail, uint32(len(tc.blob)), tc.blob) | |||
| 296 | + | reject(t, v4Envelope(version, 0, body), ErrInvalidData) | |||
| 297 | + | }) | |||
| 298 | + | } | |||
| 280 | } | 299 | } | ||
| 281 | 300 | ||||
| 282 | func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) { | 301 | func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) { | ||
| @@ -301,7 +320,7 @@ func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) { | |||||
| 301 | 320 | ||||
| 302 | func TestRoundTripSessionMetadataAndProcess(t *testing.T) { | 321 | func TestRoundTripSessionMetadataAndProcess(t *testing.T) { | ||
| 303 | sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}, {{Rune: 'i'}}}) | 322 | sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}, {{Rune: 'i'}}}) | ||
| 304 | - | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | 323 | + | transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) |
| 305 | want := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{ | 324 | want := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{ | ||
| 306 | { | 325 | { | ||
| 307 | StableID: "t1", Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2", | 326 | StableID: "t1", Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2", | ||
| @@ -309,11 +328,11 @@ func TestRoundTripSessionMetadataAndProcess(t *testing.T) { | |||||
| 309 | layout.NewLeaf("1"), layout.NewLeaf("2"), | 328 | layout.NewLeaf("1"), layout.NewLeaf("2"), | ||
| 310 | }}}, | 329 | }}}, | ||
| 311 | Panes: []Pane{ | 330 | Panes: []Pane{ | ||
| 312 | - | {ID: "1", StableID: "p1", Cwd: "/one", SealedChunks: sealed, Tail: tail, Visible: visible, Process: &Process{Argv: []string{"pi", "--resume"}, Strategy: "pi", Opts: ProcessOpts{AgentSessionID: "agent-123"}}}, | 331 | + | {ID: "1", StableID: "p1", Cwd: "/one", SealedChunks: sealed, Tail: tail, Transcript: transcript, Process: &Process{Argv: []string{"pi", "--resume"}, Strategy: "pi", Opts: ProcessOpts{AgentSessionID: "agent-123"}}}, |
| 313 | - | {ID: "2", StableID: "p2", Cwd: "/two", SealedChunks: sealed, Tail: tail, Visible: visible}, | 332 | + | {ID: "2", StableID: "p2", Cwd: "/two", SealedChunks: sealed, Tail: tail, Transcript: transcript}, |
| 314 | }, | 333 | }, | ||
| 315 | }, | 334 | }, | ||
| 316 | - | {StableID: "t2", Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", SealedChunks: sealed, Tail: tail, Visible: visible}}}, | 335 | + | {StableID: "t2", Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", SealedChunks: sealed, Tail: tail, Transcript: transcript}}}, |
| 317 | }} | 336 | }} | ||
| 318 | data, err := marshalTest(want) | 337 | data, err := marshalTest(want) | ||
| 319 | requireNoError(t, err) | 338 | requireNoError(t, err) | ||
| @@ -339,7 +358,7 @@ func TestMarshalRejectsProcessWithoutArgv(t *testing.T) { | |||||
| 339 | 358 | ||||
| 340 | func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) { | 359 | func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) { | ||
| 341 | tail := mustHistoryTail(t) | 360 | tail := mustHistoryTail(t) | ||
| 342 | - | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | 361 | + | transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) |
| 343 | var w payloadWriter | 362 | var w payloadWriter | ||
| 344 | _ = w.putString("s") | 363 | _ = w.putString("s") | ||
| 345 | w.putUint64(0) | 364 | w.putUint64(0) | ||
| @@ -358,13 +377,13 @@ func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) { | |||||
| 358 | w.putUint32(0) | 377 | w.putUint32(0) | ||
| 359 | w.putUint32(uint32(len(tail))) | 378 | w.putUint32(uint32(len(tail))) | ||
| 360 | w.b = append(w.b, tail...) | 379 | w.b = append(w.b, tail...) | ||
| 361 | - | w.putUint32(uint32(len(visible))) | 380 | + | w.putUint32(uint32(len(transcript))) |
| 362 | - | w.b = append(w.b, visible...) | 381 | + | w.b = append(w.b, transcript...) |
| 363 | w.putUint8(1) | 382 | w.putUint8(1) | ||
| 364 | w.putUint16(0) // argv count | 383 | w.putUint16(0) // argv count | ||
| 365 | _ = w.putString("generic") | 384 | _ = w.putString("generic") | ||
| 366 | _ = w.putString("") | 385 | _ = w.putString("") | ||
| 367 | - | if _, err := Unmarshal(v3Envelope(version, 0, w.b)); !errors.Is(err, ErrInvalidData) { | 386 | + | if _, err := Unmarshal(v4Envelope(version, 0, w.b)); !errors.Is(err, ErrInvalidData) { |
| 368 | t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData) | 387 | t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData) | ||
| 369 | } | 388 | } | ||
| 370 | } | 389 | } | ||
| @@ -376,12 +395,12 @@ func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) { | |||||
| 376 | } | 395 | } | ||
| 377 | sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView()) | 396 | sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView()) | ||
| 378 | requireNoError(t, err) | 397 | requireNoError(t, err) | ||
| 379 | - | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | 398 | + | transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) |
| 380 | 399 | ||||
| 381 | const paneCount = maxSnapshotRows/256 + 1 | 400 | const paneCount = maxSnapshotRows/256 + 1 | ||
| 382 | tabs := make([]Tab, paneCount) | 401 | tabs := make([]Tab, paneCount) | ||
| 383 | for i := range tabs { | 402 | for i := range tabs { | ||
| 384 | - | tabs[i] = Tab{Panes: []Pane{{ID: layout.PaneID(strconv.Itoa(i)), SealedChunks: sealed, Tail: tail, Visible: visible}}} | 403 | + | tabs[i] = Tab{Panes: []Pane{{ID: layout.PaneID(strconv.Itoa(i)), SealedChunks: sealed, Tail: tail, Transcript: transcript}}} |
| 385 | } | 404 | } | ||
| 386 | data, err := marshalTest(Session{Name: "over-budget", Tabs: tabs}) | 405 | data, err := marshalTest(Session{Name: "over-budget", Tabs: tabs}) | ||
| 387 | requireNoError(t, err) | 406 | requireNoError(t, err) | ||
| @@ -407,15 +426,15 @@ func TestPreflightBudgetRejectsAggregateDecodedAllocation(t *testing.T) { | |||||
| 407 | } | 426 | } | ||
| 408 | 427 | ||||
| 409 | func TestPreflightRejectsBroadTreeBeforeSecondPassAllocation(t *testing.T) { | 428 | func TestPreflightRejectsBroadTreeBeforeSecondPassAllocation(t *testing.T) { | ||
| 410 | - | body := v3WideTreeManifest(32_769) | 429 | + | body := v4WideTreeManifest(32_769) |
| 411 | if err := preflightSession(body); !errors.Is(err, ErrInvalidData) { | 430 | if err := preflightSession(body); !errors.Is(err, ErrInvalidData) { | ||
| 412 | t.Fatalf("preflightSession() error = %v, want %v", err, ErrInvalidData) | 431 | t.Fatalf("preflightSession() error = %v, want %v", err, ErrInvalidData) | ||
| 413 | } | 432 | } | ||
| 414 | } | 433 | } | ||
| 415 | 434 | ||||
| 416 | func TestUnmarshalRejectsAggregateOpaqueBlobDeclarations(t *testing.T) { | 435 | func TestUnmarshalRejectsAggregateOpaqueBlobDeclarations(t *testing.T) { | ||
| 417 | - | body := v3Manifest(math.MaxUint32, nil, nil, math.MaxUint32, nil, math.MaxUint32, nil) | 436 | + | body := v4Manifest(math.MaxUint32, nil, nil, math.MaxUint32, nil, math.MaxUint32, nil) |
| 418 | - | reject(t, v3Envelope(version, 0, body), ErrInvalidData) | 437 | + | reject(t, v4Envelope(version, 0, body), ErrInvalidData) |
| 419 | } | 438 | } | ||
| 420 | 439 | ||||
| 421 | func mustHistoryTail(t *testing.T) []byte { | 440 | func mustHistoryTail(t *testing.T) []byte { | ||
| @@ -443,26 +462,22 @@ func historyBlobs(t *testing.T, rows [][]renderer.Cell) ([][]byte, []byte) { | |||||
| 443 | } | 462 | } | ||
| 444 | return sealed, tail | 463 | return sealed, tail | ||
| 445 | } | 464 | } | ||
| 446 | - | func visibleBlob(t *testing.T, rows [][]renderer.Cell) []byte { | 465 | + | func transcriptBlob(t *testing.T, rows [][]renderer.Cell) []byte { |
| 447 | t.Helper() | 466 | t.Helper() | ||
| 448 | - | width := 0 | 467 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: max(1, len(rows)), ChunkRows: 2}) |
| 449 | for _, row := range rows { | 468 | for _, row := range rows { | ||
| 450 | - | width = max(width, len(row)) | 469 | + | requireNoError(t, history.Append(row, vt.LineBound{End: len(row)})) |
| 451 | } | 470 | } | ||
| 452 | - | f := renderer.NewFrame(width, len(rows)) | 471 | + | blob, err := vt.MarshalHistory(history.SealAndView()) |
| 453 | - | for y, row := range rows { | |||
| 454 | - | copy(f.Row(y), row) | |||
| 455 | - | } | |||
| 456 | - | b, err := vt.MarshalVisible(f) | |||
| 457 | if err != nil { | 472 | if err != nil { | ||
| 458 | t.Fatal(err) | 473 | t.Fatal(err) | ||
| 459 | } | 474 | } | ||
| 460 | - | return b | 475 | + | return blob |
| 461 | } | 476 | } | ||
| 462 | - | func requireV3Envelope(t *testing.T, data []byte) { | 477 | + | func requireV4Envelope(t *testing.T, data []byte) { |
| 463 | t.Helper() | 478 | t.Helper() | ||
| 464 | - | if len(data) < 16 || binary.BigEndian.Uint16(data[4:6]) != version || binary.BigEndian.Uint16(data[6:8]) != 0 { | 479 | + | if version != 4 || len(data) < 16 || binary.BigEndian.Uint16(data[4:6]) != 4 || binary.BigEndian.Uint16(data[6:8]) != 0 { |
| 465 | - | t.Fatalf("not v3 envelope: % x", data) | 480 | + | t.Fatalf("not v4 envelope: % x", data) |
| 466 | } | 481 | } | ||
| 467 | } | 482 | } | ||
| 468 | func reject(t *testing.T, data []byte, want error) { | 483 | func reject(t *testing.T, data []byte, want error) { | ||
| @@ -480,10 +495,10 @@ func reject(t *testing.T, data []byte, want error) { | |||||
| 480 | t.Fatalf("error = %v, want %v", err, want) | 495 | t.Fatalf("error = %v, want %v", err, want) | ||
| 481 | } | 496 | } | ||
| 482 | } | 497 | } | ||
| 483 | - | func v3Envelope(v, flags uint16, body []byte) []byte { | 498 | + | func v4Envelope(v, flags uint16, body []byte) []byte { |
| 484 | - | return v3EnvelopeWithLength(v, flags, body, uint32(len(body))) | 499 | + | return v4EnvelopeWithLength(v, flags, body, uint32(len(body))) |
| 485 | } | 500 | } | ||
| 486 | - | func v3EnvelopeWithLength(v, flags uint16, body []byte, length uint32) []byte { | 501 | + | func v4EnvelopeWithLength(v, flags uint16, body []byte, length uint32) []byte { |
| 487 | out := make([]byte, 16+len(body)) | 502 | out := make([]byte, 16+len(body)) | ||
| 488 | copy(out, magic) | 503 | copy(out, magic) | ||
| 489 | binary.BigEndian.PutUint16(out[4:6], v) | 504 | binary.BigEndian.PutUint16(out[4:6], v) | ||
| @@ -494,7 +509,7 @@ func v3EnvelopeWithLength(v, flags uint16, body []byte, length uint32) []byte { | |||||
| 494 | return out | 509 | return out | ||
| 495 | } | 510 | } | ||
| 496 | 511 | ||||
| 497 | - | func v3ActiveManifest(active, tabs uint16) []byte { | 512 | + | func v4ActiveManifest(active, tabs uint16) []byte { |
| 498 | var b []byte | 513 | var b []byte | ||
| 499 | putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) } | 514 | putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) } | ||
| 500 | putS("s") | 515 | putS("s") | ||
| @@ -511,10 +526,10 @@ func v3ActiveManifest(active, tabs uint16) []byte { | |||||
| 511 | return b | 526 | return b | ||
| 512 | } | 527 | } | ||
| 513 | 528 | ||||
| 514 | - | // v3Manifest creates just enough session/tab/pane framing for hostile declarations. | 529 | + | // v4Manifest creates just enough session/tab/pane framing for hostile declarations. |
| 515 | - | // v3WideTreeManifest declares a broad but compact tree without allocating a | 530 | + | // v4WideTreeManifest declares a broad but compact tree without allocating a |
| 516 | // matching layout.Node graph. It exercises preflight's manifest budget. | 531 | // matching layout.Node graph. It exercises preflight's manifest budget. | ||
| 517 | - | func v3WideTreeManifest(children uint16) []byte { | 532 | + | func v4WideTreeManifest(children uint16) []byte { |
| 518 | var b []byte | 533 | var b []byte | ||
| 519 | putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) } | 534 | putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) } | ||
| 520 | putS("s") | 535 | putS("s") | ||
| @@ -533,7 +548,7 @@ func v3WideTreeManifest(children uint16) []byte { | |||||
| 533 | return b | 548 | return b | ||
| 534 | } | 549 | } | ||
| 535 | 550 | ||||
| 536 | - | func v3Manifest(sealed uint32, sealedLens []uint32, sealedData []byte, tailLen uint32, tailData []byte, visibleLen uint32, visibleData []byte) []byte { | 551 | + | func v4Manifest(sealed uint32, sealedLens []uint32, sealedData []byte, tailLen uint32, tailData []byte, transcriptLen uint32, transcriptData []byte) []byte { |
| 537 | var b []byte | 552 | var b []byte | ||
| 538 | putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) } | 553 | putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) } | ||
| 539 | putS("s") | 554 | putS("s") | ||
| @@ -555,7 +570,7 @@ func v3Manifest(sealed uint32, sealedLens []uint32, sealedData []byte, tailLen u | |||||
| 555 | b = append(b, sealedData...) | 570 | b = append(b, sealedData...) | ||
| 556 | b = binary.BigEndian.AppendUint32(b, tailLen) | 571 | b = binary.BigEndian.AppendUint32(b, tailLen) | ||
| 557 | b = append(b, tailData...) | 572 | b = append(b, tailData...) | ||
| 558 | - | b = binary.BigEndian.AppendUint32(b, visibleLen) | 573 | + | b = binary.BigEndian.AppendUint32(b, transcriptLen) |
| 559 | - | b = append(b, visibleData...) | 574 | + | b = append(b, transcriptData...) |
| 560 | return b | 575 | return b | ||
| 561 | } | 576 | } | ||
M internal/usecase/snapshot/manifest.go +13 -13
| @@ -16,7 +16,7 @@ const ( | |||
| 16 | 16 | manifestMagic = "VEVM" | |
| 17 | 17 | headMagic = "VEVH" | |
| 18 | 18 | objectMagic = "VEVO" | |
| 19 | - | ManifestVersion = uint16(2) | |
| 19 | + | ManifestVersion = uint16(3) | |
| 20 | 20 | manifestHeaderSize = 16 | |
| 21 | 21 | objectEnvelopeBodyPrefixSize = 1 + 4 // object kind and payload length | |
| 22 | 22 | minObjectEnvelopeSize = manifestHeaderSize + objectEnvelopeBodyPrefixSize + 1 | |
| @@ -48,7 +48,7 @@ type ObjectKind uint8 | |||
| 48 | 48 | const ( | |
| 49 | 49 | HistoryChunk ObjectKind = iota + 1 | |
| 50 | 50 | HistoryTail | |
| 51 | - | Visible | |
| 51 | + | RecoveryTranscript | |
| 52 | 52 | ) | |
| 53 | 53 | ||
| 54 | 54 | type ObjectRef struct { | |
| @@ -117,13 +117,13 @@ type ManifestTab struct { | |||
| 117 | 117 | } | |
| 118 | 118 | ||
| 119 | 119 | type ManifestPane struct { | |
| 120 | - | ID layout.PaneID | |
| 121 | - | StableID string | |
| 122 | - | Cwd string | |
| 123 | - | Sealed []ObjectRef | |
| 124 | - | Tail ObjectRef | |
| 125 | - | Visible ObjectRef | |
| 126 | - | Process *Process | |
| 120 | + | ID layout.PaneID | |
| 121 | + | StableID string | |
| 122 | + | Cwd string | |
| 123 | + | Sealed []ObjectRef | |
| 124 | + | Tail ObjectRef | |
| 125 | + | Transcript ObjectRef | |
| 126 | + | Process *Process | |
| 127 | 127 | } | |
| 128 | 128 | ||
| 129 | 129 | func MarshalManifest(m Manifest) ([]byte, error) { | |
| @@ -479,7 +479,7 @@ func writeManifestPane(w *payloadWriter, pane ManifestPane) error { | |||
| 479 | 479 | writeObjectRef(w, ref) | |
| 480 | 480 | } | |
| 481 | 481 | writeObjectRef(w, pane.Tail) | |
| 482 | - | writeObjectRef(w, pane.Visible) | |
| 482 | + | writeObjectRef(w, pane.Transcript) | |
| 483 | 483 | return writeProcess(w, pane.Process) | |
| 484 | 484 | } | |
| 485 | 485 | func writeObjectRef(w *payloadWriter, ref ObjectRef) { | |
| @@ -551,7 +551,7 @@ func readManifestPane(r *payloadReader) (ManifestPane, error) { | |||
| 551 | 551 | if pane.Tail, err = readObjectRef(r); err != nil { | |
| 552 | 552 | return ManifestPane{}, err | |
| 553 | 553 | } | |
| 554 | - | if pane.Visible, err = readObjectRef(r); err != nil { | |
| 554 | + | if pane.Transcript, err = readObjectRef(r); err != nil { | |
| 555 | 555 | return ManifestPane{}, err | |
| 556 | 556 | } | |
| 557 | 557 | pane.Process, err = readProcess(r) | |
| @@ -647,7 +647,7 @@ func validatePaneRefs(p ManifestPane) error { | |||
| 647 | 647 | if err := validateObjectRef(p.Tail, HistoryTail); err != nil { | |
| 648 | 648 | return err | |
| 649 | 649 | } | |
| 650 | - | return validateObjectRef(p.Visible, Visible) | |
| 650 | + | return validateObjectRef(p.Transcript, RecoveryTranscript) | |
| 651 | 651 | } | |
| 652 | 652 | func validateObjectRef(ref ObjectRef, want ObjectKind) error { | |
| 653 | 653 | return validateObjectRefFields(ref.Kind, ref.Digest[:], ref.Size, want) | |
| @@ -664,7 +664,7 @@ func validObjectEnvelopeSize(size uint32) bool { | |||
| 664 | 664 | } | |
| 665 | 665 | ||
| 666 | 666 | func validObjectKind(kind ObjectKind) bool { | |
| 667 | - | return kind == HistoryChunk || kind == HistoryTail || kind == Visible | |
| 667 | + | return kind == HistoryChunk || kind == HistoryTail || kind == RecoveryTranscript | |
| 668 | 668 | } | |
| 669 | 669 | func isZeroDigestBytes(d []byte) bool { | |
| 670 | 670 | for _, b := range d { | |
| @@ -16,7 +16,7 @@ const ( | |||||
| 16 | manifestMagic = "VEVM" | 16 | manifestMagic = "VEVM" | ||
| 17 | headMagic = "VEVH" | 17 | headMagic = "VEVH" | ||
| 18 | objectMagic = "VEVO" | 18 | objectMagic = "VEVO" | ||
| 19 | - | ManifestVersion = uint16(2) | 19 | + | ManifestVersion = uint16(3) |
| 20 | manifestHeaderSize = 16 | 20 | manifestHeaderSize = 16 | ||
| 21 | objectEnvelopeBodyPrefixSize = 1 + 4 // object kind and payload length | 21 | objectEnvelopeBodyPrefixSize = 1 + 4 // object kind and payload length | ||
| 22 | minObjectEnvelopeSize = manifestHeaderSize + objectEnvelopeBodyPrefixSize + 1 | 22 | minObjectEnvelopeSize = manifestHeaderSize + objectEnvelopeBodyPrefixSize + 1 | ||
| @@ -48,7 +48,7 @@ type ObjectKind uint8 | |||||
| 48 | const ( | 48 | const ( | ||
| 49 | HistoryChunk ObjectKind = iota + 1 | 49 | HistoryChunk ObjectKind = iota + 1 | ||
| 50 | HistoryTail | 50 | HistoryTail | ||
| 51 | - | Visible | 51 | + | RecoveryTranscript |
| 52 | ) | 52 | ) | ||
| 53 | 53 | ||||
| 54 | type ObjectRef struct { | 54 | type ObjectRef struct { | ||
| @@ -117,13 +117,13 @@ type ManifestTab struct { | |||||
| 117 | } | 117 | } | ||
| 118 | 118 | ||||
| 119 | type ManifestPane struct { | 119 | type ManifestPane struct { | ||
| 120 | - | ID layout.PaneID | 120 | + | ID layout.PaneID |
| 121 | - | StableID string | 121 | + | StableID string |
| 122 | - | Cwd string | 122 | + | Cwd string |
| 123 | - | Sealed []ObjectRef | 123 | + | Sealed []ObjectRef |
| 124 | - | Tail ObjectRef | 124 | + | Tail ObjectRef |
| 125 | - | Visible ObjectRef | 125 | + | Transcript ObjectRef |
| 126 | - | Process *Process | 126 | + | Process *Process |
| 127 | } | 127 | } | ||
| 128 | 128 | ||||
| 129 | func MarshalManifest(m Manifest) ([]byte, error) { | 129 | func MarshalManifest(m Manifest) ([]byte, error) { | ||
| @@ -479,7 +479,7 @@ func writeManifestPane(w *payloadWriter, pane ManifestPane) error { | |||||
| 479 | writeObjectRef(w, ref) | 479 | writeObjectRef(w, ref) | ||
| 480 | } | 480 | } | ||
| 481 | writeObjectRef(w, pane.Tail) | 481 | writeObjectRef(w, pane.Tail) | ||
| 482 | - | writeObjectRef(w, pane.Visible) | 482 | + | writeObjectRef(w, pane.Transcript) |
| 483 | return writeProcess(w, pane.Process) | 483 | return writeProcess(w, pane.Process) | ||
| 484 | } | 484 | } | ||
| 485 | func writeObjectRef(w *payloadWriter, ref ObjectRef) { | 485 | func writeObjectRef(w *payloadWriter, ref ObjectRef) { | ||
| @@ -551,7 +551,7 @@ func readManifestPane(r *payloadReader) (ManifestPane, error) { | |||||
| 551 | if pane.Tail, err = readObjectRef(r); err != nil { | 551 | if pane.Tail, err = readObjectRef(r); err != nil { | ||
| 552 | return ManifestPane{}, err | 552 | return ManifestPane{}, err | ||
| 553 | } | 553 | } | ||
| 554 | - | if pane.Visible, err = readObjectRef(r); err != nil { | 554 | + | if pane.Transcript, err = readObjectRef(r); err != nil { |
| 555 | return ManifestPane{}, err | 555 | return ManifestPane{}, err | ||
| 556 | } | 556 | } | ||
| 557 | pane.Process, err = readProcess(r) | 557 | pane.Process, err = readProcess(r) | ||
| @@ -647,7 +647,7 @@ func validatePaneRefs(p ManifestPane) error { | |||||
| 647 | if err := validateObjectRef(p.Tail, HistoryTail); err != nil { | 647 | if err := validateObjectRef(p.Tail, HistoryTail); err != nil { | ||
| 648 | return err | 648 | return err | ||
| 649 | } | 649 | } | ||
| 650 | - | return validateObjectRef(p.Visible, Visible) | 650 | + | return validateObjectRef(p.Transcript, RecoveryTranscript) |
| 651 | } | 651 | } | ||
| 652 | func validateObjectRef(ref ObjectRef, want ObjectKind) error { | 652 | func validateObjectRef(ref ObjectRef, want ObjectKind) error { | ||
| 653 | return validateObjectRefFields(ref.Kind, ref.Digest[:], ref.Size, want) | 653 | return validateObjectRefFields(ref.Kind, ref.Digest[:], ref.Size, want) | ||
| @@ -664,7 +664,7 @@ func validObjectEnvelopeSize(size uint32) bool { | |||||
| 664 | } | 664 | } | ||
| 665 | 665 | ||||
| 666 | func validObjectKind(kind ObjectKind) bool { | 666 | func validObjectKind(kind ObjectKind) bool { | ||
| 667 | - | return kind == HistoryChunk || kind == HistoryTail || kind == Visible | 667 | + | return kind == HistoryChunk || kind == HistoryTail || kind == RecoveryTranscript |
| 668 | } | 668 | } | ||
| 669 | func isZeroDigestBytes(d []byte) bool { | 669 | func isZeroDigestBytes(d []byte) bool { | ||
| 670 | for _, b := range d { | 670 | for _, b := range d { | ||
M internal/usecase/snapshot/manifest_preflight.go +1 -1
| @@ -269,7 +269,7 @@ func preflightManifestPane(r *payloadReader, budget *manifestPreflightBudget) (l | |||
| 269 | 269 | if err := preflightObjectRef(r, HistoryTail); err != nil { | |
| 270 | 270 | return "", err | |
| 271 | 271 | } | |
| 272 | - | if err := preflightObjectRef(r, Visible); err != nil { | |
| 272 | + | if err := preflightObjectRef(r, RecoveryTranscript); err != nil { | |
| 273 | 273 | return "", err | |
| 274 | 274 | } | |
| 275 | 275 | if err := preflightManifestProcess(r, budget); err != nil { | |
| @@ -269,7 +269,7 @@ func preflightManifestPane(r *payloadReader, budget *manifestPreflightBudget) (l | |||||
| 269 | if err := preflightObjectRef(r, HistoryTail); err != nil { | 269 | if err := preflightObjectRef(r, HistoryTail); err != nil { | ||
| 270 | return "", err | 270 | return "", err | ||
| 271 | } | 271 | } | ||
| 272 | - | if err := preflightObjectRef(r, Visible); err != nil { | 272 | + | if err := preflightObjectRef(r, RecoveryTranscript); err != nil { |
| 273 | return "", err | 273 | return "", err | ||
| 274 | } | 274 | } | ||
| 275 | if err := preflightManifestProcess(r, budget); err != nil { | 275 | if err := preflightManifestProcess(r, budget); err != nil { | ||
M internal/usecase/snapshot/manifest_preflight_test.go +5 -5
| @@ -140,9 +140,9 @@ func TestManifestObjectRefKinds(t *testing.T) { | |||
| 140 | 140 | }{ | |
| 141 | 141 | {"chunk", HistoryChunk, HistoryChunk, true}, | |
| 142 | 142 | {"tail", HistoryTail, HistoryTail, true}, | |
| 143 | - | {"visible", Visible, Visible, true}, | |
| 144 | - | {"invalid", ObjectKind(99), Visible, false}, | |
| 145 | - | {"wrong role", HistoryTail, Visible, false}, | |
| 143 | + | {"transcript", RecoveryTranscript, RecoveryTranscript, true}, | |
| 144 | + | {"invalid", ObjectKind(99), RecoveryTranscript, false}, | |
| 145 | + | {"wrong role", HistoryTail, RecoveryTranscript, false}, | |
| 146 | 146 | } { | |
| 147 | 147 | t.Run(tc.name, func(t *testing.T) { | |
| 148 | 148 | var w payloadWriter | |
| @@ -230,8 +230,8 @@ func testManifest(tree *layout.Tree, process *Process) Manifest { | |||
| 230 | 230 | return Manifest{Generation: 1, IncarnationID: domain.IncarnationID{1}, Name: "named", Tabs: []ManifestTab{{ | |
| 231 | 231 | StableID: "tab", Cols: 80, Rows: 24, Focus: "one", Tree: tree, | |
| 232 | 232 | Panes: []ManifestPane{ | |
| 233 | - | {ID: "one", StableID: "one", Cwd: "/one", Sealed: []ObjectRef{{Kind: HistoryChunk, Digest: SnapshotDigest{1}, Size: minObjectEnvelopeSize}}, Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize}, Visible: ObjectRef{Kind: Visible, Digest: SnapshotDigest{3}, Size: minObjectEnvelopeSize}, Process: process}, | |
| 234 | - | {ID: "two", StableID: "two", Cwd: "/two", Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{4}, Size: minObjectEnvelopeSize}, Visible: ObjectRef{Kind: Visible, Digest: SnapshotDigest{5}, Size: minObjectEnvelopeSize}}, | |
| 233 | + | {ID: "one", StableID: "one", Cwd: "/one", Sealed: []ObjectRef{{Kind: HistoryChunk, Digest: SnapshotDigest{1}, Size: minObjectEnvelopeSize}}, Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize}, Transcript: ObjectRef{Kind: RecoveryTranscript, Digest: SnapshotDigest{3}, Size: minObjectEnvelopeSize}, Process: process}, | |
| 234 | + | {ID: "two", StableID: "two", Cwd: "/two", Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{4}, Size: minObjectEnvelopeSize}, Transcript: ObjectRef{Kind: RecoveryTranscript, Digest: SnapshotDigest{5}, Size: minObjectEnvelopeSize}}, | |
| 235 | 235 | }, | |
| 236 | 236 | }}} | |
| 237 | 237 | } | |
| @@ -140,9 +140,9 @@ func TestManifestObjectRefKinds(t *testing.T) { | |||||
| 140 | }{ | 140 | }{ | ||
| 141 | {"chunk", HistoryChunk, HistoryChunk, true}, | 141 | {"chunk", HistoryChunk, HistoryChunk, true}, | ||
| 142 | {"tail", HistoryTail, HistoryTail, true}, | 142 | {"tail", HistoryTail, HistoryTail, true}, | ||
| 143 | - | {"visible", Visible, Visible, true}, | 143 | + | {"transcript", RecoveryTranscript, RecoveryTranscript, true}, |
| 144 | - | {"invalid", ObjectKind(99), Visible, false}, | 144 | + | {"invalid", ObjectKind(99), RecoveryTranscript, false}, |
| 145 | - | {"wrong role", HistoryTail, Visible, false}, | 145 | + | {"wrong role", HistoryTail, RecoveryTranscript, false}, |
| 146 | } { | 146 | } { | ||
| 147 | t.Run(tc.name, func(t *testing.T) { | 147 | t.Run(tc.name, func(t *testing.T) { | ||
| 148 | var w payloadWriter | 148 | var w payloadWriter | ||
| @@ -230,8 +230,8 @@ func testManifest(tree *layout.Tree, process *Process) Manifest { | |||||
| 230 | return Manifest{Generation: 1, IncarnationID: domain.IncarnationID{1}, Name: "named", Tabs: []ManifestTab{{ | 230 | return Manifest{Generation: 1, IncarnationID: domain.IncarnationID{1}, Name: "named", Tabs: []ManifestTab{{ | ||
| 231 | StableID: "tab", Cols: 80, Rows: 24, Focus: "one", Tree: tree, | 231 | StableID: "tab", Cols: 80, Rows: 24, Focus: "one", Tree: tree, | ||
| 232 | Panes: []ManifestPane{ | 232 | Panes: []ManifestPane{ | ||
| 233 | - | {ID: "one", StableID: "one", Cwd: "/one", Sealed: []ObjectRef{{Kind: HistoryChunk, Digest: SnapshotDigest{1}, Size: minObjectEnvelopeSize}}, Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize}, Visible: ObjectRef{Kind: Visible, Digest: SnapshotDigest{3}, Size: minObjectEnvelopeSize}, Process: process}, | 233 | + | {ID: "one", StableID: "one", Cwd: "/one", Sealed: []ObjectRef{{Kind: HistoryChunk, Digest: SnapshotDigest{1}, Size: minObjectEnvelopeSize}}, Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize}, Transcript: ObjectRef{Kind: RecoveryTranscript, Digest: SnapshotDigest{3}, Size: minObjectEnvelopeSize}, Process: process}, |
| 234 | - | {ID: "two", StableID: "two", Cwd: "/two", Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{4}, Size: minObjectEnvelopeSize}, Visible: ObjectRef{Kind: Visible, Digest: SnapshotDigest{5}, Size: minObjectEnvelopeSize}}, | 234 | + | {ID: "two", StableID: "two", Cwd: "/two", Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{4}, Size: minObjectEnvelopeSize}, Transcript: ObjectRef{Kind: RecoveryTranscript, Digest: SnapshotDigest{5}, Size: minObjectEnvelopeSize}}, |
| 235 | }, | 235 | }, | ||
| 236 | }}} | 236 | }}} | ||
| 237 | } | 237 | } | ||
M internal/usecase/snapshot/manifest_test.go +19 -15
| @@ -65,16 +65,16 @@ func TestManifestCodecUsesGenerationAndOrderedTypedReferences(t *testing.T) { | |||
| 65 | 65 | if err != nil { | |
| 66 | 66 | t.Fatal(err) | |
| 67 | 67 | } | |
| 68 | - | visible, err := MarshalObject(Visible, []byte("visible")) | |
| 68 | + | transcript, err := MarshalObject(RecoveryTranscript, []byte("transcript")) | |
| 69 | 69 | if err != nil { | |
| 70 | 70 | t.Fatal(err) | |
| 71 | 71 | } | |
| 72 | 72 | manifest := Manifest{Generation: 7, IncarnationID: domain.IncarnationID{1}, Name: "named", CreatedAt: 9, Active: 0, Tabs: []ManifestTab{{ | |
| 73 | 73 | StableID: "tab", Cols: 80, Rows: 24, NextPaneID: 2, Focus: "pane", Tree: layout.NewTree("pane"), | |
| 74 | 74 | Panes: []ManifestPane{{ID: "pane", StableID: "pane-stable", Cwd: "/tmp", | |
| 75 | - | Sealed: []ObjectRef{{Kind: HistoryChunk, Digest: chunk.Digest, Size: uint32(len(chunk.Data))}}, | |
| 76 | - | Tail: ObjectRef{Kind: HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, | |
| 77 | - | Visible: ObjectRef{Kind: Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}, | |
| 75 | + | Sealed: []ObjectRef{{Kind: HistoryChunk, Digest: chunk.Digest, Size: uint32(len(chunk.Data))}}, | |
| 76 | + | Tail: ObjectRef{Kind: HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, | |
| 77 | + | Transcript: ObjectRef{Kind: RecoveryTranscript, Digest: transcript.Digest, Size: uint32(len(transcript.Data))}, | |
| 78 | 78 | }}, | |
| 79 | 79 | }}} | |
| 80 | 80 | encoded, err := MarshalManifest(manifest) | |
| @@ -88,6 +88,10 @@ func TestManifestCodecUsesGenerationAndOrderedTypedReferences(t *testing.T) { | |||
| 88 | 88 | if got.Generation != manifest.Generation || got.Tabs[0].Panes[0].Tail != manifest.Tabs[0].Panes[0].Tail { | |
| 89 | 89 | t.Fatalf("round trip = %#v", got) | |
| 90 | 90 | } | |
| 91 | + | gotTranscript := got.Tabs[0].Panes[0].Transcript | |
| 92 | + | if gotTranscript.Kind != RecoveryTranscript || gotTranscript.Digest != transcript.Digest || gotTranscript.Size != uint32(len(transcript.Data)) { | |
| 93 | + | t.Fatalf("transcript round trip = %#v", gotTranscript) | |
| 94 | + | } | |
| 91 | 95 | for n := range len(encoded) { | |
| 92 | 96 | if _, err := UnmarshalManifest(encoded[:n]); err == nil { | |
| 93 | 97 | t.Fatalf("prefix %d accepted", n) | |
| @@ -104,7 +108,7 @@ func TestManifestCodecUsesGenerationAndOrderedTypedReferences(t *testing.T) { | |||
| 104 | 108 | } | |
| 105 | 109 | ||
| 106 | 110 | func TestObjectCodecReturnsContentAddressedObjectAndPayload(t *testing.T) { | |
| 107 | - | object, err := MarshalObject(Visible, []byte("canonical-vt")) | |
| 111 | + | object, err := MarshalObject(RecoveryTranscript, []byte("canonical-vt")) | |
| 108 | 112 | if err != nil { | |
| 109 | 113 | t.Fatal(err) | |
| 110 | 114 | } | |
| @@ -112,11 +116,11 @@ func TestObjectCodecReturnsContentAddressedObjectAndPayload(t *testing.T) { | |||
| 112 | 116 | t.Fatalf("digest does not address VEVO bytes") | |
| 113 | 117 | } | |
| 114 | 118 | kind, payload, err := UnmarshalObject(object.Data) | |
| 115 | - | if err != nil || kind != Visible || !bytes.Equal(payload, []byte("canonical-vt")) { | |
| 119 | + | if err != nil || kind != RecoveryTranscript || !bytes.Equal(payload, []byte("canonical-vt")) { | |
| 116 | 120 | t.Fatalf("round trip = %v %q %v", kind, payload, err) | |
| 117 | 121 | } | |
| 118 | 122 | kind, payload, err = PreflightObject(object.Data) | |
| 119 | - | if err != nil || kind != Visible || !bytes.Equal(payload, []byte("canonical-vt")) { | |
| 123 | + | if err != nil || kind != RecoveryTranscript || !bytes.Equal(payload, []byte("canonical-vt")) { | |
| 120 | 124 | t.Fatalf("preflight = %v %q %v", kind, payload, err) | |
| 121 | 125 | } | |
| 122 | 126 | for n := range len(object.Data) { | |
| @@ -131,7 +135,7 @@ func TestObjectCodecReturnsContentAddressedObjectAndPayload(t *testing.T) { | |||
| 131 | 135 | ||
| 132 | 136 | func TestManifestDimensionBoundaries(t *testing.T) { | |
| 133 | 137 | ref := ObjectRef{Kind: HistoryTail, Digest: ports.SnapshotDigest{1}, Size: minObjectEnvelopeSize} | |
| 134 | - | visible := ObjectRef{Kind: Visible, Digest: ports.SnapshotDigest{2}, Size: minObjectEnvelopeSize} | |
| 138 | + | transcript := ObjectRef{Kind: RecoveryTranscript, Digest: ports.SnapshotDigest{2}, Size: minObjectEnvelopeSize} | |
| 135 | 139 | for _, tc := range []struct { | |
| 136 | 140 | name string | |
| 137 | 141 | cols, rows uint16 | |
| @@ -142,7 +146,7 @@ func TestManifestDimensionBoundaries(t *testing.T) { | |||
| 142 | 146 | {"hostile product", math.MaxUint16, math.MaxUint16, true}, | |
| 143 | 147 | } { | |
| 144 | 148 | t.Run(tc.name, func(t *testing.T) { | |
| 145 | - | m := Manifest{Generation: 1, IncarnationID: domain.IncarnationID{1}, Name: "safe", Tabs: []ManifestTab{{Cols: tc.cols, Rows: tc.rows, Panes: []ManifestPane{{ID: "p", Tail: ref, Visible: visible}}}}} | |
| 149 | + | m := Manifest{Generation: 1, IncarnationID: domain.IncarnationID{1}, Name: "safe", Tabs: []ManifestTab{{Cols: tc.cols, Rows: tc.rows, Panes: []ManifestPane{{ID: "p", Tail: ref, Transcript: transcript}}}}} | |
| 146 | 150 | _, err := MarshalManifest(m) | |
| 147 | 151 | if (err != nil) != tc.wantErr { | |
| 148 | 152 | t.Fatalf("MarshalManifest() error = %v, want error=%v", err, tc.wantErr) | |
| @@ -153,9 +157,9 @@ func TestManifestDimensionBoundaries(t *testing.T) { | |||
| 153 | 157 | ||
| 154 | 158 | func TestManifestRejectsImpossibleObjectEnvelopeSize(t *testing.T) { | |
| 155 | 159 | manifest := Manifest{Generation: 1, IncarnationID: domain.IncarnationID{1}, Name: "named", Tabs: []ManifestTab{{Cols: 1, Rows: 1, Panes: []ManifestPane{{ | |
| 156 | - | ID: "pane", | |
| 157 | - | Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{1}, Size: 1}, | |
| 158 | - | Visible: ObjectRef{Kind: Visible, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize}, | |
| 160 | + | ID: "pane", | |
| 161 | + | Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{1}, Size: 1}, | |
| 162 | + | Transcript: ObjectRef{Kind: RecoveryTranscript, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize}, | |
| 159 | 163 | }}}}} | |
| 160 | 164 | if _, err := MarshalManifest(manifest); !errors.Is(err, ErrInvalidData) { | |
| 161 | 165 | t.Fatalf("MarshalManifest() error = %v, want invalid data", err) | |
| @@ -220,9 +224,9 @@ func manifestWithTailSize(size uint32) Manifest { | |||
| 220 | 224 | Cols: 1, | |
| 221 | 225 | Rows: 1, | |
| 222 | 226 | Panes: []ManifestPane{{ | |
| 223 | - | ID: "pane", | |
| 224 | - | Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{1}, Size: size}, | |
| 225 | - | Visible: ObjectRef{Kind: Visible, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize}, | |
| 227 | + | ID: "pane", | |
| 228 | + | Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{1}, Size: size}, | |
| 229 | + | Transcript: ObjectRef{Kind: RecoveryTranscript, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize}, | |
| 226 | 230 | }}, | |
| 227 | 231 | }}} | |
| 228 | 232 | } | |
| @@ -65,16 +65,16 @@ func TestManifestCodecUsesGenerationAndOrderedTypedReferences(t *testing.T) { | |||||
| 65 | if err != nil { | 65 | if err != nil { | ||
| 66 | t.Fatal(err) | 66 | t.Fatal(err) | ||
| 67 | } | 67 | } | ||
| 68 | - | visible, err := MarshalObject(Visible, []byte("visible")) | 68 | + | transcript, err := MarshalObject(RecoveryTranscript, []byte("transcript")) |
| 69 | if err != nil { | 69 | if err != nil { | ||
| 70 | t.Fatal(err) | 70 | t.Fatal(err) | ||
| 71 | } | 71 | } | ||
| 72 | manifest := Manifest{Generation: 7, IncarnationID: domain.IncarnationID{1}, Name: "named", CreatedAt: 9, Active: 0, Tabs: []ManifestTab{{ | 72 | manifest := Manifest{Generation: 7, IncarnationID: domain.IncarnationID{1}, Name: "named", CreatedAt: 9, Active: 0, Tabs: []ManifestTab{{ | ||
| 73 | StableID: "tab", Cols: 80, Rows: 24, NextPaneID: 2, Focus: "pane", Tree: layout.NewTree("pane"), | 73 | StableID: "tab", Cols: 80, Rows: 24, NextPaneID: 2, Focus: "pane", Tree: layout.NewTree("pane"), | ||
| 74 | Panes: []ManifestPane{{ID: "pane", StableID: "pane-stable", Cwd: "/tmp", | 74 | Panes: []ManifestPane{{ID: "pane", StableID: "pane-stable", Cwd: "/tmp", | ||
| 75 | - | Sealed: []ObjectRef{{Kind: HistoryChunk, Digest: chunk.Digest, Size: uint32(len(chunk.Data))}}, | 75 | + | Sealed: []ObjectRef{{Kind: HistoryChunk, Digest: chunk.Digest, Size: uint32(len(chunk.Data))}}, |
| 76 | - | Tail: ObjectRef{Kind: HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, | 76 | + | Tail: ObjectRef{Kind: HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, |
| 77 | - | Visible: ObjectRef{Kind: Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}, | 77 | + | Transcript: ObjectRef{Kind: RecoveryTranscript, Digest: transcript.Digest, Size: uint32(len(transcript.Data))}, |
| 78 | }}, | 78 | }}, | ||
| 79 | }}} | 79 | }}} | ||
| 80 | encoded, err := MarshalManifest(manifest) | 80 | encoded, err := MarshalManifest(manifest) | ||
| @@ -88,6 +88,10 @@ func TestManifestCodecUsesGenerationAndOrderedTypedReferences(t *testing.T) { | |||||
| 88 | if got.Generation != manifest.Generation || got.Tabs[0].Panes[0].Tail != manifest.Tabs[0].Panes[0].Tail { | 88 | if got.Generation != manifest.Generation || got.Tabs[0].Panes[0].Tail != manifest.Tabs[0].Panes[0].Tail { | ||
| 89 | t.Fatalf("round trip = %#v", got) | 89 | t.Fatalf("round trip = %#v", got) | ||
| 90 | } | 90 | } | ||
| 91 | + | gotTranscript := got.Tabs[0].Panes[0].Transcript | |||
| 92 | + | if gotTranscript.Kind != RecoveryTranscript || gotTranscript.Digest != transcript.Digest || gotTranscript.Size != uint32(len(transcript.Data)) { | |||
| 93 | + | t.Fatalf("transcript round trip = %#v", gotTranscript) | |||
| 94 | + | } | |||
| 91 | for n := range len(encoded) { | 95 | for n := range len(encoded) { | ||
| 92 | if _, err := UnmarshalManifest(encoded[:n]); err == nil { | 96 | if _, err := UnmarshalManifest(encoded[:n]); err == nil { | ||
| 93 | t.Fatalf("prefix %d accepted", n) | 97 | t.Fatalf("prefix %d accepted", n) | ||
| @@ -104,7 +108,7 @@ func TestManifestCodecUsesGenerationAndOrderedTypedReferences(t *testing.T) { | |||||
| 104 | } | 108 | } | ||
| 105 | 109 | ||||
| 106 | func TestObjectCodecReturnsContentAddressedObjectAndPayload(t *testing.T) { | 110 | func TestObjectCodecReturnsContentAddressedObjectAndPayload(t *testing.T) { | ||
| 107 | - | object, err := MarshalObject(Visible, []byte("canonical-vt")) | 111 | + | object, err := MarshalObject(RecoveryTranscript, []byte("canonical-vt")) |
| 108 | if err != nil { | 112 | if err != nil { | ||
| 109 | t.Fatal(err) | 113 | t.Fatal(err) | ||
| 110 | } | 114 | } | ||
| @@ -112,11 +116,11 @@ func TestObjectCodecReturnsContentAddressedObjectAndPayload(t *testing.T) { | |||||
| 112 | t.Fatalf("digest does not address VEVO bytes") | 116 | t.Fatalf("digest does not address VEVO bytes") | ||
| 113 | } | 117 | } | ||
| 114 | kind, payload, err := UnmarshalObject(object.Data) | 118 | kind, payload, err := UnmarshalObject(object.Data) | ||
| 115 | - | if err != nil || kind != Visible || !bytes.Equal(payload, []byte("canonical-vt")) { | 119 | + | if err != nil || kind != RecoveryTranscript || !bytes.Equal(payload, []byte("canonical-vt")) { |
| 116 | t.Fatalf("round trip = %v %q %v", kind, payload, err) | 120 | t.Fatalf("round trip = %v %q %v", kind, payload, err) | ||
| 117 | } | 121 | } | ||
| 118 | kind, payload, err = PreflightObject(object.Data) | 122 | kind, payload, err = PreflightObject(object.Data) | ||
| 119 | - | if err != nil || kind != Visible || !bytes.Equal(payload, []byte("canonical-vt")) { | 123 | + | if err != nil || kind != RecoveryTranscript || !bytes.Equal(payload, []byte("canonical-vt")) { |
| 120 | t.Fatalf("preflight = %v %q %v", kind, payload, err) | 124 | t.Fatalf("preflight = %v %q %v", kind, payload, err) | ||
| 121 | } | 125 | } | ||
| 122 | for n := range len(object.Data) { | 126 | for n := range len(object.Data) { | ||
| @@ -131,7 +135,7 @@ func TestObjectCodecReturnsContentAddressedObjectAndPayload(t *testing.T) { | |||||
| 131 | 135 | ||||
| 132 | func TestManifestDimensionBoundaries(t *testing.T) { | 136 | func TestManifestDimensionBoundaries(t *testing.T) { | ||
| 133 | ref := ObjectRef{Kind: HistoryTail, Digest: ports.SnapshotDigest{1}, Size: minObjectEnvelopeSize} | 137 | ref := ObjectRef{Kind: HistoryTail, Digest: ports.SnapshotDigest{1}, Size: minObjectEnvelopeSize} | ||
| 134 | - | visible := ObjectRef{Kind: Visible, Digest: ports.SnapshotDigest{2}, Size: minObjectEnvelopeSize} | 138 | + | transcript := ObjectRef{Kind: RecoveryTranscript, Digest: ports.SnapshotDigest{2}, Size: minObjectEnvelopeSize} |
| 135 | for _, tc := range []struct { | 139 | for _, tc := range []struct { | ||
| 136 | name string | 140 | name string | ||
| 137 | cols, rows uint16 | 141 | cols, rows uint16 | ||
| @@ -142,7 +146,7 @@ func TestManifestDimensionBoundaries(t *testing.T) { | |||||
| 142 | {"hostile product", math.MaxUint16, math.MaxUint16, true}, | 146 | {"hostile product", math.MaxUint16, math.MaxUint16, true}, | ||
| 143 | } { | 147 | } { | ||
| 144 | t.Run(tc.name, func(t *testing.T) { | 148 | t.Run(tc.name, func(t *testing.T) { | ||
| 145 | - | m := Manifest{Generation: 1, IncarnationID: domain.IncarnationID{1}, Name: "safe", Tabs: []ManifestTab{{Cols: tc.cols, Rows: tc.rows, Panes: []ManifestPane{{ID: "p", Tail: ref, Visible: visible}}}}} | 149 | + | m := Manifest{Generation: 1, IncarnationID: domain.IncarnationID{1}, Name: "safe", Tabs: []ManifestTab{{Cols: tc.cols, Rows: tc.rows, Panes: []ManifestPane{{ID: "p", Tail: ref, Transcript: transcript}}}}} |
| 146 | _, err := MarshalManifest(m) | 150 | _, err := MarshalManifest(m) | ||
| 147 | if (err != nil) != tc.wantErr { | 151 | if (err != nil) != tc.wantErr { | ||
| 148 | t.Fatalf("MarshalManifest() error = %v, want error=%v", err, tc.wantErr) | 152 | t.Fatalf("MarshalManifest() error = %v, want error=%v", err, tc.wantErr) | ||
| @@ -153,9 +157,9 @@ func TestManifestDimensionBoundaries(t *testing.T) { | |||||
| 153 | 157 | ||||
| 154 | func TestManifestRejectsImpossibleObjectEnvelopeSize(t *testing.T) { | 158 | func TestManifestRejectsImpossibleObjectEnvelopeSize(t *testing.T) { | ||
| 155 | manifest := Manifest{Generation: 1, IncarnationID: domain.IncarnationID{1}, Name: "named", Tabs: []ManifestTab{{Cols: 1, Rows: 1, Panes: []ManifestPane{{ | 159 | manifest := Manifest{Generation: 1, IncarnationID: domain.IncarnationID{1}, Name: "named", Tabs: []ManifestTab{{Cols: 1, Rows: 1, Panes: []ManifestPane{{ | ||
| 156 | - | ID: "pane", | 160 | + | ID: "pane", |
| 157 | - | Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{1}, Size: 1}, | 161 | + | Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{1}, Size: 1}, |
| 158 | - | Visible: ObjectRef{Kind: Visible, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize}, | 162 | + | Transcript: ObjectRef{Kind: RecoveryTranscript, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize}, |
| 159 | }}}}} | 163 | }}}}} | ||
| 160 | if _, err := MarshalManifest(manifest); !errors.Is(err, ErrInvalidData) { | 164 | if _, err := MarshalManifest(manifest); !errors.Is(err, ErrInvalidData) { | ||
| 161 | t.Fatalf("MarshalManifest() error = %v, want invalid data", err) | 165 | t.Fatalf("MarshalManifest() error = %v, want invalid data", err) | ||
| @@ -220,9 +224,9 @@ func manifestWithTailSize(size uint32) Manifest { | |||||
| 220 | Cols: 1, | 224 | Cols: 1, | ||
| 221 | Rows: 1, | 225 | Rows: 1, | ||
| 222 | Panes: []ManifestPane{{ | 226 | Panes: []ManifestPane{{ | ||
| 223 | - | ID: "pane", | 227 | + | ID: "pane", |
| 224 | - | Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{1}, Size: size}, | 228 | + | Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{1}, Size: size}, |
| 225 | - | Visible: ObjectRef{Kind: Visible, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize}, | 229 | + | Transcript: ObjectRef{Kind: RecoveryTranscript, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize}, |
| 226 | }}, | 230 | }}, | ||
| 227 | }}} | 231 | }}} | ||
| 228 | } | 232 | } | ||
M internal/usecase/snapshot/preflight.go +8 -13
| @@ -202,7 +202,7 @@ func preflightPaneStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uin | |||
| 202 | 202 | return fmt.Errorf("%w: sealed chunks", ErrInvalidData) | |
| 203 | 203 | } | |
| 204 | 204 | for range n { | |
| 205 | - | stats, err := preflightBlob(r, totals, blobs, true, budget) | |
| 205 | + | stats, err := preflightBlob(r, totals, blobs, budget) | |
| 206 | 206 | if err != nil { | |
| 207 | 207 | return err | |
| 208 | 208 | } | |
| @@ -210,7 +210,7 @@ func preflightPaneStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uin | |||
| 210 | 210 | return fmt.Errorf("%w: sealed blob role", ErrInvalidData) | |
| 211 | 211 | } | |
| 212 | 212 | } | |
| 213 | - | tail, err := preflightBlob(r, totals, blobs, true, budget) | |
| 213 | + | tail, err := preflightBlob(r, totals, blobs, budget) | |
| 214 | 214 | if err != nil { | |
| 215 | 215 | return err | |
| 216 | 216 | } | |
| @@ -220,17 +220,17 @@ func preflightPaneStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uin | |||
| 220 | 220 | if tail.Chunks > 1 { | |
| 221 | 221 | return fmt.Errorf("%w: tail blob role", ErrInvalidData) | |
| 222 | 222 | } | |
| 223 | - | visible, err := preflightBlob(r, totals, blobs, false, budget) | |
| 223 | + | transcript, err := preflightBlob(r, totals, blobs, budget) | |
| 224 | 224 | if err != nil { | |
| 225 | 225 | return err | |
| 226 | 226 | } | |
| 227 | - | if visible.Rows == 0 || visible.Cells == 0 { | |
| 228 | - | return fmt.Errorf("%w: visible geometry", ErrInvalidData) | |
| 229 | - | } | |
| 227 | + | // Transcripts are canonical history; they may contain multiple chunks or be | |
| 228 | + | // empty, so no blob-role assertion applies. | |
| 229 | + | _ = transcript | |
| 230 | 230 | return preflightProcess(r) | |
| 231 | 231 | } | |
| 232 | 232 | ||
| 233 | - | func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, history bool, budget *preflightBudget) (vt.DecodeStats, error) { | |
| 233 | + | func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, budget *preflightBudget) (vt.DecodeStats, error) { | |
| 234 | 234 | n, err := r.getUint32() | |
| 235 | 235 | if err != nil { | |
| 236 | 236 | return vt.DecodeStats{}, err | |
| @@ -243,12 +243,7 @@ func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, hist | |||
| 243 | 243 | } | |
| 244 | 244 | blob := r.b[:n] | |
| 245 | 245 | r.b = r.b[n:] | |
| 246 | - | var stats vt.DecodeStats | |
| 247 | - | if history { | |
| 248 | - | stats, err = vt.PreflightHistoryBlob(blob) | |
| 249 | - | } else { | |
| 250 | - | stats, err = vt.PreflightVisibleBlob(blob) | |
| 251 | - | } | |
| 246 | + | stats, err := vt.PreflightHistoryBlob(blob) | |
| 252 | 247 | if err != nil { | |
| 253 | 248 | return vt.DecodeStats{}, fmt.Errorf("%w: VT blob", ErrInvalidData) | |
| 254 | 249 | } | |
| @@ -202,7 +202,7 @@ func preflightPaneStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uin | |||||
| 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 { | ||
| 205 | - | stats, err := preflightBlob(r, totals, blobs, true, budget) | 205 | + | stats, err := preflightBlob(r, totals, blobs, budget) |
| 206 | if err != nil { | 206 | if err != nil { | ||
| 207 | return err | 207 | return err | ||
| 208 | } | 208 | } | ||
| @@ -210,7 +210,7 @@ func preflightPaneStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uin | |||||
| 210 | return fmt.Errorf("%w: sealed blob role", ErrInvalidData) | 210 | return fmt.Errorf("%w: sealed blob role", ErrInvalidData) | ||
| 211 | } | 211 | } | ||
| 212 | } | 212 | } | ||
| 213 | - | tail, err := preflightBlob(r, totals, blobs, true, budget) | 213 | + | tail, err := preflightBlob(r, totals, blobs, budget) |
| 214 | if err != nil { | 214 | if err != nil { | ||
| 215 | return err | 215 | return err | ||
| 216 | } | 216 | } | ||
| @@ -220,17 +220,17 @@ func preflightPaneStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uin | |||||
| 220 | if tail.Chunks > 1 { | 220 | if tail.Chunks > 1 { | ||
| 221 | return fmt.Errorf("%w: tail blob role", ErrInvalidData) | 221 | return fmt.Errorf("%w: tail blob role", ErrInvalidData) | ||
| 222 | } | 222 | } | ||
| 223 | - | visible, err := preflightBlob(r, totals, blobs, false, budget) | 223 | + | transcript, err := preflightBlob(r, totals, blobs, budget) |
| 224 | if err != nil { | 224 | if err != nil { | ||
| 225 | return err | 225 | return err | ||
| 226 | } | 226 | } | ||
| 227 | - | if visible.Rows == 0 || visible.Cells == 0 { | 227 | + | // Transcripts are canonical history; they may contain multiple chunks or be |
| 228 | - | return fmt.Errorf("%w: visible geometry", ErrInvalidData) | 228 | + | // empty, so no blob-role assertion applies. |
| 229 | - | } | 229 | + | _ = transcript |
| 230 | return preflightProcess(r) | 230 | return preflightProcess(r) | ||
| 231 | } | 231 | } | ||
| 232 | 232 | ||||
| 233 | - | func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, history bool, budget *preflightBudget) (vt.DecodeStats, error) { | 233 | + | func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, budget *preflightBudget) (vt.DecodeStats, error) { |
| 234 | n, err := r.getUint32() | 234 | n, err := r.getUint32() | ||
| 235 | if err != nil { | 235 | if err != nil { | ||
| 236 | return vt.DecodeStats{}, err | 236 | return vt.DecodeStats{}, err | ||
| @@ -243,12 +243,7 @@ func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, hist | |||||
| 243 | } | 243 | } | ||
| 244 | blob := r.b[:n] | 244 | blob := r.b[:n] | ||
| 245 | r.b = r.b[n:] | 245 | r.b = r.b[n:] | ||
| 246 | - | var stats vt.DecodeStats | 246 | + | stats, err := vt.PreflightHistoryBlob(blob) |
| 247 | - | if history { | |||
| 248 | - | stats, err = vt.PreflightHistoryBlob(blob) | |||
| 249 | - | } else { | |||
| 250 | - | stats, err = vt.PreflightVisibleBlob(blob) | |||
| 251 | - | } | |||
| 252 | if err != nil { | 247 | if err != nil { | ||
| 253 | return vt.DecodeStats{}, fmt.Errorf("%w: VT blob", ErrInvalidData) | 248 | return vt.DecodeStats{}, fmt.Errorf("%w: VT blob", ErrInvalidData) | ||
| 254 | } | 249 | } | ||
M internal/usecase/snapshot/preflight_acceptance_test.go +3 -3
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T) { | |
| 12 | 12 | sealed, tail := historyBlobs(t, nil) | |
| 13 | - | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |
| 13 | + | transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |
| 14 | 14 | for _, tt := range []struct { | |
| 15 | 15 | name string | |
| 16 | 16 | tab Tab | |
| @@ -20,7 +20,7 @@ func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T | |||
| 20 | 20 | tab: Tab{ | |
| 21 | 21 | Focus: "pane", | |
| 22 | 22 | Tree: layout.NewTree("missing"), | |
| 23 | - | Panes: []Pane{{ID: "pane", SealedChunks: sealed, Tail: tail, Visible: visible}}, | |
| 23 | + | Panes: []Pane{{ID: "pane", SealedChunks: sealed, Tail: tail, Transcript: transcript}}, | |
| 24 | 24 | }, | |
| 25 | 25 | }, | |
| 26 | 26 | { | |
| @@ -28,7 +28,7 @@ func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T | |||
| 28 | 28 | tab: Tab{ | |
| 29 | 29 | Focus: "missing", | |
| 30 | 30 | Tree: layout.NewTree("pane"), | |
| 31 | - | Panes: []Pane{{ID: "pane", SealedChunks: sealed, Tail: tail, Visible: visible}}, | |
| 31 | + | Panes: []Pane{{ID: "pane", SealedChunks: sealed, Tail: tail, Transcript: transcript}}, | |
| 32 | 32 | }, | |
| 33 | 33 | }, | |
| 34 | 34 | } { | |
| @@ -10,7 +10,7 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T) { | 11 | func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T) { | ||
| 12 | sealed, tail := historyBlobs(t, nil) | 12 | sealed, tail := historyBlobs(t, nil) | ||
| 13 | - | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | 13 | + | transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) |
| 14 | for _, tt := range []struct { | 14 | for _, tt := range []struct { | ||
| 15 | name string | 15 | name string | ||
| 16 | tab Tab | 16 | tab Tab | ||
| @@ -20,7 +20,7 @@ func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T | |||||
| 20 | tab: Tab{ | 20 | tab: Tab{ | ||
| 21 | Focus: "pane", | 21 | Focus: "pane", | ||
| 22 | Tree: layout.NewTree("missing"), | 22 | Tree: layout.NewTree("missing"), | ||
| 23 | - | Panes: []Pane{{ID: "pane", SealedChunks: sealed, Tail: tail, Visible: visible}}, | 23 | + | Panes: []Pane{{ID: "pane", SealedChunks: sealed, Tail: tail, Transcript: transcript}}, |
| 24 | }, | 24 | }, | ||
| 25 | }, | 25 | }, | ||
| 26 | { | 26 | { | ||
| @@ -28,7 +28,7 @@ func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T | |||||
| 28 | tab: Tab{ | 28 | tab: Tab{ | ||
| 29 | Focus: "missing", | 29 | Focus: "missing", | ||
| 30 | Tree: layout.NewTree("pane"), | 30 | Tree: layout.NewTree("pane"), | ||
| 31 | - | Panes: []Pane{{ID: "pane", SealedChunks: sealed, Tail: tail, Visible: visible}}, | 31 | + | Panes: []Pane{{ID: "pane", SealedChunks: sealed, Tail: tail, Transcript: transcript}}, |
| 32 | }, | 32 | }, | ||
| 33 | }, | 33 | }, | ||
| 34 | } { | 34 | } { | ||
M internal/usecase/snapshot/snapshot.go +3 -3
| @@ -27,9 +27,9 @@ type Pane struct { | |||
| 27 | 27 | ID layout.PaneID | |
| 28 | 28 | StableID string | |
| 29 | 29 | Cwd string | |
| 30 | - | SealedChunks [][]byte // oldest-first, each a self-contained VT blob | |
| 31 | - | Tail []byte // mandatory nonempty canonical VT blob | |
| 32 | - | Visible []byte // mandatory nonempty canonical VT blob | |
| 30 | + | SealedChunks [][]byte // oldest-first, each a self-contained canonical history blob | |
| 31 | + | Tail []byte // mandatory nonempty canonical history blob | |
| 32 | + | Transcript []byte // mandatory nonempty canonical history blob | |
| 33 | 33 | Process *Process | |
| 34 | 34 | } | |
| 35 | 35 | ||
| @@ -27,9 +27,9 @@ type Pane struct { | |||||
| 27 | ID layout.PaneID | 27 | ID layout.PaneID | ||
| 28 | StableID string | 28 | StableID string | ||
| 29 | Cwd string | 29 | Cwd string | ||
| 30 | - | SealedChunks [][]byte // oldest-first, each a self-contained VT blob | 30 | + | SealedChunks [][]byte // oldest-first, each a self-contained canonical history blob |
| 31 | - | Tail []byte // mandatory nonempty canonical VT blob | 31 | + | Tail []byte // mandatory nonempty canonical history blob |
| 32 | - | Visible []byte // mandatory nonempty canonical VT blob | 32 | + | Transcript []byte // mandatory nonempty canonical history blob |
| 33 | Process *Process | 33 | Process *Process | ||
| 34 | } | 34 | } | ||
| 35 | 35 | ||||
M internal/usecase/snapshot/weights_test.go +4 -4
| @@ -86,17 +86,17 @@ func TestSnapshotEnvelopeCompatibility(t *testing.T) { | |||
| 86 | 86 | const ( | |
| 87 | 87 | headGolden = "56455648000100000000002898ebcac200000000000000070100000000000000000000000000000000000000000000000000000000000000" | |
| 88 | 88 | objectGolden = "5645564f00010000000000111cb8f09f030000000c63616e6f6e6963616c2d7674" | |
| 89 | - | manifestGolden = "5645564d0002000000000133d118cc030000000000000001010000000000000000000000000000000000056e616d6564000000000000000000000001000374616200500018000000000000000000036f6e65010000020000036f6e6500000374776f000200036f6e6500036f6e6500042f6f6e65000101010000000000000000000000000000000000000000000000000000000000000000000016020200000000000000000000000000000000000000000000000000000000000000000000160303000000000000000000000000000000000000000000000000000000000000000000001600000374776f000374776f00042f74776f0000020400000000000000000000000000000000000000000000000000000000000000000000160305000000000000000000000000000000000000000000000000000000000000000000001600" | |
| 89 | + | manifestGolden = "5645564d0003000000000133d118cc030000000000000001010000000000000000000000000000000000056e616d6564000000000000000000000001000374616200500018000000000000000000036f6e65010000020000036f6e6500000374776f000200036f6e6500036f6e6500042f6f6e65000101010000000000000000000000000000000000000000000000000000000000000000000016020200000000000000000000000000000000000000000000000000000000000000000000160303000000000000000000000000000000000000000000000000000000000000000000001600000374776f000374776f00042f74776f0000020400000000000000000000000000000000000000000000000000000000000000000000160305000000000000000000000000000000000000000000000000000000000000000000001600" | |
| 90 | 90 | ) | |
| 91 | - | if ManifestVersion != 2 { | |
| 92 | - | t.Fatalf("ManifestVersion = %d, want 2", ManifestVersion) | |
| 91 | + | if ManifestVersion != 3 { | |
| 92 | + | t.Fatalf("ManifestVersion = %d, want 3", ManifestVersion) | |
| 93 | 93 | } | |
| 94 | 94 | ||
| 95 | 95 | head, err := MarshalHead(Head{Generation: 7, ManifestDigest: SnapshotDigest{1}}) | |
| 96 | 96 | if err != nil { | |
| 97 | 97 | t.Fatal(err) | |
| 98 | 98 | } | |
| 99 | - | object, err := MarshalObject(Visible, []byte("canonical-vt")) | |
| 99 | + | object, err := MarshalObject(RecoveryTranscript, []byte("canonical-vt")) | |
| 100 | 100 | if err != nil { | |
| 101 | 101 | t.Fatal(err) | |
| 102 | 102 | } | |
| @@ -86,17 +86,17 @@ func TestSnapshotEnvelopeCompatibility(t *testing.T) { | |||||
| 86 | const ( | 86 | const ( | ||
| 87 | headGolden = "56455648000100000000002898ebcac200000000000000070100000000000000000000000000000000000000000000000000000000000000" | 87 | headGolden = "56455648000100000000002898ebcac200000000000000070100000000000000000000000000000000000000000000000000000000000000" | ||
| 88 | objectGolden = "5645564f00010000000000111cb8f09f030000000c63616e6f6e6963616c2d7674" | 88 | objectGolden = "5645564f00010000000000111cb8f09f030000000c63616e6f6e6963616c2d7674" | ||
| 89 | - | manifestGolden = "5645564d0002000000000133d118cc030000000000000001010000000000000000000000000000000000056e616d6564000000000000000000000001000374616200500018000000000000000000036f6e65010000020000036f6e6500000374776f000200036f6e6500036f6e6500042f6f6e65000101010000000000000000000000000000000000000000000000000000000000000000000016020200000000000000000000000000000000000000000000000000000000000000000000160303000000000000000000000000000000000000000000000000000000000000000000001600000374776f000374776f00042f74776f0000020400000000000000000000000000000000000000000000000000000000000000000000160305000000000000000000000000000000000000000000000000000000000000000000001600" | 89 | + | manifestGolden = "5645564d0003000000000133d118cc030000000000000001010000000000000000000000000000000000056e616d6564000000000000000000000001000374616200500018000000000000000000036f6e65010000020000036f6e6500000374776f000200036f6e6500036f6e6500042f6f6e65000101010000000000000000000000000000000000000000000000000000000000000000000016020200000000000000000000000000000000000000000000000000000000000000000000160303000000000000000000000000000000000000000000000000000000000000000000001600000374776f000374776f00042f74776f0000020400000000000000000000000000000000000000000000000000000000000000000000160305000000000000000000000000000000000000000000000000000000000000000000001600" |
| 90 | ) | 90 | ) | ||
| 91 | - | if ManifestVersion != 2 { | 91 | + | if ManifestVersion != 3 { |
| 92 | - | t.Fatalf("ManifestVersion = %d, want 2", ManifestVersion) | 92 | + | t.Fatalf("ManifestVersion = %d, want 3", ManifestVersion) |
| 93 | } | 93 | } | ||
| 94 | 94 | ||||
| 95 | head, err := MarshalHead(Head{Generation: 7, ManifestDigest: SnapshotDigest{1}}) | 95 | head, err := MarshalHead(Head{Generation: 7, ManifestDigest: SnapshotDigest{1}}) | ||
| 96 | if err != nil { | 96 | if err != nil { | ||
| 97 | t.Fatal(err) | 97 | t.Fatal(err) | ||
| 98 | } | 98 | } | ||
| 99 | - | object, err := MarshalObject(Visible, []byte("canonical-vt")) | 99 | + | object, err := MarshalObject(RecoveryTranscript, []byte("canonical-vt")) |
| 100 | if err != nil { | 100 | if err != nil { | ||
| 101 | t.Fatal(err) | 101 | t.Fatal(err) | ||
| 102 | } | 102 | } | ||
M pkg/vt/history_acceptance_test.go +14 -16
| @@ -6,7 +6,7 @@ import ( | |||
| 6 | 6 | "github.com/bnema/vev/pkg/renderer" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | - | func TestSnapshotCodecsPreserveEveryTerminalCellAcrossSealedTailAndVisible(t *testing.T) { | |
| 9 | + | func TestSnapshotCodecsPreserveEveryTerminalCellAcrossHistoryAndRecoveryTranscript(t *testing.T) { | |
| 10 | 10 | indexed := renderer.Style{ | |
| 11 | 11 | Bold: true, | |
| 12 | 12 | Italic: true, | |
| @@ -79,26 +79,24 @@ func TestSnapshotCodecsPreserveEveryTerminalCellAcrossSealedTailAndVisible(t *te | |||
| 79 | 79 | }, | |
| 80 | 80 | }, | |
| 81 | 81 | { | |
| 82 | - | name: "visible frame retains blanks wide heads continuations and styles", | |
| 82 | + | name: "recovery transcript retains blanks wide heads continuations and styles", | |
| 83 | 83 | run: func(t *testing.T) { | |
| 84 | - | want := renderer.NewFrame(4, 2) | |
| 85 | - | copy(want.Row(0), makeRow('D', rgb)) | |
| 86 | - | copy(want.Row(1), makeRow('E', indexed)) | |
| 87 | - | encoded, err := MarshalVisible(want) | |
| 88 | - | if err != nil { | |
| 89 | - | t.Fatalf("MarshalVisible: %v", err) | |
| 84 | + | screen := NewScreen(4, 2) | |
| 85 | + | want := [][]renderer.Cell{makeRow('D', rgb), makeRow('E', indexed)} | |
| 86 | + | for y, row := range want { | |
| 87 | + | copy(screen.Frame.Row(y), row) | |
| 88 | + | screen.buffer.boundaries[y] = LineBound{End: len(row)} | |
| 90 | 89 | } | |
| 91 | - | got, err := UnmarshalVisible(encoded) | |
| 90 | + | ||
| 91 | + | encoded, err := screen.RecoveryTranscriptSnapshot().Marshal() | |
| 92 | 92 | if err != nil { | |
| 93 | - | t.Fatalf("UnmarshalVisible: %v", err) | |
| 93 | + | t.Fatalf("marshal recovery transcript: %v", err) | |
| 94 | 94 | } | |
| 95 | - | for y := range want.Height { | |
| 96 | - | for x, cell := range want.Row(y) { | |
| 97 | - | if !got.At(x, y).Equal(cell) { | |
| 98 | - | t.Fatalf("cell (%d,%d) = %#v, want %#v", x, y, got.At(x, y), cell) | |
| 99 | - | } | |
| 100 | - | } | |
| 95 | + | got, err := UnmarshalHistory(encoded) | |
| 96 | + | if err != nil { | |
| 97 | + | t.Fatalf("unmarshal recovery transcript: %v", err) | |
| 101 | 98 | } | |
| 99 | + | assertHistoryRowsEqual(t, got, want) | |
| 102 | 100 | }, | |
| 103 | 101 | }, | |
| 104 | 102 | } | |
| @@ -6,7 +6,7 @@ import ( | |||||
| 6 | "github.com/bnema/vev/pkg/renderer" | 6 | "github.com/bnema/vev/pkg/renderer" | ||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | - | func TestSnapshotCodecsPreserveEveryTerminalCellAcrossSealedTailAndVisible(t *testing.T) { | 9 | + | func TestSnapshotCodecsPreserveEveryTerminalCellAcrossHistoryAndRecoveryTranscript(t *testing.T) { |
| 10 | indexed := renderer.Style{ | 10 | indexed := renderer.Style{ | ||
| 11 | Bold: true, | 11 | Bold: true, | ||
| 12 | Italic: true, | 12 | Italic: true, | ||
| @@ -79,26 +79,24 @@ func TestSnapshotCodecsPreserveEveryTerminalCellAcrossSealedTailAndVisible(t *te | |||||
| 79 | }, | 79 | }, | ||
| 80 | }, | 80 | }, | ||
| 81 | { | 81 | { | ||
| 82 | - | name: "visible frame retains blanks wide heads continuations and styles", | 82 | + | name: "recovery transcript retains blanks wide heads continuations and styles", |
| 83 | run: func(t *testing.T) { | 83 | run: func(t *testing.T) { | ||
| 84 | - | want := renderer.NewFrame(4, 2) | 84 | + | screen := NewScreen(4, 2) |
| 85 | - | copy(want.Row(0), makeRow('D', rgb)) | 85 | + | want := [][]renderer.Cell{makeRow('D', rgb), makeRow('E', indexed)} |
| 86 | - | copy(want.Row(1), makeRow('E', indexed)) | 86 | + | for y, row := range want { |
| 87 | - | encoded, err := MarshalVisible(want) | 87 | + | copy(screen.Frame.Row(y), row) |
| 88 | - | if err != nil { | 88 | + | screen.buffer.boundaries[y] = LineBound{End: len(row)} |
| 89 | - | t.Fatalf("MarshalVisible: %v", err) | |||
| 90 | } | 89 | } | ||
| 91 | - | got, err := UnmarshalVisible(encoded) | 90 | + | |
| 91 | + | encoded, err := screen.RecoveryTranscriptSnapshot().Marshal() | |||
| 92 | if err != nil { | 92 | if err != nil { | ||
| 93 | - | t.Fatalf("UnmarshalVisible: %v", err) | 93 | + | t.Fatalf("marshal recovery transcript: %v", err) |
| 94 | } | 94 | } | ||
| 95 | - | for y := range want.Height { | 95 | + | got, err := UnmarshalHistory(encoded) |
| 96 | - | for x, cell := range want.Row(y) { | 96 | + | if err != nil { |
| 97 | - | if !got.At(x, y).Equal(cell) { | 97 | + | t.Fatalf("unmarshal recovery transcript: %v", err) |
| 98 | - | t.Fatalf("cell (%d,%d) = %#v, want %#v", x, y, got.At(x, y), cell) | |||
| 99 | - | } | |||
| 100 | - | } | |||
| 101 | } | 98 | } | ||
| 99 | + | assertHistoryRowsEqual(t, got, want) | |||
| 102 | }, | 100 | }, | ||
| 103 | }, | 101 | }, | ||
| 104 | } | 102 | } | ||
M pkg/vt/history_codec.go +6 -6
| @@ -14,6 +14,7 @@ const ( | |||
| 14 | 14 | historyMagic = "VTH1" | |
| 15 | 15 | historyVersion = 2 | |
| 16 | 16 | historyCellBytes = 41 | |
| 17 | + | historyBoundBytes = 5 | |
| 17 | 18 | maxHistoryChunkRows = 256 | |
| 18 | 19 | ||
| 19 | 20 | // The daemon retains 10,000 history rows. Support a 20% row margin and | |
| @@ -24,9 +25,8 @@ const ( | |||
| 24 | 25 | maxHistoryRowCells = 160 | |
| 25 | 26 | maxHistoryCells = maxHistoryRows * maxHistoryRowCells | |
| 26 | 27 | ||
| 27 | - | maxHistoryDecodeStyles = maxHistoryCells | |
| 28 | - | maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes | |
| 29 | - | maxVisibleBoundaryBytes = maxHistoryRows * visibleBoundaryBytes | |
| 28 | + | maxHistoryDecodeStyles = maxHistoryCells | |
| 29 | + | maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes | |
| 30 | 30 | ) | |
| 31 | 31 | ||
| 32 | 32 | var errInvalidHistory = errors.New("invalid history payload") | |
| @@ -206,7 +206,7 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||
| 206 | 206 | for range chunkCount { | |
| 207 | 207 | rowCount, ok := p.uint32() | |
| 208 | 208 | if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || | |
| 209 | - | uint64(rowCount) > uint64(len(p.data))/(4+visibleBoundaryBytes) || | |
| 209 | + | uint64(rowCount) > uint64(len(p.data))/(4+historyBoundBytes) || | |
| 210 | 210 | !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) { | |
| 211 | 211 | return HistoryView{}, historyDecodeStats{}, false | |
| 212 | 212 | } | |
| @@ -329,12 +329,12 @@ func (p *historyParser) cell() (renderer.Cell, bool) { | |||
| 329 | 329 | } | |
| 330 | 330 | ||
| 331 | 331 | func (p *historyParser) bound() (LineBound, bool) { | |
| 332 | - | if len(p.data) < visibleBoundaryBytes { | |
| 332 | + | if len(p.data) < historyBoundBytes { | |
| 333 | 333 | return LineBound{}, false | |
| 334 | 334 | } | |
| 335 | 335 | end := binary.BigEndian.Uint32(p.data[:4]) | |
| 336 | 336 | flag := p.data[4] | |
| 337 | - | p.data = p.data[visibleBoundaryBytes:] | |
| 337 | + | p.data = p.data[historyBoundBytes:] | |
| 338 | 338 | if flag > 1 || uint64(end) > math.MaxInt32 { | |
| 339 | 339 | return LineBound{}, false | |
| 340 | 340 | } | |
| @@ -14,6 +14,7 @@ const ( | |||||
| 14 | historyMagic = "VTH1" | 14 | historyMagic = "VTH1" | ||
| 15 | historyVersion = 2 | 15 | historyVersion = 2 | ||
| 16 | historyCellBytes = 41 | 16 | historyCellBytes = 41 | ||
| 17 | + | historyBoundBytes = 5 | |||
| 17 | maxHistoryChunkRows = 256 | 18 | maxHistoryChunkRows = 256 | ||
| 18 | 19 | ||||
| 19 | // The daemon retains 10,000 history rows. Support a 20% row margin and | 20 | // The daemon retains 10,000 history rows. Support a 20% row margin and | ||
| @@ -24,9 +25,8 @@ const ( | |||||
| 24 | maxHistoryRowCells = 160 | 25 | maxHistoryRowCells = 160 | ||
| 25 | maxHistoryCells = maxHistoryRows * maxHistoryRowCells | 26 | maxHistoryCells = maxHistoryRows * maxHistoryRowCells | ||
| 26 | 27 | ||||
| 27 | - | maxHistoryDecodeStyles = maxHistoryCells | 28 | + | maxHistoryDecodeStyles = maxHistoryCells |
| 28 | - | maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes | 29 | + | maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes |
| 29 | - | maxVisibleBoundaryBytes = maxHistoryRows * visibleBoundaryBytes | |||
| 30 | ) | 30 | ) | ||
| 31 | 31 | ||||
| 32 | var errInvalidHistory = errors.New("invalid history payload") | 32 | var errInvalidHistory = errors.New("invalid history payload") | ||
| @@ -206,7 +206,7 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||||
| 206 | for range chunkCount { | 206 | for range chunkCount { | ||
| 207 | rowCount, ok := p.uint32() | 207 | rowCount, ok := p.uint32() | ||
| 208 | if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || | 208 | if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || | ||
| 209 | - | uint64(rowCount) > uint64(len(p.data))/(4+visibleBoundaryBytes) || | 209 | + | uint64(rowCount) > uint64(len(p.data))/(4+historyBoundBytes) || |
| 210 | !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) { | 210 | !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) { | ||
| 211 | return HistoryView{}, historyDecodeStats{}, false | 211 | return HistoryView{}, historyDecodeStats{}, false | ||
| 212 | } | 212 | } | ||
| @@ -329,12 +329,12 @@ func (p *historyParser) cell() (renderer.Cell, bool) { | |||||
| 329 | } | 329 | } | ||
| 330 | 330 | ||||
| 331 | func (p *historyParser) bound() (LineBound, bool) { | 331 | func (p *historyParser) bound() (LineBound, bool) { | ||
| 332 | - | if len(p.data) < visibleBoundaryBytes { | 332 | + | if len(p.data) < historyBoundBytes { |
| 333 | return LineBound{}, false | 333 | return LineBound{}, false | ||
| 334 | } | 334 | } | ||
| 335 | end := binary.BigEndian.Uint32(p.data[:4]) | 335 | end := binary.BigEndian.Uint32(p.data[:4]) | ||
| 336 | flag := p.data[4] | 336 | flag := p.data[4] | ||
| 337 | - | p.data = p.data[visibleBoundaryBytes:] | 337 | + | p.data = p.data[historyBoundBytes:] |
| 338 | if flag > 1 || uint64(end) > math.MaxInt32 { | 338 | if flag > 1 || uint64(end) > math.MaxInt32 { | ||
| 339 | return LineBound{}, false | 339 | return LineBound{}, false | ||
| 340 | } | 340 | } | ||
A pkg/vt/history_codec_bound_test.go +49 -0
| @@ -0,0 +1,49 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/binary" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestHistoryCodecRejectsMalformedBounds(t *testing.T) { | |
| 12 | + | blob, err := MarshalHistory(HistoryView{ | |
| 13 | + | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("abcd")}, bounds: []LineBound{{End: 4, Soft: true}}}}, | |
| 14 | + | rows: 1, | |
| 15 | + | cells: 4, | |
| 16 | + | }) | |
| 17 | + | require.NoError(t, err) | |
| 18 | + | boundOffset := len(blob) - historyBoundBytes | |
| 19 | + | ||
| 20 | + | tests := []struct { | |
| 21 | + | name string | |
| 22 | + | mutate func([]byte) | |
| 23 | + | }{ | |
| 24 | + | { | |
| 25 | + | name: "end exceeds row width", | |
| 26 | + | mutate: func(data []byte) { | |
| 27 | + | binary.BigEndian.PutUint32(data[boundOffset:boundOffset+4], 5) | |
| 28 | + | }, | |
| 29 | + | }, | |
| 30 | + | { | |
| 31 | + | name: "soft flag is not canonical", | |
| 32 | + | mutate: func(data []byte) { | |
| 33 | + | data[boundOffset+4] = 2 | |
| 34 | + | }, | |
| 35 | + | }, | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | for _, tt := range tests { | |
| 39 | + | t.Run(tt.name, func(t *testing.T) { | |
| 40 | + | data := append([]byte(nil), blob...) | |
| 41 | + | tt.mutate(data) | |
| 42 | + | ||
| 43 | + | _, preflightErr := PreflightHistoryBlob(data) | |
| 44 | + | require.Error(t, preflightErr) | |
| 45 | + | _, unmarshalErr := UnmarshalHistory(data) | |
| 46 | + | require.Error(t, unmarshalErr) | |
| 47 | + | }) | |
| 48 | + | } | |
| 49 | + | } | |
| @@ -0,0 +1,49 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/binary" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestHistoryCodecRejectsMalformedBounds(t *testing.T) { | |||
| 12 | + | blob, err := MarshalHistory(HistoryView{ | |||
| 13 | + | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("abcd")}, bounds: []LineBound{{End: 4, Soft: true}}}}, | |||
| 14 | + | rows: 1, | |||
| 15 | + | cells: 4, | |||
| 16 | + | }) | |||
| 17 | + | require.NoError(t, err) | |||
| 18 | + | boundOffset := len(blob) - historyBoundBytes | |||
| 19 | + | ||||
| 20 | + | tests := []struct { | |||
| 21 | + | name string | |||
| 22 | + | mutate func([]byte) | |||
| 23 | + | }{ | |||
| 24 | + | { | |||
| 25 | + | name: "end exceeds row width", | |||
| 26 | + | mutate: func(data []byte) { | |||
| 27 | + | binary.BigEndian.PutUint32(data[boundOffset:boundOffset+4], 5) | |||
| 28 | + | }, | |||
| 29 | + | }, | |||
| 30 | + | { | |||
| 31 | + | name: "soft flag is not canonical", | |||
| 32 | + | mutate: func(data []byte) { | |||
| 33 | + | data[boundOffset+4] = 2 | |||
| 34 | + | }, | |||
| 35 | + | }, | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | for _, tt := range tests { | |||
| 39 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 40 | + | data := append([]byte(nil), blob...) | |||
| 41 | + | tt.mutate(data) | |||
| 42 | + | ||||
| 43 | + | _, preflightErr := PreflightHistoryBlob(data) | |||
| 44 | + | require.Error(t, preflightErr) | |||
| 45 | + | _, unmarshalErr := UnmarshalHistory(data) | |||
| 46 | + | require.Error(t, unmarshalErr) | |||
| 47 | + | }) | |||
| 48 | + | } | |||
| 49 | + | } | |||
M pkg/vt/history_codec_test.go +1 -1
| @@ -82,7 +82,7 @@ func TestHistoryCodecRejectsMalformedAndV1Payloads(t *testing.T) { | |||
| 82 | 82 | ||
| 83 | 83 | // This is a genuine v1-layout payload: version 1 and no trailing bound | |
| 84 | 84 | // bytes. It is not a v2 payload with only its version byte changed. | |
| 85 | - | v1 := append([]byte(nil), v2[:len(v2)-visibleBoundaryBytes]...) | |
| 85 | + | v1 := append([]byte(nil), v2[:len(v2)-historyBoundBytes]...) | |
| 86 | 86 | v1[4] = 1 | |
| 87 | 87 | if _, err := UnmarshalHistory(v1); err == nil { | |
| 88 | 88 | t.Fatal("accepted a genuine v1 history payload") | |
| @@ -82,7 +82,7 @@ func TestHistoryCodecRejectsMalformedAndV1Payloads(t *testing.T) { | |||||
| 82 | 82 | ||||
| 83 | // This is a genuine v1-layout payload: version 1 and no trailing bound | 83 | // This is a genuine v1-layout payload: version 1 and no trailing bound | ||
| 84 | // bytes. It is not a v2 payload with only its version byte changed. | 84 | // bytes. It is not a v2 payload with only its version byte changed. | ||
| 85 | - | v1 := append([]byte(nil), v2[:len(v2)-visibleBoundaryBytes]...) | 85 | + | v1 := append([]byte(nil), v2[:len(v2)-historyBoundBytes]...) |
| 86 | v1[4] = 1 | 86 | v1[4] = 1 | ||
| 87 | if _, err := UnmarshalHistory(v1); err == nil { | 87 | if _, err := UnmarshalHistory(v1); err == nil { | ||
| 88 | t.Fatal("accepted a genuine v1 history payload") | 88 | t.Fatal("accepted a genuine v1 history payload") | ||
A pkg/vt/recovery_transcript.go +116 -0
| @@ -0,0 +1,116 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/pkg/renderer" | |
| 4 | + | ||
| 5 | + | // RecoveryTranscriptSnapshot is an owned, immutable capture of the viewport | |
| 6 | + | // rows that should be replayed after retained terminal history during recovery. | |
| 7 | + | type RecoveryTranscriptSnapshot struct { | |
| 8 | + | segments []recoveryTranscriptSegment | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | type recoveryTranscriptSegment struct { | |
| 12 | + | rows [][]renderer.Cell | |
| 13 | + | bounds []LineBound | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // RecoveryTranscriptSnapshot captures the active primary viewport, or the | |
| 17 | + | // saved primary viewport followed by the active alternate viewport. | |
| 18 | + | func (s *Screen) RecoveryTranscriptSnapshot() RecoveryTranscriptSnapshot { | |
| 19 | + | if s == nil { | |
| 20 | + | return RecoveryTranscriptSnapshot{} | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | buffers := []*buffer{s.buffer} | |
| 24 | + | if s.alternate != nil { | |
| 25 | + | buffers = []*buffer{s.alternate.buffer, s.buffer} | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | snapshot := RecoveryTranscriptSnapshot{segments: make([]recoveryTranscriptSegment, 0, len(buffers))} | |
| 29 | + | for _, b := range buffers { | |
| 30 | + | segment := captureRecoveryTranscriptSegment(b) | |
| 31 | + | if len(segment.rows) > 0 { | |
| 32 | + | snapshot.segments = append(snapshot.segments, segment) | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | return snapshot | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func captureRecoveryTranscriptSegment(b *buffer) recoveryTranscriptSegment { | |
| 39 | + | if b == nil { | |
| 40 | + | return recoveryTranscriptSegment{} | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | rowCount := b.frame.Height | |
| 44 | + | for rowCount > 0 && recoveryTranscriptRowUntouched(b.frame.Row(rowCount-1), b.bound(rowCount-1)) { | |
| 45 | + | rowCount-- | |
| 46 | + | } | |
| 47 | + | if rowCount == 0 { | |
| 48 | + | return recoveryTranscriptSegment{} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | segment := recoveryTranscriptSegment{ | |
| 52 | + | rows: make([][]renderer.Cell, rowCount), | |
| 53 | + | bounds: append([]LineBound(nil), b.boundaries[:rowCount]...), | |
| 54 | + | } | |
| 55 | + | cells := make([]renderer.Cell, rowCount*b.frame.Width) | |
| 56 | + | for y := range rowCount { | |
| 57 | + | start := y * b.frame.Width | |
| 58 | + | end := start + b.frame.Width | |
| 59 | + | segment.rows[y] = cells[start:end:end] | |
| 60 | + | copy(segment.rows[y], b.frame.Row(y)) | |
| 61 | + | } | |
| 62 | + | segment.bounds[rowCount-1].Soft = false | |
| 63 | + | return segment | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func recoveryTranscriptRowUntouched(row []renderer.Cell, bound LineBound) bool { | |
| 67 | + | if bound.End != 0 || bound.Soft { | |
| 68 | + | return false | |
| 69 | + | } | |
| 70 | + | blank := renderer.BlankCell() | |
| 71 | + | for _, cell := range row { | |
| 72 | + | if !cell.Equal(blank) { | |
| 73 | + | return false | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | return true | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // Marshal encodes the captured rows as canonical terminal history without | |
| 80 | + | // consulting or mutating live Screen history. | |
| 81 | + | func (snapshot RecoveryTranscriptSnapshot) Marshal() ([]byte, error) { | |
| 82 | + | rows := 0 | |
| 83 | + | cells := 0 | |
| 84 | + | for _, segment := range snapshot.segments { | |
| 85 | + | rows += len(segment.rows) | |
| 86 | + | for _, row := range segment.rows { | |
| 87 | + | cells += len(row) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | view := HistoryView{rows: rows, cells: cells} | |
| 92 | + | for _, segment := range snapshot.segments { | |
| 93 | + | for start := 0; start < len(segment.rows); { | |
| 94 | + | space := maxHistoryChunkRows | |
| 95 | + | if len(view.chunks) > 0 { | |
| 96 | + | last := view.chunks[len(view.chunks)-1] | |
| 97 | + | if len(last.rows) < maxHistoryChunkRows { | |
| 98 | + | space = maxHistoryChunkRows - len(last.rows) | |
| 99 | + | end := min(start+space, len(segment.rows)) | |
| 100 | + | last.rows = append(last.rows, segment.rows[start:end]...) | |
| 101 | + | last.bounds = append(last.bounds, segment.bounds[start:end]...) | |
| 102 | + | start = end | |
| 103 | + | continue | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | end := min(start+space, len(segment.rows)) | |
| 108 | + | view.chunks = append(view.chunks, &HistoryChunk{ | |
| 109 | + | rows: append([][]renderer.Cell(nil), segment.rows[start:end]...), | |
| 110 | + | bounds: append([]LineBound(nil), segment.bounds[start:end]...), | |
| 111 | + | }) | |
| 112 | + | start = end | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | return MarshalHistory(view) | |
| 116 | + | } | |
| @@ -0,0 +1,116 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/pkg/renderer" | |||
| 4 | + | ||||
| 5 | + | // RecoveryTranscriptSnapshot is an owned, immutable capture of the viewport | |||
| 6 | + | // rows that should be replayed after retained terminal history during recovery. | |||
| 7 | + | type RecoveryTranscriptSnapshot struct { | |||
| 8 | + | segments []recoveryTranscriptSegment | |||
| 9 | + | } | |||
| 10 | + | ||||
| 11 | + | type recoveryTranscriptSegment struct { | |||
| 12 | + | rows [][]renderer.Cell | |||
| 13 | + | bounds []LineBound | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | // RecoveryTranscriptSnapshot captures the active primary viewport, or the | |||
| 17 | + | // saved primary viewport followed by the active alternate viewport. | |||
| 18 | + | func (s *Screen) RecoveryTranscriptSnapshot() RecoveryTranscriptSnapshot { | |||
| 19 | + | if s == nil { | |||
| 20 | + | return RecoveryTranscriptSnapshot{} | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | buffers := []*buffer{s.buffer} | |||
| 24 | + | if s.alternate != nil { | |||
| 25 | + | buffers = []*buffer{s.alternate.buffer, s.buffer} | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | snapshot := RecoveryTranscriptSnapshot{segments: make([]recoveryTranscriptSegment, 0, len(buffers))} | |||
| 29 | + | for _, b := range buffers { | |||
| 30 | + | segment := captureRecoveryTranscriptSegment(b) | |||
| 31 | + | if len(segment.rows) > 0 { | |||
| 32 | + | snapshot.segments = append(snapshot.segments, segment) | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | return snapshot | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func captureRecoveryTranscriptSegment(b *buffer) recoveryTranscriptSegment { | |||
| 39 | + | if b == nil { | |||
| 40 | + | return recoveryTranscriptSegment{} | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | rowCount := b.frame.Height | |||
| 44 | + | for rowCount > 0 && recoveryTranscriptRowUntouched(b.frame.Row(rowCount-1), b.bound(rowCount-1)) { | |||
| 45 | + | rowCount-- | |||
| 46 | + | } | |||
| 47 | + | if rowCount == 0 { | |||
| 48 | + | return recoveryTranscriptSegment{} | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | segment := recoveryTranscriptSegment{ | |||
| 52 | + | rows: make([][]renderer.Cell, rowCount), | |||
| 53 | + | bounds: append([]LineBound(nil), b.boundaries[:rowCount]...), | |||
| 54 | + | } | |||
| 55 | + | cells := make([]renderer.Cell, rowCount*b.frame.Width) | |||
| 56 | + | for y := range rowCount { | |||
| 57 | + | start := y * b.frame.Width | |||
| 58 | + | end := start + b.frame.Width | |||
| 59 | + | segment.rows[y] = cells[start:end:end] | |||
| 60 | + | copy(segment.rows[y], b.frame.Row(y)) | |||
| 61 | + | } | |||
| 62 | + | segment.bounds[rowCount-1].Soft = false | |||
| 63 | + | return segment | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func recoveryTranscriptRowUntouched(row []renderer.Cell, bound LineBound) bool { | |||
| 67 | + | if bound.End != 0 || bound.Soft { | |||
| 68 | + | return false | |||
| 69 | + | } | |||
| 70 | + | blank := renderer.BlankCell() | |||
| 71 | + | for _, cell := range row { | |||
| 72 | + | if !cell.Equal(blank) { | |||
| 73 | + | return false | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | return true | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // Marshal encodes the captured rows as canonical terminal history without | |||
| 80 | + | // consulting or mutating live Screen history. | |||
| 81 | + | func (snapshot RecoveryTranscriptSnapshot) Marshal() ([]byte, error) { | |||
| 82 | + | rows := 0 | |||
| 83 | + | cells := 0 | |||
| 84 | + | for _, segment := range snapshot.segments { | |||
| 85 | + | rows += len(segment.rows) | |||
| 86 | + | for _, row := range segment.rows { | |||
| 87 | + | cells += len(row) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | view := HistoryView{rows: rows, cells: cells} | |||
| 92 | + | for _, segment := range snapshot.segments { | |||
| 93 | + | for start := 0; start < len(segment.rows); { | |||
| 94 | + | space := maxHistoryChunkRows | |||
| 95 | + | if len(view.chunks) > 0 { | |||
| 96 | + | last := view.chunks[len(view.chunks)-1] | |||
| 97 | + | if len(last.rows) < maxHistoryChunkRows { | |||
| 98 | + | space = maxHistoryChunkRows - len(last.rows) | |||
| 99 | + | end := min(start+space, len(segment.rows)) | |||
| 100 | + | last.rows = append(last.rows, segment.rows[start:end]...) | |||
| 101 | + | last.bounds = append(last.bounds, segment.bounds[start:end]...) | |||
| 102 | + | start = end | |||
| 103 | + | continue | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | end := min(start+space, len(segment.rows)) | |||
| 108 | + | view.chunks = append(view.chunks, &HistoryChunk{ | |||
| 109 | + | rows: append([][]renderer.Cell(nil), segment.rows[start:end]...), | |||
| 110 | + | bounds: append([]LineBound(nil), segment.bounds[start:end]...), | |||
| 111 | + | }) | |||
| 112 | + | start = end | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | return MarshalHistory(view) | |||
| 116 | + | } | |||
R pkg/vt/primary_visible_snapshot_test.go → pkg/vt/recovery_transcript_ownership_test.go +5 -5 renamed, 58% similar
| @@ -6,12 +6,12 @@ import ( | |||
| 6 | 6 | "github.com/stretchr/testify/require" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | - | func TestPrimaryVisibleSnapshotOwnsSavedPrimaryAcrossLiveMutations(t *testing.T) { | |
| 9 | + | func TestRecoveryTranscriptSnapshotOwnsSavedPrimaryAcrossLiveMutations(t *testing.T) { | |
| 10 | 10 | s := NewScreen(8, 2) | |
| 11 | 11 | s.Write([]byte("primary")) | |
| 12 | 12 | s.Write([]byte("\x1b[?1049h")) | |
| 13 | 13 | ||
| 14 | - | captured := s.PrimaryVisibleSnapshot() | |
| 14 | + | captured := s.RecoveryTranscriptSnapshot() | |
| 15 | 15 | mutated := make(chan struct{}) | |
| 16 | 16 | go func() { | |
| 17 | 17 | s.Write([]byte("alternate")) | |
| @@ -23,7 +23,7 @@ func TestPrimaryVisibleSnapshotOwnsSavedPrimaryAcrossLiveMutations(t *testing.T) | |||
| 23 | 23 | blob, err := captured.Marshal() | |
| 24 | 24 | <-mutated | |
| 25 | 25 | require.NoError(t, err) | |
| 26 | - | restored := NewScreen(1, 1) | |
| 27 | - | require.NoError(t, restored.RestorePrimaryVisible(blob)) | |
| 28 | - | require.Equal(t, "primary ", rowString(restored.Frame.Row(0))) | |
| 26 | + | view, err := UnmarshalHistory(blob) | |
| 27 | + | require.NoError(t, err) | |
| 28 | + | require.Equal(t, []string{"primary "}, historyViewTexts(view)) | |
| 29 | 29 | } | |
| @@ -6,12 +6,12 @@ import ( | |||||
| 6 | "github.com/stretchr/testify/require" | 6 | "github.com/stretchr/testify/require" | ||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | - | func TestPrimaryVisibleSnapshotOwnsSavedPrimaryAcrossLiveMutations(t *testing.T) { | 9 | + | func TestRecoveryTranscriptSnapshotOwnsSavedPrimaryAcrossLiveMutations(t *testing.T) { |
| 10 | s := NewScreen(8, 2) | 10 | s := NewScreen(8, 2) | ||
| 11 | s.Write([]byte("primary")) | 11 | s.Write([]byte("primary")) | ||
| 12 | s.Write([]byte("\x1b[?1049h")) | 12 | s.Write([]byte("\x1b[?1049h")) | ||
| 13 | 13 | ||||
| 14 | - | captured := s.PrimaryVisibleSnapshot() | 14 | + | captured := s.RecoveryTranscriptSnapshot() |
| 15 | mutated := make(chan struct{}) | 15 | mutated := make(chan struct{}) | ||
| 16 | go func() { | 16 | go func() { | ||
| 17 | s.Write([]byte("alternate")) | 17 | s.Write([]byte("alternate")) | ||
| @@ -23,7 +23,7 @@ func TestPrimaryVisibleSnapshotOwnsSavedPrimaryAcrossLiveMutations(t *testing.T) | |||||
| 23 | blob, err := captured.Marshal() | 23 | blob, err := captured.Marshal() | ||
| 24 | <-mutated | 24 | <-mutated | ||
| 25 | require.NoError(t, err) | 25 | require.NoError(t, err) | ||
| 26 | - | restored := NewScreen(1, 1) | 26 | + | view, err := UnmarshalHistory(blob) |
| 27 | - | require.NoError(t, restored.RestorePrimaryVisible(blob)) | 27 | + | require.NoError(t, err) |
| 28 | - | require.Equal(t, "primary ", rowString(restored.Frame.Row(0))) | 28 | + | require.Equal(t, []string{"primary "}, historyViewTexts(view)) |
| 29 | } | 29 | } | ||
A pkg/vt/recovery_transcript_test.go +415 -0
| @@ -0,0 +1,415 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | var ( | |
| 11 | + | recoveryTranscriptSnapshotSink RecoveryTranscriptSnapshot | |
| 12 | + | recoveryTranscriptScreenSink *Screen | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestRecoveryTranscriptSnapshotOwnsPrimaryCellsAndBounds(t *testing.T) { | |
| 16 | + | s := NewScreen(4, 2) | |
| 17 | + | s.Write([]byte("keep")) | |
| 18 | + | ||
| 19 | + | snapshot := s.RecoveryTranscriptSnapshot() | |
| 20 | + | s.Frame.Set(0, 0, renderer.BlankCell()) | |
| 21 | + | s.buffer.boundaries[0] = LineBound{} | |
| 22 | + | ||
| 23 | + | view := decodeRecoveryTranscript(t, snapshot) | |
| 24 | + | require.Equal(t, []string{"keep"}, historyViewTexts(view)) | |
| 25 | + | require.Equal(t, LineBound{End: 4}, view.Bound(0)) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestRecoveryTranscriptSnapshotRetainsReflowBounds(t *testing.T) { | |
| 29 | + | s := NewScreen(5, 3) | |
| 30 | + | s.Write([]byte("abcdefgh")) | |
| 31 | + | ||
| 32 | + | view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot()) | |
| 33 | + | ||
| 34 | + | require.Equal(t, []string{"abcde", "fgh "}, historyViewTexts(view)) | |
| 35 | + | require.Equal(t, LineBound{End: 5, Soft: true}, view.Bound(0)) | |
| 36 | + | require.Equal(t, LineBound{End: 3}, view.Bound(1)) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestRecoveryTranscriptSnapshotAllocationsDoNotScaleWithRetainedRows(t *testing.T) { | |
| 40 | + | const ( | |
| 41 | + | shortHeight = 8 | |
| 42 | + | tallHeight = 512 | |
| 43 | + | ) | |
| 44 | + | newRetainedScreen := func(height int) *Screen { | |
| 45 | + | screen := NewScreen(4, height) | |
| 46 | + | for y := range height { | |
| 47 | + | screen.Frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |
| 48 | + | screen.buffer.boundaries[y] = LineBound{End: 1} | |
| 49 | + | } | |
| 50 | + | return screen | |
| 51 | + | } | |
| 52 | + | short, tall := newRetainedScreen(shortHeight), newRetainedScreen(tallHeight) | |
| 53 | + | ||
| 54 | + | shortAllocs := testing.AllocsPerRun(20, func() { | |
| 55 | + | recoveryTranscriptSnapshotSink = short.RecoveryTranscriptSnapshot() | |
| 56 | + | }) | |
| 57 | + | tallAllocs := testing.AllocsPerRun(20, func() { | |
| 58 | + | recoveryTranscriptSnapshotSink = tall.RecoveryTranscriptSnapshot() | |
| 59 | + | }) | |
| 60 | + | ||
| 61 | + | // Allow a small fixed variance while rejecting the former allocation per row. | |
| 62 | + | if tallAllocs > shortAllocs+4 { | |
| 63 | + | t.Fatalf("snapshot allocations scale with retained rows: height %d = %.0f, height %d = %.0f", shortHeight, shortAllocs, tallHeight, tallAllocs) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestRecoveryTranscriptSnapshotOrdersPrimaryThenActiveAlternateAndHardensSeams(t *testing.T) { | |
| 68 | + | s := NewScreen(4, 3) | |
| 69 | + | s.Write([]byte("prim")) | |
| 70 | + | s.buffer.boundaries[0].Soft = true | |
| 71 | + | s.Write([]byte("\x1b[?1049h")) | |
| 72 | + | s.Write([]byte("alt")) | |
| 73 | + | s.buffer.boundaries[0].Soft = true | |
| 74 | + | ||
| 75 | + | snapshot := s.RecoveryTranscriptSnapshot() | |
| 76 | + | s.Frame.Set(0, 0, renderer.BlankCell()) | |
| 77 | + | s.buffer.boundaries[0] = LineBound{} | |
| 78 | + | s.alternate.buffer.frame.Set(0, 0, renderer.BlankCell()) | |
| 79 | + | s.alternate.buffer.boundaries[0] = LineBound{} | |
| 80 | + | ||
| 81 | + | view := decodeRecoveryTranscript(t, snapshot) | |
| 82 | + | ||
| 83 | + | require.Equal(t, []string{"prim", "alt "}, historyViewTexts(view)) | |
| 84 | + | require.Equal(t, LineBound{End: 4}, view.Bound(0), "saved-primary seam must be hard") | |
| 85 | + | require.Equal(t, LineBound{End: 3}, view.Bound(1), "final alternate seam must be hard") | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func TestRecoveryTranscriptSnapshotTrimsEachViewportIndependently(t *testing.T) { | |
| 89 | + | s := NewScreen(4, 3) | |
| 90 | + | s.Write([]byte("main")) | |
| 91 | + | s.Write([]byte("\x1b[?1049h")) | |
| 92 | + | s.Write([]byte("alt")) | |
| 93 | + | ||
| 94 | + | view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot()) | |
| 95 | + | ||
| 96 | + | require.Equal(t, []string{"main", "alt "}, historyViewTexts(view)) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func TestRecoveryTranscriptSnapshotExcludesExistingBoundedHistory(t *testing.T) { | |
| 100 | + | s := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 2}) | |
| 101 | + | require.NoError(t, s.History().Append(historyRow("old"), LineBound{End: 3})) | |
| 102 | + | s.Write([]byte("live")) | |
| 103 | + | ||
| 104 | + | view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot()) | |
| 105 | + | ||
| 106 | + | require.Equal(t, []string{"live"}, historyViewTexts(view)) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestRecoveryTranscriptSnapshotTrimsOnlyTrailingUntouchedRows(t *testing.T) { | |
| 110 | + | styledBlank := renderer.BlankCell() | |
| 111 | + | styledBlank.Style.Bold = true | |
| 112 | + | ||
| 113 | + | tests := []struct { | |
| 114 | + | name string | |
| 115 | + | candidateCell renderer.Cell | |
| 116 | + | candidateBound LineBound | |
| 117 | + | wantRows int | |
| 118 | + | wantCell renderer.Cell | |
| 119 | + | }{ | |
| 120 | + | { | |
| 121 | + | name: "untouched default blank with zero hard bound", | |
| 122 | + | candidateCell: renderer.BlankCell(), | |
| 123 | + | wantRows: 1, | |
| 124 | + | }, | |
| 125 | + | { | |
| 126 | + | name: "written default space", | |
| 127 | + | candidateCell: renderer.BlankCell(), | |
| 128 | + | candidateBound: LineBound{End: 1}, | |
| 129 | + | wantRows: 2, | |
| 130 | + | wantCell: renderer.BlankCell(), | |
| 131 | + | }, | |
| 132 | + | { | |
| 133 | + | name: "styled blank", | |
| 134 | + | candidateCell: styledBlank, | |
| 135 | + | wantRows: 2, | |
| 136 | + | wantCell: styledBlank, | |
| 137 | + | }, | |
| 138 | + | { | |
| 139 | + | name: "soft blank", | |
| 140 | + | candidateCell: renderer.BlankCell(), | |
| 141 | + | candidateBound: LineBound{Soft: true}, | |
| 142 | + | wantRows: 2, | |
| 143 | + | wantCell: renderer.BlankCell(), | |
| 144 | + | }, | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | for _, tt := range tests { | |
| 148 | + | t.Run(tt.name, func(t *testing.T) { | |
| 149 | + | s := NewScreen(3, 3) | |
| 150 | + | s.Frame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |
| 151 | + | s.buffer.boundaries[0] = LineBound{End: 1} | |
| 152 | + | s.Frame.Set(0, 1, tt.candidateCell) | |
| 153 | + | s.buffer.boundaries[1] = tt.candidateBound | |
| 154 | + | ||
| 155 | + | view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot()) | |
| 156 | + | ||
| 157 | + | require.Equal(t, tt.wantRows, view.Len()) | |
| 158 | + | if tt.wantRows == 2 { | |
| 159 | + | require.True(t, view.Row(1)[0].Equal(tt.wantCell)) | |
| 160 | + | require.False(t, view.Bound(1).Soft, "the retained segment's final row must be hardened") | |
| 161 | + | } | |
| 162 | + | }) | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func TestRecoveryTranscriptSnapshotPreservesLeadingAndInternalBlankRows(t *testing.T) { | |
| 167 | + | s := NewScreen(3, 4) | |
| 168 | + | s.Frame.Set(0, 2, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |
| 169 | + | s.buffer.boundaries[2] = LineBound{End: 1} | |
| 170 | + | ||
| 171 | + | view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot()) | |
| 172 | + | ||
| 173 | + | require.Equal(t, []string{" ", " ", "x "}, historyViewTexts(view)) | |
| 174 | + | require.Equal(t, []LineBound{{}, {}, {End: 1}}, []LineBound{view.Bound(0), view.Bound(1), view.Bound(2)}) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func TestRecoveryTranscriptSnapshotPreservesBoundsAndHardensOnlyTheFinalRow(t *testing.T) { | |
| 178 | + | s := NewScreen(4, 3) | |
| 179 | + | s.buffer.boundaries = []LineBound{{End: 4, Soft: true}, {End: 2}, {End: 3, Soft: true}} | |
| 180 | + | ||
| 181 | + | view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot()) | |
| 182 | + | ||
| 183 | + | require.Equal(t, []LineBound{{End: 4, Soft: true}, {End: 2}, {End: 3}}, []LineBound{view.Bound(0), view.Bound(1), view.Bound(2)}) | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func TestRecoveryTranscriptSnapshotMarshalsCanonicalEmptyHistory(t *testing.T) { | |
| 187 | + | s := NewScreen(4, 2) | |
| 188 | + | ||
| 189 | + | got, err := s.RecoveryTranscriptSnapshot().Marshal() | |
| 190 | + | require.NoError(t, err) | |
| 191 | + | want, err := MarshalHistory(HistoryView{}) | |
| 192 | + | require.NoError(t, err) | |
| 193 | + | ||
| 194 | + | require.Equal(t, want, got) | |
| 195 | + | view, err := UnmarshalHistory(got) | |
| 196 | + | require.NoError(t, err) | |
| 197 | + | require.Zero(t, view.Len()) | |
| 198 | + | require.Zero(t, view.ChunkCount()) | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func TestRecoveryTranscriptSnapshotChunksMoreThan256RowsCanonically(t *testing.T) { | |
| 202 | + | s := NewScreen(1, 257) | |
| 203 | + | for y := range s.Frame.Height { | |
| 204 | + | s.Frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |
| 205 | + | s.buffer.boundaries[y] = LineBound{End: 1, Soft: true} | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot()) | |
| 209 | + | ||
| 210 | + | require.Equal(t, 257, view.Len()) | |
| 211 | + | require.Equal(t, 2, view.ChunkCount()) | |
| 212 | + | require.Len(t, view.Chunk(0).rows, 256) | |
| 213 | + | require.Len(t, view.Chunk(1).rows, 1) | |
| 214 | + | require.True(t, view.Bound(255).Soft, "a chunk boundary is not a transcript seam") | |
| 215 | + | require.False(t, view.Bound(256).Soft, "the transcript's final seam must be hard") | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | func TestRecoveryTranscriptSnapshotPreservesWideCells(t *testing.T) { | |
| 219 | + | s := NewScreen(4, 2) | |
| 220 | + | s.Write([]byte("界")) | |
| 221 | + | want := append([]renderer.Cell(nil), s.Frame.Row(0)...) | |
| 222 | + | ||
| 223 | + | snapshot := s.RecoveryTranscriptSnapshot() | |
| 224 | + | for x := range s.Frame.Width { | |
| 225 | + | s.Frame.Set(x, 0, renderer.BlankCell()) | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | view := decodeRecoveryTranscript(t, snapshot) | |
| 229 | + | require.Equal(t, want, view.Row(0)) | |
| 230 | + | require.Equal(t, LineBound{End: 2}, view.Bound(0)) | |
| 231 | + | require.True(t, view.Row(0)[1].Continuation) | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | func TestNewScreenWithRecoveryTranscriptRestoresHistoryThenTranscriptAndStartsBlank(t *testing.T) { | |
| 235 | + | sealed, tail := recoveryHistoryBlobs(t, []string{"sealed-a", "sealed-b", "tail"}) | |
| 236 | + | transcript := recoveryTranscriptBlob(t, | |
| 237 | + | []string{"primary", "alternate"}, | |
| 238 | + | []LineBound{{End: 7, Soft: true}, {End: 9, Soft: true}}, | |
| 239 | + | ) | |
| 240 | + | ||
| 241 | + | screen, err := NewScreenWithRecoveryTranscript(5, 2, HistoryConfig{MaxRows: 8, MaxCells: 128, ChunkRows: 2}, sealed, tail, transcript) | |
| 242 | + | require.NoError(t, err) | |
| 243 | + | ||
| 244 | + | view := screen.History().View() | |
| 245 | + | require.Equal(t, []string{"sealed-a", "sealed-b", "tail", "primary", "alternate"}, historyViewTexts(view)) | |
| 246 | + | require.Equal(t, LineBound{End: 7, Soft: true}, view.Bound(3)) | |
| 247 | + | require.Equal(t, LineBound{End: 9}, view.Bound(4), "the restored transcript must end at a hard seam") | |
| 248 | + | for y := range screen.Frame.Height { | |
| 249 | + | for x := range screen.Frame.Width { | |
| 250 | + | require.True(t, screen.Frame.At(x, y).Equal(renderer.BlankCell()), "cell (%d,%d) must start blank", x, y) | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | require.Equal(t, []LineBound{{}, {}}, screen.LineBounds()) | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | func TestNewScreenWithRecoveryTranscriptEvictsOldestRowsWithinBounds(t *testing.T) { | |
| 257 | + | tests := []struct { | |
| 258 | + | name string | |
| 259 | + | config HistoryConfig | |
| 260 | + | history []string | |
| 261 | + | transcript []string | |
| 262 | + | want []string | |
| 263 | + | }{ | |
| 264 | + | { | |
| 265 | + | name: "row budget", | |
| 266 | + | config: HistoryConfig{MaxRows: 3, MaxCells: 128, ChunkRows: 2}, | |
| 267 | + | history: []string{"old-1", "old-2", "old-3"}, | |
| 268 | + | transcript: []string{"new-1", "new-2"}, | |
| 269 | + | want: []string{"old-3", "new-1", "new-2"}, | |
| 270 | + | }, | |
| 271 | + | { | |
| 272 | + | name: "cell budget", | |
| 273 | + | config: HistoryConfig{MaxRows: 10, MaxCells: 4, ChunkRows: 4}, | |
| 274 | + | history: []string{"aa", "b", "ccc"}, | |
| 275 | + | transcript: []string{"d", "ee"}, | |
| 276 | + | want: []string{"d", "ee"}, | |
| 277 | + | }, | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | for _, tt := range tests { | |
| 281 | + | t.Run(tt.name, func(t *testing.T) { | |
| 282 | + | sealed, tail := recoveryHistoryBlobs(t, tt.history) | |
| 283 | + | transcript := recoveryTranscriptBlob(t, tt.transcript, nil) | |
| 284 | + | ||
| 285 | + | screen, err := NewScreenWithRecoveryTranscript(4, 2, tt.config, sealed, tail, transcript) | |
| 286 | + | require.NoError(t, err) | |
| 287 | + | require.Equal(t, tt.want, historyViewTexts(screen.History().View())) | |
| 288 | + | }) | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | func TestNewScreenWithRecoveryTranscriptRejectsOversizedTranscriptRow(t *testing.T) { | |
| 293 | + | sealed, tail := recoveryHistoryBlobs(t, []string{"ok"}) | |
| 294 | + | transcript := recoveryTranscriptBlob(t, []string{"wide"}, nil) | |
| 295 | + | ||
| 296 | + | screen, err := NewScreenWithRecoveryTranscript(4, 2, HistoryConfig{MaxRows: 4, MaxCells: 3}, sealed, tail, transcript) | |
| 297 | + | ||
| 298 | + | require.ErrorIs(t, err, ErrHistoryRowTooWide) | |
| 299 | + | require.Nil(t, screen) | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | func BenchmarkRecoveryTranscriptSnapshot(b *testing.B) { | |
| 303 | + | for _, tt := range []struct { | |
| 304 | + | name string | |
| 305 | + | alternate bool | |
| 306 | + | wantRows int | |
| 307 | + | }{ | |
| 308 | + | {name: "primary", wantRows: 40}, | |
| 309 | + | {name: "primary-and-active-alternate", alternate: true, wantRows: 80}, | |
| 310 | + | } { | |
| 311 | + | b.Run(tt.name, func(b *testing.B) { | |
| 312 | + | screen := NewScreen(120, 40) | |
| 313 | + | fillRecoveryTranscriptBenchmarkFrame(screen) | |
| 314 | + | if tt.alternate { | |
| 315 | + | screen.Write([]byte("\x1b[?1049h")) | |
| 316 | + | fillRecoveryTranscriptBenchmarkFrame(screen) | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | b.ReportAllocs() | |
| 320 | + | b.ReportMetric(float64(tt.wantRows), "rows/snapshot") | |
| 321 | + | b.ResetTimer() | |
| 322 | + | for b.Loop() { | |
| 323 | + | recoveryTranscriptSnapshotSink = screen.RecoveryTranscriptSnapshot() | |
| 324 | + | } | |
| 325 | + | b.StopTimer() | |
| 326 | + | if got := decodeRecoveryTranscript(b, recoveryTranscriptSnapshotSink).Len(); got != tt.wantRows { | |
| 327 | + | b.Fatalf("captured rows = %d, want %d", got, tt.wantRows) | |
| 328 | + | } | |
| 329 | + | }) | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | func fillRecoveryTranscriptBenchmarkFrame(screen *Screen) { | |
| 334 | + | for y := range screen.Frame.Height { | |
| 335 | + | for x := range screen.Frame.Width { | |
| 336 | + | screen.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + (x+y)%26), Style: renderer.DefaultStyle()}) | |
| 337 | + | } | |
| 338 | + | screen.buffer.boundaries[y] = LineBound{End: screen.Frame.Width} | |
| 339 | + | } | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | func BenchmarkNewScreenWithRecoveryTranscriptManyChunks(b *testing.B) { | |
| 343 | + | const rows = maxHistoryRows | |
| 344 | + | source := NewScreen(1, rows) | |
| 345 | + | for y := range rows { | |
| 346 | + | source.Frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |
| 347 | + | source.buffer.boundaries[y] = LineBound{End: 1} | |
| 348 | + | } | |
| 349 | + | transcript, err := source.RecoveryTranscriptSnapshot().Marshal() | |
| 350 | + | require.NoError(b, err) | |
| 351 | + | tail, err := MarshalEmptyHistoryTail() | |
| 352 | + | require.NoError(b, err) | |
| 353 | + | config := HistoryConfig{MaxRows: rows, MaxCells: rows, ChunkRows: maxHistoryChunkRows} | |
| 354 | + | ||
| 355 | + | b.ReportAllocs() | |
| 356 | + | b.ReportMetric(float64(rows), "rows/restore") | |
| 357 | + | b.ReportMetric(float64((rows+maxHistoryChunkRows-1)/maxHistoryChunkRows), "chunks/restore") | |
| 358 | + | b.ResetTimer() | |
| 359 | + | for b.Loop() { | |
| 360 | + | recoveryTranscriptScreenSink, err = NewScreenWithRecoveryTranscript(1, 1, config, nil, tail, transcript) | |
| 361 | + | if err != nil { | |
| 362 | + | b.Fatal(err) | |
| 363 | + | } | |
| 364 | + | } | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | func recoveryHistoryBlobs(t testing.TB, texts []string) ([][]byte, []byte) { | |
| 368 | + | t.Helper() | |
| 369 | + | history := NewHistory(HistoryConfig{MaxRows: len(texts) + 1, MaxCells: 1024, ChunkRows: 2}) | |
| 370 | + | for _, text := range texts { | |
| 371 | + | require.NoError(t, history.Append(historyRow(text), LineBound{End: len(text)})) | |
| 372 | + | } | |
| 373 | + | view := history.SnapshotView() | |
| 374 | + | sealed := make([][]byte, view.ChunkCount()) | |
| 375 | + | for i := range sealed { | |
| 376 | + | var err error | |
| 377 | + | sealed[i], err = MarshalHistoryChunk(view.Chunk(i)) | |
| 378 | + | require.NoError(t, err) | |
| 379 | + | } | |
| 380 | + | tail, err := MarshalHistoryTail(view) | |
| 381 | + | require.NoError(t, err) | |
| 382 | + | return sealed, tail | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | func recoveryTranscriptBlob(t testing.TB, texts []string, bounds []LineBound) []byte { | |
| 386 | + | t.Helper() | |
| 387 | + | rows := make([][]renderer.Cell, len(texts)) | |
| 388 | + | cells := 0 | |
| 389 | + | for i, text := range texts { | |
| 390 | + | rows[i] = historyRow(text) | |
| 391 | + | cells += len(rows[i]) | |
| 392 | + | } | |
| 393 | + | if bounds == nil { | |
| 394 | + | bounds = make([]LineBound, len(rows)) | |
| 395 | + | for i, row := range rows { | |
| 396 | + | bounds[i].End = len(row) | |
| 397 | + | } | |
| 398 | + | } | |
| 399 | + | view := HistoryView{rows: len(rows), cells: cells} | |
| 400 | + | if len(rows) > 0 { | |
| 401 | + | view.chunks = []*HistoryChunk{{rows: rows, bounds: bounds}} | |
| 402 | + | } | |
| 403 | + | blob, err := MarshalHistory(view) | |
| 404 | + | require.NoError(t, err) | |
| 405 | + | return blob | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | func decodeRecoveryTranscript(t testing.TB, snapshot RecoveryTranscriptSnapshot) HistoryView { | |
| 409 | + | t.Helper() | |
| 410 | + | blob, err := snapshot.Marshal() | |
| 411 | + | require.NoError(t, err) | |
| 412 | + | view, err := UnmarshalHistory(blob) | |
| 413 | + | require.NoError(t, err) | |
| 414 | + | return view | |
| 415 | + | } | |
| @@ -0,0 +1,415 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | var ( | |||
| 11 | + | recoveryTranscriptSnapshotSink RecoveryTranscriptSnapshot | |||
| 12 | + | recoveryTranscriptScreenSink *Screen | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestRecoveryTranscriptSnapshotOwnsPrimaryCellsAndBounds(t *testing.T) { | |||
| 16 | + | s := NewScreen(4, 2) | |||
| 17 | + | s.Write([]byte("keep")) | |||
| 18 | + | ||||
| 19 | + | snapshot := s.RecoveryTranscriptSnapshot() | |||
| 20 | + | s.Frame.Set(0, 0, renderer.BlankCell()) | |||
| 21 | + | s.buffer.boundaries[0] = LineBound{} | |||
| 22 | + | ||||
| 23 | + | view := decodeRecoveryTranscript(t, snapshot) | |||
| 24 | + | require.Equal(t, []string{"keep"}, historyViewTexts(view)) | |||
| 25 | + | require.Equal(t, LineBound{End: 4}, view.Bound(0)) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestRecoveryTranscriptSnapshotRetainsReflowBounds(t *testing.T) { | |||
| 29 | + | s := NewScreen(5, 3) | |||
| 30 | + | s.Write([]byte("abcdefgh")) | |||
| 31 | + | ||||
| 32 | + | view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot()) | |||
| 33 | + | ||||
| 34 | + | require.Equal(t, []string{"abcde", "fgh "}, historyViewTexts(view)) | |||
| 35 | + | require.Equal(t, LineBound{End: 5, Soft: true}, view.Bound(0)) | |||
| 36 | + | require.Equal(t, LineBound{End: 3}, view.Bound(1)) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestRecoveryTranscriptSnapshotAllocationsDoNotScaleWithRetainedRows(t *testing.T) { | |||
| 40 | + | const ( | |||
| 41 | + | shortHeight = 8 | |||
| 42 | + | tallHeight = 512 | |||
| 43 | + | ) | |||
| 44 | + | newRetainedScreen := func(height int) *Screen { | |||
| 45 | + | screen := NewScreen(4, height) | |||
| 46 | + | for y := range height { | |||
| 47 | + | screen.Frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |||
| 48 | + | screen.buffer.boundaries[y] = LineBound{End: 1} | |||
| 49 | + | } | |||
| 50 | + | return screen | |||
| 51 | + | } | |||
| 52 | + | short, tall := newRetainedScreen(shortHeight), newRetainedScreen(tallHeight) | |||
| 53 | + | ||||
| 54 | + | shortAllocs := testing.AllocsPerRun(20, func() { | |||
| 55 | + | recoveryTranscriptSnapshotSink = short.RecoveryTranscriptSnapshot() | |||
| 56 | + | }) | |||
| 57 | + | tallAllocs := testing.AllocsPerRun(20, func() { | |||
| 58 | + | recoveryTranscriptSnapshotSink = tall.RecoveryTranscriptSnapshot() | |||
| 59 | + | }) | |||
| 60 | + | ||||
| 61 | + | // Allow a small fixed variance while rejecting the former allocation per row. | |||
| 62 | + | if tallAllocs > shortAllocs+4 { | |||
| 63 | + | t.Fatalf("snapshot allocations scale with retained rows: height %d = %.0f, height %d = %.0f", shortHeight, shortAllocs, tallHeight, tallAllocs) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestRecoveryTranscriptSnapshotOrdersPrimaryThenActiveAlternateAndHardensSeams(t *testing.T) { | |||
| 68 | + | s := NewScreen(4, 3) | |||
| 69 | + | s.Write([]byte("prim")) | |||
| 70 | + | s.buffer.boundaries[0].Soft = true | |||
| 71 | + | s.Write([]byte("\x1b[?1049h")) | |||
| 72 | + | s.Write([]byte("alt")) | |||
| 73 | + | s.buffer.boundaries[0].Soft = true | |||
| 74 | + | ||||
| 75 | + | snapshot := s.RecoveryTranscriptSnapshot() | |||
| 76 | + | s.Frame.Set(0, 0, renderer.BlankCell()) | |||
| 77 | + | s.buffer.boundaries[0] = LineBound{} | |||
| 78 | + | s.alternate.buffer.frame.Set(0, 0, renderer.BlankCell()) | |||
| 79 | + | s.alternate.buffer.boundaries[0] = LineBound{} | |||
| 80 | + | ||||
| 81 | + | view := decodeRecoveryTranscript(t, snapshot) | |||
| 82 | + | ||||
| 83 | + | require.Equal(t, []string{"prim", "alt "}, historyViewTexts(view)) | |||
| 84 | + | require.Equal(t, LineBound{End: 4}, view.Bound(0), "saved-primary seam must be hard") | |||
| 85 | + | require.Equal(t, LineBound{End: 3}, view.Bound(1), "final alternate seam must be hard") | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func TestRecoveryTranscriptSnapshotTrimsEachViewportIndependently(t *testing.T) { | |||
| 89 | + | s := NewScreen(4, 3) | |||
| 90 | + | s.Write([]byte("main")) | |||
| 91 | + | s.Write([]byte("\x1b[?1049h")) | |||
| 92 | + | s.Write([]byte("alt")) | |||
| 93 | + | ||||
| 94 | + | view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot()) | |||
| 95 | + | ||||
| 96 | + | require.Equal(t, []string{"main", "alt "}, historyViewTexts(view)) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func TestRecoveryTranscriptSnapshotExcludesExistingBoundedHistory(t *testing.T) { | |||
| 100 | + | s := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 2}) | |||
| 101 | + | require.NoError(t, s.History().Append(historyRow("old"), LineBound{End: 3})) | |||
| 102 | + | s.Write([]byte("live")) | |||
| 103 | + | ||||
| 104 | + | view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot()) | |||
| 105 | + | ||||
| 106 | + | require.Equal(t, []string{"live"}, historyViewTexts(view)) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestRecoveryTranscriptSnapshotTrimsOnlyTrailingUntouchedRows(t *testing.T) { | |||
| 110 | + | styledBlank := renderer.BlankCell() | |||
| 111 | + | styledBlank.Style.Bold = true | |||
| 112 | + | ||||
| 113 | + | tests := []struct { | |||
| 114 | + | name string | |||
| 115 | + | candidateCell renderer.Cell | |||
| 116 | + | candidateBound LineBound | |||
| 117 | + | wantRows int | |||
| 118 | + | wantCell renderer.Cell | |||
| 119 | + | }{ | |||
| 120 | + | { | |||
| 121 | + | name: "untouched default blank with zero hard bound", | |||
| 122 | + | candidateCell: renderer.BlankCell(), | |||
| 123 | + | wantRows: 1, | |||
| 124 | + | }, | |||
| 125 | + | { | |||
| 126 | + | name: "written default space", | |||
| 127 | + | candidateCell: renderer.BlankCell(), | |||
| 128 | + | candidateBound: LineBound{End: 1}, | |||
| 129 | + | wantRows: 2, | |||
| 130 | + | wantCell: renderer.BlankCell(), | |||
| 131 | + | }, | |||
| 132 | + | { | |||
| 133 | + | name: "styled blank", | |||
| 134 | + | candidateCell: styledBlank, | |||
| 135 | + | wantRows: 2, | |||
| 136 | + | wantCell: styledBlank, | |||
| 137 | + | }, | |||
| 138 | + | { | |||
| 139 | + | name: "soft blank", | |||
| 140 | + | candidateCell: renderer.BlankCell(), | |||
| 141 | + | candidateBound: LineBound{Soft: true}, | |||
| 142 | + | wantRows: 2, | |||
| 143 | + | wantCell: renderer.BlankCell(), | |||
| 144 | + | }, | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | for _, tt := range tests { | |||
| 148 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 149 | + | s := NewScreen(3, 3) | |||
| 150 | + | s.Frame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |||
| 151 | + | s.buffer.boundaries[0] = LineBound{End: 1} | |||
| 152 | + | s.Frame.Set(0, 1, tt.candidateCell) | |||
| 153 | + | s.buffer.boundaries[1] = tt.candidateBound | |||
| 154 | + | ||||
| 155 | + | view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot()) | |||
| 156 | + | ||||
| 157 | + | require.Equal(t, tt.wantRows, view.Len()) | |||
| 158 | + | if tt.wantRows == 2 { | |||
| 159 | + | require.True(t, view.Row(1)[0].Equal(tt.wantCell)) | |||
| 160 | + | require.False(t, view.Bound(1).Soft, "the retained segment's final row must be hardened") | |||
| 161 | + | } | |||
| 162 | + | }) | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func TestRecoveryTranscriptSnapshotPreservesLeadingAndInternalBlankRows(t *testing.T) { | |||
| 167 | + | s := NewScreen(3, 4) | |||
| 168 | + | s.Frame.Set(0, 2, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |||
| 169 | + | s.buffer.boundaries[2] = LineBound{End: 1} | |||
| 170 | + | ||||
| 171 | + | view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot()) | |||
| 172 | + | ||||
| 173 | + | require.Equal(t, []string{" ", " ", "x "}, historyViewTexts(view)) | |||
| 174 | + | require.Equal(t, []LineBound{{}, {}, {End: 1}}, []LineBound{view.Bound(0), view.Bound(1), view.Bound(2)}) | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | func TestRecoveryTranscriptSnapshotPreservesBoundsAndHardensOnlyTheFinalRow(t *testing.T) { | |||
| 178 | + | s := NewScreen(4, 3) | |||
| 179 | + | s.buffer.boundaries = []LineBound{{End: 4, Soft: true}, {End: 2}, {End: 3, Soft: true}} | |||
| 180 | + | ||||
| 181 | + | view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot()) | |||
| 182 | + | ||||
| 183 | + | require.Equal(t, []LineBound{{End: 4, Soft: true}, {End: 2}, {End: 3}}, []LineBound{view.Bound(0), view.Bound(1), view.Bound(2)}) | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | func TestRecoveryTranscriptSnapshotMarshalsCanonicalEmptyHistory(t *testing.T) { | |||
| 187 | + | s := NewScreen(4, 2) | |||
| 188 | + | ||||
| 189 | + | got, err := s.RecoveryTranscriptSnapshot().Marshal() | |||
| 190 | + | require.NoError(t, err) | |||
| 191 | + | want, err := MarshalHistory(HistoryView{}) | |||
| 192 | + | require.NoError(t, err) | |||
| 193 | + | ||||
| 194 | + | require.Equal(t, want, got) | |||
| 195 | + | view, err := UnmarshalHistory(got) | |||
| 196 | + | require.NoError(t, err) | |||
| 197 | + | require.Zero(t, view.Len()) | |||
| 198 | + | require.Zero(t, view.ChunkCount()) | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | func TestRecoveryTranscriptSnapshotChunksMoreThan256RowsCanonically(t *testing.T) { | |||
| 202 | + | s := NewScreen(1, 257) | |||
| 203 | + | for y := range s.Frame.Height { | |||
| 204 | + | s.Frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |||
| 205 | + | s.buffer.boundaries[y] = LineBound{End: 1, Soft: true} | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot()) | |||
| 209 | + | ||||
| 210 | + | require.Equal(t, 257, view.Len()) | |||
| 211 | + | require.Equal(t, 2, view.ChunkCount()) | |||
| 212 | + | require.Len(t, view.Chunk(0).rows, 256) | |||
| 213 | + | require.Len(t, view.Chunk(1).rows, 1) | |||
| 214 | + | require.True(t, view.Bound(255).Soft, "a chunk boundary is not a transcript seam") | |||
| 215 | + | require.False(t, view.Bound(256).Soft, "the transcript's final seam must be hard") | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | func TestRecoveryTranscriptSnapshotPreservesWideCells(t *testing.T) { | |||
| 219 | + | s := NewScreen(4, 2) | |||
| 220 | + | s.Write([]byte("界")) | |||
| 221 | + | want := append([]renderer.Cell(nil), s.Frame.Row(0)...) | |||
| 222 | + | ||||
| 223 | + | snapshot := s.RecoveryTranscriptSnapshot() | |||
| 224 | + | for x := range s.Frame.Width { | |||
| 225 | + | s.Frame.Set(x, 0, renderer.BlankCell()) | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | view := decodeRecoveryTranscript(t, snapshot) | |||
| 229 | + | require.Equal(t, want, view.Row(0)) | |||
| 230 | + | require.Equal(t, LineBound{End: 2}, view.Bound(0)) | |||
| 231 | + | require.True(t, view.Row(0)[1].Continuation) | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | func TestNewScreenWithRecoveryTranscriptRestoresHistoryThenTranscriptAndStartsBlank(t *testing.T) { | |||
| 235 | + | sealed, tail := recoveryHistoryBlobs(t, []string{"sealed-a", "sealed-b", "tail"}) | |||
| 236 | + | transcript := recoveryTranscriptBlob(t, | |||
| 237 | + | []string{"primary", "alternate"}, | |||
| 238 | + | []LineBound{{End: 7, Soft: true}, {End: 9, Soft: true}}, | |||
| 239 | + | ) | |||
| 240 | + | ||||
| 241 | + | screen, err := NewScreenWithRecoveryTranscript(5, 2, HistoryConfig{MaxRows: 8, MaxCells: 128, ChunkRows: 2}, sealed, tail, transcript) | |||
| 242 | + | require.NoError(t, err) | |||
| 243 | + | ||||
| 244 | + | view := screen.History().View() | |||
| 245 | + | require.Equal(t, []string{"sealed-a", "sealed-b", "tail", "primary", "alternate"}, historyViewTexts(view)) | |||
| 246 | + | require.Equal(t, LineBound{End: 7, Soft: true}, view.Bound(3)) | |||
| 247 | + | require.Equal(t, LineBound{End: 9}, view.Bound(4), "the restored transcript must end at a hard seam") | |||
| 248 | + | for y := range screen.Frame.Height { | |||
| 249 | + | for x := range screen.Frame.Width { | |||
| 250 | + | require.True(t, screen.Frame.At(x, y).Equal(renderer.BlankCell()), "cell (%d,%d) must start blank", x, y) | |||
| 251 | + | } | |||
| 252 | + | } | |||
| 253 | + | require.Equal(t, []LineBound{{}, {}}, screen.LineBounds()) | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | func TestNewScreenWithRecoveryTranscriptEvictsOldestRowsWithinBounds(t *testing.T) { | |||
| 257 | + | tests := []struct { | |||
| 258 | + | name string | |||
| 259 | + | config HistoryConfig | |||
| 260 | + | history []string | |||
| 261 | + | transcript []string | |||
| 262 | + | want []string | |||
| 263 | + | }{ | |||
| 264 | + | { | |||
| 265 | + | name: "row budget", | |||
| 266 | + | config: HistoryConfig{MaxRows: 3, MaxCells: 128, ChunkRows: 2}, | |||
| 267 | + | history: []string{"old-1", "old-2", "old-3"}, | |||
| 268 | + | transcript: []string{"new-1", "new-2"}, | |||
| 269 | + | want: []string{"old-3", "new-1", "new-2"}, | |||
| 270 | + | }, | |||
| 271 | + | { | |||
| 272 | + | name: "cell budget", | |||
| 273 | + | config: HistoryConfig{MaxRows: 10, MaxCells: 4, ChunkRows: 4}, | |||
| 274 | + | history: []string{"aa", "b", "ccc"}, | |||
| 275 | + | transcript: []string{"d", "ee"}, | |||
| 276 | + | want: []string{"d", "ee"}, | |||
| 277 | + | }, | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | for _, tt := range tests { | |||
| 281 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 282 | + | sealed, tail := recoveryHistoryBlobs(t, tt.history) | |||
| 283 | + | transcript := recoveryTranscriptBlob(t, tt.transcript, nil) | |||
| 284 | + | ||||
| 285 | + | screen, err := NewScreenWithRecoveryTranscript(4, 2, tt.config, sealed, tail, transcript) | |||
| 286 | + | require.NoError(t, err) | |||
| 287 | + | require.Equal(t, tt.want, historyViewTexts(screen.History().View())) | |||
| 288 | + | }) | |||
| 289 | + | } | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | func TestNewScreenWithRecoveryTranscriptRejectsOversizedTranscriptRow(t *testing.T) { | |||
| 293 | + | sealed, tail := recoveryHistoryBlobs(t, []string{"ok"}) | |||
| 294 | + | transcript := recoveryTranscriptBlob(t, []string{"wide"}, nil) | |||
| 295 | + | ||||
| 296 | + | screen, err := NewScreenWithRecoveryTranscript(4, 2, HistoryConfig{MaxRows: 4, MaxCells: 3}, sealed, tail, transcript) | |||
| 297 | + | ||||
| 298 | + | require.ErrorIs(t, err, ErrHistoryRowTooWide) | |||
| 299 | + | require.Nil(t, screen) | |||
| 300 | + | } | |||
| 301 | + | ||||
| 302 | + | func BenchmarkRecoveryTranscriptSnapshot(b *testing.B) { | |||
| 303 | + | for _, tt := range []struct { | |||
| 304 | + | name string | |||
| 305 | + | alternate bool | |||
| 306 | + | wantRows int | |||
| 307 | + | }{ | |||
| 308 | + | {name: "primary", wantRows: 40}, | |||
| 309 | + | {name: "primary-and-active-alternate", alternate: true, wantRows: 80}, | |||
| 310 | + | } { | |||
| 311 | + | b.Run(tt.name, func(b *testing.B) { | |||
| 312 | + | screen := NewScreen(120, 40) | |||
| 313 | + | fillRecoveryTranscriptBenchmarkFrame(screen) | |||
| 314 | + | if tt.alternate { | |||
| 315 | + | screen.Write([]byte("\x1b[?1049h")) | |||
| 316 | + | fillRecoveryTranscriptBenchmarkFrame(screen) | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | b.ReportAllocs() | |||
| 320 | + | b.ReportMetric(float64(tt.wantRows), "rows/snapshot") | |||
| 321 | + | b.ResetTimer() | |||
| 322 | + | for b.Loop() { | |||
| 323 | + | recoveryTranscriptSnapshotSink = screen.RecoveryTranscriptSnapshot() | |||
| 324 | + | } | |||
| 325 | + | b.StopTimer() | |||
| 326 | + | if got := decodeRecoveryTranscript(b, recoveryTranscriptSnapshotSink).Len(); got != tt.wantRows { | |||
| 327 | + | b.Fatalf("captured rows = %d, want %d", got, tt.wantRows) | |||
| 328 | + | } | |||
| 329 | + | }) | |||
| 330 | + | } | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | func fillRecoveryTranscriptBenchmarkFrame(screen *Screen) { | |||
| 334 | + | for y := range screen.Frame.Height { | |||
| 335 | + | for x := range screen.Frame.Width { | |||
| 336 | + | screen.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + (x+y)%26), Style: renderer.DefaultStyle()}) | |||
| 337 | + | } | |||
| 338 | + | screen.buffer.boundaries[y] = LineBound{End: screen.Frame.Width} | |||
| 339 | + | } | |||
| 340 | + | } | |||
| 341 | + | ||||
| 342 | + | func BenchmarkNewScreenWithRecoveryTranscriptManyChunks(b *testing.B) { | |||
| 343 | + | const rows = maxHistoryRows | |||
| 344 | + | source := NewScreen(1, rows) | |||
| 345 | + | for y := range rows { | |||
| 346 | + | source.Frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |||
| 347 | + | source.buffer.boundaries[y] = LineBound{End: 1} | |||
| 348 | + | } | |||
| 349 | + | transcript, err := source.RecoveryTranscriptSnapshot().Marshal() | |||
| 350 | + | require.NoError(b, err) | |||
| 351 | + | tail, err := MarshalEmptyHistoryTail() | |||
| 352 | + | require.NoError(b, err) | |||
| 353 | + | config := HistoryConfig{MaxRows: rows, MaxCells: rows, ChunkRows: maxHistoryChunkRows} | |||
| 354 | + | ||||
| 355 | + | b.ReportAllocs() | |||
| 356 | + | b.ReportMetric(float64(rows), "rows/restore") | |||
| 357 | + | b.ReportMetric(float64((rows+maxHistoryChunkRows-1)/maxHistoryChunkRows), "chunks/restore") | |||
| 358 | + | b.ResetTimer() | |||
| 359 | + | for b.Loop() { | |||
| 360 | + | recoveryTranscriptScreenSink, err = NewScreenWithRecoveryTranscript(1, 1, config, nil, tail, transcript) | |||
| 361 | + | if err != nil { | |||
| 362 | + | b.Fatal(err) | |||
| 363 | + | } | |||
| 364 | + | } | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | func recoveryHistoryBlobs(t testing.TB, texts []string) ([][]byte, []byte) { | |||
| 368 | + | t.Helper() | |||
| 369 | + | history := NewHistory(HistoryConfig{MaxRows: len(texts) + 1, MaxCells: 1024, ChunkRows: 2}) | |||
| 370 | + | for _, text := range texts { | |||
| 371 | + | require.NoError(t, history.Append(historyRow(text), LineBound{End: len(text)})) | |||
| 372 | + | } | |||
| 373 | + | view := history.SnapshotView() | |||
| 374 | + | sealed := make([][]byte, view.ChunkCount()) | |||
| 375 | + | for i := range sealed { | |||
| 376 | + | var err error | |||
| 377 | + | sealed[i], err = MarshalHistoryChunk(view.Chunk(i)) | |||
| 378 | + | require.NoError(t, err) | |||
| 379 | + | } | |||
| 380 | + | tail, err := MarshalHistoryTail(view) | |||
| 381 | + | require.NoError(t, err) | |||
| 382 | + | return sealed, tail | |||
| 383 | + | } | |||
| 384 | + | ||||
| 385 | + | func recoveryTranscriptBlob(t testing.TB, texts []string, bounds []LineBound) []byte { | |||
| 386 | + | t.Helper() | |||
| 387 | + | rows := make([][]renderer.Cell, len(texts)) | |||
| 388 | + | cells := 0 | |||
| 389 | + | for i, text := range texts { | |||
| 390 | + | rows[i] = historyRow(text) | |||
| 391 | + | cells += len(rows[i]) | |||
| 392 | + | } | |||
| 393 | + | if bounds == nil { | |||
| 394 | + | bounds = make([]LineBound, len(rows)) | |||
| 395 | + | for i, row := range rows { | |||
| 396 | + | bounds[i].End = len(row) | |||
| 397 | + | } | |||
| 398 | + | } | |||
| 399 | + | view := HistoryView{rows: len(rows), cells: cells} | |||
| 400 | + | if len(rows) > 0 { | |||
| 401 | + | view.chunks = []*HistoryChunk{{rows: rows, bounds: bounds}} | |||
| 402 | + | } | |||
| 403 | + | blob, err := MarshalHistory(view) | |||
| 404 | + | require.NoError(t, err) | |||
| 405 | + | return blob | |||
| 406 | + | } | |||
| 407 | + | ||||
| 408 | + | func decodeRecoveryTranscript(t testing.TB, snapshot RecoveryTranscriptSnapshot) HistoryView { | |||
| 409 | + | t.Helper() | |||
| 410 | + | blob, err := snapshot.Marshal() | |||
| 411 | + | require.NoError(t, err) | |||
| 412 | + | view, err := UnmarshalHistory(blob) | |||
| 413 | + | require.NoError(t, err) | |||
| 414 | + | return view | |||
| 415 | + | } | |||
M pkg/vt/resize_reflow_regression_test.go +1 -29
| @@ -1,11 +1,6 @@ | |||
| 1 | 1 | package vt | |
| 2 | 2 | ||
| 3 | - | import ( | |
| 4 | - | "encoding/binary" | |
| 5 | - | "testing" | |
| 6 | - | ||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |
| 8 | - | ) | |
| 3 | + | import "testing" | |
| 9 | 4 | ||
| 10 | 5 | func TestCollapsedResizeUpdatesSavedPrimaryAndClearsEscape(t *testing.T) { | |
| 11 | 6 | s := NewScreen(5, 3) | |
| @@ -57,26 +52,3 @@ func TestInsertModeRetainsShiftedTailInReflowExtent(t *testing.T) { | |||
| 57 | 52 | assertCell(t, s, x, 0, r) | |
| 58 | 53 | } | |
| 59 | 54 | } | |
| 60 | - | ||
| 61 | - | func TestVisibleBoundaryMetadataBudget(t *testing.T) { | |
| 62 | - | const height = maxHistoryRows + 1 | |
| 63 | - | if _, err := MarshalVisible(renderer.NewFrame(0, height)); err == nil { | |
| 64 | - | t.Fatal("marshal accepted boundary metadata beyond its allocation budget") | |
| 65 | - | } | |
| 66 | - | s := NewScreen(0, height) | |
| 67 | - | if _, err := s.MarshalPrimaryVisible(); err == nil { | |
| 68 | - | t.Fatal("marshal accepted boundary metadata beyond its allocation budget") | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | data := make([]byte, 13+height*visibleBoundaryBytes) | |
| 72 | - | copy(data[:4], visibleMagic) | |
| 73 | - | data[4] = historyVersion | |
| 74 | - | binary.BigEndian.PutUint32(data[9:13], height) | |
| 75 | - | if _, err := PreflightVisibleBlob(data); err == nil { | |
| 76 | - | t.Fatal("preflight accepted boundary metadata beyond its allocation budget") | |
| 77 | - | } | |
| 78 | - | if _, err := UnmarshalVisible(data); err == nil { | |
| 79 | - | t.Fatal("unmarshal accepted boundary metadata beyond its allocation budget") | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | } | |
| @@ -1,11 +1,6 @@ | |||||
| 1 | package vt | 1 | package vt | ||
| 2 | 2 | ||||
| 3 | - | import ( | 3 | + | import "testing" |
| 4 | - | "encoding/binary" | |||
| 5 | - | "testing" | |||
| 6 | - | ||||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | - | ) | |||
| 9 | 4 | ||||
| 10 | func TestCollapsedResizeUpdatesSavedPrimaryAndClearsEscape(t *testing.T) { | 5 | func TestCollapsedResizeUpdatesSavedPrimaryAndClearsEscape(t *testing.T) { | ||
| 11 | s := NewScreen(5, 3) | 6 | s := NewScreen(5, 3) | ||
| @@ -57,26 +52,3 @@ func TestInsertModeRetainsShiftedTailInReflowExtent(t *testing.T) { | |||||
| 57 | assertCell(t, s, x, 0, r) | 52 | assertCell(t, s, x, 0, r) | ||
| 58 | } | 53 | } | ||
| 59 | } | 54 | } | ||
| 60 | - | ||||
| 61 | - | func TestVisibleBoundaryMetadataBudget(t *testing.T) { | |||
| 62 | - | const height = maxHistoryRows + 1 | |||
| 63 | - | if _, err := MarshalVisible(renderer.NewFrame(0, height)); err == nil { | |||
| 64 | - | t.Fatal("marshal accepted boundary metadata beyond its allocation budget") | |||
| 65 | - | } | |||
| 66 | - | s := NewScreen(0, height) | |||
| 67 | - | if _, err := s.MarshalPrimaryVisible(); err == nil { | |||
| 68 | - | t.Fatal("marshal accepted boundary metadata beyond its allocation budget") | |||
| 69 | - | } | |||
| 70 | - | ||||
| 71 | - | data := make([]byte, 13+height*visibleBoundaryBytes) | |||
| 72 | - | copy(data[:4], visibleMagic) | |||
| 73 | - | data[4] = historyVersion | |||
| 74 | - | binary.BigEndian.PutUint32(data[9:13], height) | |||
| 75 | - | if _, err := PreflightVisibleBlob(data); err == nil { | |||
| 76 | - | t.Fatal("preflight accepted boundary metadata beyond its allocation budget") | |||
| 77 | - | } | |||
| 78 | - | if _, err := UnmarshalVisible(data); err == nil { | |||
| 79 | - | t.Fatal("unmarshal accepted boundary metadata beyond its allocation budget") | |||
| 80 | - | } | |||
| 81 | - | ||||
| 82 | - | } | |||
M pkg/vt/snapshot.go +21 -224
| @@ -1,16 +1,8 @@ | |||
| 1 | 1 | package vt | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "encoding/binary" | |
| 5 | 4 | "fmt" | |
| 6 | 5 | "math" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/vev/pkg/renderer" | |
| 9 | - | ) | |
| 10 | - | ||
| 11 | - | const ( | |
| 12 | - | visibleMagic = "VTV2" | |
| 13 | - | visibleBoundaryBytes = 5 | |
| 14 | 6 | ) | |
| 15 | 7 | ||
| 16 | 8 | // DecodeStats describes resources declared by one canonical VT blob. | |
| @@ -111,229 +103,34 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist | |||
| 111 | 103 | return h, nil | |
| 112 | 104 | } | |
| 113 | 105 | ||
| 114 | - | // NewScreenWithRestoredHistory constructs a screen that owns history restored | |
| 115 | - | // directly from canonical blobs. Call RestorePrimaryVisible to install a | |
| 116 | - | // persisted primary frame before making the screen live. | |
| 117 | - | func NewScreenWithRestoredHistory(width, height int, config HistoryConfig, sealed [][]byte, tail []byte) (*Screen, error) { | |
| 118 | - | history, err := HistoryFromBlobs(config, sealed, tail) | |
| 106 | + | // NewScreenWithRecoveryTranscript constructs a fresh blank screen whose | |
| 107 | + | // history contains the restored bounded history followed by the recovery | |
| 108 | + | // transcript. The transcript is decoded in full before history is restored. | |
| 109 | + | func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, sealed [][]byte, tail, transcript []byte) (*Screen, error) { | |
| 110 | + | transcriptView, err := UnmarshalHistory(transcript) | |
| 119 | 111 | if err != nil { | |
| 120 | - | return nil, err | |
| 112 | + | return nil, fmt.Errorf("restore recovery transcript: %w", err) | |
| 121 | 113 | } | |
| 122 | - | screen := NewScreenWithHistory(width, height, config) | |
| 123 | - | screen.history = history | |
| 124 | - | return screen, nil | |
| 125 | - | } | |
| 126 | 114 | ||
| 127 | - | // MarshalVisible encodes the exact visible primary frame, including every | |
| 128 | - | // blank, row width, and terminal cell attribute. | |
| 129 | - | func MarshalVisible(frame renderer.Frame) ([]byte, error) { | |
| 130 | - | if _, _, _, ok := visibleEncodingBudget(frame.Width, frame.Height); !ok { | |
| 131 | - | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |
| 115 | + | history, err := HistoryFromBlobs(config, sealed, tail) | |
| 116 | + | if err != nil { | |
| 117 | + | return nil, err | |
| 132 | 118 | } | |
| 133 | - | boundaries := make([]LineBound, frame.Height) | |
| 134 | - | for y := range boundaries { | |
| 135 | - | for x := frame.Width - 1; x >= 0; x-- { | |
| 136 | - | if !frame.At(x, y).Equal(renderer.BlankCell()) { | |
| 137 | - | boundaries[y].End = x + 1 | |
| 138 | - | break | |
| 119 | + | remainingRows := transcriptView.rows | |
| 120 | + | for _, chunk := range transcriptView.chunks { | |
| 121 | + | for i, row := range chunk.rows { | |
| 122 | + | remainingRows-- | |
| 123 | + | bound := chunk.bounds[i] | |
| 124 | + | if remainingRows == 0 { | |
| 125 | + | bound.Soft = false | |
| 139 | 126 | } | |
| 140 | - | } | |
| 141 | - | } | |
| 142 | - | return marshalVisible(frame, boundaries) | |
| 143 | - | } | |
| 144 | - | ||
| 145 | - | // PrimaryVisibleSnapshot is an owned copy of the primary buffer's visible | |
| 146 | - | // state. Capture it while the screen owner is synchronized, then Marshal it | |
| 147 | - | // after releasing that lock. | |
| 148 | - | type PrimaryVisibleSnapshot struct { | |
| 149 | - | frame renderer.Frame | |
| 150 | - | boundaries []LineBound | |
| 151 | - | } | |
| 152 | - | ||
| 153 | - | // PrimaryVisibleSnapshot copies the saved primary frame and its logical-line | |
| 154 | - | // boundaries. When the alternate screen is active, the saved primary buffer is | |
| 155 | - | // copied rather than the currently displayed alternate buffer. | |
| 156 | - | func (s *Screen) PrimaryVisibleSnapshot() PrimaryVisibleSnapshot { | |
| 157 | - | b := s.buffer | |
| 158 | - | if s.alternate != nil { | |
| 159 | - | b = s.alternate.buffer | |
| 160 | - | } | |
| 161 | - | if b == nil { | |
| 162 | - | return PrimaryVisibleSnapshot{frame: s.PrimaryVisibleFrame()} | |
| 163 | - | } | |
| 164 | - | return PrimaryVisibleSnapshot{ | |
| 165 | - | frame: b.frame.Clone(), | |
| 166 | - | boundaries: append([]LineBound(nil), b.boundaries...), | |
| 167 | - | } | |
| 168 | - | } | |
| 169 | - | ||
| 170 | - | // Marshal encodes an owned primary visible snapshot without accessing live VT | |
| 171 | - | // state. | |
| 172 | - | func (v PrimaryVisibleSnapshot) Marshal() ([]byte, error) { | |
| 173 | - | if len(v.boundaries) == 0 && v.frame.Height > 0 { | |
| 174 | - | return MarshalVisible(v.frame) | |
| 175 | - | } | |
| 176 | - | return marshalVisible(v.frame, v.boundaries) | |
| 177 | - | } | |
| 178 | - | ||
| 179 | - | // MarshalPrimaryVisible preserves the primary buffer's logical-line boundaries | |
| 180 | - | // alongside its cells so a restored viewport can subsequently reflow. | |
| 181 | - | // | |
| 182 | - | // Deprecated: callers holding a pane lock should use PrimaryVisibleSnapshot | |
| 183 | - | // and marshal the result after unlocking. | |
| 184 | - | func (s *Screen) MarshalPrimaryVisible() ([]byte, error) { | |
| 185 | - | return s.PrimaryVisibleSnapshot().Marshal() | |
| 186 | - | } | |
| 187 | - | ||
| 188 | - | func marshalVisible(frame renderer.Frame, boundaries []LineBound) ([]byte, error) { | |
| 189 | - | _, bytes, boundaryBytes, ok := visibleEncodingBudget(frame.Width, frame.Height) | |
| 190 | - | if !ok || len(boundaries) != frame.Height { | |
| 191 | - | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |
| 192 | - | } | |
| 193 | - | out := make([]byte, 0, int(13+bytes+boundaryBytes)) | |
| 194 | - | out = append(out, visibleMagic...) | |
| 195 | - | out = append(out, historyVersion) | |
| 196 | - | out = binary.BigEndian.AppendUint32(out, uint32(frame.Width)) | |
| 197 | - | out = binary.BigEndian.AppendUint32(out, uint32(frame.Height)) | |
| 198 | - | for y := range frame.Height { | |
| 199 | - | for _, cell := range frame.Row(y) { | |
| 200 | - | if !validHistoryCell(cell) { | |
| 201 | - | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |
| 127 | + | if err := history.Append(row, bound); err != nil { | |
| 128 | + | return nil, fmt.Errorf("restore recovery transcript: %w", err) | |
| 202 | 129 | } | |
| 203 | - | out = appendHistoryCell(out, cell) | |
| 204 | - | } | |
| 205 | - | } | |
| 206 | - | for _, boundary := range boundaries { | |
| 207 | - | out = binary.BigEndian.AppendUint32(out, uint32(boundary.End)) | |
| 208 | - | if boundary.Soft { | |
| 209 | - | out = append(out, 1) | |
| 210 | - | } else { | |
| 211 | - | out = append(out, 0) | |
| 212 | 130 | } | |
| 213 | 131 | } | |
| 214 | - | return out, nil | |
| 215 | - | } | |
| 216 | 132 | ||
| 217 | - | // PreflightVisibleBlob validates dimensions and cell declarations without | |
| 218 | - | // allocating a frame. | |
| 219 | - | func PreflightVisibleBlob(data []byte) (DecodeStats, error) { | |
| 220 | - | if len(data) < 13 || string(data[:4]) != visibleMagic || data[4] != historyVersion { | |
| 221 | - | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |
| 222 | - | } | |
| 223 | - | width, height := uint64(binary.BigEndian.Uint32(data[5:9])), uint64(binary.BigEndian.Uint32(data[9:13])) | |
| 224 | - | cells, bytes, boundaryBytes, ok := visibleEncodingBudget64(width, height) | |
| 225 | - | if !ok || bytes > math.MaxUint64-boundaryBytes || uint64(len(data)-13) != bytes+boundaryBytes { | |
| 226 | - | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |
| 227 | - | } | |
| 228 | - | p := historyParser{data: data[13:]} | |
| 229 | - | for range cells { | |
| 230 | - | cell, ok := p.cell() | |
| 231 | - | if !ok || !validHistoryCell(cell) { | |
| 232 | - | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |
| 233 | - | } | |
| 234 | - | } | |
| 235 | - | for range height { | |
| 236 | - | if uint64(binary.BigEndian.Uint32(p.data[:4])) > width || p.data[4] > 1 { | |
| 237 | - | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |
| 238 | - | } | |
| 239 | - | p.data = p.data[5:] | |
| 240 | - | } | |
| 241 | - | return DecodeStats{Rows: height, Cells: cells, Styles: cells, Bytes: bytes}, nil | |
| 242 | - | } | |
| 243 | - | ||
| 244 | - | func visibleEncodingBudget(width, height int) (cells, cellBytes, boundaryBytes uint64, ok bool) { | |
| 245 | - | if width < 0 || height < 0 { | |
| 246 | - | return 0, 0, 0, false | |
| 247 | - | } | |
| 248 | - | return visibleEncodingBudget64(uint64(width), uint64(height)) | |
| 249 | - | } | |
| 250 | - | ||
| 251 | - | func visibleEncodingBudget64(width, height uint64) (cells, cellBytes, boundaryBytes uint64, ok bool) { | |
| 252 | - | if width > math.MaxUint32 || height > math.MaxUint32 || (width != 0 && height > math.MaxUint64/width) || height > math.MaxUint64/visibleBoundaryBytes { | |
| 253 | - | return 0, 0, 0, false | |
| 254 | - | } | |
| 255 | - | cells = width * height | |
| 256 | - | cellBytes, ok = historyCellByteCount(cells) | |
| 257 | - | if !ok || cells > maxHistoryCells || cellBytes > maxHistoryDecodedBytes { | |
| 258 | - | return 0, 0, 0, false | |
| 259 | - | } | |
| 260 | - | boundaryBytes = height * visibleBoundaryBytes | |
| 261 | - | if boundaryBytes > maxVisibleBoundaryBytes { | |
| 262 | - | return 0, 0, 0, false | |
| 263 | - | } | |
| 264 | - | return cells, cellBytes, boundaryBytes, true | |
| 265 | - | } | |
| 266 | - | ||
| 267 | - | // UnmarshalVisible decodes a preflighted exact visible frame. | |
| 268 | - | func UnmarshalVisible(data []byte) (renderer.Frame, error) { | |
| 269 | - | frame, _, err := unmarshalVisible(data) | |
| 270 | - | return frame, err | |
| 271 | - | } | |
| 272 | - | ||
| 273 | - | func unmarshalVisible(data []byte) (renderer.Frame, []LineBound, error) { | |
| 274 | - | if _, err := PreflightVisibleBlob(data); err != nil { | |
| 275 | - | return renderer.Frame{}, nil, err | |
| 276 | - | } | |
| 277 | - | width, height := int(binary.BigEndian.Uint32(data[5:9])), int(binary.BigEndian.Uint32(data[9:13])) | |
| 278 | - | frame := renderer.NewFrame(width, height) | |
| 279 | - | cellBytes, _ := historyCellByteCount(uint64(width) * uint64(height)) | |
| 280 | - | p := historyParser{data: data[13 : 13+cellBytes]} | |
| 281 | - | for y := range height { | |
| 282 | - | for x := range width { | |
| 283 | - | cell, _ := p.cell() | |
| 284 | - | frame.Set(x, y, cell) | |
| 285 | - | } | |
| 286 | - | } | |
| 287 | - | boundaries := make([]LineBound, height) | |
| 288 | - | offset := 13 + int(cellBytes) | |
| 289 | - | for y := range boundaries { | |
| 290 | - | boundaries[y] = LineBound{End: clamp(int(binary.BigEndian.Uint32(data[offset:offset+4])), 0, width), Soft: data[offset+4] == 1} | |
| 291 | - | offset += 5 | |
| 292 | - | } | |
| 293 | - | return frame, boundaries, nil | |
| 294 | - | } | |
| 295 | - | ||
| 296 | - | // PrimaryVisibleFrame returns a deep copy of the exact visible primary frame. | |
| 297 | - | func (s *Screen) PrimaryVisibleFrame() renderer.Frame { | |
| 298 | - | if s.alternate != nil { | |
| 299 | - | return s.alternate.frame.Clone() | |
| 300 | - | } | |
| 301 | - | return s.Frame.Clone() | |
| 302 | - | } | |
| 303 | - | ||
| 304 | - | // PrimaryVisibleRows returns a deep copy of the visible primary-screen rows. | |
| 305 | - | // When the alternate screen is active, it snapshots the saved primary screen | |
| 306 | - | // rather than the currently displayed alternate frame. | |
| 307 | - | func (s *Screen) PrimaryVisibleRows() [][]renderer.Cell { | |
| 308 | - | frame := s.Frame | |
| 309 | - | if s.alternate != nil { | |
| 310 | - | frame = s.alternate.frame | |
| 311 | - | } | |
| 312 | - | rows := make([][]renderer.Cell, frame.Height) | |
| 313 | - | for y := range frame.Height { | |
| 314 | - | rows[y] = append([]renderer.Cell(nil), frame.Row(y)...) | |
| 315 | - | } | |
| 316 | - | return rows | |
| 317 | - | } | |
| 318 | - | ||
| 319 | - | // RestorePrimaryVisible replaces the primary frame from an exact visible blob. | |
| 320 | - | func (s *Screen) RestorePrimaryVisible(blob []byte) error { | |
| 321 | - | frame, boundaries, err := unmarshalVisible(blob) | |
| 322 | - | if err != nil { | |
| 323 | - | return err | |
| 324 | - | } | |
| 325 | - | b := bufferFromFrame(frame) | |
| 326 | - | b.boundaries = boundaries | |
| 327 | - | // A persisted primary frame is authoritative: restoring it directly keeps | |
| 328 | - | // exact row widths and height even when a collapsed layout chose a smaller | |
| 329 | - | // initial PTY size. A subsequent resize reconciles it with live geometry. | |
| 330 | - | if s.alternate != nil { | |
| 331 | - | s.alternate.frame = frame | |
| 332 | - | s.alternate.buffer = b | |
| 333 | - | } else { | |
| 334 | - | s.buffer = b | |
| 335 | - | s.Frame = b.frame | |
| 336 | - | } | |
| 337 | - | s.fullRedraw() | |
| 338 | - | return nil | |
| 133 | + | screen := NewScreenWithHistory(width, height, config) | |
| 134 | + | screen.history = history | |
| 135 | + | return screen, nil | |
| 339 | 136 | } | |
| @@ -1,16 +1,8 @@ | |||||
| 1 | package vt | 1 | package vt | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "encoding/binary" | |||
| 5 | "fmt" | 4 | "fmt" | ||
| 6 | "math" | 5 | "math" | ||
| 7 | - | ||||
| 8 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 9 | - | ) | |||
| 10 | - | ||||
| 11 | - | const ( | |||
| 12 | - | visibleMagic = "VTV2" | |||
| 13 | - | visibleBoundaryBytes = 5 | |||
| 14 | ) | 6 | ) | ||
| 15 | 7 | ||||
| 16 | // DecodeStats describes resources declared by one canonical VT blob. | 8 | // DecodeStats describes resources declared by one canonical VT blob. | ||
| @@ -111,229 +103,34 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist | |||||
| 111 | return h, nil | 103 | return h, nil | ||
| 112 | } | 104 | } | ||
| 113 | 105 | ||||
| 114 | - | // NewScreenWithRestoredHistory constructs a screen that owns history restored | 106 | + | // NewScreenWithRecoveryTranscript constructs a fresh blank screen whose |
| 115 | - | // directly from canonical blobs. Call RestorePrimaryVisible to install a | 107 | + | // history contains the restored bounded history followed by the recovery |
| 116 | - | // persisted primary frame before making the screen live. | 108 | + | // transcript. The transcript is decoded in full before history is restored. |
| 117 | - | func NewScreenWithRestoredHistory(width, height int, config HistoryConfig, sealed [][]byte, tail []byte) (*Screen, error) { | 109 | + | func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, sealed [][]byte, tail, transcript []byte) (*Screen, error) { |
| 118 | - | history, err := HistoryFromBlobs(config, sealed, tail) | 110 | + | transcriptView, err := UnmarshalHistory(transcript) |
| 119 | if err != nil { | 111 | if err != nil { | ||
| 120 | - | return nil, err | 112 | + | return nil, fmt.Errorf("restore recovery transcript: %w", err) |
| 121 | } | 113 | } | ||
| 122 | - | screen := NewScreenWithHistory(width, height, config) | |||
| 123 | - | screen.history = history | |||
| 124 | - | return screen, nil | |||
| 125 | - | } | |||
| 126 | 114 | ||||
| 127 | - | // MarshalVisible encodes the exact visible primary frame, including every | 115 | + | history, err := HistoryFromBlobs(config, sealed, tail) |
| 128 | - | // blank, row width, and terminal cell attribute. | 116 | + | if err != nil { |
| 129 | - | func MarshalVisible(frame renderer.Frame) ([]byte, error) { | 117 | + | return nil, err |
| 130 | - | if _, _, _, ok := visibleEncodingBudget(frame.Width, frame.Height); !ok { | |||
| 131 | - | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |||
| 132 | } | 118 | } | ||
| 133 | - | boundaries := make([]LineBound, frame.Height) | 119 | + | remainingRows := transcriptView.rows |
| 134 | - | for y := range boundaries { | 120 | + | for _, chunk := range transcriptView.chunks { |
| 135 | - | for x := frame.Width - 1; x >= 0; x-- { | 121 | + | for i, row := range chunk.rows { |
| 136 | - | if !frame.At(x, y).Equal(renderer.BlankCell()) { | 122 | + | remainingRows-- |
| 137 | - | boundaries[y].End = x + 1 | 123 | + | bound := chunk.bounds[i] |
| 138 | - | break | 124 | + | if remainingRows == 0 { |
| 125 | + | bound.Soft = false | |||
| 139 | } | 126 | } | ||
| 140 | - | } | 127 | + | if err := history.Append(row, bound); err != nil { |
| 141 | - | } | 128 | + | return nil, fmt.Errorf("restore recovery transcript: %w", err) |
| 142 | - | return marshalVisible(frame, boundaries) | |||
| 143 | - | } | |||
| 144 | - | ||||
| 145 | - | // PrimaryVisibleSnapshot is an owned copy of the primary buffer's visible | |||
| 146 | - | // state. Capture it while the screen owner is synchronized, then Marshal it | |||
| 147 | - | // after releasing that lock. | |||
| 148 | - | type PrimaryVisibleSnapshot struct { | |||
| 149 | - | frame renderer.Frame | |||
| 150 | - | boundaries []LineBound | |||
| 151 | - | } | |||
| 152 | - | ||||
| 153 | - | // PrimaryVisibleSnapshot copies the saved primary frame and its logical-line | |||
| 154 | - | // boundaries. When the alternate screen is active, the saved primary buffer is | |||
| 155 | - | // copied rather than the currently displayed alternate buffer. | |||
| 156 | - | func (s *Screen) PrimaryVisibleSnapshot() PrimaryVisibleSnapshot { | |||
| 157 | - | b := s.buffer | |||
| 158 | - | if s.alternate != nil { | |||
| 159 | - | b = s.alternate.buffer | |||
| 160 | - | } | |||
| 161 | - | if b == nil { | |||
| 162 | - | return PrimaryVisibleSnapshot{frame: s.PrimaryVisibleFrame()} | |||
| 163 | - | } | |||
| 164 | - | return PrimaryVisibleSnapshot{ | |||
| 165 | - | frame: b.frame.Clone(), | |||
| 166 | - | boundaries: append([]LineBound(nil), b.boundaries...), | |||
| 167 | - | } | |||
| 168 | - | } | |||
| 169 | - | ||||
| 170 | - | // Marshal encodes an owned primary visible snapshot without accessing live VT | |||
| 171 | - | // state. | |||
| 172 | - | func (v PrimaryVisibleSnapshot) Marshal() ([]byte, error) { | |||
| 173 | - | if len(v.boundaries) == 0 && v.frame.Height > 0 { | |||
| 174 | - | return MarshalVisible(v.frame) | |||
| 175 | - | } | |||
| 176 | - | return marshalVisible(v.frame, v.boundaries) | |||
| 177 | - | } | |||
| 178 | - | ||||
| 179 | - | // MarshalPrimaryVisible preserves the primary buffer's logical-line boundaries | |||
| 180 | - | // alongside its cells so a restored viewport can subsequently reflow. | |||
| 181 | - | // | |||
| 182 | - | // Deprecated: callers holding a pane lock should use PrimaryVisibleSnapshot | |||
| 183 | - | // and marshal the result after unlocking. | |||
| 184 | - | func (s *Screen) MarshalPrimaryVisible() ([]byte, error) { | |||
| 185 | - | return s.PrimaryVisibleSnapshot().Marshal() | |||
| 186 | - | } | |||
| 187 | - | ||||
| 188 | - | func marshalVisible(frame renderer.Frame, boundaries []LineBound) ([]byte, error) { | |||
| 189 | - | _, bytes, boundaryBytes, ok := visibleEncodingBudget(frame.Width, frame.Height) | |||
| 190 | - | if !ok || len(boundaries) != frame.Height { | |||
| 191 | - | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |||
| 192 | - | } | |||
| 193 | - | out := make([]byte, 0, int(13+bytes+boundaryBytes)) | |||
| 194 | - | out = append(out, visibleMagic...) | |||
| 195 | - | out = append(out, historyVersion) | |||
| 196 | - | out = binary.BigEndian.AppendUint32(out, uint32(frame.Width)) | |||
| 197 | - | out = binary.BigEndian.AppendUint32(out, uint32(frame.Height)) | |||
| 198 | - | for y := range frame.Height { | |||
| 199 | - | for _, cell := range frame.Row(y) { | |||
| 200 | - | if !validHistoryCell(cell) { | |||
| 201 | - | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |||
| 202 | } | 129 | } | ||
| 203 | - | out = appendHistoryCell(out, cell) | |||
| 204 | - | } | |||
| 205 | - | } | |||
| 206 | - | for _, boundary := range boundaries { | |||
| 207 | - | out = binary.BigEndian.AppendUint32(out, uint32(boundary.End)) | |||
| 208 | - | if boundary.Soft { | |||
| 209 | - | out = append(out, 1) | |||
| 210 | - | } else { | |||
| 211 | - | out = append(out, 0) | |||
| 212 | } | 130 | } | ||
| 213 | } | 131 | } | ||
| 214 | - | return out, nil | |||
| 215 | - | } | |||
| 216 | 132 | ||||
| 217 | - | // PreflightVisibleBlob validates dimensions and cell declarations without | 133 | + | screen := NewScreenWithHistory(width, height, config) |
| 218 | - | // allocating a frame. | 134 | + | screen.history = history |
| 219 | - | func PreflightVisibleBlob(data []byte) (DecodeStats, error) { | 135 | + | return screen, nil |
| 220 | - | if len(data) < 13 || string(data[:4]) != visibleMagic || data[4] != historyVersion { | |||
| 221 | - | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |||
| 222 | - | } | |||
| 223 | - | width, height := uint64(binary.BigEndian.Uint32(data[5:9])), uint64(binary.BigEndian.Uint32(data[9:13])) | |||
| 224 | - | cells, bytes, boundaryBytes, ok := visibleEncodingBudget64(width, height) | |||
| 225 | - | if !ok || bytes > math.MaxUint64-boundaryBytes || uint64(len(data)-13) != bytes+boundaryBytes { | |||
| 226 | - | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |||
| 227 | - | } | |||
| 228 | - | p := historyParser{data: data[13:]} | |||
| 229 | - | for range cells { | |||
| 230 | - | cell, ok := p.cell() | |||
| 231 | - | if !ok || !validHistoryCell(cell) { | |||
| 232 | - | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |||
| 233 | - | } | |||
| 234 | - | } | |||
| 235 | - | for range height { | |||
| 236 | - | if uint64(binary.BigEndian.Uint32(p.data[:4])) > width || p.data[4] > 1 { | |||
| 237 | - | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |||
| 238 | - | } | |||
| 239 | - | p.data = p.data[5:] | |||
| 240 | - | } | |||
| 241 | - | return DecodeStats{Rows: height, Cells: cells, Styles: cells, Bytes: bytes}, nil | |||
| 242 | - | } | |||
| 243 | - | ||||
| 244 | - | func visibleEncodingBudget(width, height int) (cells, cellBytes, boundaryBytes uint64, ok bool) { | |||
| 245 | - | if width < 0 || height < 0 { | |||
| 246 | - | return 0, 0, 0, false | |||
| 247 | - | } | |||
| 248 | - | return visibleEncodingBudget64(uint64(width), uint64(height)) | |||
| 249 | - | } | |||
| 250 | - | ||||
| 251 | - | func visibleEncodingBudget64(width, height uint64) (cells, cellBytes, boundaryBytes uint64, ok bool) { | |||
| 252 | - | if width > math.MaxUint32 || height > math.MaxUint32 || (width != 0 && height > math.MaxUint64/width) || height > math.MaxUint64/visibleBoundaryBytes { | |||
| 253 | - | return 0, 0, 0, false | |||
| 254 | - | } | |||
| 255 | - | cells = width * height | |||
| 256 | - | cellBytes, ok = historyCellByteCount(cells) | |||
| 257 | - | if !ok || cells > maxHistoryCells || cellBytes > maxHistoryDecodedBytes { | |||
| 258 | - | return 0, 0, 0, false | |||
| 259 | - | } | |||
| 260 | - | boundaryBytes = height * visibleBoundaryBytes | |||
| 261 | - | if boundaryBytes > maxVisibleBoundaryBytes { | |||
| 262 | - | return 0, 0, 0, false | |||
| 263 | - | } | |||
| 264 | - | return cells, cellBytes, boundaryBytes, true | |||
| 265 | - | } | |||
| 266 | - | ||||
| 267 | - | // UnmarshalVisible decodes a preflighted exact visible frame. | |||
| 268 | - | func UnmarshalVisible(data []byte) (renderer.Frame, error) { | |||
| 269 | - | frame, _, err := unmarshalVisible(data) | |||
| 270 | - | return frame, err | |||
| 271 | - | } | |||
| 272 | - | ||||
| 273 | - | func unmarshalVisible(data []byte) (renderer.Frame, []LineBound, error) { | |||
| 274 | - | if _, err := PreflightVisibleBlob(data); err != nil { | |||
| 275 | - | return renderer.Frame{}, nil, err | |||
| 276 | - | } | |||
| 277 | - | width, height := int(binary.BigEndian.Uint32(data[5:9])), int(binary.BigEndian.Uint32(data[9:13])) | |||
| 278 | - | frame := renderer.NewFrame(width, height) | |||
| 279 | - | cellBytes, _ := historyCellByteCount(uint64(width) * uint64(height)) | |||
| 280 | - | p := historyParser{data: data[13 : 13+cellBytes]} | |||
| 281 | - | for y := range height { | |||
| 282 | - | for x := range width { | |||
| 283 | - | cell, _ := p.cell() | |||
| 284 | - | frame.Set(x, y, cell) | |||
| 285 | - | } | |||
| 286 | - | } | |||
| 287 | - | boundaries := make([]LineBound, height) | |||
| 288 | - | offset := 13 + int(cellBytes) | |||
| 289 | - | for y := range boundaries { | |||
| 290 | - | boundaries[y] = LineBound{End: clamp(int(binary.BigEndian.Uint32(data[offset:offset+4])), 0, width), Soft: data[offset+4] == 1} | |||
| 291 | - | offset += 5 | |||
| 292 | - | } | |||
| 293 | - | return frame, boundaries, nil | |||
| 294 | - | } | |||
| 295 | - | ||||
| 296 | - | // PrimaryVisibleFrame returns a deep copy of the exact visible primary frame. | |||
| 297 | - | func (s *Screen) PrimaryVisibleFrame() renderer.Frame { | |||
| 298 | - | if s.alternate != nil { | |||
| 299 | - | return s.alternate.frame.Clone() | |||
| 300 | - | } | |||
| 301 | - | return s.Frame.Clone() | |||
| 302 | - | } | |||
| 303 | - | ||||
| 304 | - | // PrimaryVisibleRows returns a deep copy of the visible primary-screen rows. | |||
| 305 | - | // When the alternate screen is active, it snapshots the saved primary screen | |||
| 306 | - | // rather than the currently displayed alternate frame. | |||
| 307 | - | func (s *Screen) PrimaryVisibleRows() [][]renderer.Cell { | |||
| 308 | - | frame := s.Frame | |||
| 309 | - | if s.alternate != nil { | |||
| 310 | - | frame = s.alternate.frame | |||
| 311 | - | } | |||
| 312 | - | rows := make([][]renderer.Cell, frame.Height) | |||
| 313 | - | for y := range frame.Height { | |||
| 314 | - | rows[y] = append([]renderer.Cell(nil), frame.Row(y)...) | |||
| 315 | - | } | |||
| 316 | - | return rows | |||
| 317 | - | } | |||
| 318 | - | ||||
| 319 | - | // RestorePrimaryVisible replaces the primary frame from an exact visible blob. | |||
| 320 | - | func (s *Screen) RestorePrimaryVisible(blob []byte) error { | |||
| 321 | - | frame, boundaries, err := unmarshalVisible(blob) | |||
| 322 | - | if err != nil { | |||
| 323 | - | return err | |||
| 324 | - | } | |||
| 325 | - | b := bufferFromFrame(frame) | |||
| 326 | - | b.boundaries = boundaries | |||
| 327 | - | // A persisted primary frame is authoritative: restoring it directly keeps | |||
| 328 | - | // exact row widths and height even when a collapsed layout chose a smaller | |||
| 329 | - | // initial PTY size. A subsequent resize reconciles it with live geometry. | |||
| 330 | - | if s.alternate != nil { | |||
| 331 | - | s.alternate.frame = frame | |||
| 332 | - | s.alternate.buffer = b | |||
| 333 | - | } else { | |||
| 334 | - | s.buffer = b | |||
| 335 | - | s.Frame = b.frame | |||
| 336 | - | } | |||
| 337 | - | s.fullRedraw() | |||
| 338 | - | return nil | |||
| 339 | } | 136 | } | ||
D pkg/vt/snapshot_boundary_test.go +0 -43
| @@ -1,43 +0,0 @@ | |||
| 1 | - | package vt | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "encoding/binary" | |
| 5 | - | "testing" | |
| 6 | - | ||
| 7 | - | "github.com/stretchr/testify/require" | |
| 8 | - | ) | |
| 9 | - | ||
| 10 | - | func TestPreflightVisibleRejectsMalformedBoundaries(t *testing.T) { | |
| 11 | - | s := NewScreen(4, 2) | |
| 12 | - | s.Write([]byte("abcdef")) | |
| 13 | - | blob, err := s.MarshalPrimaryVisible() | |
| 14 | - | require.NoError(t, err) | |
| 15 | - | ||
| 16 | - | boundaryOffset := 13 + 2*4*historyCellBytes | |
| 17 | - | tests := []struct { | |
| 18 | - | name string | |
| 19 | - | mutate func([]byte) | |
| 20 | - | }{ | |
| 21 | - | { | |
| 22 | - | name: "boundary end exceeds row width", | |
| 23 | - | mutate: func(data []byte) { | |
| 24 | - | binary.BigEndian.PutUint32(data[boundaryOffset:boundaryOffset+4], 5) | |
| 25 | - | }, | |
| 26 | - | }, | |
| 27 | - | { | |
| 28 | - | name: "soft flag is not canonical", | |
| 29 | - | mutate: func(data []byte) { | |
| 30 | - | data[boundaryOffset+4] = 2 | |
| 31 | - | }, | |
| 32 | - | }, | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | for _, tt := range tests { | |
| 36 | - | t.Run(tt.name, func(t *testing.T) { | |
| 37 | - | data := append([]byte(nil), blob...) | |
| 38 | - | tt.mutate(data) | |
| 39 | - | _, err := PreflightVisibleBlob(data) | |
| 40 | - | require.Error(t, err) | |
| 41 | - | }) | |
| 42 | - | } | |
| 43 | - | } | |
| @@ -1,43 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "encoding/binary" | |||
| 5 | - | "testing" | |||
| 6 | - | ||||
| 7 | - | "github.com/stretchr/testify/require" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | func TestPreflightVisibleRejectsMalformedBoundaries(t *testing.T) { | |||
| 11 | - | s := NewScreen(4, 2) | |||
| 12 | - | s.Write([]byte("abcdef")) | |||
| 13 | - | blob, err := s.MarshalPrimaryVisible() | |||
| 14 | - | require.NoError(t, err) | |||
| 15 | - | ||||
| 16 | - | boundaryOffset := 13 + 2*4*historyCellBytes | |||
| 17 | - | tests := []struct { | |||
| 18 | - | name string | |||
| 19 | - | mutate func([]byte) | |||
| 20 | - | }{ | |||
| 21 | - | { | |||
| 22 | - | name: "boundary end exceeds row width", | |||
| 23 | - | mutate: func(data []byte) { | |||
| 24 | - | binary.BigEndian.PutUint32(data[boundaryOffset:boundaryOffset+4], 5) | |||
| 25 | - | }, | |||
| 26 | - | }, | |||
| 27 | - | { | |||
| 28 | - | name: "soft flag is not canonical", | |||
| 29 | - | mutate: func(data []byte) { | |||
| 30 | - | data[boundaryOffset+4] = 2 | |||
| 31 | - | }, | |||
| 32 | - | }, | |||
| 33 | - | } | |||
| 34 | - | ||||
| 35 | - | for _, tt := range tests { | |||
| 36 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 37 | - | data := append([]byte(nil), blob...) | |||
| 38 | - | tt.mutate(data) | |||
| 39 | - | _, err := PreflightVisibleBlob(data) | |||
| 40 | - | require.Error(t, err) | |||
| 41 | - | }) | |||
| 42 | - | } | |||
| 43 | - | } | |||
M pkg/vt/snapshot_test.go +0 -46
| @@ -1,7 +1,6 @@ | |||
| 1 | 1 | package vt | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "strings" | |
| 5 | 4 | "testing" | |
| 6 | 5 | ||
| 7 | 6 | "github.com/bnema/vev/pkg/renderer" | |
| @@ -79,48 +78,3 @@ func TestHistoryFromBlobsRestoresCellAccountingAndEvictsToBothBudgets(t *testing | |||
| 79 | 78 | require.Equal(t, []string{"cc"}, historyViewTexts(restored.View())) | |
| 80 | 79 | require.Equal(t, 2, restored.Cells()) | |
| 81 | 80 | } | |
| 82 | - | ||
| 83 | - | func TestPrimaryVisibleSnapshotRetainsReflowBoundaries(t *testing.T) { | |
| 84 | - | s := NewScreen(5, 3) | |
| 85 | - | s.Write([]byte("abcdefgh")) | |
| 86 | - | blob, err := s.MarshalPrimaryVisible() | |
| 87 | - | require.NoError(t, err) | |
| 88 | - | ||
| 89 | - | restored := NewScreen(1, 1) | |
| 90 | - | require.NoError(t, restored.RestorePrimaryVisible(blob)) | |
| 91 | - | restored.Resize(3, 3) | |
| 92 | - | require.Equal(t, "abc", rowString(restored.Frame.Row(0))) | |
| 93 | - | require.Equal(t, "def", rowString(restored.Frame.Row(1))) | |
| 94 | - | require.Equal(t, "gh ", rowString(restored.Frame.Row(2))) | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | func TestPrimaryVisibleRowsCopiesActivePrimaryScreen(t *testing.T) { | |
| 98 | - | s := NewScreen(6, 2) | |
| 99 | - | s.Write([]byte("hello")) | |
| 100 | - | ||
| 101 | - | rows := s.PrimaryVisibleRows() | |
| 102 | - | require.Len(t, rows, 2) | |
| 103 | - | require.Equal(t, "hello ", rowString(rows[0])) | |
| 104 | - | ||
| 105 | - | rows[0][0].Rune = 'X' | |
| 106 | - | rows[0] = nil | |
| 107 | - | ||
| 108 | - | require.Equal(t, "hello ", rowString(s.Frame.Row(0))) | |
| 109 | - | require.Equal(t, "hello ", rowString(s.PrimaryVisibleRows()[0])) | |
| 110 | - | } | |
| 111 | - | ||
| 112 | - | func TestPrimaryVisibleRowsUsesSavedPrimaryScreenWhenAlternateActive(t *testing.T) { | |
| 113 | - | s := NewScreen(8, 2) | |
| 114 | - | s.Write([]byte("primary")) | |
| 115 | - | s.Write([]byte("\x1b[?1049h")) | |
| 116 | - | s.Write([]byte("alt")) | |
| 117 | - | ||
| 118 | - | rows := s.PrimaryVisibleRows() | |
| 119 | - | require.Len(t, rows, 2) | |
| 120 | - | require.Equal(t, "primary ", rowString(rows[0])) | |
| 121 | - | require.False(t, strings.Contains(rowString(rows[0]), "alt")) | |
| 122 | - | ||
| 123 | - | rows[0][0].Rune = 'X' | |
| 124 | - | s.Write([]byte("\x1b[?1049l")) | |
| 125 | - | require.Equal(t, "primary ", rowString(s.Frame.Row(0))) | |
| 126 | - | } | |
| @@ -1,7 +1,6 @@ | |||||
| 1 | package vt | 1 | package vt | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "strings" | |||
| 5 | "testing" | 4 | "testing" | ||
| 6 | 5 | ||||
| 7 | "github.com/bnema/vev/pkg/renderer" | 6 | "github.com/bnema/vev/pkg/renderer" | ||
| @@ -79,48 +78,3 @@ func TestHistoryFromBlobsRestoresCellAccountingAndEvictsToBothBudgets(t *testing | |||||
| 79 | require.Equal(t, []string{"cc"}, historyViewTexts(restored.View())) | 78 | require.Equal(t, []string{"cc"}, historyViewTexts(restored.View())) | ||
| 80 | require.Equal(t, 2, restored.Cells()) | 79 | require.Equal(t, 2, restored.Cells()) | ||
| 81 | } | 80 | } | ||
| 82 | - | ||||
| 83 | - | func TestPrimaryVisibleSnapshotRetainsReflowBoundaries(t *testing.T) { | |||
| 84 | - | s := NewScreen(5, 3) | |||
| 85 | - | s.Write([]byte("abcdefgh")) | |||
| 86 | - | blob, err := s.MarshalPrimaryVisible() | |||
| 87 | - | require.NoError(t, err) | |||
| 88 | - | ||||
| 89 | - | restored := NewScreen(1, 1) | |||
| 90 | - | require.NoError(t, restored.RestorePrimaryVisible(blob)) | |||
| 91 | - | restored.Resize(3, 3) | |||
| 92 | - | require.Equal(t, "abc", rowString(restored.Frame.Row(0))) | |||
| 93 | - | require.Equal(t, "def", rowString(restored.Frame.Row(1))) | |||
| 94 | - | require.Equal(t, "gh ", rowString(restored.Frame.Row(2))) | |||
| 95 | - | } | |||
| 96 | - | ||||
| 97 | - | func TestPrimaryVisibleRowsCopiesActivePrimaryScreen(t *testing.T) { | |||
| 98 | - | s := NewScreen(6, 2) | |||
| 99 | - | s.Write([]byte("hello")) | |||
| 100 | - | ||||
| 101 | - | rows := s.PrimaryVisibleRows() | |||
| 102 | - | require.Len(t, rows, 2) | |||
| 103 | - | require.Equal(t, "hello ", rowString(rows[0])) | |||
| 104 | - | ||||
| 105 | - | rows[0][0].Rune = 'X' | |||
| 106 | - | rows[0] = nil | |||
| 107 | - | ||||
| 108 | - | require.Equal(t, "hello ", rowString(s.Frame.Row(0))) | |||
| 109 | - | require.Equal(t, "hello ", rowString(s.PrimaryVisibleRows()[0])) | |||
| 110 | - | } | |||
| 111 | - | ||||
| 112 | - | func TestPrimaryVisibleRowsUsesSavedPrimaryScreenWhenAlternateActive(t *testing.T) { | |||
| 113 | - | s := NewScreen(8, 2) | |||
| 114 | - | s.Write([]byte("primary")) | |||
| 115 | - | s.Write([]byte("\x1b[?1049h")) | |||
| 116 | - | s.Write([]byte("alt")) | |||
| 117 | - | ||||
| 118 | - | rows := s.PrimaryVisibleRows() | |||
| 119 | - | require.Len(t, rows, 2) | |||
| 120 | - | require.Equal(t, "primary ", rowString(rows[0])) | |||
| 121 | - | require.False(t, strings.Contains(rowString(rows[0]), "alt")) | |||
| 122 | - | ||||
| 123 | - | rows[0][0].Rune = 'X' | |||
| 124 | - | s.Write([]byte("\x1b[?1049l")) | |||
| 125 | - | require.Equal(t, "primary ", rowString(s.Frame.Row(0))) | |||
| 126 | - | } | |||