detached 310aed6

fix(remote): harden navigation and attachment concurrency

commit
310aed63ff6f66e25d74bb66be1f851594ce8c2c
author
brice · · 2026-08-08 17:33 UTC
parent
d63c59a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +74 -37
M internal/ports/navigation_test.go +2 -2
@@ -17,8 +17,8 @@ func TestNavigationActionWireTable(t *testing.T) {
1717 }{
1818 {name: "open home", action: NavigationOpenHomePicker, payload: []byte{1}, valid: true},
1919 {name: "back", action: NavigationBack, payload: []byte{2}, valid: true},
20- {name: "zero", action: 0, valid: false},
21- {name: "unknown", action: 3, valid: false},
20+ {name: "zero", action: 0, payload: []byte{0}, valid: false},
21+ {name: "unknown", action: 3, payload: []byte{3}, valid: false},
2222 }
2323
2424 for _, tt := range tests {
@@ -17,8 +17,8 @@ func TestNavigationActionWireTable(t *testing.T) {
17 }{17 }{
18 {name: "open home", action: NavigationOpenHomePicker, payload: []byte{1}, valid: true},18 {name: "open home", action: NavigationOpenHomePicker, payload: []byte{1}, valid: true},
19 {name: "back", action: NavigationBack, payload: []byte{2}, valid: true},19 {name: "back", action: NavigationBack, payload: []byte{2}, valid: true},
20- {name: "zero", action: 0, valid: false},20+ {name: "zero", action: 0, payload: []byte{0}, valid: false},
21- {name: "unknown", action: 3, valid: false},21+ {name: "unknown", action: 3, payload: []byte{3}, valid: false},
22 }22 }
23 23
24 for _, tt := range tests {24 for _, tt := range tests {
M internal/ports/remote_target_wire_test.go +14 -10
@@ -2,6 +2,7 @@ package ports
22
33 import (
44 "bytes"
5+ "errors"
56 "testing"
67
78 "github.com/bnema/vev/internal/domain"
@@ -82,11 +83,12 @@ func TestRemoteTargetWireRoundTripPreservesExactSelector(t *testing.T) {
8283 func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
8384 target := testRemoteTarget(false)
8485 tests := []struct {
85- name string
86- validate func() error
87- marshalBad func() []byte
88- marshalGood func() []byte
89- unmarshal func([]byte) error
86+ name string
87+ validate func() error
88+ marshalBad func() []byte
89+ marshalGood func() []byte
90+ unmarshal func([]byte) error
91+ policyOffset int
9092 }{
9193 {
9294 name: "hello",
@@ -111,7 +113,8 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
111113 RemoteTarget: target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned,
112114 })
113115 },
114- unmarshal: func(payload []byte) error { _, err := UnmarshalHello(payload); return err },
116+ unmarshal: func(payload []byte) error { _, err := UnmarshalHello(payload); return err },
117+ policyOffset: 3,
115118 },
116119 {
117120 name: "attach target",
@@ -133,7 +136,8 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
133136 RemoteTarget: target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned,
134137 })
135138 },
136- unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err },
139+ unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err },
140+ policyOffset: 1,
137141 },
138142 }
139143
@@ -158,7 +162,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
158162 if err := tt.unmarshal(trailing); err == nil {
159163 t.Fatal("trailing garbage unexpectedly decoded")
160164 }
161- payload[len(payload)-3] = byte(EnvironmentPolicyClientOwned)
165+ payload[len(payload)-tt.policyOffset] = byte(EnvironmentPolicyClientOwned)
162166 if err := tt.unmarshal(payload); err == nil {
163167 t.Fatal("unmarshal accepted client-owned policy for remote target")
164168 }
@@ -166,7 +170,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
166170 }
167171 }
168172
169-func TestAttachTargetRejectsTokenlessResume(t *testing.T) {
173+func TestAttachTargetRejectsResumeIntent(t *testing.T) {
170174 tests := []AttachTarget{
171175 {Endpoint: "host", Session: "work", Intent: IntentResume},
172176 {
@@ -178,7 +182,7 @@ func TestAttachTargetRejectsTokenlessResume(t *testing.T) {
178182 },
179183 }
180184 for _, target := range tests {
181- if err := ValidateAttachTarget(target); err != ErrInvalidAttachTarget {
185+ if err := ValidateAttachTarget(target); !errors.Is(err, ErrInvalidAttachTarget) {
182186 t.Fatalf("ValidateAttachTarget(%#v) error = %v, want %v", target, err, ErrInvalidAttachTarget)
183187 }
184188 if got := MarshalAttachTarget(target); got != nil {
@@ -2,6 +2,7 @@ package ports
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "errors"
5 "testing"6 "testing"
6 7
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
@@ -82,11 +83,12 @@ func TestRemoteTargetWireRoundTripPreservesExactSelector(t *testing.T) {
82 func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {83 func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
83 target := testRemoteTarget(false)84 target := testRemoteTarget(false)
84 tests := []struct {85 tests := []struct {
85- name string86+ name string
86- validate func() error87+ validate func() error
87- marshalBad func() []byte88+ marshalBad func() []byte
88- marshalGood func() []byte89+ marshalGood func() []byte
89- unmarshal func([]byte) error90+ unmarshal func([]byte) error
91+ policyOffset int
90 }{92 }{
91 {93 {
92 name: "hello",94 name: "hello",
@@ -111,7 +113,8 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
111 RemoteTarget: target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned,113 RemoteTarget: target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned,
112 })114 })
113 },115 },
114- unmarshal: func(payload []byte) error { _, err := UnmarshalHello(payload); return err },116+ unmarshal: func(payload []byte) error { _, err := UnmarshalHello(payload); return err },
117+ policyOffset: 3,
115 },118 },
116 {119 {
117 name: "attach target",120 name: "attach target",
@@ -133,7 +136,8 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
133 RemoteTarget: target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned,136 RemoteTarget: target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned,
134 })137 })
135 },138 },
136- unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err },139+ unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err },
140+ policyOffset: 1,
137 },141 },
138 }142 }
139 143
@@ -158,7 +162,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
158 if err := tt.unmarshal(trailing); err == nil {162 if err := tt.unmarshal(trailing); err == nil {
159 t.Fatal("trailing garbage unexpectedly decoded")163 t.Fatal("trailing garbage unexpectedly decoded")
160 }164 }
161- payload[len(payload)-3] = byte(EnvironmentPolicyClientOwned)165+ payload[len(payload)-tt.policyOffset] = byte(EnvironmentPolicyClientOwned)
162 if err := tt.unmarshal(payload); err == nil {166 if err := tt.unmarshal(payload); err == nil {
163 t.Fatal("unmarshal accepted client-owned policy for remote target")167 t.Fatal("unmarshal accepted client-owned policy for remote target")
164 }168 }
@@ -166,7 +170,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
166 }170 }
167 }171 }
168 172
169-func TestAttachTargetRejectsTokenlessResume(t *testing.T) {173+func TestAttachTargetRejectsResumeIntent(t *testing.T) {
170 tests := []AttachTarget{174 tests := []AttachTarget{
171 {Endpoint: "host", Session: "work", Intent: IntentResume},175 {Endpoint: "host", Session: "work", Intent: IntentResume},
172 {176 {
@@ -178,7 +182,7 @@ func TestAttachTargetRejectsTokenlessResume(t *testing.T) {
178 },182 },
179 }183 }
180 for _, target := range tests {184 for _, target := range tests {
181- if err := ValidateAttachTarget(target); err != ErrInvalidAttachTarget {185+ if err := ValidateAttachTarget(target); !errors.Is(err, ErrInvalidAttachTarget) {
182 t.Fatalf("ValidateAttachTarget(%#v) error = %v, want %v", target, err, ErrInvalidAttachTarget)186 t.Fatalf("ValidateAttachTarget(%#v) error = %v, want %v", target, err, ErrInvalidAttachTarget)
183 }187 }
184 if got := MarshalAttachTarget(target); got != nil {188 if got := MarshalAttachTarget(target); got != nil {
M internal/ports/wire.go +1 -1
@@ -708,7 +708,7 @@ func ValidateHello(h Hello) error {
708708 return ErrInvalidHello
709709 }
710710 if err := validateHelloNavigation(h); err != nil {
711- return fmt.Errorf("%w: navigation", ErrInvalidHello)
711+ return fmt.Errorf("%w: navigation: %w", ErrInvalidHello, err)
712712 }
713713 if len(h.Name) > math.MaxUint16 || len(h.TermEnv) > math.MaxUint16 || len(h.Cwd) > math.MaxUint16 || len(h.Env) > math.MaxUint32 {
714714 return ErrInvalidHello
@@ -708,7 +708,7 @@ func ValidateHello(h Hello) error {
708 return ErrInvalidHello708 return ErrInvalidHello
709 }709 }
710 if err := validateHelloNavigation(h); err != nil {710 if err := validateHelloNavigation(h); err != nil {
711- return fmt.Errorf("%w: navigation", ErrInvalidHello)711+ return fmt.Errorf("%w: navigation: %w", ErrInvalidHello, err)
712 }712 }
713 if len(h.Name) > math.MaxUint16 || len(h.TermEnv) > math.MaxUint16 || len(h.Cwd) > math.MaxUint16 || len(h.Env) > math.MaxUint32 {713 if len(h.Name) > math.MaxUint16 || len(h.TermEnv) > math.MaxUint16 || len(h.Cwd) > math.MaxUint16 || len(h.Env) > math.MaxUint32 {
714 return ErrInvalidHello714 return ErrInvalidHello
M internal/ports/wire_test.go +1 -1
@@ -95,7 +95,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
9595 want := append(append([]byte(nil), tt.want...), 0, 0)
9696 got := MarshalHello(tt.msg)
9797 if !bytes.Equal(got, want) {
98- t.Fatalf("MarshalHello() = %#v, want %#v", got, tt.want)
98+ t.Fatalf("MarshalHello() = %#v, want %#v", got, want)
9999 }
100100 back, err := UnmarshalHello(got)
101101 if err != nil {
@@ -95,7 +95,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
95 want := append(append([]byte(nil), tt.want...), 0, 0)95 want := append(append([]byte(nil), tt.want...), 0, 0)
96 got := MarshalHello(tt.msg)96 got := MarshalHello(tt.msg)
97 if !bytes.Equal(got, want) {97 if !bytes.Equal(got, want) {
98- t.Fatalf("MarshalHello() = %#v, want %#v", got, tt.want)98+ t.Fatalf("MarshalHello() = %#v, want %#v", got, want)
99 }99 }
100 back, err := UnmarshalHello(got)100 back, err := UnmarshalHello(got)
101 if err != nil {101 if err != nil {
M internal/usecase/client/client.go +5 -1
@@ -253,7 +253,7 @@ func validateAttachRequest(request AttachRequest) error {
253253 return fmt.Errorf("vev: invalid session name: %w", err)
254254 }
255255 }
256- if err := ports.ValidateNavigation(request.NavigationCapabilities, request.StartupOverlay, request.Remote || request.RemoteTarget != nil); err != nil {
256+ if err := ports.ValidateNavigation(request.NavigationCapabilities, request.StartupOverlay, request.RemoteTarget != nil || request.EnvironmentPolicy == ports.EnvironmentPolicyDaemonOwned); err != nil {
257257 return fmt.Errorf("vev: invalid navigation route: %w", err)
258258 }
259259 if request.RemoteTarget == nil {
@@ -514,6 +514,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
514514 if result.target.RemoteTarget != nil || result.target.Endpoint != "" {
515515 if homeRoute == nil && attemptRequest.RemoteTarget == nil {
516516 routeRequest := attemptRequest
517+ if result.sessionName != "" {
518+ routeRequest.SessionName = result.sessionName
519+ }
517520 routeRequest.Intent = ports.IntentAttach
518521 routeRequest.NavigationCapabilities = 0
519522 routeRequest.StartupOverlay = ports.StartupOverlayNone
@@ -589,6 +592,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
589592 if returnNavigationPending && !returnResumeFallback && homeRoute != nil {
590593 returnRoute = nil
591594 returnNavigationPending = false
595+ dialer = homeRoute.dialer
592596 attemptRequest = homeRoute.request
593597 attemptRequest.Intent = ports.IntentAttach
594598 attemptRequest.NavigationCapabilities = ports.NavigationCapabilityBack
@@ -253,7 +253,7 @@ func validateAttachRequest(request AttachRequest) error {
253 return fmt.Errorf("vev: invalid session name: %w", err)253 return fmt.Errorf("vev: invalid session name: %w", err)
254 }254 }
255 }255 }
256- if err := ports.ValidateNavigation(request.NavigationCapabilities, request.StartupOverlay, request.Remote || request.RemoteTarget != nil); err != nil {256+ if err := ports.ValidateNavigation(request.NavigationCapabilities, request.StartupOverlay, request.RemoteTarget != nil || request.EnvironmentPolicy == ports.EnvironmentPolicyDaemonOwned); err != nil {
257 return fmt.Errorf("vev: invalid navigation route: %w", err)257 return fmt.Errorf("vev: invalid navigation route: %w", err)
258 }258 }
259 if request.RemoteTarget == nil {259 if request.RemoteTarget == nil {
@@ -514,6 +514,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
514 if result.target.RemoteTarget != nil || result.target.Endpoint != "" {514 if result.target.RemoteTarget != nil || result.target.Endpoint != "" {
515 if homeRoute == nil && attemptRequest.RemoteTarget == nil {515 if homeRoute == nil && attemptRequest.RemoteTarget == nil {
516 routeRequest := attemptRequest516 routeRequest := attemptRequest
517+ if result.sessionName != "" {
518+ routeRequest.SessionName = result.sessionName
519+ }
517 routeRequest.Intent = ports.IntentAttach520 routeRequest.Intent = ports.IntentAttach
518 routeRequest.NavigationCapabilities = 0521 routeRequest.NavigationCapabilities = 0
519 routeRequest.StartupOverlay = ports.StartupOverlayNone522 routeRequest.StartupOverlay = ports.StartupOverlayNone
@@ -589,6 +592,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
589 if returnNavigationPending && !returnResumeFallback && homeRoute != nil {592 if returnNavigationPending && !returnResumeFallback && homeRoute != nil {
590 returnRoute = nil593 returnRoute = nil
591 returnNavigationPending = false594 returnNavigationPending = false
595+ dialer = homeRoute.dialer
592 attemptRequest = homeRoute.request596 attemptRequest = homeRoute.request
593 attemptRequest.Intent = ports.IntentAttach597 attemptRequest.Intent = ports.IntentAttach
594 attemptRequest.NavigationCapabilities = ports.NavigationCapabilityBack598 attemptRequest.NavigationCapabilities = ports.NavigationCapabilityBack
M internal/usecase/client/client_test.go +1 -0
@@ -90,6 +90,7 @@ func (t *reconnectTestTimer) Reset(d time.Duration) bool {
9090 t.mu.Lock()
9191 t.duration = d
9292 t.fired = false
93+ t.stopped = false
9394 t.mu.Unlock()
9495 return true
9596 }
@@ -90,6 +90,7 @@ func (t *reconnectTestTimer) Reset(d time.Duration) bool {
90 t.mu.Lock()90 t.mu.Lock()
91 t.duration = d91 t.duration = d
92 t.fired = false92 t.fired = false
93+ t.stopped = false
93 t.mu.Unlock()94 t.mu.Unlock()
94 return true95 return true
95 }96 }
M internal/usecase/daemon/client.go +7 -2
@@ -211,7 +211,12 @@ func (ac *attachedClient) clearGeometryClaim() {
211211 ac.sizeMu.Unlock()
212212 }
213213
214-func (ac *attachedClient) currentAttachmentSession() *session { return ac.sess.Get() }
214+func (ac *attachedClient) currentAttachmentSession() *session {
215+ if ac == nil {
216+ return nil
217+ }
218+ return ac.sess.Get()
219+}
215220
216221 // currentSession narrows the attachment to its owning local session.
217222 func (ac *attachedClient) currentSession() *session {
@@ -847,7 +852,7 @@ func (d *Daemon) applyThemeForAttachment(token attachmentConnectionToken, msg po
847852 if !token.attachmentEffectCurrent() {
848853 return
849854 }
850- if rc := token.sess.core().coordinator.Load(); rc != nil {
855+ if rc := sess.renderCoordinator(); rc != nil {
851856 rc.invalidateForLease(token.ac, token.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"})
852857 }
853858 }
@@ -211,7 +211,12 @@ func (ac *attachedClient) clearGeometryClaim() {
211 ac.sizeMu.Unlock()211 ac.sizeMu.Unlock()
212 }212 }
213 213
214-func (ac *attachedClient) currentAttachmentSession() *session { return ac.sess.Get() }214+func (ac *attachedClient) currentAttachmentSession() *session {
215+ if ac == nil {
216+ return nil
217+ }
218+ return ac.sess.Get()
219+}
215 220
216 // currentSession narrows the attachment to its owning local session.221 // currentSession narrows the attachment to its owning local session.
217 func (ac *attachedClient) currentSession() *session {222 func (ac *attachedClient) currentSession() *session {
@@ -847,7 +852,7 @@ func (d *Daemon) applyThemeForAttachment(token attachmentConnectionToken, msg po
847 if !token.attachmentEffectCurrent() {852 if !token.attachmentEffectCurrent() {
848 return853 return
849 }854 }
850- if rc := token.sess.core().coordinator.Load(); rc != nil {855+ if rc := sess.renderCoordinator(); rc != nil {
851 rc.invalidateForLease(token.ac, token.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"})856 rc.invalidateForLease(token.ac, token.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"})
852 }857 }
853 }858 }
M internal/usecase/daemon/client_frame_routing.go +6 -4
@@ -278,10 +278,12 @@ func (d *Daemon) ackOutput(token attachmentConnectionToken, epoch, state uint64)
278278 return false
279279 }
280280 ac.sendMu.Lock()
281- ac.output.ack(epoch, state)
281+ acknowledged := ac.output.ack(epoch, state)
282282 ac.sendMu.Unlock()
283- if rc := token.sess.core().coordinator.Load(); rc != nil {
284- rc.notifyAckForLease(token.lease)
283+ if acknowledged {
284+ if rc := token.sess.renderCoordinator(); rc != nil {
285+ rc.notifyAckForLease(token.lease)
286+ }
285287 }
286- return true
288+ return acknowledged
287289 }
@@ -278,10 +278,12 @@ func (d *Daemon) ackOutput(token attachmentConnectionToken, epoch, state uint64)
278 return false278 return false
279 }279 }
280 ac.sendMu.Lock()280 ac.sendMu.Lock()
281- ac.output.ack(epoch, state)281+ acknowledged := ac.output.ack(epoch, state)
282 ac.sendMu.Unlock()282 ac.sendMu.Unlock()
283- if rc := token.sess.core().coordinator.Load(); rc != nil {283+ if acknowledged {
284- rc.notifyAckForLease(token.lease)284+ if rc := token.sess.renderCoordinator(); rc != nil {
285+ rc.notifyAckForLease(token.lease)
286+ }
285 }287 }
286- return true288+ return acknowledged
287 }289 }
M internal/usecase/daemon/output_state_stream.go +4 -3
@@ -250,17 +250,18 @@ func (s *outputStateStream) sideEffectLocked(data []byte, echoAck uint64) (ports
250250 return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false)
251251 }
252252
253-func (s *outputStateStream) ack(epoch, state uint64) {
253+func (s *outputStateStream) ack(epoch, state uint64) bool {
254254 if s == nil {
255- return
255+ return false
256256 }
257257 s.lockView()
258258 defer s.unlockView()
259259 if epoch != s.currentEpochLocked() || state > s.next || state <= s.acked {
260- return
260+ return false
261261 }
262262 s.acked = state
263263 s.publishOutstanding()
264+ return true
264265 }
265266
266267 func (s *outputStateStream) rebase() {
@@ -250,17 +250,18 @@ func (s *outputStateStream) sideEffectLocked(data []byte, echoAck uint64) (ports
250 return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false)250 return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false)
251 }251 }
252 252
253-func (s *outputStateStream) ack(epoch, state uint64) {253+func (s *outputStateStream) ack(epoch, state uint64) bool {
254 if s == nil {254 if s == nil {
255- return255+ return false
256 }256 }
257 s.lockView()257 s.lockView()
258 defer s.unlockView()258 defer s.unlockView()
259 if epoch != s.currentEpochLocked() || state > s.next || state <= s.acked {259 if epoch != s.currentEpochLocked() || state > s.next || state <= s.acked {
260- return260+ return false
261 }261 }
262 s.acked = state262 s.acked = state
263 s.publishOutstanding()263 s.publishOutstanding()
264+ return true
264 }265 }
265 266
266 func (s *outputStateStream) rebase() {267 func (s *outputStateStream) rebase() {
M internal/usecase/daemon/remote_picker_test.go +14 -3
@@ -376,11 +376,22 @@ type fixedRemoteRefreshClock struct{ now time.Time }
376376 func (c fixedRemoteRefreshClock) Now() time.Time { return c.now }
377377 func (fixedRemoteRefreshClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
378378
379-type remotePickerClock struct{ now time.Time }
379+type remotePickerClock struct {
380+ mu sync.Mutex
381+ now time.Time
382+}
380383
381-func (c *remotePickerClock) Now() time.Time { return c.now }
384+func (c *remotePickerClock) Now() time.Time {
385+ c.mu.Lock()
386+ defer c.mu.Unlock()
387+ return c.now
388+}
382389 func (*remotePickerClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
383-func (c *remotePickerClock) Advance(d time.Duration) { c.now = c.now.Add(d) }
390+func (c *remotePickerClock) Advance(d time.Duration) {
391+ c.mu.Lock()
392+ c.now = c.now.Add(d)
393+ c.mu.Unlock()
394+}
384395
385396 func requireNoRemoteLockViolations(t *testing.T, catalog *channelRemoteCatalog, cache *recordingRemoteCache) {
386397 t.Helper()
@@ -376,11 +376,22 @@ type fixedRemoteRefreshClock struct{ now time.Time }
376 func (c fixedRemoteRefreshClock) Now() time.Time { return c.now }376 func (c fixedRemoteRefreshClock) Now() time.Time { return c.now }
377 func (fixedRemoteRefreshClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }377 func (fixedRemoteRefreshClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
378 378
379-type remotePickerClock struct{ now time.Time }379+type remotePickerClock struct {
380+ mu sync.Mutex
381+ now time.Time
382+}
380 383
381-func (c *remotePickerClock) Now() time.Time { return c.now }384+func (c *remotePickerClock) Now() time.Time {
385+ c.mu.Lock()
386+ defer c.mu.Unlock()
387+ return c.now
388+}
382 func (*remotePickerClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }389 func (*remotePickerClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
383-func (c *remotePickerClock) Advance(d time.Duration) { c.now = c.now.Add(d) }390+func (c *remotePickerClock) Advance(d time.Duration) {
391+ c.mu.Lock()
392+ c.now = c.now.Add(d)
393+ c.mu.Unlock()
394+}
384 395
385 func requireNoRemoteLockViolations(t *testing.T, catalog *channelRemoteCatalog, cache *recordingRemoteCache) {396 func requireNoRemoteLockViolations(t *testing.T, catalog *channelRemoteCatalog, cache *recordingRemoteCache) {
386 t.Helper()397 t.Helper()
M internal/usecase/daemon/resume.go +10 -10
@@ -70,7 +70,7 @@ func (d *Daemon) markParkingInFlight(sess *session, ac *attachedClient) uint64 {
7070 ac.resumeToken = d.nextResumeTokenLocked()
7171 }
7272 d.ensureParkingInFlightLocked(sess, ac)
73- d.log.Info("parking in flight", "session", sess.name)
73+ d.log.Info("parking in flight", "session", sess.nameSnapshot())
7474 return ac.resumeToken
7575 }
7676
@@ -178,7 +178,7 @@ func (d *Daemon) waitParkingInFlight(h ports.Hello) bool {
178178 d.mu.Unlock()
179179 return false
180180 }
181- if h.Name != "" && pending.sess != nil && pending.sess.name != h.Name {
181+ if h.Name != "" && pending.sess != nil && pending.sess.nameSnapshot() != h.Name {
182182 d.mu.Unlock()
183183 return false
184184 }
@@ -208,7 +208,7 @@ func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {
208208 // terminal session removal and can strand IntentResume waiters. Callers
209209 // that need the detach→park gap advertised must markParkingInFlight while
210210 // still the exact live owner. Direct parkAttachment still parks below.
211- d.log.Info("parking client prepared", "session", sess.name)
211+ d.log.Info("parking client prepared", "session", sess.nameSnapshot())
212212 return true
213213 }
214214
@@ -262,7 +262,7 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
262262 }
263263 oldSame.closeDone()
264264 }
265- d.log.Info("client parked for resume", "session", sess.name, "grace", grace)
265+ d.log.Info("client parked for resume", "session", sess.nameSnapshot(), "grace", grace)
266266 d.watchParkedTimer(token, parked)
267267 return true
268268 }
@@ -291,7 +291,7 @@ func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
291291 d.removeParkedLocked(token, parked)
292292 d.mu.Unlock()
293293 d.closePickerIfCurrent(parked.ac, nil, parked.pickerGeneration)
294- d.log.Warn("parked client expired", "session", parked.sess.name)
294+ d.log.Warn("parked client expired", "session", parked.sess.nameSnapshot())
295295 return
296296 }
297297 d.mu.Unlock()
@@ -501,7 +501,7 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
501501
502502 if clientMismatch {
503503 reason := "client id mismatch"
504- d.log.Warn("resume rejected", "session", sess.name, "err", reason)
504+ d.log.Warn("resume rejected", "session", sess.nameSnapshot(), "err", reason)
505505 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
506506 }
507507 if !credentialMatch {
@@ -549,7 +549,7 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
549549 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
550550 }
551551 _ = ac.closeCapturedTransport(oldSnap.transport)
552- d.log.Info("live resume credential parked for reconnect", "session", sess.name)
552+ d.log.Info("live resume credential parked for reconnect", "session", sess.nameSnapshot())
553553 return d.resumeParked(h, tr, sz)
554554 }
555555
@@ -606,7 +606,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
606606 registered := d.sessions[sess.id] == sess
607607 if !registered {
608608 d.removeParkedLocked(h.ResumeToken, parked)
609- d.log.Warn("resume rejected", "session", sess.name, "registered", false)
609+ d.log.Warn("resume rejected", "session", sess.nameSnapshot(), "registered", false)
610610 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
611611 }
612612 ac := parked.ac
@@ -615,7 +615,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
615615 }
616616 if ac.clientID != h.ClientID {
617617 reason := "client id mismatch"
618- d.log.Warn("resume rejected", "session", sess.name, "err", reason)
618+ d.log.Warn("resume rejected", "session", sess.nameSnapshot(), "err", reason)
619619 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
620620 }
621621 if h.RemoteTarget != nil && !d.remoteTargetMatchesSessionLocked(sess, *h.RemoteTarget) {
@@ -663,6 +663,6 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
663663 }
664664 d.deferAttachmentTransitionCleanups(transition)
665665 d.touchMRU(sess)
666- d.log.Info("client resumed", "session", sess.name)
666+ d.log.Info("client resumed", "session", sess.nameSnapshot())
667667 return sess, ac, true, nil
668668 }
@@ -70,7 +70,7 @@ func (d *Daemon) markParkingInFlight(sess *session, ac *attachedClient) uint64 {
70 ac.resumeToken = d.nextResumeTokenLocked()70 ac.resumeToken = d.nextResumeTokenLocked()
71 }71 }
72 d.ensureParkingInFlightLocked(sess, ac)72 d.ensureParkingInFlightLocked(sess, ac)
73- d.log.Info("parking in flight", "session", sess.name)73+ d.log.Info("parking in flight", "session", sess.nameSnapshot())
74 return ac.resumeToken74 return ac.resumeToken
75 }75 }
76 76
@@ -178,7 +178,7 @@ func (d *Daemon) waitParkingInFlight(h ports.Hello) bool {
178 d.mu.Unlock()178 d.mu.Unlock()
179 return false179 return false
180 }180 }
181- if h.Name != "" && pending.sess != nil && pending.sess.name != h.Name {181+ if h.Name != "" && pending.sess != nil && pending.sess.nameSnapshot() != h.Name {
182 d.mu.Unlock()182 d.mu.Unlock()
183 return false183 return false
184 }184 }
@@ -208,7 +208,7 @@ func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {
208 // terminal session removal and can strand IntentResume waiters. Callers208 // terminal session removal and can strand IntentResume waiters. Callers
209 // that need the detach→park gap advertised must markParkingInFlight while209 // that need the detach→park gap advertised must markParkingInFlight while
210 // still the exact live owner. Direct parkAttachment still parks below.210 // still the exact live owner. Direct parkAttachment still parks below.
211- d.log.Info("parking client prepared", "session", sess.name)211+ d.log.Info("parking client prepared", "session", sess.nameSnapshot())
212 return true212 return true
213 }213 }
214 214
@@ -262,7 +262,7 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
262 }262 }
263 oldSame.closeDone()263 oldSame.closeDone()
264 }264 }
265- d.log.Info("client parked for resume", "session", sess.name, "grace", grace)265+ d.log.Info("client parked for resume", "session", sess.nameSnapshot(), "grace", grace)
266 d.watchParkedTimer(token, parked)266 d.watchParkedTimer(token, parked)
267 return true267 return true
268 }268 }
@@ -291,7 +291,7 @@ func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
291 d.removeParkedLocked(token, parked)291 d.removeParkedLocked(token, parked)
292 d.mu.Unlock()292 d.mu.Unlock()
293 d.closePickerIfCurrent(parked.ac, nil, parked.pickerGeneration)293 d.closePickerIfCurrent(parked.ac, nil, parked.pickerGeneration)
294- d.log.Warn("parked client expired", "session", parked.sess.name)294+ d.log.Warn("parked client expired", "session", parked.sess.nameSnapshot())
295 return295 return
296 }296 }
297 d.mu.Unlock()297 d.mu.Unlock()
@@ -501,7 +501,7 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
501 501
502 if clientMismatch {502 if clientMismatch {
503 reason := "client id mismatch"503 reason := "client id mismatch"
504- d.log.Warn("resume rejected", "session", sess.name, "err", reason)504+ d.log.Warn("resume rejected", "session", sess.nameSnapshot(), "err", reason)
505 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}505 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
506 }506 }
507 if !credentialMatch {507 if !credentialMatch {
@@ -549,7 +549,7 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
549 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}549 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
550 }550 }
551 _ = ac.closeCapturedTransport(oldSnap.transport)551 _ = ac.closeCapturedTransport(oldSnap.transport)
552- d.log.Info("live resume credential parked for reconnect", "session", sess.name)552+ d.log.Info("live resume credential parked for reconnect", "session", sess.nameSnapshot())
553 return d.resumeParked(h, tr, sz)553 return d.resumeParked(h, tr, sz)
554 }554 }
555 555
@@ -606,7 +606,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
606 registered := d.sessions[sess.id] == sess606 registered := d.sessions[sess.id] == sess
607 if !registered {607 if !registered {
608 d.removeParkedLocked(h.ResumeToken, parked)608 d.removeParkedLocked(h.ResumeToken, parked)
609- d.log.Warn("resume rejected", "session", sess.name, "registered", false)609+ d.log.Warn("resume rejected", "session", sess.nameSnapshot(), "registered", false)
610 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}610 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
611 }611 }
612 ac := parked.ac612 ac := parked.ac
@@ -615,7 +615,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
615 }615 }
616 if ac.clientID != h.ClientID {616 if ac.clientID != h.ClientID {
617 reason := "client id mismatch"617 reason := "client id mismatch"
618- d.log.Warn("resume rejected", "session", sess.name, "err", reason)618+ d.log.Warn("resume rejected", "session", sess.nameSnapshot(), "err", reason)
619 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}619 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
620 }620 }
621 if h.RemoteTarget != nil && !d.remoteTargetMatchesSessionLocked(sess, *h.RemoteTarget) {621 if h.RemoteTarget != nil && !d.remoteTargetMatchesSessionLocked(sess, *h.RemoteTarget) {
@@ -663,6 +663,6 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
663 }663 }
664 d.deferAttachmentTransitionCleanups(transition)664 d.deferAttachmentTransitionCleanups(transition)
665 d.touchMRU(sess)665 d.touchMRU(sess)
666- d.log.Info("client resumed", "session", sess.name)666+ d.log.Info("client resumed", "session", sess.nameSnapshot())
667 return sess, ac, true, nil667 return sess, ac, true, nil
668 }668 }
M internal/usecase/daemon/session.go +9 -0
@@ -118,6 +118,15 @@ type session struct {
118118 floatingLaunches map[*floatingLaunch]struct{}
119119 }
120120
121+func (s *session) nameSnapshot() string {
122+ if s == nil {
123+ return ""
124+ }
125+ s.mu.Lock()
126+ defer s.mu.Unlock()
127+ return s.name
128+}
129+
121130 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.
122131 type tab struct {
123132 mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, and pane map membership
@@ -118,6 +118,15 @@ type session struct {
118 floatingLaunches map[*floatingLaunch]struct{}118 floatingLaunches map[*floatingLaunch]struct{}
119 }119 }
120 120
121+func (s *session) nameSnapshot() string {
122+ if s == nil {
123+ return ""
124+ }
125+ s.mu.Lock()
126+ defer s.mu.Unlock()
127+ return s.name
128+}
129+
121 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.130 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.
122 type tab struct {131 type tab struct {
123 mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, and pane map membership132 mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, and pane map membership

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