remote_attach.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5	"errors"
6
7	"github.com/bnema/vev/internal/domain"
8	"github.com/bnema/vev/internal/ports"
9	"github.com/bnema/vev/internal/protocol"
10)
11
12// routeRemoteTargetWithContext is the exact-target branch for picker handoffs.
13// It is deliberately separate from legacy name routing: a lifecycle mismatch,
14// missing tab, broken record, or stopped-record race is a no-such-target error,
15// never an invitation to attach a same-name replacement.
16func (d *Daemon) routeRemoteTargetWithContext(ctx context.Context, h protocol.Hello, tr ports.ServerConnection) (*session, *attachedClient, error) {
17	if h.SessionTarget == nil {
18		return nil, nil, errors.New("daemon: missing remote target")
19	}
20	target := *h.SessionTarget
21	if err := target.Validate(); err != nil {
22		return nil, nil, &protoErr{protocol.ErrNoSuchTarget, "invalid remote target"}
23	}
24	if h.EnvironmentPolicy != protocol.EnvironmentPolicyDaemonOwned {
25		return nil, nil, &protoErr{protocol.ErrNoSuchTarget, "remote target requires daemon-owned environment"}
26	}
27	if h.Intent != protocol.IntentAttach && h.Intent != protocol.IntentResume {
28		return nil, nil, &protoErr{protocol.ErrNoSuchTarget, "remote target requires attach or resume"}
29	}
30	if err := d.waitForTargetRestore(ctx, target.SessionName); err != nil {
31		var protocolErr *protoErr
32		if errors.As(err, &protocolErr) && protocolErr.code == protocol.ErrInternal {
33			return nil, nil, &protoErr{protocol.ErrNoSuchTarget, "remote session is unavailable"}
34		}
35		return nil, nil, remoteTargetError(err)
36	}
37
38	d.mu.Lock()
39	if d.closing {
40		d.mu.Unlock()
41		return nil, nil, &protoErr{protocol.ErrServerShutdown, "daemon is shutting down"}
42	}
43	// Purge admission is already reserved by routeWithContext before this
44	// dispatch, so an admitted remote target must not re-check the transient
45	// gate: a purge that begins while this route is admitted waits for it and
46	// must not reject it.
47	if live := d.findByNameLocked(target.SessionName); live != nil {
48		if live.incarnation != target.LifecycleID {
49			d.mu.Unlock()
50			return nil, nil, &protoErr{protocol.ErrNoSuchTarget, "remote session lifecycle has changed"}
51		}
52		if _, ok := remoteTargetTabIndexLocked(live, target); !ok {
53			d.mu.Unlock()
54			return nil, nil, &protoErr{protocol.ErrNoSuchTarget, "remote tab no longer exists"}
55		}
56		ac, err := d.finishRouteAttach(live, tr, h.Size, h, false, false)
57		return live, ac, err
58	}
59
60	if !target.Stopped {
61		d.mu.Unlock()
62		return nil, nil, &protoErr{protocol.ErrNoSuchTarget, "live remote target has no active runtime"}
63	}
64	inactive, ok := d.inactive[target.SessionName]
65	if !ok || inactive.incarnation != target.LifecycleID {
66		d.mu.Unlock()
67		return nil, nil, &protoErr{protocol.ErrNoSuchTarget, "remote session lifecycle no longer exists"}
68	}
69	if !inactive.canResume() {
70		d.mu.Unlock()
71		return nil, nil, &protoErr{protocol.ErrNoSuchTarget, "remote session is unavailable"}
72	}
73	if _, ok := remoteTargetTabIndexInactive(inactive, target); !ok {
74		d.mu.Unlock()
75		return nil, nil, &protoErr{protocol.ErrNoSuchTarget, "remote stopped tab no longer exists"}
76	}
77
78	// Picker handoffs use the daemon's own environment and persisted CWD. The
79	// client's Env/Cwd fields remain present for wire compatibility but are not
80	// trusted for this branch.
81	env := copyEnvironment(d.baseEnv)
82	cwd := d.dirOrHome(inactive.cwd)
83	sess, err := d.resumeRemoteInactiveSessionLocked(target, cwd, h.Geometry(), env, inactive)
84	if err != nil {
85		d.mu.Unlock()
86		if errors.Is(err, errAttachmentTransition) || errors.Is(err, errSessionNameInUse) {
87			return nil, nil, remoteTargetError(err)
88		}
89		return nil, nil, err
90	}
91	// Like a snapshot restore, the resumed session starts from the daemon's
92	// environment until a local client attaches.
93	markEnvironmentProvisional(sess)
94	ac, err := d.finishRouteAttach(sess, tr, h.Size, h, true, false)
95	return sess, ac, err
96}
97
98func (d *Daemon) resumeRemoteInactiveSessionLocked(target protocol.SessionAttachTarget, cwd string, geometry domain.Geometry, env []string, expected inactiveSession) (*session, error) {
99	validate := func(current inactiveSession, _ domain.CatalogueRecord, authoritativeExists bool) bool {
100		if !target.Stopped || d.persistEnabled && !authoritativeExists {
101			return false
102		}
103		_, ok := target.ResolveTab(stoppedTabMetadata(current))
104		return ok
105	}
106	return d.createSessionLockedWithModeAndInactiveFence(target.SessionName, false, cwd, geometry, env, &expected, validate, expected.tabNames)
107}
108
109func (d *Daemon) sendRecentRouteNavigationActionForAttachment(effect *attachmentEffect, action protocol.RouteNavigationAction) error {
110	if action.SnapshotGeneration == 0 || action.Key == 0 || action.Generation == 0 {
111		return errAttachmentTransition
112	}
113	return effect.sendControl(action)
114}
115
116func (d *Daemon) sendCommittedRouteIdentityForAttachment(effect *attachmentEffect) error {
117	if !effect.current() || effect.sess == nil {
118		return errAttachmentTransition
119	}
120	effect.sess.mu.Lock()
121	identity := protocol.CommittedRouteIdentity{
122		Target:    protocol.ExactSessionTarget{LifecycleID: effect.sess.incarnation, SessionName: effect.sess.name},
123		Ephemeral: effect.sess.ephemeral,
124	}
125	effect.sess.mu.Unlock()
126	if err := identity.Validate(); err != nil {
127		return errAttachmentTransition
128	}
129	return effect.sendControl(identity)
130}
131
132func (d *Daemon) finishRouteAttach(sess *session, tr ports.ServerConnection, sz domain.Size, h protocol.Hello, routeCreated, purge bool) (*attachedClient, error) {
133	ac, attachErr := d.finishAttach(sess, tr, sz, h)
134	err := attachErr
135	if attachErr != nil && routeCreated {
136		if cleanupErr := d.killSessionIfEmpty(sess, protocol.ReasonSessionKilled, purge); cleanupErr != nil {
137			err = errors.Join(err, cleanupErr)
138		}
139	}
140	if verdict, ok := d.routeAttachStopVerdict(attachErr); ok {
141		return nil, verdict
142	}
143	if err == nil && routeCreated && ac != nil {
144		ac.routeCreatedSession = true
145		ac.routeSessionPurge = purge
146	}
147	return ac, err
148}
149
150// routeAttachStopVerdict reports the authoritative explicit-stop outcome when an
151// attach failure is the leaked transition of a handshake the stop tore down. It
152// deliberately inspects only attachErr: the rollback cleanup error the caller
153// may join onto it is internal bookkeeping and must never decide the verdict, so
154// an unrelated attach failure is reported as-is even while the daemon stops.
155func (d *Daemon) routeAttachStopVerdict(attachErr error) (error, bool) {
156	if attachErr == nil || !errors.Is(attachErr, errAttachmentTransition) || !d.purgeSupersededForShutdown() {
157		return nil, false
158	}
159	return &protoErr{protocol.ErrServerShutdown, "daemon is shutting down"}, true
160}
161
162// remoteTargetMatchesSessionLocked validates the exact lifecycle and tab
163// selector while the daemon registry lock is held. Callers use this before
164// changing resume ownership so a stale target cannot partially claim a session.
165func (d *Daemon) remoteTargetMatchesSessionLocked(sess *session, target protocol.SessionAttachTarget) bool {
166	if sess == nil || d.sessions[sess.id] != sess {
167		return false
168	}
169	sess.mu.Lock()
170	matches := sess.incarnation == target.LifecycleID && sess.name == target.SessionName
171	sess.mu.Unlock()
172	if !matches {
173		return false
174	}
175	_, ok := remoteTargetTabIndexLocked(sess, target)
176	return ok
177}
178
179func remoteTargetError(err error) error {
180	var protocolErr *protoErr
181	if errors.As(err, &protocolErr) {
182		return protocolErr
183	}
184	if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
185		return err
186	}
187	return &protoErr{protocol.ErrNoSuchTarget, "remote target is unavailable"}
188}
189
190func stoppedTabMetadata(stopped inactiveSession) []domain.TabSelectorTab {
191	records := stopped.tabRecords
192	if len(records) == 0 {
193		records = make([]domain.CatalogueTabRecord, len(stopped.tabNames))
194		for i, name := range stopped.tabNames {
195			records[i].Name = name
196		}
197	}
198	metadata := make([]domain.TabSelectorTab, 0, len(records))
199	for _, record := range records {
200		metadata = append(metadata, domain.TabSelectorTab{ID: record.StableID, Name: record.Name})
201	}
202	return metadata
203}
204
205func remoteTargetTabIndexInactive(inactive inactiveSession, target protocol.SessionAttachTarget) (int, bool) {
206	return target.ResolveTab(stoppedTabMetadata(inactive))
207}
208
209// remoteTargetTabIndexLocked resolves the selector against the live session's
210// current ordered tabs. Caller holds d.mu; this function takes only the
211// session/tab locks needed for a coherent metadata snapshot.
212func remoteTargetTabIndexLocked(sess *session, target protocol.SessionAttachTarget) (int, bool) {
213	if sess == nil {
214		return 0, false
215	}
216	sess.mu.Lock()
217	tabs := append([]*tab(nil), sess.tabs...)
218	sess.mu.Unlock()
219	metadata := make([]domain.TabSelectorTab, 0, len(tabs))
220	for _, tab := range tabs {
221		if tab == nil {
222			return 0, false
223		}
224		tab.mu.Lock()
225		metadata = append(metadata, domain.TabSelectorTab{ID: domain.TabStableID(tab.stableID), Name: tab.name})
226		tab.mu.Unlock()
227	}
228	return target.ResolveTab(metadata)
229}

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