v0.3.0 99dc266

snapshot_resume_publication_test.go RawHistoryFolder

1package daemon
2
3import (
4	"bytes"
5	"context"
6	"crypto/sha256"
7	"fmt"
8	"sync"
9	"testing"
10
11	"github.com/stretchr/testify/require"
12
13	"github.com/bnema/vev/internal/domain"
14	"github.com/bnema/vev/internal/ports"
15)
16
17type snapshotLifecycleRepository struct {
18	mu          sync.Mutex
19	current     map[domain.IncarnationID]ports.CheckpointRef
20	generations map[domain.IncarnationID]map[ports.CheckpointRef]ports.SnapshotGeneration
21	attempts    []ports.SnapshotPublication
22	errors      []error
23}
24
25func (r *snapshotLifecycleRepository) Publish(ctx context.Context, publication ports.SnapshotPublication) error {
26	r.mu.Lock()
27	defer r.mu.Unlock()
28
29	err := r.publish(ctx, publication)
30	r.attempts = append(r.attempts, publication)
31	r.errors = append(r.errors, err)
32	return err
33}
34
35func (r *snapshotLifecycleRepository) publish(ctx context.Context, publication ports.SnapshotPublication) error {
36	if err := ctx.Err(); err != nil {
37		return err
38	}
39	if publication.IncarnationID == (domain.IncarnationID{}) || publication.Name == "" || publication.Generation == 0 {
40		return fmt.Errorf("invalid snapshot publication identity")
41	}
42
43	current, exists := r.current[publication.IncarnationID]
44	if !exists {
45		if publication.Generation != 1 || publication.ParentCheckpoint != nil {
46			return fmt.Errorf("first snapshot generation must be 1 with no parent")
47		}
48	} else {
49		generation := r.generations[publication.IncarnationID][current]
50		if generation.Name != publication.Name {
51			return fmt.Errorf("snapshot name does not match current checkpoint")
52		}
53		if publication.Generation == current.Generation && bytes.Equal(publication.Manifest, generation.Manifest) {
54			return nil
55		}
56		if publication.Generation != current.Generation+1 || publication.ParentCheckpoint == nil || *publication.ParentCheckpoint != current {
57			return fmt.Errorf("snapshot generation or parent does not follow current checkpoint")
58		}
59	}
60
61	ref := ports.CheckpointRef{Generation: publication.Generation, ManifestDigest: sha256.Sum256(publication.Manifest)}
62	objects := make(map[ports.SnapshotDigest][]byte, len(publication.Objects))
63	for _, object := range publication.Objects {
64		objects[object.Digest] = append([]byte(nil), object.Data...)
65	}
66	generation := ports.SnapshotGeneration{
67		IncarnationID:    publication.IncarnationID,
68		Name:             publication.Name,
69		Generation:       publication.Generation,
70		ParentCheckpoint: cloneCheckpointRef(publication.ParentCheckpoint),
71		Manifest:         append([]byte(nil), publication.Manifest...),
72		Objects:          objects,
73	}
74	if r.generations[publication.IncarnationID] == nil {
75		r.generations[publication.IncarnationID] = make(map[ports.CheckpointRef]ports.SnapshotGeneration)
76	}
77	r.generations[publication.IncarnationID][ref] = generation
78	r.current[publication.IncarnationID] = ref
79	return nil
80}
81
82func (r *snapshotLifecycleRepository) LoadCheckpoint(ctx context.Context, id domain.IncarnationID, name string, ref ports.CheckpointRef) (ports.SnapshotGeneration, error) {
83	r.mu.Lock()
84	defer r.mu.Unlock()
85	if err := ctx.Err(); err != nil {
86		return ports.SnapshotGeneration{}, err
87	}
88	generation, ok := r.generations[id][ref]
89	if !ok || generation.Name != name {
90		return ports.SnapshotGeneration{}, fmt.Errorf("checkpoint not found")
91	}
92	return generation, nil
93}
94
95func (r *snapshotLifecycleRepository) ReconcileCheckpoint(ctx context.Context, id domain.IncarnationID, ref ports.CheckpointRef) error {
96	r.mu.Lock()
97	defer r.mu.Unlock()
98	if err := ctx.Err(); err != nil {
99		return err
100	}
101	if _, ok := r.generations[id][ref]; !ok {
102		return fmt.Errorf("checkpoint not found")
103	}
104	r.current[id] = ref
105	return nil
106}
107
108func (r *snapshotLifecycleRepository) DeleteIncarnation(ctx context.Context, id domain.IncarnationID) error {
109	r.mu.Lock()
110	defer r.mu.Unlock()
111	if err := ctx.Err(); err != nil {
112		return err
113	}
114	delete(r.current, id)
115	delete(r.generations, id)
116	return nil
117}
118
119func (r *snapshotLifecycleRepository) CollectGarbage(ctx context.Context, _ map[domain.IncarnationID]domain.CheckpointRef) error {
120	return ctx.Err()
121}
122
123func (r *snapshotLifecycleRepository) results() ([]ports.SnapshotPublication, []error) {
124	r.mu.Lock()
125	defer r.mu.Unlock()
126	return append([]ports.SnapshotPublication(nil), r.attempts...), append([]error(nil), r.errors...)
127}
128
129func newSnapshotLifecycleRepository(testing.TB) *snapshotLifecycleRepository {
130	return &snapshotLifecycleRepository{
131		current:     make(map[domain.IncarnationID]ports.CheckpointRef),
132		generations: make(map[domain.IncarnationID]map[ports.CheckpointRef]ports.SnapshotGeneration),
133	}
134}
135
136func cloneCheckpointRef(ref *domain.CheckpointRef) *domain.CheckpointRef {
137	if ref == nil {
138		return nil
139	}
140	clone := *ref
141	return &clone
142}
143
144func TestStoppedNamedSessionResumePublishesNextCheckpointInSameDaemon(t *testing.T) {
145	keeperPTY, releaseKeeper := newBlockingPTY(t)
146	firstPTY, releaseFirst := newBlockingPTY(t)
147	resumedPTY, releaseResumed := newBlockingPTY(t)
148	repository := newSnapshotLifecycleRepository(t)
149	d := newTestDaemon(t, newFactorySeq(t, keeperPTY, firstPTY, resumedPTY), stubClock{})
150	WithSnapshotRepository(repository)(d)
151	store, _ := newMockStore(t)
152	WithStore(t, store)(d)
153	startSnapshotEncodeWorker(t, d)
154	t.Cleanup(func() {
155		d.snapsEnabled = false
156		releaseKeeper()
157		releaseFirst()
158		releaseResumed()
159		d.sessWg.Wait()
160	})
161
162	_, _, err := d.route(snapshotLifecycleHello(ports.IntentEphemeral, ""), &closeTrackingTransport{})
163	require.NoError(t, err, "keeper session setup failed")
164	sess, _, err := d.route(snapshotLifecycleHello(ports.IntentNew, "work"), &closeTrackingTransport{})
165	require.NoError(t, err)
166
167	testAttachmentTab(sess).focusedPane().screen.Write([]byte("first checkpoint"))
168	markSnapshotDirty(sess)
169	require.True(t, d.scheduleSnapshot(sess))
170	awaitSnapshotClean(t, sess)
171	firstRecord := snapshotLifecycleRecord(t, d, sess.name)
172	require.NotNil(t, firstRecord.Committed)
173	require.Equal(t, uint64(1), firstRecord.Committed.Generation)
174
175	require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
176	d.mu.Lock()
177	stopped, retained := d.stopped[sess.name]
178	d.mu.Unlock()
179	require.True(t, retained, "non-purged named session must remain stopped")
180	require.False(t, stopped.purging)
181	closedRecord := snapshotLifecycleRecord(t, d, sess.name)
182	require.NotNil(t, closedRecord.Committed)
183	require.Equal(t, firstRecord.Committed.Generation+1, closedRecord.Committed.Generation,
184		"terminal checkpoint must be authoritative before same-daemon resume")
185
186	resumed, _, err := d.route(snapshotLifecycleHello(ports.IntentAttach, sess.name), &closeTrackingTransport{})
187	require.NoError(t, err)
188	require.NotSame(t, sess, resumed, "stopped-session attach must create a new live runtime")
189	require.Equal(t, sess.incarnation, resumed.incarnation, "resume must retain durable incarnation authority")
190
191	testAttachmentTab(resumed).focusedPane().screen.Write([]byte("checkpoint after resume"))
192	markSnapshotDirty(resumed)
193	require.True(t, d.scheduleSnapshot(resumed))
194	awaitSnapshotIdle(t, resumed)
195
196	resumedRecord := snapshotLifecycleRecord(t, d, resumed.name)
197	require.NotNil(t, resumedRecord.Committed)
198	attempts, publicationErrors := repository.results()
199	require.Len(t, attempts, 3, "the resumed checkpoint must reach the repository after the initial and terminal checkpoints")
200	require.NoError(t, publicationErrors[2])
201	require.False(t, resumed.snapDirty.Load(), "the published resumed checkpoint must clear dirty state")
202	d.snapshotNoticeMu.Lock()
203	failure := d.snapshotActiveFailureSignature
204	d.snapshotNoticeMu.Unlock()
205	require.Empty(t, failure, "successful resumed publication must not retain a failure signature")
206	require.Equal(t, closedRecord.Committed.Generation+1, resumedRecord.Committed.Generation,
207		"same-daemon resume must publish exactly committed generation+1")
208	generation, err := repository.LoadCheckpoint(context.Background(), resumed.incarnation, resumed.name, *resumedRecord.Committed)
209	require.NoError(t, err)
210	require.Equal(t, closedRecord.Committed, generation.ParentCheckpoint,
211		"same-daemon resume must use the committed terminal checkpoint as parent")
212}
213
214func snapshotLifecycleHello(intent uint8, name string) ports.Hello {
215	return ports.Hello{
216		Version: ports.ProtocolVersion,
217		Intent:  intent,
218		Name:    name,
219		Cwd:     "/tmp",
220		Size:    domain.Size{Cols: 80, Rows: 24},
221	}
222}
223
224func snapshotLifecycleRecord(t testing.TB, d *Daemon, name string) domain.CatalogueRecord {
225	t.Helper()
226	record, ok, err := d.catalogue.Record(name)
227	require.NoError(t, err)
228	require.True(t, ok, "catalogue record %q not found", name)
229	return record
230}

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