fix(daemon): hold purge admission during a client-initiated same-peer switch
A switch started from the picker moved an attachment between sessions without reserving the KillAll admission gate, so it could run inside the session set a purge had already captured. The switch now refuses while a purge owns admission and holds a reservation for its duration. The SamePeerSwitchRequest comment now describes client-initiated switches.
- commit
- 7f72ec6c2556abccb1ff9f96a364798ad9ef18f7
- author
- brice · · 2026-10-08 05:59 UTC
- parent
- dced086
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +26 -3
M internal/protocol/routes.go +5 -3
| @@ -505,9 +505,11 @@ func (a RouteCreateSessionAction) Validate() error { | |||
| 505 | 505 | return nil | |
| 506 | 506 | } | |
| 507 | 507 | ||
| 508 | - | // SamePeerSwitchRequest confirms a daemon-offered endpoint-empty target. It | |
| 509 | - | // carries only the exact lifecycle identity and the client-owned tab cursor; | |
| 510 | - | // transport origin remains proven by the existing authenticated connection. | |
| 508 | + | // SamePeerSwitchRequest moves the attachment to another session of the same | |
| 509 | + | // daemon. The client sends it from the picker or to confirm a daemon offer; a | |
| 510 | + | // pending offer pins the only accepted target. It carries only the exact | |
| 511 | + | // lifecycle identity and the client-owned tab cursor; transport origin remains | |
| 512 | + | // proven by the existing authenticated connection. | |
| 511 | 513 | type SamePeerSwitchRequest struct { | |
| 512 | 514 | RequestID uint64 | |
| 513 | 515 | Target ExactSessionTarget | |
| @@ -505,9 +505,11 @@ func (a RouteCreateSessionAction) Validate() error { | |||||
| 505 | return nil | 505 | return nil | ||
| 506 | } | 506 | } | ||
| 507 | 507 | ||||
| 508 | - | // SamePeerSwitchRequest confirms a daemon-offered endpoint-empty target. It | 508 | + | // SamePeerSwitchRequest moves the attachment to another session of the same |
| 509 | - | // carries only the exact lifecycle identity and the client-owned tab cursor; | 509 | + | // daemon. The client sends it from the picker or to confirm a daemon offer; a |
| 510 | - | // transport origin remains proven by the existing authenticated connection. | 510 | + | // pending offer pins the only accepted target. It carries only the exact |
| 511 | + | // lifecycle identity and the client-owned tab cursor; transport origin remains | |||
| 512 | + | // proven by the existing authenticated connection. | |||
| 511 | type SamePeerSwitchRequest struct { | 513 | type SamePeerSwitchRequest struct { | ||
| 512 | RequestID uint64 | 514 | RequestID uint64 | ||
| 513 | Target ExactSessionTarget | 515 | Target ExactSessionTarget | ||
M internal/usecase/daemon/same_peer_switch.go +12 -0
| @@ -72,6 +72,18 @@ func (d *Daemon) switchSamePeerForAttachment(effect *attachmentEffect, request p | |||
| 72 | 72 | return | |
| 73 | 73 | } | |
| 74 | 74 | ||
| 75 | + | // The switch moves an attachment between sessions, so it must not run | |
| 76 | + | // inside the exact set a KillAll purge has captured. | |
| 77 | + | d.mu.Lock() | |
| 78 | + | if d.purgeAdmissionClosedLocked() { | |
| 79 | + | d.mu.Unlock() | |
| 80 | + | d.sendSamePeerSwitchFailure(effect, request.RequestID, protocol.SamePeerSwitchStaleTarget) | |
| 81 | + | return | |
| 82 | + | } | |
| 83 | + | d.purgeAdmissionActive++ | |
| 84 | + | d.mu.Unlock() | |
| 85 | + | defer d.releasePurgeAdmission() | |
| 86 | + | ||
| 75 | 87 | target, targetTabIndex, ok := d.samePeerTarget(request) | |
| 76 | 88 | if !ok || target == effect.sess { | |
| 77 | 89 | d.sendSamePeerSwitchFailure(effect, request.RequestID, protocol.SamePeerSwitchStaleTarget) | |
| @@ -72,6 +72,18 @@ func (d *Daemon) switchSamePeerForAttachment(effect *attachmentEffect, request p | |||||
| 72 | return | 72 | return | ||
| 73 | } | 73 | } | ||
| 74 | 74 | ||||
| 75 | + | // The switch moves an attachment between sessions, so it must not run | |||
| 76 | + | // inside the exact set a KillAll purge has captured. | |||
| 77 | + | d.mu.Lock() | |||
| 78 | + | if d.purgeAdmissionClosedLocked() { | |||
| 79 | + | d.mu.Unlock() | |||
| 80 | + | d.sendSamePeerSwitchFailure(effect, request.RequestID, protocol.SamePeerSwitchStaleTarget) | |||
| 81 | + | return | |||
| 82 | + | } | |||
| 83 | + | d.purgeAdmissionActive++ | |||
| 84 | + | d.mu.Unlock() | |||
| 85 | + | defer d.releasePurgeAdmission() | |||
| 86 | + | ||||
| 75 | target, targetTabIndex, ok := d.samePeerTarget(request) | 87 | target, targetTabIndex, ok := d.samePeerTarget(request) | ||
| 76 | if !ok || target == effect.sess { | 88 | if !ok || target == effect.sess { | ||
| 77 | d.sendSamePeerSwitchFailure(effect, request.RequestID, protocol.SamePeerSwitchStaleTarget) | 89 | d.sendSamePeerSwitchFailure(effect, request.RequestID, protocol.SamePeerSwitchStaleTarget) | ||
M internal/usecase/daemon/same_peer_switch_test.go +9 -0
| @@ -131,11 +131,13 @@ func TestSamePeerSwitchClientInitiated(t *testing.T) { | |||
| 131 | 131 | tests := []struct { | |
| 132 | 132 | name string | |
| 133 | 133 | offer *protocol.ExactSessionTarget | |
| 134 | + | purging bool | |
| 134 | 135 | switched bool | |
| 135 | 136 | }{ | |
| 136 | 137 | {name: "no pending offer switches", switched: true}, | |
| 137 | 138 | {name: "matching offer switches", offer: &requestTarget, switched: true}, | |
| 138 | 139 | {name: "another pending offer refuses", offer: &otherOffer}, | |
| 140 | + | {name: "a running purge refuses", purging: true}, | |
| 139 | 141 | } | |
| 140 | 142 | for _, tt := range tests { | |
| 141 | 143 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -155,6 +157,9 @@ func TestSamePeerSwitchClientInitiated(t *testing.T) { | |||
| 155 | 157 | if tt.offer != nil { | |
| 156 | 158 | ac.offerSamePeerTarget(*tt.offer) | |
| 157 | 159 | } | |
| 160 | + | d.mu.Lock() | |
| 161 | + | d.purgeAdmissionClosing = tt.purging | |
| 162 | + | d.mu.Unlock() | |
| 158 | 163 | ||
| 159 | 164 | token := source.captureAttachmentCapability(ac, ac.transport()) | |
| 160 | 165 | effect, admitted := ac.beginAttachmentEffect(token) | |
| @@ -162,6 +167,10 @@ func TestSamePeerSwitchClientInitiated(t *testing.T) { | |||
| 162 | 167 | defer effect.End() | |
| 163 | 168 | d.switchSamePeerForAttachment(effect, protocol.SamePeerSwitchRequest{RequestID: 1, Target: requestTarget}) | |
| 164 | 169 | ||
| 170 | + | d.mu.Lock() | |
| 171 | + | require.Zero(t, d.purgeAdmissionActive, "the switch releases its purge admission") | |
| 172 | + | d.purgeAdmissionClosing = false | |
| 173 | + | d.mu.Unlock() | |
| 165 | 174 | if tt.switched { | |
| 166 | 175 | require.Same(t, target, ac.currentAttachmentSession()) | |
| 167 | 176 | identity := decodeServerMessage(t, awaitFrame(t, sends, "CommittedRouteIdentity")).(protocol.CommittedRouteIdentity) | |
| @@ -131,11 +131,13 @@ func TestSamePeerSwitchClientInitiated(t *testing.T) { | |||||
| 131 | tests := []struct { | 131 | tests := []struct { | ||
| 132 | name string | 132 | name string | ||
| 133 | offer *protocol.ExactSessionTarget | 133 | offer *protocol.ExactSessionTarget | ||
| 134 | + | purging bool | |||
| 134 | switched bool | 135 | switched bool | ||
| 135 | }{ | 136 | }{ | ||
| 136 | {name: "no pending offer switches", switched: true}, | 137 | {name: "no pending offer switches", switched: true}, | ||
| 137 | {name: "matching offer switches", offer: &requestTarget, switched: true}, | 138 | {name: "matching offer switches", offer: &requestTarget, switched: true}, | ||
| 138 | {name: "another pending offer refuses", offer: &otherOffer}, | 139 | {name: "another pending offer refuses", offer: &otherOffer}, | ||
| 140 | + | {name: "a running purge refuses", purging: true}, | |||
| 139 | } | 141 | } | ||
| 140 | for _, tt := range tests { | 142 | for _, tt := range tests { | ||
| 141 | t.Run(tt.name, func(t *testing.T) { | 143 | t.Run(tt.name, func(t *testing.T) { | ||
| @@ -155,6 +157,9 @@ func TestSamePeerSwitchClientInitiated(t *testing.T) { | |||||
| 155 | if tt.offer != nil { | 157 | if tt.offer != nil { | ||
| 156 | ac.offerSamePeerTarget(*tt.offer) | 158 | ac.offerSamePeerTarget(*tt.offer) | ||
| 157 | } | 159 | } | ||
| 160 | + | d.mu.Lock() | |||
| 161 | + | d.purgeAdmissionClosing = tt.purging | |||
| 162 | + | d.mu.Unlock() | |||
| 158 | 163 | ||||
| 159 | token := source.captureAttachmentCapability(ac, ac.transport()) | 164 | token := source.captureAttachmentCapability(ac, ac.transport()) | ||
| 160 | effect, admitted := ac.beginAttachmentEffect(token) | 165 | effect, admitted := ac.beginAttachmentEffect(token) | ||
| @@ -162,6 +167,10 @@ func TestSamePeerSwitchClientInitiated(t *testing.T) { | |||||
| 162 | defer effect.End() | 167 | defer effect.End() | ||
| 163 | d.switchSamePeerForAttachment(effect, protocol.SamePeerSwitchRequest{RequestID: 1, Target: requestTarget}) | 168 | d.switchSamePeerForAttachment(effect, protocol.SamePeerSwitchRequest{RequestID: 1, Target: requestTarget}) | ||
| 164 | 169 | ||||
| 170 | + | d.mu.Lock() | |||
| 171 | + | require.Zero(t, d.purgeAdmissionActive, "the switch releases its purge admission") | |||
| 172 | + | d.purgeAdmissionClosing = false | |||
| 173 | + | d.mu.Unlock() | |||
| 165 | if tt.switched { | 174 | if tt.switched { | ||
| 166 | require.Same(t, target, ac.currentAttachmentSession()) | 175 | require.Same(t, target, ac.currentAttachmentSession()) | ||
| 167 | identity := decodeServerMessage(t, awaitFrame(t, sends, "CommittedRouteIdentity")).(protocol.CommittedRouteIdentity) | 176 | identity := decodeServerMessage(t, awaitFrame(t, sends, "CommittedRouteIdentity")).(protocol.CommittedRouteIdentity) | ||