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}