1package client
2
3import (
4 "errors"
5
6 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/ports"
8 "github.com/bnema/vev/internal/protocol"
9)
10
11// Routes and daemon navigation while attached.
12//
13// The supervisor publishes the client route ledger to the serving daemon after
14// the committed initial publication, whenever the attached session changes,
15// and on every broker publication. The daemon renders the status-bar MRU,
16// other-session bells, and the palette's remote, recent-route, and
17// create-on-host rows from it; it never monitors another daemon.
18//
19// The daemon never dials either. Its navigation requests reach the
20// supervisor, which settles them exactly like a picker commit: another tab of
21// the attached session switches in place with SelectTab, anything else detaches
22// cleanly and swaps through Connecting.
23//
24// - RouteNavigationAction and RouteCreateSessionAction name a reference of
25// the published ledger; the supervisor resolves it against the latest
26// broker catalogue and answers a refusal with the typed failure the daemon
27// turns into a toast.
28// - An endpoint-empty AttachTarget names a lifecycle on the serving daemon
29// itself (a stopped session or a close-and-dial handoff); the same-peer
30// offer is confirmed by the worker in place.
31//
32// Every method runs on the supervisor's run goroutine.
33
34// brokerChanged is the adopted connection's publication wake, or nil.
35func (s *Supervisor) brokerChanged() <-chan struct{} {
36 if supervisorNil(s.readySub) {
37 return nil
38 }
39 return s.readySub.Changed()
40}
41
42// publishRoutes rebuilds the ledger over the live attachment and hands a
43// changed snapshot, or the first one of this attachment, to its worker.
44func (s *Supervisor) publishRoutes(service ports.BrokerNavigator, run *attachmentRun, request ports.BrokerOpenStreamRequest) {
45 target, _, known := s.attachments.committedView()
46 if !known {
47 return
48 }
49 if s.routes == nil {
50 s.routes = newRouteLedger()
51 }
52 snapshot, changed := s.routes.build(service.Snapshot(), routeActive{known: true, authority: requestAuthority(request), target: target})
53 if snapshot.Generation == 0 || (!changed && s.routesSent == run.token) {
54 return
55 }
56 if s.attachments.publishRoutes(run.token, snapshot) {
57 s.routesSent = run.token
58 }
59}
60
61func requestAuthority(request ports.BrokerOpenStreamRequest) routeAuthority {
62 if request.Local {
63 return routeAuthority{local: true}
64 }
65 return routeAuthority{endpoint: request.Endpoint}
66}
67
68// settleDaemonNavigation carries out one daemon navigation request over the
69// live attachment.
70func (s *Supervisor) settleDaemonNavigation(service ports.BrokerNavigator, overlay *attachmentPickerOverlay, message protocol.ServerMessage) {
71 target, ok := s.resolveDaemonNavigation(service, overlay.run.token, overlay.request, message)
72 if !ok {
73 return
74 }
75 if actionID := navigationCauseActionID(message); actionID != 0 && s.cfg.UI != nil {
76 s.cfg.UI.follow(overlay.run.fg.uiGeneration, actionID)
77 }
78 if s.pendingInPlace != nil && s.pendingInPlace.target.request.Target == target.request.Target && target.tab.stopped == nil {
79 // The attachment is already switching there.
80 overlay.release(false)
81 return
82 }
83 if s.pendingInPlace == nil && sameAttachmentTarget(overlay.request, s.attachments.committedTargetOrZero(), target.request) && target.tab.stopped == nil {
84 _, current, _ := s.attachments.committedView()
85 if target.tab.preferred != "" && target.tab.preferred != current {
86 s.attachments.requestTabSelection(overlay.run.token, target.tab.preferred)
87 }
88 return
89 }
90 // A recent route on the serving daemon moves in place. A daemon handoff
91 // (AttachTarget) keeps close-and-dial: the daemon chose it on purpose.
92 if _, route := message.(protocol.RouteNavigationAction); route && overlay.tryInPlace(target) {
93 overlay.release(false)
94 return
95 }
96 if overlay.active {
97 overlay.exit()
98 }
99 s.pendingSwap = &target
100 overlay.swapping = true
101 s.attachments.requestDetach(overlay.run.token)
102}
103
104func navigationCauseActionID(message protocol.ServerMessage) uint64 {
105 switch action := message.(type) {
106 case protocol.RouteNavigationAction:
107 return action.CauseActionID
108 case protocol.RouteCreateSessionAction:
109 return action.CauseActionID
110 case protocol.AttachTarget:
111 return action.CauseActionID
112 default:
113 return 0
114 }
115}
116
117// takeSettledNavigation adopts a navigation request the worker handed over
118// just before its attachment ended, such as a close-and-dial handoff whose
119// daemon detached the source right after sending it.
120func (s *Supervisor) takeSettledNavigation(service ports.BrokerNavigator, token AttachmentToken, request ports.BrokerOpenStreamRequest) {
121 message, ok := s.attachments.takeNavigation()
122 if !ok || s.pendingSwap != nil {
123 return
124 }
125 if target, ok := s.resolveDaemonNavigation(service, token, request, message); ok {
126 if actionID := navigationCauseActionID(message); actionID != 0 && s.cfg.UI != nil {
127 s.cfg.UI.follow(s.cfg.UI.generation, actionID)
128 }
129 s.pendingSwap = &target
130 }
131}
132
133// resolveDaemonNavigation turns one daemon request into an exact attachment
134// target. A refusal is answered on the attachment with the typed failure.
135func (s *Supervisor) resolveDaemonNavigation(service ports.BrokerNavigator, token AttachmentToken, request ports.BrokerOpenStreamRequest, message protocol.ServerMessage) (pickerAttachmentTarget, bool) {
136 switch typed := message.(type) {
137 case protocol.RouteNavigationAction:
138 fail := func(code protocol.RouteFailureCode) (pickerAttachmentTarget, bool) {
139 s.attachments.replyNavigation(token, protocol.RouteNavigationFailure{Key: typed.Key, Generation: typed.Generation, Code: code})
140 return pickerAttachmentTarget{}, false
141 }
142 routed, ok := s.resolveRoute(protocol.RouteRef{Key: typed.Key, Generation: typed.Generation})
143 if !ok || routed.host {
144 return fail(protocol.RouteFailureStaleSelection)
145 }
146 target, err := s.resolveRouteSelection(service, pickerSelectionRef{
147 kind: pickerSelectionExact, local: routed.authority.local, endpoint: routed.authority.endpoint,
148 lifecycle: routed.target.LifecycleID, name: routed.target.SessionName,
149 })
150 if err != nil {
151 return fail(routeNavigationFailureCode(err))
152 }
153 return target, true
154 case protocol.RouteCreateSessionAction:
155 fail := func(code protocol.RouteFailureCode) (pickerAttachmentTarget, bool) {
156 s.attachments.replyNavigation(token, protocol.SessionCreationFailure{RequestID: typed.RequestID, Code: code})
157 return pickerAttachmentTarget{}, false
158 }
159 routed, ok := s.resolveRoute(protocol.RouteRef{Key: typed.Key, Generation: typed.Generation})
160 if !ok {
161 return fail(protocol.RouteFailureStaleSelection)
162 }
163 target, err := s.resolveRouteSelection(service, pickerSelectionRef{
164 kind: pickerSelectionCreateNamed, local: routed.authority.local, endpoint: routed.authority.endpoint,
165 createName: typed.SessionName,
166 })
167 if err != nil {
168 return fail(routeNavigationFailureCode(err))
169 }
170 return target, true
171 case protocol.AttachTarget:
172 return s.resolveServingHandoff(service, request, typed)
173 default:
174 return pickerAttachmentTarget{}, false
175 }
176}
177
178// resolveServingHandoff resolves an endpoint-empty handoff: the serving
179// daemon itself named one of its own lifecycles, so the attachment's own
180// authority is kept and only the exact target and tab change. The daemon
181// revalidates the exact identity on Hello.
182func (s *Supervisor) resolveServingHandoff(service ports.BrokerNavigator, request ports.BrokerOpenStreamRequest, handoff protocol.AttachTarget) (pickerAttachmentTarget, bool) {
183 if handoff.Endpoint != "" || handoff.SessionTarget != nil || handoff.ExactTarget == nil || handoff.Intent != protocol.IntentAttach {
184 s.notifyPicker(domain.Notification{Code: domain.NoticeSessionUnavailable, Scope: "handoff", Severity: domain.NoticeWarn, Message: "the daemon asked for a destination this client cannot open"})
185 return pickerAttachmentTarget{}, false
186 }
187 stream, err := service.NextStreamID()
188 if err != nil {
189 return pickerAttachmentTarget{}, false
190 }
191 next := request
192 next.Stream = stream
193 next.Connection = service.ConnectionID()
194 next.Admission = ports.BrokerAdmissionExact
195 next.Target = *handoff.ExactTarget
196 next.Name = ""
197 next.Purpose = ports.BrokerStreamAttachment
198 next.StartMode = ports.BrokerDaemonStartIfNeeded
199 if next.Validate() != nil {
200 return pickerAttachmentTarget{}, false
201 }
202 return pickerAttachmentTarget{request: next, tab: attachmentTab{preferred: handoff.PreferredTabID}}, true
203}
204
205func (s *Supervisor) resolveRoute(ref protocol.RouteRef) (routeLedgerTarget, bool) {
206 if s.routes == nil || ref.IsZero() {
207 return routeLedgerTarget{}, false
208 }
209 return s.routes.resolve(ref)
210}
211
212// resolveRouteSelection resolves one route through the same catalogue rules
213// as a picker commit, against the latest broker publication.
214func (s *Supervisor) resolveRouteSelection(service ports.BrokerNavigator, selection pickerSelectionRef) (pickerAttachmentTarget, error) {
215 stream, err := service.NextStreamID()
216 if err != nil {
217 return pickerAttachmentTarget{}, err
218 }
219 snapshot := service.Snapshot()
220 authority := pickerAuthorityInSnapshot(snapshot, selection)
221 if authority.found {
222 // The ledger keys a host by endpoint; the registration is the
223 // catalogue's current one.
224 selection.registration = authority.observation.Registration
225 }
226 base := pickerResolveBase{Connection: service.ConnectionID(), Stream: stream}
227 request, tab, err := resolvePickerTarget(snapshot.Epoch, authority, selection, base, true)
228 if err != nil {
229 return pickerAttachmentTarget{}, err
230 }
231 return pickerAttachmentTarget{request: request, tab: tab}, nil
232}
233
234func routeNavigationFailureCode(err error) protocol.RouteFailureCode {
235 var catalogueErr pickerCatalogueError
236 if errors.As(err, &catalogueErr) {
237 switch catalogueErr.Code {
238 case pickerCatalogueGone:
239 return protocol.RouteFailureNoSuchRoute
240 case pickerCatalogueReplaced:
241 return protocol.RouteFailureTargetChanged
242 case pickerCatalogueEpochStale:
243 return protocol.RouteFailureStaleSelection
244 }
245 }
246 return protocol.RouteFailureUnavailable
247}