v0.9.1 ece7682

move_persistence_test.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5	"errors"
6	"sync"
7	"testing"
8
9	"github.com/stretchr/testify/require"
10
11	"github.com/bnema/vev/internal/domain"
12	"github.com/bnema/vev/internal/ports"
13	recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
14)
15
16type movePersistenceCatalogue struct {
17	mu                 sync.Mutex
18	records            map[string]domain.CatalogueRecord
19	events             []string
20	source             *session
21	destination        *session
22	requireSourceDirty bool
23	requireDestDirty   bool
24	dirtyBeforeUpdate  bool
25}
26
27func (c *movePersistenceCatalogue) Records() ([]domain.CatalogueRecord, error) { return nil, nil }
28func (c *movePersistenceCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) {
29	c.mu.Lock()
30	defer c.mu.Unlock()
31	record, ok := c.records[name]
32	return record, ok, nil
33}
34func (c *movePersistenceCatalogue) Create(domain.CatalogueRecord) error          { return nil }
35func (c *movePersistenceCatalogue) Replace(string, domain.CatalogueRecord) error { return nil }
36func (c *movePersistenceCatalogue) Rename(string, domain.CatalogueRecord) error  { return nil }
37func (c *movePersistenceCatalogue) Delete(name string) error {
38	c.mu.Lock()
39	defer c.mu.Unlock()
40	delete(c.records, name)
41	c.events = append(c.events, "delete:"+name)
42	return nil
43}
44func (c *movePersistenceCatalogue) UpdateMetadata(update domain.CatalogueMetadataUpdate) error {
45	sourceDirty := true
46	if c.requireSourceDirty {
47		c.source.snapshotMu.Lock()
48		sourceDirty = c.source.snapDirty.Load() && c.source.snapshotGeneration > 0
49		c.source.snapshotMu.Unlock()
50	}
51	destinationDirty := true
52	if c.requireDestDirty {
53		c.destination.snapshotMu.Lock()
54		destinationDirty = c.destination.snapDirty.Load() && c.destination.snapshotGeneration > 0
55		c.destination.snapshotMu.Unlock()
56	}
57	c.mu.Lock()
58	defer c.mu.Unlock()
59	c.dirtyBeforeUpdate = c.dirtyBeforeUpdate && sourceDirty && destinationDirty
60	c.events = append(c.events, "metadata:"+update.Name)
61	return nil
62}
63func (c *movePersistenceCatalogue) Sync() error  { return nil }
64func (c *movePersistenceCatalogue) Close() error { return nil }
65
66type movePersistenceRepository struct {
67	noOpSnapshotRepository
68	mu           sync.Mutex
69	events       []string
70	publish      error
71	daemon       *Daemon
72	source       *session
73	destination  *session
74	moved        *tab
75	pane         *pane
76	outsideLocks bool
77}
78
79func (r *movePersistenceRepository) Publish(context.Context, ports.SnapshotPublication) error {
80	outsideLocks := true
81	unlocks := make([]func(), 0, 7)
82	candidates := make([]struct {
83		tryLock func() bool
84		unlock  func()
85	}, 0, 7)
86	if r.daemon != nil {
87		candidates = append(candidates, struct {
88			tryLock func() bool
89			unlock  func()
90		}{r.daemon.mu.TryLock, r.daemon.mu.Unlock})
91	}
92	if r.source != nil {
93		candidates = append(candidates,
94			struct {
95				tryLock func() bool
96				unlock  func()
97			}{r.source.mu.TryLock, r.source.mu.Unlock},
98			struct {
99				tryLock func() bool
100				unlock  func()
101			}{r.source.layoutApplyMu.TryLock, r.source.layoutApplyMu.Unlock})
102	}
103	if r.destination != nil {
104		candidates = append(candidates,
105			struct {
106				tryLock func() bool
107				unlock  func()
108			}{r.destination.mu.TryLock, r.destination.mu.Unlock},
109			struct {
110				tryLock func() bool
111				unlock  func()
112			}{r.destination.layoutApplyMu.TryLock, r.destination.layoutApplyMu.Unlock})
113	}
114	if r.moved != nil {
115		candidates = append(candidates, struct {
116			tryLock func() bool
117			unlock  func()
118		}{r.moved.mu.TryLock, r.moved.mu.Unlock})
119	}
120	if r.pane != nil {
121		candidates = append(candidates, struct {
122			tryLock func() bool
123			unlock  func()
124		}{r.pane.mu.TryLock, r.pane.mu.Unlock})
125	}
126	for _, candidate := range candidates {
127		if candidate.tryLock() {
128			unlocks = append(unlocks, candidate.unlock)
129		} else {
130			outsideLocks = false
131		}
132	}
133	for i := len(unlocks) - 1; i >= 0; i-- {
134		unlocks[i]()
135	}
136	r.mu.Lock()
137	r.events = append(r.events, "publish")
138	r.outsideLocks = outsideLocks
139	r.mu.Unlock()
140	return r.publish
141}
142
143func (r *movePersistenceRepository) DeleteIncarnation(_ context.Context, _ domain.IncarnationID) error {
144	r.mu.Lock()
145	r.events = append(r.events, "purge")
146	r.mu.Unlock()
147	return nil
148}
149
150func TestMoveTabPersistenceMatrix(t *testing.T) {
151	tests := []struct {
152		name                      string
153		sourceNamed               bool
154		destinationNamed          bool
155		finalSource               bool
156		wantSourceGeneration      bool
157		wantDestinationGeneration bool
158		wantMetadata              []string
159		wantPurge                 bool
160	}{
161		{name: "named to named", sourceNamed: true, destinationNamed: true, wantSourceGeneration: true, wantDestinationGeneration: true, wantMetadata: []string{"metadata:work", "metadata:destination"}},
162		{name: "ephemeral to named", destinationNamed: true, wantDestinationGeneration: true, wantMetadata: []string{"metadata:destination"}},
163		{name: "named to named final source", sourceNamed: true, destinationNamed: true, finalSource: true, wantDestinationGeneration: true, wantMetadata: []string{"metadata:destination"}, wantPurge: true},
164		{name: "named to ephemeral final source", sourceNamed: true, finalSource: true, wantPurge: true},
165		{name: "ephemeral to ephemeral", finalSource: true},
166	}
167
168	for _, tt := range tests {
169		t.Run(tt.name, func(t *testing.T) {
170			ptyCount := 2
171			if tt.finalSource {
172				ptyCount = 1
173			}
174			ptys := make([]ports.PTY, ptyCount)
175			for i := range ptys {
176				ptys[i] = newQuietPTY()
177			}
178			d, source, client, _ := newManualSessionWithPTYs(t, ptys...)
179			if tt.finalSource {
180				clearAttachmentsForTest(source)
181				client.setSession(nil)
182			}
183			source.incarnation = domain.IncarnationID{1}
184			source.ephemeral = !tt.sourceNamed
185			source.snapEligible.Store(tt.sourceNamed)
186			source.snapshotWake = d.snapshots.wake
187			moved := source.tabs[0]
188			moved.stableID = "moved-tab"
189
190			destination := addMoveTabTestSession(d, "destination", "destination-tab")
191			destination.ephemeral = !tt.destinationNamed
192			destination.snapEligible.Store(tt.destinationNamed)
193			destination.snapshotWake = d.snapshots.wake
194
195			catalogue := &movePersistenceCatalogue{
196				records:            map[string]domain.CatalogueRecord{},
197				source:             source,
198				destination:        destination,
199				requireSourceDirty: tt.sourceNamed && !tt.finalSource,
200				requireDestDirty:   tt.destinationNamed,
201				dirtyBeforeUpdate:  true,
202			}
203			if tt.sourceNamed {
204				catalogue.records[source.name] = domain.CatalogueRecord{Name: source.name, IncarnationID: source.incarnation}
205			}
206			if tt.destinationNamed {
207				catalogue.records[destination.name] = domain.CatalogueRecord{Name: destination.name, IncarnationID: destination.incarnation}
208			}
209			repository := &movePersistenceRepository{}
210			WithCatalogue(catalogue, nil)(d)
211			WithSnapshotRepository(repository)(d)
212			WithRecoveryCoordinator(recoveryusecase.NewCoordinator(catalogue, repository, nil))(d)
213
214			require.NoError(t, d.moveTab(moveTabRequest{
215				Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
216				Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
217			}))
218
219			source.snapshotMu.Lock()
220			sourceGeneration := source.snapshotGeneration
221			source.snapshotMu.Unlock()
222			destination.snapshotMu.Lock()
223			destinationGeneration := destination.snapshotGeneration
224			destination.snapshotMu.Unlock()
225			require.Equal(t, tt.wantSourceGeneration, sourceGeneration > 0)
226			require.Equal(t, tt.wantDestinationGeneration, destinationGeneration > 0)
227			catalogue.mu.Lock()
228			metadata := append([]string(nil), catalogue.events...)
229			dirtyBeforeUpdate := catalogue.dirtyBeforeUpdate
230			catalogue.mu.Unlock()
231			require.True(t, dirtyBeforeUpdate, "snapshot dirtiness must be admitted before catalogue metadata")
232			if tt.wantPurge {
233				require.Equal(t, append(append([]string(nil), tt.wantMetadata...), "delete:"+source.name), metadata)
234			} else {
235				require.ElementsMatch(t, tt.wantMetadata, metadata)
236			}
237			repository.mu.Lock()
238			purged := len(repository.events) == 1 && repository.events[0] == "purge"
239			repository.mu.Unlock()
240			require.Equal(t, tt.wantPurge, purged)
241			require.Same(t, moved, destination.tabs[len(destination.tabs)-1])
242		})
243	}
244}
245
246func TestMoveSnapshotPublicationFailureReportsWithoutRollback(t *testing.T) {
247	d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
248	source.ephemeral = true
249	moved := source.tabs[0]
250	moved.stableID = "moved-tab"
251	destination := addMoveTabTestSession(d, "destination", "destination-tab")
252	destination.ephemeral = false
253	destination.snapEligible.Store(true)
254	destination.snapshotWake = d.snapshots.wake
255
256	repository := &movePersistenceRepository{
257		publish: errors.New("injected publication failure"),
258		daemon:  d, source: source, destination: destination, moved: moved, pane: moved.focusedPane(),
259	}
260	WithSnapshotRepository(repository)(d)
261	startSnapshotEncodeWorker(t, d)
262
263	require.NoError(t, d.moveTab(moveTabRequest{
264		Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
265		Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
266	}))
267	d.scheduleEligibleRepositorySnapshots()
268	awaitSnapshotIdle(t, destination)
269
270	require.Same(t, moved, destination.tabs[len(destination.tabs)-1], "repository failure must not roll back live membership")
271	require.Same(t, destination, moved.focusedPane().ownerSnapshot().session)
272	require.True(t, destination.snapDirty.Load(), "failed publication remains scheduler-retryable")
273	repository.mu.Lock()
274	require.Equal(t, []string{"publish"}, repository.events, "move persistence uses only the existing snapshot publication format")
275	require.True(t, repository.outsideLocks, "repository publication must run outside architecture locks")
276	repository.mu.Unlock()
277	history := d.notices.history()
278	require.NotEmpty(t, history)
279	require.Equal(t, domain.NoticeSnapshotWrite, history[len(history)-1].Code)
280}

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