detached 28680b3

feat(daemon): retain detached remote views briefly

commit
28680b34326562a3f553f611f8678c0e67f2c5fb
author
brice · · 2026-08-06 19:25 UTC
parent
d3ad7ec
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +265 -0
M internal/usecase/daemon/attachment_owner.go +4 -0
@@ -68,6 +68,10 @@ type remoteView struct {
6868 linkGeneration uint64
6969 output remoteOutputState
7070 resetRequested bool
71+ // warm and warmGeneration fence the five-minute retention timer for an
72+ // unattached view. The timer is always stopped after view.mu is released.
73+ warm *remoteViewWarm
74+ warmGeneration uint64
7175 }
7276
7377 func (*remoteView) attachmentOwner() {}
@@ -68,6 +68,10 @@ type remoteView struct {
68 linkGeneration uint6468 linkGeneration uint64
69 output remoteOutputState69 output remoteOutputState
70 resetRequested bool70 resetRequested bool
71+ // warm and warmGeneration fence the five-minute retention timer for an
72+ // unattached view. The timer is always stopped after view.mu is released.
73+ warm *remoteViewWarm
74+ warmGeneration uint64
71 }75 }
72 76
73 func (*remoteView) attachmentOwner() {}77 func (*remoteView) attachmentOwner() {}
M internal/usecase/daemon/client_transport_errors.go +2 -0
@@ -88,6 +88,7 @@ func (d *Daemon) finishRemoteClientGone(view *remoteView, ac *attachedClient, tr
8888 d.unregisterPreview(ac)
8989 if !explicit && d.parkAttachmentOwner(view, ac) {
9090 _ = ac.closeCapturedTransport(transport)
91+ d.parkRemoteViewWarm(view)
9192 return true
9293 }
9394 d.clearParkingInFlight(ac.resumeToken, ac)
@@ -97,6 +98,7 @@ func (d *Daemon) finishRemoteClientGone(view *remoteView, ac *attachedClient, tr
9798 d.boundedSend(ac, frameDetached(ports.ReasonDetach))
9899 }
99100 _ = ac.closeCapturedTransport(transport)
101+ d.parkRemoteViewWarm(view)
100102 return true
101103 }
102104
@@ -88,6 +88,7 @@ func (d *Daemon) finishRemoteClientGone(view *remoteView, ac *attachedClient, tr
88 d.unregisterPreview(ac)88 d.unregisterPreview(ac)
89 if !explicit && d.parkAttachmentOwner(view, ac) {89 if !explicit && d.parkAttachmentOwner(view, ac) {
90 _ = ac.closeCapturedTransport(transport)90 _ = ac.closeCapturedTransport(transport)
91+ d.parkRemoteViewWarm(view)
91 return true92 return true
92 }93 }
93 d.clearParkingInFlight(ac.resumeToken, ac)94 d.clearParkingInFlight(ac.resumeToken, ac)
@@ -97,6 +98,7 @@ func (d *Daemon) finishRemoteClientGone(view *remoteView, ac *attachedClient, tr
97 d.boundedSend(ac, frameDetached(ports.ReasonDetach))98 d.boundedSend(ac, frameDetached(ports.ReasonDetach))
98 }99 }
99 _ = ac.closeCapturedTransport(transport)100 _ = ac.closeCapturedTransport(transport)
101+ d.parkRemoteViewWarm(view)
100 return true102 return true
101 }103 }
102 104
M internal/usecase/daemon/daemon.go +7 -0
@@ -832,15 +832,22 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
832832 for _, cancel := range constructions {
833833 cancel()
834834 }
835+ remoteWarms := make([]*remoteViewWarm, 0, len(remoteViews))
835836 remoteLinks := make([]*remoteLink, 0, len(remoteViews))
836837 for _, view := range remoteViews {
837838 for _, ac := range view.close() {
838839 d.retireShutdownRemoteAttachment(view, ac, reason)
839840 }
841+ if warm := d.stopRemoteViewWarm(view); warm != nil {
842+ remoteWarms = append(remoteWarms, warm)
843+ }
840844 if link := d.stopRemoteViewLink(view); link != nil {
841845 remoteLinks = append(remoteLinks, link)
842846 }
843847 }
848+ for _, warm := range remoteWarms {
849+ warm.stop()
850+ }
844851 for _, link := range remoteLinks {
845852 <-link.done
846853 }
@@ -832,15 +832,22 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
832 for _, cancel := range constructions {832 for _, cancel := range constructions {
833 cancel()833 cancel()
834 }834 }
835+ remoteWarms := make([]*remoteViewWarm, 0, len(remoteViews))
835 remoteLinks := make([]*remoteLink, 0, len(remoteViews))836 remoteLinks := make([]*remoteLink, 0, len(remoteViews))
836 for _, view := range remoteViews {837 for _, view := range remoteViews {
837 for _, ac := range view.close() {838 for _, ac := range view.close() {
838 d.retireShutdownRemoteAttachment(view, ac, reason)839 d.retireShutdownRemoteAttachment(view, ac, reason)
839 }840 }
841+ if warm := d.stopRemoteViewWarm(view); warm != nil {
842+ remoteWarms = append(remoteWarms, warm)
843+ }
840 if link := d.stopRemoteViewLink(view); link != nil {844 if link := d.stopRemoteViewLink(view); link != nil {
841 remoteLinks = append(remoteLinks, link)845 remoteLinks = append(remoteLinks, link)
842 }846 }
843 }847 }
848+ for _, warm := range remoteWarms {
849+ warm.stop()
850+ }
844 for _, link := range remoteLinks {851 for _, link := range remoteLinks {
845 <-link.done852 <-link.done
846 }853 }
M internal/usecase/daemon/remote_attachment_transition.go +2 -0
@@ -61,6 +61,7 @@ func (d *Daemon) transitionToRemoteView(token attachmentConnectionToken, target
6161 source.mu.Unlock()
6262 d.notices.routingMu.Unlock()
6363 d.mu.Unlock()
64+ d.activateRemoteView(target)
6465
6566 // The old coordinator is no longer a lease owner for this attachment. Its
6667 // cleanup runs after publication and outside architecture locks.
@@ -162,5 +163,6 @@ func (d *Daemon) transitionFromRemoteView(token attachmentConnectionToken, sourc
162163 // Record the reverse toggle while the attachment gate is still frozen, so a
163164 // concurrent handoff cannot observe the new local owner without its history.
164165 token.ac.recordPreviousOwner(source)
166+ d.parkRemoteViewWarm(source)
165167 return published, nil
166168 }
@@ -61,6 +61,7 @@ func (d *Daemon) transitionToRemoteView(token attachmentConnectionToken, target
61 source.mu.Unlock()61 source.mu.Unlock()
62 d.notices.routingMu.Unlock()62 d.notices.routingMu.Unlock()
63 d.mu.Unlock()63 d.mu.Unlock()
64+ d.activateRemoteView(target)
64 65
65 // The old coordinator is no longer a lease owner for this attachment. Its66 // The old coordinator is no longer a lease owner for this attachment. Its
66 // cleanup runs after publication and outside architecture locks.67 // cleanup runs after publication and outside architecture locks.
@@ -162,5 +163,6 @@ func (d *Daemon) transitionFromRemoteView(token attachmentConnectionToken, sourc
162 // Record the reverse toggle while the attachment gate is still frozen, so a163 // Record the reverse toggle while the attachment gate is still frozen, so a
163 // concurrent handoff cannot observe the new local owner without its history.164 // concurrent handoff cannot observe the new local owner without its history.
164 token.ac.recordPreviousOwner(source)165 token.ac.recordPreviousOwner(source)
166+ d.parkRemoteViewWarm(source)
165 return published, nil167 return published, nil
166 }168 }
M internal/usecase/daemon/remote_resume.go +1 -0
@@ -150,6 +150,7 @@ func (d *Daemon) resumeRemoteAttachment(view *remoteView, ac *attachedClient, tr
150150 ac.publishFrozenAttachmentCapability(published)
151151 view.mu.Unlock()
152152 d.mu.Unlock()
153+ d.activateRemoteView(view)
153154 return published, nil
154155 }
155156
@@ -150,6 +150,7 @@ func (d *Daemon) resumeRemoteAttachment(view *remoteView, ac *attachedClient, tr
150 ac.publishFrozenAttachmentCapability(published)150 ac.publishFrozenAttachmentCapability(published)
151 view.mu.Unlock()151 view.mu.Unlock()
152 d.mu.Unlock()152 d.mu.Unlock()
153+ d.activateRemoteView(view)
153 return published, nil154 return published, nil
154 }155 }
155 156
A internal/usecase/daemon/remote_warm.go +162 -0
@@ -0,0 +1,162 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/ports"
8+)
9+
10+const remoteViewWarmTTL = 5 * time.Minute
11+
12+// remoteViewWarm is a single generation-fenced retention timer. It owns no
13+// view state: callers snapshot it while holding view.mu, then stop it after
14+// every architecture lock is released.
15+type remoteViewWarm struct {
16+ generation uint64
17+ timer ports.Timer
18+ done chan struct{}
19+ doneOnce sync.Once
20+}
21+
22+func (warm *remoteViewWarm) stop() {
23+ if warm == nil {
24+ return
25+ }
26+ if warm.timer != nil {
27+ warm.timer.Stop()
28+ }
29+ warm.doneOnce.Do(func() { close(warm.done) })
30+}
31+
32+// parkRemoteViewWarm retains an exact registered remote view for a bounded
33+// time after its final local attachment leaves. It never makes a remote view a
34+// daemon-liveness root: daemon shutdown revokes this timer with the registry.
35+func (d *Daemon) parkRemoteViewWarm(view *remoteView) {
36+ if d == nil || view == nil {
37+ return
38+ }
39+ warm := &remoteViewWarm{
40+ timer: d.clock.NewTimer(remoteViewWarmTTL),
41+ done: make(chan struct{}),
42+ }
43+
44+ d.mu.Lock()
45+ if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(view) {
46+ d.mu.Unlock()
47+ warm.stop()
48+ return
49+ }
50+ view.mu.Lock()
51+ if view.closed || len(view.attachments) != 0 {
52+ view.mu.Unlock()
53+ d.mu.Unlock()
54+ warm.stop()
55+ return
56+ }
57+ view.warmGeneration++
58+ warm.generation = view.warmGeneration
59+ old := view.warm
60+ view.warm = warm
61+ view.mu.Unlock()
62+ d.mu.Unlock()
63+
64+ old.stop()
65+ d.watchRemoteViewWarm(view, warm)
66+}
67+
68+// activateRemoteView revokes a previously scheduled expiry when an attachment
69+// becomes visible again. The exact-pointer/generation checks in the timer
70+// callback make a racing stale expiry harmless.
71+func (d *Daemon) activateRemoteView(view *remoteView) {
72+ if d == nil || view == nil {
73+ return
74+ }
75+ d.mu.Lock()
76+ if !d.attachmentOwnerRegisteredByDaemonLocked(view) {
77+ d.mu.Unlock()
78+ return
79+ }
80+ view.mu.Lock()
81+ warm := view.warm
82+ if warm != nil {
83+ view.warm = nil
84+ view.warmGeneration++
85+ }
86+ view.mu.Unlock()
87+ d.mu.Unlock()
88+ warm.stop()
89+}
90+
91+func (d *Daemon) watchRemoteViewWarm(view *remoteView, warm *remoteViewWarm) {
92+ if d == nil || view == nil || warm == nil || warm.timer == nil {
93+ return
94+ }
95+ go func() {
96+ select {
97+ case <-warm.timer.C():
98+ d.expireRemoteViewWarm(view, warm)
99+ case <-warm.done:
100+ }
101+ }()
102+}
103+
104+// expireRemoteViewWarm removes only the exact, still-detached registered view.
105+// It marks the candidate closed under registry/view locks, then interrupts and
106+// joins its exact remote link without holding either lock.
107+func (d *Daemon) expireRemoteViewWarm(view *remoteView, warm *remoteViewWarm) {
108+ if d == nil || view == nil || warm == nil {
109+ return
110+ }
111+
112+ d.mu.Lock()
113+ if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(view) {
114+ d.mu.Unlock()
115+ return
116+ }
117+ view.mu.Lock()
118+ current := !view.closed && view.warm == warm && view.warmGeneration == warm.generation && len(view.attachments) == 0
119+ if !current {
120+ view.mu.Unlock()
121+ d.mu.Unlock()
122+ return
123+ }
124+ view.warm = nil
125+ view.warmGeneration++
126+ view.closed = true
127+ link := view.link
128+ view.link = nil
129+ view.linkGeneration++
130+ if link != nil {
131+ link.active = false
132+ }
133+ _ = d.unregisterRemoteViewLocked(view)
134+ view.mu.Unlock()
135+ d.mu.Unlock()
136+
137+ warm.stop()
138+ if link == nil {
139+ return
140+ }
141+ link.cancel()
142+ if link.transport != nil {
143+ _ = link.transport.Close()
144+ }
145+ <-link.done
146+}
147+
148+// stopRemoteViewWarm clears and returns only the current timer. Its caller is
149+// responsible for stopping it after all architecture locks are released.
150+func (d *Daemon) stopRemoteViewWarm(view *remoteView) *remoteViewWarm {
151+ if view == nil {
152+ return nil
153+ }
154+ view.mu.Lock()
155+ warm := view.warm
156+ if warm != nil {
157+ view.warm = nil
158+ view.warmGeneration++
159+ }
160+ view.mu.Unlock()
161+ return warm
162+}
@@ -0,0 +1,162 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/ports"
8+)
9+
10+const remoteViewWarmTTL = 5 * time.Minute
11+
12+// remoteViewWarm is a single generation-fenced retention timer. It owns no
13+// view state: callers snapshot it while holding view.mu, then stop it after
14+// every architecture lock is released.
15+type remoteViewWarm struct {
16+ generation uint64
17+ timer ports.Timer
18+ done chan struct{}
19+ doneOnce sync.Once
20+}
21+
22+func (warm *remoteViewWarm) stop() {
23+ if warm == nil {
24+ return
25+ }
26+ if warm.timer != nil {
27+ warm.timer.Stop()
28+ }
29+ warm.doneOnce.Do(func() { close(warm.done) })
30+}
31+
32+// parkRemoteViewWarm retains an exact registered remote view for a bounded
33+// time after its final local attachment leaves. It never makes a remote view a
34+// daemon-liveness root: daemon shutdown revokes this timer with the registry.
35+func (d *Daemon) parkRemoteViewWarm(view *remoteView) {
36+ if d == nil || view == nil {
37+ return
38+ }
39+ warm := &remoteViewWarm{
40+ timer: d.clock.NewTimer(remoteViewWarmTTL),
41+ done: make(chan struct{}),
42+ }
43+
44+ d.mu.Lock()
45+ if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(view) {
46+ d.mu.Unlock()
47+ warm.stop()
48+ return
49+ }
50+ view.mu.Lock()
51+ if view.closed || len(view.attachments) != 0 {
52+ view.mu.Unlock()
53+ d.mu.Unlock()
54+ warm.stop()
55+ return
56+ }
57+ view.warmGeneration++
58+ warm.generation = view.warmGeneration
59+ old := view.warm
60+ view.warm = warm
61+ view.mu.Unlock()
62+ d.mu.Unlock()
63+
64+ old.stop()
65+ d.watchRemoteViewWarm(view, warm)
66+}
67+
68+// activateRemoteView revokes a previously scheduled expiry when an attachment
69+// becomes visible again. The exact-pointer/generation checks in the timer
70+// callback make a racing stale expiry harmless.
71+func (d *Daemon) activateRemoteView(view *remoteView) {
72+ if d == nil || view == nil {
73+ return
74+ }
75+ d.mu.Lock()
76+ if !d.attachmentOwnerRegisteredByDaemonLocked(view) {
77+ d.mu.Unlock()
78+ return
79+ }
80+ view.mu.Lock()
81+ warm := view.warm
82+ if warm != nil {
83+ view.warm = nil
84+ view.warmGeneration++
85+ }
86+ view.mu.Unlock()
87+ d.mu.Unlock()
88+ warm.stop()
89+}
90+
91+func (d *Daemon) watchRemoteViewWarm(view *remoteView, warm *remoteViewWarm) {
92+ if d == nil || view == nil || warm == nil || warm.timer == nil {
93+ return
94+ }
95+ go func() {
96+ select {
97+ case <-warm.timer.C():
98+ d.expireRemoteViewWarm(view, warm)
99+ case <-warm.done:
100+ }
101+ }()
102+}
103+
104+// expireRemoteViewWarm removes only the exact, still-detached registered view.
105+// It marks the candidate closed under registry/view locks, then interrupts and
106+// joins its exact remote link without holding either lock.
107+func (d *Daemon) expireRemoteViewWarm(view *remoteView, warm *remoteViewWarm) {
108+ if d == nil || view == nil || warm == nil {
109+ return
110+ }
111+
112+ d.mu.Lock()
113+ if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(view) {
114+ d.mu.Unlock()
115+ return
116+ }
117+ view.mu.Lock()
118+ current := !view.closed && view.warm == warm && view.warmGeneration == warm.generation && len(view.attachments) == 0
119+ if !current {
120+ view.mu.Unlock()
121+ d.mu.Unlock()
122+ return
123+ }
124+ view.warm = nil
125+ view.warmGeneration++
126+ view.closed = true
127+ link := view.link
128+ view.link = nil
129+ view.linkGeneration++
130+ if link != nil {
131+ link.active = false
132+ }
133+ _ = d.unregisterRemoteViewLocked(view)
134+ view.mu.Unlock()
135+ d.mu.Unlock()
136+
137+ warm.stop()
138+ if link == nil {
139+ return
140+ }
141+ link.cancel()
142+ if link.transport != nil {
143+ _ = link.transport.Close()
144+ }
145+ <-link.done
146+}
147+
148+// stopRemoteViewWarm clears and returns only the current timer. Its caller is
149+// responsible for stopping it after all architecture locks are released.
150+func (d *Daemon) stopRemoteViewWarm(view *remoteView) *remoteViewWarm {
151+ if view == nil {
152+ return nil
153+ }
154+ view.mu.Lock()
155+ warm := view.warm
156+ if warm != nil {
157+ view.warm = nil
158+ view.warmGeneration++
159+ }
160+ view.mu.Unlock()
161+ return warm
162+}
A internal/usecase/daemon/remote_warm_test.go +87 -0
@@ -0,0 +1,87 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+func TestRemoteViewWarmExpiryRemovesOnlyDetachedExactView(t *testing.T) {
13+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
14+ d := newTestDaemon(t, nil, clock)
15+ view := registerRemoteWarmTestView(t, d)
16+
17+ d.parkRemoteViewWarm(view)
18+ warm, timer := remoteWarmTimer(t, clock, view)
19+ require.Equal(t, remoteViewWarmTTL, timer.duration)
20+ timer.ch <- time.Now()
21+ awaitTestCompletion(t, warm.done, "remote warm expiry did not complete")
22+
23+ d.mu.Lock()
24+ require.Nil(t, d.remoteViewByKeyLocked(view.key))
25+ d.mu.Unlock()
26+ view.mu.Lock()
27+ require.True(t, view.closed)
28+ require.Nil(t, view.link)
29+ view.mu.Unlock()
30+}
31+
32+func TestRemoteViewWarmActivationFencesStaleExpiry(t *testing.T) {
33+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
34+ d := newTestDaemon(t, nil, clock)
35+ view := registerRemoteWarmTestView(t, d)
36+
37+ d.parkRemoteViewWarm(view)
38+ warm, timer := remoteWarmTimer(t, clock, view)
39+ attachment := &attachedClient{}
40+ view.mu.Lock()
41+ require.True(t, view.registerAttachmentLocked(attachment))
42+ view.mu.Unlock()
43+ d.activateRemoteView(view)
44+ awaitTestCompletion(t, warm.done, "activating remote view did not cancel warm expiry")
45+ // A timer event already queued by the clock belongs to the canceled
46+ // generation and cannot remove the newly active remote view.
47+ timer.ch <- time.Now()
48+
49+ d.mu.Lock()
50+ require.Same(t, view, d.remoteViewByKeyLocked(view.key))
51+ d.mu.Unlock()
52+ view.mu.Lock()
53+ require.False(t, view.closed)
54+ view.mu.Unlock()
55+}
56+
57+func TestShutdownAllCancelsRemoteViewWarmTimer(t *testing.T) {
58+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
59+ d := newTestDaemon(t, nil, clock)
60+ view := registerRemoteWarmTestView(t, d)
61+
62+ d.parkRemoteViewWarm(view)
63+ warm, _ := remoteWarmTimer(t, clock, view)
64+ d.shutdownAll(ports.ReasonServerShutdown)
65+ awaitTestCompletion(t, warm.done, "shutdown did not cancel remote warm timer")
66+}
67+
68+func registerRemoteWarmTestView(t *testing.T, d *Daemon) *remoteView {
69+ t.Helper()
70+ key, err := remoteViewKeyForTarget(remoteLinkTestTarget())
71+ require.NoError(t, err)
72+ view := &remoteView{key: key}
73+ d.mu.Lock()
74+ require.NoError(t, d.registerRemoteViewLocked(view))
75+ d.mu.Unlock()
76+ return view
77+}
78+
79+func remoteWarmTimer(t *testing.T, clock *signalClock, view *remoteView) (*remoteViewWarm, *signalTimer) {
80+ t.Helper()
81+ timer := awaitTestValue(t, clock.timers, "remote warm timer was not created")
82+ view.mu.Lock()
83+ warm := view.warm
84+ view.mu.Unlock()
85+ require.NotNil(t, warm)
86+ return warm, timer
87+}
@@ -0,0 +1,87 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+func TestRemoteViewWarmExpiryRemovesOnlyDetachedExactView(t *testing.T) {
13+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
14+ d := newTestDaemon(t, nil, clock)
15+ view := registerRemoteWarmTestView(t, d)
16+
17+ d.parkRemoteViewWarm(view)
18+ warm, timer := remoteWarmTimer(t, clock, view)
19+ require.Equal(t, remoteViewWarmTTL, timer.duration)
20+ timer.ch <- time.Now()
21+ awaitTestCompletion(t, warm.done, "remote warm expiry did not complete")
22+
23+ d.mu.Lock()
24+ require.Nil(t, d.remoteViewByKeyLocked(view.key))
25+ d.mu.Unlock()
26+ view.mu.Lock()
27+ require.True(t, view.closed)
28+ require.Nil(t, view.link)
29+ view.mu.Unlock()
30+}
31+
32+func TestRemoteViewWarmActivationFencesStaleExpiry(t *testing.T) {
33+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
34+ d := newTestDaemon(t, nil, clock)
35+ view := registerRemoteWarmTestView(t, d)
36+
37+ d.parkRemoteViewWarm(view)
38+ warm, timer := remoteWarmTimer(t, clock, view)
39+ attachment := &attachedClient{}
40+ view.mu.Lock()
41+ require.True(t, view.registerAttachmentLocked(attachment))
42+ view.mu.Unlock()
43+ d.activateRemoteView(view)
44+ awaitTestCompletion(t, warm.done, "activating remote view did not cancel warm expiry")
45+ // A timer event already queued by the clock belongs to the canceled
46+ // generation and cannot remove the newly active remote view.
47+ timer.ch <- time.Now()
48+
49+ d.mu.Lock()
50+ require.Same(t, view, d.remoteViewByKeyLocked(view.key))
51+ d.mu.Unlock()
52+ view.mu.Lock()
53+ require.False(t, view.closed)
54+ view.mu.Unlock()
55+}
56+
57+func TestShutdownAllCancelsRemoteViewWarmTimer(t *testing.T) {
58+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
59+ d := newTestDaemon(t, nil, clock)
60+ view := registerRemoteWarmTestView(t, d)
61+
62+ d.parkRemoteViewWarm(view)
63+ warm, _ := remoteWarmTimer(t, clock, view)
64+ d.shutdownAll(ports.ReasonServerShutdown)
65+ awaitTestCompletion(t, warm.done, "shutdown did not cancel remote warm timer")
66+}
67+
68+func registerRemoteWarmTestView(t *testing.T, d *Daemon) *remoteView {
69+ t.Helper()
70+ key, err := remoteViewKeyForTarget(remoteLinkTestTarget())
71+ require.NoError(t, err)
72+ view := &remoteView{key: key}
73+ d.mu.Lock()
74+ require.NoError(t, d.registerRemoteViewLocked(view))
75+ d.mu.Unlock()
76+ return view
77+}
78+
79+func remoteWarmTimer(t *testing.T, clock *signalClock, view *remoteView) (*remoteViewWarm, *signalTimer) {
80+ t.Helper()
81+ timer := awaitTestValue(t, clock.timers, "remote warm timer was not created")
82+ view.mu.Lock()
83+ warm := view.warm
84+ view.mu.Unlock()
85+ require.NotNil(t, warm)
86+ return warm, timer
87+}

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