detached 7f72ec6

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
505505 return nil
506506 }
507507
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.
511513 type SamePeerSwitchRequest struct {
512514 RequestID uint64
513515 Target ExactSessionTarget
@@ -505,9 +505,11 @@ func (a RouteCreateSessionAction) Validate() error {
505 return nil505 return nil
506 }506 }
507 507
508-// SamePeerSwitchRequest confirms a daemon-offered endpoint-empty target. It508+// 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 uint64514 RequestID uint64
513 Target ExactSessionTarget515 Target ExactSessionTarget
M internal/usecase/daemon/same_peer_switch.go +12 -0
@@ -72,6 +72,18 @@ func (d *Daemon) switchSamePeerForAttachment(effect *attachmentEffect, request p
7272 return
7373 }
7474
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+
7587 target, targetTabIndex, ok := d.samePeerTarget(request)
7688 if !ok || target == effect.sess {
7789 d.sendSamePeerSwitchFailure(effect, request.RequestID, protocol.SamePeerSwitchStaleTarget)
@@ -72,6 +72,18 @@ func (d *Daemon) switchSamePeerForAttachment(effect *attachmentEffect, request p
72 return72 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) {
131131 tests := []struct {
132132 name string
133133 offer *protocol.ExactSessionTarget
134+ purging bool
134135 switched bool
135136 }{
136137 {name: "no pending offer switches", switched: true},
137138 {name: "matching offer switches", offer: &requestTarget, switched: true},
138139 {name: "another pending offer refuses", offer: &otherOffer},
140+ {name: "a running purge refuses", purging: true},
139141 }
140142 for _, tt := range tests {
141143 t.Run(tt.name, func(t *testing.T) {
@@ -155,6 +157,9 @@ func TestSamePeerSwitchClientInitiated(t *testing.T) {
155157 if tt.offer != nil {
156158 ac.offerSamePeerTarget(*tt.offer)
157159 }
160+ d.mu.Lock()
161+ d.purgeAdmissionClosing = tt.purging
162+ d.mu.Unlock()
158163
159164 token := source.captureAttachmentCapability(ac, ac.transport())
160165 effect, admitted := ac.beginAttachmentEffect(token)
@@ -162,6 +167,10 @@ func TestSamePeerSwitchClientInitiated(t *testing.T) {
162167 defer effect.End()
163168 d.switchSamePeerForAttachment(effect, protocol.SamePeerSwitchRequest{RequestID: 1, Target: requestTarget})
164169
170+ d.mu.Lock()
171+ require.Zero(t, d.purgeAdmissionActive, "the switch releases its purge admission")
172+ d.purgeAdmissionClosing = false
173+ d.mu.Unlock()
165174 if tt.switched {
166175 require.Same(t, target, ac.currentAttachmentSession())
167176 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 string132 name string
133 offer *protocol.ExactSessionTarget133 offer *protocol.ExactSessionTarget
134+ purging bool
134 switched bool135 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)

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