detached 38bcf15

fix(daemon): address PR 156 review findings

Fix a Hello.Proxied compile error in session_core_test.go by removing the
unsupported field (no proxied/remote attachment mechanism exists on this
branch yet) and repairing the test's teardown so it exercises the real
graceful-skip behavior of an invalid active-tab index during first paint.

Guard nil session cores before dereferencing registry IDs in the
attachment-transition validators, resolve req.target.core() into a single
local for the target-init block, and wrap the session-registry rejection
path in createSessionLocked with the same domain.UserErr/NoticeSessionSpawn
used by its sibling failure paths.
commit
38bcf153ab4885d0a3e205ffc75800506fb23680
author
brice · · 2026-07-31 07:30 UTC
parent
951a71d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +31 -15
M internal/usecase/daemon/attachment_transition.go +13 -5
@@ -84,19 +84,27 @@ func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (
8484 if source == nil {
8585 source = req.target
8686 }
87- if source == nil || (req.target == nil && req.createTargetLocked == nil) || req.next == nil || d.closing ||
88- d.sessions[source.core().id] != source || req.target != nil && d.sessions[req.target.core().id] != req.target ||
87+ if source == nil || (req.target == nil && req.createTargetLocked == nil) {
88+ return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
89+ }
90+ sourceCore := source.core()
91+ var targetCore *sessionCore
92+ if req.target != nil {
93+ targetCore = req.target.core()
94+ }
95+ if sourceCore == nil || req.target != nil && targetCore == nil || req.next == nil || d.closing ||
96+ d.sessions[sourceCore.id] != source || req.target != nil && d.sessions[targetCore.id] != req.target ||
8997 req.activationBarrier && (req.sourceToken == nil || source != req.target ||
9098 req.expectedRole != attachmentSnatched || req.targetRole != attachmentActive) {
9199 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
92100 }
93101 if req.target != nil {
94- req.target.core().mu.Lock()
95- req.expectedTargetCurrent = req.target.core().client
102+ targetCore.mu.Lock()
103+ req.expectedTargetCurrent = targetCore.client
96104 if req.expectedTargetCurrent != nil && req.expectedTargetCurrent != req.next && req.targetRole == attachmentActive {
97105 req.expectedTargetTransport = req.expectedTargetCurrent.transportSnapshot()
98106 }
99- req.target.core().mu.Unlock()
107+ targetCore.mu.Unlock()
100108 }
101109 if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) {
102110 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
@@ -84,19 +84,27 @@ func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (
84 if source == nil {84 if source == nil {
85 source = req.target85 source = req.target
86 }86 }
87- if source == nil || (req.target == nil && req.createTargetLocked == nil) || req.next == nil || d.closing ||87+ if source == nil || (req.target == nil && req.createTargetLocked == nil) {
88- d.sessions[source.core().id] != source || req.target != nil && d.sessions[req.target.core().id] != req.target ||88+ return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
89+ }
90+ sourceCore := source.core()
91+ var targetCore *sessionCore
92+ if req.target != nil {
93+ targetCore = req.target.core()
94+ }
95+ if sourceCore == nil || req.target != nil && targetCore == nil || req.next == nil || d.closing ||
96+ d.sessions[sourceCore.id] != source || req.target != nil && d.sessions[targetCore.id] != req.target ||
89 req.activationBarrier && (req.sourceToken == nil || source != req.target ||97 req.activationBarrier && (req.sourceToken == nil || source != req.target ||
90 req.expectedRole != attachmentSnatched || req.targetRole != attachmentActive) {98 req.expectedRole != attachmentSnatched || req.targetRole != attachmentActive) {
91 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition99 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
92 }100 }
93 if req.target != nil {101 if req.target != nil {
94- req.target.core().mu.Lock()102+ targetCore.mu.Lock()
95- req.expectedTargetCurrent = req.target.core().client103+ req.expectedTargetCurrent = targetCore.client
96 if req.expectedTargetCurrent != nil && req.expectedTargetCurrent != req.next && req.targetRole == attachmentActive {104 if req.expectedTargetCurrent != nil && req.expectedTargetCurrent != req.next && req.targetRole == attachmentActive {
97 req.expectedTargetTransport = req.expectedTargetCurrent.transportSnapshot()105 req.expectedTargetTransport = req.expectedTargetCurrent.transportSnapshot()
98 }106 }
99- req.target.core().mu.Unlock()107+ targetCore.mu.Unlock()
100 }108 }
101 if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) {109 if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) {
102 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition110 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
M internal/usecase/daemon/attachment_transition_publish.go +11 -5
@@ -103,10 +103,16 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
103103 if source == nil {
104104 source = req.target
105105 }
106- if !req.preflighted || !req.roleEffectsFrozen ||
106+ if source == nil || req.target == nil {
107+ return nil, errAttachmentTransition
108+ }
109+ sourceCore := source.core()
110+ targetCore := req.target.core()
111+ if sourceCore == nil || targetCore == nil ||
112+ !req.preflighted || !req.roleEffectsFrozen ||
107113 !validAttachmentTransitionRole(req.expectedRole, true) || !validAttachmentTransitionRole(req.targetRole, false) ||
108- source == nil || req.target == nil || req.next == nil || d.closing ||
109- d.sessions[source.core().id] != source || d.sessions[req.target.core().id] != req.target ||
114+ req.next == nil || d.closing ||
115+ d.sessions[sourceCore.id] != source || d.sessions[targetCore.id] != req.target ||
110116 req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) ||
111117 req.preserveRole && req.sourceToken == nil {
112118 return nil, errAttachmentTransition
@@ -118,13 +124,13 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
118124 targetCoordinator = d.ensureRenderCoordinatorPrelocked(target)
119125 }
120126 }
121- sourceCoordinator := source.core().coordinator.Load()
127+ sourceCoordinator := sourceCore.coordinator.Load()
122128 if attachmentSessionRoleLocked(source, req.next) != req.expectedRole ||
123129 !req.next.transportSnapshotCurrent(req.expectedTransport) {
124130 return nil, errAttachmentTransition
125131 }
126132
127- old := req.target.core().client
133+ old := targetCore.client
128134 if old != req.expectedTargetCurrent || source != req.target && old == req.next {
129135 return nil, errAttachmentTransition
130136 }
@@ -103,10 +103,16 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
103 if source == nil {103 if source == nil {
104 source = req.target104 source = req.target
105 }105 }
106- if !req.preflighted || !req.roleEffectsFrozen ||106+ if source == nil || req.target == nil {
107+ return nil, errAttachmentTransition
108+ }
109+ sourceCore := source.core()
110+ targetCore := req.target.core()
111+ if sourceCore == nil || targetCore == nil ||
112+ !req.preflighted || !req.roleEffectsFrozen ||
107 !validAttachmentTransitionRole(req.expectedRole, true) || !validAttachmentTransitionRole(req.targetRole, false) ||113 !validAttachmentTransitionRole(req.expectedRole, true) || !validAttachmentTransitionRole(req.targetRole, false) ||
108- source == nil || req.target == nil || req.next == nil || d.closing ||114+ req.next == nil || d.closing ||
109- d.sessions[source.core().id] != source || d.sessions[req.target.core().id] != req.target ||115+ d.sessions[sourceCore.id] != source || d.sessions[targetCore.id] != req.target ||
110 req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) ||116 req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) ||
111 req.preserveRole && req.sourceToken == nil {117 req.preserveRole && req.sourceToken == nil {
112 return nil, errAttachmentTransition118 return nil, errAttachmentTransition
@@ -118,13 +124,13 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
118 targetCoordinator = d.ensureRenderCoordinatorPrelocked(target)124 targetCoordinator = d.ensureRenderCoordinatorPrelocked(target)
119 }125 }
120 }126 }
121- sourceCoordinator := source.core().coordinator.Load()127+ sourceCoordinator := sourceCore.coordinator.Load()
122 if attachmentSessionRoleLocked(source, req.next) != req.expectedRole ||128 if attachmentSessionRoleLocked(source, req.next) != req.expectedRole ||
123 !req.next.transportSnapshotCurrent(req.expectedTransport) {129 !req.next.transportSnapshotCurrent(req.expectedTransport) {
124 return nil, errAttachmentTransition130 return nil, errAttachmentTransition
125 }131 }
126 132
127- old := req.target.core().client133+ old := targetCore.client
128 if old != req.expectedTargetCurrent || source != req.target && old == req.next {134 if old != req.expectedTargetCurrent || source != req.target && old == req.next {
129 return nil, errAttachmentTransition135 return nil, errAttachmentTransition
130 }136 }
M internal/usecase/daemon/session.go +1 -1
@@ -363,7 +363,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
363363 }
364364 closeTabs(tabs)
365365 cancel()
366- return nil, err
366+ return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
367367 }
368368 if !ephemeral {
369369 // Keep stopped lifecycle authority intact until runtime publication and
@@ -363,7 +363,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
363 }363 }
364 closeTabs(tabs)364 closeTabs(tabs)
365 cancel()365 cancel()
366- return nil, err366+ return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
367 }367 }
368 if !ephemeral {368 if !ephemeral {
369 // Keep stopped lifecycle authority intact until runtime publication and369 // Keep stopped lifecycle authority intact until runtime publication and
M internal/usecase/daemon/session_core_test.go +6 -4
@@ -134,12 +134,11 @@ func TestSessionCoreLockOrderUsesImmutableIDs(t *testing.T) {
134134 second.core().mu.Unlock()
135135 }
136136
137-func TestProxiedInitialMetadataSkipsInvalidSnapshot(t *testing.T) {
137+func TestInitialMetadataSkipsInvalidSnapshot(t *testing.T) {
138138 d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
139139 hello := ports.Hello{
140140 Version: ports.ProtocolVersion,
141141 Intent: ports.IntentNew,
142- Proxied: true,
143142 Name: "remote-work",
144143 Size: defaultSize,
145144 }
@@ -150,13 +149,16 @@ func TestProxiedInitialMetadataSkipsInvalidSnapshot(t *testing.T) {
150149 close(done)
151150 }()
152151
153- awaitTestCompletion(t, tr.welcomeEntered, "proxied handshake did not send Welcome")
152+ awaitTestCompletion(t, tr.welcomeEntered, "handshake did not send Welcome")
154153 sess := firstSession(d)
155154 require.NotNil(t, sess)
156155 sess.mu.Lock()
157156 sess.active = len(sess.tabs)
158157 sess.mu.Unlock()
159- tr.release()
158+ // finish (not just release) also closes recvDone, so runConnLoop's Recv
159+ // fails immediately after the first paint and the handshake goroutine
160+ // returns instead of blocking on further input.
161+ tr.finish()
160162 awaitTestCompletion(t, done, "invalid metadata snapshot did not end the handshake")
161163
162164 welcome := <-tr.sends
@@ -134,12 +134,11 @@ func TestSessionCoreLockOrderUsesImmutableIDs(t *testing.T) {
134 second.core().mu.Unlock()134 second.core().mu.Unlock()
135 }135 }
136 136
137-func TestProxiedInitialMetadataSkipsInvalidSnapshot(t *testing.T) {137+func TestInitialMetadataSkipsInvalidSnapshot(t *testing.T) {
138 d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})138 d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
139 hello := ports.Hello{139 hello := ports.Hello{
140 Version: ports.ProtocolVersion,140 Version: ports.ProtocolVersion,
141 Intent: ports.IntentNew,141 Intent: ports.IntentNew,
142- Proxied: true,
143 Name: "remote-work",142 Name: "remote-work",
144 Size: defaultSize,143 Size: defaultSize,
145 }144 }
@@ -150,13 +149,16 @@ func TestProxiedInitialMetadataSkipsInvalidSnapshot(t *testing.T) {
150 close(done)149 close(done)
151 }()150 }()
152 151
153- awaitTestCompletion(t, tr.welcomeEntered, "proxied handshake did not send Welcome")152+ awaitTestCompletion(t, tr.welcomeEntered, "handshake did not send Welcome")
154 sess := firstSession(d)153 sess := firstSession(d)
155 require.NotNil(t, sess)154 require.NotNil(t, sess)
156 sess.mu.Lock()155 sess.mu.Lock()
157 sess.active = len(sess.tabs)156 sess.active = len(sess.tabs)
158 sess.mu.Unlock()157 sess.mu.Unlock()
159- tr.release()158+ // finish (not just release) also closes recvDone, so runConnLoop's Recv
159+ // fails immediately after the first paint and the handshake goroutine
160+ // returns instead of blocking on further input.
161+ tr.finish()
160 awaitTestCompletion(t, done, "invalid metadata snapshot did not end the handshake")162 awaitTestCompletion(t, done, "invalid metadata snapshot did not end the handshake")
161 163
162 welcome := <-tr.sends164 welcome := <-tr.sends

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