fix(remote): fence proxy picker lifecycle
- commit
- 67576a2c0186fb8eb58c0ce0e55387fe834a1f32
- author
- brice · · 2026-07-31 21:02 UTC
- parent
- d8baccc
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +56 -13
M internal/usecase/daemon/attachment_transition.go +14 -1
| @@ -1,9 +1,21 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | - | import "errors" | |
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | ) | |
| 4 | 8 | ||
| 5 | 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 | 19 | type attachmentTransitionRequest struct { | |
| 8 | 20 | source attachmentSession | |
| 9 | 21 | target attachmentSession | |
| @@ -29,6 +41,7 @@ type attachmentTransitionRequest struct { | |||
| 29 | 41 | ready bool | |
| 30 | 42 | expectedTargetCurrent *attachedClient | |
| 31 | 43 | expectedTargetTransport transportSnapshot | |
| 44 | + | expectedTargetLifecycle *attachmentLifecycleFence | |
| 32 | 45 | preflighted bool | |
| 33 | 46 | roleEffectsFrozen bool | |
| 34 | 47 | expectedTargetTransportInterrupted bool | |
| @@ -1,9 +1,21 @@ | |||||
| 1 | package daemon | 1 | 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 attachmentSession | 20 | source attachmentSession | ||
| 9 | target attachmentSession | 21 | target attachmentSession | ||
| @@ -29,6 +41,7 @@ type attachmentTransitionRequest struct { | |||||
| 29 | ready bool | 41 | ready bool | ||
| 30 | expectedTargetCurrent *attachedClient | 42 | expectedTargetCurrent *attachedClient | ||
| 31 | expectedTargetTransport transportSnapshot | 43 | expectedTargetTransport transportSnapshot | ||
| 44 | + | expectedTargetLifecycle *attachmentLifecycleFence | |||
| 32 | preflighted bool | 45 | preflighted bool | ||
| 33 | roleEffectsFrozen bool | 46 | roleEffectsFrozen bool | ||
| 34 | expectedTargetTransportInterrupted bool | 47 | expectedTargetTransportInterrupted bool | ||
M internal/usecase/daemon/attachment_transition_publish.go +15 -0
| @@ -94,6 +94,20 @@ func (p *attachmentPublication) unlockCoordinators() { | |||
| 94 | 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 | 111 | // validateAttachmentTransitionPrelocked performs every fallible transition | |
| 98 | 112 | // check without acquiring d.mu, notices.routingMu, or either session lock. | |
| 99 | 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 | 157 | invalid = invalid || targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next) | |
| 144 | 158 | invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil && | |
| 145 | 159 | (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next) | |
| 160 | + | invalid = invalid || !attachmentLifecycleCurrentLocked(req.target, req.expectedTargetLifecycle) | |
| 146 | 161 | invalid = invalid || req.activateTargetTab && !req.target.activateTargetLocked(req.targetTabIndex) | |
| 147 | 162 | if invalid { | |
| 148 | 163 | 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 transition | 111 | // 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 | |||
| 581 | 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 | 601 | func (d *Daemon) switchActiveTargetForRoleGuarded(token attachmentRoleToken, target picker.Target, guard sessionHandoffGuard, action string) error { | |
| 585 | 602 | if token.sess == nil || token.ac == nil || token.role != attachmentActive { | |
| 586 | 603 | return nil | |
| 587 | 604 | } | |
| 588 | 605 | d.mu.Lock() | |
| 589 | 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 | 607 | d.mu.Unlock() | |
| 597 | 608 | if !ok || targetSess == token.sess { | |
| 598 | 609 | return nil | |
| @@ -602,7 +613,8 @@ func (d *Daemon) switchActiveTargetForRoleGuarded(token attachmentRoleToken, tar | |||
| 602 | 613 | source: token.sess, target: targetSess, next: token.ac, | |
| 603 | 614 | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 604 | 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 | 619 | if err != nil { | |
| 608 | 620 | // 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 nil | 603 | 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 nil | 609 | 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 for | 620 | // 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) { | |||
| 722 | 722 | model := d.newPickerModel(proxy, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(tb.stableID)}) | |
| 723 | 723 | target, selectable := model.Selected() | |
| 724 | 724 | 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 | + | } | |
| 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 | 734 | require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker lifecycle must not redirect the attachment") | |
| 732 | 735 | } | |
| 733 | 736 | ||
| @@ -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 | ||||