v0.8.1 1f54f61

move_lifecycle.go RawHistoryFolder

1package daemon
2
3import (
4	"cmp"
5	"errors"
6	"slices"
7	"sync"
8)
9
10var errMoveLifecycleUnavailable = errors.New("move lifecycle is no longer available")
11
12// moveLifecycleReservation pins a unique, canonically ordered set of sessions
13// plus the daemon move gate. Release is idempotent so every abort path can use
14// defer without coordinating ownership with its caller.
15type moveLifecycleReservation struct {
16	daemon   *Daemon
17	sessions []*session
18	once     sync.Once
19}
20
21// reserveMoveLifecycles admits a move at the daemon gate before touching any
22// per-session lifecycle state. All session counts publish atomically while the
23// complete ordered teardown lock set is held.
24func (d *Daemon) reserveMoveLifecycles(source, destination *session) (*moveLifecycleReservation, error) {
25	if d == nil || source == nil || destination == nil {
26		return nil, errMoveLifecycleUnavailable
27	}
28
29	d.moveLifecycleMu.Lock()
30	if d.moveLifecycleClosing {
31		d.moveLifecycleMu.Unlock()
32		return nil, errMoveLifecycleUnavailable
33	}
34	d.mu.Lock()
35	closing := d.closing
36	purgeClosing := d.purgeAdmissionClosing
37	if !closing && !purgeClosing {
38		// A move transfers live state across the exact lifecycle set a KillAll
39		// purge captured, so it reserves the purge admission gate too: the purge
40		// drains this reservation before it snapshots, and a move arriving during
41		// the purge is rejected instead of racing the set.
42		d.purgeAdmissionActive++
43	}
44	d.mu.Unlock()
45	if closing {
46		d.moveLifecycleMu.Unlock()
47		return nil, errMoveLifecycleUnavailable
48	}
49	if purgeClosing {
50		// A transient KillAll purge owns the exact lifecycle set, so no move can
51		// reserve it. Unlike a shutdown this is retry state, so report the same
52		// typed admission outcome as the other command paths instead of a
53		// permanent "target gone" verdict.
54		d.moveLifecycleMu.Unlock()
55		return nil, errPurgeAdmissionClosed
56	}
57	d.moveLifecycleActive++
58	d.signalMoveLifecycleChangedLocked()
59	d.moveLifecycleMu.Unlock()
60	if d.afterMoveLifecycleGateBeforeTeardownLocks != nil {
61		d.afterMoveLifecycleGateBeforeTeardownLocks()
62	}
63
64	sessions := uniqueMoveSessions(source, destination)
65	for _, sess := range sessions {
66		sess.teardownMu.Lock()
67	}
68	for _, sess := range sessions {
69		if sess.teardownActive {
70			unlockMoveSessions(sessions)
71			d.moveLifecycleMu.Lock()
72			d.releaseMoveAdmissionLocked()
73			d.moveLifecycleMu.Unlock()
74			return nil, errMoveLifecycleUnavailable
75		}
76	}
77	for _, sess := range sessions {
78		sess.moveReservations++
79		sess.signalTeardownChangedLocked()
80	}
81	unlockMoveSessions(sessions)
82
83	return &moveLifecycleReservation{daemon: d, sessions: sessions}, nil
84}
85
86func uniqueMoveSessions(source, destination *session) []*session {
87	sessions := []*session{source}
88	if destination != source {
89		sessions = append(sessions, destination)
90	}
91	slices.SortFunc(sessions, func(left, right *session) int {
92		return cmp.Compare(left.id, right.id)
93	})
94	return sessions
95}
96
97func unlockMoveSessions(sessions []*session) {
98	for i := len(sessions) - 1; i >= 0; i-- {
99		sessions[i].teardownMu.Unlock()
100	}
101}
102
103func (r *moveLifecycleReservation) Release() {
104	if r == nil {
105		return
106	}
107	r.once.Do(func() {
108		r.daemon.moveLifecycleMu.Lock()
109		for _, sess := range r.sessions {
110			sess.teardownMu.Lock()
111		}
112		for _, sess := range r.sessions {
113			sess.moveReservations--
114			sess.signalTeardownChangedLocked()
115		}
116		unlockMoveSessions(r.sessions)
117		r.daemon.releaseMoveAdmissionLocked()
118		r.daemon.moveLifecycleMu.Unlock()
119	})
120}
121
122// closeMoveLifecycles rejects new moves, publishes daemon shutdown to ordinary
123// routing, then drains reservations without holding either daemon mutex. It
124// deliberately does not cancel pane processes: the caller must first publish the
125// shutdown-preservation policy for every live session, then call
126// cancelPaneProcesses, so a PTY EOF caused by the cancellation can never be
127// mistaken for a natural final-shell exit that purges a named session.
128func (d *Daemon) closeMoveLifecycles() {
129	if d == nil {
130		return
131	}
132	d.moveLifecycleMu.Lock()
133	if !d.moveLifecycleClosing {
134		d.moveLifecycleClosing = true
135		d.signalMoveLifecycleChangedLocked()
136	}
137	d.moveLifecycleMu.Unlock()
138
139	// Publish ordinary shutdown admission separately: no move gate mutex is held
140	// while taking the architecture registry lock.
141	d.mu.Lock()
142	d.closing = true
143	d.mu.Unlock()
144
145	d.moveLifecycleMu.Lock()
146	for d.moveLifecycleActive != 0 {
147		changed := d.moveLifecycleChangeLocked()
148		d.moveLifecycleMu.Unlock()
149		<-changed
150		d.moveLifecycleMu.Lock()
151	}
152	d.moveLifecycleMu.Unlock()
153}
154
155// cancelPaneProcesses cancels the daemon-wide pane process lifetime, closing
156// every PTY. It must run only after closeMoveLifecycles has drained the final
157// move reservation and after every live session has reserved its shutdown
158// teardown (see terminateAllForShutdown).
159func (d *Daemon) cancelPaneProcesses() {
160	if d == nil {
161		return
162	}
163	d.moveLifecycleMu.Lock()
164	cancel := d.paneProcessCancel
165	d.moveLifecycleMu.Unlock()
166	if cancel != nil {
167		cancel()
168	}
169}
170
171// releaseMoveAdmissionLocked drops one move's move-gate and KillAll purge-gate
172// reservations. Caller holds moveLifecycleMu; the purge gate is guarded by mu.
173func (d *Daemon) releaseMoveAdmissionLocked() {
174	d.moveLifecycleActive--
175	d.signalMoveLifecycleChangedLocked()
176	d.mu.Lock()
177	d.purgeAdmissionActive--
178	d.signalPurgeAdmissionChangedLocked()
179	d.mu.Unlock()
180}
181
182// moveLifecycleChangeLocked requires moveLifecycleMu, not d.mu.
183func (d *Daemon) moveLifecycleChangeLocked() chan struct{} {
184	if d.moveLifecycleChanged == nil {
185		d.moveLifecycleChanged = make(chan struct{})
186	}
187	return d.moveLifecycleChanged
188}
189
190// signalMoveLifecycleChangedLocked requires moveLifecycleMu, not d.mu.
191func (d *Daemon) signalMoveLifecycleChangedLocked() {
192	close(d.moveLifecycleChangeLocked())
193	d.moveLifecycleChanged = make(chan struct{})
194}

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