v0.2.0 80c61e3

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)
10
11// routeRemoteTargetWithContext is the exact-target branch for picker handoffs.
12// It is deliberately separate from legacy name routing: a lifecycle mismatch,
13// missing tab, broken record, or stopped-record race is a no-such-target error,
14// never an invitation to attach a same-name replacement.
15func (d *Daemon) routeRemoteTargetWithContext(ctx context.Context, h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) {
16	if h.RemoteTarget == nil {
17		return nil, nil, errors.New("daemon: missing remote target")
18	}
19	target := *h.RemoteTarget
20	if err := target.Validate(); err != nil {
21		return nil, nil, &protoErr{ports.ErrNoSuchTarget, "invalid remote target"}
22	}
23	if h.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {
24		return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote target requires daemon-owned environment"}
25	}
26	if h.Intent != ports.IntentAttach && h.Intent != ports.IntentResume {
27		return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote target requires attach or resume"}
28	}
29	if err := d.waitForTargetRestore(ctx, target.SessionName); err != nil {
30		return nil, nil, remoteTargetError(err)
31	}
32
33	d.mu.Lock()
34	if d.closing {
35		d.mu.Unlock()
36		return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
37	}
38	if live := d.findByNameLocked(target.SessionName); live != nil {
39		if live.incarnation != target.LifecycleID {
40			d.mu.Unlock()
41			return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote session lifecycle has changed"}
42		}
43		if target.Stopped && target.LiveTabID != "" {
44			d.mu.Unlock()
45			return nil, nil, &protoErr{ports.ErrNoSuchTarget, "stopped target has a live tab selector"}
46		}
47		if _, ok := remoteTargetTabIndexLocked(live, target); !ok {
48			d.mu.Unlock()
49			return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote tab no longer exists"}
50		}
51		ac, err := d.finishRouteAttach(live, tr, h.Size, terminalEnv{TrueColor: h.TrueColor}, h, false, false)
52		return live, ac, err
53	}
54
55	stopped, ok := d.stopped[target.SessionName]
56	if !ok || stopped.purging || stopped.incarnation != target.LifecycleID {
57		d.mu.Unlock()
58		return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote session lifecycle no longer exists"}
59	}
60	if stopped.state == ports.SessionBroken || stopped.record.DegradedReason != "" {
61		d.mu.Unlock()
62		return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote session is broken"}
63	}
64	if _, ok := remoteTargetTabIndexStopped(stopped, target); !ok {
65		d.mu.Unlock()
66		return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote stopped tab no longer exists"}
67	}
68
69	// Picker handoffs use the daemon's own environment and persisted CWD. The
70	// client's Env/Cwd fields remain present for wire compatibility but are not
71	// trusted for this branch.
72	env := copyEnvironment(d.baseEnv)
73	cwd := d.dirOrHome(stopped.cwd)
74	sess, err := d.createSessionLockedWithMode(target.SessionName, false, cwd, h.Size, terminalEnv{TrueColor: h.TrueColor}, env, stopped.tabNames)
75	if err != nil {
76		d.mu.Unlock()
77		return nil, nil, err
78	}
79	ac, err := d.finishRouteAttach(sess, tr, h.Size, terminalEnv{TrueColor: h.TrueColor}, h, true, false)
80	return sess, ac, err
81}
82
83func (d *Daemon) sendNavigationActionForAttachment(token attachmentConnectionToken, action ports.NavigationAction) error {
84	payload := ports.MarshalNavigationAction(action)
85	if payload == nil {
86		return errAttachmentTransition
87	}
88	return token.sendControl(ports.Frame{Type: ports.MsgNavigationAction, Payload: payload})
89}
90
91func (d *Daemon) sendRecentRouteNavigationActionForAttachment(token attachmentConnectionToken, action ports.RouteNavigationAction) error {
92	payload, err := ports.MarshalRouteNavigationAction(action)
93	if err != nil {
94		return errAttachmentTransition
95	}
96	return token.sendControl(ports.Frame{Type: ports.MsgNavigateRecentRoute, Payload: payload})
97}
98
99func (d *Daemon) sendCommittedRouteIdentityForAttachment(token attachmentConnectionToken) error {
100	if token.sess == nil {
101		return errAttachmentTransition
102	}
103	token.sess.mu.Lock()
104	identity := ports.CommittedRouteIdentity{
105		Target:    ports.ExactSessionTarget{LifecycleID: token.sess.incarnation, SessionName: token.sess.name},
106		Ephemeral: token.sess.ephemeral,
107	}
108	token.sess.mu.Unlock()
109	payload, err := ports.MarshalCommittedRouteIdentity(identity)
110	if err != nil {
111		return errAttachmentTransition
112	}
113	return token.sendControl(ports.Frame{Type: ports.MsgCommittedRouteIdentity, Payload: payload})
114}
115
116func (d *Daemon) finishRouteAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello, routeCreated, purge bool) (*attachedClient, error) {
117	ac, err := d.finishAttach(sess, tr, sz, term, h)
118	if err != nil && routeCreated {
119		if cleanupErr := d.killSession(sess, ports.ReasonSessionKilled, purge); cleanupErr != nil {
120			err = errors.Join(err, cleanupErr)
121		}
122		return nil, err
123	}
124	if err == nil && routeCreated && ac != nil {
125		ac.routeCreatedSession = true
126		ac.routeSessionPurge = purge
127	}
128	return ac, err
129}
130
131// remoteTargetMatchesSessionLocked validates the exact lifecycle and tab
132// selector while the daemon registry lock is held. Callers use this before
133// changing resume ownership so a stale target cannot partially claim a session.
134func (d *Daemon) remoteTargetMatchesSessionLocked(sess *session, target domain.RemoteSessionTarget) bool {
135	if sess == nil || d.sessions[sess.id] != sess {
136		return false
137	}
138	sess.mu.Lock()
139	matches := sess.incarnation == target.LifecycleID && sess.name == target.SessionName
140	sess.mu.Unlock()
141	if !matches {
142		return false
143	}
144	_, ok := remoteTargetTabIndexLocked(sess, target)
145	return ok
146}
147
148func remoteTargetError(err error) error {
149	var protocol *protoErr
150	if errors.As(err, &protocol) {
151		return protocol
152	}
153	if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
154		return err
155	}
156	return &protoErr{ports.ErrNoSuchTarget, "remote target is unavailable"}
157}
158
159func stoppedTabMetadata(stopped stoppedSession) []domain.TabSelectorTab {
160	records := stopped.tabRecords
161	if len(records) == 0 {
162		records = make([]domain.CatalogueTabRecord, len(stopped.tabNames))
163		for i, name := range stopped.tabNames {
164			records[i].Name = name
165		}
166	}
167	metadata := make([]domain.TabSelectorTab, 0, len(records))
168	for _, record := range records {
169		metadata = append(metadata, domain.TabSelectorTab{ID: record.StableID, Name: record.Name})
170	}
171	return metadata
172}
173
174func remoteTargetTabIndexStopped(stopped stoppedSession, target domain.RemoteSessionTarget) (int, bool) {
175	return target.StoppedTab.Resolve(stoppedTabMetadata(stopped))
176}
177
178// remoteTargetTabIndexLocked resolves the selector against the live session's
179// current ordered tabs. Caller holds d.mu; this function takes only the
180// session/tab locks needed for a coherent metadata snapshot.
181func remoteTargetTabIndexLocked(sess *session, target domain.RemoteSessionTarget) (int, bool) {
182	if sess == nil {
183		return 0, false
184	}
185	sess.mu.Lock()
186	tabs := append([]*tab(nil), sess.tabs...)
187	sess.mu.Unlock()
188	metadata := make([]domain.TabSelectorTab, 0, len(tabs))
189	for _, tab := range tabs {
190		if tab == nil {
191			return 0, false
192		}
193		tab.mu.Lock()
194		metadata = append(metadata, domain.TabSelectorTab{ID: domain.TabStableID(tab.stableID), Name: tab.name})
195		tab.mu.Unlock()
196	}
197	if target.Stopped {
198		return target.StoppedTab.Resolve(metadata)
199	}
200	if target.LiveTabID == "" {
201		return 0, false
202	}
203	found := -1
204	for i, tab := range metadata {
205		if tab.ID != target.LiveTabID {
206			continue
207		}
208		if found >= 0 {
209			return 0, false
210		}
211		found = i
212	}
213	return found, found >= 0
214}

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