detached ab88dd5

fix(remote): close failed handshakes cleanly

commit
ab88dd512968ea2179e8671e45c6033720a223dd
author
brice · · 2026-08-08 18:46 UTC
parent
517c29e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +34 -15
M internal/ports/wire_test.go +1 -1
@@ -167,7 +167,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
167167 base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})
168168 withCount := func(count byte) []byte {
169169 payload := append([]byte(nil), base...)
170- payload[len(payload)-3] = count
170+ payload[len(payload)-5] = count
171171 return payload
172172 }
173173 tests := []struct {
@@ -167,7 +167,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
167 base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})167 base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})
168 withCount := func(count byte) []byte {168 withCount := func(count byte) []byte {
169 payload := append([]byte(nil), base...)169 payload := append([]byte(nil), base...)
170- payload[len(payload)-3] = count170+ payload[len(payload)-5] = count
171 return payload171 return payload
172 }172 }
173 tests := []struct {173 tests := []struct {
M internal/usecase/client/client.go +6 -2
@@ -489,10 +489,14 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
489489 returnNavigationPending = true
490490 dialer = route.dialer
491491 attemptRequest = route.request
492- attemptRequest.Intent = ports.IntentResume
492+ if route.resumeToken != 0 {
493+ attemptRequest.Intent = ports.IntentResume
494+ } else {
495+ attemptRequest.Intent = ports.IntentAttach
496+ }
493497 attemptRequest.StartupOverlay = ports.StartupOverlayNone
494498 resumeToken = route.resumeToken
495- returnResumeFallback = true
499+ returnResumeFallback = route.resumeToken != 0
496500 remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
497501 backoff = defaultReconnectBackoff.initial
498502 continue
@@ -489,10 +489,14 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
489 returnNavigationPending = true489 returnNavigationPending = true
490 dialer = route.dialer490 dialer = route.dialer
491 attemptRequest = route.request491 attemptRequest = route.request
492- attemptRequest.Intent = ports.IntentResume492+ if route.resumeToken != 0 {
493+ attemptRequest.Intent = ports.IntentResume
494+ } else {
495+ attemptRequest.Intent = ports.IntentAttach
496+ }
493 attemptRequest.StartupOverlay = ports.StartupOverlayNone497 attemptRequest.StartupOverlay = ports.StartupOverlayNone
494 resumeToken = route.resumeToken498 resumeToken = route.resumeToken
495- returnResumeFallback = true499+ returnResumeFallback = route.resumeToken != 0
496 remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)500 remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
497 backoff = defaultReconnectBackoff.initial501 backoff = defaultReconnectBackoff.initial
498 continue502 continue
M internal/usecase/daemon/client_transport_errors.go +18 -9
@@ -5,6 +5,14 @@ import "github.com/bnema/vev/internal/ports"
55 // clientGone detaches ac if it is still the session's current client. The
66 // session remains registered and headless after the client is gone.
77 func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
8+ d.clientGoneWithNotice(sess, ac, failed, explicit, true)
9+}
10+
11+func (d *Daemon) clientGoneWithoutNotice(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
12+ d.clientGoneWithNotice(sess, ac, failed, explicit, false)
13+}
14+
15+func (d *Daemon) clientGoneWithNotice(sess *session, ac *attachedClient, failed ports.Transport, explicit, notice bool) {
816 if sess == nil || ac == nil {
917 return
1018 }
@@ -28,7 +36,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
2836 d.clearParkingInFlightIfAbandoned(sess, ac, parkingToken)
2937 return // displaced, or the link was rebound after the precheck
3038 }
31- d.finishClientGone(sess, ac, failed, explicit)
39+ d.finishClientGone(sess, ac, failed, explicit, notice)
3240 }
3341
3442 func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explicit bool) bool {
@@ -49,11 +57,11 @@ func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explic
4957 d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)
5058 return false
5159 }
52- d.finishClientGone(sess, token.ac, token.transport.transport, explicit)
60+ d.finishClientGone(sess, token.ac, token.transport.transport, explicit, true)
5361 return true
5462 }
5563
56-func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
64+func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit, notice bool) {
5765 if sess == nil || ac == nil {
5866 return
5967 }
@@ -66,18 +74,19 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
6674 d.recalculateSessionGeometryAndInvalidate(sess, nil, "client_transport_errors.go")
6775 sess.mu.Lock()
6876 ephemeral := sess.ephemeral
77+ name := sess.name
6978 sess.mu.Unlock()
7079 if !ephemeral {
7180 d.refreshSessionCwd(sess)
7281 }
73- d.log.Info("client detach begin", "session", sess.name, "explicit", explicit, "ephemeral", ephemeral)
82+ d.log.Info("client detach begin", "session", name, "explicit", explicit, "ephemeral", ephemeral)
7483 oldTr := failed
7584 if oldTr == nil {
7685 oldTr = ac.transport()
7786 }
7887 if !explicit && d.parkAttachment(sess, ac) {
7988 _ = ac.closeCapturedTransport(oldTr)
80- d.log.Info("client parked", "session", sess.name)
89+ d.log.Info("client parked", "session", sess.nameSnapshot())
8190 return
8291 }
8392 // Explicit winners (and non-explicit park failures) must drop any same-
@@ -88,7 +97,7 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
8897
8998 d.resetScreenDefaultColors(sess)
9099 ac.clearPreviousSession()
91- if explicit {
100+ if explicit && notice {
92101 // Synchronous so the ack is delivered before the transport closes
93102 // (the client is actively awaiting it), but deadline-bounded so a
94103 // wedged client cannot pin this conn handler and hang Serve's
@@ -96,7 +105,7 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
96105 d.boundedSend(ac, frameDetached(ports.ReasonDetach))
97106 }
98107 _ = ac.closeCapturedTransport(oldTr)
99- d.log.Info("client detached", "session", sess.name, "explicit", explicit)
108+ d.log.Info("client detached", "session", sess.nameSnapshot(), "explicit", explicit)
100109 }
101110
102111 // detachOnSendError drops a client whose transport failed, leaving the session
@@ -167,7 +176,7 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed
167176 d.recalculateSessionGeometryAndInvalidate(sess, nil, "client_transport_errors.go")
168177 if d.parkAttachment(sess, ac) {
169178 _ = ac.closeCapturedTransport(failed)
170- d.log.Warn("parked client after send error", "session", sess.name)
179+ d.log.Warn("parked client after send error", "session", sess.nameSnapshot())
171180 return
172181 }
173182 d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)
@@ -175,5 +184,5 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed
175184 d.resetScreenDefaultColors(sess)
176185 ac.clearPreviousSession()
177186 _ = ac.closeCapturedTransport(failed)
178- d.log.Warn("detached client after send error", "session", sess.name)
187+ d.log.Warn("detached client after send error", "session", sess.nameSnapshot())
179188 }
@@ -5,6 +5,14 @@ import "github.com/bnema/vev/internal/ports"
5 // clientGone detaches ac if it is still the session's current client. The5 // clientGone detaches ac if it is still the session's current client. The
6 // session remains registered and headless after the client is gone.6 // session remains registered and headless after the client is gone.
7 func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {7 func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
8+ d.clientGoneWithNotice(sess, ac, failed, explicit, true)
9+}
10+
11+func (d *Daemon) clientGoneWithoutNotice(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
12+ d.clientGoneWithNotice(sess, ac, failed, explicit, false)
13+}
14+
15+func (d *Daemon) clientGoneWithNotice(sess *session, ac *attachedClient, failed ports.Transport, explicit, notice bool) {
8 if sess == nil || ac == nil {16 if sess == nil || ac == nil {
9 return17 return
10 }18 }
@@ -28,7 +36,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
28 d.clearParkingInFlightIfAbandoned(sess, ac, parkingToken)36 d.clearParkingInFlightIfAbandoned(sess, ac, parkingToken)
29 return // displaced, or the link was rebound after the precheck37 return // displaced, or the link was rebound after the precheck
30 }38 }
31- d.finishClientGone(sess, ac, failed, explicit)39+ d.finishClientGone(sess, ac, failed, explicit, notice)
32 }40 }
33 41
34 func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explicit bool) bool {42 func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explicit bool) bool {
@@ -49,11 +57,11 @@ func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explic
49 d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)57 d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)
50 return false58 return false
51 }59 }
52- d.finishClientGone(sess, token.ac, token.transport.transport, explicit)60+ d.finishClientGone(sess, token.ac, token.transport.transport, explicit, true)
53 return true61 return true
54 }62 }
55 63
56-func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {64+func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit, notice bool) {
57 if sess == nil || ac == nil {65 if sess == nil || ac == nil {
58 return66 return
59 }67 }
@@ -66,18 +74,19 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
66 d.recalculateSessionGeometryAndInvalidate(sess, nil, "client_transport_errors.go")74 d.recalculateSessionGeometryAndInvalidate(sess, nil, "client_transport_errors.go")
67 sess.mu.Lock()75 sess.mu.Lock()
68 ephemeral := sess.ephemeral76 ephemeral := sess.ephemeral
77+ name := sess.name
69 sess.mu.Unlock()78 sess.mu.Unlock()
70 if !ephemeral {79 if !ephemeral {
71 d.refreshSessionCwd(sess)80 d.refreshSessionCwd(sess)
72 }81 }
73- d.log.Info("client detach begin", "session", sess.name, "explicit", explicit, "ephemeral", ephemeral)82+ d.log.Info("client detach begin", "session", name, "explicit", explicit, "ephemeral", ephemeral)
74 oldTr := failed83 oldTr := failed
75 if oldTr == nil {84 if oldTr == nil {
76 oldTr = ac.transport()85 oldTr = ac.transport()
77 }86 }
78 if !explicit && d.parkAttachment(sess, ac) {87 if !explicit && d.parkAttachment(sess, ac) {
79 _ = ac.closeCapturedTransport(oldTr)88 _ = ac.closeCapturedTransport(oldTr)
80- d.log.Info("client parked", "session", sess.name)89+ d.log.Info("client parked", "session", sess.nameSnapshot())
81 return90 return
82 }91 }
83 // Explicit winners (and non-explicit park failures) must drop any same-92 // Explicit winners (and non-explicit park failures) must drop any same-
@@ -88,7 +97,7 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
88 97
89 d.resetScreenDefaultColors(sess)98 d.resetScreenDefaultColors(sess)
90 ac.clearPreviousSession()99 ac.clearPreviousSession()
91- if explicit {100+ if explicit && notice {
92 // Synchronous so the ack is delivered before the transport closes101 // Synchronous so the ack is delivered before the transport closes
93 // (the client is actively awaiting it), but deadline-bounded so a102 // (the client is actively awaiting it), but deadline-bounded so a
94 // wedged client cannot pin this conn handler and hang Serve's103 // wedged client cannot pin this conn handler and hang Serve's
@@ -96,7 +105,7 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
96 d.boundedSend(ac, frameDetached(ports.ReasonDetach))105 d.boundedSend(ac, frameDetached(ports.ReasonDetach))
97 }106 }
98 _ = ac.closeCapturedTransport(oldTr)107 _ = ac.closeCapturedTransport(oldTr)
99- d.log.Info("client detached", "session", sess.name, "explicit", explicit)108+ d.log.Info("client detached", "session", sess.nameSnapshot(), "explicit", explicit)
100 }109 }
101 110
102 // detachOnSendError drops a client whose transport failed, leaving the session111 // detachOnSendError drops a client whose transport failed, leaving the session
@@ -167,7 +176,7 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed
167 d.recalculateSessionGeometryAndInvalidate(sess, nil, "client_transport_errors.go")176 d.recalculateSessionGeometryAndInvalidate(sess, nil, "client_transport_errors.go")
168 if d.parkAttachment(sess, ac) {177 if d.parkAttachment(sess, ac) {
169 _ = ac.closeCapturedTransport(failed)178 _ = ac.closeCapturedTransport(failed)
170- d.log.Warn("parked client after send error", "session", sess.name)179+ d.log.Warn("parked client after send error", "session", sess.nameSnapshot())
171 return180 return
172 }181 }
173 d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)182 d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)
@@ -175,5 +184,5 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed
175 d.resetScreenDefaultColors(sess)184 d.resetScreenDefaultColors(sess)
176 ac.clearPreviousSession()185 ac.clearPreviousSession()
177 _ = ac.closeCapturedTransport(failed)186 _ = ac.closeCapturedTransport(failed)
178- d.log.Warn("detached client after send error", "session", sess.name)187+ d.log.Warn("detached client after send error", "session", sess.nameSnapshot())
179 }188 }
M internal/usecase/daemon/daemon.go +3 -1
@@ -1147,8 +1147,9 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
11471147 return
11481148 }
11491149
1150+ welcomeSent := false
11501151 failAttachment := func() {
1151- d.failHandshakeAttachment(sess, ac, tr)
1152+ d.failHandshakeAttachment(sess, ac, tr, welcomeSent)
11521153 }
11531154 if err := handshakeCtx.Err(); err != nil {
11541155 failAttachment()
@@ -1176,6 +1177,7 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
11761177 failAttachment()
11771178 return
11781179 }
1180+ welcomeSent = true
11791181 // Release Welcome's effect before discovering post-handshake authority so a
11801182 // replacement blocked behind the send can publish its generation and lease.
11811183 welcomeTicket.End()
@@ -1147,8 +1147,9 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
1147 return1147 return
1148 }1148 }
1149 1149
1150+ welcomeSent := false
1150 failAttachment := func() {1151 failAttachment := func() {
1151- d.failHandshakeAttachment(sess, ac, tr)1152+ d.failHandshakeAttachment(sess, ac, tr, welcomeSent)
1152 }1153 }
1153 if err := handshakeCtx.Err(); err != nil {1154 if err := handshakeCtx.Err(); err != nil {
1154 failAttachment()1155 failAttachment()
@@ -1176,6 +1177,7 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
1176 failAttachment()1177 failAttachment()
1177 return1178 return
1178 }1179 }
1180+ welcomeSent = true
1179 // Release Welcome's effect before discovering post-handshake authority so a1181 // Release Welcome's effect before discovering post-handshake authority so a
1180 // replacement blocked behind the send can publish its generation and lease.1182 // replacement blocked behind the send can publish its generation and lease.
1181 welcomeTicket.End()1183 welcomeTicket.End()
M internal/usecase/daemon/handshake.go +6 -2
@@ -114,7 +114,7 @@ func handshakeContextError(parent context.Context, timedOut <-chan struct{}, fal
114114 // failHandshakeAttachment synchronously retires the exact connection admitted
115115 // by route. A fresh route-owned session is purged only after its attachment is
116116 // removed; existing sessions and their unrelated attachments are preserved.
117-func (d *Daemon) failHandshakeAttachment(sess *session, ac *attachedClient, tr ports.Transport) {
117+func (d *Daemon) failHandshakeAttachment(sess *session, ac *attachedClient, tr ports.Transport, welcomed bool) {
118118 if sess == nil || ac == nil {
119119 if tr != nil {
120120 _ = tr.Close()
@@ -130,7 +130,11 @@ func (d *Daemon) failHandshakeAttachment(sess *session, ac *attachedClient, tr p
130130 }
131131 return
132132 }
133- d.clientGone(sess, ac, tr, true)
133+ if welcomed {
134+ d.clientGone(sess, ac, tr, true)
135+ } else {
136+ d.clientGoneWithoutNotice(sess, ac, tr, true)
137+ }
134138 if tr != nil {
135139 _ = tr.Close()
136140 }
@@ -114,7 +114,7 @@ func handshakeContextError(parent context.Context, timedOut <-chan struct{}, fal
114 // failHandshakeAttachment synchronously retires the exact connection admitted114 // failHandshakeAttachment synchronously retires the exact connection admitted
115 // by route. A fresh route-owned session is purged only after its attachment is115 // by route. A fresh route-owned session is purged only after its attachment is
116 // removed; existing sessions and their unrelated attachments are preserved.116 // removed; existing sessions and their unrelated attachments are preserved.
117-func (d *Daemon) failHandshakeAttachment(sess *session, ac *attachedClient, tr ports.Transport) {117+func (d *Daemon) failHandshakeAttachment(sess *session, ac *attachedClient, tr ports.Transport, welcomed bool) {
118 if sess == nil || ac == nil {118 if sess == nil || ac == nil {
119 if tr != nil {119 if tr != nil {
120 _ = tr.Close()120 _ = tr.Close()
@@ -130,7 +130,11 @@ func (d *Daemon) failHandshakeAttachment(sess *session, ac *attachedClient, tr p
130 }130 }
131 return131 return
132 }132 }
133- d.clientGone(sess, ac, tr, true)133+ if welcomed {
134+ d.clientGone(sess, ac, tr, true)
135+ } else {
136+ d.clientGoneWithoutNotice(sess, ac, tr, true)
137+ }
134 if tr != nil {138 if tr != nil {
135 _ = tr.Close()139 _ = tr.Close()
136 }140 }

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