v0.3.0 99dc266

routes.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close