detached 448248d

fix(phase5): integrate final quality fixes

commit
448248d284a33c614b9841013c3f10503d66d0c8
author
brice · · 2026-08-03 16:41 UTC
committer
brice · · 2026-08-04 13:34 UTC
parent
6b7d2c3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
52 files · +610 -453
M internal/adapters/sshstdio/transport.go +48 -22
@@ -33,6 +33,18 @@ var (
3333
3434 type closeFunc func() error
3535 type eofErrFunc func() error
36+type operationKind uint8
37+
38+const (
39+ operationSend operationKind = iota
40+ operationReceive
41+ operationKindCount
42+)
43+
44+type operationWait struct {
45+ send <-chan struct{}
46+ receive <-chan struct{}
47+}
3648
3749 type Option func(*transport)
3850
@@ -67,9 +79,9 @@ type transport struct {
6779 eofErr eofErrFunc
6880 observer ports.SerializedRuntimeObserver
6981 operationMu sync.Mutex
70- operationCount int
82+ operationCount [operationKindCount]int
7183 closing bool
72- operationsDone chan struct{}
84+ operationsDone [operationKindCount]chan struct{}
7385 closeOnce sync.Once
7486 closeErr error
7587
@@ -136,7 +148,11 @@ func (t *transport) beginOperation(start ports.RuntimeMarkKind, bytes uint64) fu
136148 if t.observer == nil {
137149 return func(bool) {}
138150 }
139- if !t.beginObservedOperation() {
151+ kind := operationSend
152+ if start == ports.RuntimeAdapterReceiveStart {
153+ kind = operationReceive
154+ }
155+ if !t.beginObservedOperation(kind) {
140156 return func(bool) {}
141157 }
142158 correlation := ports.NewRuntimeCorrelation()
@@ -146,7 +162,7 @@ func (t *transport) beginOperation(start ports.RuntimeMarkKind, bytes uint64) fu
146162 end = ports.RuntimeAdapterReceiveEnd
147163 }
148164 return func(valid bool) {
149- defer t.finishObservedOperation()
165+ defer t.finishObservedOperation(kind)
150166 t.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("sshstdio", correlation, end, bytes, valid))
151167 }
152168 }
@@ -163,20 +179,26 @@ func (t *transport) mapEOFError(err error) error {
163179
164180 func (t *transport) Close() error {
165181 t.closeOnce.Do(func() {
166- done := t.beginShutdown()
182+ wait := t.beginShutdown()
183+ w, writeOwned := t.w.(io.Closer)
184+ r, readOwned := t.r.(io.Closer)
167185 // Close owned streams before waiting for in-flight operations. The
168186 // writer is what releases a Send blocked on a child stdin pipe; the
169- // reader releases a Recv blocked in io.ReadFull.
187+ // reader releases a Recv blocked in io.ReadFull. Unowned streams cannot
188+ // be interrupted safely, so Close must not wait for their operations.
170189 var writeErr, readErr error
171- if w, ok := t.w.(io.Closer); ok {
190+ if writeOwned {
172191 writeErr = w.Close()
173192 }
174- if r, ok := t.r.(io.Closer); ok {
193+ if readOwned {
175194 readErr = r.Close()
176195 }
177196 closeErr := t.close()
178- if done != nil {
179- <-done
197+ if writeOwned && wait.send != nil {
198+ <-wait.send
199+ }
200+ if readOwned && wait.receive != nil {
201+ <-wait.receive
180202 }
181203 switch {
182204 case closeErr != nil:
@@ -190,36 +212,40 @@ func (t *transport) Close() error {
190212 return t.closeErr
191213 }
192214
193-func (t *transport) beginObservedOperation() bool {
215+func (t *transport) beginObservedOperation(kind operationKind) bool {
194216 t.operationMu.Lock()
195217 defer t.operationMu.Unlock()
196218 if t.closing {
197219 return false
198220 }
199- if t.operationCount == 0 {
200- t.operationsDone = make(chan struct{})
221+ if t.operationCount[kind] == 0 {
222+ t.operationsDone[kind] = make(chan struct{})
201223 }
202- t.operationCount++
224+ t.operationCount[kind]++
203225 return true
204226 }
205227
206-func (t *transport) finishObservedOperation() {
228+func (t *transport) finishObservedOperation(kind operationKind) {
207229 t.operationMu.Lock()
208230 defer t.operationMu.Unlock()
209- t.operationCount--
210- if t.operationCount == 0 {
211- close(t.operationsDone)
231+ t.operationCount[kind]--
232+ if t.operationCount[kind] == 0 {
233+ close(t.operationsDone[kind])
212234 }
213235 }
214236
215-func (t *transport) beginShutdown() <-chan struct{} {
237+func (t *transport) beginShutdown() operationWait {
216238 t.operationMu.Lock()
217239 defer t.operationMu.Unlock()
218240 t.closing = true
219- if t.operationCount == 0 {
220- return nil
241+ var wait operationWait
242+ if t.operationCount[operationSend] != 0 {
243+ wait.send = t.operationsDone[operationSend]
244+ }
245+ if t.operationCount[operationReceive] != 0 {
246+ wait.receive = t.operationsDone[operationReceive]
221247 }
222- return t.operationsDone
248+ return wait
223249 }
224250
225251 type processWaiter struct {
@@ -33,6 +33,18 @@ var (
33 33
34 type closeFunc func() error34 type closeFunc func() error
35 type eofErrFunc func() error35 type eofErrFunc func() error
36+type operationKind uint8
37+
38+const (
39+ operationSend operationKind = iota
40+ operationReceive
41+ operationKindCount
42+)
43+
44+type operationWait struct {
45+ send <-chan struct{}
46+ receive <-chan struct{}
47+}
36 48
37 type Option func(*transport)49 type Option func(*transport)
38 50
@@ -67,9 +79,9 @@ type transport struct {
67 eofErr eofErrFunc79 eofErr eofErrFunc
68 observer ports.SerializedRuntimeObserver80 observer ports.SerializedRuntimeObserver
69 operationMu sync.Mutex81 operationMu sync.Mutex
70- operationCount int82+ operationCount [operationKindCount]int
71 closing bool83 closing bool
72- operationsDone chan struct{}84+ operationsDone [operationKindCount]chan struct{}
73 closeOnce sync.Once85 closeOnce sync.Once
74 closeErr error86 closeErr error
75 87
@@ -136,7 +148,11 @@ func (t *transport) beginOperation(start ports.RuntimeMarkKind, bytes uint64) fu
136 if t.observer == nil {148 if t.observer == nil {
137 return func(bool) {}149 return func(bool) {}
138 }150 }
139- if !t.beginObservedOperation() {151+ kind := operationSend
152+ if start == ports.RuntimeAdapterReceiveStart {
153+ kind = operationReceive
154+ }
155+ if !t.beginObservedOperation(kind) {
140 return func(bool) {}156 return func(bool) {}
141 }157 }
142 correlation := ports.NewRuntimeCorrelation()158 correlation := ports.NewRuntimeCorrelation()
@@ -146,7 +162,7 @@ func (t *transport) beginOperation(start ports.RuntimeMarkKind, bytes uint64) fu
146 end = ports.RuntimeAdapterReceiveEnd162 end = ports.RuntimeAdapterReceiveEnd
147 }163 }
148 return func(valid bool) {164 return func(valid bool) {
149- defer t.finishObservedOperation()165+ defer t.finishObservedOperation(kind)
150 t.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("sshstdio", correlation, end, bytes, valid))166 t.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("sshstdio", correlation, end, bytes, valid))
151 }167 }
152 }168 }
@@ -163,20 +179,26 @@ func (t *transport) mapEOFError(err error) error {
163 179
164 func (t *transport) Close() error {180 func (t *transport) Close() error {
165 t.closeOnce.Do(func() {181 t.closeOnce.Do(func() {
166- done := t.beginShutdown()182+ wait := t.beginShutdown()
183+ w, writeOwned := t.w.(io.Closer)
184+ r, readOwned := t.r.(io.Closer)
167 // Close owned streams before waiting for in-flight operations. The185 // Close owned streams before waiting for in-flight operations. The
168 // writer is what releases a Send blocked on a child stdin pipe; the186 // writer is what releases a Send blocked on a child stdin pipe; the
169- // reader releases a Recv blocked in io.ReadFull.187+ // reader releases a Recv blocked in io.ReadFull. Unowned streams cannot
188+ // be interrupted safely, so Close must not wait for their operations.
170 var writeErr, readErr error189 var writeErr, readErr error
171- if w, ok := t.w.(io.Closer); ok {190+ if writeOwned {
172 writeErr = w.Close()191 writeErr = w.Close()
173 }192 }
174- if r, ok := t.r.(io.Closer); ok {193+ if readOwned {
175 readErr = r.Close()194 readErr = r.Close()
176 }195 }
177 closeErr := t.close()196 closeErr := t.close()
178- if done != nil {197+ if writeOwned && wait.send != nil {
179- <-done198+ <-wait.send
199+ }
200+ if readOwned && wait.receive != nil {
201+ <-wait.receive
180 }202 }
181 switch {203 switch {
182 case closeErr != nil:204 case closeErr != nil:
@@ -190,36 +212,40 @@ func (t *transport) Close() error {
190 return t.closeErr212 return t.closeErr
191 }213 }
192 214
193-func (t *transport) beginObservedOperation() bool {215+func (t *transport) beginObservedOperation(kind operationKind) bool {
194 t.operationMu.Lock()216 t.operationMu.Lock()
195 defer t.operationMu.Unlock()217 defer t.operationMu.Unlock()
196 if t.closing {218 if t.closing {
197 return false219 return false
198 }220 }
199- if t.operationCount == 0 {221+ if t.operationCount[kind] == 0 {
200- t.operationsDone = make(chan struct{})222+ t.operationsDone[kind] = make(chan struct{})
201 }223 }
202- t.operationCount++224+ t.operationCount[kind]++
203 return true225 return true
204 }226 }
205 227
206-func (t *transport) finishObservedOperation() {228+func (t *transport) finishObservedOperation(kind operationKind) {
207 t.operationMu.Lock()229 t.operationMu.Lock()
208 defer t.operationMu.Unlock()230 defer t.operationMu.Unlock()
209- t.operationCount--231+ t.operationCount[kind]--
210- if t.operationCount == 0 {232+ if t.operationCount[kind] == 0 {
211- close(t.operationsDone)233+ close(t.operationsDone[kind])
212 }234 }
213 }235 }
214 236
215-func (t *transport) beginShutdown() <-chan struct{} {237+func (t *transport) beginShutdown() operationWait {
216 t.operationMu.Lock()238 t.operationMu.Lock()
217 defer t.operationMu.Unlock()239 defer t.operationMu.Unlock()
218 t.closing = true240 t.closing = true
219- if t.operationCount == 0 {241+ var wait operationWait
220- return nil242+ if t.operationCount[operationSend] != 0 {
243+ wait.send = t.operationsDone[operationSend]
244+ }
245+ if t.operationCount[operationReceive] != 0 {
246+ wait.receive = t.operationsDone[operationReceive]
221 }247 }
222- return t.operationsDone248+ return wait
223 }249 }
224 250
225 type processWaiter struct {251 type processWaiter struct {
M internal/adapters/sshstdio/transport_observability_test.go +59 -0
@@ -126,6 +126,65 @@ func assertSSHReceiveFailurePair(t *testing.T, path string) {
126126 }
127127 }
128128
129+func TestTransportObservabilitySSHStdioCloseDoesNotWaitForUnownedReceive(t *testing.T) {
130+ tracePath := t.TempDir() + "/ssh-unowned-close.jsonl"
131+ observer, closer, err := observability.NewJSONL(tracePath, sshRuntimeClock{now: time.Unix(0, 313)}, "ssh-process")
132+ if err != nil {
133+ t.Fatalf("NewJSONL() error = %v", err)
134+ }
135+ reporter := ports.NewSerializedRuntimeObserver(observer, 64)
136+ defer reporter.Close()
137+ reader := &sshUnownedBlockedReader{entered: make(chan struct{}), release: make(chan struct{})}
138+ t.Cleanup(reader.unblock)
139+ transport := NewTransport(reader, &bytes.Buffer{}, nil, WithRuntimeObserver(reporter))
140+ recvDone := make(chan error, 1)
141+ go func() { _, err := transport.Recv(); recvDone <- err }()
142+ <-reader.entered
143+
144+ closeDone := make(chan error, 1)
145+ go func() { closeDone <- transport.Close() }()
146+ select {
147+ case err := <-closeDone:
148+ if err != nil {
149+ t.Fatalf("Close() error = %v", err)
150+ }
151+ case <-time.After(time.Second):
152+ t.Fatal("Close() waited for an unowned blocked reader")
153+ }
154+
155+ reader.unblock()
156+ select {
157+ case err := <-recvDone:
158+ if err == nil {
159+ t.Fatal("Recv() error = nil after releasing blocked reader")
160+ }
161+ case <-time.After(time.Second):
162+ t.Fatal("Recv() did not finish after releasing blocked reader")
163+ }
164+ reporter.Close()
165+ if err := closer.Close(); err != nil {
166+ t.Fatalf("trace Close() error = %v", err)
167+ }
168+ assertSSHReceiveFailurePair(t, tracePath)
169+}
170+
171+type sshUnownedBlockedReader struct {
172+ entered chan struct{}
173+ release chan struct{}
174+ enteredOnce sync.Once
175+ releaseOnce sync.Once
176+}
177+
178+func (r *sshUnownedBlockedReader) Read([]byte) (int, error) {
179+ r.enteredOnce.Do(func() { close(r.entered) })
180+ <-r.release
181+ return 0, errors.New("reader released")
182+}
183+
184+func (r *sshUnownedBlockedReader) unblock() {
185+ r.releaseOnce.Do(func() { close(r.release) })
186+}
187+
129188 func TestTransportObservabilitySSHStdioPreservesCarriage(t *testing.T) {
130189 tracePath := t.TempDir() + "/ssh.jsonl"
131190 observer, closer, err := observability.NewJSONL(tracePath, sshRuntimeClock{now: time.Unix(0, 307)}, "ssh-process")
@@ -126,6 +126,65 @@ func assertSSHReceiveFailurePair(t *testing.T, path string) {
126 }126 }
127 }127 }
128 128
129+func TestTransportObservabilitySSHStdioCloseDoesNotWaitForUnownedReceive(t *testing.T) {
130+ tracePath := t.TempDir() + "/ssh-unowned-close.jsonl"
131+ observer, closer, err := observability.NewJSONL(tracePath, sshRuntimeClock{now: time.Unix(0, 313)}, "ssh-process")
132+ if err != nil {
133+ t.Fatalf("NewJSONL() error = %v", err)
134+ }
135+ reporter := ports.NewSerializedRuntimeObserver(observer, 64)
136+ defer reporter.Close()
137+ reader := &sshUnownedBlockedReader{entered: make(chan struct{}), release: make(chan struct{})}
138+ t.Cleanup(reader.unblock)
139+ transport := NewTransport(reader, &bytes.Buffer{}, nil, WithRuntimeObserver(reporter))
140+ recvDone := make(chan error, 1)
141+ go func() { _, err := transport.Recv(); recvDone <- err }()
142+ <-reader.entered
143+
144+ closeDone := make(chan error, 1)
145+ go func() { closeDone <- transport.Close() }()
146+ select {
147+ case err := <-closeDone:
148+ if err != nil {
149+ t.Fatalf("Close() error = %v", err)
150+ }
151+ case <-time.After(time.Second):
152+ t.Fatal("Close() waited for an unowned blocked reader")
153+ }
154+
155+ reader.unblock()
156+ select {
157+ case err := <-recvDone:
158+ if err == nil {
159+ t.Fatal("Recv() error = nil after releasing blocked reader")
160+ }
161+ case <-time.After(time.Second):
162+ t.Fatal("Recv() did not finish after releasing blocked reader")
163+ }
164+ reporter.Close()
165+ if err := closer.Close(); err != nil {
166+ t.Fatalf("trace Close() error = %v", err)
167+ }
168+ assertSSHReceiveFailurePair(t, tracePath)
169+}
170+
171+type sshUnownedBlockedReader struct {
172+ entered chan struct{}
173+ release chan struct{}
174+ enteredOnce sync.Once
175+ releaseOnce sync.Once
176+}
177+
178+func (r *sshUnownedBlockedReader) Read([]byte) (int, error) {
179+ r.enteredOnce.Do(func() { close(r.entered) })
180+ <-r.release
181+ return 0, errors.New("reader released")
182+}
183+
184+func (r *sshUnownedBlockedReader) unblock() {
185+ r.releaseOnce.Do(func() { close(r.release) })
186+}
187+
129 func TestTransportObservabilitySSHStdioPreservesCarriage(t *testing.T) {188 func TestTransportObservabilitySSHStdioPreservesCarriage(t *testing.T) {
130 tracePath := t.TempDir() + "/ssh.jsonl"189 tracePath := t.TempDir() + "/ssh.jsonl"
131 observer, closer, err := observability.NewJSONL(tracePath, sshRuntimeClock{now: time.Unix(0, 307)}, "ssh-process")190 observer, closer, err := observability.NewJSONL(tracePath, sshRuntimeClock{now: time.Unix(0, 307)}, "ssh-process")
M internal/ports/wire.go +26 -10
@@ -26,6 +26,7 @@ var errShortPayload = errors.New("ports: payload too short")
2626 var errTrailingBytes = errors.New("ports: unexpected trailing bytes in payload")
2727
2828 var errInvalidBoolean = errors.New("ports: invalid boolean flag")
29+var errInvalidEnum = errors.New("ports: invalid enum value")
2930
3031 const (
3132 outputHeaderLen = 5*8 + 2*2 + 1
@@ -170,6 +171,15 @@ func validClientNoticeAction(action uint8) bool {
170171 }
171172 }
172173
174+func validDetachedReason(reason uint8) bool {
175+ switch reason {
176+ case ReasonDetach, ReasonSessionKilled, ReasonServerShutdown, ReasonReplaced:
177+ return true
178+ default:
179+ return false
180+ }
181+}
182+
173183 // Detach asks the daemon to detach the current client without killing the
174184 // session.
175185 type Detach struct{}
@@ -817,6 +827,9 @@ func UnmarshalTheme(b []byte) (Theme, error) {
817827 if err != nil {
818828 return Theme{}, err
819829 }
830+ if flags&^uint8(0x1f) != 0 {
831+ return Theme{}, errInvalidEnum
832+ }
820833 fgR, err := r.getUint8()
821834 if err != nil {
822835 return Theme{}, err
@@ -994,11 +1007,11 @@ func UnmarshalWelcome(b []byte) (Welcome, error) {
9941007 if m.SessionName, err = r.getString(); err != nil {
9951008 return Welcome{}, err
9961009 }
997- eph, err := r.getUint8()
998- if err != nil {
1010+ var ephemeral bool
1011+ if ephemeral, err = r.getBool(); err != nil {
9991012 return Welcome{}, err
10001013 }
1001- m.Ephemeral = eph != 0
1014+ m.Ephemeral = ephemeral
10021015 if m.ResumeToken, err = r.getUint64(); err != nil {
10031016 return Welcome{}, err
10041017 }
@@ -1185,6 +1198,9 @@ func UnmarshalDetached(b []byte) (Detached, error) {
11851198 if err != nil {
11861199 return Detached{}, err
11871200 }
1201+ if !validDetachedReason(reason) {
1202+ return Detached{}, errInvalidEnum
1203+ }
11881204 if err := r.done(); err != nil {
11891205 return Detached{}, err
11901206 }
@@ -1312,9 +1328,9 @@ func UnmarshalKill(b []byte) (Kill, error) {
13121328 if err != nil {
13131329 return Kill{}, err
13141330 }
1315- var all uint8
1331+ var all bool
13161332 if len(r.b) > 0 {
1317- all, err = r.getUint8()
1333+ all, err = r.getBool()
13181334 if err != nil {
13191335 return Kill{}, err
13201336 }
@@ -1322,7 +1338,7 @@ func UnmarshalKill(b []byte) (Kill, error) {
13221338 if err := r.done(); err != nil {
13231339 return Kill{}, err
13241340 }
1325- return Kill{Name: name, All: all != 0}, nil
1341+ return Kill{Name: name, All: all}, nil
13261342 }
13271343
13281344 // MarshalSessions encodes m into a Sessions message payload: a uint16 count
@@ -1365,19 +1381,19 @@ func UnmarshalSessions(b []byte) (Sessions, error) {
13651381 if s.Name, err = r.getString(); err != nil {
13661382 return Sessions{}, err
13671383 }
1368- eph, err := r.getUint8()
1384+ eph, err := r.getBool()
13691385 if err != nil {
13701386 return Sessions{}, err
13711387 }
1372- s.Ephemeral = eph != 0
1388+ s.Ephemeral = eph
13731389 if s.Tabs, err = r.getUint16(); err != nil {
13741390 return Sessions{}, err
13751391 }
1376- att, err := r.getUint8()
1392+ att, err := r.getBool()
13771393 if err != nil {
13781394 return Sessions{}, err
13791395 }
1380- s.Attached = att != 0
1396+ s.Attached = att
13811397 state, err := r.getUint8()
13821398 if err != nil {
13831399 return Sessions{}, err
@@ -26,6 +26,7 @@ var errShortPayload = errors.New("ports: payload too short")
26 var errTrailingBytes = errors.New("ports: unexpected trailing bytes in payload")26 var errTrailingBytes = errors.New("ports: unexpected trailing bytes in payload")
27 27
28 var errInvalidBoolean = errors.New("ports: invalid boolean flag")28 var errInvalidBoolean = errors.New("ports: invalid boolean flag")
29+var errInvalidEnum = errors.New("ports: invalid enum value")
29 30
30 const (31 const (
31 outputHeaderLen = 5*8 + 2*2 + 132 outputHeaderLen = 5*8 + 2*2 + 1
@@ -170,6 +171,15 @@ func validClientNoticeAction(action uint8) bool {
170 }171 }
171 }172 }
172 173
174+func validDetachedReason(reason uint8) bool {
175+ switch reason {
176+ case ReasonDetach, ReasonSessionKilled, ReasonServerShutdown, ReasonReplaced:
177+ return true
178+ default:
179+ return false
180+ }
181+}
182+
173 // Detach asks the daemon to detach the current client without killing the183 // Detach asks the daemon to detach the current client without killing the
174 // session.184 // session.
175 type Detach struct{}185 type Detach struct{}
@@ -817,6 +827,9 @@ func UnmarshalTheme(b []byte) (Theme, error) {
817 if err != nil {827 if err != nil {
818 return Theme{}, err828 return Theme{}, err
819 }829 }
830+ if flags&^uint8(0x1f) != 0 {
831+ return Theme{}, errInvalidEnum
832+ }
820 fgR, err := r.getUint8()833 fgR, err := r.getUint8()
821 if err != nil {834 if err != nil {
822 return Theme{}, err835 return Theme{}, err
@@ -994,11 +1007,11 @@ func UnmarshalWelcome(b []byte) (Welcome, error) {
994 if m.SessionName, err = r.getString(); err != nil {1007 if m.SessionName, err = r.getString(); err != nil {
995 return Welcome{}, err1008 return Welcome{}, err
996 }1009 }
997- eph, err := r.getUint8()1010+ var ephemeral bool
998- if err != nil {1011+ if ephemeral, err = r.getBool(); err != nil {
999 return Welcome{}, err1012 return Welcome{}, err
1000 }1013 }
1001- m.Ephemeral = eph != 01014+ m.Ephemeral = ephemeral
1002 if m.ResumeToken, err = r.getUint64(); err != nil {1015 if m.ResumeToken, err = r.getUint64(); err != nil {
1003 return Welcome{}, err1016 return Welcome{}, err
1004 }1017 }
@@ -1185,6 +1198,9 @@ func UnmarshalDetached(b []byte) (Detached, error) {
1185 if err != nil {1198 if err != nil {
1186 return Detached{}, err1199 return Detached{}, err
1187 }1200 }
1201+ if !validDetachedReason(reason) {
1202+ return Detached{}, errInvalidEnum
1203+ }
1188 if err := r.done(); err != nil {1204 if err := r.done(); err != nil {
1189 return Detached{}, err1205 return Detached{}, err
1190 }1206 }
@@ -1312,9 +1328,9 @@ func UnmarshalKill(b []byte) (Kill, error) {
1312 if err != nil {1328 if err != nil {
1313 return Kill{}, err1329 return Kill{}, err
1314 }1330 }
1315- var all uint81331+ var all bool
1316 if len(r.b) > 0 {1332 if len(r.b) > 0 {
1317- all, err = r.getUint8()1333+ all, err = r.getBool()
1318 if err != nil {1334 if err != nil {
1319 return Kill{}, err1335 return Kill{}, err
1320 }1336 }
@@ -1322,7 +1338,7 @@ func UnmarshalKill(b []byte) (Kill, error) {
1322 if err := r.done(); err != nil {1338 if err := r.done(); err != nil {
1323 return Kill{}, err1339 return Kill{}, err
1324 }1340 }
1325- return Kill{Name: name, All: all != 0}, nil1341+ return Kill{Name: name, All: all}, nil
1326 }1342 }
1327 1343
1328 // MarshalSessions encodes m into a Sessions message payload: a uint16 count1344 // MarshalSessions encodes m into a Sessions message payload: a uint16 count
@@ -1365,19 +1381,19 @@ func UnmarshalSessions(b []byte) (Sessions, error) {
1365 if s.Name, err = r.getString(); err != nil {1381 if s.Name, err = r.getString(); err != nil {
1366 return Sessions{}, err1382 return Sessions{}, err
1367 }1383 }
1368- eph, err := r.getUint8()1384+ eph, err := r.getBool()
1369 if err != nil {1385 if err != nil {
1370 return Sessions{}, err1386 return Sessions{}, err
1371 }1387 }
1372- s.Ephemeral = eph != 01388+ s.Ephemeral = eph
1373 if s.Tabs, err = r.getUint16(); err != nil {1389 if s.Tabs, err = r.getUint16(); err != nil {
1374 return Sessions{}, err1390 return Sessions{}, err
1375 }1391 }
1376- att, err := r.getUint8()1392+ att, err := r.getBool()
1377 if err != nil {1393 if err != nil {
1378 return Sessions{}, err1394 return Sessions{}, err
1379 }1395 }
1380- s.Attached = att != 01396+ s.Attached = att
1381 state, err := r.getUint8()1397 state, err := r.getUint8()
1382 if err != nil {1398 if err != nil {
1383 return Sessions{}, err1399 return Sessions{}, err
M internal/ports/wire_final_test.go +59 -0
@@ -195,3 +195,62 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) {
195195 }
196196 }
197197 }
198+
199+func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) {
200+ meta, err := MarshalSessionMeta(SessionMeta{Tabs: []SessionTabMeta{{Name: "tab"}}})
201+ if err != nil {
202+ t.Fatal(err)
203+ }
204+ tests := []struct {
205+ name string
206+ payload []byte
207+ decode func([]byte) error
208+ }{
209+ {
210+ name: "hello boolean",
211+ payload: func() []byte {
212+ b := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})
213+ b[len(b)-6] = 2
214+ return b
215+ }(),
216+ decode: func(b []byte) error { _, err := UnmarshalHello(b); return err },
217+ },
218+ {
219+ name: "welcome boolean",
220+ payload: func() []byte {
221+ b := MarshalWelcome(Welcome{SessionID: "id"})
222+ b[6] = 2
223+ return b
224+ }(),
225+ decode: func(b []byte) error { _, err := UnmarshalWelcome(b); return err },
226+ },
227+ {name: "detached reason", payload: []byte{0xff}, decode: func(b []byte) error { _, err := UnmarshalDetached(b); return err }},
228+ {
229+ name: "kill boolean",
230+ payload: func() []byte {
231+ b := MarshalKill(Kill{All: true})
232+ b[len(b)-1] = 2
233+ return b
234+ }(),
235+ decode: func(b []byte) error { _, err := UnmarshalKill(b); return err },
236+ },
237+ {
238+ name: "sessions booleans",
239+ payload: func() []byte {
240+ b := MarshalSessions(Sessions{Sessions: []SessionInfo{{State: SessionRunning}}})
241+ b[4], b[7] = 2, 2
242+ return b
243+ }(),
244+ decode: func(b []byte) error { _, err := UnmarshalSessions(b); return err },
245+ },
246+ {name: "session metadata boolean", payload: func() []byte { b := append([]byte(nil), meta...); b[len(b)-1] = 2; return b }(), decode: func(b []byte) error { _, err := UnmarshalSessionMeta(b); return err }},
247+ {name: "theme flags", payload: func() []byte { b := make([]byte, 57); b[0] = 0x20; return b }(), decode: func(b []byte) error { _, err := UnmarshalTheme(b); return err }},
248+ }
249+ for _, tt := range tests {
250+ t.Run(tt.name, func(t *testing.T) {
251+ if err := tt.decode(tt.payload); err == nil {
252+ t.Fatal("decoder accepted unknown closed wire value")
253+ }
254+ })
255+ }
256+}
@@ -195,3 +195,62 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) {
195 }195 }
196 }196 }
197 }197 }
198+
199+func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) {
200+ meta, err := MarshalSessionMeta(SessionMeta{Tabs: []SessionTabMeta{{Name: "tab"}}})
201+ if err != nil {
202+ t.Fatal(err)
203+ }
204+ tests := []struct {
205+ name string
206+ payload []byte
207+ decode func([]byte) error
208+ }{
209+ {
210+ name: "hello boolean",
211+ payload: func() []byte {
212+ b := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})
213+ b[len(b)-6] = 2
214+ return b
215+ }(),
216+ decode: func(b []byte) error { _, err := UnmarshalHello(b); return err },
217+ },
218+ {
219+ name: "welcome boolean",
220+ payload: func() []byte {
221+ b := MarshalWelcome(Welcome{SessionID: "id"})
222+ b[6] = 2
223+ return b
224+ }(),
225+ decode: func(b []byte) error { _, err := UnmarshalWelcome(b); return err },
226+ },
227+ {name: "detached reason", payload: []byte{0xff}, decode: func(b []byte) error { _, err := UnmarshalDetached(b); return err }},
228+ {
229+ name: "kill boolean",
230+ payload: func() []byte {
231+ b := MarshalKill(Kill{All: true})
232+ b[len(b)-1] = 2
233+ return b
234+ }(),
235+ decode: func(b []byte) error { _, err := UnmarshalKill(b); return err },
236+ },
237+ {
238+ name: "sessions booleans",
239+ payload: func() []byte {
240+ b := MarshalSessions(Sessions{Sessions: []SessionInfo{{State: SessionRunning}}})
241+ b[4], b[7] = 2, 2
242+ return b
243+ }(),
244+ decode: func(b []byte) error { _, err := UnmarshalSessions(b); return err },
245+ },
246+ {name: "session metadata boolean", payload: func() []byte { b := append([]byte(nil), meta...); b[len(b)-1] = 2; return b }(), decode: func(b []byte) error { _, err := UnmarshalSessionMeta(b); return err }},
247+ {name: "theme flags", payload: func() []byte { b := make([]byte, 57); b[0] = 0x20; return b }(), decode: func(b []byte) error { _, err := UnmarshalTheme(b); return err }},
248+ }
249+ for _, tt := range tests {
250+ t.Run(tt.name, func(t *testing.T) {
251+ if err := tt.decode(tt.payload); err == nil {
252+ t.Fatal("decoder accepted unknown closed wire value")
253+ }
254+ })
255+ }
256+}
M internal/usecase/daemon/attachment_connection.go +5 -14
@@ -2,21 +2,12 @@ package daemon
22
33 import "github.com/bnema/vev/internal/ports"
44
5-// attachmentSessionCore normalizes nil interfaces and implementations that
6-// deliberately expose no attachment core.
7-func attachmentSessionCore(entry attachmentSession) *sessionCore {
8- if entry == nil {
9- return nil
10- }
11- return entry.core()
12-}
13-
145 // attachmentConnectionToken is the exact capability for one registered
156 // attachment connection incarnation. It is invalid as soon as any of the
167 // session, attachment membership, connection generation, transport link, or
178 // coordinator lease changes.
189 type attachmentConnectionToken struct {
19- sess attachmentSession
10+ sess *session
2011 ac *attachedClient
2112 generation uint64
2213 transport transportSnapshot
@@ -27,7 +18,7 @@ type attachmentConnectionToken struct {
2718 rebase bool
2819 }
2920
30-func attachmentToken(entry attachmentSession, ac *attachedClient, tr ports.Transport) attachmentConnectionToken {
21+func attachmentToken(entry *session, ac *attachedClient, tr ports.Transport) attachmentConnectionToken {
3122 if entry == nil || entry.core() == nil || ac == nil || tr == nil {
3223 return attachmentConnectionToken{}
3324 }
@@ -70,7 +61,7 @@ func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attach
7061 }
7162
7263 // current validates every immutable identity captured by the token. Membership
73-// is the sole authority; there is no owner, replacement, or compatibility distinction.
64+// is the sole authority; there is no owner, replacement, or compatibility role.
7465 func (t attachmentConnectionToken) current() bool {
7566 return t.sess != nil && t.ac != nil &&
7667 t.ac.connectionGeneration.Load() == t.generation &&
@@ -100,8 +91,8 @@ func (t attachmentConnectionToken) attachmentEffectCurrent() bool {
10091 return t.attachmentCurrent()
10192 }
10293
103-func beginAttachmentLeaseEffect(sess attachmentSession, ac *attachedClient, lease *attachmentLease) (*attachmentEffectTicket, bool) {
104- if sess == nil || ac == nil || lease == nil || lease.attachment != ac {
94+func beginAttachmentLeaseEffect(sess *session, ac *attachedClient, lease *attachmentLease) (*attachmentEffectTicket, bool) {
95+ if sess == nil || ac == nil || lease == nil {
10596 return nil, false
10697 }
10798 token := attachmentToken(sess, ac, ac.transport())
@@ -2,21 +2,12 @@ package daemon
2 2
3 import "github.com/bnema/vev/internal/ports"3 import "github.com/bnema/vev/internal/ports"
4 4
5-// attachmentSessionCore normalizes nil interfaces and implementations that
6-// deliberately expose no attachment core.
7-func attachmentSessionCore(entry attachmentSession) *sessionCore {
8- if entry == nil {
9- return nil
10- }
11- return entry.core()
12-}
13-
14 // attachmentConnectionToken is the exact capability for one registered5 // attachmentConnectionToken is the exact capability for one registered
15 // attachment connection incarnation. It is invalid as soon as any of the6 // attachment connection incarnation. It is invalid as soon as any of the
16 // session, attachment membership, connection generation, transport link, or7 // session, attachment membership, connection generation, transport link, or
17 // coordinator lease changes.8 // coordinator lease changes.
18 type attachmentConnectionToken struct {9 type attachmentConnectionToken struct {
19- sess attachmentSession10+ sess *session
20 ac *attachedClient11 ac *attachedClient
21 generation uint6412 generation uint64
22 transport transportSnapshot13 transport transportSnapshot
@@ -27,7 +18,7 @@ type attachmentConnectionToken struct {
27 rebase bool18 rebase bool
28 }19 }
29 20
30-func attachmentToken(entry attachmentSession, ac *attachedClient, tr ports.Transport) attachmentConnectionToken {21+func attachmentToken(entry *session, ac *attachedClient, tr ports.Transport) attachmentConnectionToken {
31 if entry == nil || entry.core() == nil || ac == nil || tr == nil {22 if entry == nil || entry.core() == nil || ac == nil || tr == nil {
32 return attachmentConnectionToken{}23 return attachmentConnectionToken{}
33 }24 }
@@ -70,7 +61,7 @@ func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attach
70 }61 }
71 62
72 // current validates every immutable identity captured by the token. Membership63 // current validates every immutable identity captured by the token. Membership
73-// is the sole authority; there is no owner, replacement, or compatibility distinction.64+// is the sole authority; there is no owner, replacement, or compatibility role.
74 func (t attachmentConnectionToken) current() bool {65 func (t attachmentConnectionToken) current() bool {
75 return t.sess != nil && t.ac != nil &&66 return t.sess != nil && t.ac != nil &&
76 t.ac.connectionGeneration.Load() == t.generation &&67 t.ac.connectionGeneration.Load() == t.generation &&
@@ -100,8 +91,8 @@ func (t attachmentConnectionToken) attachmentEffectCurrent() bool {
100 return t.attachmentCurrent()91 return t.attachmentCurrent()
101 }92 }
102 93
103-func beginAttachmentLeaseEffect(sess attachmentSession, ac *attachedClient, lease *attachmentLease) (*attachmentEffectTicket, bool) {94+func beginAttachmentLeaseEffect(sess *session, ac *attachedClient, lease *attachmentLease) (*attachmentEffectTicket, bool) {
104- if sess == nil || ac == nil || lease == nil || lease.attachment != ac {95+ if sess == nil || ac == nil || lease == nil {
105 return nil, false96 return nil, false
106 }97 }
107 token := attachmentToken(sess, ac, ac.transport())98 token := attachmentToken(sess, ac, ac.transport())
M internal/usecase/daemon/attachment_effect_gate.go +1 -1
@@ -18,7 +18,7 @@ const (
1818 // effect admission. It deliberately contains no independently mutable state:
1919 // a ticket is admitted only when every field matches the frame's token.
2020 type attachmentCapability struct {
21- sess attachmentSession
21+ sess *session
2222 generation uint64
2323 transport transportSnapshot
2424 lease *attachmentLease
@@ -18,7 +18,7 @@ const (
18 // effect admission. It deliberately contains no independently mutable state:18 // effect admission. It deliberately contains no independently mutable state:
19 // a ticket is admitted only when every field matches the frame's token.19 // a ticket is admitted only when every field matches the frame's token.
20 type attachmentCapability struct {20 type attachmentCapability struct {
21- sess attachmentSession21+ sess *session
22 generation uint6422 generation uint64
23 transport transportSnapshot23 transport transportSnapshot
24 lease *attachmentLease24 lease *attachmentLease
M internal/usecase/daemon/attachment_registry.go +41 -44
@@ -23,13 +23,11 @@ type attachmentView struct {
2323 revision uint64
2424 }
2525
26-// viewInvalidation records the order in which attachment views observed one
27-// shared session mutation. The order is stable client-ID order, not map
28-// iteration or lock-acquisition order, which keeps concurrent attaches
29-// deterministic.
30-type viewInvalidation struct {
26+// attachmentViewSnapshot is the immutable value returned to callers that need
27+// to inspect attachment-local state without retaining its mutex.
28+type attachmentViewSnapshot struct {
3129 attachment *attachedClient
32- revision uint64
30+ view attachmentView
3331 }
3432
3533 func (ac *attachedClient) viewSnapshot() attachmentView {
@@ -96,7 +94,7 @@ func (c *sessionCore) registerAttachmentLocked(ac *attachedClient) bool {
9694 return true
9795 }
9896
99-func registerAttachmentSessionLocked(entry attachmentSession, ac *attachedClient) bool {
97+func registerAttachmentSessionLocked(entry *session, ac *attachedClient) bool {
10098 if entry == nil || entry.core() == nil {
10199 return false
102100 }
@@ -138,7 +136,7 @@ func (c *sessionCore) unregisterAttachmentLocked(ac *attachedClient) bool {
138136 return true
139137 }
140138
141-func unregisterAttachmentSessionLocked(entry attachmentSession, ac *attachedClient) bool {
139+func unregisterAttachmentSessionLocked(entry *session, ac *attachedClient) bool {
142140 if entry == nil || entry.core() == nil {
143141 return false
144142 }
@@ -156,26 +154,26 @@ func (s *session) unregisterAttachment(ac *attachedClient) bool {
156154 return s.unregisterAttachmentLocked(ac)
157155 }
158156
159-// snapshotAttachmentsLocked returns a stable registration-ordered snapshot.
160-// Callers hold s.mu; returned attachments may be used after unlocking.
161-func (c *sessionCore) snapshotAttachmentsLocked() []*attachedClient {
162- if c == nil || len(c.attachments) == 0 {
157+func (s *session) snapshotAttachmentsLocked() []*attachedClient {
158+ if s == nil || len(s.attachments) == 0 {
163159 return nil
164160 }
165- out := make([]*attachedClient, 0, len(c.attachments))
166- for ac := range c.attachments {
161+ out := make([]*attachedClient, 0, len(s.attachments))
162+ for ac := range s.attachments {
167163 out = append(out, ac)
168164 }
169165 slices.SortStableFunc(out, func(a, b *attachedClient) int {
166+ // ClientID is the stable wire identity, so concurrent registration order
167+ // cannot affect snapshots or invalidation order.
170168 if id := bytes.Compare(a.clientID[:], b.clientID[:]); id != 0 {
171169 return id
172170 }
173- return cmp.Compare(c.attachmentOrder[a], c.attachmentOrder[b])
171+ return cmp.Compare(s.attachmentOrder[a], s.attachmentOrder[b])
174172 })
175173 return out
176174 }
177175
178-func attachmentRegisteredLocked(entry attachmentSession, ac *attachedClient) bool {
176+func attachmentRegisteredLocked(entry *session, ac *attachedClient) bool {
179177 if entry == nil || ac == nil || entry.core() == nil {
180178 return false
181179 }
@@ -183,7 +181,7 @@ func attachmentRegisteredLocked(entry attachmentSession, ac *attachedClient) boo
183181 return ok
184182 }
185183
186-func attachmentRegistered(entry attachmentSession, ac *attachedClient) bool {
184+func attachmentRegistered(entry *session, ac *attachedClient) bool {
187185 if entry == nil || ac == nil || entry.core() == nil {
188186 return false
189187 }
@@ -213,16 +211,6 @@ func (s *session) attachmentViewsTabLocked(tb *tab) bool {
213211 return false
214212 }
215213
216-func snapshotAttachmentSession(entry attachmentSession) []*attachedClient {
217- if entry == nil || entry.core() == nil {
218- return nil
219- }
220- core := entry.core()
221- core.mu.Lock()
222- defer core.mu.Unlock()
223- return core.snapshotAttachmentsLocked()
224-}
225-
226214 func (s *session) snapshotAttachments() []*attachedClient {
227215 if s == nil {
228216 return nil
@@ -232,6 +220,18 @@ func (s *session) snapshotAttachments() []*attachedClient {
232220 return s.snapshotAttachmentsLocked()
233221 }
234222
223+func (s *session) snapshotAttachmentViews() []attachmentViewSnapshot {
224+ attachments := s.snapshotAttachments()
225+ if len(attachments) == 0 {
226+ return nil
227+ }
228+ views := make([]attachmentViewSnapshot, 0, len(attachments))
229+ for _, ac := range attachments {
230+ views = append(views, attachmentViewSnapshot{attachment: ac, view: ac.viewSnapshot()})
231+ }
232+ return views
233+}
234+
235235 // repairAttachmentViewLocked validates stable targets and chooses the nearest
236236 // surviving target when a tab or pane was removed. Caller holds s.mu.
237237 func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachmentView) attachmentView {
@@ -279,10 +279,12 @@ func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachment
279279 } else {
280280 view.paneID = ""
281281 }
282+ targetTab.mu.Unlock()
282283 if view.windowRows <= 0 {
284+ targetTab.mu.Lock()
283285 view.windowRows = targetTab.size.Rows
286+ targetTab.mu.Unlock()
284287 }
285- targetTab.mu.Unlock()
286288 if view.windowTop < 0 {
287289 view.windowTop = 0
288290 }
@@ -347,17 +349,13 @@ func (s *session) prepareAttachmentViewsForRemovedTabLocked(removed *tab, index
347349
348350 // invalidateViewsLocked orders all per-attachment invalidations behind the
349351 // shared session mutation. Caller holds s.mu and dispatchMu.
350-func (s *session) invalidateViewsLocked() []viewInvalidation {
351- attachments := s.snapshotAttachmentsLocked()
352- invalidations := make([]viewInvalidation, 0, len(attachments))
353- for _, ac := range attachments {
352+func (s *session) invalidateViewsLocked() {
353+ for _, ac := range s.snapshotAttachmentsLocked() {
354354 view := ac.viewSnapshot()
355355 repaired := s.repairAttachmentViewLocked(ac, view)
356356 repaired.revision++
357357 ac.publishView(repaired)
358- invalidations = append(invalidations, viewInvalidation{attachment: ac, revision: repaired.revision})
359358 }
360- return invalidations
361359 }
362360
363361 // runMutation is the one admission boundary for shared session mutations.
@@ -378,6 +376,7 @@ func (s *session) tabForAttachment(ac *attachedClient) *tab {
378376 if s == nil || ac == nil {
379377 return nil
380378 }
379+ s.repairAttachmentView(ac)
381380 s.mu.Lock()
382381 defer s.mu.Unlock()
383382 if _, ok := s.attachments[ac]; !ok {
@@ -578,26 +577,24 @@ func (s *session) switchAttachmentRelativeForDispatch(ac *attachedClient, delta
578577
579578 // repairAttachmentViews repairs all live attachment targets after a shared
580579 // topology mutation.
581-func (s *session) repairAttachmentViews() []viewInvalidation {
580+func (s *session) repairAttachmentViews() {
582581 if s == nil {
583- return nil
582+ return
584583 }
585- var invalidations []viewInvalidation
586584 _ = s.runMutation(func() error {
587585 s.mu.Lock()
588- invalidations = s.invalidateViewsLocked()
586+ s.invalidateViewsLocked()
589587 s.mu.Unlock()
590588 return nil
591589 })
592- return invalidations
593590 }
594591
595592 // repairAttachmentViewsLocked repairs all live attachment targets after a tab
596-// or pane mutation. Caller holds dispatchMu and s.mu; it is intended to run at
597-// the same mutation linearization point as the shared membership change.
598-func (s *session) repairAttachmentViewsLocked() []viewInvalidation {
593+// or pane mutation. It is intended to run at the same mutation linearization
594+// point as the shared membership change.
595+func (s *session) repairAttachmentViewsLocked() {
599596 if s == nil {
600- return nil
597+ return
601598 }
602- return s.invalidateViewsLocked()
599+ s.invalidateViewsLocked()
603600 }
@@ -23,13 +23,11 @@ type attachmentView struct {
23 revision uint6423 revision uint64
24 }24 }
25 25
26-// viewInvalidation records the order in which attachment views observed one26+// attachmentViewSnapshot is the immutable value returned to callers that need
27-// shared session mutation. The order is stable client-ID order, not map27+// to inspect attachment-local state without retaining its mutex.
28-// iteration or lock-acquisition order, which keeps concurrent attaches28+type attachmentViewSnapshot struct {
29-// deterministic.
30-type viewInvalidation struct {
31 attachment *attachedClient29 attachment *attachedClient
32- revision uint6430+ view attachmentView
33 }31 }
34 32
35 func (ac *attachedClient) viewSnapshot() attachmentView {33 func (ac *attachedClient) viewSnapshot() attachmentView {
@@ -96,7 +94,7 @@ func (c *sessionCore) registerAttachmentLocked(ac *attachedClient) bool {
96 return true94 return true
97 }95 }
98 96
99-func registerAttachmentSessionLocked(entry attachmentSession, ac *attachedClient) bool {97+func registerAttachmentSessionLocked(entry *session, ac *attachedClient) bool {
100 if entry == nil || entry.core() == nil {98 if entry == nil || entry.core() == nil {
101 return false99 return false
102 }100 }
@@ -138,7 +136,7 @@ func (c *sessionCore) unregisterAttachmentLocked(ac *attachedClient) bool {
138 return true136 return true
139 }137 }
140 138
141-func unregisterAttachmentSessionLocked(entry attachmentSession, ac *attachedClient) bool {139+func unregisterAttachmentSessionLocked(entry *session, ac *attachedClient) bool {
142 if entry == nil || entry.core() == nil {140 if entry == nil || entry.core() == nil {
143 return false141 return false
144 }142 }
@@ -156,26 +154,26 @@ func (s *session) unregisterAttachment(ac *attachedClient) bool {
156 return s.unregisterAttachmentLocked(ac)154 return s.unregisterAttachmentLocked(ac)
157 }155 }
158 156
159-// snapshotAttachmentsLocked returns a stable registration-ordered snapshot.157+func (s *session) snapshotAttachmentsLocked() []*attachedClient {
160-// Callers hold s.mu; returned attachments may be used after unlocking.158+ if s == nil || len(s.attachments) == 0 {
161-func (c *sessionCore) snapshotAttachmentsLocked() []*attachedClient {
162- if c == nil || len(c.attachments) == 0 {
163 return nil159 return nil
164 }160 }
165- out := make([]*attachedClient, 0, len(c.attachments))161+ out := make([]*attachedClient, 0, len(s.attachments))
166- for ac := range c.attachments {162+ for ac := range s.attachments {
167 out = append(out, ac)163 out = append(out, ac)
168 }164 }
169 slices.SortStableFunc(out, func(a, b *attachedClient) int {165 slices.SortStableFunc(out, func(a, b *attachedClient) int {
166+ // ClientID is the stable wire identity, so concurrent registration order
167+ // cannot affect snapshots or invalidation order.
170 if id := bytes.Compare(a.clientID[:], b.clientID[:]); id != 0 {168 if id := bytes.Compare(a.clientID[:], b.clientID[:]); id != 0 {
171 return id169 return id
172 }170 }
173- return cmp.Compare(c.attachmentOrder[a], c.attachmentOrder[b])171+ return cmp.Compare(s.attachmentOrder[a], s.attachmentOrder[b])
174 })172 })
175 return out173 return out
176 }174 }
177 175
178-func attachmentRegisteredLocked(entry attachmentSession, ac *attachedClient) bool {176+func attachmentRegisteredLocked(entry *session, ac *attachedClient) bool {
179 if entry == nil || ac == nil || entry.core() == nil {177 if entry == nil || ac == nil || entry.core() == nil {
180 return false178 return false
181 }179 }
@@ -183,7 +181,7 @@ func attachmentRegisteredLocked(entry attachmentSession, ac *attachedClient) boo
183 return ok181 return ok
184 }182 }
185 183
186-func attachmentRegistered(entry attachmentSession, ac *attachedClient) bool {184+func attachmentRegistered(entry *session, ac *attachedClient) bool {
187 if entry == nil || ac == nil || entry.core() == nil {185 if entry == nil || ac == nil || entry.core() == nil {
188 return false186 return false
189 }187 }
@@ -213,16 +211,6 @@ func (s *session) attachmentViewsTabLocked(tb *tab) bool {
213 return false211 return false
214 }212 }
215 213
216-func snapshotAttachmentSession(entry attachmentSession) []*attachedClient {
217- if entry == nil || entry.core() == nil {
218- return nil
219- }
220- core := entry.core()
221- core.mu.Lock()
222- defer core.mu.Unlock()
223- return core.snapshotAttachmentsLocked()
224-}
225-
226 func (s *session) snapshotAttachments() []*attachedClient {214 func (s *session) snapshotAttachments() []*attachedClient {
227 if s == nil {215 if s == nil {
228 return nil216 return nil
@@ -232,6 +220,18 @@ func (s *session) snapshotAttachments() []*attachedClient {
232 return s.snapshotAttachmentsLocked()220 return s.snapshotAttachmentsLocked()
233 }221 }
234 222
223+func (s *session) snapshotAttachmentViews() []attachmentViewSnapshot {
224+ attachments := s.snapshotAttachments()
225+ if len(attachments) == 0 {
226+ return nil
227+ }
228+ views := make([]attachmentViewSnapshot, 0, len(attachments))
229+ for _, ac := range attachments {
230+ views = append(views, attachmentViewSnapshot{attachment: ac, view: ac.viewSnapshot()})
231+ }
232+ return views
233+}
234+
235 // repairAttachmentViewLocked validates stable targets and chooses the nearest235 // repairAttachmentViewLocked validates stable targets and chooses the nearest
236 // surviving target when a tab or pane was removed. Caller holds s.mu.236 // surviving target when a tab or pane was removed. Caller holds s.mu.
237 func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachmentView) attachmentView {237 func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachmentView) attachmentView {
@@ -279,10 +279,12 @@ func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachment
279 } else {279 } else {
280 view.paneID = ""280 view.paneID = ""
281 }281 }
282+ targetTab.mu.Unlock()
282 if view.windowRows <= 0 {283 if view.windowRows <= 0 {
284+ targetTab.mu.Lock()
283 view.windowRows = targetTab.size.Rows285 view.windowRows = targetTab.size.Rows
286+ targetTab.mu.Unlock()
284 }287 }
285- targetTab.mu.Unlock()
286 if view.windowTop < 0 {288 if view.windowTop < 0 {
287 view.windowTop = 0289 view.windowTop = 0
288 }290 }
@@ -347,17 +349,13 @@ func (s *session) prepareAttachmentViewsForRemovedTabLocked(removed *tab, index
347 349
348 // invalidateViewsLocked orders all per-attachment invalidations behind the350 // invalidateViewsLocked orders all per-attachment invalidations behind the
349 // shared session mutation. Caller holds s.mu and dispatchMu.351 // shared session mutation. Caller holds s.mu and dispatchMu.
350-func (s *session) invalidateViewsLocked() []viewInvalidation {352+func (s *session) invalidateViewsLocked() {
351- attachments := s.snapshotAttachmentsLocked()353+ for _, ac := range s.snapshotAttachmentsLocked() {
352- invalidations := make([]viewInvalidation, 0, len(attachments))
353- for _, ac := range attachments {
354 view := ac.viewSnapshot()354 view := ac.viewSnapshot()
355 repaired := s.repairAttachmentViewLocked(ac, view)355 repaired := s.repairAttachmentViewLocked(ac, view)
356 repaired.revision++356 repaired.revision++
357 ac.publishView(repaired)357 ac.publishView(repaired)
358- invalidations = append(invalidations, viewInvalidation{attachment: ac, revision: repaired.revision})
359 }358 }
360- return invalidations
361 }359 }
362 360
363 // runMutation is the one admission boundary for shared session mutations.361 // runMutation is the one admission boundary for shared session mutations.
@@ -378,6 +376,7 @@ func (s *session) tabForAttachment(ac *attachedClient) *tab {
378 if s == nil || ac == nil {376 if s == nil || ac == nil {
379 return nil377 return nil
380 }378 }
379+ s.repairAttachmentView(ac)
381 s.mu.Lock()380 s.mu.Lock()
382 defer s.mu.Unlock()381 defer s.mu.Unlock()
383 if _, ok := s.attachments[ac]; !ok {382 if _, ok := s.attachments[ac]; !ok {
@@ -578,26 +577,24 @@ func (s *session) switchAttachmentRelativeForDispatch(ac *attachedClient, delta
578 577
579 // repairAttachmentViews repairs all live attachment targets after a shared578 // repairAttachmentViews repairs all live attachment targets after a shared
580 // topology mutation.579 // topology mutation.
581-func (s *session) repairAttachmentViews() []viewInvalidation {580+func (s *session) repairAttachmentViews() {
582 if s == nil {581 if s == nil {
583- return nil582+ return
584 }583 }
585- var invalidations []viewInvalidation
586 _ = s.runMutation(func() error {584 _ = s.runMutation(func() error {
587 s.mu.Lock()585 s.mu.Lock()
588- invalidations = s.invalidateViewsLocked()586+ s.invalidateViewsLocked()
589 s.mu.Unlock()587 s.mu.Unlock()
590 return nil588 return nil
591 })589 })
592- return invalidations
593 }590 }
594 591
595 // repairAttachmentViewsLocked repairs all live attachment targets after a tab592 // repairAttachmentViewsLocked repairs all live attachment targets after a tab
596-// or pane mutation. Caller holds dispatchMu and s.mu; it is intended to run at593+// or pane mutation. It is intended to run at the same mutation linearization
597-// the same mutation linearization point as the shared membership change.594+// point as the shared membership change.
598-func (s *session) repairAttachmentViewsLocked() []viewInvalidation {595+func (s *session) repairAttachmentViewsLocked() {
599 if s == nil {596 if s == nil {
600- return nil597+ return
601 }598 }
602- return s.invalidateViewsLocked()599+ s.invalidateViewsLocked()
603 }600 }
M internal/usecase/daemon/attachment_registry_test.go +10 -3
@@ -186,10 +186,17 @@ func TestAttachmentRegistryConcurrentRegistrationSnapshotIsStable(t *testing.T)
186186 t.Fatalf("snapshot[%d] id = %d, want %d", i, got[i].clientID[0], want)
187187 }
188188 }
189- invalidations := sess.repairAttachmentViews()
189+ ordered := sess.repairAttachmentViewsLockedForTest()
190190 for i, want := range []byte{1, 2, 3} {
191- if invalidations[i].attachment.clientID[0] != want {
192- t.Fatalf("invalidation[%d] id = %d, want %d", i, invalidations[i].attachment.clientID[0], want)
191+ if ordered[i].clientID[0] != want {
192+ t.Fatalf("invalidation[%d] id = %d, want %d", i, ordered[i].clientID[0], want)
193193 }
194194 }
195195 }
196+
197+func (s *session) repairAttachmentViewsLockedForTest() []*attachedClient {
198+ s.mu.Lock()
199+ defer s.mu.Unlock()
200+ s.repairAttachmentViewsLocked()
201+ return s.snapshotAttachmentsLocked()
202+}
@@ -186,10 +186,17 @@ func TestAttachmentRegistryConcurrentRegistrationSnapshotIsStable(t *testing.T)
186 t.Fatalf("snapshot[%d] id = %d, want %d", i, got[i].clientID[0], want)186 t.Fatalf("snapshot[%d] id = %d, want %d", i, got[i].clientID[0], want)
187 }187 }
188 }188 }
189- invalidations := sess.repairAttachmentViews()189+ ordered := sess.repairAttachmentViewsLockedForTest()
190 for i, want := range []byte{1, 2, 3} {190 for i, want := range []byte{1, 2, 3} {
191- if invalidations[i].attachment.clientID[0] != want {191+ if ordered[i].clientID[0] != want {
192- t.Fatalf("invalidation[%d] id = %d, want %d", i, invalidations[i].attachment.clientID[0], want)192+ t.Fatalf("invalidation[%d] id = %d, want %d", i, ordered[i].clientID[0], want)
193 }193 }
194 }194 }
195 }195 }
196+
197+func (s *session) repairAttachmentViewsLockedForTest() []*attachedClient {
198+ s.mu.Lock()
199+ defer s.mu.Unlock()
200+ s.repairAttachmentViewsLocked()
201+ return s.snapshotAttachmentsLocked()
202+}
M internal/usecase/daemon/attachment_render_test.go +70 -0
@@ -76,6 +76,76 @@ func TestAttachmentResizeKeepsSessionContentAndPeersFixed(t *testing.T) {
7676 require.Greater(t, second.output.currentEpoch(), secondEpoch)
7777 }
7878
79+func TestAttachmentFirstPaintDoesNotWaitForBlockedPeer(t *testing.T) {
80+ pty, releasePTY := newBlockingPTY(t)
81+ defer releasePTY()
82+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
83+ oldTransport := &blockedAttachmentTransport{entered: make(chan struct{}), release: make(chan struct{}), sends: make(chan ports.Frame, 8)}
84+ sess, old, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{1}}, oldTransport)
85+ require.NoError(t, err)
86+ newTransport := &blockedAttachmentTransport{entered: make(chan struct{}), release: make(chan struct{}), sends: make(chan ports.Frame, 8)}
87+ _, fresh, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{2}}, newTransport)
88+ require.NoError(t, err)
89+
90+ rc := sess.renderCoordinator()
91+ oldLease := rc.attachmentLease(old)
92+ freshLease := rc.attachmentLease(fresh)
93+ require.True(t, rc.markAttachmentReady(oldLease))
94+ oldTransport.blocked.Store(true)
95+ sess.tabs[0].focusedPane().screen.Write([]byte("slow peer"))
96+ require.True(t, rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"}))
97+ rc.fireCurrent(false)
98+ awaitTestCompletion(t, oldTransport.entered, "slow attachment did not begin its blocked output")
99+
100+ require.True(t, rc.markAttachmentReady(freshLease))
101+ token := sess.attachmentToken(fresh, newTransport)
102+ token.lease = freshLease
103+ painted := make(chan bool, 1)
104+ go func() { painted <- d.firstPaintForTransition(token) }()
105+ frame := awaitTestValue(t, newTransport.sends, "healthy attachment first paint was gated by slow peer")
106+ require.Equal(t, ports.MsgOutput, frame.Type)
107+ require.True(t, <-painted)
108+ close(oldTransport.release)
109+}
110+
111+func TestMultiAttachmentHandshakeFirstPaintNotGatedByBlockedPeer(t *testing.T) {
112+ pty, releasePTY := newBlockingPTY(t)
113+ defer releasePTY()
114+ clock := &signalClock{timers: make(chan *signalTimer, 16)}
115+ d := newTestDaemon(t, newFactory(t, pty), clock)
116+ oldTransport := &blockedAttachmentTransport{entered: make(chan struct{}), release: make(chan struct{}), sends: make(chan ports.Frame, 8)}
117+ sess, old, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{1}}, oldTransport)
118+ require.NoError(t, err)
119+ rc := sess.renderCoordinator()
120+ require.True(t, rc.markAttachmentReady(rc.attachmentLease(old)))
121+
122+ oldTransport.blocked.Store(true)
123+ sess.tabs[0].focusedPane().screen.Write([]byte("slow peer"))
124+ require.True(t, rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"}))
125+ fireDone := make(chan struct{})
126+ go func() {
127+ rc.fireCurrent(false)
128+ close(fireDone)
129+ }()
130+ awaitTestCompletion(t, oldTransport.entered, "slow attachment did not begin its blocked output")
131+
132+ hello := ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{2}}
133+ tr, sends, releaseConn := newConn(t, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
134+ defer releaseConn()
135+ handshakeDone := make(chan struct{})
136+ go func() {
137+ d.handleConn(tr)
138+ close(handshakeDone)
139+ }()
140+ awaitFrame(t, sends, ports.MsgWelcome)
141+ firstPaint := awaitFrame(t, sends, ports.MsgOutput)
142+ require.Equal(t, ports.MsgOutput, firstPaint.Type)
143+ releaseConn()
144+ awaitTestCompletion(t, handshakeDone, "multi-attachment handshake did not complete after first paint")
145+ close(oldTransport.release)
146+ awaitTestCompletion(t, fireDone, "slow peer output did not finish after release")
147+}
148+
79149 func TestAttachmentPaintFanoutDoesNotWaitForBlockedPeer(t *testing.T) {
80150 pty, releasePTY := newBlockingPTY(t)
81151 defer releasePTY()
@@ -76,6 +76,76 @@ func TestAttachmentResizeKeepsSessionContentAndPeersFixed(t *testing.T) {
76 require.Greater(t, second.output.currentEpoch(), secondEpoch)76 require.Greater(t, second.output.currentEpoch(), secondEpoch)
77 }77 }
78 78
79+func TestAttachmentFirstPaintDoesNotWaitForBlockedPeer(t *testing.T) {
80+ pty, releasePTY := newBlockingPTY(t)
81+ defer releasePTY()
82+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
83+ oldTransport := &blockedAttachmentTransport{entered: make(chan struct{}), release: make(chan struct{}), sends: make(chan ports.Frame, 8)}
84+ sess, old, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{1}}, oldTransport)
85+ require.NoError(t, err)
86+ newTransport := &blockedAttachmentTransport{entered: make(chan struct{}), release: make(chan struct{}), sends: make(chan ports.Frame, 8)}
87+ _, fresh, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{2}}, newTransport)
88+ require.NoError(t, err)
89+
90+ rc := sess.renderCoordinator()
91+ oldLease := rc.attachmentLease(old)
92+ freshLease := rc.attachmentLease(fresh)
93+ require.True(t, rc.markAttachmentReady(oldLease))
94+ oldTransport.blocked.Store(true)
95+ sess.tabs[0].focusedPane().screen.Write([]byte("slow peer"))
96+ require.True(t, rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"}))
97+ rc.fireCurrent(false)
98+ awaitTestCompletion(t, oldTransport.entered, "slow attachment did not begin its blocked output")
99+
100+ require.True(t, rc.markAttachmentReady(freshLease))
101+ token := sess.attachmentToken(fresh, newTransport)
102+ token.lease = freshLease
103+ painted := make(chan bool, 1)
104+ go func() { painted <- d.firstPaintForTransition(token) }()
105+ frame := awaitTestValue(t, newTransport.sends, "healthy attachment first paint was gated by slow peer")
106+ require.Equal(t, ports.MsgOutput, frame.Type)
107+ require.True(t, <-painted)
108+ close(oldTransport.release)
109+}
110+
111+func TestMultiAttachmentHandshakeFirstPaintNotGatedByBlockedPeer(t *testing.T) {
112+ pty, releasePTY := newBlockingPTY(t)
113+ defer releasePTY()
114+ clock := &signalClock{timers: make(chan *signalTimer, 16)}
115+ d := newTestDaemon(t, newFactory(t, pty), clock)
116+ oldTransport := &blockedAttachmentTransport{entered: make(chan struct{}), release: make(chan struct{}), sends: make(chan ports.Frame, 8)}
117+ sess, old, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{1}}, oldTransport)
118+ require.NoError(t, err)
119+ rc := sess.renderCoordinator()
120+ require.True(t, rc.markAttachmentReady(rc.attachmentLease(old)))
121+
122+ oldTransport.blocked.Store(true)
123+ sess.tabs[0].focusedPane().screen.Write([]byte("slow peer"))
124+ require.True(t, rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"}))
125+ fireDone := make(chan struct{})
126+ go func() {
127+ rc.fireCurrent(false)
128+ close(fireDone)
129+ }()
130+ awaitTestCompletion(t, oldTransport.entered, "slow attachment did not begin its blocked output")
131+
132+ hello := ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{2}}
133+ tr, sends, releaseConn := newConn(t, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
134+ defer releaseConn()
135+ handshakeDone := make(chan struct{})
136+ go func() {
137+ d.handleConn(tr)
138+ close(handshakeDone)
139+ }()
140+ awaitFrame(t, sends, ports.MsgWelcome)
141+ firstPaint := awaitFrame(t, sends, ports.MsgOutput)
142+ require.Equal(t, ports.MsgOutput, firstPaint.Type)
143+ releaseConn()
144+ awaitTestCompletion(t, handshakeDone, "multi-attachment handshake did not complete after first paint")
145+ close(oldTransport.release)
146+ awaitTestCompletion(t, fireDone, "slow peer output did not finish after release")
147+}
148+
79 func TestAttachmentPaintFanoutDoesNotWaitForBlockedPeer(t *testing.T) {149 func TestAttachmentPaintFanoutDoesNotWaitForBlockedPeer(t *testing.T) {
80 pty, releasePTY := newBlockingPTY(t)150 pty, releasePTY := newBlockingPTY(t)
81 defer releasePTY()151 defer releasePTY()
M internal/usecase/daemon/attachment_transition.go +4 -4
@@ -20,8 +20,8 @@ type attachmentLifecycleFence struct {
2020 }
2121
2222 type attachmentTransitionRequest struct {
23- source attachmentSession
24- target attachmentSession
23+ source *session
24+ target *session
2525 next *attachedClient
2626 expectedTransport transportSnapshot
2727 sourceToken *attachmentConnectionToken
@@ -44,7 +44,7 @@ type attachmentTransitionRequest struct {
4444 attachmentEffectsFrozen bool
4545 }
4646
47-func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source attachmentSession, req attachmentTransitionRequest) bool {
47+func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source *session, req attachmentTransitionRequest) bool {
4848 return token.sess == source && token.ac == req.next &&
4949 token.generation == req.next.connectionGeneration.Load() &&
5050 token.transport.transport == req.expectedTransport.transport &&
@@ -54,7 +54,7 @@ func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source
5454 // transitionSourceTokenCurrentLocked requires source.core().mu and, when a
5555 // lease exists, sourceCoordinator.mu. It is the canonical exact-connection
5656 // handoff check for client-originated navigation and lifecycle mutations.
57-func transitionSourceTokenCurrentLocked(token attachmentConnectionToken, source attachmentSession, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {
57+func transitionSourceTokenCurrentLocked(token attachmentConnectionToken, source *session, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {
5858 if token.sess != source || token.ac != req.next ||
5959 token.generation != req.next.connectionGeneration.Load() ||
6060 token.sess == nil || token.ac.currentAttachmentSession() != source ||
@@ -20,8 +20,8 @@ type attachmentLifecycleFence struct {
20 }20 }
21 21
22 type attachmentTransitionRequest struct {22 type attachmentTransitionRequest struct {
23- source attachmentSession23+ source *session
24- target attachmentSession24+ target *session
25 next *attachedClient25 next *attachedClient
26 expectedTransport transportSnapshot26 expectedTransport transportSnapshot
27 sourceToken *attachmentConnectionToken27 sourceToken *attachmentConnectionToken
@@ -44,7 +44,7 @@ type attachmentTransitionRequest struct {
44 attachmentEffectsFrozen bool44 attachmentEffectsFrozen bool
45 }45 }
46 46
47-func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source attachmentSession, req attachmentTransitionRequest) bool {47+func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source *session, req attachmentTransitionRequest) bool {
48 return token.sess == source && token.ac == req.next &&48 return token.sess == source && token.ac == req.next &&
49 token.generation == req.next.connectionGeneration.Load() &&49 token.generation == req.next.connectionGeneration.Load() &&
50 token.transport.transport == req.expectedTransport.transport &&50 token.transport.transport == req.expectedTransport.transport &&
@@ -54,7 +54,7 @@ func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source
54 // transitionSourceTokenCurrentLocked requires source.core().mu and, when a54 // transitionSourceTokenCurrentLocked requires source.core().mu and, when a
55 // lease exists, sourceCoordinator.mu. It is the canonical exact-connection55 // lease exists, sourceCoordinator.mu. It is the canonical exact-connection
56 // handoff check for client-originated navigation and lifecycle mutations.56 // handoff check for client-originated navigation and lifecycle mutations.
57-func transitionSourceTokenCurrentLocked(token attachmentConnectionToken, source attachmentSession, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {57+func transitionSourceTokenCurrentLocked(token attachmentConnectionToken, source *session, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {
58 if token.sess != source || token.ac != req.next ||58 if token.sess != source || token.ac != req.next ||
59 token.generation != req.next.connectionGeneration.Load() ||59 token.generation != req.next.connectionGeneration.Load() ||
60 token.sess == nil || token.ac.currentAttachmentSession() != source ||60 token.sess == nil || token.ac.currentAttachmentSession() != source ||
M internal/usecase/daemon/attachment_transition_publish.go +22 -27
@@ -50,16 +50,15 @@ func (d *Daemon) sourceAttachmentTokenCurrentFrozen(token attachmentConnectionTo
5050 return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
5151 }
5252
53-func transitionSourceTabCurrentLocked(source attachmentSession, expected *tab) bool {
53+func transitionSourceTabCurrentLocked(source *session, expected *tab) bool {
5454 return transitionSourceTabCurrentForRequestLocked(source, expected, false)
5555 }
5656
57-func transitionSourceTabCurrentForRequestLocked(source attachmentSession, expected *tab, transferExpected bool) bool {
57+func transitionSourceTabCurrentForRequestLocked(source *session, expected *tab, transferExpected bool) bool {
5858 if expected == nil {
5959 return true
6060 }
61- local, ok := localSession(source)
62- if !ok || !containsTab(local.tabs, expected) {
61+ if source == nil || !containsTab(source.tabs, expected) {
6362 return false
6463 }
6564 expected.mu.Lock()
@@ -81,7 +80,7 @@ func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (at
8180
8281 type attachmentPublication struct {
8382 req attachmentTransitionRequest
84- source attachmentSession
83+ source *session
8584 targetCoordinator *renderCoordinator
8685 sourceCoordinator *renderCoordinator
8786 releaseCoordinators func()
@@ -92,22 +91,21 @@ func (p *attachmentPublication) unlockCoordinators() {
9291 p.releaseCoordinators()
9392 }
9493
95-func attachmentLifecycleCurrentLocked(entry attachmentSession, fence *attachmentLifecycleFence) bool {
94+func attachmentLifecycleCurrentLocked(entry *session, fence *attachmentLifecycleFence) bool {
9695 if fence == nil {
9796 return true
9897 }
99- target, ok := localSession(entry)
100- if !ok {
98+ if entry == nil {
10199 return false
102100 }
103- if fence.checkCreatedAt && (target.name != fence.name || target.createdAt != fence.createdAt) {
101+ if fence.checkCreatedAt && (entry.name != fence.name || entry.createdAt != fence.createdAt) {
104102 return false
105103 }
106- if fence.checkIncarnation && target.incarnation != fence.incarnation {
104+ if fence.checkIncarnation && entry.incarnation != fence.incarnation {
107105 return false
108106 }
109- return !fence.checkTab || fence.tabIndex >= 0 && fence.tabIndex < len(target.tabs) &&
110- domain.TabStableID(target.tabs[fence.tabIndex].stableID) == fence.tabID
107+ return !fence.checkTab || fence.tabIndex >= 0 && fence.tabIndex < len(entry.tabs) &&
108+ domain.TabStableID(entry.tabs[fence.tabIndex].stableID) == fence.tabID
111109 }
112110
113111 // validateAttachmentTransitionPrelocked performs every fallible check before
@@ -174,18 +172,12 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
174172 // shared environment state as part of the same publication.
175173 func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool {
176174 req := publication.req
177- if req.activateTargetTab {
178- if target, ok := localSession(req.target); ok {
179- target.activateAttachmentViewLocked(req.next, req.targetTabIndex)
180- }
175+ if req.activateTargetTab && req.target != nil {
176+ req.target.activateAttachmentViewLocked(req.next, req.targetTabIndex)
181177 }
182- if req.copySourceEnvironment {
183- source, sourceOK := localSession(publication.source)
184- target, targetOK := localSession(req.target)
185- if sourceOK && targetOK {
186- target.terminal = source.terminal
187- target.env = copyEnvironment(source.env)
188- }
178+ if req.copySourceEnvironment && publication.source != nil && req.target != nil {
179+ req.target.terminal = publication.source.terminal
180+ req.target.env = copyEnvironment(publication.source.env)
189181 }
190182 return req.preserveAttachment
191183 }
@@ -259,9 +251,12 @@ func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionReques
259251 }
260252
261253 // lockAttachmentSessions gives every two-session transition one stable order.
262-func lockAttachmentSessions(a, b attachmentSession) func() {
263- aCore := attachmentSessionCore(a)
264- bCore := attachmentSessionCore(b)
254+func lockAttachmentSessions(a, b *session) func() {
255+ if a == nil || b == nil {
256+ return func() {}
257+ }
258+ aCore := a.core()
259+ bCore := b.core()
265260 if aCore == nil || bCore == nil {
266261 return func() {}
267262 }
@@ -283,7 +278,7 @@ func lockAttachmentSessions(a, b attachmentSession) func() {
283278
284279 // lockAttachmentCoordinators follows the same immutable session-ID order as
285280 // lockAttachmentSessions. Callers already hold ordered session locks.
286-func lockAttachmentCoordinators(a attachmentSession, aCoordinator *renderCoordinator, b attachmentSession, bCoordinator *renderCoordinator) func() {
281+func lockAttachmentCoordinators(a *session, aCoordinator *renderCoordinator, b *session, bCoordinator *renderCoordinator) func() {
287282 if aCoordinator == nil && bCoordinator == nil {
288283 return func() {}
289284 }
@@ -50,16 +50,15 @@ func (d *Daemon) sourceAttachmentTokenCurrentFrozen(token attachmentConnectionTo
50 return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)50 return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
51 }51 }
52 52
53-func transitionSourceTabCurrentLocked(source attachmentSession, expected *tab) bool {53+func transitionSourceTabCurrentLocked(source *session, expected *tab) bool {
54 return transitionSourceTabCurrentForRequestLocked(source, expected, false)54 return transitionSourceTabCurrentForRequestLocked(source, expected, false)
55 }55 }
56 56
57-func transitionSourceTabCurrentForRequestLocked(source attachmentSession, expected *tab, transferExpected bool) bool {57+func transitionSourceTabCurrentForRequestLocked(source *session, expected *tab, transferExpected bool) bool {
58 if expected == nil {58 if expected == nil {
59 return true59 return true
60 }60 }
61- local, ok := localSession(source)61+ if source == nil || !containsTab(source.tabs, expected) {
62- if !ok || !containsTab(local.tabs, expected) {
63 return false62 return false
64 }63 }
65 expected.mu.Lock()64 expected.mu.Lock()
@@ -81,7 +80,7 @@ func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (at
81 80
82 type attachmentPublication struct {81 type attachmentPublication struct {
83 req attachmentTransitionRequest82 req attachmentTransitionRequest
84- source attachmentSession83+ source *session
85 targetCoordinator *renderCoordinator84 targetCoordinator *renderCoordinator
86 sourceCoordinator *renderCoordinator85 sourceCoordinator *renderCoordinator
87 releaseCoordinators func()86 releaseCoordinators func()
@@ -92,22 +91,21 @@ func (p *attachmentPublication) unlockCoordinators() {
92 p.releaseCoordinators()91 p.releaseCoordinators()
93 }92 }
94 93
95-func attachmentLifecycleCurrentLocked(entry attachmentSession, fence *attachmentLifecycleFence) bool {94+func attachmentLifecycleCurrentLocked(entry *session, fence *attachmentLifecycleFence) bool {
96 if fence == nil {95 if fence == nil {
97 return true96 return true
98 }97 }
99- target, ok := localSession(entry)98+ if entry == nil {
100- if !ok {
101 return false99 return false
102 }100 }
103- if fence.checkCreatedAt && (target.name != fence.name || target.createdAt != fence.createdAt) {101+ if fence.checkCreatedAt && (entry.name != fence.name || entry.createdAt != fence.createdAt) {
104 return false102 return false
105 }103 }
106- if fence.checkIncarnation && target.incarnation != fence.incarnation {104+ if fence.checkIncarnation && entry.incarnation != fence.incarnation {
107 return false105 return false
108 }106 }
109- return !fence.checkTab || fence.tabIndex >= 0 && fence.tabIndex < len(target.tabs) &&107+ return !fence.checkTab || fence.tabIndex >= 0 && fence.tabIndex < len(entry.tabs) &&
110- domain.TabStableID(target.tabs[fence.tabIndex].stableID) == fence.tabID108+ domain.TabStableID(entry.tabs[fence.tabIndex].stableID) == fence.tabID
111 }109 }
112 110
113 // validateAttachmentTransitionPrelocked performs every fallible check before111 // validateAttachmentTransitionPrelocked performs every fallible check before
@@ -174,18 +172,12 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
174 // shared environment state as part of the same publication.172 // shared environment state as part of the same publication.
175 func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool {173 func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool {
176 req := publication.req174 req := publication.req
177- if req.activateTargetTab {175+ if req.activateTargetTab && req.target != nil {
178- if target, ok := localSession(req.target); ok {176+ req.target.activateAttachmentViewLocked(req.next, req.targetTabIndex)
179- target.activateAttachmentViewLocked(req.next, req.targetTabIndex)
180- }
181 }177 }
182- if req.copySourceEnvironment {178+ if req.copySourceEnvironment && publication.source != nil && req.target != nil {
183- source, sourceOK := localSession(publication.source)179+ req.target.terminal = publication.source.terminal
184- target, targetOK := localSession(req.target)180+ req.target.env = copyEnvironment(publication.source.env)
185- if sourceOK && targetOK {
186- target.terminal = source.terminal
187- target.env = copyEnvironment(source.env)
188- }
189 }181 }
190 return req.preserveAttachment182 return req.preserveAttachment
191 }183 }
@@ -259,9 +251,12 @@ func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionReques
259 }251 }
260 252
261 // lockAttachmentSessions gives every two-session transition one stable order.253 // lockAttachmentSessions gives every two-session transition one stable order.
262-func lockAttachmentSessions(a, b attachmentSession) func() {254+func lockAttachmentSessions(a, b *session) func() {
263- aCore := attachmentSessionCore(a)255+ if a == nil || b == nil {
264- bCore := attachmentSessionCore(b)256+ return func() {}
257+ }
258+ aCore := a.core()
259+ bCore := b.core()
265 if aCore == nil || bCore == nil {260 if aCore == nil || bCore == nil {
266 return func() {}261 return func() {}
267 }262 }
@@ -283,7 +278,7 @@ func lockAttachmentSessions(a, b attachmentSession) func() {
283 278
284 // lockAttachmentCoordinators follows the same immutable session-ID order as279 // lockAttachmentCoordinators follows the same immutable session-ID order as
285 // lockAttachmentSessions. Callers already hold ordered session locks.280 // lockAttachmentSessions. Callers already hold ordered session locks.
286-func lockAttachmentCoordinators(a attachmentSession, aCoordinator *renderCoordinator, b attachmentSession, bCoordinator *renderCoordinator) func() {281+func lockAttachmentCoordinators(a *session, aCoordinator *renderCoordinator, b *session, bCoordinator *renderCoordinator) func() {
287 if aCoordinator == nil && bCoordinator == nil {282 if aCoordinator == nil && bCoordinator == nil {
288 return func() {}283 return func() {}
289 }284 }
M internal/usecase/daemon/attention.go +4 -4
@@ -90,8 +90,8 @@ type attentionTarget struct {
9090 func (d *Daemon) oldestOtherSessionAttention(current *session) (attentionTarget, bool) {
9191 d.mu.Lock()
9292 sessions := make([]*session, 0, len(d.sessions))
93- for _, entry := range d.sessions {
94- if sess, ok := localSession(entry); ok && sess != current {
93+ for _, sess := range d.sessions {
94+ if sess != nil && sess != current {
9595 sessions = append(sessions, sess)
9696 }
9797 }
@@ -137,7 +137,7 @@ func (d *Daemon) repaintAttachedClients(sess *session) {
137137
138138 func (d *Daemon) repaintAllAttachedClients() {
139139 d.mu.Lock()
140- sessions := localSessionsSnapshot(d.sessions)
140+ sessions := sessionsSnapshot(d.sessions)
141141 d.mu.Unlock()
142142
143143 for _, sess := range sessions {
@@ -245,7 +245,7 @@ func (d *Daemon) attentionAnimator(ctx context.Context) {
245245
246246 func (d *Daemon) anyAttention() bool {
247247 d.mu.Lock()
248- sessions := localSessionsSnapshot(d.sessions)
248+ sessions := sessionsSnapshot(d.sessions)
249249 d.mu.Unlock()
250250
251251 for _, sess := range sessions {
@@ -90,8 +90,8 @@ type attentionTarget struct {
90 func (d *Daemon) oldestOtherSessionAttention(current *session) (attentionTarget, bool) {90 func (d *Daemon) oldestOtherSessionAttention(current *session) (attentionTarget, bool) {
91 d.mu.Lock()91 d.mu.Lock()
92 sessions := make([]*session, 0, len(d.sessions))92 sessions := make([]*session, 0, len(d.sessions))
93- for _, entry := range d.sessions {93+ for _, sess := range d.sessions {
94- if sess, ok := localSession(entry); ok && sess != current {94+ if sess != nil && sess != current {
95 sessions = append(sessions, sess)95 sessions = append(sessions, sess)
96 }96 }
97 }97 }
@@ -137,7 +137,7 @@ func (d *Daemon) repaintAttachedClients(sess *session) {
137 137
138 func (d *Daemon) repaintAllAttachedClients() {138 func (d *Daemon) repaintAllAttachedClients() {
139 d.mu.Lock()139 d.mu.Lock()
140- sessions := localSessionsSnapshot(d.sessions)140+ sessions := sessionsSnapshot(d.sessions)
141 d.mu.Unlock()141 d.mu.Unlock()
142 142
143 for _, sess := range sessions {143 for _, sess := range sessions {
@@ -245,7 +245,7 @@ func (d *Daemon) attentionAnimator(ctx context.Context) {
245 245
246 func (d *Daemon) anyAttention() bool {246 func (d *Daemon) anyAttention() bool {
247 d.mu.Lock()247 d.mu.Lock()
248- sessions := localSessionsSnapshot(d.sessions)248+ sessions := sessionsSnapshot(d.sessions)
249 d.mu.Unlock()249 d.mu.Unlock()
250 250
251 for _, sess := range sessions {251 for _, sess := range sessions {
M internal/usecase/daemon/bar_refresh.go +2 -3
@@ -196,7 +196,7 @@ func (d *Daemon) refreshBarScriptsAllSessions() {
196196 func (d *Daemon) sessionsSnapshot() []*session {
197197 d.mu.Lock()
198198 defer d.mu.Unlock()
199- return localSessionsSnapshot(d.sessions)
199+ return sessionsSnapshot(d.sessions)
200200 }
201201
202202 func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) {
@@ -390,8 +390,7 @@ func (d *Daemon) logBarScriptFailure(sess *session, sessionName, anchor, command
390390 return
391391 }
392392 attrs := []any{"anchor", anchor, "session", sessionName, "command", command, "err", err}
393- var scriptErr *barScriptError
394- if errors.As(err, &scriptErr) {
393+ if scriptErr, ok := errors.AsType[*barScriptError](err); ok {
395394 attrs = append(attrs, "exit_code", scriptErr.exitCode)
396395 if scriptErr.stderr != "" {
397396 attrs = append(attrs, "stderr", scriptErr.stderr)
@@ -196,7 +196,7 @@ func (d *Daemon) refreshBarScriptsAllSessions() {
196 func (d *Daemon) sessionsSnapshot() []*session {196 func (d *Daemon) sessionsSnapshot() []*session {
197 d.mu.Lock()197 d.mu.Lock()
198 defer d.mu.Unlock()198 defer d.mu.Unlock()
199- return localSessionsSnapshot(d.sessions)199+ return sessionsSnapshot(d.sessions)
200 }200 }
201 201
202 func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) {202 func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) {
@@ -390,8 +390,7 @@ func (d *Daemon) logBarScriptFailure(sess *session, sessionName, anchor, command
390 return390 return
391 }391 }
392 attrs := []any{"anchor", anchor, "session", sessionName, "command", command, "err", err}392 attrs := []any{"anchor", anchor, "session", sessionName, "command", command, "err", err}
393- var scriptErr *barScriptError393+ if scriptErr, ok := errors.AsType[*barScriptError](err); ok {
394- if errors.As(err, &scriptErr) {
395 attrs = append(attrs, "exit_code", scriptErr.exitCode)394 attrs = append(attrs, "exit_code", scriptErr.exitCode)
396 if scriptErr.stderr != "" {395 if scriptErr.stderr != "" {
397 attrs = append(attrs, "stderr", scriptErr.stderr)396 attrs = append(attrs, "stderr", scriptErr.stderr)
M internal/usecase/daemon/bar_refresh_test.go +1 -1
@@ -219,7 +219,7 @@ func TestRefreshBarScriptsAllSessionsForcesRun(t *testing.T) {
219219 d := newBarRefreshTestDaemon(r, 60*time.Second)
220220 sess := newBarRefreshTestSession()
221221 sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
222- d.sessions = map[domain.SessionID]attachmentSession{sess.id: sess}
222+ d.sessions = map[domain.SessionID]*session{sess.id: sess}
223223
224224 // Verifies refreshBarScriptsAllSessions iterates every live session and
225225 // dispatches a run for each. This does not exercise force specifically:
@@ -219,7 +219,7 @@ func TestRefreshBarScriptsAllSessionsForcesRun(t *testing.T) {
219 d := newBarRefreshTestDaemon(r, 60*time.Second)219 d := newBarRefreshTestDaemon(r, 60*time.Second)
220 sess := newBarRefreshTestSession()220 sess := newBarRefreshTestSession()
221 sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})221 sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
222- d.sessions = map[domain.SessionID]attachmentSession{sess.id: sess}222+ d.sessions = map[domain.SessionID]*session{sess.id: sess}
223 223
224 // Verifies refreshBarScriptsAllSessions iterates every live session and224 // Verifies refreshBarScriptsAllSessions iterates every live session and
225 // dispatches a run for each. This does not exercise force specifically:225 // dispatches a run for each. This does not exercise force specifically:
M internal/usecase/daemon/client.go +29 -31
@@ -40,7 +40,7 @@ type attachedClient struct {
4040 overlayOnce sync.Once
4141 clientID [16]byte
4242 // connectionGeneration is the wire-facing capability generation. attachmentEffects is
43- // the linearization gate for every attachment-bound observable operation. An attachment
43+ // the linearization gate for every attachment-bound observable operation. A role
4444 // transition freezes and drains this gate before changing either generation
4545 // or the session/coordinator registries.
4646 connectionGeneration atomic.Uint64
@@ -73,7 +73,7 @@ type attachedClient struct {
7373 // tabs and panes without changing shared session ownership.
7474 viewMu sync.Mutex
7575 view attachmentView
76- sess Guarded[attachmentSession]
76+ sess Guarded[*session]
7777 mouseScan mouse.Scanner
7878 themeMu sync.Mutex
7979 clientTheme themeui.Theme
@@ -82,7 +82,7 @@ type attachedClient struct {
8282 renderStages renderStageHooks // optional render and handoff observability hooks
8383 // previousSession is guarded independently. It is retained through temporary
8484 // setSession(nil) hand-offs and cleared only on terminal teardown.
85- previousSession Guarded[attachmentSession]
85+ previousSession Guarded[*session]
8686 linkMu sync.Mutex
8787 sendMu sync.Mutex
8888 // routeCreatedSession marks a session created by this attachment's route.
@@ -141,15 +141,14 @@ func (ac *attachedClient) initOverlays() {
141141 ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) })
142142 }
143143
144-func (ac *attachedClient) currentAttachmentSession() attachmentSession { return ac.sess.Get() }
144+func (ac *attachedClient) currentAttachmentSession() *session { return ac.sess.Get() }
145145
146146 // currentSession narrows the attachment to its owning local session.
147147 func (ac *attachedClient) currentSession() *session {
148- sess, _ := localSession(ac.currentAttachmentSession())
149- return sess
148+ return ac.currentAttachmentSession()
150149 }
151150
152-func (ac *attachedClient) setSession(sess attachmentSession) { ac.sess.Set(sess) }
151+func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }
153152
154153 func (ac *attachedClient) clearPreviousSession() {
155154 if ac != nil {
@@ -558,13 +557,13 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size,
558557 // attachCoordinator installs the coordinator lease for an attachment. Session
559558 // membership remains independent, so installing one attachment never displaces
560559 // another.
561-func (d *Daemon) attachCoordinator(sess *session, current *attachedClient, ready bool) *renderCoordinator {
562- rc, cleanup := d.attachCoordinatorDeferred(sess, current, ready)
560+func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient, ready bool) *renderCoordinator {
561+ rc, cleanup := d.attachCoordinatorDeferred(sess, old, current, ready)
563562 cleanup.finish()
564563 return rc
565564 }
566565
567-func (d *Daemon) attachCoordinatorDeferred(sess *session, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) {
566+func (d *Daemon) attachCoordinatorDeferred(sess *session, _ *attachedClient, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) {
568567 rc := d.ensureRenderCoordinator(sess)
569568 if current != nil {
570569 rc.attachWithReadiness(current, ready)
@@ -588,8 +587,8 @@ func (d *Daemon) ensureRenderCoordinatorPrelocked(sess *session) *renderCoordina
588587 }
589588
590589 // ensureAttachmentRenderCoordinatorPrelocked publishes the coordinator for an
591-// exact attachment implementation. The caller holds entry.core().mu.
592-func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSession) *renderCoordinator {
590+// exact session. The caller holds entry.core().mu.
591+func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry *session) *renderCoordinator {
593592 if rc := attachmentRenderCoordinator(entry); rc != nil {
594593 return rc
595594 }
@@ -604,7 +603,7 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess
604603 // The session snapshot is deterministic. Each paint revalidates the
605604 // attachment's membership and transport fence after that snapshot, so
606605 // a detached peer cannot stop remaining attachments receiving output.
607- attachments := snapshotAttachmentSession(entry)
606+ attachments := entry.snapshotAttachments()
608607 for _, attachment := range attachments {
609608 if !rc.wakeCurrent(w) {
610609 return
@@ -614,27 +613,28 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess
614613 continue
615614 }
616615 // Pane damage is session-shared; every fan-out attachment needs a
617- // complete frame because any peer may capture it first. A handshake
618- // target is the one synchronous exception: its first paint must finish
619- // before the handshake releases its lifecycle gate.
616+ // fresh capture because any peer may acknowledge the damage first.
617+ // Each attachment owns its send lock, so a blocked peer cannot stall
618+ // this fan-out or the PTY reader. A handshake-targeted wake is the
619+ // one synchronous exception: its first paint must finish before the
620+ // handshake deadline is released.
620621 fanoutReset := w.reset || len(attachments) > 1
621622 if w.lease != nil && w.lease.attachment == attachment {
622623 d.paint(entry, attachment, fanoutReset, lease)
623624 } else if len(attachments) > 1 {
624625 go d.paint(entry, attachment, fanoutReset, lease)
625-
626626 } else {
627627 d.paint(entry, attachment, fanoutReset, lease)
628628 }
629629 }
630630 },
631+ ackReadyFor: func(attached *attachedClient) bool {
632+ // outputStateStream publishes capacity atomically. Do not take
633+ // attached.sendMu here: a slow transport may be holding it for an
634+ // in-flight Send, and that peer must not gate healthy attachments.
635+ return attached == nil || attached.output == nil || !attached.output.atCapacity()
636+ },
631637 })
632- rc.ackReadyFor = func(attached *attachedClient) bool {
633- // outputStateStream publishes capacity atomically. Do not take
634- // attached.sendMu here: a slow transport may be holding it for an
635- // in-flight Send, and that peer must not gate healthy attachments.
636- return attached == nil || attached.output == nil || !attached.output.atCapacity()
637- }
638638 installAttachmentRenderCoordinator(entry, rc)
639639 return rc
640640 }
@@ -684,11 +684,10 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
684684 token.ac.captureFrames = nil
685685 token.ac.sendMu.Unlock()
686686 }
687- sess, ok := localSession(token.sess)
688- if !ok {
687+ if token.sess == nil {
689688 return false
690689 }
691- d.firstPaintWithLease(sess, token.ac, token.ac.size, token.lease)
690+ d.firstPaintWithLease(token.sess, token.ac, token.ac.size, token.lease)
692691 return true
693692 }
694693
@@ -752,8 +751,8 @@ func (d *Daemon) applyThemeForAttachment(token attachmentConnectionToken, msg po
752751 }
753752 clientTheme := themeFromMessage(msg)
754753 token.ac.setClientTheme(clientTheme)
755- sess, ok := localSession(token.sess)
756- if !ok || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {
754+ sess := token.sess
755+ if sess == nil || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {
757756 return false
758757 }
759758 if !token.attachmentEffectCurrent() {
@@ -847,12 +846,11 @@ func (d *Daemon) handleClientNoticeForAttachment(token attachmentConnectionToken
847846 d.notices.routingMu.Unlock()
848847 return
849848 }
850- sess, ok := localSession(token.sess)
851- if !ok {
849+ if token.sess == nil {
852850 d.notices.routingMu.Unlock()
853851 return
854852 }
855- repaint := d.mutateClientNotice(sess, token.ac, notice)
853+ repaint := d.mutateClientNotice(token.sess, token.ac, notice)
856854 d.notices.routingMu.Unlock()
857855 if repaint && token.attachmentEffectCurrent() {
858856 d.repaintForNotice(token.ac)
@@ -40,7 +40,7 @@ type attachedClient struct {
40 overlayOnce sync.Once40 overlayOnce sync.Once
41 clientID [16]byte41 clientID [16]byte
42 // connectionGeneration is the wire-facing capability generation. attachmentEffects is42 // connectionGeneration is the wire-facing capability generation. attachmentEffects is
43- // the linearization gate for every attachment-bound observable operation. An attachment43+ // the linearization gate for every attachment-bound observable operation. A role
44 // transition freezes and drains this gate before changing either generation44 // transition freezes and drains this gate before changing either generation
45 // or the session/coordinator registries.45 // or the session/coordinator registries.
46 connectionGeneration atomic.Uint6446 connectionGeneration atomic.Uint64
@@ -73,7 +73,7 @@ type attachedClient struct {
73 // tabs and panes without changing shared session ownership.73 // tabs and panes without changing shared session ownership.
74 viewMu sync.Mutex74 viewMu sync.Mutex
75 view attachmentView75 view attachmentView
76- sess Guarded[attachmentSession]76+ sess Guarded[*session]
77 mouseScan mouse.Scanner77 mouseScan mouse.Scanner
78 themeMu sync.Mutex78 themeMu sync.Mutex
79 clientTheme themeui.Theme79 clientTheme themeui.Theme
@@ -82,7 +82,7 @@ type attachedClient struct {
82 renderStages renderStageHooks // optional render and handoff observability hooks82 renderStages renderStageHooks // optional render and handoff observability hooks
83 // previousSession is guarded independently. It is retained through temporary83 // previousSession is guarded independently. It is retained through temporary
84 // setSession(nil) hand-offs and cleared only on terminal teardown.84 // setSession(nil) hand-offs and cleared only on terminal teardown.
85- previousSession Guarded[attachmentSession]85+ previousSession Guarded[*session]
86 linkMu sync.Mutex86 linkMu sync.Mutex
87 sendMu sync.Mutex87 sendMu sync.Mutex
88 // routeCreatedSession marks a session created by this attachment's route.88 // routeCreatedSession marks a session created by this attachment's route.
@@ -141,15 +141,14 @@ func (ac *attachedClient) initOverlays() {
141 ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) })141 ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) })
142 }142 }
143 143
144-func (ac *attachedClient) currentAttachmentSession() attachmentSession { return ac.sess.Get() }144+func (ac *attachedClient) currentAttachmentSession() *session { return ac.sess.Get() }
145 145
146 // currentSession narrows the attachment to its owning local session.146 // currentSession narrows the attachment to its owning local session.
147 func (ac *attachedClient) currentSession() *session {147 func (ac *attachedClient) currentSession() *session {
148- sess, _ := localSession(ac.currentAttachmentSession())148+ return ac.currentAttachmentSession()
149- return sess
150 }149 }
151 150
152-func (ac *attachedClient) setSession(sess attachmentSession) { ac.sess.Set(sess) }151+func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }
153 152
154 func (ac *attachedClient) clearPreviousSession() {153 func (ac *attachedClient) clearPreviousSession() {
155 if ac != nil {154 if ac != nil {
@@ -558,13 +557,13 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size,
558 // attachCoordinator installs the coordinator lease for an attachment. Session557 // attachCoordinator installs the coordinator lease for an attachment. Session
559 // membership remains independent, so installing one attachment never displaces558 // membership remains independent, so installing one attachment never displaces
560 // another.559 // another.
561-func (d *Daemon) attachCoordinator(sess *session, current *attachedClient, ready bool) *renderCoordinator {560+func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient, ready bool) *renderCoordinator {
562- rc, cleanup := d.attachCoordinatorDeferred(sess, current, ready)561+ rc, cleanup := d.attachCoordinatorDeferred(sess, old, current, ready)
563 cleanup.finish()562 cleanup.finish()
564 return rc563 return rc
565 }564 }
566 565
567-func (d *Daemon) attachCoordinatorDeferred(sess *session, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) {566+func (d *Daemon) attachCoordinatorDeferred(sess *session, _ *attachedClient, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) {
568 rc := d.ensureRenderCoordinator(sess)567 rc := d.ensureRenderCoordinator(sess)
569 if current != nil {568 if current != nil {
570 rc.attachWithReadiness(current, ready)569 rc.attachWithReadiness(current, ready)
@@ -588,8 +587,8 @@ func (d *Daemon) ensureRenderCoordinatorPrelocked(sess *session) *renderCoordina
588 }587 }
589 588
590 // ensureAttachmentRenderCoordinatorPrelocked publishes the coordinator for an589 // ensureAttachmentRenderCoordinatorPrelocked publishes the coordinator for an
591-// exact attachment implementation. The caller holds entry.core().mu.590+// exact session. The caller holds entry.core().mu.
592-func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSession) *renderCoordinator {591+func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry *session) *renderCoordinator {
593 if rc := attachmentRenderCoordinator(entry); rc != nil {592 if rc := attachmentRenderCoordinator(entry); rc != nil {
594 return rc593 return rc
595 }594 }
@@ -604,7 +603,7 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess
604 // The session snapshot is deterministic. Each paint revalidates the603 // The session snapshot is deterministic. Each paint revalidates the
605 // attachment's membership and transport fence after that snapshot, so604 // attachment's membership and transport fence after that snapshot, so
606 // a detached peer cannot stop remaining attachments receiving output.605 // a detached peer cannot stop remaining attachments receiving output.
607- attachments := snapshotAttachmentSession(entry)606+ attachments := entry.snapshotAttachments()
608 for _, attachment := range attachments {607 for _, attachment := range attachments {
609 if !rc.wakeCurrent(w) {608 if !rc.wakeCurrent(w) {
610 return609 return
@@ -614,27 +613,28 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess
614 continue613 continue
615 }614 }
616 // Pane damage is session-shared; every fan-out attachment needs a615 // Pane damage is session-shared; every fan-out attachment needs a
617- // complete frame because any peer may capture it first. A handshake616+ // fresh capture because any peer may acknowledge the damage first.
618- // target is the one synchronous exception: its first paint must finish617+ // Each attachment owns its send lock, so a blocked peer cannot stall
619- // before the handshake releases its lifecycle gate.618+ // this fan-out or the PTY reader. A handshake-targeted wake is the
619+ // one synchronous exception: its first paint must finish before the
620+ // handshake deadline is released.
620 fanoutReset := w.reset || len(attachments) > 1621 fanoutReset := w.reset || len(attachments) > 1
621 if w.lease != nil && w.lease.attachment == attachment {622 if w.lease != nil && w.lease.attachment == attachment {
622 d.paint(entry, attachment, fanoutReset, lease)623 d.paint(entry, attachment, fanoutReset, lease)
623 } else if len(attachments) > 1 {624 } else if len(attachments) > 1 {
624 go d.paint(entry, attachment, fanoutReset, lease)625 go d.paint(entry, attachment, fanoutReset, lease)
625-
626 } else {626 } else {
627 d.paint(entry, attachment, fanoutReset, lease)627 d.paint(entry, attachment, fanoutReset, lease)
628 }628 }
629 }629 }
630 },630 },
631+ ackReadyFor: func(attached *attachedClient) bool {
632+ // outputStateStream publishes capacity atomically. Do not take
633+ // attached.sendMu here: a slow transport may be holding it for an
634+ // in-flight Send, and that peer must not gate healthy attachments.
635+ return attached == nil || attached.output == nil || !attached.output.atCapacity()
636+ },
631 })637 })
632- rc.ackReadyFor = func(attached *attachedClient) bool {
633- // outputStateStream publishes capacity atomically. Do not take
634- // attached.sendMu here: a slow transport may be holding it for an
635- // in-flight Send, and that peer must not gate healthy attachments.
636- return attached == nil || attached.output == nil || !attached.output.atCapacity()
637- }
638 installAttachmentRenderCoordinator(entry, rc)638 installAttachmentRenderCoordinator(entry, rc)
639 return rc639 return rc
640 }640 }
@@ -684,11 +684,10 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
684 token.ac.captureFrames = nil684 token.ac.captureFrames = nil
685 token.ac.sendMu.Unlock()685 token.ac.sendMu.Unlock()
686 }686 }
687- sess, ok := localSession(token.sess)687+ if token.sess == nil {
688- if !ok {
689 return false688 return false
690 }689 }
691- d.firstPaintWithLease(sess, token.ac, token.ac.size, token.lease)690+ d.firstPaintWithLease(token.sess, token.ac, token.ac.size, token.lease)
692 return true691 return true
693 }692 }
694 693
@@ -752,8 +751,8 @@ func (d *Daemon) applyThemeForAttachment(token attachmentConnectionToken, msg po
752 }751 }
753 clientTheme := themeFromMessage(msg)752 clientTheme := themeFromMessage(msg)
754 token.ac.setClientTheme(clientTheme)753 token.ac.setClientTheme(clientTheme)
755- sess, ok := localSession(token.sess)754+ sess := token.sess
756- if !ok || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {755+ if sess == nil || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {
757 return false756 return false
758 }757 }
759 if !token.attachmentEffectCurrent() {758 if !token.attachmentEffectCurrent() {
@@ -847,12 +846,11 @@ func (d *Daemon) handleClientNoticeForAttachment(token attachmentConnectionToken
847 d.notices.routingMu.Unlock()846 d.notices.routingMu.Unlock()
848 return847 return
849 }848 }
850- sess, ok := localSession(token.sess)849+ if token.sess == nil {
851- if !ok {
852 d.notices.routingMu.Unlock()850 d.notices.routingMu.Unlock()
853 return851 return
854 }852 }
855- repaint := d.mutateClientNotice(sess, token.ac, notice)853+ repaint := d.mutateClientNotice(token.sess, token.ac, notice)
856 d.notices.routingMu.Unlock()854 d.notices.routingMu.Unlock()
857 if repaint && token.attachmentEffectCurrent() {855 if repaint && token.attachmentEffectCurrent() {
858 d.repaintForNotice(token.ac)856 d.repaintForNotice(token.ac)
M internal/usecase/daemon/client_frame_routing.go +4 -8
@@ -13,7 +13,7 @@ const connectionSnapshotAttempts = 8
1313 // currentAttachmentConnection retries the lock-free session-to-role snapshot when a
1414 // handoff lands between those reads. The attachment gate still performs the final
1515 // mutation admission after this function returns.
16-func (d *Daemon) currentAttachmentConnection(ac *attachedClient, tr ports.Transport) (attachmentSession, attachmentConnectionToken, bool) {
16+func (d *Daemon) currentAttachmentConnection(ac *attachedClient, tr ports.Transport) (*session, attachmentConnectionToken, bool) {
1717 for range connectionSnapshotAttempts {
1818 if !ac.currentTransportIs(tr) {
1919 break
@@ -54,11 +54,7 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
5454 if !ok {
5555 return
5656 }
57- local, ok := localSession(sess)
58- if !ok {
59- return
60- }
61- d.clientGone(local, ac, tr, false)
57+ d.clientGone(sess, ac, tr, false)
6258 current := ac.currentAttachmentSession()
6359 if current == sess || !ac.currentTransportIs(tr) || current == nil {
6460 return
@@ -180,8 +176,8 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
180176 result.Text = "attached commands cannot override their active session target"
181177 return result
182178 }
183- sess, ok := localSession(token.sess)
184- if !ok || !token.attachmentEffectCurrent() {
179+ sess := token.sess
180+ if sess == nil || !token.attachmentEffectCurrent() {
185181 result.Code = ports.ErrNoSuchTarget
186182 result.Text = "attached session is no longer active"
187183 return result
@@ -13,7 +13,7 @@ const connectionSnapshotAttempts = 8
13 // currentAttachmentConnection retries the lock-free session-to-role snapshot when a13 // currentAttachmentConnection retries the lock-free session-to-role snapshot when a
14 // handoff lands between those reads. The attachment gate still performs the final14 // handoff lands between those reads. The attachment gate still performs the final
15 // mutation admission after this function returns.15 // mutation admission after this function returns.
16-func (d *Daemon) currentAttachmentConnection(ac *attachedClient, tr ports.Transport) (attachmentSession, attachmentConnectionToken, bool) {16+func (d *Daemon) currentAttachmentConnection(ac *attachedClient, tr ports.Transport) (*session, attachmentConnectionToken, bool) {
17 for range connectionSnapshotAttempts {17 for range connectionSnapshotAttempts {
18 if !ac.currentTransportIs(tr) {18 if !ac.currentTransportIs(tr) {
19 break19 break
@@ -54,11 +54,7 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
54 if !ok {54 if !ok {
55 return55 return
56 }56 }
57- local, ok := localSession(sess)57+ d.clientGone(sess, ac, tr, false)
58- if !ok {
59- return
60- }
61- d.clientGone(local, ac, tr, false)
62 current := ac.currentAttachmentSession()58 current := ac.currentAttachmentSession()
63 if current == sess || !ac.currentTransportIs(tr) || current == nil {59 if current == sess || !ac.currentTransportIs(tr) || current == nil {
64 return60 return
@@ -180,8 +176,8 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
180 result.Text = "attached commands cannot override their active session target"176 result.Text = "attached commands cannot override their active session target"
181 return result177 return result
182 }178 }
183- sess, ok := localSession(token.sess)179+ sess := token.sess
184- if !ok || !token.attachmentEffectCurrent() {180+ if sess == nil || !token.attachmentEffectCurrent() {
185 result.Code = ports.ErrNoSuchTarget181 result.Code = ports.ErrNoSuchTarget
186 result.Text = "attached session is no longer active"182 result.Text = "attached session is no longer active"
187 return result183 return result
M internal/usecase/daemon/client_transport_errors.go +4 -4
@@ -37,8 +37,8 @@ func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explic
3737 }
3838 token.effect.bindActionEnd(d, "detach")
3939 token.effect.End()
40- sess, ok := localSession(token.sess)
41- if !ok {
40+ sess := token.sess
41+ if sess == nil {
4242 return false
4343 }
4444 var parkingToken uint64
@@ -126,8 +126,8 @@ func (d *Daemon) detachOnAttachmentSendErrorUntil(token attachmentConnectionToke
126126 if failed == nil || failed != token.transport.transport {
127127 return
128128 }
129- sess, ok := localSession(token.sess)
130- if !ok {
129+ sess := token.sess
130+ if sess == nil {
131131 return
132132 }
133133 parkingToken := d.markParkingInFlight(sess, token.ac)
@@ -37,8 +37,8 @@ func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explic
37 }37 }
38 token.effect.bindActionEnd(d, "detach")38 token.effect.bindActionEnd(d, "detach")
39 token.effect.End()39 token.effect.End()
40- sess, ok := localSession(token.sess)40+ sess := token.sess
41- if !ok {41+ if sess == nil {
42 return false42 return false
43 }43 }
44 var parkingToken uint6444 var parkingToken uint64
@@ -126,8 +126,8 @@ func (d *Daemon) detachOnAttachmentSendErrorUntil(token attachmentConnectionToke
126 if failed == nil || failed != token.transport.transport {126 if failed == nil || failed != token.transport.transport {
127 return127 return
128 }128 }
129- sess, ok := localSession(token.sess)129+ sess := token.sess
130- if !ok {130+ if sess == nil {
131 return131 return
132 }132 }
133 parkingToken := d.markParkingInFlight(sess, token.ac)133 parkingToken := d.markParkingInFlight(sess, token.ac)
M internal/usecase/daemon/clipboard.go +4 -4
@@ -89,8 +89,8 @@ func (d *Daemon) writeClipboardImageForAttachment(token attachmentConnectionToke
8989 if err != nil {
9090 return "", err
9191 }
92- sess, ok := localSession(token.sess)
93- if !ok {
92+ sess := token.sess
93+ if sess == nil {
9494 _ = os.Remove(path)
9595 return "", errAttachmentTransition
9696 }
@@ -135,8 +135,8 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) {
135135 }
136136
137137 func (d *Daemon) injectClipboardPathForAttachment(token attachmentConnectionToken, path string) {
138- sess, ok := localSession(token.sess)
139- if !ok || !token.attachmentEffectCurrent() {
138+ sess := token.sess
139+ if sess == nil || !token.attachmentEffectCurrent() {
140140 return
141141 }
142142 d.injectClipboardPathToTarget(sess, path, &token)
@@ -89,8 +89,8 @@ func (d *Daemon) writeClipboardImageForAttachment(token attachmentConnectionToke
89 if err != nil {89 if err != nil {
90 return "", err90 return "", err
91 }91 }
92- sess, ok := localSession(token.sess)92+ sess := token.sess
93- if !ok {93+ if sess == nil {
94 _ = os.Remove(path)94 _ = os.Remove(path)
95 return "", errAttachmentTransition95 return "", errAttachmentTransition
96 }96 }
@@ -135,8 +135,8 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) {
135 }135 }
136 136
137 func (d *Daemon) injectClipboardPathForAttachment(token attachmentConnectionToken, path string) {137 func (d *Daemon) injectClipboardPathForAttachment(token attachmentConnectionToken, path string) {
138- sess, ok := localSession(token.sess)138+ sess := token.sess
139- if !ok || !token.attachmentEffectCurrent() {139+ if sess == nil || !token.attachmentEffectCurrent() {
140 return140 return
141 }141 }
142 d.injectClipboardPathToTarget(sess, path, &token)142 d.injectClipboardPathToTarget(sess, path, &token)
M internal/usecase/daemon/config.go +1 -1
@@ -336,7 +336,7 @@ func (d *Daemon) resolveAppliedTheme(raw theme.Theme) appliedTheme {
336336
337337 func (d *Daemon) reapplyThemeAllSessions() {
338338 d.mu.Lock()
339- sessions := localSessionsSnapshot(d.sessions)
339+ sessions := sessionsSnapshot(d.sessions)
340340 d.mu.Unlock()
341341
342342 for _, sess := range sessions {
@@ -336,7 +336,7 @@ func (d *Daemon) resolveAppliedTheme(raw theme.Theme) appliedTheme {
336 336
337 func (d *Daemon) reapplyThemeAllSessions() {337 func (d *Daemon) reapplyThemeAllSessions() {
338 d.mu.Lock()338 d.mu.Lock()
339- sessions := localSessionsSnapshot(d.sessions)339+ sessions := sessionsSnapshot(d.sessions)
340 d.mu.Unlock()340 d.mu.Unlock()
341 341
342 for _, sess := range sessions {342 for _, sess := range sessions {
M internal/usecase/daemon/control.go +5 -6
@@ -186,9 +186,8 @@ func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, u
186186 return nil, ports.ErrNoSuchTarget, "target tab and pane IDs must be provided together"
187187 }
188188 var match *session
189- for _, entry := range d.sessions {
190- sess, ok := localSession(entry)
191- if !ok || !sess.containsStableIDs(request.TargetTab, request.TargetPane) {
189+ for _, sess := range d.sessions {
190+ if sess == nil || !sess.containsStableIDs(request.TargetTab, request.TargetPane) {
192191 continue
193192 }
194193 if match != nil {
@@ -201,7 +200,7 @@ func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, u
201200 }
202201 return nil, ports.ErrNoSuchTarget, "no live session contains the target tab/pane"
203202 }
204- locals := localSessionsSnapshot(d.sessions)
203+ locals := sessionsSnapshot(d.sessions)
205204 if len(locals) == 0 {
206205 return nil, ports.ErrNoSuchTarget, "no live sessions"
207206 }
@@ -658,7 +657,7 @@ func (e controlExec) ListSessions(asJSON bool) (string, error) {
658657 Active bool `json:"active"`
659658 }
660659 e.d.mu.Lock()
661- sessions := localSessionsSnapshot(e.d.sessions)
660+ sessions := sessionsSnapshot(e.d.sessions)
662661 e.d.mu.Unlock()
663662 snaps := make([]sessionView, 0, len(sessions))
664663 for _, sess := range sessions {
@@ -695,7 +694,7 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
695694 return "", command.ErrInvalidArguments
696695 }
697696 e.d.mu.Lock()
698- sessions := localSessionsSnapshot(e.d.sessions)
697+ sessions := sessionsSnapshot(e.d.sessions)
699698 e.d.mu.Unlock()
700699 rows := make([]ports.RemoteCatalogSession, 0, len(sessions))
701700 // Stopped sessions live in d.stopped and are intentionally not in this listing.
@@ -186,9 +186,8 @@ func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, u
186 return nil, ports.ErrNoSuchTarget, "target tab and pane IDs must be provided together"186 return nil, ports.ErrNoSuchTarget, "target tab and pane IDs must be provided together"
187 }187 }
188 var match *session188 var match *session
189- for _, entry := range d.sessions {189+ for _, sess := range d.sessions {
190- sess, ok := localSession(entry)190+ if sess == nil || !sess.containsStableIDs(request.TargetTab, request.TargetPane) {
191- if !ok || !sess.containsStableIDs(request.TargetTab, request.TargetPane) {
192 continue191 continue
193 }192 }
194 if match != nil {193 if match != nil {
@@ -201,7 +200,7 @@ func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, u
201 }200 }
202 return nil, ports.ErrNoSuchTarget, "no live session contains the target tab/pane"201 return nil, ports.ErrNoSuchTarget, "no live session contains the target tab/pane"
203 }202 }
204- locals := localSessionsSnapshot(d.sessions)203+ locals := sessionsSnapshot(d.sessions)
205 if len(locals) == 0 {204 if len(locals) == 0 {
206 return nil, ports.ErrNoSuchTarget, "no live sessions"205 return nil, ports.ErrNoSuchTarget, "no live sessions"
207 }206 }
@@ -658,7 +657,7 @@ func (e controlExec) ListSessions(asJSON bool) (string, error) {
658 Active bool `json:"active"`657 Active bool `json:"active"`
659 }658 }
660 e.d.mu.Lock()659 e.d.mu.Lock()
661- sessions := localSessionsSnapshot(e.d.sessions)660+ sessions := sessionsSnapshot(e.d.sessions)
662 e.d.mu.Unlock()661 e.d.mu.Unlock()
663 snaps := make([]sessionView, 0, len(sessions))662 snaps := make([]sessionView, 0, len(sessions))
664 for _, sess := range sessions {663 for _, sess := range sessions {
@@ -695,7 +694,7 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
695 return "", command.ErrInvalidArguments694 return "", command.ErrInvalidArguments
696 }695 }
697 e.d.mu.Lock()696 e.d.mu.Lock()
698- sessions := localSessionsSnapshot(e.d.sessions)697+ sessions := sessionsSnapshot(e.d.sessions)
699 e.d.mu.Unlock()698 e.d.mu.Unlock()
700 rows := make([]ports.RemoteCatalogSession, 0, len(sessions))699 rows := make([]ports.RemoteCatalogSession, 0, len(sessions))
701 // Stopped sessions live in d.stopped and are intentionally not in this listing.700 // Stopped sessions live in d.stopped and are intentionally not in this listing.
M internal/usecase/daemon/daemon.go +8 -9
@@ -70,7 +70,7 @@ var defaultSize = domain.Size{Cols: 80, Rows: 24}
7070
7171 type Daemon struct {
7272 mu sync.Mutex
73- sessions map[domain.SessionID]attachmentSession
73+ sessions map[domain.SessionID]*session
7474 stopped map[string]stoppedSession
7575 // creating reserves names while durable creation I/O runs without mu.
7676 creating map[string]struct{}
@@ -144,7 +144,7 @@ type Daemon struct {
144144 afterResizeCommitSendLocked func()
145145 // afterConnectionSessionSnapshot is a deterministic test seam after the
146146 // connection loop reads its current session and before it captures that role.
147- afterConnectionSessionSnapshot func(attachmentSession)
147+ afterConnectionSessionSnapshot func(*session)
148148 // afterAttachmentFrameDispatch is a deterministic test seam after the
149149 // connection loop snapshots a token and before a decoded frame takes effect.
150150 afterAttachmentFrameDispatch func(attachmentConnectionToken)
@@ -586,7 +586,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
586586 }
587587 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())
588588 d := &Daemon{
589- sessions: make(map[domain.SessionID]attachmentSession),
589+ sessions: make(map[domain.SessionID]*session),
590590 stopped: make(map[string]stoppedSession),
591591 creating: make(map[string]struct{}),
592592 parked: make(map[uint64]*parkedAttachment),
@@ -914,7 +914,7 @@ func snapshotStopContext(deadline *snapshotShutdownDeadline) (context.Context, c
914914
915915 // registerSessionLocked publishes an exact attachment-session identity. Caller
916916 // holds d.mu.
917-func (d *Daemon) registerSessionLocked(entry attachmentSession) bool {
917+func (d *Daemon) registerSessionLocked(entry *session) bool {
918918 if entry == nil {
919919 return false
920920 }
@@ -928,7 +928,7 @@ func (d *Daemon) registerSessionLocked(entry attachmentSession) bool {
928928
929929 // unregisterSessionLocked removes only the exact registered identity. Caller
930930 // holds d.mu.
931-func (d *Daemon) unregisterSessionLocked(entry attachmentSession) bool {
931+func (d *Daemon) unregisterSessionLocked(entry *session) bool {
932932 if entry == nil {
933933 return false
934934 }
@@ -941,7 +941,7 @@ func (d *Daemon) unregisterSessionLocked(entry attachmentSession) bool {
941941 }
942942
943943 func (d *Daemon) sessionsSnapshotLocked() []*session {
944- return localSessionsSnapshot(d.sessions)
944+ return sessionsSnapshot(d.sessions)
945945 }
946946
947947 // handleConn reads the first frame off a fresh connection and routes it. A
@@ -1001,9 +1001,8 @@ func (d *Daemon) handleList(tr ports.Transport) {
10011001 d.mu.Lock()
10021002 infos := make([]ports.SessionInfo, 0, len(d.sessions)+len(d.stopped))
10031003 liveNames := make(map[string]struct{}, len(d.sessions))
1004- for _, entry := range d.sessions {
1005- s, ok := localSession(entry)
1006- if !ok {
1004+ for _, s := range d.sessions {
1005+ if s == nil {
10071006 continue
10081007 }
10091008 s.mu.Lock()
@@ -70,7 +70,7 @@ var defaultSize = domain.Size{Cols: 80, Rows: 24}
70 70
71 type Daemon struct {71 type Daemon struct {
72 mu sync.Mutex72 mu sync.Mutex
73- sessions map[domain.SessionID]attachmentSession73+ sessions map[domain.SessionID]*session
74 stopped map[string]stoppedSession74 stopped map[string]stoppedSession
75 // creating reserves names while durable creation I/O runs without mu.75 // creating reserves names while durable creation I/O runs without mu.
76 creating map[string]struct{}76 creating map[string]struct{}
@@ -144,7 +144,7 @@ type Daemon struct {
144 afterResizeCommitSendLocked func()144 afterResizeCommitSendLocked func()
145 // afterConnectionSessionSnapshot is a deterministic test seam after the145 // afterConnectionSessionSnapshot is a deterministic test seam after the
146 // connection loop reads its current session and before it captures that role.146 // connection loop reads its current session and before it captures that role.
147- afterConnectionSessionSnapshot func(attachmentSession)147+ afterConnectionSessionSnapshot func(*session)
148 // afterAttachmentFrameDispatch is a deterministic test seam after the148 // afterAttachmentFrameDispatch is a deterministic test seam after the
149 // connection loop snapshots a token and before a decoded frame takes effect.149 // connection loop snapshots a token and before a decoded frame takes effect.
150 afterAttachmentFrameDispatch func(attachmentConnectionToken)150 afterAttachmentFrameDispatch func(attachmentConnectionToken)
@@ -586,7 +586,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
586 }586 }
587 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())587 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())
588 d := &Daemon{588 d := &Daemon{
589- sessions: make(map[domain.SessionID]attachmentSession),589+ sessions: make(map[domain.SessionID]*session),
590 stopped: make(map[string]stoppedSession),590 stopped: make(map[string]stoppedSession),
591 creating: make(map[string]struct{}),591 creating: make(map[string]struct{}),
592 parked: make(map[uint64]*parkedAttachment),592 parked: make(map[uint64]*parkedAttachment),
@@ -914,7 +914,7 @@ func snapshotStopContext(deadline *snapshotShutdownDeadline) (context.Context, c
914 914
915 // registerSessionLocked publishes an exact attachment-session identity. Caller915 // registerSessionLocked publishes an exact attachment-session identity. Caller
916 // holds d.mu.916 // holds d.mu.
917-func (d *Daemon) registerSessionLocked(entry attachmentSession) bool {917+func (d *Daemon) registerSessionLocked(entry *session) bool {
918 if entry == nil {918 if entry == nil {
919 return false919 return false
920 }920 }
@@ -928,7 +928,7 @@ func (d *Daemon) registerSessionLocked(entry attachmentSession) bool {
928 928
929 // unregisterSessionLocked removes only the exact registered identity. Caller929 // unregisterSessionLocked removes only the exact registered identity. Caller
930 // holds d.mu.930 // holds d.mu.
931-func (d *Daemon) unregisterSessionLocked(entry attachmentSession) bool {931+func (d *Daemon) unregisterSessionLocked(entry *session) bool {
932 if entry == nil {932 if entry == nil {
933 return false933 return false
934 }934 }
@@ -941,7 +941,7 @@ func (d *Daemon) unregisterSessionLocked(entry attachmentSession) bool {
941 }941 }
942 942
943 func (d *Daemon) sessionsSnapshotLocked() []*session {943 func (d *Daemon) sessionsSnapshotLocked() []*session {
944- return localSessionsSnapshot(d.sessions)944+ return sessionsSnapshot(d.sessions)
945 }945 }
946 946
947 // handleConn reads the first frame off a fresh connection and routes it. A947 // handleConn reads the first frame off a fresh connection and routes it. A
@@ -1001,9 +1001,8 @@ func (d *Daemon) handleList(tr ports.Transport) {
1001 d.mu.Lock()1001 d.mu.Lock()
1002 infos := make([]ports.SessionInfo, 0, len(d.sessions)+len(d.stopped))1002 infos := make([]ports.SessionInfo, 0, len(d.sessions)+len(d.stopped))
1003 liveNames := make(map[string]struct{}, len(d.sessions))1003 liveNames := make(map[string]struct{}, len(d.sessions))
1004- for _, entry := range d.sessions {1004+ for _, s := range d.sessions {
1005- s, ok := localSession(entry)1005+ if s == nil {
1006- if !ok {
1007 continue1006 continue
1008 }1007 }
1009 s.mu.Lock()1008 s.mu.Lock()
M internal/usecase/daemon/daemon_test.go +2 -2
@@ -297,8 +297,8 @@ func awaitFrame(t *testing.T, ch chan ports.Frame, typ ports.MsgType) ports.Fram
297297 func firstSession(d *Daemon) *session {
298298 d.mu.Lock()
299299 defer d.mu.Unlock()
300- for _, entry := range d.sessions {
301- if s, ok := localSession(entry); ok {
300+ for _, s := range d.sessions {
301+ if s != nil {
302302 return s
303303 }
304304 }
@@ -297,8 +297,8 @@ func awaitFrame(t *testing.T, ch chan ports.Frame, typ ports.MsgType) ports.Fram
297 func firstSession(d *Daemon) *session {297 func firstSession(d *Daemon) *session {
298 d.mu.Lock()298 d.mu.Lock()
299 defer d.mu.Unlock()299 defer d.mu.Unlock()
300- for _, entry := range d.sessions {300+ for _, s := range d.sessions {
301- if s, ok := localSession(entry); ok {301+ if s != nil {
302 return s302 return s
303 }303 }
304 }304 }
M internal/usecase/daemon/input.go +3 -5
@@ -383,8 +383,7 @@ type daemonKeyHandler struct {
383383 func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) {
384384 if h.connectionToken.ac != nil {
385385 if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {
386- sess, _ := localSession(h.connectionToken.sess)
387- return sess, effect, false
386+ return h.connectionToken.sess, effect, false
388387 }
389388 effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken)
390389 if h.d.afterDelayedKeyEffectAttempt != nil {
@@ -398,12 +397,11 @@ func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffect
398397 token.effect = effect
399398 h.d.afterAttachmentEffectAdmitted(token)
400399 }
401- sess, ok := localSession(h.connectionToken.sess)
402- if !ok {
400+ if h.connectionToken.sess == nil {
403401 effect.End()
404402 return nil, nil, false
405403 }
406- return sess, effect, true
404+ return h.connectionToken.sess, effect, true
407405 }
408406 sess := h.ac.currentSession()
409407 if sess == nil {
@@ -383,8 +383,7 @@ type daemonKeyHandler struct {
383 func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) {383 func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) {
384 if h.connectionToken.ac != nil {384 if h.connectionToken.ac != nil {
385 if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {385 if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {
386- sess, _ := localSession(h.connectionToken.sess)386+ return h.connectionToken.sess, effect, false
387- return sess, effect, false
388 }387 }
389 effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken)388 effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken)
390 if h.d.afterDelayedKeyEffectAttempt != nil {389 if h.d.afterDelayedKeyEffectAttempt != nil {
@@ -398,12 +397,11 @@ func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffect
398 token.effect = effect397 token.effect = effect
399 h.d.afterAttachmentEffectAdmitted(token)398 h.d.afterAttachmentEffectAdmitted(token)
400 }399 }
401- sess, ok := localSession(h.connectionToken.sess)400+ if h.connectionToken.sess == nil {
402- if !ok {
403 effect.End()401 effect.End()
404 return nil, nil, false402 return nil, nil, false
405 }403 }
406- return sess, effect, true404+ return h.connectionToken.sess, effect, true
407 }405 }
408 sess := h.ac.currentSession()406 sess := h.ac.currentSession()
409 if sess == nil {407 if sess == nil {
M internal/usecase/daemon/move.go +2 -2
@@ -180,8 +180,8 @@ func moveSessionForLocatorLocked(d *Daemon, locator moveSessionLocator) *session
180180 if d == nil {
181181 return nil
182182 }
183- sess, ok := localSession(d.sessions[locator.ID])
184- if !ok || sess.incarnation != locator.Incarnation {
183+ sess := d.sessions[locator.ID]
184+ if sess == nil || sess.incarnation != locator.Incarnation {
185185 return nil
186186 }
187187 return sess
@@ -180,8 +180,8 @@ func moveSessionForLocatorLocked(d *Daemon, locator moveSessionLocator) *session
180 if d == nil {180 if d == nil {
181 return nil181 return nil
182 }182 }
183- sess, ok := localSession(d.sessions[locator.ID])183+ sess := d.sessions[locator.ID]
184- if !ok || sess.incarnation != locator.Incarnation {184+ if sess == nil || sess.incarnation != locator.Incarnation {
185 return nil185 return nil
186 }186 }
187 return sess187 return sess
M internal/usecase/daemon/move_picker.go +4 -4
@@ -10,8 +10,8 @@ func (d *Daemon) movePickerSourceError(source moveSourceLocator) error {
1010 if d == nil || source.Session.ID == "" || source.TabID == "" {
1111 return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)
1212 }
13- sess, ok := localSession(d.sessionByID(source.Session.ID))
14- if !ok || sess.incarnation != source.Session.Incarnation {
13+ sess := d.sessionByID(source.Session.ID)
14+ if sess == nil || sess.incarnation != source.Session.Incarnation {
1515 return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)
1616 }
1717 sess.mu.Lock()
@@ -120,8 +120,8 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc
120120
121121 func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*session, *tab) {
122122 d.mu.Lock()
123- sess, ok := localSession(d.sessions[target.Session])
124- if !ok {
123+ sess := d.sessions[target.Session]
124+ if sess == nil {
125125 d.mu.Unlock()
126126 return nil, nil
127127 }
@@ -10,8 +10,8 @@ func (d *Daemon) movePickerSourceError(source moveSourceLocator) error {
10 if d == nil || source.Session.ID == "" || source.TabID == "" {10 if d == nil || source.Session.ID == "" || source.TabID == "" {
11 return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)11 return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)
12 }12 }
13- sess, ok := localSession(d.sessionByID(source.Session.ID))13+ sess := d.sessionByID(source.Session.ID)
14- if !ok || sess.incarnation != source.Session.Incarnation {14+ if sess == nil || sess.incarnation != source.Session.Incarnation {
15 return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)15 return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)
16 }16 }
17 sess.mu.Lock()17 sess.mu.Lock()
@@ -120,8 +120,8 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc
120 120
121 func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*session, *tab) {121 func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*session, *tab) {
122 d.mu.Lock()122 d.mu.Lock()
123- sess, ok := localSession(d.sessions[target.Session])123+ sess := d.sessions[target.Session]
124- if !ok {124+ if sess == nil {
125 d.mu.Unlock()125 d.mu.Unlock()
126 return nil, nil126 return nil, nil
127 }127 }
M internal/usecase/daemon/navigation_overflow.go +1 -1
@@ -85,7 +85,7 @@ func (d *Daemon) prepareSessionOverflow(current *session, dir layout.Direction,
8585 current.mu.Unlock()
8686
8787 d.mu.Lock()
88- live := localSessionsSnapshot(d.sessions)
88+ live := sessionsSnapshot(d.sessions)
8989 d.mu.Unlock()
9090
9191 snapshots := make([]sessionOverflowSnapshot, 0, len(live))
@@ -85,7 +85,7 @@ func (d *Daemon) prepareSessionOverflow(current *session, dir layout.Direction,
85 current.mu.Unlock()85 current.mu.Unlock()
86 86
87 d.mu.Lock()87 d.mu.Lock()
88- live := localSessionsSnapshot(d.sessions)88+ live := sessionsSnapshot(d.sessions)
89 d.mu.Unlock()89 d.mu.Unlock()
90 90
91 snapshots := make([]sessionOverflowSnapshot, 0, len(live))91 snapshots := make([]sessionOverflowSnapshot, 0, len(live))
M internal/usecase/daemon/navigation_overflow_test.go +4 -6
@@ -152,9 +152,8 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t
152152 d.mu.Lock()
153153 _, stopped := d.stopped["bravo"]
154154 var bravoLive bool
155- for _, entry := range d.sessions {
156- candidate, ok := localSession(entry)
157- if !ok {
155+ for _, candidate := range d.sessions {
156+ if candidate == nil {
158157 continue
159158 }
160159 candidate.mu.Lock()
@@ -322,9 +321,8 @@ func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) {
322321 current := ac.currentSession()
323322 owners := make([]*session, 0, 1)
324323 d.mu.Lock()
325- for _, entry := range d.sessions {
326- candidate, ok := localSession(entry)
327- if !ok {
324+ for _, candidate := range d.sessions {
325+ if candidate == nil {
328326 continue
329327 }
330328 candidate.mu.Lock()
@@ -152,9 +152,8 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t
152 d.mu.Lock()152 d.mu.Lock()
153 _, stopped := d.stopped["bravo"]153 _, stopped := d.stopped["bravo"]
154 var bravoLive bool154 var bravoLive bool
155- for _, entry := range d.sessions {155+ for _, candidate := range d.sessions {
156- candidate, ok := localSession(entry)156+ if candidate == nil {
157- if !ok {
158 continue157 continue
159 }158 }
160 candidate.mu.Lock()159 candidate.mu.Lock()
@@ -322,9 +321,8 @@ func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) {
322 current := ac.currentSession()321 current := ac.currentSession()
323 owners := make([]*session, 0, 1)322 owners := make([]*session, 0, 1)
324 d.mu.Lock()323 d.mu.Lock()
325- for _, entry := range d.sessions {324+ for _, candidate := range d.sessions {
326- candidate, ok := localSession(entry)325+ if candidate == nil {
327- if !ok {
328 continue326 continue
329 }327 }
330 candidate.mu.Lock()328 candidate.mu.Lock()
M internal/usecase/daemon/notices_overlay.go +4 -4
@@ -17,7 +17,7 @@ var noticesModal = ui.Modal{WidthPct: 70, HeightPct: 70, MinWidth: 40, MinHeight
1717
1818 // enterNotices opens the notification history overlay from the daemon's full
1919 // notice center history, newest first.
20-func (d *Daemon) enterNotices(sess attachmentSession, ac *attachedClient) {
20+func (d *Daemon) enterNotices(sess *session, ac *attachedClient) {
2121 history := d.notices.history()
2222 now := d.clock.Now()
2323 ac.overlays.noticeMu.Lock()
@@ -221,13 +221,13 @@ func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {
221221 // OSC52, mirroring copy mode's own yank path (copymode.go handleCopyInput):
222222 // send each chunk, then leave a one-shot status-bar confirmation that the
223223 // next repaint clears.
224-func (d *Daemon) yankNotice(entry attachmentSession, ac *attachedClient, n domain.Notification) {
224+func (d *Daemon) yankNotice(entry *session, ac *attachedClient, n domain.Notification) {
225225 chunks := scopy.OSC52(noticeYankPayload(n))
226226 for _, chunk := range chunks {
227227 failed, err := d.boundedSendOutputErrTransport(ac, chunk)
228228 if err != nil {
229- if sess, ok := localSession(entry); ok {
230- d.detachOnSendError(sess, ac, failed)
229+ if entry != nil {
230+ d.detachOnSendError(entry, ac, failed)
231231 } else {
232232 _ = ac.closeCapturedTransport(failed)
233233 }
@@ -17,7 +17,7 @@ var noticesModal = ui.Modal{WidthPct: 70, HeightPct: 70, MinWidth: 40, MinHeight
17 17
18 // enterNotices opens the notification history overlay from the daemon's full18 // enterNotices opens the notification history overlay from the daemon's full
19 // notice center history, newest first.19 // notice center history, newest first.
20-func (d *Daemon) enterNotices(sess attachmentSession, ac *attachedClient) {20+func (d *Daemon) enterNotices(sess *session, ac *attachedClient) {
21 history := d.notices.history()21 history := d.notices.history()
22 now := d.clock.Now()22 now := d.clock.Now()
23 ac.overlays.noticeMu.Lock()23 ac.overlays.noticeMu.Lock()
@@ -221,13 +221,13 @@ func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {
221 // OSC52, mirroring copy mode's own yank path (copymode.go handleCopyInput):221 // OSC52, mirroring copy mode's own yank path (copymode.go handleCopyInput):
222 // send each chunk, then leave a one-shot status-bar confirmation that the222 // send each chunk, then leave a one-shot status-bar confirmation that the
223 // next repaint clears.223 // next repaint clears.
224-func (d *Daemon) yankNotice(entry attachmentSession, ac *attachedClient, n domain.Notification) {224+func (d *Daemon) yankNotice(entry *session, ac *attachedClient, n domain.Notification) {
225 chunks := scopy.OSC52(noticeYankPayload(n))225 chunks := scopy.OSC52(noticeYankPayload(n))
226 for _, chunk := range chunks {226 for _, chunk := range chunks {
227 failed, err := d.boundedSendOutputErrTransport(ac, chunk)227 failed, err := d.boundedSendOutputErrTransport(ac, chunk)
228 if err != nil {228 if err != nil {
229- if sess, ok := localSession(entry); ok {229+ if entry != nil {
230- d.detachOnSendError(sess, ac, failed)230+ d.detachOnSendError(entry, ac, failed)
231 } else {231 } else {
232 _ = ac.closeCapturedTransport(failed)232 _ = ac.closeCapturedTransport(failed)
233 }233 }
M internal/usecase/daemon/notify.go +4 -5
@@ -236,9 +236,8 @@ func (d *Daemon) reportError(sess *session, err error) {
236236 d.notify(sess, domain.NoticeError, domain.NoticeInternal, "internal error", err)
237237 }
238238
239-func (d *Daemon) reportAttachmentError(entry attachmentSession, err error) {
240- local, _ := localSession(entry)
241- d.reportError(local, err)
239+func (d *Daemon) reportAttachmentError(entry *session, err error) {
240+ d.reportError(entry, err)
242241 }
243242
244243 // notify records a notice and routes it to whoever can see it. A nil sess means
@@ -300,7 +299,7 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No
300299 func (d *Daemon) deliverGlobal(n domain.Notification) {
301300 d.mu.Lock()
302301 d.notices.routingMu.Lock()
303- sessions := make([]attachmentSession, 0, len(d.sessions))
302+ sessions := make([]*session, 0, len(d.sessions))
304303 for _, entry := range d.sessions {
305304 sessions = append(sessions, entry)
306305 }
@@ -311,7 +310,7 @@ func (d *Daemon) deliverGlobal(n domain.Notification) {
311310 }
312311 targets := make([]*attachedClient, 0, len(sessions))
313312 for _, s := range sessions {
314- for _, ac := range snapshotAttachmentSession(s) {
313+ for _, ac := range s.snapshotAttachments() {
315314 if !d.publishToast(ac, n) {
316315 continue
317316 }
@@ -236,9 +236,8 @@ func (d *Daemon) reportError(sess *session, err error) {
236 d.notify(sess, domain.NoticeError, domain.NoticeInternal, "internal error", err)236 d.notify(sess, domain.NoticeError, domain.NoticeInternal, "internal error", err)
237 }237 }
238 238
239-func (d *Daemon) reportAttachmentError(entry attachmentSession, err error) {239+func (d *Daemon) reportAttachmentError(entry *session, err error) {
240- local, _ := localSession(entry)240+ d.reportError(entry, err)
241- d.reportError(local, err)
242 }241 }
243 242
244 // notify records a notice and routes it to whoever can see it. A nil sess means243 // notify records a notice and routes it to whoever can see it. A nil sess means
@@ -300,7 +299,7 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No
300 func (d *Daemon) deliverGlobal(n domain.Notification) {299 func (d *Daemon) deliverGlobal(n domain.Notification) {
301 d.mu.Lock()300 d.mu.Lock()
302 d.notices.routingMu.Lock()301 d.notices.routingMu.Lock()
303- sessions := make([]attachmentSession, 0, len(d.sessions))302+ sessions := make([]*session, 0, len(d.sessions))
304 for _, entry := range d.sessions {303 for _, entry := range d.sessions {
305 sessions = append(sessions, entry)304 sessions = append(sessions, entry)
306 }305 }
@@ -311,7 +310,7 @@ func (d *Daemon) deliverGlobal(n domain.Notification) {
311 }310 }
312 targets := make([]*attachedClient, 0, len(sessions))311 targets := make([]*attachedClient, 0, len(sessions))
313 for _, s := range sessions {312 for _, s := range sessions {
314- for _, ac := range snapshotAttachmentSession(s) {313+ for _, ac := range s.snapshotAttachments() {
315 if !d.publishToast(ac, n) {314 if !d.publishToast(ac, n) {
316 continue315 continue
317 }316 }
M internal/usecase/daemon/output_state_stream.go +6 -3
@@ -71,7 +71,6 @@ func newOutputStateStream(windowSize ...uint8) *outputStateStream {
7171 maxOutstanding: uint64(window),
7272 }
7373 stream.maxOutstandingAtomic.Store(uint64(window))
74- stream.publishOutstanding()
7574 return stream
7675 }
7776
@@ -287,8 +286,8 @@ func (s *outputStateStream) rebaseLocked() {
287286 s.next = 0
288287 s.acked = 0
289288 s.generation++
290- s.maxOutstandingAtomic.Store(s.maxOutstanding)
291289 s.publishOutstanding()
290+ s.maxOutstandingAtomic.Store(s.maxOutstanding)
292291 s.renderer.Reset()
293292 s.forceSnapshot = true
294293 s.initialized = false
@@ -317,7 +316,11 @@ func (s *outputStateStream) atCapacity() bool {
317316 if s == nil {
318317 return false
319318 }
320- return s.outstandingAtomic.Load() >= s.maxOutstandingAtomic.Load()
319+ maxOutstanding := s.maxOutstandingAtomic.Load()
320+ if maxOutstanding == 0 {
321+ maxOutstanding = s.maxOutstanding
322+ }
323+ return s.outstandingAtomic.Load() >= maxOutstanding
321324 }
322325
323326 func (s *outputStateStream) outstanding() uint64 {
@@ -71,7 +71,6 @@ func newOutputStateStream(windowSize ...uint8) *outputStateStream {
71 maxOutstanding: uint64(window),71 maxOutstanding: uint64(window),
72 }72 }
73 stream.maxOutstandingAtomic.Store(uint64(window))73 stream.maxOutstandingAtomic.Store(uint64(window))
74- stream.publishOutstanding()
75 return stream74 return stream
76 }75 }
77 76
@@ -287,8 +286,8 @@ func (s *outputStateStream) rebaseLocked() {
287 s.next = 0286 s.next = 0
288 s.acked = 0287 s.acked = 0
289 s.generation++288 s.generation++
290- s.maxOutstandingAtomic.Store(s.maxOutstanding)
291 s.publishOutstanding()289 s.publishOutstanding()
290+ s.maxOutstandingAtomic.Store(s.maxOutstanding)
292 s.renderer.Reset()291 s.renderer.Reset()
293 s.forceSnapshot = true292 s.forceSnapshot = true
294 s.initialized = false293 s.initialized = false
@@ -317,7 +316,11 @@ func (s *outputStateStream) atCapacity() bool {
317 if s == nil {316 if s == nil {
318 return false317 return false
319 }318 }
320- return s.outstandingAtomic.Load() >= s.maxOutstandingAtomic.Load()319+ maxOutstanding := s.maxOutstandingAtomic.Load()
320+ if maxOutstanding == 0 {
321+ maxOutstanding = s.maxOutstanding
322+ }
323+ return s.outstandingAtomic.Load() >= maxOutstanding
321 }324 }
322 325
323 func (s *outputStateStream) outstanding() uint64 {326 func (s *outputStateStream) outstanding() uint64 {
M internal/usecase/daemon/palette.go +10 -24
@@ -150,7 +150,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
150150 if entry == nil {
151151 return
152152 }
153- sess, local := localSession(entry)
153+ sess := entry
154154 var cmd command.Command
155155 var sessionTarget palette.Result
156156 var hasSessionTarget bool
@@ -276,10 +276,8 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
276276 var err error
277277 if effect != nil {
278278 err = d.switchToTargetForAttachment(effect.connectionToken(), target, sessionHandoffGuard{}, "palette-session")
279- } else if local {
280- err = d.switchToTarget(sess, ac, target)
281279 } else {
282- err = errAttachmentTransition
280+ err = d.switchToTarget(sess, ac, target)
283281 }
284282 if errors.Is(err, errAttachmentTransition) {
285283 return
@@ -317,12 +315,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
317315 }
318316 return
319317 }
320- if !local {
321- if ac.closeExecutedPalette(generation, rawQuery) {
322- d.notify(nil, domain.NoticeError, domain.NoticeSessionUnavailable, cmd.Name+" is unavailable for this attachment", nil)
323- }
324- return
325- }
326318 attachmentHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach"
327319 if !attachmentHandoff && !ac.closeExecutedPalette(generation, rawQuery) {
328320 return
@@ -401,7 +393,7 @@ func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery strin
401393 type paletteExec struct {
402394 d *Daemon
403395 sess *session
404- attachment attachmentSession
396+ attachment *session
405397 ac *attachedClient
406398 recent []recentSession
407399 actions daemonActionRunner
@@ -445,11 +437,10 @@ func (e paletteExec) CreateSession() error {
445437 }
446438 e.d.enterTransitionPrompt(entry, e.ac, " Create session ", "", func(name string, token attachmentConnectionToken) error {
447439 if token.ac == nil {
448- sess, ok := e.localSession()
449- if !ok {
440+ if e.sess == nil {
450441 return errAttachmentTransition
451442 }
452- return e.d.createSessionAndSwitch(sess, e.ac, name)
443+ return e.d.createSessionAndSwitch(e.sess, e.ac, name)
453444 }
454445 return e.d.createSessionAndSwitchForAttachment(token, name)
455446 })
@@ -633,14 +624,6 @@ func (e paletteExec) YankLastNotification() error {
633624 return nil
634625 }
635626
636-func (e paletteExec) localSession() (*session, bool) {
637- entry := e.attachment
638- if entry == nil {
639- entry = e.sess
640- }
641- return localSession(entry)
642-}
643-
644627 func (e paletteExec) JumpRecentSession(rank int) error {
645628 if rank < 1 || rank > len(e.recent) {
646629 return command.ErrInvalidArguments
@@ -656,8 +639,11 @@ func (e paletteExec) JumpRecentSession(rank int) error {
656639 if e.effect != nil {
657640 err = e.d.switchToTargetForAttachment(e.effect.connectionToken(), picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "palette-recent-session")
658641 } else {
659- sess, ok := e.localSession()
660- if !ok {
642+ sess := e.attachment
643+ if sess == nil {
644+ sess = e.sess
645+ }
646+ if sess == nil {
661647 return errAttachmentTransition
662648 }
663649 err = e.d.switchToTarget(sess, e.ac, picker.Target{Session: target.id, TabIndex: -1})
@@ -150,7 +150,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
150 if entry == nil {150 if entry == nil {
151 return151 return
152 }152 }
153- sess, local := localSession(entry)153+ sess := entry
154 var cmd command.Command154 var cmd command.Command
155 var sessionTarget palette.Result155 var sessionTarget palette.Result
156 var hasSessionTarget bool156 var hasSessionTarget bool
@@ -276,10 +276,8 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
276 var err error276 var err error
277 if effect != nil {277 if effect != nil {
278 err = d.switchToTargetForAttachment(effect.connectionToken(), target, sessionHandoffGuard{}, "palette-session")278 err = d.switchToTargetForAttachment(effect.connectionToken(), target, sessionHandoffGuard{}, "palette-session")
279- } else if local {
280- err = d.switchToTarget(sess, ac, target)
281 } else {279 } else {
282- err = errAttachmentTransition280+ err = d.switchToTarget(sess, ac, target)
283 }281 }
284 if errors.Is(err, errAttachmentTransition) {282 if errors.Is(err, errAttachmentTransition) {
285 return283 return
@@ -317,12 +315,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
317 }315 }
318 return316 return
319 }317 }
320- if !local {
321- if ac.closeExecutedPalette(generation, rawQuery) {
322- d.notify(nil, domain.NoticeError, domain.NoticeSessionUnavailable, cmd.Name+" is unavailable for this attachment", nil)
323- }
324- return
325- }
326 attachmentHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach"318 attachmentHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach"
327 if !attachmentHandoff && !ac.closeExecutedPalette(generation, rawQuery) {319 if !attachmentHandoff && !ac.closeExecutedPalette(generation, rawQuery) {
328 return320 return
@@ -401,7 +393,7 @@ func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery strin
401 type paletteExec struct {393 type paletteExec struct {
402 d *Daemon394 d *Daemon
403 sess *session395 sess *session
404- attachment attachmentSession396+ attachment *session
405 ac *attachedClient397 ac *attachedClient
406 recent []recentSession398 recent []recentSession
407 actions daemonActionRunner399 actions daemonActionRunner
@@ -445,11 +437,10 @@ func (e paletteExec) CreateSession() error {
445 }437 }
446 e.d.enterTransitionPrompt(entry, e.ac, " Create session ", "", func(name string, token attachmentConnectionToken) error {438 e.d.enterTransitionPrompt(entry, e.ac, " Create session ", "", func(name string, token attachmentConnectionToken) error {
447 if token.ac == nil {439 if token.ac == nil {
448- sess, ok := e.localSession()440+ if e.sess == nil {
449- if !ok {
450 return errAttachmentTransition441 return errAttachmentTransition
451 }442 }
452- return e.d.createSessionAndSwitch(sess, e.ac, name)443+ return e.d.createSessionAndSwitch(e.sess, e.ac, name)
453 }444 }
454 return e.d.createSessionAndSwitchForAttachment(token, name)445 return e.d.createSessionAndSwitchForAttachment(token, name)
455 })446 })
@@ -633,14 +624,6 @@ func (e paletteExec) YankLastNotification() error {
633 return nil624 return nil
634 }625 }
635 626
636-func (e paletteExec) localSession() (*session, bool) {
637- entry := e.attachment
638- if entry == nil {
639- entry = e.sess
640- }
641- return localSession(entry)
642-}
643-
644 func (e paletteExec) JumpRecentSession(rank int) error {627 func (e paletteExec) JumpRecentSession(rank int) error {
645 if rank < 1 || rank > len(e.recent) {628 if rank < 1 || rank > len(e.recent) {
646 return command.ErrInvalidArguments629 return command.ErrInvalidArguments
@@ -656,8 +639,11 @@ func (e paletteExec) JumpRecentSession(rank int) error {
656 if e.effect != nil {639 if e.effect != nil {
657 err = e.d.switchToTargetForAttachment(e.effect.connectionToken(), picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "palette-recent-session")640 err = e.d.switchToTargetForAttachment(e.effect.connectionToken(), picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "palette-recent-session")
658 } else {641 } else {
659- sess, ok := e.localSession()642+ sess := e.attachment
660- if !ok {643+ if sess == nil {
644+ sess = e.sess
645+ }
646+ if sess == nil {
661 return errAttachmentTransition647 return errAttachmentTransition
662 }648 }
663 err = e.d.switchToTarget(sess, e.ac, picker.Target{Session: target.id, TabIndex: -1})649 err = e.d.switchToTarget(sess, e.ac, picker.Target{Session: target.id, TabIndex: -1})
M internal/usecase/daemon/picker.go +24 -30
@@ -51,12 +51,12 @@ func (d *Daemon) togglePickerSort() {
5151 // enterPicker preserves the existing navigation entry point. Navigation always
5252 // publishes its model, including an empty one; only move entry can fail for a
5353 // missing destination.
54-func (d *Daemon) enterPicker(sess attachmentSession, ac *attachedClient) {
54+func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
5555 model := d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
5656 d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
5757 }
5858
59-func (d *Daemon) publishPicker(sess attachmentSession, ac *attachedClient, model *picker.Model, intent pickerIntent, source moveSourceLocator) {
59+func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.Model, intent pickerIntent, source moveSourceLocator) {
6060 rt := ac.overlays
6161 rt.pickerMu.Lock()
6262 previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration
@@ -83,9 +83,9 @@ func (d *Daemon) publishPicker(sess attachmentSession, ac *attachedClient, model
8383
8484 // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally
8585 // knows nothing about picker intent; picker.New owns all destination policy.
86-func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picker.SessionView, picker.SourceFilter) {
86+func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.SessionView, picker.SourceFilter) {
8787 d.mu.Lock()
88- sessions := make([]attachmentSession, 0, len(d.sessions))
88+ sessions := make([]*session, 0, len(d.sessions))
8989 for _, entry := range d.sessions {
9090 sessions = append(sessions, entry)
9191 }
@@ -98,14 +98,14 @@ func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picke
9898 d.mu.Unlock()
9999
100100 for _, entry := range sessions {
101- if local, ok := localSession(entry); ok {
102- d.refreshSessionFocusedTitles(local)
101+ if entry != nil {
102+ d.refreshSessionFocusedTitles(entry)
103103 }
104104 }
105105 opts := viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: d.currentTabsConfig().TerminalTitle}
106106
107107 type liveSnapshot struct {
108- entry attachmentSession
108+ entry *session
109109 view sessionView
110110 }
111111 // One snapshot per live session: sorting and view building read the same
@@ -116,13 +116,11 @@ func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picke
116116 var current picker.SourceFilter
117117 for _, entry := range sessions {
118118 if entry == cur && ac != nil {
119- if local, ok := localSession(entry); ok {
120- local.repairAttachmentView(ac)
121- }
119+ entry.repairAttachmentView(ac)
122120 }
123121 snap := entry.snapshotView(opts)
124122 if entry == cur {
125- if _, ok := localSession(entry); ok && ac != nil {
123+ if ac != nil {
126124 view := ac.viewSnapshot()
127125 if view.tabID != "" {
128126 current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: view.tabID}
@@ -197,7 +195,7 @@ func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picke
197195 return views, current
198196 }
199197
200-func (d *Daemon) newPickerModel(cur attachmentSession, ac *attachedClient, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model {
198+func (d *Daemon) newPickerModel(cur *session, ac *attachedClient, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model {
201199 views, attachedCurrent := d.pickerViews(cur, ac)
202200 if intent == pickerNavigate && current == (picker.SourceFilter{}) {
203201 current = attachedCurrent
@@ -346,11 +344,9 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
346344 var err error
347345 if len(effects) != 0 && effects[0] != nil {
348346 err = d.switchToTargetForAttachment(effects[0].connectionToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select")
349- } else if local, ok := localSession(sess); ok {
350- d.closePicker(ac)
351- err = d.switchToTarget(local, ac, target)
352347 } else {
353- err = errAttachmentTransition
348+ d.closePicker(ac)
349+ err = d.switchToTarget(sess, ac, target)
354350 }
355351 if errors.Is(err, errAttachmentTransition) {
356352 return
@@ -479,9 +475,8 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
479475 }
480476 d.mu.Lock()
481477 var clients []*attachedClient
482- for _, entry := range d.sessions {
483- sess, ok := localSession(entry)
484- if !ok {
478+ for _, sess := range d.sessions {
479+ if sess == nil {
485480 continue
486481 }
487482 sess.mu.Lock()
@@ -556,7 +551,7 @@ func remoteKeysEqual(left, right *domain.RemoteSessionKey) bool {
556551 return *left == *right
557552 }
558553
559-func (d *Daemon) sessionByID(id domain.SessionID) attachmentSession {
554+func (d *Daemon) sessionByID(id domain.SessionID) *session {
560555 d.mu.Lock()
561556 defer d.mu.Unlock()
562557 return d.sessions[id]
@@ -605,9 +600,9 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnecti
605600 return nil
606601 }
607602 d.mu.Lock()
608- targetSess, ok := localSession(d.sessions[target.Session])
603+ targetSess := d.sessions[target.Session]
609604 d.mu.Unlock()
610- if !ok {
605+ if targetSess == nil {
611606 if !token.attachmentCurrent() {
612607 return nil
613608 }
@@ -667,11 +662,10 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
667662 }
668663 token.effect.bindActionEnd(d, action)
669664 token.effect.End()
670- sess, ok := localSession(token.sess)
671- if !ok {
665+ if token.sess == nil {
672666 return errAttachmentTransition
673667 }
674- return d.switchToTargetGuardedForAttachment(sess, token.ac, target, guard, &token, action)
668+ return d.switchToTargetGuardedForAttachment(token.sess, token.ac, target, guard, &token, action)
675669 }
676670
677671 // sendRemoteAttachTargetForAttachment validates the catalog row and hands the
@@ -755,7 +749,7 @@ func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedC
755749 targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard, sourceToken, action)
756750 }
757751 }
758- } else if active, ok := localSession(d.sessions[target.Session]); ok {
752+ } else if active := d.sessions[target.Session]; active != nil {
759753 targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard, sourceToken, action)
760754 }
761755 d.mu.Unlock()
@@ -1031,9 +1025,9 @@ func (d *Daemon) killPickerTargetForAttachment(target picker.Target, token attac
10311025 return d.killPickerTarget(target)
10321026 }
10331027 d.mu.Lock()
1034- targetSess, ok := localSession(d.sessions[target.Session])
1028+ targetSess := d.sessions[target.Session]
10351029 d.mu.Unlock()
1036- if !ok {
1030+ if targetSess == nil {
10371031 return nil
10381032 }
10391033 return d.killSessionForAttachment(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete")
@@ -1048,9 +1042,9 @@ func (d *Daemon) killPickerTarget(target picker.Target) error {
10481042 return nil
10491043 }
10501044 d.mu.Lock()
1051- targetSess, ok := localSession(d.sessions[target.Session])
1045+ targetSess := d.sessions[target.Session]
10521046 d.mu.Unlock()
1053- if ok {
1047+ if targetSess != nil {
10541048 return d.killSession(targetSess, ports.ReasonSessionKilled, true)
10551049 }
10561050 return nil
@@ -51,12 +51,12 @@ func (d *Daemon) togglePickerSort() {
51 // enterPicker preserves the existing navigation entry point. Navigation always51 // enterPicker preserves the existing navigation entry point. Navigation always
52 // publishes its model, including an empty one; only move entry can fail for a52 // publishes its model, including an empty one; only move entry can fail for a
53 // missing destination.53 // missing destination.
54-func (d *Daemon) enterPicker(sess attachmentSession, ac *attachedClient) {54+func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
55 model := d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})55 model := d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
56 d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})56 d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
57 }57 }
58 58
59-func (d *Daemon) publishPicker(sess attachmentSession, ac *attachedClient, model *picker.Model, intent pickerIntent, source moveSourceLocator) {59+func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.Model, intent pickerIntent, source moveSourceLocator) {
60 rt := ac.overlays60 rt := ac.overlays
61 rt.pickerMu.Lock()61 rt.pickerMu.Lock()
62 previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration62 previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration
@@ -83,9 +83,9 @@ func (d *Daemon) publishPicker(sess attachmentSession, ac *attachedClient, model
83 83
84 // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally84 // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally
85 // knows nothing about picker intent; picker.New owns all destination policy.85 // knows nothing about picker intent; picker.New owns all destination policy.
86-func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picker.SessionView, picker.SourceFilter) {86+func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.SessionView, picker.SourceFilter) {
87 d.mu.Lock()87 d.mu.Lock()
88- sessions := make([]attachmentSession, 0, len(d.sessions))88+ sessions := make([]*session, 0, len(d.sessions))
89 for _, entry := range d.sessions {89 for _, entry := range d.sessions {
90 sessions = append(sessions, entry)90 sessions = append(sessions, entry)
91 }91 }
@@ -98,14 +98,14 @@ func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picke
98 d.mu.Unlock()98 d.mu.Unlock()
99 99
100 for _, entry := range sessions {100 for _, entry := range sessions {
101- if local, ok := localSession(entry); ok {101+ if entry != nil {
102- d.refreshSessionFocusedTitles(local)102+ d.refreshSessionFocusedTitles(entry)
103 }103 }
104 }104 }
105 opts := viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: d.currentTabsConfig().TerminalTitle}105 opts := viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: d.currentTabsConfig().TerminalTitle}
106 106
107 type liveSnapshot struct {107 type liveSnapshot struct {
108- entry attachmentSession108+ entry *session
109 view sessionView109 view sessionView
110 }110 }
111 // One snapshot per live session: sorting and view building read the same111 // One snapshot per live session: sorting and view building read the same
@@ -116,13 +116,11 @@ func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picke
116 var current picker.SourceFilter116 var current picker.SourceFilter
117 for _, entry := range sessions {117 for _, entry := range sessions {
118 if entry == cur && ac != nil {118 if entry == cur && ac != nil {
119- if local, ok := localSession(entry); ok {119+ entry.repairAttachmentView(ac)
120- local.repairAttachmentView(ac)
121- }
122 }120 }
123 snap := entry.snapshotView(opts)121 snap := entry.snapshotView(opts)
124 if entry == cur {122 if entry == cur {
125- if _, ok := localSession(entry); ok && ac != nil {123+ if ac != nil {
126 view := ac.viewSnapshot()124 view := ac.viewSnapshot()
127 if view.tabID != "" {125 if view.tabID != "" {
128 current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: view.tabID}126 current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: view.tabID}
@@ -197,7 +195,7 @@ func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picke
197 return views, current195 return views, current
198 }196 }
199 197
200-func (d *Daemon) newPickerModel(cur attachmentSession, ac *attachedClient, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model {198+func (d *Daemon) newPickerModel(cur *session, ac *attachedClient, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model {
201 views, attachedCurrent := d.pickerViews(cur, ac)199 views, attachedCurrent := d.pickerViews(cur, ac)
202 if intent == pickerNavigate && current == (picker.SourceFilter{}) {200 if intent == pickerNavigate && current == (picker.SourceFilter{}) {
203 current = attachedCurrent201 current = attachedCurrent
@@ -346,11 +344,9 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
346 var err error344 var err error
347 if len(effects) != 0 && effects[0] != nil {345 if len(effects) != 0 && effects[0] != nil {
348 err = d.switchToTargetForAttachment(effects[0].connectionToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select")346 err = d.switchToTargetForAttachment(effects[0].connectionToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select")
349- } else if local, ok := localSession(sess); ok {
350- d.closePicker(ac)
351- err = d.switchToTarget(local, ac, target)
352 } else {347 } else {
353- err = errAttachmentTransition348+ d.closePicker(ac)
349+ err = d.switchToTarget(sess, ac, target)
354 }350 }
355 if errors.Is(err, errAttachmentTransition) {351 if errors.Is(err, errAttachmentTransition) {
356 return352 return
@@ -479,9 +475,8 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
479 }475 }
480 d.mu.Lock()476 d.mu.Lock()
481 var clients []*attachedClient477 var clients []*attachedClient
482- for _, entry := range d.sessions {478+ for _, sess := range d.sessions {
483- sess, ok := localSession(entry)479+ if sess == nil {
484- if !ok {
485 continue480 continue
486 }481 }
487 sess.mu.Lock()482 sess.mu.Lock()
@@ -556,7 +551,7 @@ func remoteKeysEqual(left, right *domain.RemoteSessionKey) bool {
556 return *left == *right551 return *left == *right
557 }552 }
558 553
559-func (d *Daemon) sessionByID(id domain.SessionID) attachmentSession {554+func (d *Daemon) sessionByID(id domain.SessionID) *session {
560 d.mu.Lock()555 d.mu.Lock()
561 defer d.mu.Unlock()556 defer d.mu.Unlock()
562 return d.sessions[id]557 return d.sessions[id]
@@ -605,9 +600,9 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnecti
605 return nil600 return nil
606 }601 }
607 d.mu.Lock()602 d.mu.Lock()
608- targetSess, ok := localSession(d.sessions[target.Session])603+ targetSess := d.sessions[target.Session]
609 d.mu.Unlock()604 d.mu.Unlock()
610- if !ok {605+ if targetSess == nil {
611 if !token.attachmentCurrent() {606 if !token.attachmentCurrent() {
612 return nil607 return nil
613 }608 }
@@ -667,11 +662,10 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
667 }662 }
668 token.effect.bindActionEnd(d, action)663 token.effect.bindActionEnd(d, action)
669 token.effect.End()664 token.effect.End()
670- sess, ok := localSession(token.sess)665+ if token.sess == nil {
671- if !ok {
672 return errAttachmentTransition666 return errAttachmentTransition
673 }667 }
674- return d.switchToTargetGuardedForAttachment(sess, token.ac, target, guard, &token, action)668+ return d.switchToTargetGuardedForAttachment(token.sess, token.ac, target, guard, &token, action)
675 }669 }
676 670
677 // sendRemoteAttachTargetForAttachment validates the catalog row and hands the671 // sendRemoteAttachTargetForAttachment validates the catalog row and hands the
@@ -755,7 +749,7 @@ func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedC
755 targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard, sourceToken, action)749 targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard, sourceToken, action)
756 }750 }
757 }751 }
758- } else if active, ok := localSession(d.sessions[target.Session]); ok {752+ } else if active := d.sessions[target.Session]; active != nil {
759 targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard, sourceToken, action)753 targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard, sourceToken, action)
760 }754 }
761 d.mu.Unlock()755 d.mu.Unlock()
@@ -1031,9 +1025,9 @@ func (d *Daemon) killPickerTargetForAttachment(target picker.Target, token attac
1031 return d.killPickerTarget(target)1025 return d.killPickerTarget(target)
1032 }1026 }
1033 d.mu.Lock()1027 d.mu.Lock()
1034- targetSess, ok := localSession(d.sessions[target.Session])1028+ targetSess := d.sessions[target.Session]
1035 d.mu.Unlock()1029 d.mu.Unlock()
1036- if !ok {1030+ if targetSess == nil {
1037 return nil1031 return nil
1038 }1032 }
1039 return d.killSessionForAttachment(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete")1033 return d.killSessionForAttachment(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete")
@@ -1048,9 +1042,9 @@ func (d *Daemon) killPickerTarget(target picker.Target) error {
1048 return nil1042 return nil
1049 }1043 }
1050 d.mu.Lock()1044 d.mu.Lock()
1051- targetSess, ok := localSession(d.sessions[target.Session])1045+ targetSess := d.sessions[target.Session]
1052 d.mu.Unlock()1046 d.mu.Unlock()
1053- if ok {1047+ if targetSess != nil {
1054 return d.killSession(targetSess, ports.ReasonSessionKilled, true)1048 return d.killSession(targetSess, ports.ReasonSessionKilled, true)
1055 }1049 }
1056 return nil1050 return nil
M internal/usecase/daemon/prompt.go +2 -2
@@ -13,7 +13,7 @@ func promptModalFor(title string) ui.Modal {
1313 return ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 4, Title: title, Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}}
1414 }
1515
16-func (d *Daemon) enterPrompt(sess attachmentSession, ac *attachedClient, title, initial string, submit func(string) error) {
16+func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial string, submit func(string) error) {
1717 d.closePrompt(ac)
1818 ac.overlays.promptMu.Lock()
1919 ac.overlays.prompt = promptui.New(title, initial)
@@ -23,7 +23,7 @@ func (d *Daemon) enterPrompt(sess attachmentSession, ac *attachedClient, title,
2323 d.invalidateRender(sess, ac, true, "prompt.go")
2424 }
2525
26-func (d *Daemon) enterTransitionPrompt(sess attachmentSession, ac *attachedClient, title, initial string, submit func(string, attachmentConnectionToken) error) {
26+func (d *Daemon) enterTransitionPrompt(sess *session, ac *attachedClient, title, initial string, submit func(string, attachmentConnectionToken) error) {
2727 d.closePrompt(ac)
2828 ac.overlays.promptMu.Lock()
2929 ac.overlays.prompt = promptui.New(title, initial)
@@ -13,7 +13,7 @@ func promptModalFor(title string) ui.Modal {
13 return ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 4, Title: title, Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}}13 return ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 4, Title: title, Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}}
14 }14 }
15 15
16-func (d *Daemon) enterPrompt(sess attachmentSession, ac *attachedClient, title, initial string, submit func(string) error) {16+func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial string, submit func(string) error) {
17 d.closePrompt(ac)17 d.closePrompt(ac)
18 ac.overlays.promptMu.Lock()18 ac.overlays.promptMu.Lock()
19 ac.overlays.prompt = promptui.New(title, initial)19 ac.overlays.prompt = promptui.New(title, initial)
@@ -23,7 +23,7 @@ func (d *Daemon) enterPrompt(sess attachmentSession, ac *attachedClient, title,
23 d.invalidateRender(sess, ac, true, "prompt.go")23 d.invalidateRender(sess, ac, true, "prompt.go")
24 }24 }
25 25
26-func (d *Daemon) enterTransitionPrompt(sess attachmentSession, ac *attachedClient, title, initial string, submit func(string, attachmentConnectionToken) error) {26+func (d *Daemon) enterTransitionPrompt(sess *session, ac *attachedClient, title, initial string, submit func(string, attachmentConnectionToken) error) {
27 d.closePrompt(ac)27 d.closePrompt(ac)
28 ac.overlays.promptMu.Lock()28 ac.overlays.promptMu.Lock()
29 ac.overlays.prompt = promptui.New(title, initial)29 ac.overlays.prompt = promptui.New(title, initial)
M internal/usecase/daemon/recent_sessions.go +1 -1
@@ -17,7 +17,7 @@ type recentSession struct {
1717
1818 // recentSessions returns the current session's capped named-session MRU list.
1919 // Callers may retain the returned values for the lifetime of an interaction.
20-func (d *Daemon) recentSessions(current attachmentSession) []recentSession {
20+func (d *Daemon) recentSessions(current *session) []recentSession {
2121 if d == nil {
2222 return nil
2323 }
@@ -17,7 +17,7 @@ type recentSession struct {
17 17
18 // recentSessions returns the current session's capped named-session MRU list.18 // recentSessions returns the current session's capped named-session MRU list.
19 // Callers may retain the returned values for the lifetime of an interaction.19 // Callers may retain the returned values for the lifetime of an interaction.
20-func (d *Daemon) recentSessions(current attachmentSession) []recentSession {20+func (d *Daemon) recentSessions(current *session) []recentSession {
21 if d == nil {21 if d == nil {
22 return nil22 return nil
23 }23 }
M internal/usecase/daemon/render.go +15 -7
@@ -60,7 +60,7 @@ func (d *Daemon) tabIsPickerPreview(tb *tab) bool {
6060 return false
6161 }
6262 d.mu.Lock()
63- sessions := localSessionsSnapshot(d.sessions)
63+ sessions := sessionsSnapshot(d.sessions)
6464 d.mu.Unlock()
6565 for _, sess := range sessions {
6666 for _, ac := range sess.snapshotAttachments() {
@@ -290,10 +290,11 @@ func (b *runtimeMarkBatch) flush() {
290290 }
291291 }
292292
293-func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, lease *attachmentLease) {
293+func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *attachmentLease) {
294294 // Session-owned PTY preparation remains local; attachment rendering is
295- // captured through attachmentSession.captureRenderState.
296- sess, local := localSession(entry)
295+ // captured through session.captureRenderState.
296+ sess := entry
297+ local := sess != nil
297298 marks := d.newRuntimeMarkBatch()
298299 if lease != nil {
299300 token := attachmentToken(entry, ac, ac.transport())
@@ -331,9 +332,16 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
331332 return
332333 }
333334 }
334- // Capacity is checked before any destructive capture. The coordinator is
335- // the normal gate, but this ownership check also protects direct resize and
336- // test paint paths from consuming damage that cannot be emitted.
335+ // Capacity is checked before any destructive capture. Refresh the atomic
336+ // readiness snapshot while sendMu is held so direct test/setup mutations of
337+ // the stream fields remain covered without making coordinator probes wait on
338+ // a blocked transport send.
339+ if ac.output != nil {
340+ ac.output.syncCapacityLocked()
341+ }
342+ // The coordinator is the normal gate, but this ownership check also protects
343+ // direct resize and test paint paths from consuming damage that cannot be
344+ // emitted.
337345 if ac.output != nil && ac.output.atCapacity() {
338346 // The coordinator owns the blocked interval; this guard only protects
339347 // direct test/resize paint calls from destructive capture.
@@ -60,7 +60,7 @@ func (d *Daemon) tabIsPickerPreview(tb *tab) bool {
60 return false60 return false
61 }61 }
62 d.mu.Lock()62 d.mu.Lock()
63- sessions := localSessionsSnapshot(d.sessions)63+ sessions := sessionsSnapshot(d.sessions)
64 d.mu.Unlock()64 d.mu.Unlock()
65 for _, sess := range sessions {65 for _, sess := range sessions {
66 for _, ac := range sess.snapshotAttachments() {66 for _, ac := range sess.snapshotAttachments() {
@@ -290,10 +290,11 @@ func (b *runtimeMarkBatch) flush() {
290 }290 }
291 }291 }
292 292
293-func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, lease *attachmentLease) {293+func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *attachmentLease) {
294 // Session-owned PTY preparation remains local; attachment rendering is294 // Session-owned PTY preparation remains local; attachment rendering is
295- // captured through attachmentSession.captureRenderState.295+ // captured through session.captureRenderState.
296- sess, local := localSession(entry)296+ sess := entry
297+ local := sess != nil
297 marks := d.newRuntimeMarkBatch()298 marks := d.newRuntimeMarkBatch()
298 if lease != nil {299 if lease != nil {
299 token := attachmentToken(entry, ac, ac.transport())300 token := attachmentToken(entry, ac, ac.transport())
@@ -331,9 +332,16 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
331 return332 return
332 }333 }
333 }334 }
334- // Capacity is checked before any destructive capture. The coordinator is335+ // Capacity is checked before any destructive capture. Refresh the atomic
335- // the normal gate, but this ownership check also protects direct resize and336+ // readiness snapshot while sendMu is held so direct test/setup mutations of
336- // test paint paths from consuming damage that cannot be emitted.337+ // the stream fields remain covered without making coordinator probes wait on
338+ // a blocked transport send.
339+ if ac.output != nil {
340+ ac.output.syncCapacityLocked()
341+ }
342+ // The coordinator is the normal gate, but this ownership check also protects
343+ // direct resize and test paint paths from consuming damage that cannot be
344+ // emitted.
337 if ac.output != nil && ac.output.atCapacity() {345 if ac.output != nil && ac.output.atCapacity() {
338 // The coordinator owns the blocked interval; this guard only protects346 // The coordinator owns the blocked interval; this guard only protects
339 // direct test/resize paint calls from destructive capture.347 // direct test/resize paint calls from destructive capture.
M internal/usecase/daemon/render_capture.go +1 -1
@@ -344,7 +344,7 @@ func captureLocalRenderState(
344344 }
345345
346346 // captureRenderState preserves the focused local test seam. Production
347-// rendering delegates through attachmentSession.captureRenderState.
347+// rendering delegates through *session.captureRenderState.
348348 func captureRenderState(sess *session, ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) {
349349 return captureLocalRenderState(sess, ac, request)
350350 }
@@ -344,7 +344,7 @@ func captureLocalRenderState(
344 }344 }
345 345
346 // captureRenderState preserves the focused local test seam. Production346 // captureRenderState preserves the focused local test seam. Production
347-// rendering delegates through attachmentSession.captureRenderState.347+// rendering delegates through *session.captureRenderState.
348 func captureRenderState(sess *session, ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) {348 func captureRenderState(sess *session, ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) {
349 return captureLocalRenderState(sess, ac, request)349 return captureLocalRenderState(sess, ac, request)
350 }350 }
M internal/usecase/daemon/render_coordinator.go +11 -14
@@ -39,9 +39,6 @@ type renderCoordinator struct {
3939 // invalidations retain only their exact target lease here.
4040 pendingShared bool
4141 pendingTargets map[*attachedClient]*attachmentLease
42- // ackReadyFor is production's attachment-scoped ACK-window probe. The
43- // legacy options callback remains for coordinator-only tests.
44- ackReadyFor func(*attachedClient) bool
4542 // ackBlocked retains the first rejected ACK-capacity probe until the
4643 // matching consume or lifecycle closure releases that exact interval.
4744 ackBlocked *ackBlockedSpan
@@ -807,7 +804,7 @@ func (c *renderCoordinator) waitForTimerWorkers() { c.supervisor.wait() }
807804 // invalidateRender is the sole producer fan-in. In tests and transitional
808805 // headless paths without an attached coordinator it retains the old private
809806 // compositor; attached sessions always schedule through their coordinator.
810-func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, reset bool, producer string) {
807+func (d *Daemon) invalidateRender(entry *session, ac *attachedClient, reset bool, producer string) {
811808 if rc := attachmentRenderCoordinator(entry); rc != nil {
812809 invalidation := renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}
813810 if rc.invalidateForAttachment(ac, invalidation) {
@@ -832,7 +829,7 @@ func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, r
832829 // invalidateRenderNow publishes through the coordinator but immediately
833830 // flushes the wake when the client can accept state. Attach uses this path so
834831 // the required first full frame never depends on a debounce timer.
835-func (d *Daemon) invalidateRenderNow(entry attachmentSession, ac *attachedClient, reset bool, producer string) {
832+func (d *Daemon) invalidateRenderNow(entry *session, ac *attachedClient, reset bool, producer string) {
836833 if rc := attachmentRenderCoordinator(entry); rc != nil {
837834 if rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}) {
838835 rc.fireCurrent(false)
@@ -880,8 +877,8 @@ func (c *renderCoordinator) fireWithTimerToken(token *timerToken, gen uint64, wa
880877 }
881878
882879 func (c *renderCoordinator) attachmentAckReady(ac *attachedClient) bool {
883- if c.ackReadyFor != nil {
884- return c.ackReadyFor(ac)
880+ if c.opts.ackReadyFor != nil {
881+ return c.opts.ackReadyFor(ac)
885882 }
886883 if c.opts.ackReady != nil {
887884 return c.opts.ackReady()
@@ -1019,7 +1016,7 @@ func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen ui
10191016 w.lease = syncLease
10201017 }
10211018 }
1022- previews := c.takePendingPreviewsLocked()
1019+ preview, previews := c.takePendingPreviewsLocked()
10231020 worker := c.detachNormalTimerLocked()
10241021 c.armed = false
10251022
@@ -1038,7 +1035,7 @@ func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen ui
10381035 if ackStart != nil {
10391036 ackStart.publishStart()
10401037 }
1041- c.notifyPreviews(w, previews)
1038+ c.notifyPreviews(w, preview, previews)
10421039 return
10431040 }
10441041
@@ -1107,7 +1104,7 @@ func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen ui
11071104 if outputReady && wake != nil {
11081105 wake(w)
11091106 }
1110- c.notifyPreviews(w, previews)
1107+ c.notifyPreviews(w, preview, previews)
11111108 }
11121109
11131110 func (c *renderCoordinator) fireValidLocked(token *timerToken, gen uint64, watchdog bool) bool {
@@ -1151,19 +1148,19 @@ func (c *renderCoordinator) syncGateOpenLocked() bool {
11511148
11521149 // takePendingPreviewsLocked snapshots and consumes picker preview delivery
11531150 // for this target generation. Preview callbacks are never ACK-gated.
1154-func (c *renderCoordinator) takePendingPreviewsLocked() []func(renderWake) {
1151+func (c *renderCoordinator) takePendingPreviewsLocked() (func(renderWake), []func(renderWake)) {
11551152 if !c.pendingPreview {
1156- return nil
1153+ return nil, nil
11571154 }
11581155 c.pendingPreview = false
11591156 previews := make([]func(renderWake), 0, len(c.previewWakes))
11601157 for _, subscription := range c.previewWakes {
11611158 previews = append(previews, subscription.fn)
11621159 }
1163- return previews
1160+ return nil, previews
11641161 }
11651162
1166-func (c *renderCoordinator) notifyPreviews(w renderWake, previews []func(renderWake)) {
1163+func (c *renderCoordinator) notifyPreviews(w renderWake, _ func(renderWake), previews []func(renderWake)) {
11671164 for _, fn := range previews {
11681165 fn(w)
11691166 }
@@ -39,9 +39,6 @@ type renderCoordinator struct {
39 // invalidations retain only their exact target lease here.39 // invalidations retain only their exact target lease here.
40 pendingShared bool40 pendingShared bool
41 pendingTargets map[*attachedClient]*attachmentLease41 pendingTargets map[*attachedClient]*attachmentLease
42- // ackReadyFor is production's attachment-scoped ACK-window probe. The
43- // legacy options callback remains for coordinator-only tests.
44- ackReadyFor func(*attachedClient) bool
45 // ackBlocked retains the first rejected ACK-capacity probe until the42 // ackBlocked retains the first rejected ACK-capacity probe until the
46 // matching consume or lifecycle closure releases that exact interval.43 // matching consume or lifecycle closure releases that exact interval.
47 ackBlocked *ackBlockedSpan44 ackBlocked *ackBlockedSpan
@@ -807,7 +804,7 @@ func (c *renderCoordinator) waitForTimerWorkers() { c.supervisor.wait() }
807 // invalidateRender is the sole producer fan-in. In tests and transitional804 // invalidateRender is the sole producer fan-in. In tests and transitional
808 // headless paths without an attached coordinator it retains the old private805 // headless paths without an attached coordinator it retains the old private
809 // compositor; attached sessions always schedule through their coordinator.806 // compositor; attached sessions always schedule through their coordinator.
810-func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, reset bool, producer string) {807+func (d *Daemon) invalidateRender(entry *session, ac *attachedClient, reset bool, producer string) {
811 if rc := attachmentRenderCoordinator(entry); rc != nil {808 if rc := attachmentRenderCoordinator(entry); rc != nil {
812 invalidation := renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}809 invalidation := renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}
813 if rc.invalidateForAttachment(ac, invalidation) {810 if rc.invalidateForAttachment(ac, invalidation) {
@@ -832,7 +829,7 @@ func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, r
832 // invalidateRenderNow publishes through the coordinator but immediately829 // invalidateRenderNow publishes through the coordinator but immediately
833 // flushes the wake when the client can accept state. Attach uses this path so830 // flushes the wake when the client can accept state. Attach uses this path so
834 // the required first full frame never depends on a debounce timer.831 // the required first full frame never depends on a debounce timer.
835-func (d *Daemon) invalidateRenderNow(entry attachmentSession, ac *attachedClient, reset bool, producer string) {832+func (d *Daemon) invalidateRenderNow(entry *session, ac *attachedClient, reset bool, producer string) {
836 if rc := attachmentRenderCoordinator(entry); rc != nil {833 if rc := attachmentRenderCoordinator(entry); rc != nil {
837 if rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}) {834 if rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}) {
838 rc.fireCurrent(false)835 rc.fireCurrent(false)
@@ -880,8 +877,8 @@ func (c *renderCoordinator) fireWithTimerToken(token *timerToken, gen uint64, wa
880 }877 }
881 878
882 func (c *renderCoordinator) attachmentAckReady(ac *attachedClient) bool {879 func (c *renderCoordinator) attachmentAckReady(ac *attachedClient) bool {
883- if c.ackReadyFor != nil {880+ if c.opts.ackReadyFor != nil {
884- return c.ackReadyFor(ac)881+ return c.opts.ackReadyFor(ac)
885 }882 }
886 if c.opts.ackReady != nil {883 if c.opts.ackReady != nil {
887 return c.opts.ackReady()884 return c.opts.ackReady()
@@ -1019,7 +1016,7 @@ func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen ui
1019 w.lease = syncLease1016 w.lease = syncLease
1020 }1017 }
1021 }1018 }
1022- previews := c.takePendingPreviewsLocked()1019+ preview, previews := c.takePendingPreviewsLocked()
1023 worker := c.detachNormalTimerLocked()1020 worker := c.detachNormalTimerLocked()
1024 c.armed = false1021 c.armed = false
1025 1022
@@ -1038,7 +1035,7 @@ func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen ui
1038 if ackStart != nil {1035 if ackStart != nil {
1039 ackStart.publishStart()1036 ackStart.publishStart()
1040 }1037 }
1041- c.notifyPreviews(w, previews)1038+ c.notifyPreviews(w, preview, previews)
1042 return1039 return
1043 }1040 }
1044 1041
@@ -1107,7 +1104,7 @@ func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen ui
1107 if outputReady && wake != nil {1104 if outputReady && wake != nil {
1108 wake(w)1105 wake(w)
1109 }1106 }
1110- c.notifyPreviews(w, previews)1107+ c.notifyPreviews(w, preview, previews)
1111 }1108 }
1112 1109
1113 func (c *renderCoordinator) fireValidLocked(token *timerToken, gen uint64, watchdog bool) bool {1110 func (c *renderCoordinator) fireValidLocked(token *timerToken, gen uint64, watchdog bool) bool {
@@ -1151,19 +1148,19 @@ func (c *renderCoordinator) syncGateOpenLocked() bool {
1151 1148
1152 // takePendingPreviewsLocked snapshots and consumes picker preview delivery1149 // takePendingPreviewsLocked snapshots and consumes picker preview delivery
1153 // for this target generation. Preview callbacks are never ACK-gated.1150 // for this target generation. Preview callbacks are never ACK-gated.
1154-func (c *renderCoordinator) takePendingPreviewsLocked() []func(renderWake) {1151+func (c *renderCoordinator) takePendingPreviewsLocked() (func(renderWake), []func(renderWake)) {
1155 if !c.pendingPreview {1152 if !c.pendingPreview {
1156- return nil1153+ return nil, nil
1157 }1154 }
1158 c.pendingPreview = false1155 c.pendingPreview = false
1159 previews := make([]func(renderWake), 0, len(c.previewWakes))1156 previews := make([]func(renderWake), 0, len(c.previewWakes))
1160 for _, subscription := range c.previewWakes {1157 for _, subscription := range c.previewWakes {
1161 previews = append(previews, subscription.fn)1158 previews = append(previews, subscription.fn)
1162 }1159 }
1163- return previews1160+ return nil, previews
1164 }1161 }
1165 1162
1166-func (c *renderCoordinator) notifyPreviews(w renderWake, previews []func(renderWake)) {1163+func (c *renderCoordinator) notifyPreviews(w renderWake, _ func(renderWake), previews []func(renderWake)) {
1167 for _, fn := range previews {1164 for _, fn := range previews {
1168 fn(w)1165 fn(w)
1169 }1166 }
M internal/usecase/daemon/render_coordinator_support.go +2 -2
@@ -15,7 +15,7 @@ func (c *renderCoordinator) burstMetricsSnapshot() renderCoordinatorBurstMetrics
1515
1616 // attachmentRenderCoordinator returns the coordinator installed for the exact
1717 // attachment-session identity.
18-func attachmentRenderCoordinator(entry attachmentSession) *renderCoordinator {
18+func attachmentRenderCoordinator(entry *session) *renderCoordinator {
1919 if entry == nil || entry.core() == nil {
2020 return nil
2121 }
@@ -24,7 +24,7 @@ func attachmentRenderCoordinator(entry attachmentSession) *renderCoordinator {
2424
2525 // installAttachmentRenderCoordinator publishes rc for the exact
2626 // attachment-session identity.
27-func installAttachmentRenderCoordinator(entry attachmentSession, rc *renderCoordinator) {
27+func installAttachmentRenderCoordinator(entry *session, rc *renderCoordinator) {
2828 if entry == nil || entry.core() == nil {
2929 return
3030 }
@@ -15,7 +15,7 @@ func (c *renderCoordinator) burstMetricsSnapshot() renderCoordinatorBurstMetrics
15 15
16 // attachmentRenderCoordinator returns the coordinator installed for the exact16 // attachmentRenderCoordinator returns the coordinator installed for the exact
17 // attachment-session identity.17 // attachment-session identity.
18-func attachmentRenderCoordinator(entry attachmentSession) *renderCoordinator {18+func attachmentRenderCoordinator(entry *session) *renderCoordinator {
19 if entry == nil || entry.core() == nil {19 if entry == nil || entry.core() == nil {
20 return nil20 return nil
21 }21 }
@@ -24,7 +24,7 @@ func attachmentRenderCoordinator(entry attachmentSession) *renderCoordinator {
24 24
25 // installAttachmentRenderCoordinator publishes rc for the exact25 // installAttachmentRenderCoordinator publishes rc for the exact
26 // attachment-session identity.26 // attachment-session identity.
27-func installAttachmentRenderCoordinator(entry attachmentSession, rc *renderCoordinator) {27+func installAttachmentRenderCoordinator(entry *session, rc *renderCoordinator) {
28 if entry == nil || entry.core() == nil {28 if entry == nil || entry.core() == nil {
29 return29 return
30 }30 }
M internal/usecase/daemon/render_coordinator_test.go +6 -5
@@ -403,13 +403,12 @@ func TestRenderCoordinatorUrgentDeadlineCannotBeExtended(t *testing.T) {
403403
404404 func TestRenderCoordinatorAckReadinessIsAttachmentScoped(t *testing.T) {
405405 slow, healthy := &attachedClient{}, &attachedClient{}
406- var slowReady atomic.Bool
407- slowReady.Store(false)
406+ slowReady := false
408407 wakes := make(chan renderWake, 2)
409408 rc := newRenderCoordinator(renderCoordinatorOptions{
410- wake: func(w renderWake) { wakes <- w },
409+ ackReadyFor: func(ac *attachedClient) bool { return ac == healthy || slowReady },
410+ wake: func(w renderWake) { wakes <- w },
411411 })
412- rc.ackReadyFor = func(ac *attachedClient) bool { return ac == healthy || slowReady.Load() }
413412 rc.attach(slow)
414413 rc.attach(healthy)
415414 rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"})
@@ -423,7 +422,7 @@ func TestRenderCoordinatorAckReadinessIsAttachmentScoped(t *testing.T) {
423422 require.True(t, rc.pending, "slow attachment must retain the shared mutation until its ACK window opens")
424423 rc.mu.Unlock()
425424
426- slowReady.Store(true)
425+ slowReady = true
427426 rc.notifyAckForLease(rc.attachmentLease(slow))
428427 wake = awaitTestValue(t, wakes, "slow attachment did not receive its deferred wake")
429428 require.Contains(t, wake.attachmentLeases, slow)
@@ -977,6 +976,7 @@ func TestRenderCoordinatorLifecycleDropsStaleWakes(t *testing.T) {
977976 name string
978977 teardown func(rc *renderCoordinator, owner *attachedClient)
979978 }{
979+ {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},
980980 {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},
981981 {"session teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.beginSessionTeardown().finish() }},
982982 }
@@ -1409,6 +1409,7 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
14091409 run func(*renderCoordinator, *attachedClient)
14101410 }{
14111411 {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }},
1412+ {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }},
14121413 } {
14131414 t.Run(transition.name, func(t *testing.T) {
14141415 h := newCoordinatorHarness(t)
@@ -403,13 +403,12 @@ func TestRenderCoordinatorUrgentDeadlineCannotBeExtended(t *testing.T) {
403 403
404 func TestRenderCoordinatorAckReadinessIsAttachmentScoped(t *testing.T) {404 func TestRenderCoordinatorAckReadinessIsAttachmentScoped(t *testing.T) {
405 slow, healthy := &attachedClient{}, &attachedClient{}405 slow, healthy := &attachedClient{}, &attachedClient{}
406- var slowReady atomic.Bool406+ slowReady := false
407- slowReady.Store(false)
408 wakes := make(chan renderWake, 2)407 wakes := make(chan renderWake, 2)
409 rc := newRenderCoordinator(renderCoordinatorOptions{408 rc := newRenderCoordinator(renderCoordinatorOptions{
410- wake: func(w renderWake) { wakes <- w },409+ ackReadyFor: func(ac *attachedClient) bool { return ac == healthy || slowReady },
410+ wake: func(w renderWake) { wakes <- w },
411 })411 })
412- rc.ackReadyFor = func(ac *attachedClient) bool { return ac == healthy || slowReady.Load() }
413 rc.attach(slow)412 rc.attach(slow)
414 rc.attach(healthy)413 rc.attach(healthy)
415 rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"})414 rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"})
@@ -423,7 +422,7 @@ func TestRenderCoordinatorAckReadinessIsAttachmentScoped(t *testing.T) {
423 require.True(t, rc.pending, "slow attachment must retain the shared mutation until its ACK window opens")422 require.True(t, rc.pending, "slow attachment must retain the shared mutation until its ACK window opens")
424 rc.mu.Unlock()423 rc.mu.Unlock()
425 424
426- slowReady.Store(true)425+ slowReady = true
427 rc.notifyAckForLease(rc.attachmentLease(slow))426 rc.notifyAckForLease(rc.attachmentLease(slow))
428 wake = awaitTestValue(t, wakes, "slow attachment did not receive its deferred wake")427 wake = awaitTestValue(t, wakes, "slow attachment did not receive its deferred wake")
429 require.Contains(t, wake.attachmentLeases, slow)428 require.Contains(t, wake.attachmentLeases, slow)
@@ -977,6 +976,7 @@ func TestRenderCoordinatorLifecycleDropsStaleWakes(t *testing.T) {
977 name string976 name string
978 teardown func(rc *renderCoordinator, owner *attachedClient)977 teardown func(rc *renderCoordinator, owner *attachedClient)
979 }{978 }{
979+ {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},
980 {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},980 {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},
981 {"session teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.beginSessionTeardown().finish() }},981 {"session teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.beginSessionTeardown().finish() }},
982 }982 }
@@ -1409,6 +1409,7 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
1409 run func(*renderCoordinator, *attachedClient)1409 run func(*renderCoordinator, *attachedClient)
1410 }{1410 }{
1411 {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }},1411 {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }},
1412+ {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }},
1412 } {1413 } {
1413 t.Run(transition.name, func(t *testing.T) {1414 t.Run(transition.name, func(t *testing.T) {
1414 h := newCoordinatorHarness(t)1415 h := newCoordinatorHarness(t)
M internal/usecase/daemon/render_coordinator_types.go +5 -2
@@ -95,8 +95,11 @@ type renderCoordinatorOptions struct {
9595 // wake composes a coalesced render request.
9696 wake func(renderWake)
9797 // ackReady reports whether the attachment may compose another output
98- // state (the outputStateStream window has capacity).
99- ackReady func() bool
98+ // state (the outputStateStream window has capacity). It is retained for
99+ // coordinator-only tests and headless callers; production uses ackReadyFor
100+ // so one attachment cannot gate another.
101+ ackReady func() bool
102+ ackReadyFor func(*attachedClient) bool
100103 // syncRenderable reports whether a pane's synchronized batch currently
101104 // contributes to the attached composition. It must not acquire c.mu.
102105 syncRenderable func(*pane) bool
@@ -95,8 +95,11 @@ type renderCoordinatorOptions struct {
95 // wake composes a coalesced render request.95 // wake composes a coalesced render request.
96 wake func(renderWake)96 wake func(renderWake)
97 // ackReady reports whether the attachment may compose another output97 // ackReady reports whether the attachment may compose another output
98- // state (the outputStateStream window has capacity).98+ // state (the outputStateStream window has capacity). It is retained for
99- ackReady func() bool99+ // coordinator-only tests and headless callers; production uses ackReadyFor
100+ // so one attachment cannot gate another.
101+ ackReady func() bool
102+ ackReadyFor func(*attachedClient) bool
100 // syncRenderable reports whether a pane's synchronized batch currently103 // syncRenderable reports whether a pane's synchronized batch currently
101 // contributes to the attached composition. It must not acquire c.mu.104 // contributes to the attached composition. It must not acquire c.mu.
102 syncRenderable func(*pane) bool105 syncRenderable func(*pane) bool
M internal/usecase/daemon/render_pipeline.go +8 -12
@@ -428,7 +428,7 @@ func commitDamageReceipts(receipts []damageReceipt) {
428428
429429 // emitFrame is the sole side-effecting half of the pipeline. The caller holds
430430 // sendMu for the complete capture/compose/emit transaction.
431-func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, batches ...*runtimeMarkBatch) bool {
431+func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, batches ...*runtimeMarkBatch) bool {
432432 var ownedMarks runtimeMarkBatch
433433 var marks *runtimeMarkBatch
434434 if len(batches) != 0 {
@@ -480,17 +480,15 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
480480 // Without a coordinator reportError repaints synchronously. Suppress only
481481 // that nested notice repaint; leave the guard before returning so a later,
482482 // independent failed transaction can still notify the user.
483- if sess, ok := localSession(entry); ok {
484- if sess.renderCoordinator() == nil {
485- if !ac.prepareFailureFallback.CompareAndSwap(false, true) {
486- return true
487- }
488- d.reportError(sess, domain.UserErr(domain.NoticeInternal, "display update failed", err))
489- ac.prepareFailureFallback.Store(false)
483+ if entry.renderCoordinator() == nil {
484+ if !ac.prepareFailureFallback.CompareAndSwap(false, true) {
490485 return true
491486 }
492- d.reportError(sess, domain.UserErr(domain.NoticeInternal, "display update failed", err))
487+ d.reportError(entry, domain.UserErr(domain.NoticeInternal, "display update failed", err))
488+ ac.prepareFailureFallback.Store(false)
489+ return true
493490 }
491+ d.reportError(entry, domain.UserErr(domain.NoticeInternal, "display update failed", err))
494492 // Notices are session-scoped, while the attachment repaint still needs
495493 // to redraw its chrome after a failed transaction.
496494 d.invalidateRender(entry, ac, true, "render_pipeline.go:prepare-failed")
@@ -563,9 +561,7 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
563561 // admission first. Detachment freezes the gate and therefore cannot mutate
564562 // ownership until any enclosing admitted operation has also ended.
565563 if marks.attachmentEffect == nil {
566- if sess, ok := localSession(entry); ok {
567- d.detachOnSendError(sess, ac, sendTr)
568- }
564+ d.detachOnSendError(entry, ac, sendTr)
569565 } else {
570566 // Capture the exact admitted capability, including its coordinator
571567 // lease, before End permits a new attachment publication. Reserve
@@ -428,7 +428,7 @@ func commitDamageReceipts(receipts []damageReceipt) {
428 428
429 // emitFrame is the sole side-effecting half of the pipeline. The caller holds429 // emitFrame is the sole side-effecting half of the pipeline. The caller holds
430 // sendMu for the complete capture/compose/emit transaction.430 // sendMu for the complete capture/compose/emit transaction.
431-func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, batches ...*runtimeMarkBatch) bool {431+func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, batches ...*runtimeMarkBatch) bool {
432 var ownedMarks runtimeMarkBatch432 var ownedMarks runtimeMarkBatch
433 var marks *runtimeMarkBatch433 var marks *runtimeMarkBatch
434 if len(batches) != 0 {434 if len(batches) != 0 {
@@ -480,17 +480,15 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
480 // Without a coordinator reportError repaints synchronously. Suppress only480 // Without a coordinator reportError repaints synchronously. Suppress only
481 // that nested notice repaint; leave the guard before returning so a later,481 // that nested notice repaint; leave the guard before returning so a later,
482 // independent failed transaction can still notify the user.482 // independent failed transaction can still notify the user.
483- if sess, ok := localSession(entry); ok {483+ if entry.renderCoordinator() == nil {
484- if sess.renderCoordinator() == nil {484+ if !ac.prepareFailureFallback.CompareAndSwap(false, true) {
485- if !ac.prepareFailureFallback.CompareAndSwap(false, true) {
486- return true
487- }
488- d.reportError(sess, domain.UserErr(domain.NoticeInternal, "display update failed", err))
489- ac.prepareFailureFallback.Store(false)
490 return true485 return true
491 }486 }
492- d.reportError(sess, domain.UserErr(domain.NoticeInternal, "display update failed", err))487+ d.reportError(entry, domain.UserErr(domain.NoticeInternal, "display update failed", err))
488+ ac.prepareFailureFallback.Store(false)
489+ return true
493 }490 }
491+ d.reportError(entry, domain.UserErr(domain.NoticeInternal, "display update failed", err))
494 // Notices are session-scoped, while the attachment repaint still needs492 // Notices are session-scoped, while the attachment repaint still needs
495 // to redraw its chrome after a failed transaction.493 // to redraw its chrome after a failed transaction.
496 d.invalidateRender(entry, ac, true, "render_pipeline.go:prepare-failed")494 d.invalidateRender(entry, ac, true, "render_pipeline.go:prepare-failed")
@@ -563,9 +561,7 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
563 // admission first. Detachment freezes the gate and therefore cannot mutate561 // admission first. Detachment freezes the gate and therefore cannot mutate
564 // ownership until any enclosing admitted operation has also ended.562 // ownership until any enclosing admitted operation has also ended.
565 if marks.attachmentEffect == nil {563 if marks.attachmentEffect == nil {
566- if sess, ok := localSession(entry); ok {564+ d.detachOnSendError(entry, ac, sendTr)
567- d.detachOnSendError(sess, ac, sendTr)
568- }
569 } else {565 } else {
570 // Capture the exact admitted capability, including its coordinator566 // Capture the exact admitted capability, including its coordinator
571 // lease, before End permits a new attachment publication. Reserve567 // lease, before End permits a new attachment publication. Reserve
M internal/usecase/daemon/resume.go +6 -32
@@ -252,22 +252,15 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
252252 oldSame.closeDone()
253253 }
254254 d.log.Info("client parked for resume", "session", sess.name, "grace", grace)
255- d.watchParkedTimer(token, parked)
256- return true
257-}
258255
259-func (d *Daemon) watchParkedTimer(token uint64, parked *parkedAttachment) {
260- if parked == nil || parked.timer == nil {
261- return
262- }
263- timer := parked.timer
264- go func() {
256+ go func(token uint64, parked *parkedAttachment) {
265257 select {
266258 case <-timer.C():
267259 d.expireParked(token, parked)
268260 case <-parked.done:
269261 }
270- }()
262+ }(token, parked)
263+ return true
271264 }
272265
273266 func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
@@ -316,12 +309,9 @@ func (d *Daemon) commitResumeClaim(ac *attachedClient) bool {
316309 d.mu.Lock()
317310 defer d.mu.Unlock()
318311 token := ac.resumeClaimToken
319- if token == 0 {
320- return true
321- }
322312 parked := d.parked[token]
323- if parked == nil || parked.ac != ac || !parked.claimed {
324- return false
313+ if token == 0 || parked == nil || parked.ac != ac || !parked.claimed {
314+ return token == 0
325315 }
326316 delete(d.parked, token)
327317 parked.claimed = false
@@ -358,25 +348,9 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
358348 sess.unregisterAttachmentLocked(ac)
359349 sess.mu.Unlock()
360350 }
361- // Recreate the parked entry and its watcher instead of restoring a timer
362- // that may already have fired while the claim held the old entry. This also
363- // makes a failed handshake's credential expiry deterministic under fake clocks.
364- if parked.timer != nil {
365- parked.timer.Stop()
366- }
367- parked.closeDone()
368- rearmed := &parkedAttachment{
369- sess: parked.sess,
370- ac: ac,
371- pickerGeneration: parked.pickerGeneration,
372- timer: d.clock.NewTimer(d.resumeParkGrace),
373- done: make(chan struct{}),
374- }
375- d.parked[token] = rearmed
376351 captured := ac.transportSnapshot().transport
377352 ac.setSession(nil)
378353 d.mu.Unlock()
379- d.watchParkedTimer(token, rearmed)
380354 if captured != nil {
381355 _ = ac.closeCapturedTransport(ac.revokeTransport(captured))
382356 }
@@ -612,7 +586,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
612586 // mandatory first paint cannot be blocked by ACKs that died with the link.
613587 ac.rebaseOutput()
614588 ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight))
615- ac.output.syncCapacityLocked()
589+ ac.output.maxOutstandingAtomic.Store(ac.output.maxOutstanding)
616590 ac.replaceTransport(tr)
617591 ac.size = sz
618592 ac.resumeToken = d.nextResumeTokenLocked()
@@ -252,22 +252,15 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
252 oldSame.closeDone()252 oldSame.closeDone()
253 }253 }
254 d.log.Info("client parked for resume", "session", sess.name, "grace", grace)254 d.log.Info("client parked for resume", "session", sess.name, "grace", grace)
255- d.watchParkedTimer(token, parked)
256- return true
257-}
258 255
259-func (d *Daemon) watchParkedTimer(token uint64, parked *parkedAttachment) {256+ go func(token uint64, parked *parkedAttachment) {
260- if parked == nil || parked.timer == nil {
261- return
262- }
263- timer := parked.timer
264- go func() {
265 select {257 select {
266 case <-timer.C():258 case <-timer.C():
267 d.expireParked(token, parked)259 d.expireParked(token, parked)
268 case <-parked.done:260 case <-parked.done:
269 }261 }
270- }()262+ }(token, parked)
263+ return true
271 }264 }
272 265
273 func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {266 func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
@@ -316,12 +309,9 @@ func (d *Daemon) commitResumeClaim(ac *attachedClient) bool {
316 d.mu.Lock()309 d.mu.Lock()
317 defer d.mu.Unlock()310 defer d.mu.Unlock()
318 token := ac.resumeClaimToken311 token := ac.resumeClaimToken
319- if token == 0 {
320- return true
321- }
322 parked := d.parked[token]312 parked := d.parked[token]
323- if parked == nil || parked.ac != ac || !parked.claimed {313+ if token == 0 || parked == nil || parked.ac != ac || !parked.claimed {
324- return false314+ return token == 0
325 }315 }
326 delete(d.parked, token)316 delete(d.parked, token)
327 parked.claimed = false317 parked.claimed = false
@@ -358,25 +348,9 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
358 sess.unregisterAttachmentLocked(ac)348 sess.unregisterAttachmentLocked(ac)
359 sess.mu.Unlock()349 sess.mu.Unlock()
360 }350 }
361- // Recreate the parked entry and its watcher instead of restoring a timer
362- // that may already have fired while the claim held the old entry. This also
363- // makes a failed handshake's credential expiry deterministic under fake clocks.
364- if parked.timer != nil {
365- parked.timer.Stop()
366- }
367- parked.closeDone()
368- rearmed := &parkedAttachment{
369- sess: parked.sess,
370- ac: ac,
371- pickerGeneration: parked.pickerGeneration,
372- timer: d.clock.NewTimer(d.resumeParkGrace),
373- done: make(chan struct{}),
374- }
375- d.parked[token] = rearmed
376 captured := ac.transportSnapshot().transport351 captured := ac.transportSnapshot().transport
377 ac.setSession(nil)352 ac.setSession(nil)
378 d.mu.Unlock()353 d.mu.Unlock()
379- d.watchParkedTimer(token, rearmed)
380 if captured != nil {354 if captured != nil {
381 _ = ac.closeCapturedTransport(ac.revokeTransport(captured))355 _ = ac.closeCapturedTransport(ac.revokeTransport(captured))
382 }356 }
@@ -612,7 +586,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
612 // mandatory first paint cannot be blocked by ACKs that died with the link.586 // mandatory first paint cannot be blocked by ACKs that died with the link.
613 ac.rebaseOutput()587 ac.rebaseOutput()
614 ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight))588 ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight))
615- ac.output.syncCapacityLocked()589+ ac.output.maxOutstandingAtomic.Store(ac.output.maxOutstanding)
616 ac.replaceTransport(tr)590 ac.replaceTransport(tr)
617 ac.size = sz591 ac.size = sz
618 ac.resumeToken = d.nextResumeTokenLocked()592 ac.resumeToken = d.nextResumeTokenLocked()
M internal/usecase/daemon/session.go +16 -25
@@ -155,7 +155,7 @@ type tab struct {
155155 // what it has actually seen). sendMu serialises the two senders — the render
156156 // coordinator and the connection handler — so the transport's single-writer
157157
158-func (d *Daemon) touchMRU(entry attachmentSession) {
158+func (d *Daemon) touchMRU(entry *session) {
159159 if d == nil || entry == nil || entry.core() == nil {
160160 return
161161 }
@@ -170,15 +170,11 @@ func (d *Daemon) touchMRU(entry attachmentSession) {
170170 break
171171 }
172172 }
173- sess, ok := localSession(entry)
174- if !ok {
175- return
176- }
177- sess.mu.Lock()
178- if !sess.ephemeral {
179- d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
173+ entry.mu.Lock()
174+ if !entry.ephemeral {
175+ d.markCatalogueDirty(entry.persistRecordLocked(max(d.nowUnixNano(), entry.createdAt, int64(1))).MetadataUpdate())
180176 }
181- sess.mu.Unlock()
177+ entry.mu.Unlock()
182178 }
183179
184180 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {
@@ -474,8 +470,8 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
474470 expectedTransport: token.transport, sourceToken: &token, action: "create-session",
475471 copySourceEnvironment: true, ready: true,
476472 createTargetLocked: func() (*session, error) {
477- source, ok := localSession(token.sess)
478- if !ok {
473+ source := token.sess
474+ if source == nil {
479475 return nil, errAttachmentTransition
480476 }
481477 if d.closing {
@@ -500,8 +496,8 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
500496 }
501497
502498 d.touchMRU(created)
503- if source, ok := localSession(token.sess); ok {
504- token.ac.recordPreviousSession(source)
499+ if token.sess != nil {
500+ token.ac.recordPreviousSession(token.sess)
505501 }
506502 d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)
507503 d.deferAttachmentTransitionCleanups(transition)
@@ -774,10 +770,7 @@ func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken,
774770 // own an independent connection lifecycle and detach through the same exact
775771 // transport/generation fence.
776772 if token.lease == nil {
777- if sess, ok := localSession(token.sess); ok {
778- return d.detachIfCurrentTransport(sess, token.ac, token.transport)
779- }
780- return false
773+ return d.detachIfCurrentTransport(token.sess, token.ac, token.transport)
781774 }
782775 frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac)
783776 defer frozen.unfreeze()
@@ -1152,9 +1145,8 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
11521145 if token.sess == nil || token.ac == nil || token.transport.transport == nil {
11531146 return sessionKillParticipants{}, false
11541147 }
1155- var ok bool
1156- snapshot.source, ok = localSession(token.sess)
1157- if !ok {
1148+ snapshot.source = token.sess
1149+ if snapshot.source == nil {
11581150 return sessionKillParticipants{}, false
11591151 }
11601152 snapshot.sourceToken = &admission.token
@@ -1641,8 +1633,8 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
16411633 // d.mu.
16421634 func (d *Daemon) allocEphemeralNameLocked() string {
16431635 used := make(map[string]struct{}, len(d.sessions)+len(d.stopped))
1644- for _, entry := range d.sessions {
1645- if s, ok := localSession(entry); ok {
1636+ for _, s := range d.sessions {
1637+ if s != nil {
16461638 used[s.name] = struct{}{}
16471639 }
16481640 }
@@ -1658,9 +1650,8 @@ func (d *Daemon) allocEphemeralNameLocked() string {
16581650 }
16591651
16601652 func (d *Daemon) findByNameLocked(name string) *session {
1661- for _, entry := range d.sessions {
1662- s, ok := localSession(entry)
1663- if ok && s.name == name {
1653+ for _, s := range d.sessions {
1654+ if s != nil && s.name == name {
16641655 return s
16651656 }
16661657 }
@@ -155,7 +155,7 @@ type tab struct {
155 // what it has actually seen). sendMu serialises the two senders — the render155 // what it has actually seen). sendMu serialises the two senders — the render
156 // coordinator and the connection handler — so the transport's single-writer156 // coordinator and the connection handler — so the transport's single-writer
157 157
158-func (d *Daemon) touchMRU(entry attachmentSession) {158+func (d *Daemon) touchMRU(entry *session) {
159 if d == nil || entry == nil || entry.core() == nil {159 if d == nil || entry == nil || entry.core() == nil {
160 return160 return
161 }161 }
@@ -170,15 +170,11 @@ func (d *Daemon) touchMRU(entry attachmentSession) {
170 break170 break
171 }171 }
172 }172 }
173- sess, ok := localSession(entry)173+ entry.mu.Lock()
174- if !ok {174+ if !entry.ephemeral {
175- return175+ d.markCatalogueDirty(entry.persistRecordLocked(max(d.nowUnixNano(), entry.createdAt, int64(1))).MetadataUpdate())
176- }
177- sess.mu.Lock()
178- if !sess.ephemeral {
179- d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
180 }176 }
181- sess.mu.Unlock()177+ entry.mu.Unlock()
182 }178 }
183 179
184 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {180 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {
@@ -474,8 +470,8 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
474 expectedTransport: token.transport, sourceToken: &token, action: "create-session",470 expectedTransport: token.transport, sourceToken: &token, action: "create-session",
475 copySourceEnvironment: true, ready: true,471 copySourceEnvironment: true, ready: true,
476 createTargetLocked: func() (*session, error) {472 createTargetLocked: func() (*session, error) {
477- source, ok := localSession(token.sess)473+ source := token.sess
478- if !ok {474+ if source == nil {
479 return nil, errAttachmentTransition475 return nil, errAttachmentTransition
480 }476 }
481 if d.closing {477 if d.closing {
@@ -500,8 +496,8 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
500 }496 }
501 497
502 d.touchMRU(created)498 d.touchMRU(created)
503- if source, ok := localSession(token.sess); ok {499+ if token.sess != nil {
504- token.ac.recordPreviousSession(source)500+ token.ac.recordPreviousSession(token.sess)
505 }501 }
506 d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)502 d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)
507 d.deferAttachmentTransitionCleanups(transition)503 d.deferAttachmentTransitionCleanups(transition)
@@ -774,10 +770,7 @@ func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken,
774 // own an independent connection lifecycle and detach through the same exact770 // own an independent connection lifecycle and detach through the same exact
775 // transport/generation fence.771 // transport/generation fence.
776 if token.lease == nil {772 if token.lease == nil {
777- if sess, ok := localSession(token.sess); ok {773+ return d.detachIfCurrentTransport(token.sess, token.ac, token.transport)
778- return d.detachIfCurrentTransport(sess, token.ac, token.transport)
779- }
780- return false
781 }774 }
782 frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac)775 frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac)
783 defer frozen.unfreeze()776 defer frozen.unfreeze()
@@ -1152,9 +1145,8 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
1152 if token.sess == nil || token.ac == nil || token.transport.transport == nil {1145 if token.sess == nil || token.ac == nil || token.transport.transport == nil {
1153 return sessionKillParticipants{}, false1146 return sessionKillParticipants{}, false
1154 }1147 }
1155- var ok bool1148+ snapshot.source = token.sess
1156- snapshot.source, ok = localSession(token.sess)1149+ if snapshot.source == nil {
1157- if !ok {
1158 return sessionKillParticipants{}, false1150 return sessionKillParticipants{}, false
1159 }1151 }
1160 snapshot.sourceToken = &admission.token1152 snapshot.sourceToken = &admission.token
@@ -1641,8 +1633,8 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1641 // d.mu.1633 // d.mu.
1642 func (d *Daemon) allocEphemeralNameLocked() string {1634 func (d *Daemon) allocEphemeralNameLocked() string {
1643 used := make(map[string]struct{}, len(d.sessions)+len(d.stopped))1635 used := make(map[string]struct{}, len(d.sessions)+len(d.stopped))
1644- for _, entry := range d.sessions {1636+ for _, s := range d.sessions {
1645- if s, ok := localSession(entry); ok {1637+ if s != nil {
1646 used[s.name] = struct{}{}1638 used[s.name] = struct{}{}
1647 }1639 }
1648 }1640 }
@@ -1658,9 +1650,8 @@ func (d *Daemon) allocEphemeralNameLocked() string {
1658 }1650 }
1659 1651
1660 func (d *Daemon) findByNameLocked(name string) *session {1652 func (d *Daemon) findByNameLocked(name string) *session {
1661- for _, entry := range d.sessions {1653+ for _, s := range d.sessions {
1662- s, ok := localSession(entry)1654+ if s != nil && s.name == name {
1663- if ok && s.name == name {
1664 return s1655 return s
1665 }1656 }
1666 }1657 }
M internal/usecase/daemon/session_back.go +3 -3
@@ -45,11 +45,11 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
4545
4646 // clearPreviousSessionIf clears target only if it has not been replaced since
4747 // the caller observed it. This preserves a concurrent successful hand-off.
48-func (ac *attachedClient) clearPreviousSessionIf(target attachmentSession) {
48+func (ac *attachedClient) clearPreviousSessionIf(target *session) {
4949 if ac == nil {
5050 return
5151 }
52- ac.previousSession.With(func(previous *attachmentSession) {
52+ ac.previousSession.With(func(previous **session) {
5353 if *previous == target {
5454 *previous = nil
5555 }
@@ -59,7 +59,7 @@ func (ac *attachedClient) clearPreviousSessionIf(target attachmentSession) {
5959 // recordPreviousSession is called only after a cross-session hand-off has
6060 // committed. Keeping this after the commit makes failed/displaced switches
6161 // leave the toggle pair intact.
62-func (ac *attachedClient) recordPreviousSession(origin attachmentSession) {
62+func (ac *attachedClient) recordPreviousSession(origin *session) {
6363 if ac != nil && origin != nil {
6464 ac.previousSession.Set(origin)
6565 }
@@ -45,11 +45,11 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
45 45
46 // clearPreviousSessionIf clears target only if it has not been replaced since46 // clearPreviousSessionIf clears target only if it has not been replaced since
47 // the caller observed it. This preserves a concurrent successful hand-off.47 // the caller observed it. This preserves a concurrent successful hand-off.
48-func (ac *attachedClient) clearPreviousSessionIf(target attachmentSession) {48+func (ac *attachedClient) clearPreviousSessionIf(target *session) {
49 if ac == nil {49 if ac == nil {
50 return50 return
51 }51 }
52- ac.previousSession.With(func(previous *attachmentSession) {52+ ac.previousSession.With(func(previous **session) {
53 if *previous == target {53 if *previous == target {
54 *previous = nil54 *previous = nil
55 }55 }
@@ -59,7 +59,7 @@ func (ac *attachedClient) clearPreviousSessionIf(target attachmentSession) {
59 // recordPreviousSession is called only after a cross-session hand-off has59 // recordPreviousSession is called only after a cross-session hand-off has
60 // committed. Keeping this after the commit makes failed/displaced switches60 // committed. Keeping this after the commit makes failed/displaced switches
61 // leave the toggle pair intact.61 // leave the toggle pair intact.
62-func (ac *attachedClient) recordPreviousSession(origin attachmentSession) {62+func (ac *attachedClient) recordPreviousSession(origin *session) {
63 if ac != nil && origin != nil {63 if ac != nil && origin != nil {
64 ac.previousSession.Set(origin)64 ac.previousSession.Set(origin)
65 }65 }
M internal/usecase/daemon/session_core.go +6 -23
@@ -7,16 +7,6 @@ import (
77 "github.com/bnema/vev/internal/domain"
88 )
99
10-// attachmentSession is the private attachment-facing session contract. Local
11-// process, tab, persistence, and teardown behavior remains on *session.
12-type attachmentSession interface {
13- core() *sessionCore
14- snapshotView(viewOptions) sessionView
15- statusSegments(includeTerminalTitle bool) statusSnapshot
16- captureRenderState(*attachedClient, renderCaptureRequest) (*capturedRenderState, bool)
17- validTargetTabLocked(tabIndex int) bool
18-}
19-
2010 // sessionCore is the state shared by every attachment-capable session. It is
2111 // embedded as the first field of session so the existing s.mu selector remains
2212 // the one lock guarding both attachment membership and local tab state.
@@ -60,19 +50,12 @@ func (s *session) validTargetTabLocked(tabIndex int) bool {
6050 return tabIndex < len(s.tabs)
6151 }
6252
63-// localSession narrows an attachment entry for operations that own local PTYs,
64-// tabs, snapshots, or durable lifecycle state.
65-func localSession(entry attachmentSession) (*session, bool) {
66- sess, ok := entry.(*session)
67- return sess, ok && sess != nil
68-}
69-
70-func localSessionsSnapshot(entries map[domain.SessionID]attachmentSession) []*session {
71- locals := make([]*session, 0, len(entries))
72- for _, entry := range entries {
73- if sess, ok := localSession(entry); ok {
74- locals = append(locals, sess)
53+func sessionsSnapshot(entries map[domain.SessionID]*session) []*session {
54+ sessions := make([]*session, 0, len(entries))
55+ for _, sess := range entries {
56+ if sess != nil {
57+ sessions = append(sessions, sess)
7558 }
7659 }
77- return locals
60+ return sessions
7861 }
@@ -7,16 +7,6 @@ import (
7 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
8 )8 )
9 9
10-// attachmentSession is the private attachment-facing session contract. Local
11-// process, tab, persistence, and teardown behavior remains on *session.
12-type attachmentSession interface {
13- core() *sessionCore
14- snapshotView(viewOptions) sessionView
15- statusSegments(includeTerminalTitle bool) statusSnapshot
16- captureRenderState(*attachedClient, renderCaptureRequest) (*capturedRenderState, bool)
17- validTargetTabLocked(tabIndex int) bool
18-}
19-
20 // sessionCore is the state shared by every attachment-capable session. It is10 // sessionCore is the state shared by every attachment-capable session. It is
21 // embedded as the first field of session so the existing s.mu selector remains11 // embedded as the first field of session so the existing s.mu selector remains
22 // the one lock guarding both attachment membership and local tab state.12 // the one lock guarding both attachment membership and local tab state.
@@ -60,19 +50,12 @@ func (s *session) validTargetTabLocked(tabIndex int) bool {
60 return tabIndex < len(s.tabs)50 return tabIndex < len(s.tabs)
61 }51 }
62 52
63-// localSession narrows an attachment entry for operations that own local PTYs,53+func sessionsSnapshot(entries map[domain.SessionID]*session) []*session {
64-// tabs, snapshots, or durable lifecycle state.54+ sessions := make([]*session, 0, len(entries))
65-func localSession(entry attachmentSession) (*session, bool) {55+ for _, sess := range entries {
66- sess, ok := entry.(*session)56+ if sess != nil {
67- return sess, ok && sess != nil57+ sessions = append(sessions, sess)
68-}
69-
70-func localSessionsSnapshot(entries map[domain.SessionID]attachmentSession) []*session {
71- locals := make([]*session, 0, len(entries))
72- for _, entry := range entries {
73- if sess, ok := localSession(entry); ok {
74- locals = append(locals, sess)
75 }58 }
76 }59 }
77- return locals60+ return sessions
78 }61 }
M internal/usecase/daemon/session_core_test.go +5 -6
@@ -30,7 +30,7 @@ func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) {
3030
3131 core := sess.core()
3232 require.Same(t, &sess.sessionCore, core)
33- require.Same(t, sess, mustLocalSession(t, attachmentSession(sess)))
33+ require.Same(t, sess, sess)
3434 require.Equal(t, sessionCapabilities{}, core.caps, "zero-value local sessions must retain all local capabilities")
3535
3636 core.mu.Lock()
@@ -113,7 +113,7 @@ func TestLocalCreateThenKillRemovesLiveRegistryEntry(t *testing.T) {
113113 }
114114
115115 func TestAttachmentSessionRegistryUsesExactIdentity(t *testing.T) {
116- d := &Daemon{sessions: make(map[domain.SessionID]attachmentSession)}
116+ d := &Daemon{sessions: make(map[domain.SessionID]*session)}
117117 first := &session{}
118118 first.id = domain.SessionID("same")
119119 second := &session{}
@@ -146,9 +146,8 @@ func TestSessionCoreLockOrderUsesImmutableIDs(t *testing.T) {
146146 second.core().mu.Unlock()
147147 }
148148
149-func mustLocalSession(t *testing.T, entry attachmentSession) *session {
149+func mustLocalSession(t *testing.T, entry *session) *session {
150150 t.Helper()
151- sess, ok := localSession(entry)
152- require.True(t, ok)
153- return sess
151+ require.NotNil(t, entry)
152+ return entry
154153 }
@@ -30,7 +30,7 @@ func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) {
30 30
31 core := sess.core()31 core := sess.core()
32 require.Same(t, &sess.sessionCore, core)32 require.Same(t, &sess.sessionCore, core)
33- require.Same(t, sess, mustLocalSession(t, attachmentSession(sess)))33+ require.Same(t, sess, sess)
34 require.Equal(t, sessionCapabilities{}, core.caps, "zero-value local sessions must retain all local capabilities")34 require.Equal(t, sessionCapabilities{}, core.caps, "zero-value local sessions must retain all local capabilities")
35 35
36 core.mu.Lock()36 core.mu.Lock()
@@ -113,7 +113,7 @@ func TestLocalCreateThenKillRemovesLiveRegistryEntry(t *testing.T) {
113 }113 }
114 114
115 func TestAttachmentSessionRegistryUsesExactIdentity(t *testing.T) {115 func TestAttachmentSessionRegistryUsesExactIdentity(t *testing.T) {
116- d := &Daemon{sessions: make(map[domain.SessionID]attachmentSession)}116+ d := &Daemon{sessions: make(map[domain.SessionID]*session)}
117 first := &session{}117 first := &session{}
118 first.id = domain.SessionID("same")118 first.id = domain.SessionID("same")
119 second := &session{}119 second := &session{}
@@ -146,9 +146,8 @@ func TestSessionCoreLockOrderUsesImmutableIDs(t *testing.T) {
146 second.core().mu.Unlock()146 second.core().mu.Unlock()
147 }147 }
148 148
149-func mustLocalSession(t *testing.T, entry attachmentSession) *session {149+func mustLocalSession(t *testing.T, entry *session) *session {
150 t.Helper()150 t.Helper()
151- sess, ok := localSession(entry)151+ require.NotNil(t, entry)
152- require.True(t, ok)152+ return entry
153- return sess
154 }153 }
M internal/usecase/daemon/session_purge_retry_test.go +1 -1
@@ -38,7 +38,7 @@ func TestLivePurgeLeavesFailedDirectoryDeletionForStartupGarbageCollection(t *te
3838 pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
3939 require.True(t, ok)
4040 pty.EXPECT().Close().Return(nil).Once()
41- d.sessions = map[domain.SessionID]attachmentSession{sess.id: sess}
41+ d.sessions = map[domain.SessionID]*session{sess.id: sess}
4242
4343 require.Error(t, d.killSession(sess, ports.ReasonSessionKilled, true))
4444 require.Equal(t, []string{"delete incarnation"}, repository.calls)
@@ -38,7 +38,7 @@ func TestLivePurgeLeavesFailedDirectoryDeletionForStartupGarbageCollection(t *te
38 pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)38 pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
39 require.True(t, ok)39 require.True(t, ok)
40 pty.EXPECT().Close().Return(nil).Once()40 pty.EXPECT().Close().Return(nil).Once()
41- d.sessions = map[domain.SessionID]attachmentSession{sess.id: sess}41+ d.sessions = map[domain.SessionID]*session{sess.id: sess}
42 42
43 require.Error(t, d.killSession(sess, ports.ReasonSessionKilled, true))43 require.Error(t, d.killSession(sess, ports.ReasonSessionKilled, true))
44 require.Equal(t, []string{"delete incarnation"}, repository.calls)44 require.Equal(t, []string{"delete incarnation"}, repository.calls)
M internal/usecase/daemon/session_rename_purge_test.go +1 -1
@@ -16,7 +16,7 @@ func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) {
1616 store, state := newMockStore(t)
1717 WithStore(t, store)(d)
1818 sess := newSnapshotTestSession(t, "old", false, "/work")
19- d.sessions = map[domain.SessionID]attachmentSession{sess.id: sess}
19+ d.sessions = map[domain.SessionID]*session{sess.id: sess}
2020 sess.mu.Lock()
2121 record := sess.persistRecordLocked(1)
2222 sess.mu.Unlock()
@@ -16,7 +16,7 @@ func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) {
16 store, state := newMockStore(t)16 store, state := newMockStore(t)
17 WithStore(t, store)(d)17 WithStore(t, store)(d)
18 sess := newSnapshotTestSession(t, "old", false, "/work")18 sess := newSnapshotTestSession(t, "old", false, "/work")
19- d.sessions = map[domain.SessionID]attachmentSession{sess.id: sess}19+ d.sessions = map[domain.SessionID]*session{sess.id: sess}
20 sess.mu.Lock()20 sess.mu.Lock()
21 record := sess.persistRecordLocked(1)21 record := sess.persistRecordLocked(1)
22 sess.mu.Unlock()22 sess.mu.Unlock()
M internal/usecase/daemon/status.go +8 -15
@@ -240,24 +240,21 @@ func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession
240240 // palette interaction has captured recent-session hints. Normal palette state
241241 // continues to use the canonical, live MRU list.
242242 func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
243- var entry attachmentSession
243+ var entry *session
244244 if cur != nil {
245245 entry = cur
246246 }
247247 return d.barStateForAttachmentPaletteHints(entry, statusFeedback, hints, recent)
248248 }
249249
250-// barStateForAttachmentPaletteHints composes daemon-owned chrome for either
251-// attachment implementation. Bar scripts remain local-session-only because
250+// barStateForAttachmentPaletteHints composes daemon-owned chrome for an
251+// attachment. Bar scripts remain local-session-only because
252252 // their existing execution contract depends on local tabs and PTYs.
253-func (d *Daemon) barStateForAttachmentPaletteHints(cur attachmentSession, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
253+func (d *Daemon) barStateForAttachmentPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
254254 return d.barStateForAttachmentPaletteHintsFor(cur, nil, statusFeedback, hints, recent)
255255 }
256256
257-func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur attachmentSession, ac *attachedClient, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
258- if attachmentSessionCore(cur) == nil {
259- cur = nil
260- }
257+func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attachedClient, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
261258 ranked := rankedRecentForHints(hints, recent)
262259 state := barState{statusFeedback: statusFeedback}
263260 if d != nil {
@@ -268,14 +265,10 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur attachmentSession, ac
268265 if d != nil {
269266 includeTerminalTitle = d.currentTabsConfig().TerminalTitle
270267 }
271- if local, ok := localSession(cur); ok {
272- state.status = local.statusSegmentsFor(ac, includeTerminalTitle)
273- } else {
274- state.status = cur.statusSegments(includeTerminalTitle)
275- }
268+ state.status = cur.statusSegmentsFor(ac, includeTerminalTitle)
276269 }
277- if local, ok := localSession(cur); ok && d != nil {
278- state.topRight, state.bottomRight = d.barScriptSnapshot(local)
270+ if cur != nil && d != nil {
271+ state.topRight, state.bottomRight = d.barScriptSnapshot(cur)
279272 }
280273 if ranked != nil {
281274 state.rankedRecent = ranked
@@ -240,24 +240,21 @@ func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession
240 // palette interaction has captured recent-session hints. Normal palette state240 // palette interaction has captured recent-session hints. Normal palette state
241 // continues to use the canonical, live MRU list.241 // continues to use the canonical, live MRU list.
242 func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {242 func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
243- var entry attachmentSession243+ var entry *session
244 if cur != nil {244 if cur != nil {
245 entry = cur245 entry = cur
246 }246 }
247 return d.barStateForAttachmentPaletteHints(entry, statusFeedback, hints, recent)247 return d.barStateForAttachmentPaletteHints(entry, statusFeedback, hints, recent)
248 }248 }
249 249
250-// barStateForAttachmentPaletteHints composes daemon-owned chrome for either250+// barStateForAttachmentPaletteHints composes daemon-owned chrome for an
251-// attachment implementation. Bar scripts remain local-session-only because251+// attachment. Bar scripts remain local-session-only because
252 // their existing execution contract depends on local tabs and PTYs.252 // their existing execution contract depends on local tabs and PTYs.
253-func (d *Daemon) barStateForAttachmentPaletteHints(cur attachmentSession, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {253+func (d *Daemon) barStateForAttachmentPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
254 return d.barStateForAttachmentPaletteHintsFor(cur, nil, statusFeedback, hints, recent)254 return d.barStateForAttachmentPaletteHintsFor(cur, nil, statusFeedback, hints, recent)
255 }255 }
256 256
257-func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur attachmentSession, ac *attachedClient, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {257+func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attachedClient, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
258- if attachmentSessionCore(cur) == nil {
259- cur = nil
260- }
261 ranked := rankedRecentForHints(hints, recent)258 ranked := rankedRecentForHints(hints, recent)
262 state := barState{statusFeedback: statusFeedback}259 state := barState{statusFeedback: statusFeedback}
263 if d != nil {260 if d != nil {
@@ -268,14 +265,10 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur attachmentSession, ac
268 if d != nil {265 if d != nil {
269 includeTerminalTitle = d.currentTabsConfig().TerminalTitle266 includeTerminalTitle = d.currentTabsConfig().TerminalTitle
270 }267 }
271- if local, ok := localSession(cur); ok {268+ state.status = cur.statusSegmentsFor(ac, includeTerminalTitle)
272- state.status = local.statusSegmentsFor(ac, includeTerminalTitle)
273- } else {
274- state.status = cur.statusSegments(includeTerminalTitle)
275- }
276 }269 }
277- if local, ok := localSession(cur); ok && d != nil {270+ if cur != nil && d != nil {
278- state.topRight, state.bottomRight = d.barScriptSnapshot(local)271+ state.topRight, state.bottomRight = d.barScriptSnapshot(cur)
279 }272 }
280 if ranked != nil {273 if ranked != nil {
281 state.rankedRecent = ranked274 state.rankedRecent = ranked
M internal/usecase/daemon/status_test.go +3 -18
@@ -22,20 +22,6 @@ import (
2222 "github.com/bnema/vev/pkg/renderer"
2323 )
2424
25-// --- test doubles -----------------------------------------------------------
26-
27-type coreNilAttachment struct{}
28-
29-func (*coreNilAttachment) core() *sessionCore { return nil }
30-func (*coreNilAttachment) snapshotView(viewOptions) sessionView {
31- return sessionView{}
32-}
33-func (*coreNilAttachment) statusSegments(bool) statusSnapshot { return statusSnapshot{} }
34-func (*coreNilAttachment) captureRenderState(*attachedClient, renderCaptureRequest) (*capturedRenderState, bool) {
35- return nil, false
36-}
37-func (*coreNilAttachment) validTargetTabLocked(int) bool { return false }
38-
3925 // stubClock returns timers whose channel never fires, so a scheduler under it
4026 // blocks in its debounce loop until the session context is cancelled. Used by
4127
@@ -931,18 +917,17 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
931917 }
932918
933919 func TestLockAttachmentSessionsRejectsNilEntriesWithoutPanic(t *testing.T) {
934- var nilEntry attachmentSession
920+ var nilEntry *session
935921 var typedNil *session
936922 valid := &session{sessionCore: sessionCore{id: "valid"}}
937923
938924 for _, tc := range []struct {
939925 name string
940- a attachmentSession
941- b attachmentSession
926+ a *session
927+ b *session
942928 }{
943929 {name: "nil interfaces", a: nilEntry, b: nilEntry},
944930 {name: "typed nil", a: typedNil, b: valid},
945- {name: "core nil", a: &coreNilAttachment{}, b: valid},
946931 } {
947932 t.Run(tc.name, func(t *testing.T) {
948933 require.NotPanics(t, func() {
@@ -22,20 +22,6 @@ import (
22 "github.com/bnema/vev/pkg/renderer"22 "github.com/bnema/vev/pkg/renderer"
23 )23 )
24 24
25-// --- test doubles -----------------------------------------------------------
26-
27-type coreNilAttachment struct{}
28-
29-func (*coreNilAttachment) core() *sessionCore { return nil }
30-func (*coreNilAttachment) snapshotView(viewOptions) sessionView {
31- return sessionView{}
32-}
33-func (*coreNilAttachment) statusSegments(bool) statusSnapshot { return statusSnapshot{} }
34-func (*coreNilAttachment) captureRenderState(*attachedClient, renderCaptureRequest) (*capturedRenderState, bool) {
35- return nil, false
36-}
37-func (*coreNilAttachment) validTargetTabLocked(int) bool { return false }
38-
39 // stubClock returns timers whose channel never fires, so a scheduler under it25 // stubClock returns timers whose channel never fires, so a scheduler under it
40 // blocks in its debounce loop until the session context is cancelled. Used by26 // blocks in its debounce loop until the session context is cancelled. Used by
41 27
@@ -931,18 +917,17 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
931 }917 }
932 918
933 func TestLockAttachmentSessionsRejectsNilEntriesWithoutPanic(t *testing.T) {919 func TestLockAttachmentSessionsRejectsNilEntriesWithoutPanic(t *testing.T) {
934- var nilEntry attachmentSession920+ var nilEntry *session
935 var typedNil *session921 var typedNil *session
936 valid := &session{sessionCore: sessionCore{id: "valid"}}922 valid := &session{sessionCore: sessionCore{id: "valid"}}
937 923
938 for _, tc := range []struct {924 for _, tc := range []struct {
939 name string925 name string
940- a attachmentSession926+ a *session
941- b attachmentSession927+ b *session
942 }{928 }{
943 {name: "nil interfaces", a: nilEntry, b: nilEntry},929 {name: "nil interfaces", a: nilEntry, b: nilEntry},
944 {name: "typed nil", a: typedNil, b: valid},930 {name: "typed nil", a: typedNil, b: valid},
945- {name: "core nil", a: &coreNilAttachment{}, b: valid},
946 } {931 } {
947 t.Run(tc.name, func(t *testing.T) {932 t.Run(tc.name, func(t *testing.T) {
948 require.NotPanics(t, func() {933 require.NotPanics(t, func() {
M internal/usecase/daemon/wire.go +3 -1
@@ -7,13 +7,15 @@ import (
77 )
88
99 func frameWelcome(s *session, ac *attachedClient) ports.Frame {
10+ s.mu.Lock()
1011 w := ports.Welcome{
1112 SessionID: string(s.id),
1213 SessionName: s.name,
1314 Ephemeral: s.ephemeral,
1415 Capabilities: ports.CapabilityResume,
15- ResumeToken: ac.resumeToken,
1616 }
17+ s.mu.Unlock()
18+ w.ResumeToken = ac.resumeToken
1719 return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}
1820 }
1921
@@ -7,13 +7,15 @@ import (
7 )7 )
8 8
9 func frameWelcome(s *session, ac *attachedClient) ports.Frame {9 func frameWelcome(s *session, ac *attachedClient) ports.Frame {
10+ s.mu.Lock()
10 w := ports.Welcome{11 w := ports.Welcome{
11 SessionID: string(s.id),12 SessionID: string(s.id),
12 SessionName: s.name,13 SessionName: s.name,
13 Ephemeral: s.ephemeral,14 Ephemeral: s.ephemeral,
14 Capabilities: ports.CapabilityResume,15 Capabilities: ports.CapabilityResume,
15- ResumeToken: ac.resumeToken,
16 }16 }
17+ s.mu.Unlock()
18+ w.ResumeToken = ac.resumeToken
17 return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}19 return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}
18 }20 }
19 21
A internal/usecase/daemon/wire_metadata_test.go +36 -0
@@ -0,0 +1,36 @@
1+package daemon
2+
3+import (
4+ "fmt"
5+ "sync"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+func TestFrameWelcomeSnapshotsSessionMetadataUnderLock(t *testing.T) {
12+ sess := &session{sessionCore: sessionCore{id: "session", name: "initial"}}
13+ ac := &attachedClient{resumeToken: 42}
14+
15+ var wg sync.WaitGroup
16+ wg.Go(func() {
17+ for i := range 1000 {
18+ sess.mu.Lock()
19+ sess.name = fmt.Sprintf("session-%d", i)
20+ sess.ephemeral = i%2 == 0
21+ sess.mu.Unlock()
22+ }
23+ })
24+
25+ for range 1000 {
26+ frame := frameWelcome(sess, ac)
27+ welcome, err := ports.UnmarshalWelcome(frame.Payload)
28+ if err != nil {
29+ t.Fatalf("UnmarshalWelcome() error = %v", err)
30+ }
31+ if welcome.SessionID != "session" || welcome.SessionName == "" {
32+ t.Fatalf("welcome metadata = %+v", welcome)
33+ }
34+ }
35+ wg.Wait()
36+}
@@ -0,0 +1,36 @@
1+package daemon
2+
3+import (
4+ "fmt"
5+ "sync"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+func TestFrameWelcomeSnapshotsSessionMetadataUnderLock(t *testing.T) {
12+ sess := &session{sessionCore: sessionCore{id: "session", name: "initial"}}
13+ ac := &attachedClient{resumeToken: 42}
14+
15+ var wg sync.WaitGroup
16+ wg.Go(func() {
17+ for i := range 1000 {
18+ sess.mu.Lock()
19+ sess.name = fmt.Sprintf("session-%d", i)
20+ sess.ephemeral = i%2 == 0
21+ sess.mu.Unlock()
22+ }
23+ })
24+
25+ for range 1000 {
26+ frame := frameWelcome(sess, ac)
27+ welcome, err := ports.UnmarshalWelcome(frame.Payload)
28+ if err != nil {
29+ t.Fatalf("UnmarshalWelcome() error = %v", err)
30+ }
31+ if welcome.SessionID != "session" || welcome.SessionName == "" {
32+ t.Fatalf("welcome metadata = %+v", welcome)
33+ }
34+ }
35+ wg.Wait()
36+}

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