move_transaction.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5	"errors"
6
7	"github.com/bnema/vev/internal/domain"
8)
9
10// moveTopology is the private policy Interface consumed by moveTransaction.
11// Admission runs under daemon and ordered Session locks and must be read-only.
12// Publication runs under daemon, routing, ordered Session, and resize fences;
13// it must finish every fallible check before its first write.
14type moveTopology interface {
15	transactionRequest() moveTransactionRequest
16	validRequest() bool
17	admitLocked(*Daemon, *session, *session) error
18	afterAdmission(*Daemon)
19	willRetireSource() bool
20	resizeFences(*session, *session) *moveResizeFences
21	publishLocked(*Daemon, *session, *session) (moveTopologyPublication, error)
22}
23
24type moveTransactionRequest struct {
25	follow               bool
26	beforeFollow         func() error
27	afterFollow          func(*attachmentEffect)
28	operation            string
29	attachment           *attachedClient
30	attachmentCapability attachmentCapability
31	source               moveSessionLocator
32	destination          moveSessionLocator
33	logAttrs             []any
34}
35
36// moveTopologyPublication contains immutable topology outputs needed after the
37// in-memory publication point. No field grants authority to perform additional
38// topology writes.
39type moveTopologyPublication struct {
40	sourceTab, destinationTab *tab
41	movedPane                 *pane
42	movedPanes                []*pane
43	syncCleanup               syncTimerCleanup
44	oldTabCancel              context.CancelFunc
45	sourceTabRemoved          bool
46	sourceEmpty               bool
47}
48
49// moveTransaction owns the shared pane/tab Move phase protocol. Topology and
50// owner policy remain private Implementations behind moveTopology.
51type moveTransaction struct {
52	daemon   *Daemon
53	topology moveTopology
54	request  moveTransactionRequest
55
56	source      *session
57	destination *session
58
59	sourceAttachments []*attachedClient
60	sourceTransports  map[*attachedClient]transportSnapshot
61	frozenEffects     attachmentTransitionGuard
62	effectsFrozen     bool
63
64	followResult attachmentTransitionResult
65
66	publication moveTopologyPublication
67	publishErr  error
68
69	retiredParked      []parkedAttachmentRetirement
70	retiredAttachments []detachedAttachmentSnapshot
71	sourceMetadata     domain.CatalogueMetadataUpdate
72	sourceMetadataOK   bool
73	destMetadata       domain.CatalogueMetadataUpdate
74	destMetadataOK     bool
75	sourceName         string
76	destinationName    string
77}
78
79func (d *Daemon) executeMove(topology moveTopology) (result error) {
80	if d == nil || topology == nil {
81		return errMovePaneInvalid
82	}
83	t := moveTransaction{daemon: d, topology: topology, request: topology.transactionRequest()}
84	d.log.Info(t.request.operation+" move requested", t.request.logAttrs...)
85	defer func() {
86		result = normalizeMoveRejection(result)
87		if result != nil {
88			attrs := make([]any, 0, len(t.request.logAttrs)+2)
89			attrs = append(attrs, "err", result)
90			attrs = append(attrs, t.request.logAttrs...)
91			d.log.Warn(t.request.operation+" move rejected", attrs...)
92		}
93	}()
94	if !t.validRequest() {
95		return errMovePaneInvalid
96	}
97	if err := t.resolveSessions(); err != nil {
98		return err
99	}
100
101	if d.beforeMoveDispatch != nil {
102		d.beforeMoveDispatch()
103	}
104	unlockDispatch := lockMoveDispatch(t.source, t.destination)
105	dispatchHeld := true
106	defer func() {
107		if dispatchHeld {
108			unlockDispatch()
109		}
110	}()
111
112	reservation, err := d.reserveMoveLifecycles(t.source, t.destination)
113	if err != nil {
114		// Preserve a typed transient admission rejection (a KillAll purge owning
115		// the exact set) so the command path maps it retryably instead of
116		// misreporting the destination as gone.
117		if errors.Is(err, errPurgeAdmissionClosed) {
118			return err
119		}
120		return errMoveStaleTarget
121	}
122	reservationHeld := true
123	defer func() {
124		if reservationHeld {
125			reservation.Release()
126		}
127	}()
128	if d.afterMoveLifecycleReserved != nil {
129		d.afterMoveLifecycleReserved()
130	}
131
132	if err := t.admit(); err != nil {
133		return err
134	}
135	t.topology.afterAdmission(d)
136	if t.follows() && t.request.beforeFollow != nil {
137		if err := t.request.beforeFollow(); err != nil {
138			return err
139		}
140	}
141	if err := t.freezeSourceAttachments(); err != nil {
142		return err
143	}
144	defer func() {
145		if t.effectsFrozen {
146			t.frozenEffects.unfreeze()
147		}
148	}()
149
150	fences := t.topology.resizeFences(t.source, t.destination)
151	if fences == nil || !fences.acquire(func() bool {
152		t.publishErr = t.publishLocked()
153		return t.publishErr == nil
154	}) {
155		if t.publishErr != nil {
156			return t.publishErr
157		}
158		return errMoveStaleTarget
159	}
160	fences.Release()
161
162	postcommit := t.postcommitPlan(unlockDispatch, reservation)
163	t.effectsFrozen = false
164	dispatchHeld = false
165	reservationHeld = false
166	postcommit.execute(d)
167	return nil
168}
169
170func (t *moveTransaction) validRequest() bool {
171	return t != nil && t.topology != nil && t.request.operation != "" &&
172		t.request.source.ID != "" && t.request.destination.ID != "" && t.topology.validRequest()
173}
174
175func (t *moveTransaction) resolveSessions() error {
176	d := t.daemon
177	d.mu.Lock()
178	t.source = moveSessionForLocatorLocked(d, t.request.source)
179	t.destination = moveSessionForLocatorLocked(d, t.request.destination)
180	d.mu.Unlock()
181	if t.source == nil || t.destination == nil {
182		return errMoveStaleTarget
183	}
184	return nil
185}
186
187func (t *moveTransaction) attachmentAuthorityCurrentLocked() bool {
188	req := t.request
189	if req.attachment != nil && !attachmentRegisteredLocked(t.source, req.attachment) {
190		return false
191	}
192	if req.attachmentCapability.ac != nil &&
193		(req.attachmentCapability.ac != req.attachment || !req.attachmentCapability.currentInSessionLocked(t.source)) {
194		return false
195	}
196	return true
197}
198
199// admit snapshots exact common Attachment participants while the topology
200// Implementation snapshots only its private identity and layout state.
201func (t *moveTransaction) admit() error {
202	d := t.daemon
203	d.mu.Lock()
204	defer d.mu.Unlock()
205	if d.closing || d.sessions[t.source.id] != t.source || d.sessions[t.destination.id] != t.destination {
206		return errMoveStaleTarget
207	}
208	unlockSessions := lockAttachmentSessions(t.source, t.destination)
209	defer unlockSessions()
210	if !moveSessionLocatorCurrentLocked(t.source, t.request.source) ||
211		!moveSessionLocatorCurrentLocked(t.destination, t.request.destination) ||
212		!t.attachmentAuthorityCurrentLocked() {
213		return errMoveStaleTarget
214	}
215	if err := t.topology.admitLocked(d, t.source, t.destination); err != nil {
216		return err
217	}
218	t.sourceAttachments = t.source.snapshotAttachmentsLocked()
219	t.sourceTransports = make(map[*attachedClient]transportSnapshot, len(t.sourceAttachments))
220	for _, attachment := range t.sourceAttachments {
221		t.sourceTransports[attachment] = attachment.transportSnapshot()
222	}
223	return nil
224}
225
226// freezeSourceAttachments delegates effect admission and drain authority to the
227// attachment lifecycle Module. Move uses nonblocking acquisition to preserve
228// its ordering with final teardown.
229func (t *moveTransaction) freezeSourceAttachments() error {
230	if (!t.topology.willRetireSource() && !t.follows()) || len(t.sourceAttachments) == 0 {
231		return nil
232	}
233	participants := t.sourceAttachments
234	if !t.topology.willRetireSource() {
235		participants = []*attachedClient{t.request.attachment}
236	}
237	interrupts := make([]attachmentTransportInterrupt, 0, len(participants))
238	for _, attachment := range participants {
239		if t.follows() && attachment == t.request.attachment {
240			continue // The following connection must remain usable for first paint.
241		}
242		if transport := t.sourceTransports[attachment]; transport.transport != nil {
243			interrupts = append(interrupts, attachmentTransportInterrupt{ac: attachment, transport: transport})
244		}
245	}
246	frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{
247		interrupts:  interrupts,
248		nonblocking: true,
249		afterFrozen: func(attachment *attachedClient) {
250			if t.daemon.afterAttachmentEffectGateFrozen != nil {
251				t.daemon.afterAttachmentEffectGateFrozen("move-"+t.request.operation, attachment)
252			}
253		},
254	}, participants...)
255	if !frozen.acquired || !frozen.drained {
256		frozen.unfreeze()
257		return errMovePaneInvalid
258	}
259	t.frozenEffects = frozen
260	t.effectsFrozen = true
261	return nil
262}
263
264// publishLocked is the only shared architecture-lock window. Topology
265// Implementations perform all fallible private validation before their first
266// write. Common retirement and metadata capture are non-failing in-memory work.
267func (t *moveTransaction) publishLocked() error {
268	d := t.daemon
269	d.mu.Lock()
270	defer d.mu.Unlock()
271	if d.closing || d.sessions[t.source.id] != t.source || d.sessions[t.destination.id] != t.destination {
272		return errMoveStaleTarget
273	}
274	d.notices.routingMu.Lock()
275	defer d.notices.routingMu.Unlock()
276	unlockSessions := lockAttachmentSessions(t.source, t.destination)
277	defer unlockSessions()
278	if !moveSessionLocatorCurrentLocked(t.source, t.request.source) ||
279		!moveSessionLocatorCurrentLocked(t.destination, t.request.destination) ||
280		!t.attachmentAuthorityCurrentLocked() {
281		return errMoveStaleTarget
282	}
283	if t.topology.willRetireSource() && !sameMoveAttachmentsLocked(t.source, t.sourceAttachments) {
284		return errMoveStaleTarget
285	}
286
287	var follow *attachmentPublication
288	if t.follows() {
289		req := attachmentTransitionRequest{
290			source: t.source, target: t.destination, next: t.request.attachment,
291			expectedTransport: t.sourceTransports[t.request.attachment],
292			preflighted:       true, attachmentEffectsFrozen: true, ready: true,
293		}
294		if t.request.attachmentCapability.ac != nil {
295			req.sourceCapability = &t.request.attachmentCapability
296		}
297		var err error
298		follow, err = d.validateAttachmentTransitionPrelocked(req)
299		if err != nil {
300			return errMoveStaleTarget
301		}
302		// Topology sync migration takes coordinator locks itself. Session locks
303		// and the frozen gate keep membership/lease authority pinned meanwhile.
304		follow.unlockCoordinators()
305	}
306	publication, err := t.topology.publishLocked(d, t.source, t.destination)
307	if err != nil {
308		return err
309	}
310	t.publication = publication
311	if follow != nil {
312		follow.releaseCoordinators = lockAttachmentCoordinators(t.source, follow.sourceCoordinator, t.destination, follow.targetCoordinator)
313		follow.req.activateTargetTab = true
314		follow.req.targetTabIndex = indexMoveTabLocked(t.destination, publication.destinationTab)
315		t.followResult = d.publishAttachmentTransitionPrelocked(follow)
316		follow.unlockCoordinators()
317		if t.source != t.destination && !publication.sourceEmpty {
318			t.followResult.sourceGeometrySession = t.source
319		}
320	}
321	if publication.sourceEmpty {
322		d.unregisterSessionLocked(t.source)
323		t.retiredAttachments = detachMoveAttachmentsLocked(t.source, t.sourceTransports)
324		t.source.tabs = nil
325		d.purgeParkingForSessionLocked(t.source)
326		d.purgeSuspendedForSessionLocked(t.source)
327		t.retiredParked = d.purgeParkedForSessionLocked(t.source)
328	}
329	t.sourceName = t.source.name
330	t.destinationName = t.destination.name
331	if !t.source.ephemeral {
332		t.sourceMetadata = t.source.persistRecordLocked(max(d.nowUnixNano(), t.source.createdAt, int64(1))).MetadataUpdate()
333		t.sourceMetadataOK = true
334	}
335	if !t.destination.ephemeral && t.destination != t.source {
336		t.destMetadata = t.destination.persistRecordLocked(max(d.nowUnixNano(), t.destination.createdAt, int64(1))).MetadataUpdate()
337		t.destMetadataOK = true
338	}
339	return nil
340}
341
342func (t *moveTransaction) postcommitPlan(unlockDispatch func(), reservation *moveLifecycleReservation) movePostcommitPlan {
343	publication := t.publication
344	return movePostcommitPlan{
345		followResult:             t.followResult,
346		afterFollow:              t.request.afterFollow,
347		source:                   t.source,
348		destination:              t.destination,
349		sourceName:               t.sourceName,
350		destinationName:          t.destinationName,
351		sourceTab:                publication.sourceTab,
352		destinationTab:           publication.destinationTab,
353		movedPane:                publication.movedPane,
354		movedPanes:               publication.movedPanes,
355		operation:                t.request.operation,
356		sourceAttachments:        t.sourceAttachments,
357		syncCleanup:              publication.syncCleanup,
358		frozenEffects:            t.frozenEffects,
359		effectsFrozen:            t.effectsFrozen,
360		unlockDispatch:           unlockDispatch,
361		reservation:              reservation,
362		oldTabCancel:             publication.oldTabCancel,
363		sourceTabRemoved:         publication.sourceTabRemoved,
364		sourceEmpty:              publication.sourceEmpty,
365		retiredParked:            t.retiredParked,
366		retiredAttachments:       t.retiredAttachments,
367		sourceMetadata:           t.sourceMetadata,
368		sourceMetadataValid:      t.sourceMetadataOK,
369		destinationMetadata:      t.destMetadata,
370		destinationMetadataValid: t.destMetadataOK,
371	}
372}
373
374// Picker pane moves follow only when their source retires; tab moves always
375// follow. Command/API moves retain their existing topology-only semantics.
376func (t *moveTransaction) follows() bool {
377	return t.request.follow && t.request.attachment != nil &&
378		(t.request.operation == "tab" || t.topology.willRetireSource())
379}

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