detached 67576a2

fix(remote): fence proxy picker lifecycle

commit
67576a2c0186fb8eb58c0ce0e55387fe834a1f32
author
brice · · 2026-07-31 21:02 UTC
parent
d8baccc
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +56 -13
M internal/usecase/daemon/attachment_transition.go +14 -1
@@ -1,9 +1,21 @@
11 package daemon
22
3-import "errors"
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
48
59 var errAttachmentTransition = errors.New("attachment transition is no longer valid")
610
11+type attachmentLifecycleFence struct {
12+ name string
13+ createdAt int64
14+ checkCreatedAt bool
15+ incarnation domain.IncarnationID
16+ checkIncarnation bool
17+}
18+
719 type attachmentTransitionRequest struct {
820 source attachmentSession
921 target attachmentSession
@@ -29,6 +41,7 @@ type attachmentTransitionRequest struct {
2941 ready bool
3042 expectedTargetCurrent *attachedClient
3143 expectedTargetTransport transportSnapshot
44+ expectedTargetLifecycle *attachmentLifecycleFence
3245 preflighted bool
3346 roleEffectsFrozen bool
3447 expectedTargetTransportInterrupted bool
@@ -1,9 +1,21 @@
1 package daemon1 package daemon
2 2
3-import "errors"3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
4 8
5 var errAttachmentTransition = errors.New("attachment transition is no longer valid")9 var errAttachmentTransition = errors.New("attachment transition is no longer valid")
6 10
11+type attachmentLifecycleFence struct {
12+ name string
13+ createdAt int64
14+ checkCreatedAt bool
15+ incarnation domain.IncarnationID
16+ checkIncarnation bool
17+}
18+
7 type attachmentTransitionRequest struct {19 type attachmentTransitionRequest struct {
8 source attachmentSession20 source attachmentSession
9 target attachmentSession21 target attachmentSession
@@ -29,6 +41,7 @@ type attachmentTransitionRequest struct {
29 ready bool41 ready bool
30 expectedTargetCurrent *attachedClient42 expectedTargetCurrent *attachedClient
31 expectedTargetTransport transportSnapshot43 expectedTargetTransport transportSnapshot
44+ expectedTargetLifecycle *attachmentLifecycleFence
32 preflighted bool45 preflighted bool
33 roleEffectsFrozen bool46 roleEffectsFrozen bool
34 expectedTargetTransportInterrupted bool47 expectedTargetTransportInterrupted bool
M internal/usecase/daemon/attachment_transition_publish.go +15 -0
@@ -94,6 +94,20 @@ func (p *attachmentPublication) unlockCoordinators() {
9494 p.releaseCoordinators()
9595 }
9696
97+func attachmentLifecycleCurrentLocked(entry attachmentSession, fence *attachmentLifecycleFence) bool {
98+ if fence == nil {
99+ return true
100+ }
101+ target, ok := localSession(entry)
102+ if !ok {
103+ return false
104+ }
105+ if fence.checkCreatedAt && (target.name != fence.name || target.createdAt != fence.createdAt) {
106+ return false
107+ }
108+ return !fence.checkIncarnation || target.incarnation == fence.incarnation
109+}
110+
97111 // validateAttachmentTransitionPrelocked performs every fallible transition
98112 // check without acquiring d.mu, notices.routingMu, or either session lock.
99113 // The caller holds those locks in that order, with session locks ordered by ID.
@@ -143,6 +157,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
143157 invalid = invalid || targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next)
144158 invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil &&
145159 (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next)
160+ invalid = invalid || !attachmentLifecycleCurrentLocked(req.target, req.expectedTargetLifecycle)
146161 invalid = invalid || req.activateTargetTab && !req.target.activateTargetLocked(req.targetTabIndex)
147162 if invalid {
148163 publication.unlockCoordinators()
@@ -94,6 +94,20 @@ func (p *attachmentPublication) unlockCoordinators() {
94 p.releaseCoordinators()94 p.releaseCoordinators()
95 }95 }
96 96
97+func attachmentLifecycleCurrentLocked(entry attachmentSession, fence *attachmentLifecycleFence) bool {
98+ if fence == nil {
99+ return true
100+ }
101+ target, ok := localSession(entry)
102+ if !ok {
103+ return false
104+ }
105+ if fence.checkCreatedAt && (target.name != fence.name || target.createdAt != fence.createdAt) {
106+ return false
107+ }
108+ return !fence.checkIncarnation || target.incarnation == fence.incarnation
109+}
110+
97 // validateAttachmentTransitionPrelocked performs every fallible transition111 // validateAttachmentTransitionPrelocked performs every fallible transition
98 // check without acquiring d.mu, notices.routingMu, or either session lock.112 // check without acquiring d.mu, notices.routingMu, or either session lock.
99 // The caller holds those locks in that order, with session locks ordered by ID.113 // The caller holds those locks in that order, with session locks ordered by ID.
@@ -143,6 +157,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
143 invalid = invalid || targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next)157 invalid = invalid || targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next)
144 invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil &&158 invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil &&
145 (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next)159 (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next)
160+ invalid = invalid || !attachmentLifecycleCurrentLocked(req.target, req.expectedTargetLifecycle)
146 invalid = invalid || req.activateTargetTab && !req.target.activateTargetLocked(req.targetTabIndex)161 invalid = invalid || req.activateTargetTab && !req.target.activateTargetLocked(req.targetTabIndex)
147 if invalid {162 if invalid {
148 publication.unlockCoordinators()163 publication.unlockCoordinators()
M internal/usecase/daemon/picker.go +19 -7
@@ -581,18 +581,29 @@ func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target pic
581581 return d.switchActiveTargetForRoleGuarded(token, target, sessionHandoffGuard{}, "jump-attention")
582582 }
583583
584+func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence {
585+ fence := &attachmentLifecycleFence{}
586+ if target.ExpectedCreatedAt != nil {
587+ fence.name = target.Name
588+ fence.createdAt = *target.ExpectedCreatedAt
589+ fence.checkCreatedAt = true
590+ }
591+ if target.Incarnation != (domain.IncarnationID{}) {
592+ fence.incarnation = target.Incarnation
593+ fence.checkIncarnation = true
594+ }
595+ if !fence.checkCreatedAt && !fence.checkIncarnation {
596+ return nil
597+ }
598+ return fence
599+}
600+
584601 func (d *Daemon) switchActiveTargetForRoleGuarded(token attachmentRoleToken, target picker.Target, guard sessionHandoffGuard, action string) error {
585602 if token.sess == nil || token.ac == nil || token.role != attachmentActive {
586603 return nil
587604 }
588605 d.mu.Lock()
589606 targetSess, ok := localSession(d.sessions[target.Session])
590- if ok {
591- targetSess.mu.Lock()
592- ok = targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) &&
593- (target.Incarnation == (domain.IncarnationID{}) || target.Incarnation == targetSess.incarnation)
594- targetSess.mu.Unlock()
595- }
596607 d.mu.Unlock()
597608 if !ok || targetSess == token.sess {
598609 return nil
@@ -602,7 +613,8 @@ func (d *Daemon) switchActiveTargetForRoleGuarded(token attachmentRoleToken, tar
602613 source: token.sess, target: targetSess, next: token.ac,
603614 expectedRole: attachmentActive, targetRole: attachmentActive,
604615 expectedTransport: token.transport, sourceToken: &token, action: action,
605- activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,
616+ expectedTargetLifecycle: pickerTargetLifecycleFence(target),
617+ activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,
606618 })
607619 if err != nil {
608620 // Losing the exact source role is a benign stale action, not a notice for
@@ -581,18 +581,29 @@ func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target pic
581 return d.switchActiveTargetForRoleGuarded(token, target, sessionHandoffGuard{}, "jump-attention")581 return d.switchActiveTargetForRoleGuarded(token, target, sessionHandoffGuard{}, "jump-attention")
582 }582 }
583 583
584+func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence {
585+ fence := &attachmentLifecycleFence{}
586+ if target.ExpectedCreatedAt != nil {
587+ fence.name = target.Name
588+ fence.createdAt = *target.ExpectedCreatedAt
589+ fence.checkCreatedAt = true
590+ }
591+ if target.Incarnation != (domain.IncarnationID{}) {
592+ fence.incarnation = target.Incarnation
593+ fence.checkIncarnation = true
594+ }
595+ if !fence.checkCreatedAt && !fence.checkIncarnation {
596+ return nil
597+ }
598+ return fence
599+}
600+
584 func (d *Daemon) switchActiveTargetForRoleGuarded(token attachmentRoleToken, target picker.Target, guard sessionHandoffGuard, action string) error {601 func (d *Daemon) switchActiveTargetForRoleGuarded(token attachmentRoleToken, target picker.Target, guard sessionHandoffGuard, action string) error {
585 if token.sess == nil || token.ac == nil || token.role != attachmentActive {602 if token.sess == nil || token.ac == nil || token.role != attachmentActive {
586 return nil603 return nil
587 }604 }
588 d.mu.Lock()605 d.mu.Lock()
589 targetSess, ok := localSession(d.sessions[target.Session])606 targetSess, ok := localSession(d.sessions[target.Session])
590- if ok {
591- targetSess.mu.Lock()
592- ok = targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) &&
593- (target.Incarnation == (domain.IncarnationID{}) || target.Incarnation == targetSess.incarnation)
594- targetSess.mu.Unlock()
595- }
596 d.mu.Unlock()607 d.mu.Unlock()
597 if !ok || targetSess == token.sess {608 if !ok || targetSess == token.sess {
598 return nil609 return nil
@@ -602,7 +613,8 @@ func (d *Daemon) switchActiveTargetForRoleGuarded(token attachmentRoleToken, tar
602 source: token.sess, target: targetSess, next: token.ac,613 source: token.sess, target: targetSess, next: token.ac,
603 expectedRole: attachmentActive, targetRole: attachmentActive,614 expectedRole: attachmentActive, targetRole: attachmentActive,
604 expectedTransport: token.transport, sourceToken: &token, action: action,615 expectedTransport: token.transport, sourceToken: &token, action: action,
605- activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,616+ expectedTargetLifecycle: pickerTargetLifecycleFence(target),
617+ activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,
606 })618 })
607 if err != nil {619 if err != nil {
608 // Losing the exact source role is a benign stale action, not a notice for620 // Losing the exact source role is a benign stale action, not a notice for
M internal/usecase/daemon/proxy_input_test.go +8 -5
@@ -722,12 +722,15 @@ func TestRemotePickerRejectsReplacedLocalLifecycle(t *testing.T) {
722722 model := d.newPickerModel(proxy, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(tb.stableID)})
723723 target, selectable := model.Selected()
724724 require.True(t, selectable)
725- local.mu.Lock()
726- local.name = "replacement"
727- local.createdAt++
728- local.mu.Unlock()
725+ d.afterRoleEffectsFrozen = func() {
726+ d.afterRoleEffectsFrozen = nil
727+ local.mu.Lock()
728+ local.name = "replacement"
729+ local.createdAt++
730+ local.mu.Unlock()
731+ }
729732
730- require.NoError(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{}, "picker-select"))
733+ require.Error(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{}, "picker-select"))
731734 require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker lifecycle must not redirect the attachment")
732735 }
733736
@@ -722,12 +722,15 @@ func TestRemotePickerRejectsReplacedLocalLifecycle(t *testing.T) {
722 model := d.newPickerModel(proxy, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(tb.stableID)})722 model := d.newPickerModel(proxy, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(tb.stableID)})
723 target, selectable := model.Selected()723 target, selectable := model.Selected()
724 require.True(t, selectable)724 require.True(t, selectable)
725- local.mu.Lock()725+ d.afterRoleEffectsFrozen = func() {
726- local.name = "replacement"726+ d.afterRoleEffectsFrozen = nil
727- local.createdAt++727+ local.mu.Lock()
728- local.mu.Unlock()728+ local.name = "replacement"
729+ local.createdAt++
730+ local.mu.Unlock()
731+ }
729 732
730- require.NoError(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{}, "picker-select"))733+ require.Error(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{}, "picker-select"))
731 require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker lifecycle must not redirect the attachment")734 require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker lifecycle must not redirect the attachment")
732 }735 }
733 736

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