1package client
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "sync"
8
9 "github.com/bnema/vev/internal/ports"
10)
11
12// maxRouteLedgerEntries is the product history cap. It is deliberately
13// smaller than the wire parser bound so an active metadata entry can be
14// excluded without allowing an oversized client history.
15const maxRouteLedgerEntries = 20
16
17var (
18 errRouteResumeUnavailable = errors.New("route resume unavailable")
19 errRouteStaleSelection = errors.New("route selection is stale")
20 errRouteTargetChanged = errors.New("route target changed")
21)
22
23// routeKey is process-local identity. It is never derived from a daemon
24// lifecycle ID and is not exposed outside the client ledger. originKey keeps
25// distinct daemon endpoints separate even when their lifecycle/name pairs
26// happen to match; neither is serialized into the route snapshot.
27type routeKey uint64
28
29type routeGeneration uint64
30
31type routeIdentity struct {
32 key routeKey
33 generation routeGeneration
34}
35
36func (i routeIdentity) empty() bool { return i.key == 0 && i.generation == 0 }
37
38func (i routeIdentity) wire() ports.RouteRef {
39 return ports.RouteRef{Key: uint64(i.key), Generation: uint64(i.generation)}
40}
41
42func identityFromWire(ref ports.RouteRef) routeIdentity {
43 return routeIdentity{key: routeKey(ref.Key), generation: routeGeneration(ref.Generation)}
44}
45
46// routePresentation contains only immutable display data. Connection and
47// attach capabilities live in routeRecord beside it and are never copied into
48// a RecentRouteSnapshot.
49type routePresentation struct {
50 name string
51 hostLabel string
52 kind ports.RouteKind
53 ephemeral bool
54 attention bool
55 reachability ports.RouteReachability
56}
57
58type routeCandidate struct {
59 origin ports.RouteOrigin
60 originKey string
61 target ports.ExactSessionTarget
62 presentation routePresentation
63 dialer ports.Dialer
64 request AttachRequest
65 resumeToken uint64
66 home bool
67}
68
69type routeRecord struct {
70 identity routeIdentity
71 origin ports.RouteOrigin
72 originKey string
73 target ports.ExactSessionTarget
74 presentation routePresentation
75 dialer ports.Dialer
76 request AttachRequest
77 resumeToken uint64
78 home bool
79}
80
81// routeLedger owns client-global route identity/history for one client
82// process. Its lock protects both the bounded record set and active/previous
83// references; snapshots are copied while holding the lock and are immutable
84// after publication.
85type routeLedger struct {
86 mu sync.RWMutex
87 nextKey routeKey
88 generation routeGeneration
89 entries []routeRecord
90 active routeIdentity
91 previous routeIdentity
92 home routeIdentity
93
94 discoveryMu sync.Mutex
95 discoveryCycle uint64
96}
97
98func newRouteLedger() *routeLedger { return &routeLedger{} }
99
100func cloneCommittedIdentity(identity *ports.CommittedRouteIdentity) *ports.CommittedRouteIdentity {
101 if identity == nil {
102 return nil
103 }
104 clone := *identity
105 return &clone
106}
107
108func cloneRoutePosition(position *ports.RoutePosition) *ports.RoutePosition {
109 if position == nil {
110 return nil
111 }
112 clone := *position
113 return &clone
114}
115
116func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRouteIdentity, dialer ports.Dialer, resumeToken uint64) routeCandidate {
117 origin := normalizeRouteOrigin(request.Origin, request.Remote)
118 originKey := normalizeRouteOriginKey(request.OriginKey, origin)
119 kind := ports.RouteKindLocal
120 hostLabel := ""
121 if request.Remote || request.RemoteTarget != nil || origin != ports.RouteOriginLocal {
122 kind = ports.RouteKindRemote
123 if request.RemoteTarget != nil {
124 hostLabel = request.RemoteTarget.DisplayOrigin
125 }
126 if hostLabel == "" {
127 hostLabel = originKey
128 }
129 }
130 request = cloneAttachRequest(request)
131 request.Origin = origin
132 request.OriginKey = originKey
133 request.SessionName = identity.Target.SessionName
134 request.ExactTarget = &identity.Target
135 request.RemoteOrigin = hostLabel
136 // Discovery targets are point-in-time attach authority, including a live
137 // tab selector. Once Welcome commits an exact route, mutable tab memory is
138 // carried independently by PreferredTabID.
139 request.RemoteTarget = nil
140 return routeCandidate{
141 origin: origin,
142 originKey: originKey,
143 target: identity.Target,
144 presentation: routePresentation{
145 name: identity.Target.SessionName,
146 hostLabel: hostLabel,
147 kind: kind,
148 ephemeral: identity.Ephemeral,
149 reachability: ports.RouteReachabilityReachable,
150 },
151 dialer: dialer,
152 request: request,
153 resumeToken: resumeToken,
154 }
155}
156
157func routeCandidateForCommittedIdentity(active routeRecord, identity ports.CommittedRouteIdentity) routeCandidate {
158 request := cloneAttachRequest(active.request)
159 request.SessionName = identity.Target.SessionName
160 request.ExactTarget = &identity.Target
161 request.RemoteTarget = nil
162 if active.target != identity.Target {
163 request.PreferredTabID = ""
164 }
165 return routeCandidate{
166 origin: active.origin,
167 originKey: active.originKey,
168 target: identity.Target,
169 presentation: routePresentation{
170 name: identity.Target.SessionName,
171 hostLabel: active.presentation.hostLabel,
172 kind: active.presentation.kind,
173 ephemeral: identity.Ephemeral,
174 reachability: ports.RouteReachabilityReachable,
175 },
176 dialer: active.dialer,
177 request: request,
178 resumeToken: active.resumeToken,
179 home: false,
180 }
181}
182
183func normalizeRouteOrigin(origin ports.RouteOrigin, remote bool) ports.RouteOrigin {
184 if origin != 0 {
185 return origin
186 }
187 if remote {
188 return ports.RouteOriginRemote
189 }
190 return ports.RouteOriginLocal
191}
192
193func routeFailureCode(err error) ports.RouteFailureCode {
194 var protocolErr *ProtocolError
195 if errors.As(err, &protocolErr) {
196 switch protocolErr.Code {
197 case ports.ErrNoSuchSession, ports.ErrNoSuchTarget:
198 return ports.RouteFailureNoSuchRoute
199 }
200 }
201 if errors.Is(err, errRouteTargetChanged) {
202 return ports.RouteFailureTargetChanged
203 }
204 return ports.RouteFailureUnavailable
205}
206
207func normalizeRouteOriginKey(key string, origin ports.RouteOrigin) string {
208 if key != "" {
209 return key
210 }
211 switch origin {
212 case ports.RouteOriginLocal:
213 return "local"
214 case ports.RouteOriginDiscovery:
215 return "discovery"
216 default:
217 return "remote"
218 }
219}
220
221func validateRoutePresentation(p routePresentation) error {
222 if err := ports.ValidateRouteLabel(p.name, false); err != nil {
223 return err
224 }
225 if err := ports.ValidateRouteLabel(p.hostLabel, true); err != nil {
226 return err
227 }
228 if err := p.kind.Validate(); err != nil {
229 return err
230 }
231 if err := p.reachability.Validate(); err != nil {
232 return err
233 }
234 return nil
235}
236
237func (l *routeLedger) nextIdentityLocked() (routeIdentity, error) {
238 if l.nextKey == ^routeKey(0) || l.generation == ^routeGeneration(0) {
239 return routeIdentity{}, errors.New("route ledger identity space exhausted")
240 }
241 l.nextKey++
242 l.generation++
243 return routeIdentity{key: l.nextKey, generation: l.generation}, nil
244}
245
246func (l *routeLedger) findByTargetLocked(origin ports.RouteOrigin, originKey string, target ports.ExactSessionTarget) int {
247 for i := range l.entries {
248 entry := l.entries[i]
249 if entry.origin == origin && entry.originKey == originKey && entry.target == target {
250 return i
251 }
252 }
253 return -1
254}
255
256func (l *routeLedger) moveToFrontLocked(index int, entry routeRecord) {
257 if index < 0 {
258 l.entries = append([]routeRecord{entry}, l.entries...)
259 return
260 }
261 l.entries = append(l.entries[:index], l.entries[index+1:]...)
262 l.entries = append([]routeRecord{entry}, l.entries...)
263}
264
265func (l *routeLedger) removeAtLocked(index int) {
266 if index < 0 || index >= len(l.entries) {
267 return
268 }
269 removed := l.entries[index].identity
270 l.entries = append(l.entries[:index], l.entries[index+1:]...)
271 for _, ref := range []*routeIdentity{&l.active, &l.previous, &l.home} {
272 if *ref == removed {
273 *ref = routeIdentity{}
274 }
275 }
276}
277
278func (l *routeLedger) evictLocked() {
279 for len(l.entries) > maxRouteLedgerEntries {
280 index := len(l.entries) - 1
281 for i := len(l.entries) - 1; i >= 0; i-- {
282 identity := l.entries[i].identity
283 if identity != l.active && identity != l.previous && identity != l.home {
284 index = i
285 break
286 }
287 }
288 l.removeAtLocked(index)
289 }
290}
291
292func prepareRouteCandidate(candidate routeCandidate) (routeCandidate, error) {
293 if err := candidate.origin.Validate(); err != nil {
294 return routeCandidate{}, err
295 }
296 if err := candidate.target.Validate(); err != nil {
297 return routeCandidate{}, err
298 }
299 if err := validateRoutePresentation(candidate.presentation); err != nil {
300 return routeCandidate{}, err
301 }
302 candidate.originKey = normalizeRouteOriginKey(candidate.originKey, candidate.origin)
303 candidate.request = cloneAttachRequest(candidate.request)
304 candidate.request.Origin = candidate.origin
305 candidate.request.OriginKey = candidate.originKey
306 candidate.request.ExactTarget = &candidate.target
307 return candidate, nil
308}
309
310func (l *routeLedger) commit(candidate routeCandidate) (routeIdentity, error) {
311 candidate, err := prepareRouteCandidate(candidate)
312 if err != nil {
313 return routeIdentity{}, err
314 }
315
316 l.mu.Lock()
317 defer l.mu.Unlock()
318 return l.commitLocked(candidate)
319}
320
321// commitAttach assigns the process home route while holding the same lock as
322// the commit. Two concurrent initial attachments therefore cannot both claim
323// an empty home reference.
324func (l *routeLedger) commitAttach(candidate routeCandidate) (routeIdentity, error) {
325 candidate, err := prepareRouteCandidate(candidate)
326 if err != nil {
327 return routeIdentity{}, err
328 }
329
330 l.mu.Lock()
331 defer l.mu.Unlock()
332 candidate.home = l.home.empty()
333 return l.commitLocked(candidate)
334}
335
336// commitCommittedIdentity snapshots the current active record and commits the
337// daemon's local transition under one lock. The active route, origin, dialer,
338// and attach request cannot be read from different ledger generations.
339func (l *routeLedger) commitCommittedIdentity(identity ports.CommittedRouteIdentity) (routeIdentity, error) {
340 if err := identity.Validate(); err != nil {
341 return routeIdentity{}, err
342 }
343
344 l.mu.Lock()
345 defer l.mu.Unlock()
346 activeIndex := l.indexByIdentityLocked(l.active)
347 if activeIndex < 0 {
348 return routeIdentity{}, errors.New("active route unavailable")
349 }
350 active := l.entries[activeIndex]
351 candidate, err := prepareRouteCandidate(routeCandidateForCommittedIdentity(active, identity))
352 if err != nil {
353 return routeIdentity{}, err
354 }
355 // A session rename changes its exact attach target but not its lifecycle.
356 // Keep the active route's process-local identity so the obsolete name cannot
357 // survive as a separately selectable history entry.
358 if active.target.LifecycleID == candidate.target.LifecycleID {
359 if l.generation == ^routeGeneration(0) {
360 return routeIdentity{}, errors.New("route ledger identity space exhausted")
361 }
362 l.generation++
363 l.entries[activeIndex] = routeRecord{
364 identity: active.identity,
365 origin: candidate.origin,
366 originKey: candidate.originKey,
367 target: candidate.target,
368 presentation: candidate.presentation,
369 dialer: candidate.dialer,
370 request: candidate.request,
371 resumeToken: candidate.resumeToken,
372 home: active.home,
373 }
374 return active.identity, nil
375 }
376 return l.commitLocked(candidate)
377}
378
379// updateRoutePosition remembers a cursor only for the currently active exact
380// route. Delayed frames from a prior transition are valid wire messages but do
381// not mutate another route.
382func (l *routeLedger) updateRoutePosition(position ports.RoutePosition) error {
383 if err := position.Validate(); err != nil {
384 return err
385 }
386 l.mu.Lock()
387 defer l.mu.Unlock()
388 activeIndex := l.indexByIdentityLocked(l.active)
389 if activeIndex < 0 || l.entries[activeIndex].target != position.Target {
390 return nil
391 }
392 entry := &l.entries[activeIndex]
393 if entry.request.PreferredTabID == position.ActiveTabID {
394 return nil
395 }
396 // The tab cursor is mutable payload on an otherwise unchanged route.
397 // Rotating the route generation here would invalidate navigation actions
398 // captured immediately before a paint published the new cursor.
399 entry.request.PreferredTabID = position.ActiveTabID
400 entry.request.RemoteTarget = nil
401 return nil
402}
403
404func (l *routeLedger) commitLocked(candidate routeCandidate) (routeIdentity, error) {
405 identity, err := l.nextIdentityLocked()
406 if err != nil {
407 return routeIdentity{}, err
408 }
409 index := l.findByTargetLocked(candidate.origin, candidate.originKey, candidate.target)
410 if index >= 0 {
411 identity.key = l.entries[index].identity.key
412 }
413 isHome := candidate.home || (l.home.key != 0 && l.home.key == identity.key)
414 entry := routeRecord{
415 identity: identity,
416 origin: candidate.origin,
417 originKey: candidate.originKey,
418 target: candidate.target,
419 presentation: candidate.presentation,
420 dialer: candidate.dialer,
421 request: candidate.request,
422 resumeToken: candidate.resumeToken,
423 home: isHome,
424 }
425 oldActive := l.active
426 l.moveToFrontLocked(index, entry)
427 if !oldActive.empty() && oldActive.key != identity.key {
428 l.previous = oldActive
429 }
430 if l.previous.key == identity.key {
431 l.previous = routeIdentity{}
432 }
433 l.active = identity
434 if isHome {
435 l.home = identity
436 }
437 if l.home.key == identity.key {
438 l.home.generation = identity.generation
439 }
440 if l.previous.key != 0 {
441 if previousIndex := l.indexByIdentityLocked(l.previous); previousIndex < 0 {
442 l.previous = routeIdentity{}
443 }
444 }
445 l.evictLocked()
446 return identity, nil
447}
448
449func (l *routeLedger) indexByIdentityLocked(identity routeIdentity) int {
450 for i := range l.entries {
451 if l.entries[i].identity == identity {
452 return i
453 }
454 }
455 return -1
456}
457
458func (l *routeLedger) lookup(ref ports.RouteRef) (routeRecord, bool) {
459 identity := identityFromWire(ref)
460 if identity.empty() {
461 return routeRecord{}, false
462 }
463 l.mu.RLock()
464 defer l.mu.RUnlock()
465 for _, entry := range l.entries {
466 if entry.identity == identity {
467 return cloneRouteRecord(entry), true
468 }
469 }
470 return routeRecord{}, false
471}
472
473func cloneRouteRecord(entry routeRecord) routeRecord {
474 entry.request = cloneAttachRequest(entry.request)
475 return entry
476}
477
478// attentionSubscription publishes only routes served by the active route's
479// daemon. Other route origins remain private to their owning daemon.
480func (l *routeLedger) attentionSubscription() ports.RouteAttentionSubscription {
481 l.mu.RLock()
482 defer l.mu.RUnlock()
483
484 activeIndex := l.indexByIdentityLocked(l.active)
485 if activeIndex < 0 {
486 return ports.RouteAttentionSubscription{}
487 }
488 active := l.entries[activeIndex]
489 subscription := ports.RouteAttentionSubscription{Targets: make([]ports.RouteAttentionTarget, 0, len(l.entries))}
490 for _, entry := range l.entries {
491 if entry.identity == active.identity || entry.origin != active.origin || entry.originKey != active.originKey {
492 continue
493 }
494 subscription.Targets = append(subscription.Targets, ports.RouteAttentionTarget{
495 Ref: entry.identity.wire(),
496 Target: entry.target,
497 })
498 }
499 return subscription
500}
501
502func (l *routeLedger) snapshot() ports.RecentRouteSnapshot {
503 l.mu.RLock()
504 defer l.mu.RUnlock()
505
506 snapshot := ports.RecentRouteSnapshot{
507 Generation: uint64(l.generation),
508 Active: l.active.wire(),
509 Previous: l.previous.wire(),
510 Home: l.home.wire(),
511 }
512 if len(l.entries) == 0 {
513 return snapshot
514 }
515 snapshot.Entries = make([]ports.RecentRouteEntry, 0, len(l.entries))
516 for _, entry := range l.entries {
517 if entry.identity == l.active {
518 continue
519 }
520 snapshot.Entries = append(snapshot.Entries, ports.RecentRouteEntry{
521 Key: uint64(entry.identity.key),
522 Generation: uint64(entry.identity.generation),
523 Name: entry.presentation.name,
524 HostLabel: entry.presentation.hostLabel,
525 Kind: entry.presentation.kind,
526 Ephemeral: entry.presentation.ephemeral,
527 Attention: entry.presentation.attention,
528 Reachability: entry.presentation.reachability,
529 })
530 }
531 return snapshot
532}
533
534// samePeerHandoff restores a previously committed route on the daemon already
535// connected to by the client. The daemon deliberately sends no tab state: the
536// ledger is the sole owner of per-route view memory.
537func (l *routeLedger) samePeerHandoff(active AttachRequest, target ports.AttachTarget) AttachRequest {
538 origin := normalizeRouteOrigin(active.Origin, active.Remote)
539 originKey := normalizeRouteOriginKey(active.OriginKey, origin)
540 environmentPolicy := target.EnvironmentPolicy
541 if origin == ports.RouteOriginLocal {
542 environmentPolicy = ports.EnvironmentPolicyClientOwned
543 }
544
545 l.mu.RLock()
546 defer l.mu.RUnlock()
547 for _, entry := range l.entries {
548 if entry.origin != origin || entry.originKey != originKey || entry.target.SessionName != target.Session {
549 continue
550 }
551 if target.ExactTarget != nil && entry.target != *target.ExactTarget {
552 continue
553 }
554 request := cloneAttachRequest(entry.request)
555 request.Intent = target.Intent
556 // The active dialer already reaches this daemon. Retaining the route's
557 // original discovery target would make its Hello request a second,
558 // incorrect remote handoff instead of a same-peer session switch.
559 request.RemoteTarget = nil
560 if request.EnvironmentPolicy != environmentPolicy && environmentPolicy == ports.EnvironmentPolicyClientOwned {
561 request.NavigationCapabilities = 0
562 request.StartupOverlay = ports.StartupOverlayNone
563 }
564 request.EnvironmentPolicy = environmentPolicy
565 request.ExactTarget = target.ExactTarget
566 if target.ExactTarget == nil {
567 request.PreferredTabID = ""
568 }
569 return request
570 }
571
572 request := cloneAttachRequest(active)
573 request.Intent = target.Intent
574 request.SessionName = target.Session
575 request.ExactTarget = target.ExactTarget
576 request.PreferredTabID = ""
577 request.RemoteTarget = nil
578 if request.EnvironmentPolicy != environmentPolicy && environmentPolicy == ports.EnvironmentPolicyClientOwned {
579 request.NavigationCapabilities = 0
580 request.StartupOverlay = ports.StartupOverlayNone
581 }
582 request.EnvironmentPolicy = environmentPolicy
583 return request
584}
585
586func (l *routeLedger) activeRef() ports.RouteRef {
587 l.mu.RLock()
588 defer l.mu.RUnlock()
589 return l.active.wire()
590}
591
592func (l *routeLedger) previousRef() ports.RouteRef {
593 l.mu.RLock()
594 defer l.mu.RUnlock()
595 return l.previous.wire()
596}
597
598func (l *routeLedger) homeRef() ports.RouteRef {
599 l.mu.RLock()
600 defer l.mu.RUnlock()
601 return l.home.wire()
602}
603
604// routeTransitionPlan is the private lookup result used by navigation. It
605// preserves the original origin and exact target while leaving display labels
606// and transport capabilities separate.
607type routeTransitionPlan struct {
608 identity routeIdentity
609 record routeRecord
610 snapshotGeneration routeGeneration
611 active routeIdentity
612 origin ports.RouteOrigin
613 originKey string
614 target ports.ExactSessionTarget
615}
616
617type routeTransitionCommit struct {
618 identity ports.CommittedRouteIdentity
619 presentation routePresentation
620 dialer ports.Dialer
621 resumeToken uint64
622}
623
624type routeTransitionConnector interface {
625 Resume(context.Context, routeRecord) (routeTransitionCommit, error)
626 Attach(context.Context, routeRecord) (routeTransitionCommit, error)
627 Restore(context.Context, routeRecord) error
628}
629
630type routeNavigationSelection struct {
631 selected routeRecord
632 prior routeRecord
633 snapshotGeneration routeGeneration
634 active routeIdentity
635 noOp bool
636}
637
638// navigationSelection validates an action against the latest complete
639// snapshot and captures the selected route, live restoration route, snapshot
640// generation, and active identity under one read lock. The commit phase uses
641// the captured generation and active identity to reject interleavings that
642// changed the ledger while dialing.
643func (l *routeLedger) navigationSelection(action ports.RouteNavigationAction) (routeNavigationSelection, bool) {
644 if action.SnapshotGeneration == 0 || action.Key == 0 || action.Generation == 0 {
645 return routeNavigationSelection{}, false
646 }
647 identity := identityFromWire(ports.RouteRef{Key: action.Key, Generation: action.Generation})
648 l.mu.RLock()
649 defer l.mu.RUnlock()
650 if uint64(l.generation) != action.SnapshotGeneration {
651 return routeNavigationSelection{}, false
652 }
653 selection := routeNavigationSelection{
654 snapshotGeneration: l.generation,
655 active: l.active,
656 }
657 if identity == l.active {
658 selection.noOp = true
659 return selection, true
660 }
661 selectedIndex := l.indexByIdentityLocked(identity)
662 activeIndex := l.indexByIdentityLocked(l.active)
663 if selectedIndex < 0 || activeIndex < 0 {
664 return routeNavigationSelection{}, false
665 }
666 selection.selected = cloneRouteRecord(l.entries[selectedIndex])
667 selection.prior = cloneRouteRecord(l.entries[activeIndex])
668 return selection, true
669}
670
671// navigationRecord validates that an action belongs to the latest complete
672// snapshot. The active route is metadata-only and selecting it is a no-op.
673func (l *routeLedger) navigationRecord(action ports.RouteNavigationAction) (routeRecord, bool, bool) {
674 selection, ok := l.navigationSelection(action)
675 return selection.selected, ok, selection.noOp
676}
677
678// navigate resolves a captured key/generation, attempts resume before exact
679// attach, and commits only after the connector returns a matching identity.
680// A failed transition restores the prior live route and leaves history alone.
681func (l *routeLedger) navigate(ctx context.Context, action ports.RouteNavigationAction, connector routeTransitionConnector) error {
682 selection, ok := l.navigationSelection(action)
683 if !ok {
684 return errRouteStaleSelection
685 }
686 if selection.noOp {
687 return nil
688 }
689 record := selection.selected
690 prior := selection.prior
691 if connector == nil {
692 return errors.New("route transition connector is nil")
693 }
694 if ctx == nil {
695 return errors.New("route transition context is nil")
696 }
697 selected := ports.RouteRef{Key: action.Key, Generation: action.Generation}
698 plan := routeTransitionPlan{
699 identity: identityFromWire(selected),
700 record: record,
701 snapshotGeneration: selection.snapshotGeneration,
702 active: selection.active,
703 origin: record.origin,
704 originKey: record.originKey,
705 target: record.target,
706 }
707 restoreOnFailure := func(err error) error {
708 if restoreErr := connector.Restore(ctx, prior); restoreErr != nil {
709 return errors.Join(err, fmt.Errorf("restoring prior route: %w", restoreErr))
710 }
711 return err
712 }
713
714 var commit routeTransitionCommit
715 var err error
716 if record.resumeToken != 0 {
717 commit, err = connector.Resume(ctx, record)
718 if errors.Is(err, errRouteResumeUnavailable) {
719 commit, err = connector.Attach(ctx, record)
720 }
721 } else {
722 commit, err = connector.Attach(ctx, record)
723 }
724 if err != nil {
725 return restoreOnFailure(err)
726 }
727 if err := commit.identity.Validate(); err != nil {
728 return restoreOnFailure(fmt.Errorf("invalid committed route identity: %w", err))
729 }
730 if commit.identity.Target != plan.target {
731 return restoreOnFailure(errRouteTargetChanged)
732 }
733 if err := validateRoutePresentation(commit.presentation); err != nil {
734 return restoreOnFailure(err)
735 }
736
737 candidate := routeCandidate{
738 origin: plan.origin,
739 originKey: plan.originKey,
740 target: plan.target,
741 presentation: commit.presentation,
742 dialer: commit.dialer,
743 request: cloneAttachRequest(plan.record.request),
744 resumeToken: commit.resumeToken,
745 home: plan.record.home,
746 }
747 candidate.request.Origin = plan.origin
748 candidate.request.OriginKey = plan.originKey
749 candidate.request.ExactTarget = &candidate.target
750 if err := l.commitTransition(plan.identity, plan.snapshotGeneration, plan.active, candidate); err != nil {
751 return restoreOnFailure(err)
752 }
753 return nil
754}
755
756func (l *routeLedger) commitTransition(selected routeIdentity, snapshotGeneration routeGeneration, active routeIdentity, candidate routeCandidate) error {
757 candidate, err := prepareRouteCandidate(candidate)
758 if err != nil {
759 return err
760 }
761 l.mu.Lock()
762 defer l.mu.Unlock()
763 if l.generation != snapshotGeneration || l.active != active {
764 return errRouteStaleSelection
765 }
766 if l.indexByIdentityLocked(selected) < 0 {
767 return errRouteStaleSelection
768 }
769 if _, err := l.commitLocked(candidate); err != nil {
770 return err
771 }
772 return nil
773}
774
775// beginDiscoveryCycle and discoveryCycleCurrent deliberately use a lock
776// separate from route history. A stale discovery result cannot clear or commit
777// unrelated navigation history.
778func (l *routeLedger) beginDiscoveryCycle() uint64 {
779 l.discoveryMu.Lock()
780 defer l.discoveryMu.Unlock()
781 l.discoveryCycle++
782 if l.discoveryCycle == 0 {
783 l.discoveryCycle++
784 }
785 return l.discoveryCycle
786}
787
788func (l *routeLedger) discoveryCycleCurrent(cycle uint64) bool {
789 if cycle == 0 {
790 return false
791 }
792 l.discoveryMu.Lock()
793 defer l.discoveryMu.Unlock()
794 return l.discoveryCycle == cycle
795}