refactor(daemon): remove monolithic snapshot encoding
- commit
- 78de75a8592a074f549f54c5bf17154ec826bc9b
- author
- brice · · 2026-07-22 01:02 UTC
- parent
- 90ff4fa
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +99 -95
M internal/usecase/daemon/daemon.go +0 -3
| @@ -16,7 +16,6 @@ import ( | |||
| 16 | 16 | "github.com/bnema/vev/internal/persist" | |
| 17 | 17 | "github.com/bnema/vev/internal/ports" | |
| 18 | 18 | "github.com/bnema/vev/internal/usecase/keys" | |
| 19 | - | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 20 | 19 | ) | |
| 21 | 20 | ||
| 22 | 21 | // Scheduler debounce bounds. Idle updates use the minimum for low latency; | |
| @@ -130,7 +129,6 @@ type Daemon struct { | |||
| 130 | 129 | legacyImportMu sync.Mutex | |
| 131 | 130 | legacyImportPending map[string][]byte | |
| 132 | 131 | noticeStore ports.NoticeStore | |
| 133 | - | snapshotMarshal func(snapcodec.Session) ([]byte, error) | |
| 134 | 132 | snapshotJobs chan *snapshotCapture | |
| 135 | 133 | // snapshotWake wakes the repository scheduler when a session becomes dirty | |
| 136 | 134 | // or an attempt completes. It is never closed and producers only send | |
| @@ -377,7 +375,6 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 377 | 375 | done: make(chan struct{}), | |
| 378 | 376 | restoreDone: make(chan struct{}), | |
| 379 | 377 | animWake: make(chan struct{}, 1), | |
| 380 | - | snapshotMarshal: snapcodec.Marshal, | |
| 381 | 378 | legacyImportPending: make(map[string][]byte), | |
| 382 | 379 | snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity), | |
| 383 | 380 | snapshotWake: make(chan struct{}, 1), | |
| @@ -16,7 +16,6 @@ import ( | |||||
| 16 | "github.com/bnema/vev/internal/persist" | 16 | "github.com/bnema/vev/internal/persist" | ||
| 17 | "github.com/bnema/vev/internal/ports" | 17 | "github.com/bnema/vev/internal/ports" | ||
| 18 | "github.com/bnema/vev/internal/usecase/keys" | 18 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 19 | - | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 20 | ) | 19 | ) | ||
| 21 | 20 | ||||
| 22 | // Scheduler debounce bounds. Idle updates use the minimum for low latency; | 21 | // Scheduler debounce bounds. Idle updates use the minimum for low latency; | ||
| @@ -130,7 +129,6 @@ type Daemon struct { | |||||
| 130 | legacyImportMu sync.Mutex | 129 | legacyImportMu sync.Mutex | ||
| 131 | legacyImportPending map[string][]byte | 130 | legacyImportPending map[string][]byte | ||
| 132 | noticeStore ports.NoticeStore | 131 | noticeStore ports.NoticeStore | ||
| 133 | - | snapshotMarshal func(snapcodec.Session) ([]byte, error) | |||
| 134 | snapshotJobs chan *snapshotCapture | 132 | snapshotJobs chan *snapshotCapture | ||
| 135 | // snapshotWake wakes the repository scheduler when a session becomes dirty | 133 | // snapshotWake wakes the repository scheduler when a session becomes dirty | ||
| 136 | // or an attempt completes. It is never closed and producers only send | 134 | // or an attempt completes. It is never closed and producers only send | ||
| @@ -377,7 +375,6 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 377 | done: make(chan struct{}), | 375 | done: make(chan struct{}), | ||
| 378 | restoreDone: make(chan struct{}), | 376 | restoreDone: make(chan struct{}), | ||
| 379 | animWake: make(chan struct{}, 1), | 377 | animWake: make(chan struct{}, 1), | ||
| 380 | - | snapshotMarshal: snapcodec.Marshal, | |||
| 381 | legacyImportPending: make(map[string][]byte), | 378 | legacyImportPending: make(map[string][]byte), | ||
| 382 | snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity), | 379 | snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity), | ||
| 383 | snapshotWake: make(chan struct{}, 1), | 380 | snapshotWake: make(chan struct{}, 1), | ||
M internal/usecase/daemon/performance_benchmark_test.go +58 -30
| @@ -19,9 +19,10 @@ import ( | |||
| 19 | 19 | func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) { | |
| 20 | 20 | repository := &countingSnapshotRepository{} | |
| 21 | 21 | payload := []byte{0xff, 0x00, 0xfe} | |
| 22 | + | object := []byte{0x01, 0x02} | |
| 22 | 23 | ||
| 23 | - | require.NoError(t, repository.Publish(context.Background(), ports.SnapshotPublication{Name: "session", Manifest: payload})) | |
| 24 | - | require.Equal(t, countingSnapshotMetrics{writes: 1, bytes: uint64(len(payload))}, repository.metrics()) | |
| 24 | + | require.NoError(t, repository.Publish(context.Background(), ports.SnapshotPublication{Name: "session", Manifest: payload, Objects: []ports.SnapshotObject{{Data: object}}})) | |
| 25 | + | require.Equal(t, countingSnapshotMetrics{writes: 1, manifestBytes: uint64(len(payload)), objectBytes: uint64(len(object))}, repository.metrics()) | |
| 25 | 26 | require.Equal(t, payload, repository.lastPayload()) | |
| 26 | 27 | ||
| 27 | 28 | payload[0] = 0x01 | |
| @@ -64,7 +65,8 @@ func TestPerformanceFixtureCounters(t *testing.T) { | |||
| 64 | 65 | fixture.captureSnapshot() | |
| 65 | 66 | metrics = fixture.metrics() | |
| 66 | 67 | require.Equal(t, uint64(1), metrics.snapshotWrites) | |
| 67 | - | require.Positive(t, metrics.snapshotBytes) | |
| 68 | + | require.Positive(t, metrics.snapshotManifestBytes) | |
| 69 | + | require.Positive(t, metrics.snapshotObjectBytes) | |
| 68 | 70 | ||
| 69 | 71 | fixture.enterCopySearch() | |
| 70 | 72 | require.Positive(t, fixture.searchMatches()) | |
| @@ -217,14 +219,14 @@ func BenchmarkDaemonHistorySnapshotCapture(b *testing.B) { | |||
| 217 | 219 | benchmarkDaemonSnapshotCapture(b) | |
| 218 | 220 | } | |
| 219 | 221 | ||
| 220 | - | // BenchmarkDaemonHistorySnapshotEncode measures v3 encoding from an already | |
| 221 | - | // immutable capture, without queueing or persistence. | |
| 222 | + | // BenchmarkDaemonHistorySnapshotEncode measures incremental publication from an | |
| 223 | + | // already immutable capture, without queueing or persistence. | |
| 222 | 224 | func BenchmarkDaemonHistorySnapshotEncode(b *testing.B) { | |
| 223 | 225 | benchmarkDaemonSnapshotEncode(b) | |
| 224 | 226 | } | |
| 225 | 227 | ||
| 226 | 228 | // BenchmarkDaemonHistorySnapshotRestore measures terminal-state restoration | |
| 227 | - | // from a validated v3 pane manifest without spawning PTY or session workers. | |
| 229 | + | // from a validated incremental manifest without spawning PTY or session workers. | |
| 228 | 230 | func BenchmarkDaemonHistorySnapshotRestore(b *testing.B) { | |
| 229 | 231 | benchmarkDaemonSnapshotRestore(b) | |
| 230 | 232 | } | |
| @@ -327,7 +329,7 @@ func benchmarkDaemonSnapshotCapture(b *testing.B) { | |||
| 327 | 329 | b.Fatal("invalid snapshot capture fixture") | |
| 328 | 330 | } | |
| 329 | 331 | firstPane := first.tabs[0].panes[0] | |
| 330 | - | firstChunk := firstPane.history.Chunk(0) | |
| 332 | + | firstChunk := firstPane.sealed.Chunk(0) | |
| 331 | 333 | captures := 0 | |
| 332 | 334 | var last *snapshotCapture | |
| 333 | 335 | b.ReportAllocs() | |
| @@ -341,7 +343,7 @@ func benchmarkDaemonSnapshotCapture(b *testing.B) { | |||
| 341 | 343 | } | |
| 342 | 344 | b.StopTimer() | |
| 343 | 345 | lastPane := capturePaneByID(last, firstPane.id) | |
| 344 | - | if captures != b.N || !validBenchmarkCapture(last) || lastPane == nil || lastPane.history.Chunk(0) != firstChunk { | |
| 346 | + | if captures != b.N || !validBenchmarkCapture(last) || lastPane == nil || lastPane.sealed.Chunk(0) != firstChunk { | |
| 345 | 347 | b.Fatal("snapshot capture did not preserve immutable sealed history") | |
| 346 | 348 | } | |
| 347 | 349 | b.ReportMetric(float64(capturePaneCount(last)), "capturepanes/op") | |
| @@ -354,34 +356,36 @@ func benchmarkDaemonSnapshotEncode(b *testing.B) { | |||
| 354 | 356 | b.Run(topology.name, func(b *testing.B) { | |
| 355 | 357 | fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000}) | |
| 356 | 358 | if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) { | |
| 357 | - | b.Fatal("invalid daemon snapshot encode fixture") | |
| 359 | + | b.Fatal("invalid daemon snapshot publication fixture") | |
| 358 | 360 | } | |
| 359 | 361 | capture, ok := fixture.d.captureSnapshotState(fixture.sess, 1) | |
| 360 | 362 | if !ok || !validBenchmarkCapture(capture) { | |
| 361 | - | b.Fatal("invalid snapshot encode fixture") | |
| 363 | + | b.Fatal("invalid snapshot publication fixture") | |
| 362 | 364 | } | |
| 363 | - | encoded, err := fixture.d.encodeSnapshotCapture(capture) | |
| 365 | + | publication, err := fixture.d.incrementalPublication(capture) | |
| 364 | 366 | if err != nil { | |
| 365 | 367 | b.Fatal(err) | |
| 366 | 368 | } | |
| 367 | - | if _, err := snapcodec.Unmarshal(encoded); err != nil { | |
| 368 | - | b.Fatalf("invalid v3 snapshot encode fixture: %v", err) | |
| 369 | + | if _, err := snapcodec.UnmarshalManifest(publication.Manifest); err != nil || len(publication.Objects) == 0 { | |
| 370 | + | b.Fatalf("invalid incremental snapshot publication fixture: %v", err) | |
| 369 | 371 | } | |
| 370 | - | var last []byte | |
| 372 | + | var last ports.SnapshotPublication | |
| 371 | 373 | b.ReportAllocs() | |
| 372 | - | b.SetBytes(int64(len(encoded))) | |
| 374 | + | b.SetBytes(int64(snapshotPublicationBytes(publication))) | |
| 373 | 375 | b.ResetTimer() | |
| 374 | 376 | for range b.N { | |
| 375 | - | last, err = fixture.d.encodeSnapshotCapture(capture) | |
| 377 | + | last, err = fixture.d.incrementalPublication(capture) | |
| 376 | 378 | if err != nil { | |
| 377 | 379 | b.Fatal(err) | |
| 378 | 380 | } | |
| 379 | 381 | } | |
| 380 | 382 | b.StopTimer() | |
| 381 | - | if _, err := snapcodec.Unmarshal(last); err != nil { | |
| 382 | - | b.Fatalf("snapshot encoder produced invalid v3 data: %v", err) | |
| 383 | + | if _, err := snapcodec.UnmarshalManifest(last.Manifest); err != nil { | |
| 384 | + | b.Fatalf("snapshot publication produced invalid manifest: %v", err) | |
| 383 | 385 | } | |
| 384 | - | b.ReportMetric(float64(len(last)), "snapshotbytes/op") | |
| 386 | + | b.ReportMetric(float64(len(last.Manifest)), "manifestbytes/op") | |
| 387 | + | b.ReportMetric(float64(snapshotObjectBytes(last)), "objectbytes/op") | |
| 388 | + | b.ReportMetric(float64(len(last.Objects)), "objects/op") | |
| 385 | 389 | }) | |
| 386 | 390 | } | |
| 387 | 391 | } | |
| @@ -395,17 +399,17 @@ func benchmarkDaemonSnapshotRestore(b *testing.B) { | |||
| 395 | 399 | if !ok || !validBenchmarkCapture(capture) { | |
| 396 | 400 | b.Fatal("invalid snapshot restore fixture") | |
| 397 | 401 | } | |
| 398 | - | encoded, err := fixture.d.encodeSnapshotCapture(capture) | |
| 402 | + | publication, err := fixture.d.incrementalPublication(capture) | |
| 399 | 403 | if err != nil { | |
| 400 | 404 | b.Fatal(err) | |
| 401 | 405 | } | |
| 402 | - | snapshot, err := snapcodec.Unmarshal(encoded) | |
| 406 | + | snapshot, err := sessionFromGeneration(snapshotGeneration(publication)) | |
| 403 | 407 | if err != nil || len(snapshot.Tabs) != 1 || len(snapshot.Tabs[0].Panes) == 0 { | |
| 404 | - | b.Fatalf("invalid v3 snapshot restore fixture: %v", err) | |
| 408 | + | b.Fatalf("invalid incremental snapshot restore fixture: %v", err) | |
| 405 | 409 | } | |
| 406 | 410 | paneSnapshot := snapshot.Tabs[0].Panes[0] | |
| 407 | 411 | b.ReportAllocs() | |
| 408 | - | b.SetBytes(int64(len(encoded))) | |
| 412 | + | b.SetBytes(int64(snapshotPublicationBytes(publication))) | |
| 409 | 413 | b.ResetTimer() | |
| 410 | 414 | for range b.N { | |
| 411 | 415 | if err := restorePaneTerminal(fixture.activePane, paneSnapshot); err != nil { | |
| @@ -423,7 +427,27 @@ func benchmarkDaemonSnapshotRestore(b *testing.B) { | |||
| 423 | 427 | } | |
| 424 | 428 | ||
| 425 | 429 | func validBenchmarkCapture(capture *snapshotCapture) bool { | |
| 426 | - | return capture != nil && len(capture.tabs) > 0 && len(capture.tabs[0].panes) > 0 && capture.tabs[0].panes[0].history.Len() == 10_000 && capture.tabs[0].panes[0].history.ChunkCount() > 0 | |
| 430 | + | return capture != nil && len(capture.tabs) > 0 && len(capture.tabs[0].panes) > 0 && capture.tabs[0].panes[0].sealed.Len() == 10_000 && capture.tabs[0].panes[0].sealed.ChunkCount() > 0 | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | func snapshotGeneration(publication ports.SnapshotPublication) ports.SnapshotGeneration { | |
| 434 | + | objects := make(map[ports.SnapshotDigest][]byte, len(publication.Objects)) | |
| 435 | + | for _, object := range publication.Objects { | |
| 436 | + | objects[object.Digest] = object.Data | |
| 437 | + | } | |
| 438 | + | return ports.SnapshotGeneration{Name: publication.Name, Generation: publication.Generation, Manifest: publication.Manifest, Objects: objects} | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | func snapshotObjectBytes(publication ports.SnapshotPublication) uint64 { | |
| 442 | + | var bytes uint64 | |
| 443 | + | for _, object := range publication.Objects { | |
| 444 | + | bytes += uint64(len(object.Data)) | |
| 445 | + | } | |
| 446 | + | return bytes | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | func snapshotPublicationBytes(publication ports.SnapshotPublication) uint64 { | |
| 450 | + | return uint64(len(publication.Manifest)) + snapshotObjectBytes(publication) | |
| 427 | 451 | } | |
| 428 | 452 | ||
| 429 | 453 | func capturePaneCount(capture *snapshotCapture) int { | |
| @@ -474,7 +498,8 @@ func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations | |||
| 474 | 498 | b.ReportMetric(float64(metrics.outputBytes)/perOperation, "outputbytes/op") | |
| 475 | 499 | b.ReportMetric(float64(metrics.outputPayloadBytes)/perOperation, "framepayloadbytes/op") | |
| 476 | 500 | b.ReportMetric(float64(metrics.snapshotWrites)/perOperation, "snapshotwrites/op") | |
| 477 | - | b.ReportMetric(float64(metrics.snapshotBytes)/perOperation, "snapshotbytes/op") | |
| 501 | + | b.ReportMetric(float64(metrics.snapshotManifestBytes)/perOperation, "snapshotmanifestbytes/op") | |
| 502 | + | b.ReportMetric(float64(metrics.snapshotObjectBytes)/perOperation, "snapshotobjectbytes/op") | |
| 478 | 503 | b.ReportMetric(float64(metrics.coordinatorInvalidations)/perOperation, "coordinatorinvalidations/op") | |
| 479 | 504 | b.ReportMetric(float64(metrics.coordinatorWakes)/perOperation, "coordinatorwakes/op") | |
| 480 | 505 | b.ReportMetric(float64(metrics.coordinatorCoalesced)/perOperation, "coordinatorcoalesced/op") | |
| @@ -588,7 +613,8 @@ type performanceMetrics struct { | |||
| 588 | 613 | outputBytes uint64 | |
| 589 | 614 | outputPayloadBytes uint64 | |
| 590 | 615 | snapshotWrites uint64 | |
| 591 | - | snapshotBytes uint64 | |
| 616 | + | snapshotManifestBytes uint64 | |
| 617 | + | snapshotObjectBytes uint64 | |
| 592 | 618 | coordinatorInvalidations uint64 | |
| 593 | 619 | coordinatorWakes uint64 | |
| 594 | 620 | coordinatorCoalesced uint64 | |
| @@ -947,7 +973,7 @@ func (f *performanceFixture) metrics() performanceMetrics { | |||
| 947 | 973 | snapshots := f.snapshots.metrics() | |
| 948 | 974 | metrics := performanceMetrics{ | |
| 949 | 975 | outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes, | |
| 950 | - | snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes, | |
| 976 | + | snapshotWrites: snapshots.writes, snapshotManifestBytes: snapshots.manifestBytes, snapshotObjectBytes: snapshots.objectBytes, | |
| 951 | 977 | renderCaptures: f.renderCaptures, renderCompositions: f.renderCompositions, renderEmissions: f.renderEmissions, | |
| 952 | 978 | resizeRequests: f.resizeRequests, resizeCommits: f.resizeCommits, resizeCommitNanos: f.resizeCommitNanos, | |
| 953 | 979 | ptyFailures: f.ptyFailures, ptyRetries: f.ptyRetries, | |
| @@ -1011,7 +1037,9 @@ func (t *countingOutputTransport) lastPayload() []byte { | |||
| 1011 | 1037 | return t.last | |
| 1012 | 1038 | } | |
| 1013 | 1039 | ||
| 1014 | - | type countingSnapshotMetrics struct{ writes, bytes uint64 } | |
| 1040 | + | type countingSnapshotMetrics struct { | |
| 1041 | + | writes, manifestBytes, objectBytes uint64 | |
| 1042 | + | } | |
| 1015 | 1043 | type countingSnapshotRepository struct { | |
| 1016 | 1044 | mu sync.Mutex | |
| 1017 | 1045 | countingSnapshotMetrics | |
| @@ -1021,9 +1049,9 @@ type countingSnapshotRepository struct { | |||
| 1021 | 1049 | func (s *countingSnapshotRepository) Publish(_ context.Context, publication ports.SnapshotPublication) error { | |
| 1022 | 1050 | s.mu.Lock() | |
| 1023 | 1051 | s.writes++ | |
| 1024 | - | s.bytes += uint64(len(publication.Manifest)) | |
| 1052 | + | s.manifestBytes += uint64(len(publication.Manifest)) | |
| 1025 | 1053 | for _, object := range publication.Objects { | |
| 1026 | - | s.bytes += uint64(len(object.Data)) | |
| 1054 | + | s.objectBytes += uint64(len(object.Data)) | |
| 1027 | 1055 | } | |
| 1028 | 1056 | s.last = publication.Manifest | |
| 1029 | 1057 | s.mu.Unlock() | |
| @@ -19,9 +19,10 @@ import ( | |||||
| 19 | func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) { | 19 | func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) { | ||
| 20 | repository := &countingSnapshotRepository{} | 20 | repository := &countingSnapshotRepository{} | ||
| 21 | payload := []byte{0xff, 0x00, 0xfe} | 21 | payload := []byte{0xff, 0x00, 0xfe} | ||
| 22 | + | object := []byte{0x01, 0x02} | |||
| 22 | 23 | ||||
| 23 | - | require.NoError(t, repository.Publish(context.Background(), ports.SnapshotPublication{Name: "session", Manifest: payload})) | 24 | + | require.NoError(t, repository.Publish(context.Background(), ports.SnapshotPublication{Name: "session", Manifest: payload, Objects: []ports.SnapshotObject{{Data: object}}})) |
| 24 | - | require.Equal(t, countingSnapshotMetrics{writes: 1, bytes: uint64(len(payload))}, repository.metrics()) | 25 | + | require.Equal(t, countingSnapshotMetrics{writes: 1, manifestBytes: uint64(len(payload)), objectBytes: uint64(len(object))}, repository.metrics()) |
| 25 | require.Equal(t, payload, repository.lastPayload()) | 26 | require.Equal(t, payload, repository.lastPayload()) | ||
| 26 | 27 | ||||
| 27 | payload[0] = 0x01 | 28 | payload[0] = 0x01 | ||
| @@ -64,7 +65,8 @@ func TestPerformanceFixtureCounters(t *testing.T) { | |||||
| 64 | fixture.captureSnapshot() | 65 | fixture.captureSnapshot() | ||
| 65 | metrics = fixture.metrics() | 66 | metrics = fixture.metrics() | ||
| 66 | require.Equal(t, uint64(1), metrics.snapshotWrites) | 67 | require.Equal(t, uint64(1), metrics.snapshotWrites) | ||
| 67 | - | require.Positive(t, metrics.snapshotBytes) | 68 | + | require.Positive(t, metrics.snapshotManifestBytes) |
| 69 | + | require.Positive(t, metrics.snapshotObjectBytes) | |||
| 68 | 70 | ||||
| 69 | fixture.enterCopySearch() | 71 | fixture.enterCopySearch() | ||
| 70 | require.Positive(t, fixture.searchMatches()) | 72 | require.Positive(t, fixture.searchMatches()) | ||
| @@ -217,14 +219,14 @@ func BenchmarkDaemonHistorySnapshotCapture(b *testing.B) { | |||||
| 217 | benchmarkDaemonSnapshotCapture(b) | 219 | benchmarkDaemonSnapshotCapture(b) | ||
| 218 | } | 220 | } | ||
| 219 | 221 | ||||
| 220 | - | // BenchmarkDaemonHistorySnapshotEncode measures v3 encoding from an already | 222 | + | // BenchmarkDaemonHistorySnapshotEncode measures incremental publication from an |
| 221 | - | // immutable capture, without queueing or persistence. | 223 | + | // already immutable capture, without queueing or persistence. |
| 222 | func BenchmarkDaemonHistorySnapshotEncode(b *testing.B) { | 224 | func BenchmarkDaemonHistorySnapshotEncode(b *testing.B) { | ||
| 223 | benchmarkDaemonSnapshotEncode(b) | 225 | benchmarkDaemonSnapshotEncode(b) | ||
| 224 | } | 226 | } | ||
| 225 | 227 | ||||
| 226 | // BenchmarkDaemonHistorySnapshotRestore measures terminal-state restoration | 228 | // BenchmarkDaemonHistorySnapshotRestore measures terminal-state restoration | ||
| 227 | - | // from a validated v3 pane manifest without spawning PTY or session workers. | 229 | + | // from a validated incremental manifest without spawning PTY or session workers. |
| 228 | func BenchmarkDaemonHistorySnapshotRestore(b *testing.B) { | 230 | func BenchmarkDaemonHistorySnapshotRestore(b *testing.B) { | ||
| 229 | benchmarkDaemonSnapshotRestore(b) | 231 | benchmarkDaemonSnapshotRestore(b) | ||
| 230 | } | 232 | } | ||
| @@ -327,7 +329,7 @@ func benchmarkDaemonSnapshotCapture(b *testing.B) { | |||||
| 327 | b.Fatal("invalid snapshot capture fixture") | 329 | b.Fatal("invalid snapshot capture fixture") | ||
| 328 | } | 330 | } | ||
| 329 | firstPane := first.tabs[0].panes[0] | 331 | firstPane := first.tabs[0].panes[0] | ||
| 330 | - | firstChunk := firstPane.history.Chunk(0) | 332 | + | firstChunk := firstPane.sealed.Chunk(0) |
| 331 | captures := 0 | 333 | captures := 0 | ||
| 332 | var last *snapshotCapture | 334 | var last *snapshotCapture | ||
| 333 | b.ReportAllocs() | 335 | b.ReportAllocs() | ||
| @@ -341,7 +343,7 @@ func benchmarkDaemonSnapshotCapture(b *testing.B) { | |||||
| 341 | } | 343 | } | ||
| 342 | b.StopTimer() | 344 | b.StopTimer() | ||
| 343 | lastPane := capturePaneByID(last, firstPane.id) | 345 | lastPane := capturePaneByID(last, firstPane.id) | ||
| 344 | - | if captures != b.N || !validBenchmarkCapture(last) || lastPane == nil || lastPane.history.Chunk(0) != firstChunk { | 346 | + | if captures != b.N || !validBenchmarkCapture(last) || lastPane == nil || lastPane.sealed.Chunk(0) != firstChunk { |
| 345 | b.Fatal("snapshot capture did not preserve immutable sealed history") | 347 | b.Fatal("snapshot capture did not preserve immutable sealed history") | ||
| 346 | } | 348 | } | ||
| 347 | b.ReportMetric(float64(capturePaneCount(last)), "capturepanes/op") | 349 | b.ReportMetric(float64(capturePaneCount(last)), "capturepanes/op") | ||
| @@ -354,34 +356,36 @@ func benchmarkDaemonSnapshotEncode(b *testing.B) { | |||||
| 354 | b.Run(topology.name, func(b *testing.B) { | 356 | b.Run(topology.name, func(b *testing.B) { | ||
| 355 | fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000}) | 357 | fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000}) | ||
| 356 | if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) { | 358 | if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) { | ||
| 357 | - | b.Fatal("invalid daemon snapshot encode fixture") | 359 | + | b.Fatal("invalid daemon snapshot publication fixture") |
| 358 | } | 360 | } | ||
| 359 | capture, ok := fixture.d.captureSnapshotState(fixture.sess, 1) | 361 | capture, ok := fixture.d.captureSnapshotState(fixture.sess, 1) | ||
| 360 | if !ok || !validBenchmarkCapture(capture) { | 362 | if !ok || !validBenchmarkCapture(capture) { | ||
| 361 | - | b.Fatal("invalid snapshot encode fixture") | 363 | + | b.Fatal("invalid snapshot publication fixture") |
| 362 | } | 364 | } | ||
| 363 | - | encoded, err := fixture.d.encodeSnapshotCapture(capture) | 365 | + | publication, err := fixture.d.incrementalPublication(capture) |
| 364 | if err != nil { | 366 | if err != nil { | ||
| 365 | b.Fatal(err) | 367 | b.Fatal(err) | ||
| 366 | } | 368 | } | ||
| 367 | - | if _, err := snapcodec.Unmarshal(encoded); err != nil { | 369 | + | if _, err := snapcodec.UnmarshalManifest(publication.Manifest); err != nil || len(publication.Objects) == 0 { |
| 368 | - | b.Fatalf("invalid v3 snapshot encode fixture: %v", err) | 370 | + | b.Fatalf("invalid incremental snapshot publication fixture: %v", err) |
| 369 | } | 371 | } | ||
| 370 | - | var last []byte | 372 | + | var last ports.SnapshotPublication |
| 371 | b.ReportAllocs() | 373 | b.ReportAllocs() | ||
| 372 | - | b.SetBytes(int64(len(encoded))) | 374 | + | b.SetBytes(int64(snapshotPublicationBytes(publication))) |
| 373 | b.ResetTimer() | 375 | b.ResetTimer() | ||
| 374 | for range b.N { | 376 | for range b.N { | ||
| 375 | - | last, err = fixture.d.encodeSnapshotCapture(capture) | 377 | + | last, err = fixture.d.incrementalPublication(capture) |
| 376 | if err != nil { | 378 | if err != nil { | ||
| 377 | b.Fatal(err) | 379 | b.Fatal(err) | ||
| 378 | } | 380 | } | ||
| 379 | } | 381 | } | ||
| 380 | b.StopTimer() | 382 | b.StopTimer() | ||
| 381 | - | if _, err := snapcodec.Unmarshal(last); err != nil { | 383 | + | if _, err := snapcodec.UnmarshalManifest(last.Manifest); err != nil { |
| 382 | - | b.Fatalf("snapshot encoder produced invalid v3 data: %v", err) | 384 | + | b.Fatalf("snapshot publication produced invalid manifest: %v", err) |
| 383 | } | 385 | } | ||
| 384 | - | b.ReportMetric(float64(len(last)), "snapshotbytes/op") | 386 | + | b.ReportMetric(float64(len(last.Manifest)), "manifestbytes/op") |
| 387 | + | b.ReportMetric(float64(snapshotObjectBytes(last)), "objectbytes/op") | |||
| 388 | + | b.ReportMetric(float64(len(last.Objects)), "objects/op") | |||
| 385 | }) | 389 | }) | ||
| 386 | } | 390 | } | ||
| 387 | } | 391 | } | ||
| @@ -395,17 +399,17 @@ func benchmarkDaemonSnapshotRestore(b *testing.B) { | |||||
| 395 | if !ok || !validBenchmarkCapture(capture) { | 399 | if !ok || !validBenchmarkCapture(capture) { | ||
| 396 | b.Fatal("invalid snapshot restore fixture") | 400 | b.Fatal("invalid snapshot restore fixture") | ||
| 397 | } | 401 | } | ||
| 398 | - | encoded, err := fixture.d.encodeSnapshotCapture(capture) | 402 | + | publication, err := fixture.d.incrementalPublication(capture) |
| 399 | if err != nil { | 403 | if err != nil { | ||
| 400 | b.Fatal(err) | 404 | b.Fatal(err) | ||
| 401 | } | 405 | } | ||
| 402 | - | snapshot, err := snapcodec.Unmarshal(encoded) | 406 | + | snapshot, err := sessionFromGeneration(snapshotGeneration(publication)) |
| 403 | if err != nil || len(snapshot.Tabs) != 1 || len(snapshot.Tabs[0].Panes) == 0 { | 407 | if err != nil || len(snapshot.Tabs) != 1 || len(snapshot.Tabs[0].Panes) == 0 { | ||
| 404 | - | b.Fatalf("invalid v3 snapshot restore fixture: %v", err) | 408 | + | b.Fatalf("invalid incremental snapshot restore fixture: %v", err) |
| 405 | } | 409 | } | ||
| 406 | paneSnapshot := snapshot.Tabs[0].Panes[0] | 410 | paneSnapshot := snapshot.Tabs[0].Panes[0] | ||
| 407 | b.ReportAllocs() | 411 | b.ReportAllocs() | ||
| 408 | - | b.SetBytes(int64(len(encoded))) | 412 | + | b.SetBytes(int64(snapshotPublicationBytes(publication))) |
| 409 | b.ResetTimer() | 413 | b.ResetTimer() | ||
| 410 | for range b.N { | 414 | for range b.N { | ||
| 411 | if err := restorePaneTerminal(fixture.activePane, paneSnapshot); err != nil { | 415 | if err := restorePaneTerminal(fixture.activePane, paneSnapshot); err != nil { | ||
| @@ -423,7 +427,27 @@ func benchmarkDaemonSnapshotRestore(b *testing.B) { | |||||
| 423 | } | 427 | } | ||
| 424 | 428 | ||||
| 425 | func validBenchmarkCapture(capture *snapshotCapture) bool { | 429 | func validBenchmarkCapture(capture *snapshotCapture) bool { | ||
| 426 | - | return capture != nil && len(capture.tabs) > 0 && len(capture.tabs[0].panes) > 0 && capture.tabs[0].panes[0].history.Len() == 10_000 && capture.tabs[0].panes[0].history.ChunkCount() > 0 | 430 | + | return capture != nil && len(capture.tabs) > 0 && len(capture.tabs[0].panes) > 0 && capture.tabs[0].panes[0].sealed.Len() == 10_000 && capture.tabs[0].panes[0].sealed.ChunkCount() > 0 |
| 431 | + | } | |||
| 432 | + | ||||
| 433 | + | func snapshotGeneration(publication ports.SnapshotPublication) ports.SnapshotGeneration { | |||
| 434 | + | objects := make(map[ports.SnapshotDigest][]byte, len(publication.Objects)) | |||
| 435 | + | for _, object := range publication.Objects { | |||
| 436 | + | objects[object.Digest] = object.Data | |||
| 437 | + | } | |||
| 438 | + | return ports.SnapshotGeneration{Name: publication.Name, Generation: publication.Generation, Manifest: publication.Manifest, Objects: objects} | |||
| 439 | + | } | |||
| 440 | + | ||||
| 441 | + | func snapshotObjectBytes(publication ports.SnapshotPublication) uint64 { | |||
| 442 | + | var bytes uint64 | |||
| 443 | + | for _, object := range publication.Objects { | |||
| 444 | + | bytes += uint64(len(object.Data)) | |||
| 445 | + | } | |||
| 446 | + | return bytes | |||
| 447 | + | } | |||
| 448 | + | ||||
| 449 | + | func snapshotPublicationBytes(publication ports.SnapshotPublication) uint64 { | |||
| 450 | + | return uint64(len(publication.Manifest)) + snapshotObjectBytes(publication) | |||
| 427 | } | 451 | } | ||
| 428 | 452 | ||||
| 429 | func capturePaneCount(capture *snapshotCapture) int { | 453 | func capturePaneCount(capture *snapshotCapture) int { | ||
| @@ -474,7 +498,8 @@ func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations | |||||
| 474 | b.ReportMetric(float64(metrics.outputBytes)/perOperation, "outputbytes/op") | 498 | b.ReportMetric(float64(metrics.outputBytes)/perOperation, "outputbytes/op") | ||
| 475 | b.ReportMetric(float64(metrics.outputPayloadBytes)/perOperation, "framepayloadbytes/op") | 499 | b.ReportMetric(float64(metrics.outputPayloadBytes)/perOperation, "framepayloadbytes/op") | ||
| 476 | b.ReportMetric(float64(metrics.snapshotWrites)/perOperation, "snapshotwrites/op") | 500 | b.ReportMetric(float64(metrics.snapshotWrites)/perOperation, "snapshotwrites/op") | ||
| 477 | - | b.ReportMetric(float64(metrics.snapshotBytes)/perOperation, "snapshotbytes/op") | 501 | + | b.ReportMetric(float64(metrics.snapshotManifestBytes)/perOperation, "snapshotmanifestbytes/op") |
| 502 | + | b.ReportMetric(float64(metrics.snapshotObjectBytes)/perOperation, "snapshotobjectbytes/op") | |||
| 478 | b.ReportMetric(float64(metrics.coordinatorInvalidations)/perOperation, "coordinatorinvalidations/op") | 503 | b.ReportMetric(float64(metrics.coordinatorInvalidations)/perOperation, "coordinatorinvalidations/op") | ||
| 479 | b.ReportMetric(float64(metrics.coordinatorWakes)/perOperation, "coordinatorwakes/op") | 504 | b.ReportMetric(float64(metrics.coordinatorWakes)/perOperation, "coordinatorwakes/op") | ||
| 480 | b.ReportMetric(float64(metrics.coordinatorCoalesced)/perOperation, "coordinatorcoalesced/op") | 505 | b.ReportMetric(float64(metrics.coordinatorCoalesced)/perOperation, "coordinatorcoalesced/op") | ||
| @@ -588,7 +613,8 @@ type performanceMetrics struct { | |||||
| 588 | outputBytes uint64 | 613 | outputBytes uint64 | ||
| 589 | outputPayloadBytes uint64 | 614 | outputPayloadBytes uint64 | ||
| 590 | snapshotWrites uint64 | 615 | snapshotWrites uint64 | ||
| 591 | - | snapshotBytes uint64 | 616 | + | snapshotManifestBytes uint64 |
| 617 | + | snapshotObjectBytes uint64 | |||
| 592 | coordinatorInvalidations uint64 | 618 | coordinatorInvalidations uint64 | ||
| 593 | coordinatorWakes uint64 | 619 | coordinatorWakes uint64 | ||
| 594 | coordinatorCoalesced uint64 | 620 | coordinatorCoalesced uint64 | ||
| @@ -947,7 +973,7 @@ func (f *performanceFixture) metrics() performanceMetrics { | |||||
| 947 | snapshots := f.snapshots.metrics() | 973 | snapshots := f.snapshots.metrics() | ||
| 948 | metrics := performanceMetrics{ | 974 | metrics := performanceMetrics{ | ||
| 949 | outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes, | 975 | outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes, | ||
| 950 | - | snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes, | 976 | + | snapshotWrites: snapshots.writes, snapshotManifestBytes: snapshots.manifestBytes, snapshotObjectBytes: snapshots.objectBytes, |
| 951 | renderCaptures: f.renderCaptures, renderCompositions: f.renderCompositions, renderEmissions: f.renderEmissions, | 977 | renderCaptures: f.renderCaptures, renderCompositions: f.renderCompositions, renderEmissions: f.renderEmissions, | ||
| 952 | resizeRequests: f.resizeRequests, resizeCommits: f.resizeCommits, resizeCommitNanos: f.resizeCommitNanos, | 978 | resizeRequests: f.resizeRequests, resizeCommits: f.resizeCommits, resizeCommitNanos: f.resizeCommitNanos, | ||
| 953 | ptyFailures: f.ptyFailures, ptyRetries: f.ptyRetries, | 979 | ptyFailures: f.ptyFailures, ptyRetries: f.ptyRetries, | ||
| @@ -1011,7 +1037,9 @@ func (t *countingOutputTransport) lastPayload() []byte { | |||||
| 1011 | return t.last | 1037 | return t.last | ||
| 1012 | } | 1038 | } | ||
| 1013 | 1039 | ||||
| 1014 | - | type countingSnapshotMetrics struct{ writes, bytes uint64 } | 1040 | + | type countingSnapshotMetrics struct { |
| 1041 | + | writes, manifestBytes, objectBytes uint64 | |||
| 1042 | + | } | |||
| 1015 | type countingSnapshotRepository struct { | 1043 | type countingSnapshotRepository struct { | ||
| 1016 | mu sync.Mutex | 1044 | mu sync.Mutex | ||
| 1017 | countingSnapshotMetrics | 1045 | countingSnapshotMetrics | ||
| @@ -1021,9 +1049,9 @@ type countingSnapshotRepository struct { | |||||
| 1021 | func (s *countingSnapshotRepository) Publish(_ context.Context, publication ports.SnapshotPublication) error { | 1049 | func (s *countingSnapshotRepository) Publish(_ context.Context, publication ports.SnapshotPublication) error { | ||
| 1022 | s.mu.Lock() | 1050 | s.mu.Lock() | ||
| 1023 | s.writes++ | 1051 | s.writes++ | ||
| 1024 | - | s.bytes += uint64(len(publication.Manifest)) | 1052 | + | s.manifestBytes += uint64(len(publication.Manifest)) |
| 1025 | for _, object := range publication.Objects { | 1053 | for _, object := range publication.Objects { | ||
| 1026 | - | s.bytes += uint64(len(object.Data)) | 1054 | + | s.objectBytes += uint64(len(object.Data)) |
| 1027 | } | 1055 | } | ||
| 1028 | s.last = publication.Manifest | 1056 | s.last = publication.Manifest | ||
| 1029 | s.mu.Unlock() | 1057 | s.mu.Unlock() | ||
M internal/usecase/daemon/snapshot.go +4 -35
| @@ -60,12 +60,10 @@ type snapshotCapturePane struct { | |||
| 60 | 60 | id layout.PaneID | |
| 61 | 61 | stableID string | |
| 62 | 62 | cwd string | |
| 63 | - | // history is retained for the v3 compatibility encoder. Incremental | |
| 64 | - | // captures keep sealed identities and a copied tail separately so capture | |
| 65 | - | // never rotates a live mutable tail merely to persist it. | |
| 66 | - | history vt.HistoryView | |
| 67 | - | sealed vt.HistorySnapshotView | |
| 68 | - | tail vt.HistoryView | |
| 63 | + | // Sealed identities and the copied mutable tail are retained separately, so | |
| 64 | + | // capture never rotates a live mutable tail merely to persist it. | |
| 65 | + | sealed vt.HistorySnapshotView | |
| 66 | + | tail vt.HistoryView | |
| 69 | 67 | // visible is an owned copy taken while pane.mu is held. It must be | |
| 70 | 68 | // marshaled only after that lock has been released. | |
| 71 | 69 | visible vt.PrimaryVisibleSnapshot | |
| @@ -743,35 +741,6 @@ func (d *Daemon) captureSession(sess *session) bool { | |||
| 743 | 741 | return d.scheduleSnapshot(sess) | |
| 744 | 742 | } | |
| 745 | 743 | ||
| 746 | - | func (d *Daemon) encodeSnapshotCapture(capture *snapshotCapture) ([]byte, error) { | |
| 747 | - | snap := snapcodec.Session{Name: capture.name, CreatedAt: capture.createdAt, Active: capture.active, Tabs: make([]snapcodec.Tab, 0, len(capture.tabs))} | |
| 748 | - | for _, tabCapture := range capture.tabs { | |
| 749 | - | tabSnap := snapcodec.Tab{ | |
| 750 | - | StableID: tabCapture.stableID, Cols: tabCapture.cols, Rows: tabCapture.rows, | |
| 751 | - | NextPaneID: tabCapture.nextPaneID, Focus: tabCapture.focus, Tree: tabCapture.tree, | |
| 752 | - | Panes: make([]snapcodec.Pane, 0, len(tabCapture.panes)), | |
| 753 | - | } | |
| 754 | - | paneIDs := make(map[layout.PaneID]struct{}, len(tabCapture.panes)) | |
| 755 | - | for _, paneCapture := range tabCapture.panes { | |
| 756 | - | sealed, tail, historyErr := vt.MarshalSealedHistory(paneCapture.history) | |
| 757 | - | if historyErr != nil { | |
| 758 | - | return nil, fmt.Errorf("terminal snapshot pane %s history: %w", paneCapture.id, historyErr) | |
| 759 | - | } | |
| 760 | - | visible, visibleErr := paneCapture.visible.Marshal() | |
| 761 | - | if visibleErr != nil { | |
| 762 | - | return nil, fmt.Errorf("terminal snapshot pane %s visible: %w", paneCapture.id, visibleErr) | |
| 763 | - | } | |
| 764 | - | tabSnap.Panes = append(tabSnap.Panes, snapcodec.Pane{ID: paneCapture.id, StableID: paneCapture.stableID, Cwd: paneCapture.cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: paneCapture.process}) | |
| 765 | - | paneIDs[paneCapture.id] = struct{}{} | |
| 766 | - | } | |
| 767 | - | if tabSnap.Tree != nil { | |
| 768 | - | pruneTreeToPanes(tabSnap.Tree.Root, paneIDs) | |
| 769 | - | } | |
| 770 | - | snap.Tabs = append(snap.Tabs, tabSnap) | |
| 771 | - | } | |
| 772 | - | return d.snapshotMarshal(snap) | |
| 773 | - | } | |
| 774 | - | ||
| 775 | 744 | // startSnapshotPublication moves a retained capture from the producer queue to | |
| 776 | 745 | // the repository worker. Quarantine and this transition share snapshotMu, so a | |
| 777 | 746 | // repository call cannot begin after destructive teardown has quarantined the | |
| @@ -60,12 +60,10 @@ type snapshotCapturePane struct { | |||||
| 60 | id layout.PaneID | 60 | id layout.PaneID | ||
| 61 | stableID string | 61 | stableID string | ||
| 62 | cwd string | 62 | cwd string | ||
| 63 | - | // history is retained for the v3 compatibility encoder. Incremental | 63 | + | // Sealed identities and the copied mutable tail are retained separately, so |
| 64 | - | // captures keep sealed identities and a copied tail separately so capture | 64 | + | // capture never rotates a live mutable tail merely to persist it. |
| 65 | - | // never rotates a live mutable tail merely to persist it. | 65 | + | sealed vt.HistorySnapshotView |
| 66 | - | history vt.HistoryView | 66 | + | tail vt.HistoryView |
| 67 | - | sealed vt.HistorySnapshotView | |||
| 68 | - | tail vt.HistoryView | |||
| 69 | // visible is an owned copy taken while pane.mu is held. It must be | 67 | // visible is an owned copy taken while pane.mu is held. It must be | ||
| 70 | // marshaled only after that lock has been released. | 68 | // marshaled only after that lock has been released. | ||
| 71 | visible vt.PrimaryVisibleSnapshot | 69 | visible vt.PrimaryVisibleSnapshot | ||
| @@ -743,35 +741,6 @@ func (d *Daemon) captureSession(sess *session) bool { | |||||
| 743 | return d.scheduleSnapshot(sess) | 741 | return d.scheduleSnapshot(sess) | ||
| 744 | } | 742 | } | ||
| 745 | 743 | ||||
| 746 | - | func (d *Daemon) encodeSnapshotCapture(capture *snapshotCapture) ([]byte, error) { | |||
| 747 | - | snap := snapcodec.Session{Name: capture.name, CreatedAt: capture.createdAt, Active: capture.active, Tabs: make([]snapcodec.Tab, 0, len(capture.tabs))} | |||
| 748 | - | for _, tabCapture := range capture.tabs { | |||
| 749 | - | tabSnap := snapcodec.Tab{ | |||
| 750 | - | StableID: tabCapture.stableID, Cols: tabCapture.cols, Rows: tabCapture.rows, | |||
| 751 | - | NextPaneID: tabCapture.nextPaneID, Focus: tabCapture.focus, Tree: tabCapture.tree, | |||
| 752 | - | Panes: make([]snapcodec.Pane, 0, len(tabCapture.panes)), | |||
| 753 | - | } | |||
| 754 | - | paneIDs := make(map[layout.PaneID]struct{}, len(tabCapture.panes)) | |||
| 755 | - | for _, paneCapture := range tabCapture.panes { | |||
| 756 | - | sealed, tail, historyErr := vt.MarshalSealedHistory(paneCapture.history) | |||
| 757 | - | if historyErr != nil { | |||
| 758 | - | return nil, fmt.Errorf("terminal snapshot pane %s history: %w", paneCapture.id, historyErr) | |||
| 759 | - | } | |||
| 760 | - | visible, visibleErr := paneCapture.visible.Marshal() | |||
| 761 | - | if visibleErr != nil { | |||
| 762 | - | return nil, fmt.Errorf("terminal snapshot pane %s visible: %w", paneCapture.id, visibleErr) | |||
| 763 | - | } | |||
| 764 | - | tabSnap.Panes = append(tabSnap.Panes, snapcodec.Pane{ID: paneCapture.id, StableID: paneCapture.stableID, Cwd: paneCapture.cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: paneCapture.process}) | |||
| 765 | - | paneIDs[paneCapture.id] = struct{}{} | |||
| 766 | - | } | |||
| 767 | - | if tabSnap.Tree != nil { | |||
| 768 | - | pruneTreeToPanes(tabSnap.Tree.Root, paneIDs) | |||
| 769 | - | } | |||
| 770 | - | snap.Tabs = append(snap.Tabs, tabSnap) | |||
| 771 | - | } | |||
| 772 | - | return d.snapshotMarshal(snap) | |||
| 773 | - | } | |||
| 774 | - | ||||
| 775 | // startSnapshotPublication moves a retained capture from the producer queue to | 744 | // startSnapshotPublication moves a retained capture from the producer queue to | ||
| 776 | // the repository worker. Quarantine and this transition share snapshotMu, so a | 745 | // the repository worker. Quarantine and this transition share snapshotMu, so a | ||
| 777 | // repository call cannot begin after destructive teardown has quarantined the | 746 | // repository call cannot begin after destructive teardown has quarantined the | ||
M internal/usecase/daemon/snapshot_acceptance_test.go +5 -4
| @@ -48,7 +48,7 @@ func TestSnapshotChunkCacheIsScopedToNamedSession(t *testing.T) { | |||
| 48 | 48 | require.LessOrEqual(t, b.snapshotChunkCache.used, snapshotChunkCacheLimit) | |
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | - | func snapshotCacheHistory(t *testing.T, chunks int, first rune) vt.HistoryView { | |
| 51 | + | func snapshotCacheHistory(t *testing.T, chunks int, first rune) vt.HistorySnapshotView { | |
| 52 | 52 | t.Helper() | |
| 53 | 53 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: chunks * 256, ChunkRows: 256}) | |
| 54 | 54 | for row := 0; row < chunks*256; row++ { | |
| @@ -58,10 +58,10 @@ func snapshotCacheHistory(t *testing.T, chunks int, first rune) vt.HistoryView { | |||
| 58 | 58 | } | |
| 59 | 59 | require.NoError(t, history.Append(cells)) | |
| 60 | 60 | } | |
| 61 | - | return history.SealAndView() | |
| 61 | + | return history.SnapshotView() | |
| 62 | 62 | } | |
| 63 | 63 | ||
| 64 | - | func snapshotCacheCapture(sess *session, history vt.HistoryView, generation uint64) *snapshotCapture { | |
| 64 | + | func snapshotCacheCapture(sess *session, history vt.HistorySnapshotView, generation uint64) *snapshotCapture { | |
| 65 | 65 | return &snapshotCapture{ | |
| 66 | 66 | session: sess, | |
| 67 | 67 | name: sess.name, | |
| @@ -73,7 +73,8 @@ func snapshotCacheCapture(sess *session, history vt.HistoryView, generation uint | |||
| 73 | 73 | panes: []snapshotCapturePane{{ | |
| 74 | 74 | id: "pane", | |
| 75 | 75 | stableID: "pane", | |
| 76 | - | history: history, | |
| 76 | + | sealed: history, | |
| 77 | + | tail: history.Tail(), | |
| 77 | 78 | visible: vt.NewScreen(1, 1).PrimaryVisibleSnapshot(), | |
| 78 | 79 | }}, | |
| 79 | 80 | }}, | |
| @@ -48,7 +48,7 @@ func TestSnapshotChunkCacheIsScopedToNamedSession(t *testing.T) { | |||||
| 48 | require.LessOrEqual(t, b.snapshotChunkCache.used, snapshotChunkCacheLimit) | 48 | require.LessOrEqual(t, b.snapshotChunkCache.used, snapshotChunkCacheLimit) | ||
| 49 | } | 49 | } | ||
| 50 | 50 | ||||
| 51 | - | func snapshotCacheHistory(t *testing.T, chunks int, first rune) vt.HistoryView { | 51 | + | func snapshotCacheHistory(t *testing.T, chunks int, first rune) vt.HistorySnapshotView { |
| 52 | t.Helper() | 52 | t.Helper() | ||
| 53 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: chunks * 256, ChunkRows: 256}) | 53 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: chunks * 256, ChunkRows: 256}) | ||
| 54 | for row := 0; row < chunks*256; row++ { | 54 | for row := 0; row < chunks*256; row++ { | ||
| @@ -58,10 +58,10 @@ func snapshotCacheHistory(t *testing.T, chunks int, first rune) vt.HistoryView { | |||||
| 58 | } | 58 | } | ||
| 59 | require.NoError(t, history.Append(cells)) | 59 | require.NoError(t, history.Append(cells)) | ||
| 60 | } | 60 | } | ||
| 61 | - | return history.SealAndView() | 61 | + | return history.SnapshotView() |
| 62 | } | 62 | } | ||
| 63 | 63 | ||||
| 64 | - | func snapshotCacheCapture(sess *session, history vt.HistoryView, generation uint64) *snapshotCapture { | 64 | + | func snapshotCacheCapture(sess *session, history vt.HistorySnapshotView, generation uint64) *snapshotCapture { |
| 65 | return &snapshotCapture{ | 65 | return &snapshotCapture{ | ||
| 66 | session: sess, | 66 | session: sess, | ||
| 67 | name: sess.name, | 67 | name: sess.name, | ||
| @@ -73,7 +73,8 @@ func snapshotCacheCapture(sess *session, history vt.HistoryView, generation uint | |||||
| 73 | panes: []snapshotCapturePane{{ | 73 | panes: []snapshotCapturePane{{ | ||
| 74 | id: "pane", | 74 | id: "pane", | ||
| 75 | stableID: "pane", | 75 | stableID: "pane", | ||
| 76 | - | history: history, | 76 | + | sealed: history, |
| 77 | + | tail: history.Tail(), | |||
| 77 | visible: vt.NewScreen(1, 1).PrimaryVisibleSnapshot(), | 78 | visible: vt.NewScreen(1, 1).PrimaryVisibleSnapshot(), | ||
| 78 | }}, | 79 | }}, | ||
| 79 | }}, | 80 | }}, | ||
M internal/usecase/daemon/snapshot_incremental.go +4 -16
| @@ -120,14 +120,8 @@ func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.Snapsho | |||
| 120 | 120 | return ports.SnapshotPublication{}, fmt.Errorf("snapshot visible: %w", err) | |
| 121 | 121 | } | |
| 122 | 122 | outPane := snapcodec.ManifestPane{ID: pane.id, StableID: pane.stableID, Cwd: pane.cwd, Process: pane.process} | |
| 123 | - | sealedCount := pane.sealed.ChunkCount() | |
| 124 | - | sealedChunk := pane.sealed.Chunk | |
| 125 | - | if sealedCount == 0 && pane.history.ChunkCount() > 0 { | |
| 126 | - | sealedCount = pane.history.ChunkCount() | |
| 127 | - | sealedChunk = pane.history.Chunk | |
| 128 | - | } | |
| 129 | - | for i := 0; i < sealedCount; i++ { | |
| 130 | - | object, err := snapshotChunkObject(capture, sealedChunk(i)) | |
| 123 | + | for i := 0; i < pane.sealed.ChunkCount(); i++ { | |
| 124 | + | object, err := snapshotChunkObject(capture, pane.sealed.Chunk(i)) | |
| 131 | 125 | if err != nil { | |
| 132 | 126 | return ports.SnapshotPublication{}, fmt.Errorf("snapshot history chunk: %w", err) | |
| 133 | 127 | } | |
| @@ -203,14 +197,8 @@ func collectSnapshotCaptureChunks(referenced map[*vt.HistoryChunk]struct{}, capt | |||
| 203 | 197 | } | |
| 204 | 198 | for _, tab := range capture.tabs { | |
| 205 | 199 | for _, pane := range tab.panes { | |
| 206 | - | sealedCount := pane.sealed.ChunkCount() | |
| 207 | - | sealedChunk := pane.sealed.Chunk | |
| 208 | - | if sealedCount == 0 && pane.history.ChunkCount() > 0 { | |
| 209 | - | sealedCount = pane.history.ChunkCount() | |
| 210 | - | sealedChunk = pane.history.Chunk | |
| 211 | - | } | |
| 212 | - | for i := 0; i < sealedCount; i++ { | |
| 213 | - | if chunk := sealedChunk(i); chunk != nil { | |
| 200 | + | for i := 0; i < pane.sealed.ChunkCount(); i++ { | |
| 201 | + | if chunk := pane.sealed.Chunk(i); chunk != nil { | |
| 214 | 202 | referenced[chunk] = struct{}{} | |
| 215 | 203 | } | |
| 216 | 204 | } | |
| @@ -120,14 +120,8 @@ func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.Snapsho | |||||
| 120 | return ports.SnapshotPublication{}, fmt.Errorf("snapshot visible: %w", err) | 120 | return ports.SnapshotPublication{}, fmt.Errorf("snapshot visible: %w", err) | ||
| 121 | } | 121 | } | ||
| 122 | outPane := snapcodec.ManifestPane{ID: pane.id, StableID: pane.stableID, Cwd: pane.cwd, Process: pane.process} | 122 | outPane := snapcodec.ManifestPane{ID: pane.id, StableID: pane.stableID, Cwd: pane.cwd, Process: pane.process} | ||
| 123 | - | sealedCount := pane.sealed.ChunkCount() | 123 | + | for i := 0; i < pane.sealed.ChunkCount(); i++ { |
| 124 | - | sealedChunk := pane.sealed.Chunk | 124 | + | object, err := snapshotChunkObject(capture, pane.sealed.Chunk(i)) |
| 125 | - | if sealedCount == 0 && pane.history.ChunkCount() > 0 { | |||
| 126 | - | sealedCount = pane.history.ChunkCount() | |||
| 127 | - | sealedChunk = pane.history.Chunk | |||
| 128 | - | } | |||
| 129 | - | for i := 0; i < sealedCount; i++ { | |||
| 130 | - | object, err := snapshotChunkObject(capture, sealedChunk(i)) | |||
| 131 | if err != nil { | 125 | if err != nil { | ||
| 132 | return ports.SnapshotPublication{}, fmt.Errorf("snapshot history chunk: %w", err) | 126 | return ports.SnapshotPublication{}, fmt.Errorf("snapshot history chunk: %w", err) | ||
| 133 | } | 127 | } | ||
| @@ -203,14 +197,8 @@ func collectSnapshotCaptureChunks(referenced map[*vt.HistoryChunk]struct{}, capt | |||||
| 203 | } | 197 | } | ||
| 204 | for _, tab := range capture.tabs { | 198 | for _, tab := range capture.tabs { | ||
| 205 | for _, pane := range tab.panes { | 199 | for _, pane := range tab.panes { | ||
| 206 | - | sealedCount := pane.sealed.ChunkCount() | 200 | + | for i := 0; i < pane.sealed.ChunkCount(); i++ { |
| 207 | - | sealedChunk := pane.sealed.Chunk | 201 | + | if chunk := pane.sealed.Chunk(i); chunk != nil { |
| 208 | - | if sealedCount == 0 && pane.history.ChunkCount() > 0 { | |||
| 209 | - | sealedCount = pane.history.ChunkCount() | |||
| 210 | - | sealedChunk = pane.history.Chunk | |||
| 211 | - | } | |||
| 212 | - | for i := 0; i < sealedCount; i++ { | |||
| 213 | - | if chunk := sealedChunk(i); chunk != nil { | |||
| 214 | referenced[chunk] = struct{}{} | 202 | referenced[chunk] = struct{}{} | ||
| 215 | } | 203 | } | ||
| 216 | } | 204 | } | ||
M internal/usecase/daemon/snapshot_incremental_test.go +2 -2
| @@ -38,11 +38,11 @@ func TestIncrementalPublicationEncodesAfterPaneUnlock(t *testing.T) { | |||
| 38 | 38 | func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) { | |
| 39 | 39 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}) | |
| 40 | 40 | history.Append([]renderer.Cell{renderer.BlankCell()}) | |
| 41 | - | view := history.SealAndView() | |
| 41 | + | view := history.SnapshotView() | |
| 42 | 42 | visible := vt.NewScreen(1, 1).PrimaryVisibleSnapshot() | |
| 43 | 43 | d := New(nil, nil, nil) | |
| 44 | 44 | sess := newSnapshotTestSession(t, "work", false, "/work") | |
| 45 | - | capture := &snapshotCapture{session: sess, name: "work", generation: 1, tabs: []snapshotCaptureTab{{stableID: "t", cols: 1, rows: 1, panes: []snapshotCapturePane{{id: "p", stableID: "p", history: view, visible: visible}}}}} | |
| 45 | + | capture := &snapshotCapture{session: sess, name: "work", generation: 1, tabs: []snapshotCaptureTab{{stableID: "t", cols: 1, rows: 1, panes: []snapshotCapturePane{{id: "p", stableID: "p", sealed: view, tail: view.Tail(), visible: visible}}}}} | |
| 46 | 46 | first, err := d.incrementalPublication(capture) | |
| 47 | 47 | if err != nil { | |
| 48 | 48 | t.Fatal(err) | |
| @@ -38,11 +38,11 @@ func TestIncrementalPublicationEncodesAfterPaneUnlock(t *testing.T) { | |||||
| 38 | func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) { | 38 | func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) { | ||
| 39 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}) | 39 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}) | ||
| 40 | history.Append([]renderer.Cell{renderer.BlankCell()}) | 40 | history.Append([]renderer.Cell{renderer.BlankCell()}) | ||
| 41 | - | view := history.SealAndView() | 41 | + | view := history.SnapshotView() |
| 42 | visible := vt.NewScreen(1, 1).PrimaryVisibleSnapshot() | 42 | visible := vt.NewScreen(1, 1).PrimaryVisibleSnapshot() | ||
| 43 | d := New(nil, nil, nil) | 43 | d := New(nil, nil, nil) | ||
| 44 | sess := newSnapshotTestSession(t, "work", false, "/work") | 44 | sess := newSnapshotTestSession(t, "work", false, "/work") | ||
| 45 | - | capture := &snapshotCapture{session: sess, name: "work", generation: 1, tabs: []snapshotCaptureTab{{stableID: "t", cols: 1, rows: 1, panes: []snapshotCapturePane{{id: "p", stableID: "p", history: view, visible: visible}}}}} | 45 | + | capture := &snapshotCapture{session: sess, name: "work", generation: 1, tabs: []snapshotCaptureTab{{stableID: "t", cols: 1, rows: 1, panes: []snapshotCapturePane{{id: "p", stableID: "p", sealed: view, tail: view.Tail(), visible: visible}}}}} |
| 46 | first, err := d.incrementalPublication(capture) | 46 | first, err := d.incrementalPublication(capture) | ||
| 47 | if err != nil { | 47 | if err != nil { | ||
| 48 | t.Fatal(err) | 48 | t.Fatal(err) | ||
M internal/usecase/snapshot/codec_test.go +26 -5
| @@ -193,8 +193,8 @@ func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) { | |||
| 193 | 193 | } | |
| 194 | 194 | } | |
| 195 | 195 | ||
| 196 | - | func TestUnmarshalRejectsNonCanonicalBlobRolesDuringPreflight(t *testing.T) { | |
| 197 | - | sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}}) | |
| 196 | + | func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) { | |
| 197 | + | sealed, emptyTail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}}) | |
| 198 | 198 | if len(sealed) == 0 { | |
| 199 | 199 | t.Fatal("expected a sealed chunk") | |
| 200 | 200 | } | |
| @@ -204,13 +204,34 @@ func TestUnmarshalRejectsNonCanonicalBlobRolesDuringPreflight(t *testing.T) { | |||
| 204 | 204 | multiChunk = append(multiChunk, sealed[0][9:]...) | |
| 205 | 205 | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |
| 206 | 206 | emptyVisible := visibleBlob(t, nil) | |
| 207 | + | ||
| 208 | + | // Incremental captures retain one copied mutable tail chunk instead of | |
| 209 | + | // rotating it into the sealed history set. That one chunk is canonical and | |
| 210 | + | // must remain a valid v3 import payload. | |
| 211 | + | mutableHistory := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |
| 212 | + | for _, row := range [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}} { | |
| 213 | + | requireNoError(t, mutableHistory.Append(row)) | |
| 214 | + | } | |
| 215 | + | mutableTail, err := vt.MarshalHistoryTail(mutableHistory.SnapshotView()) | |
| 216 | + | requireNoError(t, err) | |
| 217 | + | fullHistory, err := vt.MarshalHistory(mutableHistory.SealAndView()) | |
| 218 | + | requireNoError(t, err) | |
| 219 | + | valid, err := Marshal(Session{Name: "s", Tabs: []Tab{{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: sealed[:1], Tail: mutableTail, Visible: visible}}}}}) | |
| 220 | + | requireNoError(t, err) | |
| 221 | + | roundTrip, err := Unmarshal(valid) | |
| 222 | + | requireNoError(t, err) | |
| 223 | + | if got := roundTrip.Tabs[0].Panes[0].Tail; !bytes.Equal(got, mutableTail) { | |
| 224 | + | t.Fatal("mutable tail did not round trip") | |
| 225 | + | } | |
| 226 | + | ||
| 207 | 227 | for _, tc := range []struct { | |
| 208 | 228 | name string | |
| 209 | 229 | tab Tab | |
| 210 | 230 | }{ | |
| 211 | - | {name: "sealed multi-chunk", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: [][]byte{multiChunk}, Tail: tail, Visible: visible}}}}, | |
| 212 | - | {name: "nonempty tail", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: sealed[0], Visible: visible}}}}, | |
| 213 | - | {name: "empty visible geometry", tab: Tab{Panes: []Pane{{ID: "p", Tail: tail, Visible: emptyVisible}}}}, | |
| 231 | + | {name: "sealed multi-chunk", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: [][]byte{multiChunk}, Tail: emptyTail, Visible: visible}}}}, | |
| 232 | + | {name: "tail has multiple chunks", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: multiChunk, Visible: visible}}}}, | |
| 233 | + | {name: "noncanonical full history tail", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: fullHistory, Visible: visible}}}}, | |
| 234 | + | {name: "empty visible geometry", tab: Tab{Panes: []Pane{{ID: "p", Tail: emptyTail, Visible: emptyVisible}}}}, | |
| 214 | 235 | } { | |
| 215 | 236 | t.Run(tc.name, func(t *testing.T) { | |
| 216 | 237 | data, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}}) | |
| @@ -193,8 +193,8 @@ func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) { | |||||
| 193 | } | 193 | } | ||
| 194 | } | 194 | } | ||
| 195 | 195 | ||||
| 196 | - | func TestUnmarshalRejectsNonCanonicalBlobRolesDuringPreflight(t *testing.T) { | 196 | + | func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) { |
| 197 | - | sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}}) | 197 | + | sealed, emptyTail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}}) |
| 198 | if len(sealed) == 0 { | 198 | if len(sealed) == 0 { | ||
| 199 | t.Fatal("expected a sealed chunk") | 199 | t.Fatal("expected a sealed chunk") | ||
| 200 | } | 200 | } | ||
| @@ -204,13 +204,34 @@ func TestUnmarshalRejectsNonCanonicalBlobRolesDuringPreflight(t *testing.T) { | |||||
| 204 | multiChunk = append(multiChunk, sealed[0][9:]...) | 204 | multiChunk = append(multiChunk, sealed[0][9:]...) | ||
| 205 | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | 205 | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | ||
| 206 | emptyVisible := visibleBlob(t, nil) | 206 | emptyVisible := visibleBlob(t, nil) | ||
| 207 | + | ||||
| 208 | + | // Incremental captures retain one copied mutable tail chunk instead of | |||
| 209 | + | // rotating it into the sealed history set. That one chunk is canonical and | |||
| 210 | + | // must remain a valid v3 import payload. | |||
| 211 | + | mutableHistory := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |||
| 212 | + | for _, row := range [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}} { | |||
| 213 | + | requireNoError(t, mutableHistory.Append(row)) | |||
| 214 | + | } | |||
| 215 | + | mutableTail, err := vt.MarshalHistoryTail(mutableHistory.SnapshotView()) | |||
| 216 | + | requireNoError(t, err) | |||
| 217 | + | fullHistory, err := vt.MarshalHistory(mutableHistory.SealAndView()) | |||
| 218 | + | requireNoError(t, err) | |||
| 219 | + | valid, err := Marshal(Session{Name: "s", Tabs: []Tab{{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: sealed[:1], Tail: mutableTail, Visible: visible}}}}}) | |||
| 220 | + | requireNoError(t, err) | |||
| 221 | + | roundTrip, err := Unmarshal(valid) | |||
| 222 | + | requireNoError(t, err) | |||
| 223 | + | if got := roundTrip.Tabs[0].Panes[0].Tail; !bytes.Equal(got, mutableTail) { | |||
| 224 | + | t.Fatal("mutable tail did not round trip") | |||
| 225 | + | } | |||
| 226 | + | ||||
| 207 | for _, tc := range []struct { | 227 | for _, tc := range []struct { | ||
| 208 | name string | 228 | name string | ||
| 209 | tab Tab | 229 | tab Tab | ||
| 210 | }{ | 230 | }{ | ||
| 211 | - | {name: "sealed multi-chunk", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: [][]byte{multiChunk}, Tail: tail, Visible: visible}}}}, | 231 | + | {name: "sealed multi-chunk", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: [][]byte{multiChunk}, Tail: emptyTail, Visible: visible}}}}, |
| 212 | - | {name: "nonempty tail", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: sealed[0], Visible: visible}}}}, | 232 | + | {name: "tail has multiple chunks", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: multiChunk, Visible: visible}}}}, |
| 213 | - | {name: "empty visible geometry", tab: Tab{Panes: []Pane{{ID: "p", Tail: tail, Visible: emptyVisible}}}}, | 233 | + | {name: "noncanonical full history tail", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: fullHistory, Visible: visible}}}}, |
| 234 | + | {name: "empty visible geometry", tab: Tab{Panes: []Pane{{ID: "p", Tail: emptyTail, Visible: emptyVisible}}}}, | |||
| 214 | } { | 235 | } { | ||
| 215 | t.Run(tc.name, func(t *testing.T) { | 236 | t.Run(tc.name, func(t *testing.T) { | ||
| 216 | data, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}}) | 237 | data, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}}) | ||