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// publishRoutes rebuilds the ledger over the live attachment and hands a
35// changed snapshot, or the first one of this attachment, to its worker.
36func (s *Supervisor) publishRoutes(service ports.BrokerNavigator, run *attachmentRun, request ports.BrokerOpenStreamRequest) {
37 target, _, known := s.attachments.committedView()
38 if !known {
39 return
40 }
41 snapshot, changed := s.nav.buildRoutes(service.Snapshot(), routeActive{known: true, authority: requestAuthority(request), target: target})
42 if snapshot.Generation == 0 || (!changed && s.nav.routesSent == run.token) {
43 return
44 }
45 if s.attachments.publishRoutes(run.token, snapshot) {
46 s.nav.routesSent = run.token
47 }
48}
49
50func requestAuthority(request ports.BrokerOpenStreamRequest) routeAuthority {
51 if request.Local {
52 return routeAuthority{local: true}
53 }
54 return routeAuthority{endpoint: request.Endpoint}
55}
56
57// settleDaemonNavigation carries out one daemon navigation request over the
58// live attachment.
59func (s *Supervisor) settleDaemonNavigation(service ports.BrokerNavigator, overlay *attachmentPickerOverlay, message protocol.ServerMessage) {
60 target, ok := s.resolveDaemonNavigation(service, overlay.run.token, overlay.request, message)
61 if !ok {
62 return
63 }
64 if actionID := navigationCauseActionID(message); actionID != 0 && s.cfg.UI != nil {
65 s.cfg.UI.follow(overlay.run.fg.uiGeneration, actionID)
66 }
67 if s.nav.switchingInPlaceTo(target.request.Target) && target.tab.stopped == nil {
68 // The attachment is already switching there.
69 overlay.release(false)
70 return
71 }
72 if s.nav.inPlaceIdle() && sameAttachmentTarget(overlay.request, s.attachments.committedTargetOrZero(), target.request) && target.tab.stopped == nil {
73 _, current, _ := s.attachments.committedView()
74 if target.tab.preferred != "" && target.tab.preferred != current {
75 s.attachments.requestTabSelection(overlay.run.token, target.tab.preferred)
76 }
77 return
78 }
79 // A recent route on the serving daemon moves in place. A daemon handoff
80 // (AttachTarget) keeps close-and-dial: the daemon chose it on purpose.
81 if _, route := message.(protocol.RouteNavigationAction); route && overlay.tryInPlace(target) {
82 overlay.release(false)
83 return
84 }
85 if overlay.active {
86 overlay.exit()
87 }
88 s.nav.pendingSwap = &target
89 overlay.swapping = true
90 s.attachments.requestDetach(overlay.run.token)
91}
92
93func navigationCauseActionID(message protocol.ServerMessage) uint64 {
94 switch action := message.(type) {
95 case protocol.RouteNavigationAction:
96 return action.CauseActionID
97 case protocol.RouteCreateSessionAction:
98 return action.CauseActionID
99 case protocol.AttachTarget:
100 return action.CauseActionID
101 default:
102 return 0
103 }
104}
105
106// takeSettledNavigation adopts a navigation request the worker handed over
107// just before its attachment ended, such as a close-and-dial handoff whose
108// daemon detached the source right after sending it.
109func (s *Supervisor) takeSettledNavigation(service ports.BrokerNavigator, token AttachmentToken, request ports.BrokerOpenStreamRequest) {
110 message, ok := s.attachments.takeNavigation()
111 if !ok || s.nav.pendingSwap != nil {
112 return
113 }
114 if target, ok := s.resolveDaemonNavigation(service, token, request, message); ok {
115 if actionID := navigationCauseActionID(message); actionID != 0 && s.cfg.UI != nil {
116 s.cfg.UI.follow(s.cfg.UI.generation, actionID)
117 }
118 s.nav.pendingSwap = &target
119 }
120}
121
122// resolveDaemonNavigation turns one daemon request into an exact attachment
123// target. A refusal is answered on the attachment with the typed failure.
124func (s *Supervisor) resolveDaemonNavigation(service ports.BrokerNavigator, token AttachmentToken, request ports.BrokerOpenStreamRequest, message protocol.ServerMessage) (pickerAttachmentTarget, bool) {
125 switch typed := message.(type) {
126 case protocol.RouteNavigationAction:
127 fail := func(code protocol.RouteFailureCode) (pickerAttachmentTarget, bool) {
128 s.attachments.replyNavigation(token, protocol.RouteNavigationFailure{Key: typed.Key, Generation: typed.Generation, Code: code})
129 return pickerAttachmentTarget{}, false
130 }
131 routed, ok := s.nav.resolveRoute(protocol.RouteRef{Key: typed.Key, Generation: typed.Generation})
132 if !ok || routed.host {
133 return fail(protocol.RouteFailureStaleSelection)
134 }
135 target, err := s.resolveRouteSelection(service, pickerSelectionRef{
136 kind: pickerSelectionExact, local: routed.authority.local, endpoint: routed.authority.endpoint,
137 lifecycle: routed.target.LifecycleID, name: routed.target.SessionName,
138 })
139 if err != nil {
140 return fail(routeNavigationFailureCode(err))
141 }
142 return target, true
143 case protocol.RouteCreateSessionAction:
144 fail := func(code protocol.RouteFailureCode) (pickerAttachmentTarget, bool) {
145 s.attachments.replyNavigation(token, protocol.SessionCreationFailure{RequestID: typed.RequestID, Code: code})
146 return pickerAttachmentTarget{}, false
147 }
148 routed, ok := s.nav.resolveRoute(protocol.RouteRef{Key: typed.Key, Generation: typed.Generation})
149 if !ok {
150 return fail(protocol.RouteFailureStaleSelection)
151 }
152 target, err := s.resolveRouteSelection(service, pickerSelectionRef{
153 kind: pickerSelectionCreateNamed, local: routed.authority.local, endpoint: routed.authority.endpoint,
154 createName: typed.SessionName,
155 })
156 if err != nil {
157 return fail(routeNavigationFailureCode(err))
158 }
159 return target, true
160 case protocol.AttachTarget:
161 return s.resolveServingHandoff(service, request, typed)
162 default:
163 return pickerAttachmentTarget{}, false
164 }
165}
166
167// resolveServingHandoff resolves an endpoint-empty handoff: the serving
168// daemon itself named one of its own lifecycles, so the attachment's own
169// authority is kept and only the exact target and tab change. The daemon
170// revalidates the exact identity on Hello.
171func (s *Supervisor) resolveServingHandoff(service ports.BrokerNavigator, request ports.BrokerOpenStreamRequest, handoff protocol.AttachTarget) (pickerAttachmentTarget, bool) {
172 if handoff.Endpoint != "" || handoff.SessionTarget != nil || handoff.ExactTarget == nil || handoff.Intent != protocol.IntentAttach {
173 s.notifyPicker(domain.Notification{Code: domain.NoticeSessionUnavailable, Scope: "handoff", Severity: domain.NoticeWarn, Message: "the daemon asked for a destination this client cannot open"})
174 return pickerAttachmentTarget{}, false
175 }
176 stream, err := service.NextStreamID()
177 if err != nil {
178 return pickerAttachmentTarget{}, false
179 }
180 next := request
181 next.Stream = stream
182 next.Connection = service.ConnectionID()
183 next.Admission = ports.BrokerAdmissionExact
184 next.Target = *handoff.ExactTarget
185 next.Name = ""
186 next.Purpose = ports.BrokerStreamAttachment
187 next.StartMode = ports.BrokerDaemonStartIfNeeded
188 if next.Validate() != nil {
189 return pickerAttachmentTarget{}, false
190 }
191 return pickerAttachmentTarget{request: next, tab: attachmentTab{preferred: handoff.PreferredTabID}}, true
192}
193
194// resolveRouteSelection resolves one route through the same catalogue rules
195// as a picker commit, against the latest broker publication.
196func (s *Supervisor) resolveRouteSelection(service ports.BrokerNavigator, selection pickerSelectionRef) (pickerAttachmentTarget, error) {
197 stream, err := service.NextStreamID()
198 if err != nil {
199 return pickerAttachmentTarget{}, err
200 }
201 snapshot := service.Snapshot()
202 authority := pickerAuthorityInSnapshot(snapshot, selection)
203 if authority.found {
204 // The ledger keys a host by endpoint; the registration is the
205 // catalogue's current one.
206 selection.registration = authority.observation.Registration
207 }
208 base := pickerResolveBase{Connection: service.ConnectionID(), Stream: stream}
209 request, tab, err := resolvePickerTarget(snapshot.Epoch, authority, selection, base, true)
210 if err != nil {
211 return pickerAttachmentTarget{}, err
212 }
213 return pickerAttachmentTarget{request: request, tab: tab}, nil
214}
215
216func routeNavigationFailureCode(err error) protocol.RouteFailureCode {
217 var catalogueErr pickerCatalogueError
218 if errors.As(err, &catalogueErr) {
219 switch catalogueErr.Code {
220 case pickerCatalogueGone:
221 return protocol.RouteFailureNoSuchRoute
222 case pickerCatalogueReplaced:
223 return protocol.RouteFailureTargetChanged
224 case pickerCatalogueEpochStale:
225 return protocol.RouteFailureStaleSelection
226 }
227 }
228 return protocol.RouteFailureUnavailable
229}