detached 78de75a

refactor(daemon): remove monolithic snapshot encoding

commit
78de75a8592a074f549f54c5bf17154ec826bc9b
author
brice · · 2026-07-22 01:02 UTC
parent
90ff4fa
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +99 -95
M internal/usecase/daemon/daemon.go +0 -3
@@ -16,7 +16,6 @@ import (
1616 "github.com/bnema/vev/internal/persist"
1717 "github.com/bnema/vev/internal/ports"
1818 "github.com/bnema/vev/internal/usecase/keys"
19- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
2019 )
2120
2221 // Scheduler debounce bounds. Idle updates use the minimum for low latency;
@@ -130,7 +129,6 @@ type Daemon struct {
130129 legacyImportMu sync.Mutex
131130 legacyImportPending map[string][]byte
132131 noticeStore ports.NoticeStore
133- snapshotMarshal func(snapcodec.Session) ([]byte, error)
134132 snapshotJobs chan *snapshotCapture
135133 // snapshotWake wakes the repository scheduler when a session becomes dirty
136134 // 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
377375 done: make(chan struct{}),
378376 restoreDone: make(chan struct{}),
379377 animWake: make(chan struct{}, 1),
380- snapshotMarshal: snapcodec.Marshal,
381378 legacyImportPending: make(map[string][]byte),
382379 snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
383380 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.Mutex129 legacyImportMu sync.Mutex
131 legacyImportPending map[string][]byte130 legacyImportPending map[string][]byte
132 noticeStore ports.NoticeStore131 noticeStore ports.NoticeStore
133- snapshotMarshal func(snapcodec.Session) ([]byte, error)
134 snapshotJobs chan *snapshotCapture132 snapshotJobs chan *snapshotCapture
135 // snapshotWake wakes the repository scheduler when a session becomes dirty133 // snapshotWake wakes the repository scheduler when a session becomes dirty
136 // or an attempt completes. It is never closed and producers only send134 // 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 (
1919 func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) {
2020 repository := &countingSnapshotRepository{}
2121 payload := []byte{0xff, 0x00, 0xfe}
22+ object := []byte{0x01, 0x02}
2223
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())
2526 require.Equal(t, payload, repository.lastPayload())
2627
2728 payload[0] = 0x01
@@ -64,7 +65,8 @@ func TestPerformanceFixtureCounters(t *testing.T) {
6465 fixture.captureSnapshot()
6566 metrics = fixture.metrics()
6667 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)
6870
6971 fixture.enterCopySearch()
7072 require.Positive(t, fixture.searchMatches())
@@ -217,14 +219,14 @@ func BenchmarkDaemonHistorySnapshotCapture(b *testing.B) {
217219 benchmarkDaemonSnapshotCapture(b)
218220 }
219221
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.
222224 func BenchmarkDaemonHistorySnapshotEncode(b *testing.B) {
223225 benchmarkDaemonSnapshotEncode(b)
224226 }
225227
226228 // 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.
228230 func BenchmarkDaemonHistorySnapshotRestore(b *testing.B) {
229231 benchmarkDaemonSnapshotRestore(b)
230232 }
@@ -327,7 +329,7 @@ func benchmarkDaemonSnapshotCapture(b *testing.B) {
327329 b.Fatal("invalid snapshot capture fixture")
328330 }
329331 firstPane := first.tabs[0].panes[0]
330- firstChunk := firstPane.history.Chunk(0)
332+ firstChunk := firstPane.sealed.Chunk(0)
331333 captures := 0
332334 var last *snapshotCapture
333335 b.ReportAllocs()
@@ -341,7 +343,7 @@ func benchmarkDaemonSnapshotCapture(b *testing.B) {
341343 }
342344 b.StopTimer()
343345 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 {
345347 b.Fatal("snapshot capture did not preserve immutable sealed history")
346348 }
347349 b.ReportMetric(float64(capturePaneCount(last)), "capturepanes/op")
@@ -354,34 +356,36 @@ func benchmarkDaemonSnapshotEncode(b *testing.B) {
354356 b.Run(topology.name, func(b *testing.B) {
355357 fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000})
356358 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")
358360 }
359361 capture, ok := fixture.d.captureSnapshotState(fixture.sess, 1)
360362 if !ok || !validBenchmarkCapture(capture) {
361- b.Fatal("invalid snapshot encode fixture")
363+ b.Fatal("invalid snapshot publication fixture")
362364 }
363- encoded, err := fixture.d.encodeSnapshotCapture(capture)
365+ publication, err := fixture.d.incrementalPublication(capture)
364366 if err != nil {
365367 b.Fatal(err)
366368 }
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)
369371 }
370- var last []byte
372+ var last ports.SnapshotPublication
371373 b.ReportAllocs()
372- b.SetBytes(int64(len(encoded)))
374+ b.SetBytes(int64(snapshotPublicationBytes(publication)))
373375 b.ResetTimer()
374376 for range b.N {
375- last, err = fixture.d.encodeSnapshotCapture(capture)
377+ last, err = fixture.d.incrementalPublication(capture)
376378 if err != nil {
377379 b.Fatal(err)
378380 }
379381 }
380382 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)
383385 }
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")
385389 })
386390 }
387391 }
@@ -395,17 +399,17 @@ func benchmarkDaemonSnapshotRestore(b *testing.B) {
395399 if !ok || !validBenchmarkCapture(capture) {
396400 b.Fatal("invalid snapshot restore fixture")
397401 }
398- encoded, err := fixture.d.encodeSnapshotCapture(capture)
402+ publication, err := fixture.d.incrementalPublication(capture)
399403 if err != nil {
400404 b.Fatal(err)
401405 }
402- snapshot, err := snapcodec.Unmarshal(encoded)
406+ snapshot, err := sessionFromGeneration(snapshotGeneration(publication))
403407 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)
405409 }
406410 paneSnapshot := snapshot.Tabs[0].Panes[0]
407411 b.ReportAllocs()
408- b.SetBytes(int64(len(encoded)))
412+ b.SetBytes(int64(snapshotPublicationBytes(publication)))
409413 b.ResetTimer()
410414 for range b.N {
411415 if err := restorePaneTerminal(fixture.activePane, paneSnapshot); err != nil {
@@ -423,7 +427,27 @@ func benchmarkDaemonSnapshotRestore(b *testing.B) {
423427 }
424428
425429 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)
427451 }
428452
429453 func capturePaneCount(capture *snapshotCapture) int {
@@ -474,7 +498,8 @@ func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations
474498 b.ReportMetric(float64(metrics.outputBytes)/perOperation, "outputbytes/op")
475499 b.ReportMetric(float64(metrics.outputPayloadBytes)/perOperation, "framepayloadbytes/op")
476500 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")
478503 b.ReportMetric(float64(metrics.coordinatorInvalidations)/perOperation, "coordinatorinvalidations/op")
479504 b.ReportMetric(float64(metrics.coordinatorWakes)/perOperation, "coordinatorwakes/op")
480505 b.ReportMetric(float64(metrics.coordinatorCoalesced)/perOperation, "coordinatorcoalesced/op")
@@ -588,7 +613,8 @@ type performanceMetrics struct {
588613 outputBytes uint64
589614 outputPayloadBytes uint64
590615 snapshotWrites uint64
591- snapshotBytes uint64
616+ snapshotManifestBytes uint64
617+ snapshotObjectBytes uint64
592618 coordinatorInvalidations uint64
593619 coordinatorWakes uint64
594620 coordinatorCoalesced uint64
@@ -947,7 +973,7 @@ func (f *performanceFixture) metrics() performanceMetrics {
947973 snapshots := f.snapshots.metrics()
948974 metrics := performanceMetrics{
949975 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,
951977 renderCaptures: f.renderCaptures, renderCompositions: f.renderCompositions, renderEmissions: f.renderEmissions,
952978 resizeRequests: f.resizeRequests, resizeCommits: f.resizeCommits, resizeCommitNanos: f.resizeCommitNanos,
953979 ptyFailures: f.ptyFailures, ptyRetries: f.ptyRetries,
@@ -1011,7 +1037,9 @@ func (t *countingOutputTransport) lastPayload() []byte {
10111037 return t.last
10121038 }
10131039
1014-type countingSnapshotMetrics struct{ writes, bytes uint64 }
1040+type countingSnapshotMetrics struct {
1041+ writes, manifestBytes, objectBytes uint64
1042+}
10151043 type countingSnapshotRepository struct {
10161044 mu sync.Mutex
10171045 countingSnapshotMetrics
@@ -1021,9 +1049,9 @@ type countingSnapshotRepository struct {
10211049 func (s *countingSnapshotRepository) Publish(_ context.Context, publication ports.SnapshotPublication) error {
10221050 s.mu.Lock()
10231051 s.writes++
1024- s.bytes += uint64(len(publication.Manifest))
1052+ s.manifestBytes += uint64(len(publication.Manifest))
10251053 for _, object := range publication.Objects {
1026- s.bytes += uint64(len(object.Data))
1054+ s.objectBytes += uint64(len(object.Data))
10271055 }
10281056 s.last = publication.Manifest
10291057 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] = 0x0128 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 already222+// 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 restoration228 // 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 := 0333 captures := 0
332 var last *snapshotCapture334 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 []byte372+ 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() > 0430+ 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 uint64613 outputBytes uint64
589 outputPayloadBytes uint64614 outputPayloadBytes uint64
590 snapshotWrites uint64615 snapshotWrites uint64
591- snapshotBytes uint64616+ snapshotManifestBytes uint64
617+ snapshotObjectBytes uint64
592 coordinatorInvalidations uint64618 coordinatorInvalidations uint64
593 coordinatorWakes uint64619 coordinatorWakes uint64
594 coordinatorCoalesced uint64620 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.last1037 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.Mutex1044 mu sync.Mutex
1017 countingSnapshotMetrics1045 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.Manifest1056 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 {
6060 id layout.PaneID
6161 stableID string
6262 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
6967 // visible is an owned copy taken while pane.mu is held. It must be
7068 // marshaled only after that lock has been released.
7169 visible vt.PrimaryVisibleSnapshot
@@ -743,35 +741,6 @@ func (d *Daemon) captureSession(sess *session) bool {
743741 return d.scheduleSnapshot(sess)
744742 }
745743
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-
775744 // startSnapshotPublication moves a retained capture from the producer queue to
776745 // the repository worker. Quarantine and this transition share snapshotMu, so a
777746 // repository call cannot begin after destructive teardown has quarantined the
@@ -60,12 +60,10 @@ type snapshotCapturePane struct {
60 id layout.PaneID60 id layout.PaneID
61 stableID string61 stableID string
62 cwd string62 cwd string
63- // history is retained for the v3 compatibility encoder. Incremental63+ // Sealed identities and the copied mutable tail are retained separately, so
64- // captures keep sealed identities and a copied tail separately so capture64+ // 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.HistoryView66+ 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 be67 // 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.PrimaryVisibleSnapshot69 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 to744 // startSnapshotPublication moves a retained capture from the producer queue to
776 // the repository worker. Quarantine and this transition share snapshotMu, so a745 // the repository worker. Quarantine and this transition share snapshotMu, so a
777 // repository call cannot begin after destructive teardown has quarantined the746 // 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) {
4848 require.LessOrEqual(t, b.snapshotChunkCache.used, snapshotChunkCacheLimit)
4949 }
5050
51-func snapshotCacheHistory(t *testing.T, chunks int, first rune) vt.HistoryView {
51+func snapshotCacheHistory(t *testing.T, chunks int, first rune) vt.HistorySnapshotView {
5252 t.Helper()
5353 history := vt.NewHistory(vt.HistoryConfig{MaxRows: chunks * 256, ChunkRows: 256})
5454 for row := 0; row < chunks*256; row++ {
@@ -58,10 +58,10 @@ func snapshotCacheHistory(t *testing.T, chunks int, first rune) vt.HistoryView {
5858 }
5959 require.NoError(t, history.Append(cells))
6060 }
61- return history.SealAndView()
61+ return history.SnapshotView()
6262 }
6363
64-func snapshotCacheCapture(sess *session, history vt.HistoryView, generation uint64) *snapshotCapture {
64+func snapshotCacheCapture(sess *session, history vt.HistorySnapshotView, generation uint64) *snapshotCapture {
6565 return &snapshotCapture{
6666 session: sess,
6767 name: sess.name,
@@ -73,7 +73,8 @@ func snapshotCacheCapture(sess *session, history vt.HistoryView, generation uint
7373 panes: []snapshotCapturePane{{
7474 id: "pane",
7575 stableID: "pane",
76- history: history,
76+ sealed: history,
77+ tail: history.Tail(),
7778 visible: vt.NewScreen(1, 1).PrimaryVisibleSnapshot(),
7879 }},
7980 }},
@@ -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
120120 return ports.SnapshotPublication{}, fmt.Errorf("snapshot visible: %w", err)
121121 }
122122 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))
131125 if err != nil {
132126 return ports.SnapshotPublication{}, fmt.Errorf("snapshot history chunk: %w", err)
133127 }
@@ -203,14 +197,8 @@ func collectSnapshotCaptureChunks(referenced map[*vt.HistoryChunk]struct{}, capt
203197 }
204198 for _, tab := range capture.tabs {
205199 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 {
214202 referenced[chunk] = struct{}{}
215203 }
216204 }
@@ -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.Chunk124+ 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.Chunk201+ 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) {
3838 func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) {
3939 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1})
4040 history.Append([]renderer.Cell{renderer.BlankCell()})
41- view := history.SealAndView()
41+ view := history.SnapshotView()
4242 visible := vt.NewScreen(1, 1).PrimaryVisibleSnapshot()
4343 d := New(nil, nil, nil)
4444 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}}}}}
4646 first, err := d.incrementalPublication(capture)
4747 if err != nil {
4848 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) {
193193 }
194194 }
195195
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'}}})
198198 if len(sealed) == 0 {
199199 t.Fatal("expected a sealed chunk")
200200 }
@@ -204,13 +204,34 @@ func TestUnmarshalRejectsNonCanonicalBlobRolesDuringPreflight(t *testing.T) {
204204 multiChunk = append(multiChunk, sealed[0][9:]...)
205205 visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
206206 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+
207227 for _, tc := range []struct {
208228 name string
209229 tab Tab
210230 }{
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}}}},
214235 } {
215236 t.Run(tc.name, func(t *testing.T) {
216237 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 string228 name string
209 tab Tab229 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}})

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close