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 ac, err := d.finishRouteAttach(sess, tr, h.Size, h, true, false)
92 return sess, ac, err
93}
94
95func (d *Daemon) resumeRemoteInactiveSessionLocked(target protocol.SessionAttachTarget, cwd string, geometry domain.Geometry, env []string, expected inactiveSession) (*session, error) {
96 validate := func(current inactiveSession, _ domain.CatalogueRecord, authoritativeExists bool) bool {
97 if !target.Stopped || d.persistEnabled && !authoritativeExists {
98 return false
99 }
100 _, ok := target.ResolveTab(stoppedTabMetadata(current))
101 return ok
102 }
103 return d.createSessionLockedWithModeAndInactiveFence(target.SessionName, false, cwd, geometry, env, &expected, validate, expected.tabNames)
104}
105
106func (d *Daemon) sendRecentRouteNavigationActionForAttachment(effect *attachmentEffect, action protocol.RouteNavigationAction) error {
107 if action.SnapshotGeneration == 0 || action.Key == 0 || action.Generation == 0 {
108 return errAttachmentTransition
109 }
110 return effect.sendControl(action)
111}
112
113func (d *Daemon) sendCommittedRouteIdentityForAttachment(effect *attachmentEffect) error {
114 if !effect.current() || effect.sess == nil {
115 return errAttachmentTransition
116 }
117 effect.sess.mu.Lock()
118 identity := protocol.CommittedRouteIdentity{
119 Target: protocol.ExactSessionTarget{LifecycleID: effect.sess.incarnation, SessionName: effect.sess.name},
120 Ephemeral: effect.sess.ephemeral,
121 }
122 effect.sess.mu.Unlock()
123 if err := identity.Validate(); err != nil {
124 return errAttachmentTransition
125 }
126 return effect.sendControl(identity)
127}
128
129func (d *Daemon) finishRouteAttach(sess *session, tr ports.ServerConnection, sz domain.Size, h protocol.Hello, routeCreated, purge bool) (*attachedClient, error) {
130 ac, attachErr := d.finishAttach(sess, tr, sz, h)
131 err := attachErr
132 if attachErr != nil && routeCreated {
133 if cleanupErr := d.killSessionIfEmpty(sess, protocol.ReasonSessionKilled, purge); cleanupErr != nil {
134 err = errors.Join(err, cleanupErr)
135 }
136 }
137 if verdict, ok := d.routeAttachStopVerdict(attachErr); ok {
138 return nil, verdict
139 }
140 if err == nil && routeCreated && ac != nil {
141 ac.routeCreatedSession = true
142 ac.routeSessionPurge = purge
143 }
144 return ac, err
145}
146
147// routeAttachStopVerdict reports the authoritative explicit-stop outcome when an
148// attach failure is the leaked transition of a handshake the stop tore down. It
149// deliberately inspects only attachErr: the rollback cleanup error the caller
150// may join onto it is internal bookkeeping and must never decide the verdict, so
151// an unrelated attach failure is reported as-is even while the daemon stops.
152func (d *Daemon) routeAttachStopVerdict(attachErr error) (error, bool) {
153 if attachErr == nil || !errors.Is(attachErr, errAttachmentTransition) || !d.purgeSupersededForShutdown() {
154 return nil, false
155 }
156 return &protoErr{protocol.ErrServerShutdown, "daemon is shutting down"}, true
157}
158
159// remoteTargetMatchesSessionLocked validates the exact lifecycle and tab
160// selector while the daemon registry lock is held. Callers use this before
161// changing resume ownership so a stale target cannot partially claim a session.
162func (d *Daemon) remoteTargetMatchesSessionLocked(sess *session, target protocol.SessionAttachTarget) bool {
163 if sess == nil || d.sessions[sess.id] != sess {
164 return false
165 }
166 sess.mu.Lock()
167 matches := sess.incarnation == target.LifecycleID && sess.name == target.SessionName
168 sess.mu.Unlock()
169 if !matches {
170 return false
171 }
172 _, ok := remoteTargetTabIndexLocked(sess, target)
173 return ok
174}
175
176func remoteTargetError(err error) error {
177 var protocolErr *protoErr
178 if errors.As(err, &protocolErr) {
179 return protocolErr
180 }
181 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
182 return err
183 }
184 return &protoErr{protocol.ErrNoSuchTarget, "remote target is unavailable"}
185}
186
187func stoppedTabMetadata(stopped inactiveSession) []domain.TabSelectorTab {
188 records := stopped.tabRecords
189 if len(records) == 0 {
190 records = make([]domain.CatalogueTabRecord, len(stopped.tabNames))
191 for i, name := range stopped.tabNames {
192 records[i].Name = name
193 }
194 }
195 metadata := make([]domain.TabSelectorTab, 0, len(records))
196 for _, record := range records {
197 metadata = append(metadata, domain.TabSelectorTab{ID: record.StableID, Name: record.Name})
198 }
199 return metadata
200}
201
202func remoteTargetTabIndexInactive(inactive inactiveSession, target protocol.SessionAttachTarget) (int, bool) {
203 return target.ResolveTab(stoppedTabMetadata(inactive))
204}
205
206// remoteTargetTabIndexLocked resolves the selector against the live session's
207// current ordered tabs. Caller holds d.mu; this function takes only the
208// session/tab locks needed for a coherent metadata snapshot.
209func remoteTargetTabIndexLocked(sess *session, target protocol.SessionAttachTarget) (int, bool) {
210 if sess == nil {
211 return 0, false
212 }
213 sess.mu.Lock()
214 tabs := append([]*tab(nil), sess.tabs...)
215 sess.mu.Unlock()
216 metadata := make([]domain.TabSelectorTab, 0, len(tabs))
217 for _, tab := range tabs {
218 if tab == nil {
219 return 0, false
220 }
221 tab.mu.Lock()
222 metadata = append(metadata, domain.TabSelectorTab{ID: domain.TabStableID(tab.stableID), Name: tab.name})
223 tab.mu.Unlock()
224 }
225 return target.ResolveTab(metadata)
226}