1package daemon
2
3import (
4 "errors"
5 "fmt"
6 "slices"
7 "time"
8
9 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/protocol"
11 "github.com/bnema/vev/internal/usecase/picker"
12)
13
14// This file retains the serving-daemon move-destination interaction. It must
15// never admit a navigation interaction: session-picker only offers the client
16// its own picker over the live attachment (offerClientNavigationPicker), and
17// the client supervisor owns all navigation and terminal input while it is
18// open.
19
20// servingPickerSourceID names the source the serving daemon itself owns.
21const servingPickerSourceID = protocol.PickerServingSourceID
22
23var errClientOwnedNavigation = errors.New("session navigation is client-owned")
24
25// pickerAcquisitionGrace bounds how long raw input from the client may still
26// arrive after a move-picker offer and be dropped as bytes that were already
27// in flight before the client saw the offer. A client that presents the picker
28// consumes every key locally, so raw input after this grace proves the client
29// never took the interaction.
30const pickerAcquisitionGrace = 2 * time.Second
31
32// pickerSwallowsInput reports whether raw input must be dropped because an
33// open client-owned picker interaction owns it. It is the fail-safe that keeps
34// input from being swallowed without a live offer: once the acquisition grace
35// elapsed, raw input releases the interaction (confirming the close to the
36// client when an effect is available and forcing an authoritative repaint) and
37// the input is routed normally. Callers must not hold pickerMu.
38func (d *Daemon) pickerSwallowsInput(ac *attachedClient, effect *attachmentEffect) bool {
39 if ac == nil || ac.overlays == nil {
40 return false
41 }
42 ac.overlays.pickerMu.Lock()
43 open := ac.overlays.pickerOpen
44 interaction := ac.overlays.pickerInteraction
45 openedAt := ac.overlays.pickerOpenedAt
46 ac.overlays.pickerMu.Unlock()
47 if !open {
48 return false
49 }
50 if d.daemonNow().Sub(openedAt) < pickerAcquisitionGrace {
51 return true
52 }
53 if d.log != nil {
54 d.log.Warn("releasing picker interaction the client never acquired", "interaction", interaction)
55 }
56 if d.closePickerForAttachment(ac, effect, interaction) {
57 if sess := ac.currentAttachmentSession(); sess != nil {
58 d.invalidateRender(sess, ac, true, "picker_interaction.go:unacquired-release")
59 }
60 }
61 return false
62}
63
64// offerClientNavigationPicker asks the client to open its own session picker
65// over this live attachment. The daemon opens no interaction: navigation rows
66// come from the client's broker catalogue, the client picker consumes the
67// input it owns locally, and the attachment stays attached so cancelling the
68// picker returns to it without reconnecting.
69func (d *Daemon) offerClientNavigationPicker(ac *attachedClient, effect *attachmentEffect) error {
70 if ac == nil || ac.overlays == nil || effect == nil || !effect.current() {
71 return errAttachmentTransition
72 }
73 ac.overlays.pickerMu.Lock()
74 ac.overlays.pickerNavigationOffers++
75 if ac.overlays.pickerNavigationOffers == 0 {
76 ac.overlays.pickerNavigationOffers = 1
77 }
78 offerID := ac.overlays.pickerNavigationOffers
79 ac.overlays.pickerMu.Unlock()
80 offer := protocol.PickerOffer{InteractionID: offerID, Intent: protocol.PickerIntentNavigation, Title: picker.SortRecent.Title()}
81 if err := protocol.ValidatePickerOffer(offer); err != nil {
82 return err
83 }
84 return effect.sendControl(offer)
85}
86
87// pickerDuplicateRows reports the first repeated row key with a readable
88// identity for both rows. A refused snapshot otherwise says only that a key
89// repeated, which is not enough to find the two rows that collided.
90func pickerDuplicateRows(lines []protocol.PickerLine) (first, second string, duplicated bool) {
91 seen := make(map[string]string, len(lines))
92 for _, line := range lines {
93 if line.Key == "" {
94 continue
95 }
96 identity := fmt.Sprintf("%s %q (%s)", line.Label, line.Detail, line.StatusDetail)
97 if previous, ok := seen[line.Key]; ok {
98 return previous, identity, true
99 }
100 seen[line.Key] = identity
101 }
102 return "", "", false
103}
104
105// pickerClientKey derives a stable opaque key for an unchanged navigation
106// target (lifecycle, name). Presentation-only updates keep the key; a changed
107// or removed target retires it and a reappearing target earns a new key
108// through its new lifecycle. Same role as navigationInventoryEntryKey.
109func pickerClientKey(lifecycle domain.IncarnationID, name string) string {
110 return navigationInventoryEntryKey(lifecycle, name)
111}
112
113// retirePicker closes the picker interaction of an attachment that is going
114// away. It never sends: the transport is already failing or parked, and the
115// client's own lease retires on its generation change.
116func (d *Daemon) retirePicker(ac *attachedClient) {
117 if ac == nil || ac.overlays == nil {
118 return
119 }
120 ac.overlays.pickerMu.Lock()
121 open := ac.overlays.pickerOpen
122 interaction := ac.overlays.pickerInteraction
123 ac.overlays.pickerMu.Unlock()
124 if !open {
125 return
126 }
127 d.closePickerForAttachment(ac, nil, interaction)
128}
129
130// pickerState reports the open interaction under the picker mutex.
131func (ac *attachedClient) pickerState() (open bool, interaction uint64, intent protocol.PickerIntent, source moveSourceLocator, requestID uint64) {
132 if ac == nil || ac.overlays == nil {
133 return false, 0, 0, moveSourceLocator{}, 0
134 }
135 ac.overlays.pickerMu.Lock()
136 defer ac.overlays.pickerMu.Unlock()
137 return ac.overlays.pickerOpen, ac.overlays.pickerInteraction, ac.overlays.pickerIntent, ac.overlays.pickerMoveSource, ac.overlays.pickerRequestID
138}
139
140// openPickerForAttachment admits one picker interaction on a guarded serving
141// connection, sends the offer that names the intent and the acquisition
142// barrier, and publishes the first serving snapshot. A failed publication
143// fails closed: the interaction is retired again so no namespace survives
144// without its client-side acquisition.
145func (d *Daemon) openPickerForAttachment(ac *attachedClient, effect *attachmentEffect, intent protocol.PickerIntent, source moveSourceLocator, requestID uint64) error {
146 if ac == nil || ac.overlays == nil || effect == nil || !effect.current() {
147 return errAttachmentTransition
148 }
149 if intent != protocol.PickerIntentMoveTab && intent != protocol.PickerIntentMovePane {
150 return errClientOwnedNavigation
151 }
152 sess := effect.sess
153 if intent != protocol.PickerIntentNavigation {
154 if sess == nil || !sess.capabilities().yieldsMoves() {
155 return errSessionCannotYieldMoves
156 }
157 if source.Attachment != nil && source.AttachmentCapability.ac == nil {
158 source.AttachmentCapability = sess.captureAttachmentCapability(source.Attachment, source.Attachment.transport())
159 }
160 }
161 ac.overlays.pickerMu.Lock()
162 interaction := ac.overlays.pickerInteraction + 1
163 if interaction == 0 {
164 interaction = 1
165 }
166 ac.overlays.pickerOpen = true
167 ac.overlays.pickerInteraction = interaction
168 ac.overlays.pickerIntent = intent
169 ac.overlays.pickerMoveSource = source
170 ac.overlays.pickerRequestID = requestID
171 ac.overlays.pickerOpenedAt = d.daemonNow()
172 ac.overlays.pickerRevisions = make(map[string]uint64)
173 ac.overlays.pickerKeys = nil
174 ac.overlays.pickerSourcePublished = false
175 ac.overlays.pickerRecent = protocol.PickerProjection{}
176 ac.overlays.pickerGrouped = protocol.PickerProjection{}
177 ac.overlays.pickerMu.Unlock()
178
179 barrierEpoch, barrierState, sizeEpoch := pickerClientBarrier(ac)
180 offer := protocol.PickerOffer{
181 InteractionID: interaction, RequestID: requestID, Intent: intent,
182 MoveSourceKey: pickerMoveSourceKey(source), Title: picker.SortRecent.Title(),
183 BarrierEpoch: barrierEpoch, BarrierState: barrierState, SizeEpoch: sizeEpoch,
184 }
185 if err := protocol.ValidatePickerOffer(offer); err != nil {
186 d.log.Error("picker offer rejected", "err", err, "interaction", interaction, "intent", intent)
187 d.closePickerForAttachment(ac, effect, interaction)
188 return err
189 }
190 if err := effect.sendControl(offer); err != nil {
191 d.closePickerForAttachment(ac, effect, interaction)
192 return err
193 }
194 if err := d.publishPickerSourceForAttachment(ac, effect, interaction); err != nil {
195 d.closePickerForAttachment(ac, effect, interaction)
196 return err
197 }
198 if sess := ac.currentAttachmentSession(); sess != nil {
199 d.invalidateRender(sess, ac, true, "picker.go:open")
200 }
201 return nil
202}
203
204// movePickerPrecheck reports the move-picker refusals that are known before
205// the picker opens, so a deferred open still fails its command synchronously.
206// openPickerForAttachment re-checks both when it actually opens.
207func (d *Daemon) movePickerPrecheck(sess *session, ac *attachedClient, intent protocol.PickerIntent, source moveSourceLocator) error {
208 if sess == nil || !sess.capabilities().yieldsMoves() {
209 return errSessionCannotYieldMoves
210 }
211 _, groupedViews, current := d.pickerViewProjections(sess, ac)
212 filter := pickerSourceFilter{Session: source.Session.ID, Incarnation: source.Session.Incarnation, TabID: source.TabID}
213 if !pickerLineSetHasMove(pickerLineSetFor(groupedViews, intent, filter, current)) {
214 return errNoMoveDestination
215 }
216 return nil
217}
218
219// pickerMoveSourceKey names the captured move source in the offer. It is
220// opaque and never a route: only the daemon resolves it, and only for the
221// interaction that captured it.
222func pickerMoveSourceKey(source moveSourceLocator) string {
223 if source.Session.ID == "" || source.TabID == "" {
224 return ""
225 }
226 return pickerClientKey(source.Session.Incarnation, string(source.Session.ID)+"#"+string(source.TabID))
227}
228
229// publishPickerSourceForAttachment builds the serving projection pair and
230// publishes it only when the authoritative presentation changed. Recent is the
231// flat recency order the default client sort consumes; Grouped is the sectioned
232// order the grouped sort consumes. They always carry the same destination keys
233// and actions, but their lines differ, so each client sort runs on the input
234// its mode expects instead of re-deriving one projection from the other. A
235// refresh whose projections equal the last published pair is not
236// authority-relevant — an attention pulse whose rows already reached the client
237// is the common case — and must neither bump the revision nor send a snapshot,
238// or a revision the client never observed would stale its next selection. The
239// revision and recorded projections commit only after the snapshot reached the
240// client, so a rejected validation or failed send leaves the client's last
241// observed authority intact and an identical retry republishes instead of
242// being suppressed.
243// Callers must not hold pickerMu.
244func (d *Daemon) publishPickerSourceForAttachment(ac *attachedClient, effect *attachmentEffect, interaction uint64) error {
245 if ac == nil || ac.overlays == nil || effect == nil {
246 return errAttachmentTransition
247 }
248 open, currentInteraction, intent, source, _ := ac.pickerState()
249 if !open || currentInteraction != interaction {
250 return nil
251 }
252 sess := effect.sess
253 if sess == nil {
254 sess = ac.currentAttachmentSession()
255 }
256 if sess == nil {
257 return errAttachmentTransition
258 }
259 recentViews, groupedViews, current := d.pickerViewProjections(sess, ac)
260 filter := pickerSourceFilter{
261 Session: source.Session.ID, Incarnation: source.Session.Incarnation, TabID: source.TabID,
262 }
263 groupedSet := pickerLineSetFor(groupedViews, intent, filter, current)
264 if intent != protocol.PickerIntentNavigation && !pickerLineSetHasMove(groupedSet) {
265 return errNoMoveDestination
266 }
267 recent := setProjection(pickerLineSetFor(recentViews, intent, filter, current))
268 grouped := setProjection(groupedSet)
269 ac.overlays.pickerPublishMu.Lock()
270 defer ac.overlays.pickerPublishMu.Unlock()
271 ac.overlays.pickerMu.Lock()
272 if !ac.overlays.pickerOpen || ac.overlays.pickerInteraction != interaction {
273 ac.overlays.pickerMu.Unlock()
274 return nil
275 }
276 if ac.overlays.pickerSourcePublished &&
277 pickerProjectionEqual(ac.overlays.pickerRecent, recent) &&
278 pickerProjectionEqual(ac.overlays.pickerGrouped, grouped) {
279 ac.overlays.pickerMu.Unlock()
280 return nil
281 }
282 // Reserve the next revision without committing it: a revision the client
283 // never observed must not become the recorded authority. pickerPublishMu
284 // keeps this reservation exclusive, so the send below cannot be reordered
285 // behind another publisher's.
286 revision := ac.overlays.pickerRevisions[servingPickerSourceID] + 1
287 ac.overlays.pickerMu.Unlock()
288 snapshot := protocol.PickerSnapshot{
289 InteractionID: interaction, SourceID: servingPickerSourceID, SourceRevision: revision,
290 Status: protocol.PickerSourceOK, Lines: groupedSet.lines, Cursor: groupedSet.cursor,
291 Recent: recent, Grouped: grouped,
292 }
293 if err := protocol.ValidatePickerSnapshot(snapshot); err != nil {
294 attrs := []any{"err", err, "interaction", interaction, "lines", len(snapshot.Lines), "revision", revision}
295 if first, second, dup := pickerDuplicateRows(snapshot.Lines); dup {
296 attrs = append(attrs, "first_row", first, "second_row", second)
297 }
298 d.log.Error("picker snapshot rejected", attrs...)
299 return err
300 }
301 if err := effect.sendControl(snapshot); err != nil {
302 return err
303 }
304 // The client now observes this revision: commit the keys and projections
305 // its selection will validate against. A concurrent close or replacement
306 // interaction owns a newer namespace, so a superseded commit is dropped
307 // instead of reviving the retired source.
308 ac.overlays.pickerMu.Lock()
309 if ac.overlays.pickerOpen && ac.overlays.pickerInteraction == interaction {
310 ac.overlays.pickerRevisions[servingPickerSourceID] = revision
311 ac.overlays.pickerKeys = groupedSet.keys
312 ac.overlays.pickerSourcePublished = true
313 ac.overlays.pickerRecent = recent
314 ac.overlays.pickerGrouped = grouped
315 }
316 ac.overlays.pickerMu.Unlock()
317 return nil
318}
319
320// setProjection presents one resolved line set as a publishable projection.
321func setProjection(set pickerLineSet) protocol.PickerProjection {
322 return protocol.PickerProjection{Lines: set.lines, Cursor: set.cursor}
323}
324
325// pickerProjectionEqual reports whether two published projections present the
326// same authoritative facts. Order stays significant: a reordering changes the
327// row a cursor index names, which is authority the client must observe.
328func pickerProjectionEqual(left, right protocol.PickerProjection) bool {
329 return left.Cursor == right.Cursor && slices.Equal(left.Lines, right.Lines)
330}
331
332// pickerLineSetHasMove reports whether a move line set offers any destination,
333// which is what failed move entry must report instead of opening an empty
334// picker.
335func pickerLineSetHasMove(set pickerLineSet) bool {
336 for _, line := range set.lines {
337 if line.Actions&protocol.PickerCanMove != 0 {
338 return true
339 }
340 }
341 return false
342}
343
344// refreshPickerSnapshot republishes the serving line set after its rows, the
345// attachment view, or the size changed. It derives a fresh effect from the
346// current attachment capability: the effect that opened the interaction is
347// bounded to its own operation and may already be retired. A closed
348// interaction is a no-op.
349func (d *Daemon) refreshPickerSnapshot(ac *attachedClient) {
350 open, interaction, _, _, _ := ac.pickerState()
351 if !open || interaction == 0 {
352 return
353 }
354 sess := ac.currentAttachmentSession()
355 if sess == nil {
356 return
357 }
358 current := ac.transportSnapshot()
359 _, effect, admitted := ac.beginCurrentAttachmentEffect(sess, current.transport)
360 if !admitted {
361 return
362 }
363 defer effect.End()
364 _ = d.publishPickerSourceForAttachment(ac, effect, interaction)
365 if sess := ac.currentAttachmentSession(); sess != nil {
366 d.invalidateRender(sess, ac, false, "picker.go:refresh")
367 }
368}
369
370// closePickerForAttachment retires the interaction and confirms it to the
371// client with the daemon output boundary the client must display before it
372// releases the terminal. Stale closes skip so generations stay ordered. The
373// boolean reports whether this call retired the interaction.
374func (d *Daemon) closePickerForAttachment(ac *attachedClient, effect *attachmentEffect, interaction uint64) bool {
375 if ac == nil || ac.overlays == nil || interaction == 0 {
376 return false
377 }
378 ac.overlays.pickerMu.Lock()
379 if !ac.overlays.pickerOpen || ac.overlays.pickerInteraction != interaction {
380 ac.overlays.pickerMu.Unlock()
381 return false
382 }
383 ac.overlays.pickerOpen = false
384 ac.overlays.pickerKeys = nil
385 ac.overlays.pickerRevisions = nil
386 ac.overlays.pickerSourcePublished = false
387 ac.overlays.pickerRecent = protocol.PickerProjection{}
388 ac.overlays.pickerGrouped = protocol.PickerProjection{}
389 ac.overlays.pickerMu.Unlock()
390 if effect == nil {
391 return true
392 }
393 barrierEpoch, barrierState, _ := pickerClientBarrier(ac)
394 closed := protocol.PickerClosed{InteractionID: interaction, BarrierEpoch: barrierEpoch, BarrierState: barrierState}
395 if protocol.ValidatePickerClosed(closed) != nil {
396 return true
397 }
398 _ = effect.sendControl(closed)
399 return true
400}
401
402// resolvePickerSelection validates one typed selection against the open
403// interaction and performs the action the owning source authorised. Unknown
404// keys, foreign sources, and stale revisions reject with a bounded failure.
405func (d *Daemon) resolvePickerSelection(effect *attachmentEffect, selection protocol.PickerSelection) {
406 if protocol.ValidatePickerSelection(selection) != nil || effect == nil {
407 return
408 }
409 ac := effect.ac
410 if ac == nil || ac.overlays == nil {
411 return
412 }
413 ac.overlays.pickerMu.Lock()
414 open := ac.overlays.pickerOpen
415 interaction := ac.overlays.pickerInteraction
416 intent := ac.overlays.pickerIntent
417 source := ac.overlays.pickerMoveSource
418 revision := ac.overlays.pickerRevisions[selection.SourceID]
419 target, ok := ac.overlays.pickerKeys[selection.Key]
420 ac.overlays.pickerMu.Unlock()
421 if !open || interaction != selection.InteractionID {
422 d.sendPickerFailure(effect, selection, protocol.PickerStaleRevision)
423 return
424 }
425 if selection.SourceID != servingPickerSourceID {
426 d.sendPickerFailure(effect, selection, protocol.PickerUnknownSource)
427 return
428 }
429 if selection.SourceRevision != revision {
430 d.sendPickerFailure(effect, selection, protocol.PickerStaleRevision)
431 return
432 }
433 if !ok {
434 d.sendPickerFailure(effect, selection, protocol.PickerUnknownKey)
435 return
436 }
437 // Navigation commits through the handoff, which must not accept a target
438 // whose lifecycle already moved on. Move and kill revalidate source and
439 // destination themselves (move_lifecycle.go / killPickerTarget), so they
440 // report their own precise rejection instead of a generic retire.
441 if selection.Action == protocol.PickerActionNavigate && !d.pickerTargetCurrent(target) {
442 d.sendPickerFailure(effect, selection, protocol.PickerRetiredTarget)
443 return
444 }
445 switch selection.Action {
446 case protocol.PickerActionKill:
447 d.resolvePickerKill(effect, ac, interaction, target, selection)
448 case protocol.PickerActionMove:
449 d.resolvePickerMove(effect, ac, interaction, intent, source, target, selection)
450 default:
451 d.resolvePickerNavigate(effect, ac, interaction, target, selection)
452 }
453}
454
455// resolvePickerNavigate retires the interaction before the handoff. The
456// client's release waits for the daemon's own close, and the handoff's
457// destination paint has to be that paint: closing afterwards would let it be
458// suppressed while the client still owned the terminal.
459func (d *Daemon) resolvePickerNavigate(effect *attachmentEffect, ac *attachedClient, interaction uint64, target picker.Target, selection protocol.PickerSelection) {
460 if !d.closePickerForAttachment(ac, effect, interaction) {
461 d.sendPickerFailure(effect, selection, protocol.PickerStaleRevision)
462 return
463 }
464 if err := d.switchToTargetForAttachment(effect, target, sessionHandoffGuard{allowSamePeer: true}, "picker-select"); err != nil {
465 d.sendPickerFailure(effect, selection, protocol.PickerNavigationFailed)
466 // The interaction is already retired: the client drains until an
467 // authoritative paint, so publish one even though the handoff failed.
468 d.invalidateRender(effect.sess, ac, true, "picker-select-failed")
469 }
470}
471
472// resolvePickerMove closes before committing a composite move+follow. The
473// calling effect must end before the transaction freezes attachment admission.
474func (d *Daemon) resolvePickerMove(effect *attachmentEffect, ac *attachedClient, interaction uint64, intent protocol.PickerIntent, source moveSourceLocator, target picker.Target, selection protocol.PickerSelection) {
475 sess := effect.sess
476 if err := d.movePickerSourceError(source); err != nil {
477 d.sendPickerFailure(effect, selection, protocol.PickerRetiredTarget)
478 d.reportAttachmentError(sess, movePickerUserError(err))
479 d.refreshPickerSnapshot(ac)
480 return
481 }
482 closed := false
483 beforeFollow := func() error {
484 if !d.closePickerForAttachment(ac, effect, interaction) {
485 return errMoveStaleTarget
486 }
487 closed = true
488 effect.End()
489 return nil
490 }
491 // A composite follow reports completion from the move postcommit, which
492 // admits a fresh effect on the published destination capability once the
493 // committing effect has ended and the destination paint is authoritative.
494 afterFollow := func(published *attachmentEffect) {
495 d.sendPickerResult(published, selection, interaction)
496 }
497 // Non-following pane moves retain the open interaction. The transaction
498 // calls beforeFollow only after admission proves that it will follow.
499 defer effect.End()
500 if err := d.commitMovePickerSelection(intent, source, target, beforeFollow, afterFollow); err != nil {
501 if closed {
502 // The committing effect already ended so the transaction could drain
503 // it. Reject and repaint through the attachment's current capability
504 // instead of reusing an effect that is no longer admitted.
505 d.reportAttachmentError(sess, movePickerUserError(err))
506 d.rejectClosedPickerMove(ac, selection)
507 } else {
508 d.sendPickerFailure(effect, selection, protocol.PickerActionFailed)
509 d.reportAttachmentError(sess, movePickerUserError(err))
510 d.refreshPickerSnapshot(ac)
511 }
512 return
513 }
514 if !closed {
515 d.sendPickerResult(effect, selection, interaction)
516 d.refreshPickerSnapshot(ac)
517 }
518}
519
520// rejectClosedPickerMove delivers bounded rejection feedback and an
521// authoritative repaint after a composite move closed the picker and ended its
522// committing effect. It never sends through that ended effect: a fresh effect
523// on the attachment's current capability carries the failure, and the paint
524// releases the client's post-close drain.
525func (d *Daemon) rejectClosedPickerMove(ac *attachedClient, selection protocol.PickerSelection) {
526 sess := ac.currentAttachmentSession()
527 if sess == nil {
528 return
529 }
530 d.invalidateRender(sess, ac, true, "picker-move-failed")
531 _, effect, admitted := ac.beginCurrentAttachmentEffect(sess, ac.transport())
532 if !admitted {
533 return
534 }
535 d.sendPickerFailure(effect, selection, protocol.PickerActionFailed)
536 effect.End()
537}
538
539// resolvePickerKill destroys the target and keeps the picker open.
540func (d *Daemon) resolvePickerKill(effect *attachmentEffect, ac *attachedClient, interaction uint64, target picker.Target, selection protocol.PickerSelection) {
541 // The kill holds this admission until it finishes, so a replacement
542 // initiator cannot make the destructive action look unattributed.
543 effect.bindActionEnd(d, "picker-delete")
544 if err := d.killPickerTargetForAttachment(target, effect); err != nil {
545 d.sendPickerFailure(effect, selection, protocol.PickerActionFailed)
546 d.refreshPickerSnapshot(ac)
547 return
548 }
549 d.sendPickerResult(effect, selection, interaction)
550 d.refreshPickerSnapshot(ac)
551}
552
553// pickerTargetCurrent revalidates a resolved target for lifecycle freshness:
554// incarnation pin plus expected-created-at, mirroring previewTarget and
555// targetMatchesLifecycle. A renamed-or-recreated session between snapshot and
556// commit rejects as retired, never force-commits.
557func (d *Daemon) pickerTargetCurrent(target picker.Target) bool {
558 // A remote target names an endpoint-qualified remote session, not a local
559 // one: resolving it against the local session table would always miss and
560 // report a retired target. Its exact route is revalidated against the remote
561 // catalogue by the handoff itself (remoteCatalogTargetReady), which reports
562 // its own precise rejection.
563 if target.RemoteTarget != nil || target.RemoteKey != nil {
564 return true
565 }
566 if target.Name == "" {
567 d.mu.Lock()
568 sess := d.sessions[target.Session]
569 d.mu.Unlock()
570 if sess == nil {
571 return false
572 }
573 sess.mu.Lock()
574 defer sess.mu.Unlock()
575 return targetMatchesLifecycle(target, sess.name, sess.createdAt, sess.incarnation)
576 }
577 d.mu.Lock()
578 defer d.mu.Unlock()
579 _, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target)
580 if !ok {
581 return false
582 }
583 if isStopped {
584 return targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation)
585 }
586 return true
587}
588
589// pickerClientBarrier snapshots the acquisition boundary for one interaction
590// offer or close: the output fence epoch, the latest committed state, and the
591// view revision that doubles as the size epoch. It reads viewMu-protected
592// state only (never pane/session locks, never sendMu — see the lock-order
593// notes at the top of client.go); callers must not hold pickerMu.
594func pickerClientBarrier(ac *attachedClient) (epoch, state, sizeEpoch uint64) {
595 if ac == nil {
596 return 0, 0, 0
597 }
598 view := ac.viewSnapshot()
599 if ac.output != nil {
600 epoch = ac.output.currentEpoch()
601 state = ac.output.committedState()
602 }
603 return epoch, state, view.revision
604}
605
606func (d *Daemon) sendPickerResult(effect *attachmentEffect, selection protocol.PickerSelection, interaction uint64) {
607 result := protocol.PickerResult{
608 CauseActionID: selection.CauseActionID, RequestID: selection.RequestID,
609 InteractionID: interaction, SourceID: selection.SourceID, Key: selection.Key, Action: selection.Action,
610 }
611 if protocol.ValidatePickerResult(result) != nil {
612 return
613 }
614 _ = effect.sendControl(result)
615}
616
617func (d *Daemon) sendPickerFailure(effect *attachmentEffect, selection protocol.PickerSelection, code protocol.PickerFailureCode) {
618 failure := protocol.PickerFailure{
619 CauseActionID: selection.CauseActionID, RequestID: selection.RequestID,
620 InteractionID: selection.InteractionID, SourceID: selection.SourceID,
621 Key: selection.Key, Action: selection.Action, Code: code,
622 }
623 if protocol.ValidatePickerFailure(failure) != nil {
624 return
625 }
626 _ = effect.sendControl(failure)
627}