detached 17667bd

fix(remote): fence remote picker transitions

commit
17667bd84b739bb6b28679c94f2a6b705543412b
author
brice · · 2026-08-07 11:14 UTC
parent
76c6a00
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +36 -6
M internal/usecase/daemon/coderabbit_regression_test.go +1 -1
@@ -270,7 +270,7 @@ func TestShutdownWithoutDeadlineBoundsUncooperativeRemoteConstruction(t *testing
270270 go func() { result <- d.shutdownAll(ports.ReasonServerShutdown) }()
271271 timer := awaitTestValue(t, clock.timers, "remote construction shutdown timer")
272272 require.Equal(t, remoteConstructionShutdownGrace, timer.duration)
273- timer.ch <- time.Now()
273+ timer.ch <- time.Time{}
274274 require.True(t, awaitTestValue(t, result, "shutdown blocked on uncooperative remote construction"))
275275 }
276276
@@ -270,7 +270,7 @@ func TestShutdownWithoutDeadlineBoundsUncooperativeRemoteConstruction(t *testing
270 go func() { result <- d.shutdownAll(ports.ReasonServerShutdown) }()270 go func() { result <- d.shutdownAll(ports.ReasonServerShutdown) }()
271 timer := awaitTestValue(t, clock.timers, "remote construction shutdown timer")271 timer := awaitTestValue(t, clock.timers, "remote construction shutdown timer")
272 require.Equal(t, remoteConstructionShutdownGrace, timer.duration)272 require.Equal(t, remoteConstructionShutdownGrace, timer.duration)
273- timer.ch <- time.Now()273+ timer.ch <- time.Time{}
274 require.True(t, awaitTestValue(t, result, "shutdown blocked on uncooperative remote construction"))274 require.True(t, awaitTestValue(t, result, "shutdown blocked on uncooperative remote construction"))
275 }275 }
276 276
M internal/usecase/daemon/daemon.go +5 -0
@@ -841,6 +841,7 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
841841 // observes d.closing and retires its unpublished candidate independently.
842842 var constructionTimer ports.Timer
843843 var constructionTimeout <-chan time.Time
844+ graceExpired := false
844845 if deadline == nil && len(constructions) != 0 {
845846 clock := d.clock
846847 if clock == nil {
@@ -858,10 +859,14 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
858859 continue
859860 }
860861 if deadline == nil {
862+ if graceExpired {
863+ continue
864+ }
861865 select {
862866 case <-construction.done:
863867 case <-constructionTimeout:
864868 checkpointIncomplete = true
869+ graceExpired = true
865870 }
866871 continue
867872 }
@@ -841,6 +841,7 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
841 // observes d.closing and retires its unpublished candidate independently.841 // observes d.closing and retires its unpublished candidate independently.
842 var constructionTimer ports.Timer842 var constructionTimer ports.Timer
843 var constructionTimeout <-chan time.Time843 var constructionTimeout <-chan time.Time
844+ graceExpired := false
844 if deadline == nil && len(constructions) != 0 {845 if deadline == nil && len(constructions) != 0 {
845 clock := d.clock846 clock := d.clock
846 if clock == nil {847 if clock == nil {
@@ -858,10 +859,14 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
858 continue859 continue
859 }860 }
860 if deadline == nil {861 if deadline == nil {
862+ if graceExpired {
863+ continue
864+ }
861 select {865 select {
862 case <-construction.done:866 case <-construction.done:
863 case <-constructionTimeout:867 case <-constructionTimeout:
864 checkpointIncomplete = true868 checkpointIncomplete = true
869+ graceExpired = true
865 }870 }
866 continue871 continue
867 }872 }
M internal/usecase/daemon/picker.go +5 -2
@@ -423,7 +423,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
423423 if len(effects) != 0 && effects[0] != nil {
424424 effect = effects[0]
425425 } else {
426- token := sess.attachmentToken(ac, ac.transport())
426+ token := attachmentOwnerToken(ac.currentAttachmentOwner(), ac, ac.transport())
427427 var admitted bool
428428 effect, admitted = ac.beginAttachmentEffect(token)
429429 if !admitted {
@@ -946,9 +946,12 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
946946 }
947947 return nil
948948 }
949- if token.localSession() == nil || token.effect == nil {
949+ if token.localSession() == nil {
950950 return nil
951951 }
952+ if token.effect == nil {
953+ return errAttachmentTransition
954+ }
952955 if target.RemoteTarget != nil {
953956 return d.startRemotePickerSelection(token, target, guard, action)
954957 }
@@ -423,7 +423,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
423 if len(effects) != 0 && effects[0] != nil {423 if len(effects) != 0 && effects[0] != nil {
424 effect = effects[0]424 effect = effects[0]
425 } else {425 } else {
426- token := sess.attachmentToken(ac, ac.transport())426+ token := attachmentOwnerToken(ac.currentAttachmentOwner(), ac, ac.transport())
427 var admitted bool427 var admitted bool
428 effect, admitted = ac.beginAttachmentEffect(token)428 effect, admitted = ac.beginAttachmentEffect(token)
429 if !admitted {429 if !admitted {
@@ -946,9 +946,12 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
946 }946 }
947 return nil947 return nil
948 }948 }
949- if token.localSession() == nil || token.effect == nil {949+ if token.localSession() == nil {
950 return nil950 return nil
951 }951 }
952+ if token.effect == nil {
953+ return errAttachmentTransition
954+ }
952 if target.RemoteTarget != nil {955 if target.RemoteTarget != nil {
953 return d.startRemotePickerSelection(token, target, guard, action)956 return d.startRemotePickerSelection(token, target, guard, action)
954 }957 }
M internal/usecase/daemon/remote_attachment_transition_back_test.go +13 -0
@@ -39,6 +39,19 @@ func TestBackSessionForAttachmentReturnsFromRemoteViewToPreviousLocal(t *testing
3939 require.NotNil(t, current.lease, "returning to a local owner restores its coordinator lease")
4040 }
4141
42+func TestBackSessionForAttachmentReturnsToPreviousRemoteView(t *testing.T) {
43+ d, local, ac, view, remoteToken := remoteBackFixture(t)
44+
45+ require.NoError(t, d.backSessionForAttachment(remoteToken))
46+ localToken := local.attachmentToken(ac, ac.transport())
47+ require.NoError(t, d.backSessionForAttachment(localToken))
48+
49+ require.Same(t, view, ac.currentAttachmentOwner())
50+ require.True(t, view.attachmentRegistered(ac))
51+ require.False(t, local.attachmentRegistered(ac))
52+ require.Same(t, local, ac.previousOwner.Get())
53+}
54+
4255 func TestRemoteRecentAppearsInLocalStatusMRUAfterReturn(t *testing.T) {
4356 d, local, ac, _, token := remoteBackFixture(t)
4457
@@ -39,6 +39,19 @@ func TestBackSessionForAttachmentReturnsFromRemoteViewToPreviousLocal(t *testing
39 require.NotNil(t, current.lease, "returning to a local owner restores its coordinator lease")39 require.NotNil(t, current.lease, "returning to a local owner restores its coordinator lease")
40 }40 }
41 41
42+func TestBackSessionForAttachmentReturnsToPreviousRemoteView(t *testing.T) {
43+ d, local, ac, view, remoteToken := remoteBackFixture(t)
44+
45+ require.NoError(t, d.backSessionForAttachment(remoteToken))
46+ localToken := local.attachmentToken(ac, ac.transport())
47+ require.NoError(t, d.backSessionForAttachment(localToken))
48+
49+ require.Same(t, view, ac.currentAttachmentOwner())
50+ require.True(t, view.attachmentRegistered(ac))
51+ require.False(t, local.attachmentRegistered(ac))
52+ require.Same(t, local, ac.previousOwner.Get())
53+}
54+
42 func TestRemoteRecentAppearsInLocalStatusMRUAfterReturn(t *testing.T) {55 func TestRemoteRecentAppearsInLocalStatusMRUAfterReturn(t *testing.T) {
43 d, local, ac, _, token := remoteBackFixture(t)56 d, local, ac, _, token := remoteBackFixture(t)
44 57
M internal/usecase/daemon/remote_picker_selection.go +1 -1
@@ -79,7 +79,7 @@ func (d *Daemon) startRemotePickerSelection(token attachmentConnectionToken, tar
7979 token.effect.bindActionEnd(d, action)
8080 }
8181 d.invalidateAttachedOwner(token.ac, true, "remote_picker_selection.go")
82- go d.completeRemotePickerSelection(selection)
82+ d.sessWg.Go(func() { d.completeRemotePickerSelection(selection) })
8383 return nil
8484 }
8585
@@ -79,7 +79,7 @@ func (d *Daemon) startRemotePickerSelection(token attachmentConnectionToken, tar
79 token.effect.bindActionEnd(d, action)79 token.effect.bindActionEnd(d, action)
80 }80 }
81 d.invalidateAttachedOwner(token.ac, true, "remote_picker_selection.go")81 d.invalidateAttachedOwner(token.ac, true, "remote_picker_selection.go")
82- go d.completeRemotePickerSelection(selection)82+ d.sessWg.Go(func() { d.completeRemotePickerSelection(selection) })
83 return nil83 return nil
84 }84 }
85 85
M internal/usecase/daemon/session_back.go +11 -2
@@ -45,10 +45,19 @@ func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken, after
4545 return nil
4646 }
4747 previous := token.ac.previousOwner.Get()
48+ if view, remote := normalizeAttachmentOwner(previous).(*remoteView); remote {
49+ published, err := d.transitionToRemoteView(token, view)
50+ if err != nil {
51+ if !token.current() {
52+ return nil
53+ }
54+ return err
55+ }
56+ d.firstPaintForTransition(published)
57+ return nil
58+ }
4859 target := localSession(previous)
4960 if target == nil {
50- // A remote predecessor remains stable history for the remote transition
51- // path; the local-only back path must not discard it.
5261 return nil
5362 }
5463 if target == current || target.core() == nil || d.sessionByID(target.core().id) != target {
@@ -45,10 +45,19 @@ func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken, after
45 return nil45 return nil
46 }46 }
47 previous := token.ac.previousOwner.Get()47 previous := token.ac.previousOwner.Get()
48+ if view, remote := normalizeAttachmentOwner(previous).(*remoteView); remote {
49+ published, err := d.transitionToRemoteView(token, view)
50+ if err != nil {
51+ if !token.current() {
52+ return nil
53+ }
54+ return err
55+ }
56+ d.firstPaintForTransition(published)
57+ return nil
58+ }
48 target := localSession(previous)59 target := localSession(previous)
49 if target == nil {60 if target == nil {
50- // A remote predecessor remains stable history for the remote transition
51- // path; the local-only back path must not discard it.
52 return nil61 return nil
53 }62 }
54 if target == current || target.core() == nil || d.sessionByID(target.core().id) != target {63 if target == current || target.core() == nil || d.sessionByID(target.core().id) != target {

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 2

Tags 13

↑↓ moveenter openesc close