detached 6a384ad

Merge pull request #173 from bnema/feat/multi-attachment-session-views

feat(session): support independent attachment views
commit
6a384adbdcfdf2855898281af179dae270afa7fc
author
bnema · · 2026-08-04 03:17 UTC
committer
GitHub · · 2026-08-04 03:17 UTC
merge of
738b9eb + 95dd009 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
158 files · +6831 -12512
A .coderabbit.yaml +11 -0
@@ -0,0 +1,11 @@
1+# Temporary PR #173 review scope; remove after the clean reviewed-head merge.
2+reviews:
3+ path_filters:
4+ - "!internal/usecase/daemon/move_atomicity_test.go"
5+ - "!internal/usecase/daemon/move_final_source_attachment_race_test.go"
6+ - "!internal/usecase/daemon/pane_sync_owner_test.go"
7+ - "!internal/usecase/daemon/role_effect_gate_test.go"
8+ - "!internal/usecase/daemon/snatched_client_test.go"
9+ - "!internal/usecase/daemon/snatched_reclaim_test.go"
10+ - "!internal/usecase/daemon/snatched_render_test.go"
11+ - "!internal/usecase/daemon/stalled_attachment_test.go"
M internal/app/daemon_integration_test.go +42 -1
@@ -261,6 +261,47 @@ func TestIntegration_AttachEnvironmentRefreshesFuturePTYChildren(t *testing.T) {
261261 assertChildEnvironment(t, tr2, p2, sz, "second", secondShell, "/run/second", "wayland-second")
262262 }
263263
264+func TestIntegration_TwoAttachmentsReceiveSharedMutationPTYOutput(t *testing.T) {
265+ sz := domain.Size{Cols: 80, Rows: 24}
266+ dir, _ := startDaemon(t)
267+ firstShell := shellFixture(t, "first")
268+ const sharedOutput = "SECOND_SHARED_PTY_OUTPUT"
269+ secondShell := filepath.Join(t.TempDir(), "second-shell")
270+ require.NoError(t, os.WriteFile(secondShell, []byte("#!/bin/sh\nprintf 'SHELL_COMMAND=second\\n'\nIFS= read -r _\nprintf '"+sharedOutput+"\\n'\nexec /bin/sh\n"), 0o700))
271+ firstEnv := []string{
272+ "VEV_TEST_ENV=first", "SHELL=" + firstShell, "XDG_RUNTIME_DIR=/run/first", "WAYLAND_DISPLAY=wayland-first",
273+ "TERM=client", "COLORTERM=client", "TERM_PROGRAM=client", "VEV=client",
274+ }
275+ tr1, p1 := attachWithEnvironment(t, dir, ports.IntentNew, "environment", sz, firstEnv)
276+ defer func() { _ = tr1.Close() }()
277+ awaitText(t, p1, sz, "SHELL_COMMAND=first")
278+ assertChildEnvironment(t, tr1, p1, sz, "first", firstShell, "/run/first", "wayland-first")
279+
280+ secondEnv := []string{
281+ "VEV_TEST_ENV=second", "SHELL=" + secondShell, "XDG_RUNTIME_DIR=/run/second", "WAYLAND_DISPLAY=wayland-second",
282+ "TERM=client", "COLORTERM=client", "TERM_PROGRAM=client", "VEV=client",
283+ }
284+ tr2, p2 := attachWithEnvironment(t, dir, ports.IntentAttach, "environment", sz, secondEnv)
285+ defer func() { _ = tr2.Close() }()
286+ assertChildEnvironment(t, tr2, p2, sz, "first", firstShell, "/run/first", "wayland-first")
287+
288+ // The second attachment mutates shared tab state. Its newly opened PTY must
289+ // also be rendered to that attachment, not only to the coordinator primary.
290+ require.NoError(t, tr2.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("\x1b ")})}))
291+ awaitText(t, p2, sz, "Commands")
292+ require.NoError(t, tr2.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("CNT\r")})}))
293+ awaitText(t, p2, sz, "SHELL_COMMAND=second")
294+
295+ // Each attachment owns its selected tab. Move the first attachment to the
296+ // new PTY before releasing its gate, so the fresh PTY output must reach both
297+ // views rather than only being recovered by a later first paint.
298+ require.NoError(t, tr1.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("\x1b2")})}))
299+ awaitText(t, p1, sz, "SHELL_COMMAND=second")
300+ require.NoError(t, tr1.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("go\n")})}))
301+ awaitText(t, p2, sz, sharedOutput)
302+ awaitText(t, p1, sz, sharedOutput)
303+}
304+
264305 func TestIntegration_AttachFirstOutput(t *testing.T) {
265306 sz := domain.Size{Cols: 80, Rows: 24}
266307 dir, _ := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf HELLO; sleep 30"}))
@@ -329,7 +370,7 @@ func TestIntegration_CommandPaletteRenamesEphemeralSession(t *testing.T) {
329370 require.Equal(t, ports.MsgSessions, f.Type)
330371 sessions, err := ports.UnmarshalSessions(f.Payload)
331372 require.NoError(t, err)
332- require.Len(t, sessions.Sessions, 1)
373+ require.NotEmpty(t, sessions.Sessions)
333374 require.Equal(t, "work", sessions.Sessions[0].Name)
334375 require.False(t, sessions.Sessions[0].Ephemeral)
335376 }
A internal/usecase/daemon/attachment_connection.go +156 -0
@@ -0,0 +1,156 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/ports"
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 registered
15+// attachment connection incarnation. It is invalid as soon as any of the
16+// session, attachment membership, connection generation, transport link, or
17+// coordinator lease changes.
18+type attachmentConnectionToken struct {
19+ sess attachmentSession
20+ ac *attachedClient
21+ generation uint64
22+ transport transportSnapshot
23+ lease *attachmentLease
24+ effect *attachmentEffectTicket
25+ // rebase marks a cross-session move whose first paint starts a dependency-
26+ // free output chain after publication releases architecture locks.
27+ rebase bool
28+}
29+
30+func attachmentToken(entry attachmentSession, ac *attachedClient, tr ports.Transport) attachmentConnectionToken {
31+ if entry == nil || entry.core() == nil || ac == nil || tr == nil {
32+ return attachmentConnectionToken{}
33+ }
34+ // Capture one concrete link incarnation before taking the session lock. The
35+ // same snapshot is revalidated below; do not take a second snapshot and
36+ // accidentally bind a replacement link.
37+ transport := ac.transportSnapshot()
38+ if transport.transport != tr {
39+ return attachmentConnectionToken{}
40+ }
41+ if ac.beforeAttachmentTokenValidation != nil {
42+ ac.beforeAttachmentTokenValidation()
43+ }
44+ core := entry.core()
45+ core.mu.Lock()
46+ if !attachmentRegisteredLocked(entry, ac) || !ac.transportSnapshotCurrent(transport) {
47+ core.mu.Unlock()
48+ return attachmentConnectionToken{}
49+ }
50+ generation := ac.connectionGeneration.Load()
51+ token := attachmentConnectionToken{
52+ sess: entry,
53+ ac: ac,
54+ generation: generation,
55+ transport: transport,
56+ }
57+ if rc := core.coordinator.Load(); rc != nil {
58+ token.lease = rc.attachmentLease(ac)
59+ }
60+ core.mu.Unlock()
61+ if !token.current() {
62+ return attachmentConnectionToken{}
63+ }
64+ ac.bootstrapAttachmentCapability(token)
65+ return token
66+}
67+
68+func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentConnectionToken {
69+ return attachmentToken(s, ac, tr)
70+}
71+
72+// current validates every immutable identity captured by the token. Membership
73+// is the sole authority; there is no owner, replacement, or compatibility distinction.
74+func (t attachmentConnectionToken) current() bool {
75+ return t.sess != nil && t.ac != nil &&
76+ t.ac.connectionGeneration.Load() == t.generation &&
77+ t.ac.currentAttachmentSession() == t.sess &&
78+ t.ac.transportSnapshotCurrent(t.transport) &&
79+ attachmentRegistered(t.sess, t.ac)
80+}
81+
82+// attachmentCurrent additionally validates the optional coordinator lease used
83+// by render and resize effects. Attachments without that optional lease still
84+// own their independent connection lifecycle.
85+func (t attachmentConnectionToken) attachmentCurrent() bool {
86+ if !t.current() || t.ac.currentAttachmentSession() != t.sess {
87+ return false
88+ }
89+ if t.lease == nil {
90+ return true
91+ }
92+ rc := t.sess.core().coordinator.Load()
93+ return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
94+}
95+
96+func (t attachmentConnectionToken) attachmentEffectCurrent() bool {
97+ if t.effect != nil && !t.effect.ended.Load() {
98+ return true
99+ }
100+ return t.attachmentCurrent()
101+}
102+
103+func beginAttachmentLeaseEffect(sess attachmentSession, ac *attachedClient, lease *attachmentLease) (*attachmentEffectTicket, bool) {
104+ if sess == nil || ac == nil || lease == nil || lease.attachment != ac {
105+ return nil, false
106+ }
107+ token := attachmentToken(sess, ac, ac.transport())
108+ token.lease = lease
109+ return ac.beginAttachmentEffect(token)
110+}
111+
112+func (t *attachmentConnectionToken) endAttachmentEffect() {
113+ if t == nil || t.effect == nil {
114+ return
115+ }
116+ t.effect.End()
117+ t.effect = nil
118+}
119+
120+// attachmentEffectCurrentSessionLocked is the same check at a session-owned
121+// mutation boundary where sess.core().mu is already held.
122+func (t attachmentConnectionToken) attachmentEffectCurrentSessionLocked() bool {
123+ if t.effect != nil && !t.effect.ended.Load() {
124+ return true
125+ }
126+ if t.sess == nil || t.ac == nil ||
127+ t.ac.connectionGeneration.Load() != t.generation ||
128+ !t.ac.transportSnapshotCurrent(t.transport) ||
129+ t.ac.currentAttachmentSession() != t.sess ||
130+ !attachmentRegisteredLocked(t.sess, t.ac) {
131+ return false
132+ }
133+ if t.lease == nil {
134+ return true
135+ }
136+ rc := t.sess.core().coordinator.Load()
137+ return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
138+}
139+
140+func (t attachmentConnectionToken) sendControl(frame ports.Frame) error {
141+ if t.ac == nil || t.transport.transport == nil {
142+ return errAttachmentTransition
143+ }
144+ t.ac.sendMu.Lock()
145+ defer t.ac.sendMu.Unlock()
146+ if t.effect == nil || t.effect.ended.Load() || !t.ac.transportSnapshotCurrent(t.transport) ||
147+ !t.effect.beginTransportSend(t.transport) {
148+ return errAttachmentTransition
149+ }
150+ err := t.transport.transport.Send(frame)
151+ if err != nil {
152+ t.effect.reportTransportFailure(t.transport)
153+ }
154+ t.effect.endTransportSend()
155+ return err
156+}
R internal/usecase/daemon/role_effect_freeze.go → internal/usecase/daemon/attachment_effect_freeze.go +50 -64 renamed, 55% similar
@@ -8,7 +8,7 @@ import (
88 "github.com/bnema/vev/internal/ports"
99 )
1010
11-type roleEffectDrainDeadline struct {
11+type attachmentEffectDrainDeadline struct {
1212 clock ports.Clock
1313 done chan struct{}
1414 stopCh chan struct{}
@@ -18,13 +18,13 @@ type roleEffectDrainDeadline struct {
1818 started atomic.Bool
1919 }
2020
21-func newRoleEffectDrainDeadline(clock ports.Clock) *roleEffectDrainDeadline {
22- return &roleEffectDrainDeadline{
21+func newAttachmentEffectDrainDeadline(clock ports.Clock) *attachmentEffectDrainDeadline {
22+ return &attachmentEffectDrainDeadline{
2323 clock: clock, done: make(chan struct{}), stopCh: make(chan struct{}), finished: make(chan struct{}),
2424 }
2525 }
2626
27-func (d *roleEffectDrainDeadline) stop() {
27+func (d *attachmentEffectDrainDeadline) stop() {
2828 if d == nil {
2929 return
3030 }
@@ -40,7 +40,7 @@ func (d *roleEffectDrainDeadline) stop() {
4040 })
4141 }
4242
43-func (d *roleEffectDrainDeadline) Done() <-chan struct{} {
43+func (d *attachmentEffectDrainDeadline) Done() <-chan struct{} {
4444 if d == nil {
4545 return nil
4646 }
@@ -60,71 +60,57 @@ func (d *roleEffectDrainDeadline) Done() <-chan struct{} {
6060 return d.done
6161 }
6262
63-type frozenRoleEffectGates struct {
63+type frozenAttachmentEffectGates struct {
6464 clients []*attachedClient
6565 interruptions map[*attachedClient]transportSnapshot
6666 acquired bool
6767 drained bool
6868 }
6969
70-func (f frozenRoleEffectGates) interrupted(ac *attachedClient, expected transportSnapshot) bool {
71- interrupted, ok := f.interruptions[ac]
72- return ok && interrupted.transport == expected.transport && interrupted.incarnation == expected.incarnation
73-}
74-
75-func (f frozenRoleEffectGates) contains(target *attachedClient) bool {
76- for _, ac := range f.clients {
77- if ac == target {
78- return true
79- }
80- }
81- return false
82-}
83-
84-type roleTransportInterrupt struct {
70+type attachmentTransportInterrupt struct {
8571 ac *attachedClient
8672 transport transportSnapshot
8773 }
8874
89-// roleEffectFreezeOptions controls one role-gate freeze operation. A freeze
75+// attachmentEffectFreezeOptions controls one attachment-effect-gate freeze operation. A freeze
9076 // must be called without daemon, routing, session, coordinator, send, overlay,
9177 // router, or pane locks held.
92-type roleEffectFreezeOptions struct {
93- interrupts []roleTransportInterrupt
78+type attachmentEffectFreezeOptions struct {
79+ interrupts []attachmentTransportInterrupt
9480 done func() <-chan struct{}
9581 afterFrozen func(*attachedClient)
9682 nonblocking bool
9783 }
9884
99-// freezeRoleEffectGates establishes transition priority in immutable attachment
85+// freezeAttachmentEffectGates establishes transition priority in immutable attachment
10086 // order, then drains earlier admissions.
101-func freezeRoleEffectGates(clients ...*attachedClient) frozenRoleEffectGates {
102- return freezeRoleEffectGatesWith(roleEffectFreezeOptions{}, clients...)
87+func freezeAttachmentEffectGates(clients ...*attachedClient) frozenAttachmentEffectGates {
88+ return freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{}, clients...)
10389 }
10490
105-// freezeRoleEffectGatesWith uses done as one overall bound for ordered
91+// freezeAttachmentEffectGatesWith uses done as one overall bound for ordered
10692 // acquisition and drain. Acquisition is all-or-nothing: if the bound expires
10793 // behind another owner, only gates acquired by this call are rolled back, in
10894 // reverse order. nonblocking is used by move reservations to avoid inverting
10995 // the move/teardown protocols.
110-func freezeRoleEffectGatesWith(options roleEffectFreezeOptions, clients ...*attachedClient) frozenRoleEffectGates {
111- ordered := orderedRoleEffectClients(clients)
112- frozen, acquired := acquireRoleEffectGates(ordered, options.done, options.afterFrozen, options.nonblocking)
96+func freezeAttachmentEffectGatesWith(options attachmentEffectFreezeOptions, clients ...*attachedClient) frozenAttachmentEffectGates {
97+ ordered := orderedAttachmentEffectClients(clients)
98+ frozen, acquired := acquireAttachmentEffectGates(ordered, options.done, options.afterFrozen, options.nonblocking)
11399 if !acquired {
114- return frozenRoleEffectGates{}
100+ return frozenAttachmentEffectGates{}
115101 }
116102
117- interruptByClient := roleTransportInterruptsByClient(options.interrupts)
103+ interruptByClient := attachmentTransportInterruptsByClient(options.interrupts)
118104 frozen.interruptions = make(map[*attachedClient]transportSnapshot, len(interruptByClient))
119- interruptFrozenRoleTransports(ordered, interruptByClient)
105+ interruptFrozenAttachmentTransports(ordered, interruptByClient)
120106 frozen.acquired = true
121- frozen.drained = drainFrozenRoleEffects(ordered, interruptByClient, frozen.interruptions, options.done)
107+ frozen.drained = drainFrozenAttachmentEffects(ordered, interruptByClient, frozen.interruptions, options.done)
122108 return frozen
123109 }
124110
125-// orderedRoleEffectClients snapshots unique participants in immutable lock
111+// orderedAttachmentEffectClients snapshots unique participants in immutable lock
126112 // order. Assigning a previously unused order does not freeze or wait on a gate.
127-func orderedRoleEffectClients(clients []*attachedClient) []*attachedClient {
113+func orderedAttachmentEffectClients(clients []*attachedClient) []*attachedClient {
128114 unique := make(map[*attachedClient]struct{}, len(clients))
129115 ordered := make([]*attachedClient, 0, len(clients))
130116 for _, ac := range clients {
@@ -135,18 +121,18 @@ func orderedRoleEffectClients(clients []*attachedClient) []*attachedClient {
135121 continue
136122 }
137123 unique[ac] = struct{}{}
138- ac.roleEffects.immutableOrder()
124+ ac.attachmentEffects.immutableOrder()
139125 ordered = append(ordered, ac)
140126 }
141127 sort.Slice(ordered, func(i, j int) bool {
142- return ordered[i].roleEffects.order.Load() < ordered[j].roleEffects.order.Load()
128+ return ordered[i].attachmentEffects.order.Load() < ordered[j].attachmentEffects.order.Load()
143129 })
144130 return ordered
145131 }
146132
147-// waitForRoleEffectChangeLocked requires g.mu and returns with g.mu held. A
133+// waitForAttachmentEffectChangeLocked requires g.mu and returns with g.mu held. A
148134 // true result means the overall deadline won and the caller must stop waiting.
149-func waitForRoleEffectChangeLocked(g *roleEffectGate, done func() <-chan struct{}) bool {
135+func waitForAttachmentEffectChangeLocked(g *attachmentEffectGate, done func() <-chan struct{}) bool {
150136 if done == nil {
151137 g.cond.Wait()
152138 return false
@@ -167,28 +153,28 @@ func waitForRoleEffectChangeLocked(g *roleEffectGate, done func() <-chan struct{
167153 }
168154 }
169155
170-// acquireRoleEffectGates establishes exclusive transition ownership in order.
156+// acquireAttachmentEffectGates establishes exclusive transition ownership in order.
171157 // It must run without architecture locks. Deadline rollback releases only this
172158 // call's partial acquisition and does so in reverse order.
173-func acquireRoleEffectGates(ordered []*attachedClient, done func() <-chan struct{}, afterFrozen func(*attachedClient), nonblocking bool) (frozenRoleEffectGates, bool) {
174- frozen := frozenRoleEffectGates{clients: make([]*attachedClient, 0, len(ordered))}
159+func acquireAttachmentEffectGates(ordered []*attachedClient, done func() <-chan struct{}, afterFrozen func(*attachedClient), nonblocking bool) (frozenAttachmentEffectGates, bool) {
160+ frozen := frozenAttachmentEffectGates{clients: make([]*attachedClient, 0, len(ordered))}
175161 for _, ac := range ordered {
176- g := &ac.roleEffects
162+ g := &ac.attachmentEffects
177163 g.mu.Lock()
178164 g.initLocked()
179- for g.phase == roleEffectsFrozen {
165+ for g.phase == attachmentEffectsFrozen {
180166 if nonblocking {
181167 g.mu.Unlock()
182168 frozen.unfreeze()
183- return frozenRoleEffectGates{}, false
169+ return frozenAttachmentEffectGates{}, false
184170 }
185- if waitForRoleEffectChangeLocked(g, done) {
171+ if waitForAttachmentEffectChangeLocked(g, done) {
186172 g.mu.Unlock()
187173 frozen.unfreeze()
188- return frozenRoleEffectGates{}, false
174+ return frozenAttachmentEffectGates{}, false
189175 }
190176 }
191- g.phase = roleEffectsFrozen
177+ g.phase = attachmentEffectsFrozen
192178 g.mu.Unlock()
193179 frozen.clients = append(frozen.clients, ac)
194180 if afterFrozen != nil {
@@ -198,7 +184,7 @@ func acquireRoleEffectGates(ordered []*attachedClient, done func() <-chan struct
198184 return frozen, true
199185 }
200186
201-func roleTransportInterruptsByClient(interrupts []roleTransportInterrupt) map[*attachedClient]transportSnapshot {
187+func attachmentTransportInterruptsByClient(interrupts []attachmentTransportInterrupt) map[*attachedClient]transportSnapshot {
202188 byClient := make(map[*attachedClient]transportSnapshot, len(interrupts))
203189 for _, interrupt := range interrupts {
204190 if interrupt.ac != nil && interrupt.transport.transport != nil {
@@ -208,20 +194,20 @@ func roleTransportInterruptsByClient(interrupts []roleTransportInterrupt) map[*a
208194 return byClient
209195 }
210196
211-// interruptFrozenRoleTransports snapshots every exact in-flight link while all
197+// interruptFrozenAttachmentTransports snapshots every exact in-flight link while all
212198 // gates are frozen, then closes the complete set before any drain wait. One
213199 // uncooperative Send cannot prevent another participant from being interrupted.
214-func interruptFrozenRoleTransports(ordered []*attachedClient, interruptByClient map[*attachedClient]transportSnapshot) {
215- toClose := make([]roleTransportInterrupt, 0, len(interruptByClient))
200+func interruptFrozenAttachmentTransports(ordered []*attachedClient, interruptByClient map[*attachedClient]transportSnapshot) {
201+ toClose := make([]attachmentTransportInterrupt, 0, len(interruptByClient))
216202 for _, ac := range ordered {
217203 expected, mayInterrupt := interruptByClient[ac]
218204 if !mayInterrupt {
219205 continue
220206 }
221- g := &ac.roleEffects
207+ g := &ac.attachmentEffects
222208 g.mu.Lock()
223209 if gateHasTransportEffectLocked(g, expected) {
224- toClose = append(toClose, roleTransportInterrupt{ac: ac, transport: expected})
210+ toClose = append(toClose, attachmentTransportInterrupt{ac: ac, transport: expected})
225211 }
226212 g.mu.Unlock()
227213 }
@@ -230,15 +216,15 @@ func interruptFrozenRoleTransports(ordered []*attachedClient, interruptByClient
230216 }
231217 }
232218
233-// drainFrozenRoleEffects waits for admissions made before the freeze. A closed
219+// drainFrozenAttachmentEffects waits for admissions made before the freeze. A closed
234220 // deadline permits terminal teardown to continue with the capability frozen;
235221 // late tickets can then retire without pinning the owner indefinitely.
236-func drainFrozenRoleEffects(ordered []*attachedClient, interruptByClient, interruptions map[*attachedClient]transportSnapshot, done func() <-chan struct{}) bool {
222+func drainFrozenAttachmentEffects(ordered []*attachedClient, interruptByClient, interruptions map[*attachedClient]transportSnapshot, done func() <-chan struct{}) bool {
237223 for _, ac := range ordered {
238- g := &ac.roleEffects
224+ g := &ac.attachmentEffects
239225 g.mu.Lock()
240226 for g.inFlight != 0 {
241- if waitForRoleEffectChangeLocked(g, done) {
227+ if waitForAttachmentEffectChangeLocked(g, done) {
242228 g.mu.Unlock()
243229 return false
244230 }
@@ -252,7 +238,7 @@ func drainFrozenRoleEffects(ordered []*attachedClient, interruptByClient, interr
252238 return true
253239 }
254240
255-func gateHasTransportEffectLocked(g *roleEffectGate, expected transportSnapshot) bool {
241+func gateHasTransportEffectLocked(g *attachmentEffectGate, expected transportSnapshot) bool {
256242 for _, active := range g.transportEffects {
257243 if active.transport == expected.transport && active.incarnation == expected.incarnation {
258244 return true
@@ -261,12 +247,12 @@ func gateHasTransportEffectLocked(g *roleEffectGate, expected transportSnapshot)
261247 return false
262248 }
263249
264-func (f frozenRoleEffectGates) unfreeze() {
250+func (f frozenAttachmentEffectGates) unfreeze() {
265251 for i := len(f.clients) - 1; i >= 0; i-- {
266- g := &f.clients[i].roleEffects
252+ g := &f.clients[i].attachmentEffects
267253 g.mu.Lock()
268254 g.initLocked()
269- g.phase = roleEffectsStable
255+ g.phase = attachmentEffectsStable
270256 g.signalLocked()
271257 g.mu.Unlock()
272258 }
A internal/usecase/daemon/attachment_effect_gate.go +311 -0
@@ -0,0 +1,311 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "sync/atomic"
6+
7+ "github.com/bnema/vev/internal/ports"
8+)
9+
10+type attachmentEffectPhase uint8
11+
12+const (
13+ attachmentEffectsStable attachmentEffectPhase = iota
14+ attachmentEffectsFrozen
15+)
16+
17+// attachmentCapability is the single attachment-local publication consumed by
18+// effect admission. It deliberately contains no independently mutable state:
19+// a ticket is admitted only when every field matches the frame's token.
20+type attachmentCapability struct {
21+ sess attachmentSession
22+ generation uint64
23+ transport transportSnapshot
24+ lease *attachmentLease
25+}
26+
27+func capabilityFromToken(token attachmentConnectionToken) attachmentCapability {
28+ return attachmentCapability{
29+ sess: token.sess,
30+ generation: token.generation,
31+ transport: token.transport,
32+ lease: token.lease,
33+ }
34+}
35+
36+func (c attachmentCapability) matches(token attachmentConnectionToken) bool {
37+ return token.ac != nil && c.sess == token.sess &&
38+ c.generation == token.generation && c.transport.transport == token.transport.transport &&
39+ c.transport.incarnation == token.transport.incarnation && c.lease == token.lease
40+}
41+
42+type attachmentEffectGate struct {
43+ mu sync.Mutex
44+ cond *sync.Cond
45+ changed chan struct{}
46+ phase attachmentEffectPhase
47+ capability attachmentCapability
48+ inFlight uint64
49+ transportEffects map[*attachmentEffectTicket]transportSnapshot
50+ failedTransport transportSnapshot
51+ order atomic.Uint64
52+}
53+
54+var nextAttachmentEffectGateOrder atomic.Uint64
55+
56+func (g *attachmentEffectGate) initLocked() {
57+ if g.cond == nil {
58+ g.cond = sync.NewCond(&g.mu)
59+ }
60+ if g.changed == nil {
61+ g.changed = make(chan struct{})
62+ }
63+}
64+
65+func (g *attachmentEffectGate) signalLocked() {
66+ g.initLocked()
67+ close(g.changed)
68+ g.changed = make(chan struct{})
69+ g.cond.Broadcast()
70+}
71+
72+func (g *attachmentEffectGate) immutableOrder() uint64 {
73+ if order := g.order.Load(); order != 0 {
74+ return order
75+ }
76+ order := nextAttachmentEffectGateOrder.Add(1)
77+ if g.order.CompareAndSwap(0, order) {
78+ return order
79+ }
80+ return g.order.Load()
81+}
82+
83+func (g *attachmentEffectGate) inFlightCount() uint64 {
84+ if g == nil {
85+ return 0
86+ }
87+ g.mu.Lock()
88+ defer g.mu.Unlock()
89+ return g.inFlight
90+}
91+
92+// attachmentEffectTicket reserves the exact published capability until End. End is
93+// idempotent so error paths can safely defer it before doing any observable I/O.
94+type attachmentEffectTicket struct {
95+ gate *attachmentEffectGate
96+ token attachmentConnectionToken
97+ actionDaemon *Daemon
98+ action string
99+ ended atomic.Bool
100+}
101+
102+func (t *attachmentEffectTicket) bindActionEnd(d *Daemon, action string) {
103+ if t == nil || t.ended.Load() {
104+ return
105+ }
106+ t.actionDaemon = d
107+ t.action = action
108+}
109+
110+func (t *attachmentEffectTicket) connectionToken() attachmentConnectionToken {
111+ if t == nil {
112+ return attachmentConnectionToken{}
113+ }
114+ token := t.token
115+ token.effect = t
116+ return token
117+}
118+
119+func (t *attachmentEffectTicket) End() {
120+ if t == nil || t.gate == nil || !t.ended.CompareAndSwap(false, true) {
121+ return
122+ }
123+ g := t.gate
124+ g.mu.Lock()
125+ g.initLocked()
126+ if g.inFlight == 0 {
127+ g.mu.Unlock()
128+ panic("daemon: attachment effect ticket ended without admission")
129+ }
130+ delete(g.transportEffects, t)
131+ g.inFlight--
132+ g.signalLocked()
133+ g.mu.Unlock()
134+ if t.actionDaemon != nil && t.actionDaemon.afterActionAttachmentEffectEnded != nil {
135+ t.actionDaemon.afterActionAttachmentEffectEnded(t.action)
136+ }
137+}
138+
139+func (d *Daemon) endActionAttachmentEffect(effect *attachmentEffectTicket, _ string) {
140+ if effect != nil {
141+ effect.End()
142+ }
143+}
144+
145+// beginTransportSend marks only the interval spent in an external transport
146+// send as interruptible. A replacement may close this exact published link to
147+// release the send without retiring an idle healthy attachment.
148+func (t *attachmentEffectTicket) beginTransportSend(expected transportSnapshot) bool {
149+ if t == nil || t.gate == nil || t.ended.Load() || expected.transport == nil {
150+ return false
151+ }
152+ g := t.gate
153+ g.mu.Lock()
154+ g.initLocked()
155+ if t.ended.Load() || g.capability.transport.transport != expected.transport ||
156+ g.capability.transport.incarnation != expected.incarnation {
157+ g.mu.Unlock()
158+ return false
159+ }
160+ if g.transportEffects == nil {
161+ g.transportEffects = make(map[*attachmentEffectTicket]transportSnapshot)
162+ }
163+ g.transportEffects[t] = expected
164+ g.signalLocked()
165+ g.mu.Unlock()
166+ return true
167+}
168+
169+func (t *attachmentEffectTicket) reportTransportFailure(expected transportSnapshot) {
170+ if t == nil || t.gate == nil || expected.transport == nil {
171+ return
172+ }
173+ g := t.gate
174+ g.mu.Lock()
175+ if !t.ended.Load() && g.capability.transport.transport == expected.transport &&
176+ g.capability.transport.incarnation == expected.incarnation {
177+ g.failedTransport = expected
178+ g.signalLocked()
179+ }
180+ g.mu.Unlock()
181+}
182+
183+func (t *attachmentEffectTicket) endTransportSend() {
184+ if t == nil || t.gate == nil {
185+ return
186+ }
187+ g := t.gate
188+ g.mu.Lock()
189+ if _, exists := g.transportEffects[t]; exists {
190+ delete(g.transportEffects, t)
191+ g.signalLocked()
192+ }
193+ g.mu.Unlock()
194+}
195+
196+// beginAttachmentEffect is the sole attachment-bound effect admission point. The gate is
197+// held only long enough to reserve the capability; the effect itself must run
198+// without the gate mutex.
199+func (ac *attachedClient) beginAttachmentEffect(token attachmentConnectionToken) (*attachmentEffectTicket, bool) {
200+ if ac == nil || token.ac != ac {
201+ return nil, false
202+ }
203+ g := &ac.attachmentEffects
204+ g.mu.Lock()
205+ g.initLocked()
206+ if g.phase != attachmentEffectsStable || !g.capability.matches(token) {
207+ g.mu.Unlock()
208+ return nil, false
209+ }
210+ g.inFlight++
211+ g.mu.Unlock()
212+ captured := token
213+ captured.effect = nil
214+ return &attachmentEffectTicket{gate: g, token: captured}, true
215+}
216+
217+// beginCurrentAttachmentEffect waits out a transition that already froze ac, then
218+// admits the capability derived from the session registry. Handshakes use it
219+// after Welcome has completed so a replacement blocked behind that send can
220+// publish before readiness or first paint.
221+func (ac *attachedClient) beginCurrentAttachmentEffect(sess *session, tr ports.Transport) (attachmentConnectionToken, *attachmentEffectTicket, bool) {
222+ if ac == nil || sess == nil || tr == nil {
223+ return attachmentConnectionToken{}, nil, false
224+ }
225+ for {
226+ token := sess.attachmentToken(ac, tr)
227+ if token.ac == nil {
228+ return token, nil, false
229+ }
230+ if ticket, admitted := ac.beginAttachmentEffect(token); admitted {
231+ return token, ticket, true
232+ }
233+
234+ g := &ac.attachmentEffects
235+ g.mu.Lock()
236+ g.initLocked()
237+ if g.phase == attachmentEffectsFrozen {
238+ changed := g.changed
239+ g.mu.Unlock()
240+ <-changed
241+ continue
242+ }
243+ g.mu.Unlock()
244+ // A stable mismatch is only recoverable when a publication raced token
245+ // capture; an already-stale token proves that happened. A current token
246+ // with a stable mismatch violates the publication invariant, so fail
247+ // closed instead of spinning forever.
248+ if token.current() {
249+ return token, nil, false
250+ }
251+ // Publication may have completed between token capture and admission.
252+ // Re-read the registry and exact generation instead of using stale authority.
253+ }
254+}
255+
256+// publishAttachmentCapability is used by direct/headless setup and by transition
257+// publication while the gate is frozen. Production attachment changes publish only
258+// through publishFrozenAttachmentCapability.
259+func (ac *attachedClient) publishAttachmentCapability(token attachmentConnectionToken) {
260+ if ac == nil {
261+ return
262+ }
263+ g := &ac.attachmentEffects
264+ g.mu.Lock()
265+ g.initLocked()
266+ g.capability = capabilityFromToken(token)
267+ g.failedTransport = transportSnapshot{}
268+ g.mu.Unlock()
269+}
270+
271+// bootstrapAttachmentCapability supports direct/headless session construction. It
272+// never changes an existing or frozen production publication.
273+func (ac *attachedClient) bootstrapAttachmentCapability(token attachmentConnectionToken) {
274+ if ac == nil || token.sess == nil || token.ac != ac {
275+ return
276+ }
277+ g := &ac.attachmentEffects
278+ g.mu.Lock()
279+ g.initLocked()
280+ if g.phase == attachmentEffectsStable && g.capability.sess == nil {
281+ g.capability = capabilityFromToken(token)
282+ g.failedTransport = transportSnapshot{}
283+ }
284+ g.mu.Unlock()
285+}
286+
287+func (ac *attachedClient) publishFrozenAttachmentCapability(token attachmentConnectionToken) {
288+ g := &ac.attachmentEffects
289+ g.mu.Lock()
290+ g.initLocked()
291+ if g.phase != attachmentEffectsFrozen {
292+ g.mu.Unlock()
293+ panic("daemon: publishing attachment capability without transition freeze")
294+ }
295+ g.capability = capabilityFromToken(token)
296+ g.failedTransport = transportSnapshot{}
297+ g.mu.Unlock()
298+}
299+
300+func (ac *attachedClient) invalidateFrozenAttachmentCapability() {
301+ g := &ac.attachmentEffects
302+ g.mu.Lock()
303+ g.initLocked()
304+ if g.phase != attachmentEffectsFrozen {
305+ g.mu.Unlock()
306+ panic("daemon: invalidating attachment capability without transition freeze")
307+ }
308+ g.capability = attachmentCapability{}
309+ g.failedTransport = transportSnapshot{}
310+ g.mu.Unlock()
311+}
A internal/usecase/daemon/attachment_effect_gate_test.go +868 -0
@@ -0,0 +1,868 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "io"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/usecase/keys"
15+ "github.com/bnema/vev/pkg/renderer"
16+)
17+
18+func releaseTestGate(t *testing.T, ch chan struct{}) func() {
19+ t.Helper()
20+ var once sync.Once
21+ release := func() { once.Do(func() { close(ch) }) }
22+ t.Cleanup(release)
23+ return release
24+}
25+
26+type blockedRenderReplacementTransport struct {
27+ sendEntered chan struct{}
28+ closed chan struct{}
29+ sendOnce sync.Once
30+ closeOnce sync.Once
31+ locksFree chan bool
32+ d *Daemon
33+ sess *session
34+}
35+
36+func newBlockedRenderReplacementTransport(d *Daemon, sess *session) *blockedRenderReplacementTransport {
37+ return &blockedRenderReplacementTransport{
38+ sendEntered: make(chan struct{}),
39+ closed: make(chan struct{}),
40+ locksFree: make(chan bool, 1),
41+ d: d,
42+ sess: sess,
43+ }
44+}
45+
46+func (t *blockedRenderReplacementTransport) Send(ports.Frame) error {
47+ t.sendOnce.Do(func() { close(t.sendEntered) })
48+ <-t.closed
49+ return errors.New("transport closed")
50+}
51+
52+func (*blockedRenderReplacementTransport) Recv() (ports.Frame, error) {
53+ return ports.Frame{}, io.EOF
54+}
55+
56+func (t *blockedRenderReplacementTransport) Close() error {
57+ locksFree := t.d.mu.TryLock()
58+ if locksFree {
59+ t.d.mu.Unlock()
60+ }
61+ if locksFree {
62+ locksFree = t.d.notices.routingMu.TryLock()
63+ if locksFree {
64+ t.d.notices.routingMu.Unlock()
65+ }
66+ }
67+ if locksFree {
68+ locksFree = t.sess.mu.TryLock()
69+ if locksFree {
70+ t.sess.mu.Unlock()
71+ }
72+ }
73+ if locksFree {
74+ if rc := t.sess.renderCoordinator(); rc != nil {
75+ locksFree = rc.mu.TryLock()
76+ if locksFree {
77+ rc.mu.Unlock()
78+ }
79+ }
80+ }
81+ t.closeOnce.Do(func() {
82+ t.locksFree <- locksFree
83+ close(t.closed)
84+ })
85+ return nil
86+}
87+
88+type teardownBlockingTransport struct {
89+ sendEntered chan struct{}
90+ closed chan struct{}
91+ sendOnce sync.Once
92+ closeOnce sync.Once
93+}
94+
95+func newTeardownBlockingTransport() *teardownBlockingTransport {
96+ return &teardownBlockingTransport{sendEntered: make(chan struct{}), closed: make(chan struct{})}
97+}
98+
99+func (t *teardownBlockingTransport) Send(ports.Frame) error {
100+ t.sendOnce.Do(func() { close(t.sendEntered) })
101+ <-t.closed
102+ return errors.New("transport closed")
103+}
104+
105+func (*teardownBlockingTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
106+
107+func (t *teardownBlockingTransport) Close() error {
108+ t.closeOnce.Do(func() { close(t.closed) })
109+ return nil
110+}
111+
112+func (t *teardownBlockingTransport) Closed() bool {
113+ select {
114+ case <-t.closed:
115+ return true
116+ default:
117+ return false
118+ }
119+}
120+
121+func startBlockedAttachmentSend(t *testing.T, token attachmentConnectionToken) <-chan struct{} {
122+ t.Helper()
123+ ticket, admitted := token.ac.beginAttachmentEffect(token)
124+ require.True(t, admitted)
125+ done := make(chan struct{})
126+ go func() {
127+ defer close(done)
128+ defer ticket.End()
129+ _ = token.ac.sendExpectedTransportForAttachment(token.transport, framePong(), ticket)
130+ }()
131+ return done
132+}
133+
134+func requireAttachmentEffectGateRetired(t *testing.T, ac *attachedClient) {
135+ t.Helper()
136+ ac.attachmentEffects.mu.Lock()
137+ defer ac.attachmentEffects.mu.Unlock()
138+ require.Equal(t, attachmentEffectsStable, ac.attachmentEffects.phase)
139+ require.Zero(t, ac.attachmentEffects.inFlight)
140+ require.Empty(t, ac.attachmentEffects.transportEffects)
141+}
142+
143+func TestBeginCurrentAttachmentEffectFailsClosedOnStableCapabilityMismatch(t *testing.T) {
144+ _, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
145+ current := sess.attachmentToken(ac, ac.transport())
146+ stale := current
147+ stale.generation++
148+ ac.publishAttachmentCapability(stale)
149+
150+ result := make(chan bool, 1)
151+ go func() {
152+ _, ticket, admitted := ac.beginCurrentAttachmentEffect(sess, ac.transport())
153+ if ticket != nil {
154+ ticket.End()
155+ }
156+ result <- admitted
157+ }()
158+ select {
159+ case admitted := <-result:
160+ require.False(t, admitted)
161+ case <-time.After(time.Second):
162+ t.Fatal("stable capability mismatch spun instead of failing closed")
163+ }
164+}
165+
166+func TestBeginAttachmentLeaseEffectRejectsForeignLease(t *testing.T) {
167+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
168+ rc := d.attachCoordinator(sess, ac, true)
169+ foreign := &attachmentLease{attachment: &attachedClient{}}
170+ _, admitted := beginAttachmentLeaseEffect(sess, ac, foreign)
171+ require.False(t, admitted)
172+ require.NotNil(t, rc.attachmentLease(ac))
173+}
174+
175+func attachmentEffectGatePublicationSnapshot(ac *attachedClient) (attachmentEffectPhase, attachmentCapability) {
176+ ac.attachmentEffects.mu.Lock()
177+ defer ac.attachmentEffects.mu.Unlock()
178+ return ac.attachmentEffects.phase, ac.attachmentEffects.capability
179+}
180+
181+func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testing.T) {
182+ d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
183+ firstTransport := &closeTrackingTransport{}
184+ first.replaceTransport(firstTransport)
185+
186+ blockedTransport := &closeTrackingTransport{}
187+ blocked := &attachedClient{tr: blockedTransport, output: newOutputStateStream(), size: first.size}
188+ blocked.initOverlays()
189+ blocked.setSession(sess)
190+ laterTransport := &closeTrackingTransport{}
191+ later := &attachedClient{tr: laterTransport, output: newOutputStateStream(), size: first.size}
192+ later.initOverlays()
193+ later.setSession(sess)
194+ sess.mu.Lock()
195+ sess.registerAttachmentLocked(blocked)
196+ sess.registerAttachmentLocked(later)
197+ sess.mu.Unlock()
198+
199+ rc := d.attachCoordinator(sess, first, true)
200+ firstToken := sess.attachmentToken(first, firstTransport)
201+ firstToken.lease = rc.attachmentLease(first)
202+ first.publishAttachmentCapability(firstToken)
203+ blockedToken := sess.attachmentToken(blocked, blockedTransport)
204+ blocked.publishAttachmentCapability(blockedToken)
205+ laterToken := sess.attachmentToken(later, laterTransport)
206+ later.publishAttachmentCapability(laterToken)
207+
208+ // Fix the canonical order so teardown acquires first, waits behind the gate
209+ // owned by this test, and never reaches the still-stable later participant.
210+ first.attachmentEffects.immutableOrder()
211+ blocked.attachmentEffects.immutableOrder()
212+ later.attachmentEffects.immutableOrder()
213+ require.Less(t, first.attachmentEffects.order.Load(), blocked.attachmentEffects.order.Load())
214+ require.Less(t, blocked.attachmentEffects.order.Load(), later.attachmentEffects.order.Load())
215+
216+ blockedOwner := freezeAttachmentEffectGates(blocked)
217+ blockedOwnerHeld := true
218+ t.Cleanup(func() {
219+ if blockedOwnerHeld {
220+ blockedOwner.unfreeze()
221+ }
222+ })
223+
224+ firstPhase, firstCapability := attachmentEffectGatePublicationSnapshot(first)
225+ blockedPhase, blockedCapability := attachmentEffectGatePublicationSnapshot(blocked)
226+ laterPhase, laterCapability := attachmentEffectGatePublicationSnapshot(later)
227+ require.Equal(t, attachmentEffectsStable, firstPhase)
228+ require.Equal(t, attachmentEffectsFrozen, blockedPhase)
229+ require.Equal(t, attachmentEffectsStable, laterPhase)
230+ firstGeneration := first.connectionGeneration.Load()
231+ blockedGeneration := blocked.connectionGeneration.Load()
232+ laterGeneration := later.connectionGeneration.Load()
233+ firstSnapshot := first.transportSnapshot()
234+ blockedSnapshot := blocked.transportSnapshot()
235+ laterSnapshot := later.transportSnapshot()
236+
237+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
238+ d.clock = clock
239+ frozenByKill := make(chan *attachedClient, 3)
240+ d.afterAttachmentEffectGateFrozen = func(_ string, ac *attachedClient) { frozenByKill <- ac }
241+ killDone := make(chan error, 1)
242+ go func() { killDone <- d.killSession(sess, ports.ReasonSessionKilled, false) }()
243+
244+ require.Same(t, first, awaitTestValue(t, frozenByKill, "teardown did not acquire the first attachment effect gate"))
245+ deadline := awaitTestValue(t, clock.timers, "teardown did not arm the gate acquisition deadline")
246+ require.Equal(t, detachNotifyTimeout, deadline.duration)
247+ deadline.ch <- time.Now()
248+ select {
249+ case err := <-killDone:
250+ require.NoError(t, err)
251+ case <-time.After(time.Second):
252+ t.Fatal("teardown did not abort after partial gate acquisition timed out")
253+ }
254+ require.Empty(t, frozenByKill, "teardown acquired a participant after its deadline expired")
255+
256+ d.mu.Lock()
257+ require.Same(t, sess, d.sessions[sess.id], "partial gate acquisition removed the session registry owner")
258+ d.mu.Unlock()
259+ sess.mu.Lock()
260+ require.Contains(t, sess.snapshotAttachmentsLocked(), first)
261+ require.Contains(t, sess.snapshotAttachmentsLocked(), blocked)
262+ require.Contains(t, sess.snapshotAttachmentsLocked(), later)
263+ sess.mu.Unlock()
264+ for _, ac := range []*attachedClient{first, blocked, later} {
265+ require.Same(t, sess, ac.currentSession(), "partial gate acquisition cleared attachment ownership")
266+ }
267+ require.Equal(t, firstGeneration, first.connectionGeneration.Load())
268+ require.Equal(t, blockedGeneration, blocked.connectionGeneration.Load())
269+ require.Equal(t, laterGeneration, later.connectionGeneration.Load())
270+ require.True(t, first.transportSnapshotCurrent(firstSnapshot))
271+ require.True(t, blocked.transportSnapshotCurrent(blockedSnapshot))
272+ require.True(t, later.transportSnapshotCurrent(laterSnapshot))
273+ require.False(t, firstTransport.Closed())
274+ require.False(t, blockedTransport.Closed())
275+ require.False(t, laterTransport.Closed())
276+
277+ firstPhase, gotFirstCapability := attachmentEffectGatePublicationSnapshot(first)
278+ blockedPhase, gotBlockedCapability := attachmentEffectGatePublicationSnapshot(blocked)
279+ laterPhase, gotLaterCapability := attachmentEffectGatePublicationSnapshot(later)
280+ require.Equal(t, attachmentEffectsStable, firstPhase, "partially acquired gate was not rolled back")
281+ require.Equal(t, attachmentEffectsFrozen, blockedPhase, "teardown released a gate owned by another transition")
282+ require.Equal(t, attachmentEffectsStable, laterPhase, "unacquired later gate was frozen or invalidated")
283+ require.Equal(t, firstCapability, gotFirstCapability)
284+ require.Equal(t, blockedCapability, gotBlockedCapability)
285+ require.Equal(t, laterCapability, gotLaterCapability)
286+
287+ blockedOwner.unfreeze()
288+ blockedOwnerHeld = false
289+ complete := freezeAttachmentEffectGates(first, blocked, later)
290+ require.True(t, complete.acquired, "rolled-back gate set could not be acquired again")
291+ require.True(t, complete.drained)
292+ complete.unfreeze()
293+ for _, token := range []attachmentConnectionToken{firstToken, blockedToken, laterToken} {
294+ ticket, admitted := token.ac.beginAttachmentEffect(token)
295+ require.True(t, admitted, "pre-publication abort changed an attachment capability")
296+ ticket.End()
297+ requireAttachmentEffectGateRetired(t, token.ac)
298+ }
299+}
300+
301+func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
302+ d, sess, snatched, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
303+ snatchedTransport := newTeardownBlockingTransport()
304+ snatched.replaceTransport(snatchedTransport)
305+ d.attachCoordinator(sess, snatched, true)
306+ snatched.publishAttachmentCapability(sess.attachmentToken(snatched, snatchedTransport))
307+
308+ activeTransport := newTeardownBlockingTransport()
309+ active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: snatched.size}
310+ active.initOverlays()
311+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
312+ target: sess, next: active, expectedTransport: active.transportSnapshot(), ready: true,
313+ })
314+ require.NoError(t, err)
315+ defer d.deferAttachmentTransitionCleanups(transition)
316+
317+ unrelatedTransport := &closeTrackingTransport{}
318+ unrelatedClient := &attachedClient{tr: unrelatedTransport, output: newOutputStateStream(), size: active.size}
319+ unrelatedClient.initOverlays()
320+ unrelated := &session{sessionCore: sessionCore{id: "unrelated", name: "unrelated", attachments: map[*attachedClient]struct{}{unrelatedClient: {}}}, ctx: sess.ctx, cancel: func() {}}
321+ unrelatedClient.setSession(unrelated)
322+ d.mu.Lock()
323+ d.sessions[unrelated.id] = unrelated
324+ d.mu.Unlock()
325+ d.attachCoordinator(unrelated, unrelatedClient, true)
326+
327+ snatchedToken := sess.attachmentToken(snatched, snatchedTransport)
328+ activeToken := sess.attachmentToken(active, activeTransport)
329+ snatchedDone := startBlockedAttachmentSend(t, snatchedToken)
330+ activeDone := startBlockedAttachmentSend(t, activeToken)
331+ awaitTestCompletion(t, snatchedTransport.sendEntered, "snatched send did not block")
332+ awaitTestCompletion(t, activeTransport.sendEntered, "active send did not block")
333+
334+ killDone := make(chan error, 1)
335+ go func() { killDone <- d.killSession(sess, ports.ReasonSessionKilled, false) }()
336+ select {
337+ case err := <-killDone:
338+ require.NoError(t, err)
339+ case <-time.After(500 * time.Millisecond):
340+ _ = snatchedTransport.Close()
341+ _ = activeTransport.Close()
342+ t.Fatal("session kill remained pinned behind admitted participant sends")
343+ }
344+ awaitTestCompletion(t, snatchedDone, "snatched attachment effect ticket did not retire")
345+ awaitTestCompletion(t, activeDone, "active attachment effect ticket did not retire")
346+ d.waitNotifies()
347+
348+ require.True(t, snatchedTransport.Closed())
349+ require.True(t, activeTransport.Closed())
350+ require.False(t, unrelatedTransport.Closed(), "unrelated session transport was interrupted")
351+ require.Contains(t, unrelated.snapshotAttachments(), unrelatedClient)
352+ require.Same(t, unrelated, unrelatedClient.currentSession())
353+ requireAttachmentEffectGateRetired(t, snatched)
354+ requireAttachmentEffectGateRetired(t, active)
355+}
356+
357+func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *testing.T) {
358+ d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
359+ stale := newTeardownBlockingTransport()
360+ ac.replaceTransport(stale)
361+ d.attachCoordinator(sess, ac, true)
362+ ac.publishAttachmentCapability(sess.attachmentToken(ac, stale))
363+
364+ token := sess.attachmentToken(ac, stale)
365+ sendDone := startBlockedAttachmentSend(t, token)
366+ awaitTestCompletion(t, stale.sendEntered, "captured incarnation send did not block")
367+ fresh := &closeTrackingTransport{}
368+ d.afterAttachmentEffectParticipantsSnapshotted = func(string, []*attachedClient) {
369+ ac.replaceTransport(fresh)
370+ }
371+
372+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
373+ awaitTestCompletion(t, sendDone, "captured incarnation attachment effect ticket did not retire")
374+ require.True(t, stale.Closed(), "exact captured in-flight transport was not interrupted")
375+ require.False(t, fresh.Closed(), "newer transport incarnation was interrupted")
376+ require.Same(t, fresh, ac.transport())
377+ d.mu.Lock()
378+ require.Same(t, sess, d.sessions[sess.id], "transport incarnation change should abort stale teardown publication")
379+ d.mu.Unlock()
380+ sess.mu.Lock()
381+ require.Contains(t, sess.snapshotAttachmentsLocked(), ac)
382+ sess.mu.Unlock()
383+ requireAttachmentEffectGateRetired(t, ac)
384+}
385+
386+func TestShutdownSignalsServeWhenParticipantGateAcquisitionTimesOut(t *testing.T) {
387+ d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
388+ transport := &closeTrackingTransport{}
389+ active.replaceTransport(transport)
390+ d.attachCoordinator(sess, active, true)
391+ active.publishAttachmentCapability(sess.attachmentToken(active, transport))
392+
393+ owner := freezeAttachmentEffectGates(active)
394+ require.True(t, owner.acquired)
395+ defer owner.unfreeze()
396+
397+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
398+ d.clock = clock
399+ shutdownDone := make(chan bool, 1)
400+ go func() { shutdownDone <- d.shutdownAll(ports.ReasonServerShutdown) }()
401+
402+ deadline := awaitTestValue(t, clock.timers, "shutdown did not arm the gate acquisition deadline")
403+ deadline.ch <- clock.Now()
404+ require.False(t, awaitTestValue(t, shutdownDone, "shutdown did not return after its gate acquisition deadline"))
405+
406+ d.mu.Lock()
407+ registered := d.sessions[sess.id]
408+ d.mu.Unlock()
409+ require.Same(t, sess, registered, "timed-out initial teardown should leave the session for Serve's shutdown pass")
410+ select {
411+ case <-d.done:
412+ case <-time.After(time.Second):
413+ t.Fatal("shutdown request did not signal Serve after the initial teardown pass timed out")
414+ }
415+}
416+
417+func TestShutdownInterruptsBlockedParticipantSend(t *testing.T) {
418+ d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
419+ blocked := newTeardownBlockingTransport()
420+ active.replaceTransport(blocked)
421+ d.attachCoordinator(sess, active, true)
422+ active.publishAttachmentCapability(sess.attachmentToken(active, blocked))
423+
424+ token := sess.attachmentToken(active, blocked)
425+ sendDone := startBlockedAttachmentSend(t, token)
426+ awaitTestCompletion(t, blocked.sendEntered, "active send did not block")
427+
428+ shutdownDone := make(chan bool, 1)
429+ go func() { shutdownDone <- d.shutdownAll(ports.ReasonServerShutdown) }()
430+ select {
431+ case incomplete := <-shutdownDone:
432+ require.False(t, incomplete)
433+ case <-time.After(500 * time.Millisecond):
434+ _ = blocked.Close()
435+ t.Fatal("shutdown remained pinned behind admitted participant send")
436+ }
437+ awaitTestCompletion(t, sendDone, "shutdown-interrupted attachment effect ticket did not retire")
438+ d.waitNotifies()
439+ require.True(t, blocked.Closed())
440+ requireAttachmentEffectGateRetired(t, active)
441+}
442+
443+func TestRenderSendFailureCleanupIsDeadlineBoundedWhileAttachmentEffectGateIsBusy(t *testing.T) {
444+ d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
445+ transport := &closeTrackingTransport{}
446+ ac.replaceTransport(transport)
447+ rc := d.attachCoordinator(sess, ac, true)
448+ token := sess.attachmentToken(ac, transport)
449+ token.lease = rc.attachmentLease(ac)
450+ ac.publishAttachmentCapability(token)
451+
452+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
453+ d.clock = clock
454+ frozen := freezeAttachmentEffectGates(ac)
455+ defer frozen.unfreeze()
456+ cleanupDone := make(chan struct{})
457+ d.afterAttachmentSendErrorCleanup = func() { close(cleanupDone) }
458+
459+ launchCleanup := d.reserveAttachmentSendErrorCleanup(token, transport)
460+ launchCleanup()
461+ deadline := awaitTestValue(t, clock.timers, "render cleanup did not arm its deadline")
462+ require.Equal(t, detachNotifyTimeout, deadline.duration)
463+ deadline.ch <- time.Now()
464+ awaitTestCompletion(t, cleanupDone, "deadline did not release blocked render cleanup")
465+ awaitTestCompletion(t, waitGroupDone(&d.attachmentCleanupWg), "bounded render cleanup leaked its tracked goroutine")
466+
467+ sess.mu.Lock()
468+ require.Contains(t, sess.snapshotAttachmentsLocked(), ac)
469+ sess.mu.Unlock()
470+ require.False(t, transport.Closed())
471+}
472+
473+func TestAttachmentEffectGateReplacementInterruptsBlockedOldRenderBeforePublication(t *testing.T) {
474+ d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
475+ oldTransport := newBlockedRenderReplacementTransport(d, sess)
476+ old.replaceTransport(oldTransport)
477+ rc := d.attachCoordinator(sess, old, true)
478+ lease := rc.attachmentLease(old)
479+ token := sess.attachmentToken(old, oldTransport)
480+ token.lease = lease
481+ old.publishAttachmentCapability(token)
482+
483+ paintDone := make(chan struct{})
484+ go func() {
485+ d.paint(sess, old, true, lease)
486+ close(paintDone)
487+ }()
488+ <-oldTransport.sendEntered
489+
490+ newTransport := &closeTrackingTransport{}
491+ next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size}
492+ next.initOverlays()
493+ transitionDone := make(chan attachmentTransitionResult, 1)
494+ transitionErr := make(chan error, 1)
495+ go func() {
496+ result, err := d.transitionAttachment(attachmentTransitionRequest{
497+ target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true,
498+ })
499+ transitionDone <- result
500+ transitionErr <- err
501+ }()
502+
503+ var result attachmentTransitionResult
504+ select {
505+ case result = <-transitionDone:
506+ case <-time.After(time.Second):
507+ t.Fatal("replacement publication remained blocked behind the old render send")
508+ }
509+ require.NoError(t, <-transitionErr)
510+ require.Contains(t, sess.snapshotAttachments(), next)
511+ require.Contains(t, sess.snapshotAttachments(), old)
512+ require.False(t, newTransport.Closed(), "new attachment transport was affected by publication")
513+
514+ // Generic attachment publication does not interrupt or retire another
515+ // registered connection. Release the test transport explicitly.
516+ require.NoError(t, oldTransport.Close())
517+ select {
518+ case <-paintDone:
519+ case <-time.After(time.Second):
520+ t.Fatal("closing the test transport did not unblock its render")
521+ }
522+ d.deferAttachmentTransitionCleanups(result)
523+ d.attachmentCleanupWg.Wait()
524+ require.Same(t, oldTransport, old.transport(), "unrelated attachment link was retired")
525+ require.False(t, newTransport.Closed())
526+}
527+
528+func TestAttachmentEffectGateAdmittedEffectCompletesBeforeConflictingTransition(t *testing.T) {
529+ d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
530+ oldTransport := newDatagramTestTransport()
531+ old.replaceTransport(oldTransport)
532+ rc := d.attachCoordinator(sess, old, true)
533+ token := sess.attachmentToken(old, oldTransport)
534+ token.lease = rc.attachmentLease(old)
535+ old.publishAttachmentCapability(token)
536+
537+ ticket, ok := old.beginAttachmentEffect(token)
538+ require.True(t, ok)
539+
540+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
541+ next.initOverlays()
542+ done := make(chan error, 1)
543+ go func() {
544+ _, err := d.transitionAttachment(attachmentTransitionRequest{
545+ target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true,
546+ })
547+ done <- err
548+ }()
549+
550+ // Adding an independent attachment does not wait on another connection's
551+ // effect gate.
552+ require.NoError(t, <-done)
553+ ticket.End()
554+}
555+
556+func TestAttachmentEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *testing.T) {
557+ tests := []struct {
558+ name string
559+ setup func(*Daemon, *session, *attachedClient, *transactionalResizePTY)
560+ frame ports.Frame
561+ assert func(*testing.T, *attachedClient, *transactionalResizePTY)
562+ }{
563+ {
564+ name: "PTY input",
565+ frame: frameInput([]byte("reserved")),
566+ assert: func(t *testing.T, _ *attachedClient, pty *transactionalResizePTY) {
567+ require.Equal(t, [][]byte{[]byte("reserved")}, pty.writes())
568+ },
569+ },
570+ {
571+ name: "mouse input",
572+ setup: func(_ *Daemon, sess *session, _ *attachedClient, _ *transactionalResizePTY) {
573+ p := testAttachmentTab(sess).focusedPane()
574+ p.mu.Lock()
575+ p.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
576+ p.mu.Unlock()
577+ },
578+ frame: frameInput([]byte("\x1b[<0;1;2M")),
579+ assert: func(t *testing.T, _ *attachedClient, pty *transactionalResizePTY) {
580+ require.NotEmpty(t, pty.writes())
581+ },
582+ },
583+ {
584+ name: "key action",
585+ frame: frameInput([]byte("\x1b ")),
586+ assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) {
587+ require.True(t, ac.overlays.paletteActive())
588+ },
589+ },
590+ {
591+ name: "overlay mutation",
592+ setup: func(d *Daemon, sess *session, ac *attachedClient, _ *transactionalResizePTY) {
593+ d.enterPrompt(sess, ac, "Rename", "", func(string) error { return nil })
594+ },
595+ frame: frameInput([]byte("x")),
596+ assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) {
597+ ac.overlays.promptMu.Lock()
598+ defer ac.overlays.promptMu.Unlock()
599+ require.Equal(t, "x", ac.overlays.prompt.Value())
600+ },
601+ },
602+ {
603+ name: "theme",
604+ frame: ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(ports.Theme{
605+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
606+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
607+ })},
608+ assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) {
609+ require.True(t, ac.getClientTheme().HasFG)
610+ },
611+ },
612+ {
613+ name: "ACK",
614+ setup: func(_ *Daemon, _ *session, ac *attachedClient, _ *transactionalResizePTY) {
615+ ac.output.next = 3
616+ },
617+ frame: ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: 1})},
618+ assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) {
619+ ac.sendMu.Lock()
620+ defer ac.sendMu.Unlock()
621+ require.Equal(t, uint64(1), ac.output.acked)
622+ },
623+ },
624+ }
625+
626+ for _, tt := range tests {
627+ t.Run(tt.name, func(t *testing.T) {
628+ pty := &transactionalResizePTY{}
629+ d, sess, old, _ := newManualSessionWithPTYs(t, pty)
630+ oldTransport := newDatagramTestTransport()
631+ old.replaceTransport(oldTransport)
632+ old.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: old}, &d.bindings)
633+ rc := d.attachCoordinator(sess, old, true)
634+ token := sess.attachmentToken(old, oldTransport)
635+ token.lease = rc.attachmentLease(old)
636+ if tt.setup != nil {
637+ tt.setup(d, sess, old, pty)
638+ }
639+
640+ admitted := make(chan struct{})
641+ release := make(chan struct{})
642+ d.afterAttachmentEffectAdmitted = func(attachmentConnectionToken) {
643+ close(admitted)
644+ <-release
645+ }
646+ effectDone := make(chan struct{})
647+ go func() {
648+ d.handleAttachmentClientFrame(token, tt.frame)
649+ close(effectDone)
650+ }()
651+ <-admitted
652+
653+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
654+ next.initOverlays()
655+ transitionDone := make(chan error, 1)
656+ go func() {
657+ _, err := d.transitionAttachment(attachmentTransitionRequest{
658+ target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true,
659+ })
660+ transitionDone <- err
661+ }()
662+ require.NoError(t, <-transitionDone)
663+ close(release)
664+ <-effectDone
665+ tt.assert(t, old, pty)
666+ })
667+ }
668+}
669+
670+func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {
671+ p1, release1 := newBlockingPTY(t)
672+ p2, release2 := newBlockingPTY(t)
673+ p3, release3 := newBlockingPTY(t)
674+ defer release1()
675+ defer release2()
676+ defer release3()
677+ d, source, ac, _ := newManualSessionWithPTYs(t, p1)
678+ target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{
679+ newTab(p2, domain.Size{Cols: 80, Rows: 23}),
680+ newTab(p3, domain.Size{Cols: 80, Rows: 23}),
681+ }}
682+ target.tabs[1].attention = true
683+ target.tabs[1].attentionAt = time.Unix(1, 0)
684+ d.sessions[target.id] = target
685+
686+ rc := d.attachCoordinator(source, ac, true)
687+ token := source.attachmentToken(ac, ac.transport())
688+ token.lease = rc.attachmentLease(ac)
689+ ac.publishAttachmentCapability(token)
690+
691+ d.handleAttachmentClientFrame(token, frameInput([]byte("\x1ba")))
692+
693+ require.Same(t, target, ac.currentSession())
694+ require.Equal(t, 1, testAttachmentTabIndex(target))
695+}
696+
697+func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T) {
698+ p, releasePTY := newBlockingPTY(t)
699+ defer releasePTY()
700+ d, sess, old, _ := newManualSessionWithPTYs(t, p)
701+ d.enterPicker(sess, old)
702+
703+ oldTransport := old.transport()
704+ rc := d.attachCoordinator(sess, old, true)
705+ token := sess.attachmentToken(old, oldTransport)
706+ token.lease = rc.attachmentLease(old)
707+ old.publishAttachmentCapability(token)
708+
709+ admissionEnded := make(chan struct{})
710+ releaseAction := make(chan struct{})
711+ release := releaseTestGate(t, releaseAction)
712+ var admissionEndedOnce sync.Once
713+ d.afterActionAttachmentEffectEnded = func(action string) {
714+ if action != "picker-delete" {
715+ return
716+ }
717+ admissionEndedOnce.Do(func() { close(admissionEnded) })
718+ <-releaseAction
719+ }
720+ actionDone := make(chan struct{})
721+ go func() {
722+ d.handleAttachmentClientFrame(token, frameInput([]byte("x")))
723+ close(actionDone)
724+ }()
725+ <-admissionEnded
726+
727+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
728+ next.initOverlays()
729+ result, err := d.transitionAttachment(attachmentTransitionRequest{
730+ target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true,
731+ })
732+ require.NoError(t, err)
733+ release()
734+ <-actionDone
735+
736+ d.mu.Lock()
737+ registered := d.sessions[sess.id]
738+ d.mu.Unlock()
739+ require.Same(t, sess, registered, "the replaced initiator deleted its former source session")
740+ d.deferAttachmentTransitionCleanups(result)
741+}
742+
743+func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) {
744+ p, release := newBlockingPTY(t)
745+ defer release()
746+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
747+ d.enterPicker(sess, ac)
748+ rc := d.attachCoordinator(sess, ac, true)
749+ token := sess.attachmentToken(ac, ac.transport())
750+ token.lease = rc.attachmentLease(ac)
751+ ac.publishAttachmentCapability(token)
752+
753+ d.handleAttachmentClientFrame(token, frameInput([]byte("x")))
754+
755+ d.mu.Lock()
756+ _, registered := d.sessions[sess.id]
757+ d.mu.Unlock()
758+ require.False(t, registered)
759+}
760+
761+func TestAttachmentEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *testing.T) {
762+ d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
763+ oldTransport := newDatagramTestTransport()
764+ old.replaceTransport(oldTransport)
765+ rc := d.attachCoordinator(sess, old, true)
766+ token := sess.attachmentToken(old, oldTransport)
767+ token.lease = rc.attachmentLease(old)
768+
769+ admitted := make(chan struct{})
770+ release := make(chan struct{})
771+ d.afterAttachmentEffectAdmitted = func(attachmentConnectionToken) {
772+ close(admitted)
773+ <-release
774+ }
775+ paintDone := make(chan bool, 1)
776+ go func() { paintDone <- d.firstPaintForTransition(token) }()
777+ <-admitted
778+
779+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
780+ next.initOverlays()
781+ transitionDone := make(chan error, 1)
782+ go func() {
783+ _, err := d.transitionAttachment(attachmentTransitionRequest{
784+ target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true,
785+ })
786+ transitionDone <- err
787+ }()
788+ // An independent attachment publication does not wait on this connection's
789+ // admitted first paint.
790+ require.NoError(t, <-transitionDone)
791+ close(release)
792+ require.True(t, <-paintDone)
793+}
794+
795+func TestAttachmentEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testing.T) {
796+ d, first, a, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
797+ aTransport := newDatagramTestTransport()
798+ a.replaceTransport(aTransport)
799+ d.attachCoordinator(first, a, true)
800+ first.attachmentToken(a, aTransport)
801+
802+ bTransport := newDatagramTestTransport()
803+ b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size}
804+ b.initOverlays()
805+ second := &session{sessionCore: sessionCore{id: "second", name: "second", attachments: map[*attachedClient]struct{}{b: {}}}}
806+ b.setSession(second)
807+ d.mu.Lock()
808+ d.sessions[second.id] = second
809+ d.mu.Unlock()
810+ d.attachCoordinator(second, b, true)
811+ second.attachmentToken(b, bTransport)
812+
813+ start := make(chan struct{})
814+ results := make(chan error, 2)
815+ go func() {
816+ <-start
817+ _, err := d.transitionAttachment(attachmentTransitionRequest{
818+ source: first, target: second, next: a,
819+
820+ expectedTransport: a.transportSnapshot(), ready: true,
821+ })
822+ results <- err
823+ }()
824+ go func() {
825+ <-start
826+ _, err := d.transitionAttachment(attachmentTransitionRequest{
827+ source: second, target: first, next: b,
828+
829+ expectedTransport: b.transportSnapshot(), ready: true,
830+ })
831+ results <- err
832+ }()
833+ close(start)
834+
835+ errs := make([]error, 0, 2)
836+ for range 2 {
837+ select {
838+ case err := <-results:
839+ errs = append(errs, err)
840+ case <-time.After(time.Second):
841+ t.Fatal("reversed transitions deadlocked")
842+ }
843+ }
844+ successes := 0
845+ for _, err := range errs {
846+ if err == nil {
847+ successes++
848+ }
849+ }
850+ require.Equal(t, 2, successes)
851+}
852+
853+func TestAttachmentEffectGateFrozenConnectionRejectsLateEffect(t *testing.T) {
854+ d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
855+ transport := newDatagramTestTransport()
856+ ac.replaceTransport(transport)
857+ rc := d.attachCoordinator(sess, ac, true)
858+ token := sess.attachmentToken(ac, transport)
859+ token.lease = rc.attachmentLease(ac)
860+ ac.publishAttachmentCapability(token)
861+
862+ frozen := freezeAttachmentEffectGates(ac)
863+ require.True(t, frozen.acquired)
864+ require.True(t, frozen.drained)
865+ _, ok := ac.beginAttachmentEffect(token)
866+ require.False(t, ok, "a frozen capability must admit no new effect")
867+ frozen.unfreeze()
868+}
A internal/usecase/daemon/attachment_parking_test.go +268 -0
@@ -0,0 +1,268 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+type resumeWelcomeFailureTransport struct {
13+ closeTrackingTransport
14+}
15+
16+func (*resumeWelcomeFailureTransport) Send(ports.Frame) error { return errWelcomeSendFailed }
17+
18+var errWelcomeSendFailed = errors.New("welcome send failed")
19+
20+func TestAttachmentLossParksOnlyThatAttachment(t *testing.T) {
21+ pty, release := newBlockingPTY(t)
22+ defer release()
23+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
24+
25+ firstTransport := &closeTrackingTransport{}
26+ sess, first, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), firstTransport)
27+ require.NoError(t, err)
28+ secondTransport := &closeTrackingTransport{}
29+ _, second, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), secondTransport)
30+ require.NoError(t, err)
31+
32+ d.clientGone(sess, first, firstTransport, false)
33+
34+ d.mu.Lock()
35+ parked := d.parked[first.resumeToken]
36+ d.mu.Unlock()
37+ require.NotNil(t, parked)
38+ sess.mu.Lock()
39+ _, firstAttached := sess.attachments[first]
40+ _, secondAttached := sess.attachments[second]
41+ sess.mu.Unlock()
42+ require.False(t, firstAttached)
43+ require.True(t, secondAttached)
44+ require.Contains(t, sess.snapshotAttachments(), second)
45+ require.False(t, secondTransport.Closed())
46+}
47+
48+func TestTwoAttachmentsParkIndependently(t *testing.T) {
49+ pty, release := newBlockingPTY(t)
50+ defer release()
51+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
52+ firstTransport := &closeTrackingTransport{}
53+ sess, first, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), firstTransport)
54+ require.NoError(t, err)
55+ secondTransport := &closeTrackingTransport{}
56+ _, second, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), secondTransport)
57+ require.NoError(t, err)
58+ firstToken, secondToken := first.resumeToken, second.resumeToken
59+
60+ d.clientGone(sess, first, firstTransport, false)
61+ d.clientGone(sess, second, secondTransport, false)
62+ d.mu.Lock()
63+ firstParked, secondParked := d.parked[firstToken], d.parked[secondToken]
64+ d.mu.Unlock()
65+ require.NotNil(t, firstParked)
66+ require.NotNil(t, secondParked)
67+ require.NotSame(t, firstParked, secondParked)
68+ sess.mu.Lock()
69+ require.Empty(t, sess.attachments)
70+ sess.mu.Unlock()
71+}
72+
73+func TestParkExpiryRemovesOnlyOneAttachment(t *testing.T) {
74+ pty, release := newBlockingPTY(t)
75+ defer release()
76+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
77+ firstTransport := &closeTrackingTransport{}
78+ sess, first, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), firstTransport)
79+ require.NoError(t, err)
80+ secondTransport := &closeTrackingTransport{}
81+ _, second, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), secondTransport)
82+ require.NoError(t, err)
83+ d.clientGone(sess, first, firstTransport, false)
84+ d.clientGone(sess, second, secondTransport, false)
85+ firstToken, secondToken := first.resumeToken, second.resumeToken
86+ d.mu.Lock()
87+ firstParked := d.parked[firstToken]
88+ d.mu.Unlock()
89+ d.expireParked(firstToken, firstParked)
90+ d.mu.Lock()
91+ _, firstRetained := d.parked[firstToken]
92+ _, secondRetained := d.parked[secondToken]
93+ d.mu.Unlock()
94+ require.False(t, firstRetained)
95+ require.True(t, secondRetained)
96+}
97+
98+func TestResumeRotatesCredentialAndRejectsStaleTransport(t *testing.T) {
99+ pty, release := newBlockingPTY(t)
100+ defer release()
101+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
102+ oldTransport := &closeTrackingTransport{}
103+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
104+ require.NoError(t, err)
105+ oldToken := ac.resumeToken
106+ oldGeneration := ac.connectionGeneration.Load()
107+ d.clientGone(sess, ac, oldTransport, false)
108+
109+ newTransport := &closeTrackingTransport{}
110+ resumed, same, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", oldToken), newTransport, defaultSize)
111+ require.NoError(t, err)
112+ require.True(t, ok)
113+ require.Same(t, sess, resumed)
114+ require.Same(t, ac, same)
115+ require.NotEqual(t, oldToken, ac.resumeToken)
116+ require.Greater(t, ac.connectionGeneration.Load(), oldGeneration)
117+ require.True(t, d.commitResumeClaim(ac))
118+ d.mu.Lock()
119+ _, oldRetained := d.parked[oldToken]
120+ d.mu.Unlock()
121+ require.False(t, oldRetained)
122+
123+ before := ac.resumeToken
124+ d.clientGone(sess, ac, oldTransport, false)
125+ require.Equal(t, before, ac.resumeToken)
126+ d.mu.Lock()
127+ _, staleParked := d.parked[oldToken]
128+ d.mu.Unlock()
129+ require.False(t, staleParked)
130+}
131+
132+func TestSuccessfulResumeRejectsOldCredentialAfterWelcome(t *testing.T) {
133+ pty, release := newBlockingPTY(t)
134+ defer release()
135+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
136+ oldTransport := &closeTrackingTransport{}
137+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
138+ require.NoError(t, err)
139+ oldToken := ac.resumeToken
140+ d.clientGone(sess, ac, oldTransport, false)
141+
142+ resumedTransport := &closeTrackingTransport{}
143+ d.handleHello(resumedTransport, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(helloResumeCapable(ports.IntentResume, "work", oldToken))})
144+ newToken := ac.resumeToken
145+ require.NotEqual(t, oldToken, newToken)
146+ d.mu.Lock()
147+ _, oldRetained := d.parked[oldToken]
148+ newParked := d.parked[newToken]
149+ d.mu.Unlock()
150+ require.False(t, oldRetained)
151+ require.NotNil(t, newParked)
152+ require.NotEmpty(t, resumedTransport.Sends())
153+}
154+
155+func TestFailedResumeHandshakeKeepsParkedCredential(t *testing.T) {
156+ pty, release := newBlockingPTY(t)
157+ defer release()
158+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
159+ oldTransport := &closeTrackingTransport{}
160+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
161+ require.NoError(t, err)
162+ token := ac.resumeToken
163+ d.clientGone(sess, ac, oldTransport, false)
164+
165+ failed := &resumeWelcomeFailureTransport{}
166+ d.handleHello(failed, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(helloResumeCapable(ports.IntentResume, "work", token))})
167+ require.Nil(t, ac.transport())
168+ d.mu.Lock()
169+ parked := d.parked[token]
170+ d.mu.Unlock()
171+ require.NotNil(t, parked)
172+ require.False(t, parked.claimed)
173+ require.Equal(t, token, ac.resumeToken)
174+
175+ _, resumed, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, defaultSize)
176+ require.NoError(t, err)
177+ require.True(t, ok)
178+ require.Same(t, ac, resumed)
179+}
180+
181+func TestConcurrentResumeHasExactlyOneWinner(t *testing.T) {
182+ pty, release := newBlockingPTY(t)
183+ defer release()
184+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
185+ oldTransport := &closeTrackingTransport{}
186+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
187+ require.NoError(t, err)
188+ token := ac.resumeToken
189+ d.clientGone(sess, ac, oldTransport, false)
190+
191+ type result struct {
192+ ok bool
193+ err error
194+ }
195+ results := make(chan result, 2)
196+ for range 2 {
197+ go func() {
198+ _, _, ok, resumeErr := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, defaultSize)
199+ results <- result{ok: ok, err: resumeErr}
200+ }()
201+ }
202+ var winners int
203+ for range 2 {
204+ got := <-results
205+ if got.ok {
206+ winners++
207+ }
208+ }
209+ require.Equal(t, 1, winners)
210+ require.NotEqual(t, token, ac.resumeToken)
211+}
212+
213+func TestKillSessionClosesEveryAttachmentAndParkedCredential(t *testing.T) {
214+ pty, release := newBlockingPTY(t)
215+ defer release()
216+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
217+ firstTransport := &closeTrackingTransport{}
218+ sess, _, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), firstTransport)
219+ require.NoError(t, err)
220+ secondTransport := &closeTrackingTransport{}
221+ _, _, err = d.route(helloResumeCapable(ports.IntentAttach, "work", 0), secondTransport)
222+ require.NoError(t, err)
223+ parkedTransport := &closeTrackingTransport{}
224+ _, parkedAttachment, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), parkedTransport)
225+ require.NoError(t, err)
226+ d.clientGone(sess, parkedAttachment, parkedTransport, false)
227+ parkedToken := parkedAttachment.resumeToken
228+
229+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
230+ d.waitNotifies()
231+ require.True(t, firstTransport.Closed())
232+ require.True(t, secondTransport.Closed())
233+ require.True(t, parkedTransport.Closed())
234+ d.mu.Lock()
235+ _, retained := d.parked[parkedToken]
236+ d.mu.Unlock()
237+ require.False(t, retained)
238+}
239+
240+func TestResumeCredentialIsInvalidAfterDaemonRestart(t *testing.T) {
241+ pty, release := newBlockingPTY(t)
242+ defer release()
243+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
244+ transport := &closeTrackingTransport{}
245+ _, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), transport)
246+ require.NoError(t, err)
247+ token := ac.resumeToken
248+ restarted := newTestDaemon(t, newFactory(t, pty), stubClock{})
249+ _, _, err = restarted.route(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{})
250+ var protocolErr *protoErr
251+ require.ErrorAs(t, err, &protocolErr)
252+ require.Equal(t, ports.ErrNoSuchSession, protocolErr.code)
253+}
254+
255+func TestHandleHelloAllowsMultipleAttachments(t *testing.T) {
256+ pty, release := newBlockingPTY(t)
257+ defer release()
258+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
259+ firstTransport := &closeTrackingTransport{}
260+ sess, first, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), firstTransport)
261+ require.NoError(t, err)
262+ secondTransport := &closeTrackingTransport{}
263+ _, second, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), secondTransport)
264+ require.NoError(t, err)
265+ require.Equal(t, true, sess.attachmentRegistered(first))
266+ require.Equal(t, true, sess.attachmentRegistered(second))
267+ require.NotSame(t, first, second)
268+}
A internal/usecase/daemon/attachment_registry.go +587 -0
@@ -0,0 +1,587 @@
1+package daemon
2+
3+import (
4+ "bytes"
5+ "cmp"
6+ "slices"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/pkg/vt"
10+)
11+
12+// attachmentView is the mutable viewport owned by one attachment. Tab and
13+// pane identity is stable across tab insertion, deletion, and reordering;
14+// neither field is a session-local index or layout PaneID.
15+type attachmentView struct {
16+ tabID domain.TabStableID
17+ paneID domain.PaneStableID
18+ windowTop int
19+ windowRows int
20+ bookmark vt.RowID
21+ liveBottom bool
22+ revision uint64
23+}
24+
25+// viewInvalidation records the order in which attachment views observed one
26+// shared session mutation. The order is stable client-ID order, not map
27+// iteration or lock-acquisition order, which keeps concurrent attaches
28+// deterministic.
29+type viewInvalidation struct {
30+ attachment *attachedClient
31+ revision uint64
32+}
33+
34+func (ac *attachedClient) viewSnapshot() attachmentView {
35+ if ac == nil {
36+ return attachmentView{}
37+ }
38+ ac.viewMu.Lock()
39+ defer ac.viewMu.Unlock()
40+ return ac.view
41+}
42+
43+func (ac *attachedClient) publishView(view attachmentView) {
44+ if ac == nil {
45+ return
46+ }
47+ ac.viewMu.Lock()
48+ ac.view = view
49+ ac.viewMu.Unlock()
50+}
51+
52+// registerAttachmentLocked admits ac exactly once. Caller holds s.mu and must
53+// have acquired dispatchMu first when the operation came from a client.
54+func (s *session) registerAttachmentLocked(ac *attachedClient) bool {
55+ if s == nil || !s.sessionCore.registerAttachmentLocked(ac) {
56+ return false
57+ }
58+ view := ac.viewSnapshot()
59+ if view.tabID == "" || view.paneID == "" {
60+ view = s.repairAttachmentViewLocked(ac, view)
61+ ac.publishView(view)
62+ }
63+ return true
64+}
65+
66+func (c *sessionCore) registerAttachmentLocked(ac *attachedClient) bool {
67+ if c == nil || ac == nil {
68+ return false
69+ }
70+ if c.attachments == nil {
71+ c.attachments = make(map[*attachedClient]struct{})
72+ }
73+ if _, exists := c.attachments[ac]; exists {
74+ return false
75+ }
76+ if c.attachmentOrder == nil {
77+ c.attachmentOrder = make(map[*attachedClient]uint64)
78+ }
79+ c.nextAttachmentID++
80+ c.attachments[ac] = struct{}{}
81+ c.attachmentOrder[ac] = c.nextAttachmentID
82+ return true
83+}
84+
85+func registerAttachmentSessionLocked(entry attachmentSession, ac *attachedClient) bool {
86+ if entry == nil || entry.core() == nil {
87+ return false
88+ }
89+ // Transition publication may already hold tab locks (move commit). Only
90+ // publish collection membership here; target repair runs at the next view
91+ // snapshot outside that critical section.
92+ return entry.core().registerAttachmentLocked(ac)
93+}
94+
95+func (s *session) registerAttachment(ac *attachedClient) bool {
96+ if s == nil || ac == nil {
97+ return false
98+ }
99+ s.dispatchMu.Lock()
100+ defer s.dispatchMu.Unlock()
101+ s.mu.Lock()
102+ defer s.mu.Unlock()
103+ return s.registerAttachmentLocked(ac)
104+}
105+
106+// unregisterAttachmentLocked removes only ac and leaves every other
107+// attachment's view intact. Caller holds s.mu.
108+func (s *session) unregisterAttachmentLocked(ac *attachedClient) bool {
109+ if s == nil {
110+ return false
111+ }
112+ return s.sessionCore.unregisterAttachmentLocked(ac)
113+}
114+
115+func (c *sessionCore) unregisterAttachmentLocked(ac *attachedClient) bool {
116+ if c == nil || ac == nil || c.attachments == nil {
117+ return false
118+ }
119+ if _, exists := c.attachments[ac]; !exists {
120+ return false
121+ }
122+ delete(c.attachments, ac)
123+ delete(c.attachmentOrder, ac)
124+ return true
125+}
126+
127+func unregisterAttachmentSessionLocked(entry attachmentSession, ac *attachedClient) bool {
128+ if entry == nil || entry.core() == nil {
129+ return false
130+ }
131+ return entry.core().unregisterAttachmentLocked(ac)
132+}
133+
134+func (s *session) unregisterAttachment(ac *attachedClient) bool {
135+ if s == nil || ac == nil {
136+ return false
137+ }
138+ s.dispatchMu.Lock()
139+ defer s.dispatchMu.Unlock()
140+ s.mu.Lock()
141+ defer s.mu.Unlock()
142+ return s.unregisterAttachmentLocked(ac)
143+}
144+
145+// snapshotAttachmentsLocked returns a stable registration-ordered snapshot.
146+// Callers hold s.mu; returned attachments may be used after unlocking.
147+func (c *sessionCore) snapshotAttachmentsLocked() []*attachedClient {
148+ if c == nil || len(c.attachments) == 0 {
149+ return nil
150+ }
151+ out := make([]*attachedClient, 0, len(c.attachments))
152+ for ac := range c.attachments {
153+ out = append(out, ac)
154+ }
155+ slices.SortStableFunc(out, func(a, b *attachedClient) int {
156+ if id := bytes.Compare(a.clientID[:], b.clientID[:]); id != 0 {
157+ return id
158+ }
159+ return cmp.Compare(c.attachmentOrder[a], c.attachmentOrder[b])
160+ })
161+ return out
162+}
163+
164+func attachmentRegisteredLocked(entry attachmentSession, ac *attachedClient) bool {
165+ if entry == nil || ac == nil || entry.core() == nil {
166+ return false
167+ }
168+ _, ok := entry.core().attachments[ac]
169+ return ok
170+}
171+
172+func attachmentRegistered(entry attachmentSession, ac *attachedClient) bool {
173+ if entry == nil || ac == nil || entry.core() == nil {
174+ return false
175+ }
176+ core := entry.core()
177+ core.mu.Lock()
178+ defer core.mu.Unlock()
179+ return attachmentRegisteredLocked(entry, ac)
180+}
181+
182+func (s *session) attachmentRegistered(ac *attachedClient) bool {
183+ return attachmentRegistered(s, ac)
184+}
185+
186+func (s *session) attachmentRegisteredLocked(ac *attachedClient) bool {
187+ return attachmentRegisteredLocked(s, ac)
188+}
189+
190+func (s *session) attachmentViewsTabLocked(tb *tab) bool {
191+ if s == nil || tb == nil {
192+ return false
193+ }
194+ for ac := range s.attachments {
195+ if ac.viewSnapshot().tabID == domain.TabStableID(tb.stableID) {
196+ return true
197+ }
198+ }
199+ return false
200+}
201+
202+func snapshotAttachmentSession(entry attachmentSession) []*attachedClient {
203+ if entry == nil || entry.core() == nil {
204+ return nil
205+ }
206+ core := entry.core()
207+ core.mu.Lock()
208+ defer core.mu.Unlock()
209+ return core.snapshotAttachmentsLocked()
210+}
211+
212+func (s *session) snapshotAttachments() []*attachedClient {
213+ if s == nil {
214+ return nil
215+ }
216+ s.mu.Lock()
217+ defer s.mu.Unlock()
218+ return s.snapshotAttachmentsLocked()
219+}
220+
221+// repairAttachmentViewLocked validates stable targets and chooses the nearest
222+// surviving target when a tab or pane was removed. Caller holds s.mu.
223+func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachmentView) attachmentView {
224+ _ = ac
225+ if s == nil {
226+ return attachmentView{}
227+ }
228+ var targetTab *tab
229+ for _, tb := range s.tabs {
230+ if tb != nil && domain.TabStableID(tb.stableID) == view.tabID {
231+ targetTab = tb
232+ break
233+ }
234+ }
235+ tabRepaired := targetTab == nil
236+ if targetTab == nil && len(s.tabs) != 0 {
237+ targetTab = s.tabs[0]
238+ }
239+ if targetTab == nil {
240+ view.tabID = ""
241+ view.paneID = ""
242+ view.windowTop = 0
243+ view.windowRows = 0
244+ view.bookmark = 0
245+ view.liveBottom = true
246+ return view
247+ }
248+ view.tabID = domain.TabStableID(targetTab.stableID)
249+
250+ targetPane := (*pane)(nil)
251+ targetTab.mu.Lock()
252+ for _, candidate := range targetTab.panes {
253+ if candidate != nil && domain.PaneStableID(candidate.stableID) == view.paneID {
254+ targetPane = candidate
255+ break
256+ }
257+ }
258+ paneRepaired := targetPane == nil
259+ if targetPane == nil {
260+ targetPane = targetTab.focusedPane()
261+ }
262+ if targetPane != nil {
263+ view.paneID = domain.PaneStableID(targetPane.stableID)
264+ } else {
265+ view.paneID = ""
266+ }
267+ if view.windowRows <= 0 {
268+ view.windowRows = targetTab.size.Rows
269+ }
270+ targetTab.mu.Unlock()
271+ if view.windowTop < 0 {
272+ view.windowTop = 0
273+ }
274+ if tabRepaired || paneRepaired || targetPane == nil {
275+ view.bookmark = 0
276+ view.liveBottom = true
277+ }
278+ return view
279+}
280+
281+// repairAttachmentView repairs ac and increments its revision only when the
282+// stable target changed. It is deliberately separate from registration so
283+// layout mutation code can validate every surviving attachment in one ordered
284+// pass.
285+func (s *session) repairAttachmentView(ac *attachedClient) bool {
286+ if s == nil || ac == nil {
287+ return false
288+ }
289+ s.mu.Lock()
290+ defer s.mu.Unlock()
291+ if _, ok := s.attachments[ac]; !ok {
292+ return false
293+ }
294+ before := ac.viewSnapshot()
295+ after := s.repairAttachmentViewLocked(ac, before)
296+ if before.tabID == after.tabID && before.paneID == after.paneID && before.windowTop == after.windowTop && before.windowRows == after.windowRows && before.bookmark == after.bookmark && before.liveBottom == after.liveBottom {
297+ return false
298+ }
299+ after.revision++
300+ ac.publishView(after)
301+ return true
302+}
303+
304+// prepareAttachmentViewsForRemovedTabLocked selects the nearest surviving tab
305+// for attachments that were viewing removed. Caller holds s.mu before tabs is
306+// mutated; the normal invalidation pass publishes the revised views.
307+func (s *session) prepareAttachmentViewsForRemovedTabLocked(removed *tab, index int) {
308+ if s == nil || removed == nil || len(s.tabs) <= 1 || index < 0 || index >= len(s.tabs) {
309+ return
310+ }
311+ replacement := index + 1
312+ if replacement >= len(s.tabs) {
313+ replacement = index - 1
314+ }
315+ if replacement < 0 || replacement >= len(s.tabs) || s.tabs[replacement] == nil {
316+ return
317+ }
318+ tabID := domain.TabStableID(s.tabs[replacement].stableID)
319+ removedID := domain.TabStableID(removed.stableID)
320+ for ac := range s.attachments {
321+ view := ac.viewSnapshot()
322+ if view.tabID == removedID {
323+ view.tabID = tabID
324+ view.paneID = ""
325+ view.bookmark = 0
326+ view.liveBottom = true
327+ ac.publishView(view)
328+ }
329+ }
330+}
331+
332+// invalidateViewsLocked orders all per-attachment invalidations behind the
333+// shared session mutation. Caller holds s.mu and dispatchMu.
334+func (s *session) invalidateViewsLocked() []viewInvalidation {
335+ attachments := s.snapshotAttachmentsLocked()
336+ invalidations := make([]viewInvalidation, 0, len(attachments))
337+ for _, ac := range attachments {
338+ view := ac.viewSnapshot()
339+ repaired := s.repairAttachmentViewLocked(ac, view)
340+ repaired.revision++
341+ ac.publishView(repaired)
342+ invalidations = append(invalidations, viewInvalidation{attachment: ac, revision: repaired.revision})
343+ }
344+ return invalidations
345+}
346+
347+// runMutation is the one admission boundary for shared session mutations.
348+// Callers perform their state mutation and view invalidation inside fn; the
349+// dispatch lock is acquired before any session, tab, or daemon lock.
350+func (s *session) runMutation(fn func() error) error {
351+ if s == nil || fn == nil {
352+ return nil
353+ }
354+ s.dispatchMu.Lock()
355+ defer s.dispatchMu.Unlock()
356+ return fn()
357+}
358+
359+// tabForAttachment resolves the attachment's stable target after validating
360+// it against current membership. It never changes another attachment's view.
361+func (s *session) tabForAttachment(ac *attachedClient) *tab {
362+ if s == nil || ac == nil {
363+ return nil
364+ }
365+ s.mu.Lock()
366+ defer s.mu.Unlock()
367+ if _, ok := s.attachments[ac]; !ok {
368+ return nil
369+ }
370+ view := ac.viewSnapshot()
371+ repaired := s.repairAttachmentViewLocked(ac, view)
372+ if repaired.tabID != view.tabID || repaired.paneID != view.paneID || repaired.windowTop != view.windowTop || repaired.windowRows != view.windowRows || repaired.bookmark != view.bookmark || repaired.liveBottom != view.liveBottom {
373+ repaired.revision++
374+ ac.publishView(repaired)
375+ }
376+ for _, tb := range s.tabs {
377+ if tb != nil && domain.TabStableID(tb.stableID) == repaired.tabID {
378+ return tb
379+ }
380+ }
381+ return nil
382+}
383+
384+// paneForAttachment resolves both stable IDs and repairs a removed pane before
385+// returning it. The tab remains shared; only the attachment's view moves.
386+func (s *session) tabIndexForAttachment(ac *attachedClient) (int, int) {
387+ if s == nil {
388+ return -1, 0
389+ }
390+ s.mu.Lock()
391+ tabs := append([]*tab(nil), s.tabs...)
392+ s.mu.Unlock()
393+ if len(tabs) == 0 {
394+ return -1, 0
395+ }
396+ if ac == nil {
397+ return 0, len(tabs)
398+ }
399+ view := ac.viewSnapshot()
400+ for i, tb := range tabs {
401+ if tb != nil && domain.TabStableID(tb.stableID) == view.tabID {
402+ return i, len(tabs)
403+ }
404+ }
405+ return 0, len(tabs)
406+}
407+
408+// firstTab returns the first tab in the session's ordered tab collection.
409+// It is a deterministic session-level default for headless work, never an
410+// interactive attachment selection.
411+func (s *session) firstTab() *tab {
412+ if s == nil {
413+ return nil
414+ }
415+ s.mu.Lock()
416+ defer s.mu.Unlock()
417+ if len(s.tabs) == 0 {
418+ return nil
419+ }
420+ return s.tabs[0]
421+}
422+
423+func (s *session) paneForAttachment(ac *attachedClient) (*tab, *pane) {
424+ tb := s.tabForAttachment(ac)
425+ if tb == nil {
426+ return nil, nil
427+ }
428+ view := ac.viewSnapshot()
429+ tb.mu.Lock()
430+ defer tb.mu.Unlock()
431+ for _, p := range tb.panes {
432+ if p != nil && domain.PaneStableID(p.stableID) == view.paneID {
433+ return tb, p
434+ }
435+ }
436+ return tb, tb.focusedPane()
437+}
438+
439+// setAttachmentPaneLocked publishes the initiating attachment's pane target
440+// after a shared focus mutation. Caller holds s.mu; the structural tree focus
441+// is not used to resolve any other attachment's target.
442+func (s *session) setAttachmentPaneLocked(ac *attachedClient, tb *tab, p *pane) bool {
443+ if s == nil || ac == nil || tb == nil || p == nil || !attachmentRegisteredLocked(s, ac) {
444+ return false
445+ }
446+ view := ac.viewSnapshot()
447+ before := view
448+ view.tabID = domain.TabStableID(tb.stableID)
449+ view.paneID = domain.PaneStableID(p.stableID)
450+ view = s.repairAttachmentViewLocked(ac, view)
451+ if view.tabID == before.tabID && view.paneID == before.paneID {
452+ return false
453+ }
454+ view.revision++
455+ ac.publishView(view)
456+ return true
457+}
458+
459+// updateAttachmentView applies one attachment-local view mutation at the
460+// shared mutation boundary. The revision increments exactly once and target
461+// IDs are repaired before publication.
462+func (s *session) updateAttachmentView(ac *attachedClient, update func(*attachmentView)) bool {
463+ if s == nil || ac == nil || update == nil {
464+ return false
465+ }
466+ changed := false
467+ _ = s.runMutation(func() error {
468+ s.mu.Lock()
469+ defer s.mu.Unlock()
470+ if _, ok := s.attachments[ac]; !ok {
471+ return nil
472+ }
473+ before := ac.viewSnapshot()
474+ next := before
475+ update(&next)
476+ next = s.repairAttachmentViewLocked(ac, next)
477+ next.revision++
478+ ac.publishView(next)
479+ changed = next.tabID != before.tabID || next.paneID != before.paneID || next.windowTop != before.windowTop || next.windowRows != before.windowRows || next.bookmark != before.bookmark || next.liveBottom != before.liveBottom
480+ return nil
481+ })
482+ return changed
483+}
484+
485+// selectAttachmentTab changes only one attachment's target. Shared tab order
486+// and PTY ownership are untouched.
487+func (s *session) activateAttachmentViewLocked(ac *attachedClient, idx int) bool {
488+ if s == nil || ac == nil || idx < 0 || idx >= len(s.tabs) || !attachmentRegisteredLocked(s, ac) {
489+ return false
490+ }
491+ view := ac.viewSnapshot()
492+ view.tabID = domain.TabStableID(s.tabs[idx].stableID)
493+ view.paneID = ""
494+ view = s.repairAttachmentViewLocked(ac, view)
495+ view.revision++
496+ ac.publishView(view)
497+ return true
498+}
499+
500+func (s *session) selectAttachmentTabLocked(ac *attachedClient, tabID domain.TabStableID) bool {
501+ if s == nil || ac == nil || !attachmentRegisteredLocked(s, ac) {
502+ return false
503+ }
504+ view := ac.viewSnapshot()
505+ view.tabID = tabID
506+ view.paneID = ""
507+ view = s.repairAttachmentViewLocked(ac, view)
508+ view.revision++
509+ ac.publishView(view)
510+ s.invalidateViewsLocked()
511+ return view.tabID == tabID
512+}
513+
514+func (s *session) selectAttachmentTab(ac *attachedClient, tabID domain.TabStableID) bool {
515+ if s == nil || ac == nil {
516+ return false
517+ }
518+ var changed bool
519+ _ = s.runMutation(func() error {
520+ s.mu.Lock()
521+ defer s.mu.Unlock()
522+ changed = s.selectAttachmentTabLocked(ac, tabID)
523+ return nil
524+ })
525+ return changed
526+}
527+
528+func (s *session) switchAttachmentTabForDispatch(ac *attachedClient, idx int) bool {
529+ if s == nil || ac == nil {
530+ return false
531+ }
532+ s.mu.Lock()
533+ defer s.mu.Unlock()
534+ if idx < 0 || idx >= len(s.tabs) {
535+ return false
536+ }
537+ return s.selectAttachmentTabLocked(ac, domain.TabStableID(s.tabs[idx].stableID))
538+}
539+
540+func (s *session) switchAttachmentTab(ac *attachedClient, idx int) bool {
541+ if s == nil || ac == nil {
542+ return false
543+ }
544+ s.mu.Lock()
545+ registered := attachmentRegisteredLocked(s, ac)
546+ if registered && idx >= 0 && idx < len(s.tabs) {
547+ tabID := domain.TabStableID(s.tabs[idx].stableID)
548+ s.mu.Unlock()
549+ return s.selectAttachmentTab(ac, tabID)
550+ }
551+ s.mu.Unlock()
552+ return false
553+}
554+
555+func (s *session) switchAttachmentRelativeForDispatch(ac *attachedClient, delta int) bool {
556+ position, count := s.tabIndexForAttachment(ac)
557+ if count < 2 {
558+ return false
559+ }
560+ return s.switchAttachmentTabForDispatch(ac, (position+delta+count)%count)
561+}
562+
563+// repairAttachmentViews repairs all live attachment targets after a shared
564+// topology mutation.
565+func (s *session) repairAttachmentViews() []viewInvalidation {
566+ if s == nil {
567+ return nil
568+ }
569+ var invalidations []viewInvalidation
570+ _ = s.runMutation(func() error {
571+ s.mu.Lock()
572+ invalidations = s.invalidateViewsLocked()
573+ s.mu.Unlock()
574+ return nil
575+ })
576+ return invalidations
577+}
578+
579+// repairAttachmentViewsLocked repairs all live attachment targets after a tab
580+// or pane mutation. Caller holds dispatchMu and s.mu; it is intended to run at
581+// the same mutation linearization point as the shared membership change.
582+func (s *session) repairAttachmentViewsLocked() []viewInvalidation {
583+ if s == nil {
584+ return nil
585+ }
586+ return s.invalidateViewsLocked()
587+}
A internal/usecase/daemon/attachment_registry_test.go +132 -0
@@ -0,0 +1,132 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+)
9+
10+func registryTestSession() *session {
11+ sz := domain.Size{Cols: 80, Rows: 23}
12+ first := newTabWithStableID("tab-a", "pane-a", nil, sz)
13+ second := newTabWithStableID("tab-b", "pane-b", nil, sz)
14+ return &session{
15+ sessionCore: sessionCore{id: "registry-test"},
16+ tabs: []*tab{first, second},
17+ }
18+}
19+
20+func registryTestAttachment(id byte) *attachedClient {
21+ ac := &attachedClient{}
22+ ac.clientID[0] = id
23+ return ac
24+}
25+
26+func TestAttachmentRegistryPreservesOtherAttachmentsOnDetach(t *testing.T) {
27+ sess := registryTestSession()
28+ first, second, third := registryTestAttachment(1), registryTestAttachment(2), registryTestAttachment(3)
29+
30+ for _, ac := range []*attachedClient{first, second, third} {
31+ if !sess.registerAttachment(ac) {
32+ t.Fatalf("register %v failed", ac.clientID[0])
33+ }
34+ }
35+ if got := sess.snapshotAttachments(); len(got) != 3 {
36+ t.Fatalf("attachment count = %d, want 3", len(got))
37+ }
38+ if !sess.unregisterAttachment(second) {
39+ t.Fatal("unregister second failed")
40+ }
41+ got := sess.snapshotAttachments()
42+ if len(got) != 2 || got[0] != first || got[1] != third {
43+ t.Fatalf("remaining attachments = %v, want first and third", got)
44+ }
45+}
46+
47+func TestAttachmentRegistryRepairsRemovedStableTarget(t *testing.T) {
48+ sess := registryTestSession()
49+ ac := registryTestAttachment(1)
50+ if !sess.registerAttachment(ac) || !sess.selectAttachmentTab(ac, "tab-b") {
51+ t.Fatal("failed to select second tab")
52+ }
53+ before := ac.viewSnapshot()
54+ if before.tabID != "tab-b" || before.paneID != "pane-b" {
55+ t.Fatalf("selected view = %#v", before)
56+ }
57+
58+ if err := sess.runMutation(func() error {
59+ sess.mu.Lock()
60+ defer sess.mu.Unlock()
61+ sess.tabs = sess.tabs[:1]
62+ sess.repairAttachmentViewsLocked()
63+ return nil
64+ }); err != nil {
65+ t.Fatal(err)
66+ }
67+ after := ac.viewSnapshot()
68+ if after.tabID != "tab-a" || after.paneID != "pane-a" {
69+ t.Fatalf("repaired view = %#v, want tab-a/pane-a", after)
70+ }
71+ if after.revision <= before.revision || !after.liveBottom {
72+ t.Fatalf("repaired view revision/liveBottom = %d/%v, want revision increase and live bottom", after.revision, after.liveBottom)
73+ }
74+}
75+
76+func TestAttachmentRegistryViewMutationIsSerialized(t *testing.T) {
77+ sess := registryTestSession()
78+ ac := registryTestAttachment(1)
79+ if !sess.registerAttachment(ac) {
80+ t.Fatal("register failed")
81+ }
82+ before := ac.viewSnapshot()
83+ if !sess.updateAttachmentView(ac, func(view *attachmentView) {
84+ view.windowTop = 4
85+ view.windowRows = 12
86+ view.bookmark = 9
87+ view.liveBottom = false
88+ }) {
89+ t.Fatal("view update was not published")
90+ }
91+ after := ac.viewSnapshot()
92+ if after.windowTop != 4 || after.windowRows != 12 || after.bookmark != 9 || after.liveBottom || after.revision != before.revision+1 {
93+ t.Fatalf("view after mutation = %#v, want updated fields and one revision", after)
94+ }
95+}
96+
97+func TestAttachmentRegistryConcurrentRegistrationSnapshotIsStable(t *testing.T) {
98+ sess := registryTestSession()
99+ attachments := []*attachedClient{
100+ registryTestAttachment(3),
101+ registryTestAttachment(1),
102+ registryTestAttachment(2),
103+ }
104+ start := make(chan struct{})
105+ var wg sync.WaitGroup
106+ for _, ac := range attachments {
107+ wg.Add(1)
108+ go func(ac *attachedClient) {
109+ defer wg.Done()
110+ <-start
111+ sess.registerAttachment(ac)
112+ }(ac)
113+ }
114+ close(start)
115+ wg.Wait()
116+
117+ got := sess.snapshotAttachments()
118+ if len(got) != len(attachments) {
119+ t.Fatalf("attachment count = %d, want %d", len(got), len(attachments))
120+ }
121+ for i, want := range []byte{1, 2, 3} {
122+ if got[i].clientID[0] != want {
123+ t.Fatalf("snapshot[%d] id = %d, want %d", i, got[i].clientID[0], want)
124+ }
125+ }
126+ invalidations := sess.repairAttachmentViews()
127+ for i, want := range []byte{1, 2, 3} {
128+ if invalidations[i].attachment.clientID[0] != want {
129+ t.Fatalf("invalidation[%d] id = %d, want %d", i, invalidations[i].attachment.clientID[0], want)
130+ }
131+ }
132+}
D internal/usecase/daemon/attachment_role.go +0 -224
@@ -1,224 +0,0 @@
1-package daemon
2-
3-import "github.com/bnema/vev/internal/ports"
4-
5-type attachmentRole uint8
6-
7-// attachmentSessionCore normalizes nil interfaces, typed-nil implementations,
8-// and implementations that deliberately expose no attachment core.
9-func attachmentSessionCore(entry attachmentSession) *sessionCore {
10- if entry == nil {
11- return nil
12- }
13- return entry.core()
14-}
15-
16-const (
17- attachmentDetached attachmentRole = iota
18- attachmentActive
19- attachmentSnatched
20-)
21-
22-// attachmentRole derives the attachment's role from the session-owned
23-// registries. There is deliberately no second role field that could drift.
24-func attachmentSessionRole(entry attachmentSession, ac *attachedClient) attachmentRole {
25- if entry == nil || ac == nil || entry.core() == nil {
26- return attachmentDetached
27- }
28- core := entry.core()
29- core.mu.Lock()
30- defer core.mu.Unlock()
31- return attachmentSessionRoleLocked(entry, ac)
32-}
33-
34-// attachmentSessionRoleLocked requires entry.core().mu. Active membership wins
35-// if an invalid intermediate state contains the same attachment in both
36-// registries.
37-func attachmentSessionRoleLocked(entry attachmentSession, ac *attachedClient) attachmentRole {
38- if entry == nil || ac == nil || entry.core() == nil {
39- return attachmentDetached
40- }
41- core := entry.core()
42- if core.client == ac {
43- return attachmentActive
44- }
45- if _, ok := core.snatched[ac]; ok {
46- return attachmentSnatched
47- }
48- return attachmentDetached
49-}
50-
51-func addSnatchedLocked(entry attachmentSession, ac *attachedClient) {
52- if entry == nil || ac == nil || entry.core() == nil {
53- return
54- }
55- core := entry.core()
56- if core.snatched == nil {
57- core.snatched = make(map[*attachedClient]struct{})
58- }
59- core.snatched[ac] = struct{}{}
60-}
61-
62-func (s *session) attachmentRole(ac *attachedClient) attachmentRole {
63- return attachmentSessionRole(s, ac)
64-}
65-
66-func (s *session) attachmentRoleLocked(ac *attachedClient) attachmentRole {
67- return attachmentSessionRoleLocked(s, ac)
68-}
69-
70-func (s *session) addSnatchedLocked(ac *attachedClient) { addSnatchedLocked(s, ac) }
71-
72-type attachmentRoleToken struct {
73- sess attachmentSession
74- ac *attachedClient
75- role attachmentRole
76- generation uint64
77- transport transportSnapshot
78- lease *attachmentLease
79- effect *roleEffectTicket
80- // rebase marks a cross-session ownership move whose first paint must start a
81- // dependency-free output chain. The rebase is deliberately deferred until
82- // after transition publication releases all architecture locks.
83- rebase bool
84-}
85-
86-// attachmentTokenLocked captures the exact role, transport incarnation, and
87-// active coordinator lease at an architecture commit point. The caller must
88-// hold s.mu; coordinator acquisition follows the canonical session ->
89-// coordinator order.
90-func attachmentTokenLocked(entry attachmentSession, ac *attachedClient) attachmentRoleToken {
91- if entry == nil || entry.core() == nil || ac == nil {
92- return attachmentRoleToken{}
93- }
94- transport := ac.transportSnapshot()
95- if transport.transport == nil {
96- return attachmentRoleToken{}
97- }
98- token := attachmentRoleToken{
99- sess: entry,
100- ac: ac,
101- role: attachmentSessionRoleLocked(entry, ac),
102- generation: ac.roleGeneration.Load(),
103- transport: transport,
104- }
105- if token.role == attachmentActive {
106- if rc := entry.core().coordinator.Load(); rc != nil {
107- token.lease = rc.attachmentLease(ac)
108- }
109- }
110- return token
111-}
112-
113-func (s *session) attachmentTokenLocked(ac *attachedClient) attachmentRoleToken {
114- return attachmentTokenLocked(s, ac)
115-}
116-
117-func attachmentToken(entry attachmentSession, ac *attachedClient, tr ports.Transport) attachmentRoleToken {
118- if entry == nil || entry.core() == nil || ac == nil || tr == nil {
119- return attachmentRoleToken{}
120- }
121- core := entry.core()
122- core.mu.Lock()
123- transport := ac.transportSnapshot()
124- if transport.transport != tr {
125- core.mu.Unlock()
126- return attachmentRoleToken{}
127- }
128- token := attachmentRoleToken{
129- sess: entry,
130- ac: ac,
131- role: attachmentSessionRoleLocked(entry, ac),
132- generation: ac.roleGeneration.Load(),
133- transport: transport,
134- }
135- core.mu.Unlock()
136- if token.role == attachmentActive {
137- if rc := core.coordinator.Load(); rc != nil {
138- token.lease = rc.attachmentLease(ac)
139- }
140- }
141- ac.bootstrapRoleCapability(token)
142- return token
143-}
144-
145-func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentRoleToken {
146- return attachmentToken(s, ac, tr)
147-}
148-
149-func (t attachmentRoleToken) current() bool {
150- return t.sess != nil && t.ac != nil &&
151- t.ac.roleGeneration.Load() == t.generation &&
152- t.ac.transportSnapshotCurrent(t.transport) &&
153- attachmentSessionRole(t.sess, t.ac) == t.role
154-}
155-
156-// activeCurrent admits post-transition effects only for the exact published
157-// role, transport incarnation, current-session link, and coordinator lease.
158-func (t attachmentRoleToken) activeCurrent() bool {
159- if t.role != attachmentActive || t.lease == nil || !t.current() || t.ac.currentAttachmentSession() != t.sess {
160- return false
161- }
162- rc := t.sess.core().coordinator.Load()
163- return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
164-}
165-
166-// activeEffect is the canonical admission check for a client-frame mutation.
167-// Tokens bind role generation, transport incarnation, session ownership, and
168-// coordinator lease, so handlers never rediscover authority from mutable state.
169-func (t attachmentRoleToken) activeEffect() bool {
170- if t.effect != nil && !t.effect.ended.Load() {
171- return true
172- }
173- return t.activeCurrent()
174-}
175-
176-func beginActiveLeaseEffect(sess attachmentSession, ac *attachedClient, lease *attachmentLease) (*roleEffectTicket, bool) {
177- if sess == nil || ac == nil || lease == nil {
178- return nil, false
179- }
180- token := attachmentToken(sess, ac, ac.transport())
181- token.lease = lease
182- return ac.beginRoleEffect(token)
183-}
184-
185-func (t *attachmentRoleToken) endRoleEffect() {
186- if t == nil || t.effect == nil {
187- return
188- }
189- t.effect.End()
190- t.effect = nil
191-}
192-
193-// activeEffectSessionLocked is the same admission check while t.sess.mu is
194-// already held at a session-owned mutation boundary.
195-func (t attachmentRoleToken) activeEffectSessionLocked() bool {
196- if t.effect != nil && !t.effect.ended.Load() {
197- return true
198- }
199- if t.role != attachmentActive || t.lease == nil || t.sess == nil || t.ac == nil ||
200- t.ac.roleGeneration.Load() != t.generation || !t.ac.transportSnapshotCurrent(t.transport) ||
201- t.ac.currentAttachmentSession() != t.sess || attachmentSessionRoleLocked(t.sess, t.ac) != attachmentActive {
202- return false
203- }
204- rc := t.sess.core().coordinator.Load()
205- return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
206-}
207-
208-func (t attachmentRoleToken) sendActiveControl(frame ports.Frame) error {
209- if t.ac == nil {
210- return errAttachmentTransition
211- }
212- t.ac.sendMu.Lock()
213- defer t.ac.sendMu.Unlock()
214- if t.effect == nil || t.effect.ended.Load() || !t.ac.transportSnapshotCurrent(t.transport) ||
215- !t.effect.beginTransportSend(t.transport) {
216- return errAttachmentTransition
217- }
218- err := t.transport.transport.Send(frame)
219- if err != nil {
220- t.effect.reportTransportFailure(t.transport)
221- }
222- t.effect.endTransportSend()
223- return err
224-}
M internal/usecase/daemon/attachment_transition.go +58 -135
@@ -23,76 +23,65 @@ type attachmentTransitionRequest struct {
2323 source attachmentSession
2424 target attachmentSession
2525 next *attachedClient
26- expectedRole attachmentRole
27- targetRole attachmentRole
2826 expectedTransport transportSnapshot
29- sourceToken *attachmentRoleToken
27+ sourceToken *attachmentConnectionToken
3028 action string
3129 activateTargetTab bool
3230 targetTabIndex int
3331 copySourceEnvironment bool
34- // preserveRole commits navigation state without replacing attachment
35- // ownership. It is used for same-session picker selections after the exact
36- // initiating capability has been frozen and revalidated.
37- preserveRole bool
38- expectedSourceTab *tab
32+ // preserveAttachment commits attachment-local navigation state without
33+ // changing session membership. The initiating connection token remains the
34+ // exact authority for this mutation.
35+ preserveAttachment bool
36+ expectedSourceTab *tab
3937 // transferExpectedSourceTab permits an installed visible floating pane only
40- // when the caller atomically transfers this exact tab under its own tab and
41- // floating-generation fences. Ordinary navigation still rejects visibility.
42- transferExpectedSourceTab bool
43- createTargetLocked func() (*session, error)
44- ready bool
45- expectedTargetCurrent *attachedClient
46- expectedTargetTransport transportSnapshot
47- expectedTargetLifecycle *attachmentLifecycleFence
48- preflighted bool
49- roleEffectsFrozen bool
50- expectedTargetTransportInterrupted bool
51- // activationBarrier is used when promoting an already connected snatched
52- // attachment. Its role gate is deadline-drained before sendMu is acquired,
53- // preserving the gate -> sendMu -> daemon/routing/session publication order.
54- activationBarrier bool
38+ // when the caller atomically transfers this exact tab under its fences.
39+ transferExpectedSourceTab bool
40+ createTargetLocked func() (*session, error)
41+ ready bool
42+ expectedTargetLifecycle *attachmentLifecycleFence
43+ preflighted bool
44+ attachmentEffectsFrozen bool
5545 }
5646
57-func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source attachmentSession, req attachmentTransitionRequest) bool {
58- return token.sess == source && token.ac == req.next && token.role == req.expectedRole &&
47+func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source attachmentSession, req attachmentTransitionRequest) bool {
48+ return token.sess == source && token.ac == req.next &&
49+ token.generation == req.next.connectionGeneration.Load() &&
5950 token.transport.transport == req.expectedTransport.transport &&
6051 token.transport.incarnation == req.expectedTransport.incarnation
6152 }
6253
63-// transitionSourceTokenCurrentLocked requires source.mu and, for an active
64-// source, sourceCoordinator.mu. This is the canonical handoff preflight: every
65-// client-originated navigation, delete, or create intent is bound to one exact
66-// role generation, transport incarnation, and coordinator lease.
67-func transitionSourceTokenCurrentLocked(token attachmentRoleToken, source attachmentSession, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {
68- if token.sess != source || token.ac != req.next || token.role != req.expectedRole ||
69- token.generation != req.next.roleGeneration.Load() || attachmentSessionRoleLocked(source, req.next) != token.role ||
70- req.next.currentAttachmentSession() != source || !req.next.transportSnapshotCurrent(token.transport) {
54+// transitionSourceTokenCurrentLocked requires source.core().mu and, when a
55+// lease exists, sourceCoordinator.mu. It is the canonical exact-connection
56+// handoff check for client-originated navigation and lifecycle mutations.
57+func transitionSourceTokenCurrentLocked(token attachmentConnectionToken, source attachmentSession, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {
58+ if token.sess != source || token.ac != req.next ||
59+ token.generation != req.next.connectionGeneration.Load() ||
60+ token.sess == nil || token.ac.currentAttachmentSession() != source ||
61+ !attachmentRegisteredLocked(source, req.next) ||
62+ !req.next.transportSnapshotCurrent(token.transport) {
7163 return false
7264 }
73- if token.role != attachmentActive {
65+ if token.lease == nil {
7466 return true
7567 }
76- return sourceCoordinator != nil && token.lease != nil && token.lease.attachment == req.next &&
68+ return sourceCoordinator != nil && token.lease.attachment == req.next &&
7769 sourceCoordinator.leaseCurrentLocked(token.lease, true)
7870 }
7971
8072 type attachmentTransitionResult struct {
81- published attachmentRoleToken
82- displaced attachmentRoleToken
83- terminalDisplaced detachedAttachmentSnapshot
84- displacedInterrupted bool
85- cleanups []renderLifecycleCleanup
73+ published attachmentConnectionToken
74+ cleanups []renderLifecycleCleanup
8675 }
8776
8877 type attachmentTransitionParticipants struct {
8978 clients []*attachedClient
90- interrupts []roleTransportInterrupt
79+ interrupts []attachmentTransportInterrupt
9180 }
9281
93-// snapshotAttachmentTransition discovers the exact participants while d.mu is
94-// held. It releases every architecture lock before any role effect is ended or
95-// any gate is frozen and drained.
82+// snapshotAttachmentTransition discovers the exact connection participant
83+// while d.mu is held, then releases architecture locks before effect gates are
84+// frozen and drained. Existing target attachments are never displaced.
9685 func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionRequest, attachmentTransitionParticipants, error) {
9786 d.mu.Lock()
9887 defer d.mu.Unlock()
@@ -110,73 +99,43 @@ func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (
11099 targetCore = req.target.core()
111100 }
112101 if sourceCore == nil || req.target != nil && targetCore == nil || req.next == nil || d.closing ||
113- d.sessions[sourceCore.id] != source || req.target != nil && d.sessions[targetCore.id] != req.target ||
114- req.activationBarrier && (req.sourceToken == nil || source != req.target ||
115- req.expectedRole != attachmentSnatched || req.targetRole != attachmentActive) {
102+ d.sessions[sourceCore.id] != source || req.target != nil && d.sessions[targetCore.id] != req.target {
116103 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
117104 }
118- if req.target != nil {
119- targetCore.mu.Lock()
120- req.expectedTargetCurrent = targetCore.client
121- if req.expectedTargetCurrent != nil && req.expectedTargetCurrent != req.next && req.targetRole == attachmentActive {
122- req.expectedTargetTransport = req.expectedTargetCurrent.transportSnapshot()
123- }
124- targetCore.mu.Unlock()
125- }
126105 if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) {
127106 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
128107 }
129108 req.preflighted = true
130- return req, attachmentTransitionParticipants{
131- clients: []*attachedClient{req.next, req.expectedTargetCurrent},
132- interrupts: []roleTransportInterrupt{{
133- ac: req.expectedTargetCurrent, transport: req.expectedTargetTransport,
134- }},
135- }, nil
109+ return req, attachmentTransitionParticipants{clients: []*attachedClient{req.next}}, nil
136110 }
137111
138-// freezeAttachmentTransition drains the snapshotted participants without any
139-// architecture lock held. The returned guard remains frozen through atomic
140-// publication and must be released by the caller.
141-func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, participants attachmentTransitionParticipants) (frozenRoleEffectGates, bool, error) {
142- if d.afterRoleEffectParticipantsSnapshotted != nil {
143- d.afterRoleEffectParticipantsSnapshotted(req.action, participants.clients)
112+// freezeAttachmentTransition drains the snapshotted connection without any
113+// architecture lock held. The returned guard remains frozen through publication.
114+func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, participants attachmentTransitionParticipants) (frozenAttachmentEffectGates, error) {
115+ if d.afterAttachmentEffectParticipantsSnapshotted != nil {
116+ d.afterAttachmentEffectParticipantsSnapshotted(req.action, participants.clients)
144117 }
145- var drainDeadline *roleEffectDrainDeadline
146- var drainDone func() <-chan struct{}
147- if req.activationBarrier {
148- drainDeadline = newRoleEffectDrainDeadline(d.clock)
149- drainDone = drainDeadline.Done
150- }
151- frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, done: drainDone, afterFrozen: func(ac *attachedClient) {
152- if d.afterRoleEffectGateFrozen != nil {
153- d.afterRoleEffectGateFrozen(req.action, ac)
118+ frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: participants.interrupts, afterFrozen: func(ac *attachedClient) {
119+ if d.afterAttachmentEffectGateFrozen != nil {
120+ d.afterAttachmentEffectGateFrozen(req.action, ac)
154121 }
155122 }}, participants.clients...)
156- if drainDeadline != nil {
157- drainDeadline.stop()
158- }
159123 if !frozen.acquired || !frozen.drained {
160- if req.activationBarrier {
161- _ = req.next.closeCapturedTransport(req.expectedTransport.transport)
162- return frozen, false, errSendTimedOut
163- }
164- return frozen, false, errAttachmentTransition
124+ return frozen, errAttachmentTransition
165125 }
166- interrupted := frozen.interrupted(req.expectedTargetCurrent, req.expectedTargetTransport)
167- if d.afterRoleEffectsFrozen != nil {
168- d.afterRoleEffectsFrozen()
126+ if d.afterAttachmentEffectsFrozen != nil {
127+ d.afterAttachmentEffectsFrozen()
169128 }
170- return frozen, interrupted, nil
129+ return frozen, nil
171130 }
172131
173132 // publishAttachmentTransition revalidates the frozen source, creates an
174-// optional target, and performs publication while d.mu remains held.
133+// optional target, and performs exact membership publication under d.mu.
175134 func (d *Daemon) publishAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
176135 d.mu.Lock()
177136 defer d.mu.Unlock()
178137
179- req.roleEffectsFrozen = true
138+ req.attachmentEffectsFrozen = true
180139 if req.target == nil {
181140 if !d.transitionSourcePreflightLocked(req) {
182141 return attachmentTransitionResult{}, errAttachmentTransition
@@ -186,71 +145,35 @@ func (d *Daemon) publishAttachmentTransition(req attachmentTransitionRequest) (a
186145 if err != nil {
187146 return attachmentTransitionResult{}, err
188147 }
189- req.expectedTargetCurrent = nil
190148 }
191149 return d.transitionAttachmentLocked(req)
192150 }
193151
194-// prepareActivatedAttachment rebases the first active paint while the caller
195-// still owns the activation barrier (and therefore sendMu).
196-func (d *Daemon) prepareActivatedAttachment(result *attachmentTransitionResult) {
197- ac := result.published.ac
198- ac.rebaseOutput()
199- ac.captureFrames = nil
200- if sess, ok := localSession(result.published.sess); ok {
201- d.applyHostThemeSendLocked(sess, ac, ac.getClientTheme(), false)
202- }
203- // The first paint still requests a reset, but the old panel dependency chain
204- // has already been rebased atomically under the activation barrier.
205- result.published.rebase = false
206-}
207-
208152 func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
209153 req, participants, err := d.snapshotAttachmentTransition(req)
210154 if err != nil {
211155 return attachmentTransitionResult{}, err
212156 }
213157 if req.sourceToken != nil {
214- d.endActionRoleEffect(req.sourceToken.effect, req.action)
158+ d.endActionAttachmentEffect(req.sourceToken.effect, req.action)
215159 }
216160
217- frozen, interrupted, err := d.freezeAttachmentTransition(req, participants)
161+ frozen, err := d.freezeAttachmentTransition(req, participants)
218162 defer frozen.unfreeze()
219163 if err != nil {
220164 return attachmentTransitionResult{}, err
221165 }
222- req.expectedTargetTransportInterrupted = interrupted
223-
224- var releaseActivation func()
225- if req.activationBarrier {
226- releaseActivation, err = d.acquireActivationBarrier(*req.sourceToken)
227- if err != nil {
228- return attachmentTransitionResult{}, err
229- }
230- defer func() {
231- if releaseActivation != nil {
232- releaseActivation()
233- }
234- }()
235- }
236166
237167 result, err := d.publishAttachmentTransition(req)
238168 if err != nil {
239169 return result, err
240170 }
241- if req.activationBarrier && result.published.role == attachmentActive {
242- d.prepareActivatedAttachment(&result)
243- }
244- if releaseActivation != nil {
245- releaseActivation()
246- releaseActivation = nil
247- }
248- if result.published.role == attachmentActive {
249- result.published.ac.clearSnatchedInput()
250- }
251- // Warm-proxy timers are external clock operations. Apply them only after
252- // frozen ownership publication and every architecture lock have completed;
253- // the lifecycle helper revalidates exact registry pointers and clients.
254- d.proxyAttachmentTransitionCommitted(req.source, req.target, req.next, req.preserveRole)
171+ d.proxyAttachmentTransitionCommitted(req.source, req.target, req.next, req.preserveAttachment)
255172 return result, nil
256173 }
174+
175+func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionResult) {
176+ for _, cleanup := range result.cleanups {
177+ d.attachmentCleanupWg.Go(cleanup.finish)
178+ }
179+}
M internal/usecase/daemon/attachment_transition_prelocked_test.go +20 -129
@@ -2,152 +2,43 @@ package daemon
22
33 import (
44 "testing"
5- "time"
65
76 "github.com/stretchr/testify/require"
87
98 "github.com/bnema/vev/internal/domain"
109 )
1110
12-func TestAttachmentTransitionPrelockedMatchesWrapper(t *testing.T) {
13- t.Parallel()
14-
15- type transitionObservation struct {
16- result attachmentTransitionResult
17- activeRole attachmentRole
18- displacedRole attachmentRole
19- activeGeneration uint64
20- displacedGeneration uint64
21- activeSession *session
22- displacedSession *session
23- }
24-
25- setup := func(t *testing.T) (*Daemon, *session, *attachedClient, *attachedClient) {
26- t.Helper()
27- d := newTestDaemon(t, nil, stubClock{})
28- sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}}
29- old := &attachedClient{tr: &closeTrackingTransport{}}
30- old.setSession(sess)
31- sess.client = old
32- d.sessions[sess.id] = sess
33- next := &attachedClient{tr: &closeTrackingTransport{}}
34- return d, sess, old, next
35- }
36- observe := func(sess *session, old, next *attachedClient, result attachmentTransitionResult) transitionObservation {
37- return transitionObservation{
38- result: result,
39- activeRole: sess.attachmentRole(next),
40- displacedRole: sess.attachmentRole(old),
41- activeGeneration: next.roleGeneration.Load(),
42- displacedGeneration: old.roleGeneration.Load(),
43- activeSession: next.currentSession(),
44- displacedSession: old.currentSession(),
45- }
46- }
47-
48- wrapperDaemon, wrapperSession, wrapperOld, wrapperNext := setup(t)
49- wrapperResult, err := wrapperDaemon.transitionAttachment(attachmentTransitionRequest{
50- target: wrapperSession,
51- next: wrapperNext,
52- expectedRole: attachmentDetached,
53- targetRole: attachmentActive,
54- expectedTransport: wrapperNext.transportSnapshot(),
55- ready: true,
56- })
57- require.NoError(t, err)
58- wrapper := observe(wrapperSession, wrapperOld, wrapperNext, wrapperResult)
59-
60- prelockedDaemon, prelockedSession, prelockedOld, prelockedNext := setup(t)
61- frozen := freezeRoleEffectGates(prelockedNext, prelockedOld)
62- require.True(t, frozen.acquired)
63- require.True(t, frozen.drained)
64- defer frozen.unfreeze()
65-
66- req := attachmentTransitionRequest{
67- target: prelockedSession,
68- next: prelockedNext,
69- expectedRole: attachmentDetached,
70- targetRole: attachmentActive,
71- expectedTransport: prelockedNext.transportSnapshot(),
72- expectedTargetCurrent: prelockedOld,
73- preflighted: true,
74- roleEffectsFrozen: true,
75- ready: true,
76- }
77- type prelockedOutcome struct {
78- result attachmentTransitionResult
79- err error
80- }
81- outcome := make(chan prelockedOutcome, 1)
82- go func() {
83- prelockedDaemon.mu.Lock()
84- prelockedDaemon.notices.routingMu.Lock()
85- unlockSessions := lockAttachmentSessions(prelockedSession, prelockedSession)
86- publication, validateErr := prelockedDaemon.validateAttachmentTransitionPrelocked(req)
87- var result attachmentTransitionResult
88- if validateErr == nil {
89- result = prelockedDaemon.publishAttachmentTransitionPrelocked(publication)
90- publication.unlockCoordinators()
91- }
92- unlockSessions()
93- prelockedDaemon.notices.routingMu.Unlock()
94- prelockedDaemon.mu.Unlock()
95- outcome <- prelockedOutcome{result: result, err: validateErr}
96- }()
97-
98- var prelockedResult attachmentTransitionResult
99- select {
100- case got := <-outcome:
101- require.NoError(t, got.err)
102- prelockedResult = got.result
103- case <-time.After(time.Second):
104- t.Fatal("prelocked attachment transition recursively acquired an already-held lock")
105- }
106- prelocked := observe(prelockedSession, prelockedOld, prelockedNext, prelockedResult)
107-
108- require.Equal(t, wrapper.activeRole, prelocked.activeRole)
109- require.Equal(t, wrapper.displacedRole, prelocked.displacedRole)
110- require.Equal(t, wrapper.activeGeneration, prelocked.activeGeneration)
111- require.Equal(t, wrapper.displacedGeneration, prelocked.displacedGeneration)
112- require.Same(t, prelockedSession, prelocked.activeSession)
113- require.Same(t, prelockedSession, prelocked.displacedSession)
114- require.Equal(t, wrapper.result.published.role, prelocked.result.published.role)
115- require.Equal(t, wrapper.result.displaced.role, prelocked.result.displaced.role)
116- require.NotNil(t, prelocked.result.published.lease)
117-}
118-
11911 func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure(t *testing.T) {
12012 t.Parallel()
12113
12214 d := newTestDaemon(t, nil, stubClock{})
12315 source := &session{sessionCore: sessionCore{id: domain.SessionID("source")}}
124- target := &session{sessionCore: sessionCore{id: domain.SessionID("target")}, active: 0}
16+ target := &session{sessionCore: sessionCore{id: domain.SessionID("target")}}
12517 next := &attachedClient{tr: &closeTrackingTransport{}}
12618 next.setSession(source)
127- source.addSnatchedLocked(next)
19+ source.registerAttachmentLocked(next)
12820 old := &attachedClient{tr: &closeTrackingTransport{}}
12921 old.setSession(target)
130- target.client = old
22+ target.registerAttachment(old)
13123 d.sessions[source.id] = source
13224 d.sessions[target.id] = target
13325
134- frozen := freezeRoleEffectGates(next, old)
26+ frozen := freezeAttachmentEffectGates(next, old)
13527 require.True(t, frozen.acquired)
13628 require.True(t, frozen.drained)
13729 defer frozen.unfreeze()
13830
13931 req := attachmentTransitionRequest{
140- source: source,
141- target: target,
142- next: next,
143- expectedRole: attachmentSnatched,
144- targetRole: attachmentActive,
145- expectedTransport: next.transportSnapshot(),
146- expectedTargetCurrent: old,
147- preflighted: true,
148- roleEffectsFrozen: true,
149- activateTargetTab: true,
150- targetTabIndex: 1,
32+ source: source,
33+ target: target,
34+ next: next,
35+
36+ expectedTransport: next.transportSnapshot(),
37+
38+ preflighted: true,
39+ attachmentEffectsFrozen: true,
40+ activateTargetTab: true,
41+ targetTabIndex: 1,
15142 }
15243
15344 d.mu.Lock()
@@ -160,11 +51,11 @@ func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure
16051
16152 require.ErrorIs(t, err, errAttachmentTransition)
16253 require.Nil(t, publication)
163- require.Equal(t, attachmentSnatched, source.attachmentRole(next))
164- require.Equal(t, attachmentDetached, target.attachmentRole(next))
165- require.Equal(t, attachmentActive, target.attachmentRole(old))
54+ require.Equal(t, true, source.attachmentRegistered(next))
55+ require.Equal(t, false, target.attachmentRegistered(next))
56+ require.Equal(t, true, target.attachmentRegistered(old))
16657 require.Same(t, source, next.currentSession())
167- require.Equal(t, uint64(0), next.roleGeneration.Load())
168- require.Equal(t, uint64(0), old.roleGeneration.Load())
169- require.Equal(t, 0, target.active)
58+ require.Equal(t, uint64(0), next.connectionGeneration.Load())
59+ require.Equal(t, uint64(0), old.connectionGeneration.Load())
60+ require.Equal(t, 0, testAttachmentTabIndex(target))
17061 }
M internal/usecase/daemon/attachment_transition_publish.go +87 -152
@@ -1,10 +1,14 @@
11 package daemon
22
3-import "github.com/bnema/vev/internal/domain"
3+import (
4+ "slices"
45
5-// transitionSourcePreflightLocked validates an initiating role while its gate
6-// is frozen and d.mu is held. No caller may create or delete a target before
7-// this succeeds.
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+// transitionSourcePreflightLocked validates an initiating connection while its
10+// effect gate is frozen and d.mu is held. No target is created before this
11+// succeeds.
812 func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest) bool {
913 if req.sourceToken == nil || req.source == nil || req.source.core() == nil || d.sessions[req.source.core().id] != req.source {
1014 return false
@@ -14,19 +18,18 @@ func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest
1418 req.source.core().mu.Lock()
1519 defer req.source.core().mu.Unlock()
1620 coordinator := req.source.core().coordinator.Load()
17- if coordinator == nil {
18- return false
21+ if coordinator != nil {
22+ coordinator.mu.Lock()
23+ defer coordinator.mu.Unlock()
1924 }
20- coordinator.mu.Lock()
21- defer coordinator.mu.Unlock()
2225 if !transitionSourceTokenCurrentLocked(*req.sourceToken, req.source, coordinator, req) {
2326 return false
2427 }
2528 return transitionSourceTabCurrentLocked(req.source, req.expectedSourceTab)
2629 }
2730
28-func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool {
29- if token.sess == nil || token.ac == nil || token.role != attachmentActive {
31+func (d *Daemon) sourceAttachmentTokenCurrentFrozen(token attachmentConnectionToken) bool {
32+ if token.sess == nil || token.ac == nil {
3033 return false
3134 }
3235 d.mu.Lock()
@@ -39,15 +42,11 @@ func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool {
3942 token.sess.core().mu.Lock()
4043 defer token.sess.core().mu.Unlock()
4144 coordinator := token.sess.core().coordinator.Load()
42- if coordinator == nil {
43- return false
44- }
45- coordinator.mu.Lock()
46- defer coordinator.mu.Unlock()
47- req := attachmentTransitionRequest{
48- source: token.sess, next: token.ac, expectedRole: token.role,
49- expectedTransport: token.transport,
45+ if coordinator != nil {
46+ coordinator.mu.Lock()
47+ defer coordinator.mu.Unlock()
5048 }
49+ req := attachmentTransitionRequest{source: token.sess, next: token.ac, expectedTransport: token.transport}
5150 return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
5251 }
5352
@@ -60,7 +59,7 @@ func transitionSourceTabCurrentForRequestLocked(source attachmentSession, expect
6059 return true
6160 }
6261 local, ok := localSession(source)
63- if !ok || local.active < 0 || local.active >= len(local.tabs) || local.tabs[local.active] != expected {
62+ if !ok || !containsTab(local.tabs, expected) {
6463 return false
6564 }
6665 expected.mu.Lock()
@@ -68,28 +67,25 @@ func transitionSourceTabCurrentForRequestLocked(source attachmentSession, expect
6867 return transferExpected || expected.floating.state != floatingVisible
6968 }
7069
70+func containsTab(tabs []*tab, want *tab) bool {
71+ return slices.Contains(tabs, want)
72+}
73+
7174 // transitionAttachmentLocked is the publication half of transitionAttachment.
72-// Caller holds d.mu after the lock-free gate freeze/drain has completed.
75+// Caller holds d.mu after the lock-free effect freeze/drain.
7376 func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
7477 d.notices.routingMu.Lock()
7578 defer d.notices.routingMu.Unlock()
7679 return d.transitionAttachmentRoutedLocked(req)
7780 }
7881
79-// attachmentPublication is the validated, coordinator-locked input to the
80-// non-failing ownership and lease publication step. Its caller continues to
81-// own d.mu, notices.routingMu, and the ordered source/target session locks.
8282 type attachmentPublication struct {
8383 req attachmentTransitionRequest
8484 source attachmentSession
85- old *attachedClient
86- sourceCoordinator *renderCoordinator
8785 targetCoordinator *renderCoordinator
86+ sourceCoordinator *renderCoordinator
8887 releaseCoordinators func()
89- displacedTransport transportSnapshot
90- terminalDisplaced bool
9188 nextGeneration uint64
92- displacedGeneration uint64
9389 }
9490
9591 func (p *attachmentPublication) unlockCoordinators() {
@@ -114,11 +110,9 @@ func attachmentLifecycleCurrentLocked(entry attachmentSession, fence *attachment
114110 domain.TabStableID(target.tabs[fence.tabIndex].stableID) == fence.tabID
115111 }
116112
117-// validateAttachmentTransitionPrelocked performs every fallible transition
118-// check without acquiring d.mu, notices.routingMu, or either session lock.
119-// The caller holds those locks in that order, with session locks ordered by ID.
120-// On success the returned validation retains the ordered coordinator locks
121-// through publishAttachmentTransitionPrelocked.
113+// validateAttachmentTransitionPrelocked performs every fallible check before
114+// the non-failing membership and lease publication. Caller holds d.mu,
115+// notices.routingMu, and ordered source/target session locks.
122116 func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionRequest) (*attachmentPublication, error) {
123117 source := req.source
124118 if source == nil {
@@ -129,56 +123,60 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
129123 }
130124 sourceCore := source.core()
131125 targetCore := req.target.core()
132- if sourceCore == nil || targetCore == nil ||
133- !req.preflighted || !req.roleEffectsFrozen ||
134- !validAttachmentTransitionRole(req.expectedRole, true) || !validAttachmentTransitionRole(req.targetRole, false) ||
135- req.next == nil || d.closing ||
136- d.sessions[sourceCore.id] != source || d.sessions[targetCore.id] != req.target ||
126+ if sourceCore == nil || targetCore == nil || !req.preflighted || !req.attachmentEffectsFrozen ||
127+ req.next == nil || d.closing || d.sessions[sourceCore.id] != source || d.sessions[targetCore.id] != req.target ||
137128 req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) ||
138- req.preserveRole && req.sourceToken == nil {
129+ req.preserveAttachment && req.sourceToken == nil {
139130 return nil, errAttachmentTransition
140131 }
141132
142- var targetCoordinator *renderCoordinator
143- if req.targetRole == attachmentActive {
144- targetCoordinator = d.ensureAttachmentRenderCoordinatorPrelocked(req.target)
145- }
146133 sourceCoordinator := sourceCore.coordinator.Load()
147- if attachmentSessionRoleLocked(source, req.next) != req.expectedRole ||
148- !req.next.transportSnapshotCurrent(req.expectedTransport) {
134+ targetCoordinator := d.ensureAttachmentRenderCoordinatorPrelocked(req.target)
135+ // transitionSourceTokenCurrentLocked also reads the source lease. Hold the
136+ // source coordinator lock before calling it, following session -> coordinator
137+ // ordering and preventing a lease rebind from racing validation.
138+ releaseCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator)
139+ if d.afterAttachmentTransitionCoordinatorsLocked != nil {
140+ d.afterAttachmentTransitionCoordinatorsLocked()
141+ }
142+ if req.sourceToken != nil && !transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) {
143+ releaseCoordinators()
149144 return nil, errAttachmentTransition
150145 }
151-
152- old := targetCore.client
153- if old != req.expectedTargetCurrent || source != req.target && old == req.next {
146+ if source != req.target && !attachmentRegisteredLocked(source, req.next) {
147+ releaseCoordinators()
148+ return nil, errAttachmentTransition
149+ }
150+ if !req.preserveAttachment && source != req.target && attachmentRegisteredLocked(req.target, req.next) {
151+ releaseCoordinators()
152+ return nil, errAttachmentTransition
153+ }
154+ if req.preserveAttachment && !attachmentRegisteredLocked(req.target, req.next) {
155+ releaseCoordinators()
154156 return nil, errAttachmentTransition
155157 }
158+ if !attachmentLifecycleCurrentLocked(req.target, req.expectedTargetLifecycle) ||
159+ req.activateTargetTab && !req.target.validTargetTabLocked(req.targetTabIndex) ||
160+ req.sourceToken != nil && !transitionSourceTabCurrentForRequestLocked(source, req.expectedSourceTab, req.transferExpectedSourceTab) {
161+ releaseCoordinators()
162+ return nil, errAttachmentTransition
163+ }
164+
156165 publication := &attachmentPublication{
157- req: req, source: source, old: old,
166+ req: req, source: source,
158167 sourceCoordinator: sourceCoordinator, targetCoordinator: targetCoordinator,
159- releaseCoordinators: lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator),
160- }
161- invalid := req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) ||
162- !transitionSourceTabCurrentForRequestLocked(source, req.expectedSourceTab, req.transferExpectedSourceTab))
163- invalid = invalid || targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next)
164- invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil &&
165- (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next)
166- invalid = invalid || !attachmentLifecycleCurrentLocked(req.target, req.expectedTargetLifecycle)
167- invalid = invalid || req.activateTargetTab && !req.target.activateTargetLocked(req.targetTabIndex)
168- if invalid {
169- publication.unlockCoordinators()
170- return nil, errAttachmentTransition
168+ releaseCoordinators: releaseCoordinators,
171169 }
172170 return publication, nil
173171 }
174172
175-// applyTargetStateLocked commits navigation metadata before role ownership, as
176-// part of the same locked publication. It reports role-preserving transitions.
173+// applyTargetStateLocked commits attachment-local navigation metadata and
174+// shared environment state as part of the same publication.
177175 func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool {
178176 req := publication.req
179- if req.targetRole == attachmentActive {
177+ if req.activateTargetTab {
180178 if target, ok := localSession(req.target); ok {
181- d.demoteParkedActiveForSessionLocked(target)
179+ target.activateAttachmentViewLocked(req.next, req.targetTabIndex)
182180 }
183181 }
184182 if req.copySourceEnvironment {
@@ -189,113 +187,58 @@ func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool
189187 target.env = copyEnvironment(source.env)
190188 }
191189 }
192- return req.preserveRole
190+ return req.preserveAttachment
193191 }
194192
195-// publishAttachmentOwnershipLocked atomically changes session membership,
196-// generations, and currentSession while all role gates remain frozen.
193+// publishAttachmentOwnershipLocked adds/removes only the exact attachment.
194+// Existing target attachments and their independent views/transports remain
195+// untouched.
197196 func publishAttachmentOwnershipLocked(publication *attachmentPublication) {
198197 req := publication.req
199- if req.targetRole == attachmentActive && publication.old != nil && publication.old != req.next {
200- publication.displacedTransport = publication.old.transportSnapshot()
201- // A proxied daemon attachment has no local snatched UI and must never
202- // linger in that role. Replacing it is terminal so its owning local proxy
203- // receives ReasonReplaced and cannot kick the direct user back later.
204- publication.terminalDisplaced = publication.old.proxied
205- }
206198 if publication.source != req.target {
207- if publication.source.core().client == req.next {
208- publication.source.core().client = nil
209- }
210- delete(publication.source.core().snatched, req.next)
211- }
212- if req.targetRole == attachmentActive {
213- delete(req.target.core().snatched, req.next)
214- if publication.old != nil && publication.old != req.next {
215- if publication.terminalDisplaced {
216- delete(req.target.core().snatched, publication.old)
217- } else {
218- addSnatchedLocked(req.target, publication.old)
219- }
220- }
221- req.target.core().client = req.next
222- } else {
223- addSnatchedLocked(req.target, req.next)
224- }
225- publication.nextGeneration = req.next.roleGeneration.Add(1)
226- if publication.old != nil && publication.old != req.next && req.targetRole == attachmentActive {
227- publication.displacedGeneration = publication.old.roleGeneration.Add(1)
228- if publication.terminalDisplaced {
229- publication.old.setSession(nil)
230- publication.old.invalidateFrozenRoleCapability()
231- }
199+ unregisterAttachmentSessionLocked(publication.source, req.next)
232200 }
201+ registerAttachmentSessionLocked(req.target, req.next)
202+ publication.nextGeneration = req.next.connectionGeneration.Add(1)
233203 req.next.setSession(req.target)
234204 }
235205
236-// buildAttachmentPostcommitPlanLocked binds coordinator lifecycle cleanup and
237-// constructs the exact published tokens after ownership has committed.
238206 func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) attachmentTransitionResult {
239207 req := publication.req
240208 result := attachmentTransitionResult{}
241- if publication.source != req.target && req.expectedRole == attachmentActive && publication.sourceCoordinator != nil {
209+ if publication.source != req.target && publication.sourceCoordinator != nil {
242210 result.cleanups = append(result.cleanups, publication.sourceCoordinator.beginDetachLocked(req.next))
243211 }
244212 var lease *attachmentLease
245- if publication.targetCoordinator != nil {
246- var cleanup renderLifecycleCleanup
247- cleanup, lease = publication.targetCoordinator.beginReplaceLocked(publication.old, req.next, req.ready)
248- result.cleanups = append(result.cleanups, cleanup)
249- }
250- result.published = attachmentRoleToken{
251- sess: req.target,
252- ac: req.next,
253- role: req.targetRole,
254- generation: publication.nextGeneration,
255- transport: req.expectedTransport,
256- lease: lease,
257- rebase: publication.source != req.target && req.expectedRole == attachmentActive,
258- }
259- if publication.displacedGeneration != 0 {
260- if publication.terminalDisplaced {
261- result.terminalDisplaced = detachedAttachmentSnapshot{ac: publication.old, transport: publication.displacedTransport}
213+ if publication.targetCoordinator != nil && !req.preserveAttachment {
214+ if publication.source == req.target {
215+ lease = publication.targetCoordinator.rebindAttachmentWithReadinessLocked(req.next, req.ready)
262216 } else {
263- result.displaced = attachmentRoleToken{
264- sess: req.target,
265- ac: publication.old,
266- role: attachmentSnatched,
267- generation: publication.displacedGeneration,
268- transport: publication.displacedTransport,
269- }
270- result.displacedInterrupted = req.expectedTargetTransportInterrupted
217+ lease = publication.targetCoordinator.attachWithReadinessLocked(req.next, req.ready)
271218 }
272219 }
273- // Membership, currentSession, lease, generation, and both exact admission
274- // capabilities become visible as one frozen publication. The gates remain
275- // frozen until every architecture lock has been released.
276- if req.roleEffectsFrozen {
277- req.next.publishFrozenRoleCapability(result.published)
278- if result.displaced.ac != nil {
279- result.displaced.ac.publishFrozenRoleCapability(result.displaced)
280- }
220+ result.published = attachmentConnectionToken{
221+ sess: req.target, ac: req.next,
222+ generation: publication.nextGeneration,
223+ transport: req.expectedTransport, lease: lease,
224+ rebase: publication.source != req.target,
225+ }
226+ if req.attachmentEffectsFrozen {
227+ req.next.publishFrozenAttachmentCapability(result.published)
281228 }
282229 return result
283230 }
284231
285-// publishAttachmentTransitionPrelocked performs only non-failing metadata,
286-// ownership, generation, and coordinator-lease publication. The caller retains
287-// every lock required by validateAttachmentTransitionPrelocked.
288232 func (d *Daemon) publishAttachmentTransitionPrelocked(publication *attachmentPublication) attachmentTransitionResult {
289- if d.applyTargetStateLocked(publication) {
233+ if publication.req.preserveAttachment {
234+ d.applyTargetStateLocked(publication)
290235 return attachmentTransitionResult{published: *publication.req.sourceToken}
291236 }
292237 publishAttachmentOwnershipLocked(publication)
238+ d.applyTargetStateLocked(publication)
293239 return buildAttachmentPostcommitPlanLocked(publication)
294240 }
295241
296-// transitionAttachmentRoutedLocked is the ordinary lock-acquiring wrapper for
297-// the composable prelocked validation/publication seam. Caller holds d.mu and
298-// notices.routingMu; this function acquires ordered source/target session locks.
299242 func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
300243 source := req.source
301244 if source == nil {
@@ -315,15 +258,7 @@ func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionReques
315258 return d.publishAttachmentTransitionPrelocked(publication), nil
316259 }
317260
318-func validAttachmentTransitionRole(role attachmentRole, expected bool) bool {
319- if role == attachmentActive || role == attachmentSnatched {
320- return true
321- }
322- return expected && role == attachmentDetached
323-}
324-
325261 // lockAttachmentSessions gives every two-session transition one stable order.
326-// Session IDs are immutable and unique while their lifecycles are registered.
327262 func lockAttachmentSessions(a, b attachmentSession) func() {
328263 aCore := attachmentSessionCore(a)
329264 bCore := attachmentSessionCore(b)
@@ -347,7 +282,7 @@ func lockAttachmentSessions(a, b attachmentSession) func() {
347282 }
348283
349284 // lockAttachmentCoordinators follows the same immutable session-ID order as
350-// lockAttachmentSessions. Callers already hold the ordered session locks.
285+// lockAttachmentSessions. Callers already hold ordered session locks.
351286 func lockAttachmentCoordinators(a attachmentSession, aCoordinator *renderCoordinator, b attachmentSession, bCoordinator *renderCoordinator) func() {
352287 if aCoordinator == nil && bCoordinator == nil {
353288 return func() {}
M internal/usecase/daemon/attention.go +16 -15
@@ -23,13 +23,16 @@ func (d *Daemon) noteAttention(sess *session, tb *tab) {
2323 }
2424
2525 sess.mu.Lock()
26- visible := sess.client != nil && sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
2726 if !tb.attention {
2827 tb.attentionAt = now
2928 }
3029 tb.attention = true
31- if visible {
32- tb.attentionVisiblePaint = true
30+ for _, attachment := range sess.snapshotAttachmentsLocked() {
31+ view := attachment.viewSnapshot()
32+ if domain.TabStableID(tb.stableID) == view.tabID {
33+ tb.attentionVisiblePaint = true
34+ break
35+ }
3336 }
3437 sess.mu.Unlock()
3538
@@ -47,16 +50,16 @@ func (d *Daemon) noteAttention(sess *session, tb *tab) {
4750 // happened — it also covers "no target exists", which is routine and not an
4851 // error. Only a failure to reach a target that does exist is a genuine error.
4952 func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error {
50- return d.jumpAttentionForRole(sess, ac, attachmentRoleToken{})
53+ return d.jumpAttentionForAttachment(sess, ac, attachmentConnectionToken{})
5154 }
5255
53-func (d *Daemon) jumpAttentionForRole(sess *session, ac *attachedClient, token attachmentRoleToken) error {
56+func (d *Daemon) jumpAttentionForAttachment(sess *session, ac *attachedClient, token attachmentConnectionToken) error {
5457 if sess == nil || ac == nil {
5558 return nil
5659 }
5760 if idx, ok := oldestAttentionTab(sess); ok {
58- if sess.switchTab(idx) {
59- d.activateTab(sess, sess.activeTab())
61+ if sess.switchAttachmentTab(ac, idx) {
62+ d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(ac), false, ac, token.lease)
6063 d.invalidateRender(sess, ac, true, "attention.go")
6164 }
6265 return nil
@@ -70,7 +73,7 @@ func (d *Daemon) jumpAttentionForRole(sess *session, ac *attachedClient, token a
7073 if token.ac == nil {
7174 return d.switchToTarget(sess, ac, pickerTarget)
7275 }
73- return d.switchActiveTargetForRole(token, pickerTarget)
76+ return d.switchActiveTargetForAttachment(token, pickerTarget)
7477 }
7578
7679 func oldestAttentionTab(sess *session) (int, bool) {
@@ -127,10 +130,7 @@ func oldestAttentionTabWithTime(sess *session) (int, time.Time, bool) {
127130 }
128131
129132 func (d *Daemon) repaintAttachedClients(sess *session) {
130- sess.mu.Lock()
131- ac := sess.client
132- sess.mu.Unlock()
133- if ac != nil {
133+ for _, ac := range sess.snapshotAttachments() {
134134 d.invalidateRender(sess, ac, false, "attention.go")
135135 }
136136 }
@@ -145,13 +145,14 @@ func (d *Daemon) repaintAllAttachedClients() {
145145 }
146146 }
147147
148-func (s *session) ackAttention(tb *tab, visible bool) bool {
149- if tb == nil {
148+func (s *session) ackAttention(tb *tab, ac *attachedClient, visible bool) bool {
149+ if tb == nil || ac == nil {
150150 return false
151151 }
152152 s.mu.Lock()
153153 defer s.mu.Unlock()
154- if s.active < 0 || s.active >= len(s.tabs) || s.tabs[s.active] != tb || !tb.attention {
154+ view := ac.viewSnapshot()
155+ if domain.TabStableID(tb.stableID) != view.tabID || !tb.attention {
155156 return false
156157 }
157158 if tb.attentionVisiblePaint && !visible {
M internal/usecase/daemon/attention_test.go +27 -27
@@ -23,7 +23,7 @@ func TestNoteAttentionFlagsBackgroundTab(t *testing.T) {
2323 d, sess, _, _, releases := newManualTabSession(t, 2)
2424 defer releases[0]()
2525 defer releases[1]()
26- sess.active = 0
26+ selectTestAttachmentTab(sess, 0)
2727
2828 d.noteAttention(sess, sess.tabs[1])
2929
@@ -37,7 +37,7 @@ func TestNoteAttentionFlagsBackgroundTab(t *testing.T) {
3737 func TestNoteAttentionFlagsVisibleTabUntilPainted(t *testing.T) {
3838 d, sess, _, _, releases := newManualTabSession(t, 1)
3939 defer releases[0]()
40- sess.active = 0
40+ selectTestAttachmentTab(sess, 0)
4141
4242 d.noteAttention(sess, sess.tabs[0])
4343
@@ -50,7 +50,7 @@ func TestNoteAttentionFlagsVisibleTabUntilPainted(t *testing.T) {
5050 func TestPaintDoesNotAckActiveAttentionOnBlankPulseFrame(t *testing.T) {
5151 d, sess, ac, sends, releases := newManualTabSession(t, 1)
5252 defer releases[0]()
53- sess.active = 0
53+ selectTestAttachmentTab(sess, 0)
5454 d.setAttentionFrame(0)
5555 d.noteAttention(sess, sess.tabs[0])
5656
@@ -86,7 +86,7 @@ func TestPTYReaderActiveVisibleAgentNotificationRendersBellBeforeAck(t *testing.
8686 t.Run(tt.name, func(t *testing.T) {
8787 pty := newScriptPTY([][]byte{tt.data})
8888 d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
89- sess.active = 0
89+ selectTestAttachmentTab(sess, 0)
9090 d.setAttentionFrame(1)
9191
9292 d.sessWg.Add(1)
@@ -119,7 +119,7 @@ func TestPTYReaderActiveVisibleAgentNotificationRendersBellBeforeAck(t *testing.
119119 func TestNoteAttentionFlagsDetachedActiveTab(t *testing.T) {
120120 d, sess, ac, _, releases := newManualTabSession(t, 1)
121121 defer releases[0]()
122- sess.active = 0
122+ selectTestAttachmentTab(sess, 0)
123123 require.True(t, sess.detachIfCurrent(ac))
124124
125125 d.noteAttention(sess, sess.tabs[0])
@@ -135,7 +135,7 @@ func TestPTYReaderBellMarksBackgroundTab(t *testing.T) {
135135 p2 := newScriptPTY([][]byte{[]byte("\a")})
136136 d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
137137 defer releasePTY1()
138- sess.active = 0
138+ selectTestAttachmentTab(sess, 0)
139139
140140 d.sessWg.Add(1)
141141 go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
@@ -161,7 +161,7 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) {
161161 d, sess, _, _, releases := newManualTabSession(t, 2)
162162 defer releases[0]()
163163 defer releases[1]()
164- sess.active = 0
164+ selectTestAttachmentTab(sess, 0)
165165
166166 trW := portsmocks.NewMockTransport(t)
167167 block := make(chan struct{})
@@ -176,7 +176,7 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) {
176176 sctxW, cancelW := context.WithCancel(d.serveCtx)
177177 t.Cleanup(cancelW)
178178 tabW := newTestTabWithContext(newScriptPTY(nil), sctxW, cancelW)
179- sessW := &session{sessionCore: sessionCore{id: "wedged", name: "wedged", client: acW}, ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}}
179+ sessW := &session{sessionCore: sessionCore{id: "wedged", name: "wedged", attachments: map[*attachedClient]struct{}{acW: {}}}, ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}}
180180 acW.setSession(sessW)
181181 acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW}, nil)
182182 d.sessions[sessW.id] = sessW
@@ -235,7 +235,7 @@ func TestAckAttentionClearsOnlyPaintedVisibleTab(t *testing.T) {
235235 d, sess, ac, sends, releases := newManualTabSession(t, 2)
236236 defer releases[0]()
237237 defer releases[1]()
238- sess.active = 0
238+ selectTestAttachmentTab(sess, 0)
239239 sess.mu.Lock()
240240 sess.tabs[0].attention = true
241241 sess.tabs[0].attentionAt = time.Unix(1, 0)
@@ -266,7 +266,7 @@ func TestPaintPreservesBackgroundAttention(t *testing.T) {
266266 d, sess, ac, sends, releases := newManualTabSession(t, 2)
267267 defer releases[0]()
268268 defer releases[1]()
269- sess.active = 0
269+ selectTestAttachmentTab(sess, 0)
270270 sess.mu.Lock()
271271 sess.tabs[1].attention = true
272272 sess.tabs[1].attentionAt = time.Unix(2, 0)
@@ -286,13 +286,13 @@ func TestSwitchTabClearsAttentionEndToEnd(t *testing.T) {
286286 d, sess, ac, sends, releases := newManualTabSession(t, 2)
287287 defer releases[0]()
288288 defer releases[1]()
289- sess.active = 0
289+ selectTestAttachmentTab(sess, 0)
290290 sess.mu.Lock()
291291 sess.tabs[1].attention = true
292292 sess.tabs[1].attentionAt = time.Unix(2, 0)
293293 sess.mu.Unlock()
294294
295- require.True(t, sess.switchTab(1))
295+ require.True(t, selectTestAttachmentTab(sess, 1))
296296 d.paint(sess, ac, true, nil)
297297 data := mustOutputData(t, sends)
298298 // As above: the deferred all-clients repaint has nothing new to say about
@@ -314,7 +314,7 @@ func TestCloseRingingTabClearsClientBar(t *testing.T) {
314314 d, sess, ac, sends, releases := newManualTabSession(t, 2)
315315 defer releases[0]()
316316 defer releases[1]()
317- sess.active = 0
317+ selectTestAttachmentTab(sess, 0)
318318 sess.mu.Lock()
319319 ringing := sess.tabs[1]
320320 ringing.attention = true
@@ -340,7 +340,7 @@ func TestAnimationRepaintConfinedToBarRows(t *testing.T) {
340340 d, sess, ac, sends, releases := newManualTabSession(t, 2)
341341 defer releases[0]()
342342 defer releases[1]()
343- sess.active = 0
343+ selectTestAttachmentTab(sess, 0)
344344 sess.tabs[0].focusedPane().screen.Write([]byte("MIDSCREENMARKER"))
345345 sess.mu.Lock()
346346 sess.tabs[1].attention = true
@@ -374,7 +374,7 @@ func TestComposeFrameAttentionFrameChangeDamagesOnlyBars(t *testing.T) {
374374 d, sess, _, _, releases := newManualTabSession(t, 2)
375375 defer releases[0]()
376376 defer releases[1]()
377- sess.active = 0
377+ selectTestAttachmentTab(sess, 0)
378378 sess.mu.Lock()
379379 sess.tabs[1].attention = true
380380 sess.tabs[1].attentionAt = time.Unix(1, 0)
@@ -446,7 +446,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
446446 sctxB, cancelB := context.WithCancel(d.serveCtx)
447447 t.Cleanup(cancelB)
448448 tbB := newTestTabWithContext(pB, sctxB, cancelB)
449- sessB := &session{sessionCore: sessionCore{id: "sessB", name: "other", client: acB}, ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}}
449+ sessB := &session{sessionCore: sessionCore{id: "sessB", name: "other", attachments: map[*attachedClient]struct{}{acB: {}}}, ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}}
450450 acB.setSession(sessB)
451451 acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil)
452452 d.sessions[sessB.id] = sessB
@@ -465,13 +465,13 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
465465 func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) {
466466 d, sess, ac, sends, releases := newManualTabSession(t, 3)
467467 sess.mu.Lock()
468- sess.client = ac
468+ sess.registerAttachmentLocked(ac)
469469 sess.mu.Unlock()
470470 d.ptys = newBlockingOpenFactory(t, d)
471471 defer releases[0]()
472472 defer releases[1]()
473473 defer releases[2]()
474- sess.active = 0
474+ selectTestAttachmentTab(sess, 0)
475475 sess.mu.Lock()
476476 sess.tabs[1].attention = true
477477 sess.tabs[1].attentionAt = time.Unix(20, 0)
@@ -481,8 +481,8 @@ func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) {
481481
482482 d.handleInput(sess, ac, []byte("\x1ba"))
483483
484- require.Equal(t, 2, activeTabIndex(sess))
485- requireFloatingInitialized(t, sess.activeTab())
484+ require.Equal(t, 2, testAttachmentTabIndex(sess))
485+ requireFloatingInitialized(t, testAttachmentTab(sess))
486486 _ = mustOutputData(t, sends)
487487 }
488488
@@ -512,9 +512,9 @@ func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) {
512512 require.NoError(t, d.jumpAttention(sess1, ac))
513513
514514 require.Same(t, sess2, ac.currentSession())
515- require.Same(t, ac, sess2.client)
516- require.Nil(t, sess1.client)
517- require.Equal(t, 1, activeTabIndex(sess2))
515+ require.Contains(t, sess2.snapshotAttachments(), ac)
516+ require.Empty(t, sess1.snapshotAttachments())
517+ require.Equal(t, 1, testAttachmentTabIndex(sess2))
518518 _ = mustOutputData(t, sends)
519519 }
520520
@@ -522,12 +522,12 @@ func TestJumpAttentionNoopsWithNoBells(t *testing.T) {
522522 d, sess, ac, sends, releases := newManualTabSession(t, 2)
523523 defer releases[0]()
524524 defer releases[1]()
525- sess.active = 0
525+ selectTestAttachmentTab(sess, 0)
526526
527527 require.NoError(t, d.jumpAttention(sess, ac), "no target to jump to is not a failure")
528528
529529 require.Same(t, sess, ac.currentSession())
530- require.Equal(t, 0, activeTabIndex(sess))
530+ require.Equal(t, 0, testAttachmentTabIndex(sess))
531531 select {
532532 case f := <-sends:
533533 t.Fatalf("unexpected frame on no-op jump: %v", f.Type)
@@ -556,7 +556,7 @@ func TestJumpAttentionSwitchFailureReportsNotice(t *testing.T) {
556556 d.sessions[sess2.id] = sess2
557557
558558 sess1.mu.Lock()
559- sess1.client = nil
559+ clearAttachmentsForTestLocked(sess1)
560560 sess1.mu.Unlock()
561561
562562 err := d.jumpAttention(sess1, ac)
@@ -576,7 +576,7 @@ func TestAttentionAnimatorParksAdvancesAndResets(t *testing.T) {
576576 defer releases[1]()
577577 clk := newManualAttentionClock()
578578 d.clock = clk
579- sess.active = 0
579+ selectTestAttachmentTab(sess, 0)
580580
581581 ctx, cancel := context.WithCancel(context.Background())
582582 done := make(chan struct{})
M internal/usecase/daemon/bar_refresh.go +13 -11
@@ -122,15 +122,11 @@ func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScrip
122122 // slice under sess.mu (daemon.go, resume.go), and nothing mutates the
123123 // backing array in place, so capturing the header here is safe.
124124 env := sess.env
125- active := sess.active
126- var tb *tab
127- if active >= 0 && active < len(sess.tabs) {
128- tb = sess.tabs[active]
129- }
130- ac := sess.client
125+ attachments := sess.snapshotAttachmentsLocked()
131126 ctx.PaneCWD = sess.cwd
132127 sess.mu.Unlock()
133- if ac == nil || tb == nil {
128+ tb := sess.firstTab()
129+ if len(attachments) == 0 || tb == nil {
134130 return ctx, env, false
135131 }
136132 tb.mu.Lock()
@@ -413,11 +409,17 @@ func (d *Daemon) pokeSessionRender(sess *session) {
413409 if sess == nil {
414410 return
415411 }
416- sess.mu.Lock()
417- ac := sess.client
418- sess.mu.Unlock()
419412 // A headless session can still supply a picker preview to an attachment
420413 // owned by another session. Its coordinator fans this wake out to those
421414 // preview subscribers even though no primary attachment is bound.
422- d.invalidateRender(sess, ac, false, "bar_refresh.go")
415+ attachments := sess.snapshotAttachments()
416+ if len(attachments) == 0 {
417+ if rc := sess.renderCoordinator(); rc != nil {
418+ rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "bar_refresh.go"})
419+ }
420+ return
421+ }
422+ for _, ac := range attachments {
423+ d.invalidateRender(sess, ac, false, "bar_refresh.go")
424+ }
423425 }
M internal/usecase/daemon/bar_refresh_test.go +21 -22
@@ -64,7 +64,7 @@ func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T)
6464 r := &fakeBarRunner{outs: []string{"top1", "bot1", "top2", "bot2"}}
6565 d := newBarRefreshTestDaemon(r, 100*time.Millisecond)
6666 sess := newBarRefreshTestSession()
67- sess.client = &attachedClient{size: domain.Size{Cols: 120, Rows: 24}}
67+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 120, Rows: 24}})
6868
6969 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))
7070 waitBarRefreshIdle(t, d)
@@ -85,17 +85,16 @@ func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T)
8585 require.Equal(t, "bot2", state.bottomRight)
8686 }
8787
88-func TestBarScriptContextUsesActivePaneSessionAndClientCols(t *testing.T) {
88+func TestBarScriptContextUsesFirstOrderedPaneAndClientCols(t *testing.T) {
8989 r := &fakeBarRunner{outs: []string{"top", "bottom"}}
9090 d := newBarRefreshTestDaemon(r, time.Second)
9191 sess := newBarRefreshTestSession()
9292 sess.name = "work"
9393 sess.cwd = "/repo"
94- sess.client = &attachedClient{size: domain.Size{Cols: 132, Rows: 40}}
95- sess.active = 1
96- sess.tabs[1].size = domain.Size{Cols: 132, Rows: 38}
97- active := sess.tabs[1].focusedPane()
98- active.stableID = "pane-active"
94+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 132, Rows: 40}})
95+ sess.tabs[0].size = domain.Size{Cols: 132, Rows: 38}
96+ active := sess.tabs[0].focusedPane()
97+ active.stableID = "pane-default"
9998 active.pty = newScriptPTY(nil)
10099 d.procCwd = func(pid int) (string, error) {
101100 require.Equal(t, 4242, pid)
@@ -107,8 +106,8 @@ func TestBarScriptContextUsesActivePaneSessionAndClientCols(t *testing.T) {
107106 require.Len(t, r.calls, 2)
108107 for _, call := range r.calls {
109108 require.Equal(t, "work", call.Session)
110- require.Equal(t, "tab-active", call.Tab)
111- require.Equal(t, "pane-active", call.Pane)
109+ require.Equal(t, "tab-inactive", call.Tab)
110+ require.Equal(t, "pane-default", call.Pane)
112111 require.Equal(t, "/pane-repo", call.PaneCWD)
113112 require.Equal(t, 132, call.Cols)
114113 }
@@ -131,7 +130,7 @@ func TestBarScriptRefreshSkipsRunnerWhenCommandsDisabled(t *testing.T) {
131130 d.barScripts.cfg.topRight = ""
132131 d.barScripts.cfg.bottomRight = ""
133132 sess := newBarRefreshTestSession()
134- sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
133+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
135134 d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "old", bottomRight: "old"}
136135
137136 require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
@@ -146,7 +145,7 @@ func TestDefaultBarConfigDoesNotInvokeRunner(t *testing.T) {
146145 d := newBarRefreshTestDaemon(r, time.Second)
147146 d.barScripts.cfg = barConfigFromDomain(domain.Defaults().Bar)
148147 sess := newBarRefreshTestSession()
149- sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
148+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
150149
151150 require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
152151 require.Empty(t, r.calls)
@@ -156,7 +155,7 @@ func TestBarScriptForcedRefreshRespectsMinimumInterval(t *testing.T) {
156155 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
157156 d := newBarRefreshTestDaemon(r, 5*time.Second)
158157 sess := newBarRefreshTestSession()
159- sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
158+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
160159
161160 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
162161 waitBarRefreshIdle(t, d)
@@ -173,7 +172,7 @@ func TestBarScriptContextChangeDebouncesUntilMinimumInterval(t *testing.T) {
173172 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
174173 d := newBarRefreshTestDaemon(r, 5*time.Second)
175174 sess := newBarRefreshTestSession()
176- sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
175+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
177176
178177 require.True(t, d.refreshBarScriptsIfDue(sess, time.Now(), false))
179178 waitBarRefreshIdle(t, d)
@@ -191,7 +190,7 @@ func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) {
191190 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
192191 d := newBarRefreshTestDaemon(r, 5*time.Second)
193192 sess := newBarRefreshTestSession()
194- sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
193+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
195194
196195 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))
197196 waitBarRefreshIdle(t, d)
@@ -219,7 +218,7 @@ func TestRefreshBarScriptsAllSessionsForcesRun(t *testing.T) {
219218 r := &fakeBarRunner{outs: []string{"top1", "bottom1"}}
220219 d := newBarRefreshTestDaemon(r, 60*time.Second)
221220 sess := newBarRefreshTestSession()
222- sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
221+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
223222 d.sessions = map[domain.SessionID]attachmentSession{sess.id: sess}
224223
225224 // Verifies refreshBarScriptsAllSessions iterates every live session and
@@ -252,7 +251,7 @@ func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) {
252251 d := newBarRefreshTestDaemon(r, time.Second)
253252 d.log = slog.New(slog.NewTextHandler(&logs, nil))
254253 sess := newBarRefreshTestSession()
255- sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
254+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
256255
257256 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
258257 waitBarRefreshIdle(t, d)
@@ -287,7 +286,7 @@ func TestBarScriptRunConsumesPendingAfterRunningClears(t *testing.T) {
287286 r := newBlockingBarRunner([]string{"top1", "bottom1", "top2", "bottom2"})
288287 d := newBarRefreshTestDaemon(r, time.Second)
289288 sess := newBarRefreshTestSession()
290- sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
289+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
291290
292291 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
293292 select {
@@ -337,7 +336,7 @@ func TestBarScriptFailureLogsOnceUntilSignatureChanges(t *testing.T) {
337336 d.log = logger
338337 d.baseEnv = []string{"PATH=/usr/bin"}
339338 sess := newBarRefreshTestSession()
340- sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
339+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
341340
342341 readBuf := func() string {
343342 mu.Lock()
@@ -420,11 +419,11 @@ func TestBarScriptRefreshIsPerSession(t *testing.T) {
420419 a := newBarRefreshTestSession()
421420 a.id = "a"
422421 a.name = "a"
423- a.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
422+ a.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
424423 b := newBarRefreshTestSession()
425424 b.id = "b"
426425 b.name = "b"
427- b.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
426+ b.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
428427
429428 require.True(t, d.refreshBarScriptsIfDue(a, time.Unix(0, 0), false))
430429 waitBarRefreshIdle(t, d)
@@ -587,7 +586,7 @@ func newBarRefreshTestSession() *session {
587586 inactive := newTabWithStableID("tab-inactive", "pane-inactive", nil, domain.Size{Cols: 80, Rows: 22})
588587 active := newTabWithStableID("tab-active", "pane-active", nil, domain.Size{Cols: 80, Rows: 22})
589588 active.tree.Focus = layout.PaneID("pane-1")
590- return &session{sessionCore: sessionCore{id: "s", name: "s"}, cwd: "/tmp", tabs: []*tab{inactive, active}, active: 0, ctx: context.Background()}
589+ return &session{sessionCore: sessionCore{id: "s", name: "s"}, cwd: "/tmp", tabs: []*tab{inactive, active}, ctx: context.Background()}
591590 }
592591
593592 func waitBarRefreshIdle(t *testing.T, d *Daemon) {
@@ -645,7 +644,7 @@ func TestBarScriptsUseSessionEnvNotDaemonBaseEnv(t *testing.T) {
645644 d.baseEnv = tc.baseEnv
646645 sess := newBarRefreshTestSession()
647646 sess.env = tc.sessEnv
648- sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
647+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
649648
650649 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
651650 waitBarRefreshIdle(t, d)
M internal/usecase/daemon/client.go +109 -98
@@ -42,15 +42,19 @@ type attachedClient struct {
4242 overlays *overlayRuntime
4343 overlayOnce sync.Once
4444 clientID [16]byte
45- // roleGeneration is the wire-facing capability generation. roleEffects is
46- // the linearization gate for every role-bound observable operation. A role
45+ // connectionGeneration is the wire-facing capability generation. attachmentEffects is
46+ // the linearization gate for every attachment-bound observable operation. An attachment
4747 // transition freezes and drains this gate before changing either generation
4848 // or the session/coordinator registries.
49- roleGeneration atomic.Uint64
50- roleEffects roleEffectGate
51- resumeCapable bool
52- resumeToken uint64
53- parked bool
49+ connectionGeneration atomic.Uint64
50+ attachmentEffects attachmentEffectGate
51+ resumeCapable bool
52+ resumeToken uint64
53+ // resumeClaimToken is non-zero only between a parked resume claim and its
54+ // successful Welcome. It lets a failed pre-claim handshake restore the old
55+ // credential instead of consuming it.
56+ resumeClaimToken uint64
57+ parked bool
5458 // proxied is negotiated once by Hello and remains immutable for this
5559 // attachment, including across transport resume.
5660 proxied bool
@@ -75,33 +79,28 @@ type attachedClient struct {
7579 // captureFrames is keyed by pane ownership, not the tab-local PaneID, so
7680 // snapshots cannot leak when an attachment switches tabs or sessions.
7781 captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held
78- // initialSnatchedMu elects exactly one reset-panel sender per role generation.
79- // A Welcome handshake and displaced-client cleanup may both discover the
80- // same post-transition snatched role; the loser waits for the elected send.
81- initialSnatchedMu sync.Mutex
82- initialSnatchedGeneration uint64
83- initialSnatchedAttempt *initialSnatchedPanelAttempt
84- size domain.Size
85- keys *keys.Router
86- sess Guarded[attachmentSession]
87- mouseScan mouse.Scanner
88- themeMu sync.Mutex
89- clientTheme themeui.Theme
90- appliedTheme appliedTheme
91- lastCursor cursorOut
92- renderStages renderStageHooks // optional render and handoff observability hooks
82+ size domain.Size
83+ keys *keys.Router
84+ // view is attachment-local navigation state. It is never inferred from a
85+ // session-wide active tab, so multiple attachments can observe different
86+ // tabs and panes without changing shared session ownership.
87+ viewMu sync.Mutex
88+ view attachmentView
89+ sess Guarded[attachmentSession]
90+ mouseScan mouse.Scanner
91+ themeMu sync.Mutex
92+ clientTheme themeui.Theme
93+ appliedTheme appliedTheme
94+ lastCursor cursorOut
95+ renderStages renderStageHooks // optional render and handoff observability hooks
9396 // previousSession is guarded independently. It is retained through temporary
9497 // setSession(nil) hand-offs and cleared only on terminal teardown.
9598 previousSession Guarded[attachmentSession]
96- // snatchedInputMu serializes the restricted input parser with its delayed
97- // standalone-ESC callback. It is independent of routing and transport locks;
98- // role activation and terminal close clear it only after releasing those locks.
99- snatchedInputMu sync.Mutex
100- snatchedInputPending []byte
101- snatchedInputDrain bool
102- snatchedInputESC pendingByteTimer
103- linkMu sync.Mutex
104- sendMu sync.Mutex
99+ linkMu sync.Mutex
100+ sendMu sync.Mutex
101+ // beforeAttachmentTokenValidation is a deterministic lifecycle-race seam
102+ // used only by package tests.
103+ beforeAttachmentTokenValidation func()
105104 }
106105
107106 type cursorOut struct {
@@ -243,7 +242,7 @@ func (ac *attachedClient) replaceTransport(tr ports.Transport) {
243242
244243 // revokeTransport removes tr only if it is still this attachment's current
245244 // link. It returns the revoked transport, so asynchronous retirement can
246-// close exactly that link without touching a later resume/replacement.
245+// close exactly that link without touching a later connection incarnation.
247246 func (ac *attachedClient) revokeTransport(tr ports.Transport) ports.Transport {
248247 if tr == nil {
249248 return nil
@@ -256,7 +255,6 @@ func (ac *attachedClient) revokeTransport(tr ports.Transport) ports.Transport {
256255 ac.tr = nil
257256 ac.transportIncarnation++
258257 ac.linkMu.Unlock()
259- ac.clearSnatchedInput()
260258 return tr
261259 }
262260
@@ -357,7 +355,7 @@ func (ac *attachedClient) sendExpectedTransport(expected transportSnapshot, f po
357355 // attachment's current transport incarnation and admits ticket's interruptible
358356 // transport send. The caller must already hold ac.sendMu and, on success, owns
359357 // both the send and its matching ticket.endTransportSend.
360-func (ac *attachedClient) beginExpectedTransportSendLocked(expected transportSnapshot, ticket *roleEffectTicket) error {
358+func (ac *attachedClient) beginExpectedTransportSendLocked(expected transportSnapshot, ticket *attachmentEffectTicket) error {
361359 if expected.transport == nil || !ac.transportSnapshotCurrent(expected) {
362360 return errTransportReplaced
363361 }
@@ -367,10 +365,10 @@ func (ac *attachedClient) beginExpectedTransportSendLocked(expected transportSna
367365 return nil
368366 }
369367
370-func (ac *attachedClient) sendExpectedTransportForRole(expected transportSnapshot, f ports.Frame, ticket *roleEffectTicket) error {
368+func (ac *attachedClient) sendExpectedTransportForAttachment(expected transportSnapshot, f ports.Frame, ticket *attachmentEffectTicket) error {
371369 ac.sendMu.Lock()
372370 defer ac.sendMu.Unlock()
373- // A role-bound send requires a live ticket, and reports every rejection,
371+ // A attachment-bound send requires a live ticket, and reports every rejection,
374372 // including a replaced transport, as a lost attachment transition.
375373 if ticket == nil {
376374 return errAttachmentTransition
@@ -528,15 +526,14 @@ type attachClientOptions struct {
528526 proxied bool
529527 }
530528
531-func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient, error) {
529+func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {
532530 d.mu.Lock()
533531 ac := d.prepareAttachedClientLocked(tr, sz, opts)
534532 d.mu.Unlock()
535533 result, err := d.transitionAttachment(attachmentTransitionRequest{
536- target: sess,
537- next: ac,
538- expectedRole: attachmentDetached,
539- targetRole: attachmentActive,
534+ target: sess,
535+ next: ac,
536+
540537 expectedTransport: ac.transportSnapshot(),
541538 ready: false,
542539 })
@@ -544,11 +541,11 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
544541 if tr != nil {
545542 _ = tr.Close()
546543 }
547- return nil, nil, err
544+ return nil, err
548545 }
549546 d.finishAttachedClient(sess, ac, opts)
550547 d.deferAttachmentTransitionCleanups(result)
551- return ac, result.displaced.ac, nil
548+ return ac, nil
552549 }
553550
554551 func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) {
@@ -558,7 +555,7 @@ func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts at
558555 }
559556
560557 // prepareAttachedClientLocked allocates one detached attachment. Caller holds
561-// d.mu only to allocate its resume token; role publication happens later after
558+// d.mu only to allocate its resume token; attachment publication happens later after
562559 // the caller releases every architecture lock.
563560 func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size, opts attachClientOptions) *attachedClient {
564561 resumeToken := uint64(0)
@@ -583,24 +580,21 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size,
583580 return ac
584581 }
585582
586-// attachCoordinator is the sole direct attachment handoff. It creates at
587-// most one coordinator for sess and changes its identity before any caller
588-// can publish resize or render state for the new client.
589-func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient, ready bool) *renderCoordinator {
590- rc, cleanup := d.attachCoordinatorDeferred(sess, old, current, ready)
583+// attachCoordinator installs the coordinator lease for an attachment. Session
584+// membership remains independent, so installing one attachment never displaces
585+// another.
586+func (d *Daemon) attachCoordinator(sess *session, current *attachedClient, ready bool) *renderCoordinator {
587+ rc, cleanup := d.attachCoordinatorDeferred(sess, current, ready)
591588 cleanup.finish()
592589 return rc
593590 }
594591
595-func (d *Daemon) attachCoordinatorDeferred(sess *session, old, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) {
592+func (d *Daemon) attachCoordinatorDeferred(sess *session, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) {
596593 rc := d.ensureRenderCoordinator(sess)
597- var cleanup renderLifecycleCleanup
598- if old != nil {
599- cleanup = rc.beginReplace(old, current, ready)
600- } else if current != nil {
594+ if current != nil {
601595 rc.attachWithReadiness(current, ready)
602596 }
603- return rc, cleanup
597+ return rc, renderLifecycleCleanup{}
604598 }
605599
606600 // ensureRenderCoordinator publishes the session's single coordinator without
@@ -630,25 +624,42 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess
630624 clock: d.clock,
631625 observer: d.runtimeObserver,
632626 wake: func(w renderWake) {
633- // The wake retains the exact attachment lease. paint repeats this
634- // identity check under sendMu before composing any snapshot.
635- if w.lease != nil && rc.wakeCurrent(w) {
636- d.paint(entry, w.lease.attachment, w.reset, w.lease)
627+ if !rc.wakeCurrent(w) {
628+ return
637629 }
638- },
639- ackReady: func() bool {
640- entry.core().mu.Lock()
641- attached := entry.core().client
642- entry.core().mu.Unlock()
643- if attached == nil {
644- return false
630+ // The session snapshot is deterministic. Each paint revalidates the
631+ // attachment's membership and transport fence after that snapshot, so
632+ // a detached peer cannot stop remaining attachments receiving output.
633+ attachments := snapshotAttachmentSession(entry)
634+ for _, attachment := range attachments {
635+ if !rc.wakeCurrent(w) {
636+ return
637+ }
638+ lease := w.attachmentLeases[attachment]
639+ if lease == nil || !rc.leaseCurrent(lease, true) {
640+ continue
641+ }
642+ // Pane damage is session-shared; every fan-out attachment needs a
643+ // complete frame because any peer may capture it first. A handshake
644+ // target is the one synchronous exception: its first paint must finish
645+ // before the handshake releases its lifecycle gate.
646+ fanoutReset := w.reset || len(attachments) > 1
647+ if w.lease != nil && w.lease.attachment == attachment {
648+ d.paint(entry, attachment, fanoutReset, lease)
649+ } else if len(attachments) > 1 {
650+ go d.paint(entry, attachment, fanoutReset, lease)
651+ } else {
652+ d.paint(entry, attachment, fanoutReset, lease)
653+ }
645654 }
646- attached.sendMu.Lock()
647- ready := attached.output == nil || !attached.output.atCapacity()
648- attached.sendMu.Unlock()
649- return ready
650655 },
651656 })
657+ rc.ackReadyFor = func(attached *attachedClient) bool {
658+ // outputStateStream publishes capacity atomically. Do not take
659+ // attached.sendMu here: a slow transport may be holding it for an
660+ // in-flight Send, and that peer must not gate healthy attachments.
661+ return attached == nil || attached.output == nil || !attached.output.atCapacity()
662+ }
652663 installAttachmentRenderCoordinator(entry, rc)
653664 return rc
654665 }
@@ -660,29 +671,29 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess
660671 // next client (with a different terminal theme) may never have reported.
661672 // Mirrors attachClient's reset loop: snapshot sess.tabs under sess.mu,
662673 // release it, then take each tb.mu in turn — never holding sess.mu and
663-// tb.mu together. Guarded against a race with a newer attach: if sess.client
664-// is non-nil by the time sess.mu is taken, a new client has already attached
665-// (and run its own attach-time reset), so this call must leave the tabs
666-// alone rather than clobbering that client's freshly applied colors.
674+// tb.mu together. Guarded against a race with a newer attachment: if the
675+// session has gained an attachment by the time sess.mu is taken, that
676+// attachment has already run its attach-time reset, so this call must leave
677+// the tabs alone rather than clobbering freshly applied colors.
667678 func (d *Daemon) resetScreenDefaultColors(sess *session) {
668679 d.applyHostTheme(sess, nil, themeui.Theme{}, true)
669680 }
670681
671682 // firstPaintForTransition is the only active post-transition paint entry
672-// point. It rejects a stale role capability before rebasing output and carries
683+// point. It rejects a stale attachment capability before rebasing output and carries
673684 // the exact coordinator lease through every resize and render effect.
674-func (d *Daemon) firstPaintForTransition(token attachmentRoleToken) bool {
675- if !token.activeCurrent() {
685+func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
686+ if !token.attachmentCurrent() {
676687 return false
677688 }
678- ticket, admitted := token.ac.beginRoleEffect(token)
689+ ticket, admitted := token.ac.beginAttachmentEffect(token)
679690 if !admitted {
680691 return false
681692 }
682693 token.effect = ticket
683- defer token.endRoleEffect()
684- if d.afterRoleEffectAdmitted != nil {
685- d.afterRoleEffectAdmitted(token)
694+ defer token.endAttachmentEffect()
695+ if d.afterAttachmentEffectAdmitted != nil {
696+ d.afterAttachmentEffectAdmitted(token)
686697 }
687698 if token.rebase {
688699 if d.beforeFirstPaintSendWait != nil {
@@ -713,7 +724,7 @@ func (d *Daemon) firstPaintForTransition(token attachmentRoleToken) bool {
713724 // firstPaint guarantees the freshly attached client sees the full screen: if
714725 // the tab size differs from the client's it resizes first, then immediately
715726 // emits a full redraw. It is retained for direct test/headless setup; active
716-// role transitions use firstPaintForTransition with their exact lease.
727+// attachment transitions use firstPaintForTransition with their exact lease.
717728 func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {
718729 d.firstPaintWithLease(sess, ac, clientSize, nil)
719730 }
@@ -723,7 +734,7 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi
723734 // Drained before the early return below so a session without an active tab
724735 // cannot swallow the queue.
725736 d.drainPendingForFirstPaint(sess, ac)
726- tb := sess.activeTab()
737+ tb := sess.tabForAttachment(ac)
727738 if tb == nil {
728739 return
729740 }
@@ -748,7 +759,7 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi
748759 if lease == nil {
749760 d.invalidateRenderNow(sess, ac, true, "client.go")
750761 } else if rc := sess.renderCoordinator(); rc != nil && rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"}) {
751- rc.fireCurrent(false)
762+ rc.fireCurrentForLease(lease)
752763 }
753764 }
754765 }
@@ -780,17 +791,17 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
780791 d.invalidateRender(sess, ac, true, "client.go")
781792 }
782793
783-func (d *Daemon) applyThemeForRole(token attachmentRoleToken, msg ports.Theme) bool {
784- if !token.activeEffect() {
794+func (d *Daemon) applyThemeForAttachment(token attachmentConnectionToken, msg ports.Theme) bool {
795+ if !token.attachmentEffectCurrent() {
785796 return false
786797 }
787798 clientTheme := themeFromMessage(msg)
788799 token.ac.setClientTheme(clientTheme)
789800 sess, ok := localSession(token.sess)
790- if !ok || !token.activeEffect() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {
801+ if !ok || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {
791802 return false
792803 }
793- if !token.activeEffect() {
804+ if !token.attachmentEffectCurrent() {
794805 return false
795806 }
796807 if rc := token.sess.core().coordinator.Load(); rc != nil {
@@ -805,8 +816,8 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
805816
806817 // handleClientNotice maps the closed client-event enum to daemon-owned notice
807818 // content. routingMu makes ownership validation and toast mutation one atomic
808-// attachment-routing operation: replacement also takes routingMu before
809-// publishing sess.client. Never retain sess.mu while touching notice or overlay
819+// attachment-routing operation: attachment publication also takes routingMu
820+// before changing membership. Never retain sess.mu while touching notice or overlay
810821 // state, and retain the routingMu -> sess.mu order used by attachment paths.
811822 func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) {
812823 if sess == nil || ac == nil {
@@ -816,23 +827,23 @@ func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice po
816827 token := sess.attachmentToken(ac, tr)
817828 if token.lease == nil {
818829 // Direct test/headless callers retain pointer-based routing. Production
819- // client frames always carry the exact lease through the role path below.
830+ // client frames always carry the exact lease through the attachment path below.
820831 d.handleClientNoticeDirect(sess, ac, notice)
821832 return
822833 }
823- d.handleClientNoticeForRole(token, notice)
834+ d.handleClientNoticeForAttachment(token, notice)
824835 }
825836
826-func (d *Daemon) handleClientNoticeForRole(token attachmentRoleToken, notice ports.ClientNotice) {
837+func (d *Daemon) handleClientNoticeForAttachment(token attachmentConnectionToken, notice ports.ClientNotice) {
827838 d.notices.routingMu.Lock()
828- if !token.activeEffect() {
839+ if !token.attachmentEffectCurrent() {
829840 d.notices.routingMu.Unlock()
830841 return
831842 }
832843 if d.notices.beforeClientNoticeMutation != nil {
833844 d.notices.beforeClientNoticeMutation()
834845 }
835- if !token.activeEffect() {
846+ if !token.attachmentEffectCurrent() {
836847 d.notices.routingMu.Unlock()
837848 return
838849 }
@@ -843,7 +854,7 @@ func (d *Daemon) handleClientNoticeForRole(token attachmentRoleToken, notice por
843854 }
844855 repaint := d.mutateClientNotice(sess, token.ac, notice)
845856 d.notices.routingMu.Unlock()
846- if repaint && token.activeEffect() {
857+ if repaint && token.attachmentEffectCurrent() {
847858 d.repaintForNotice(token.ac)
848859 }
849860 }
@@ -851,7 +862,7 @@ func (d *Daemon) handleClientNoticeForRole(token attachmentRoleToken, notice por
851862 func (d *Daemon) handleClientNoticeDirect(sess *session, ac *attachedClient, notice ports.ClientNotice) {
852863 d.notices.routingMu.Lock()
853864 sess.mu.Lock()
854- current := sess.client == ac
865+ _, current := sess.attachments[ac]
855866 sess.mu.Unlock()
856867 if !current {
857868 d.notices.routingMu.Unlock()
@@ -883,7 +894,7 @@ func (d *Daemon) mutateClientNotice(sess *session, ac *attachedClient, notice po
883894 }
884895
885896 // recordClientNotice mutates notice history and the selected attachment while
886-// routingMu keeps replacement from changing that selection. Rendering is left
897+// routingMu keeps another connection from changing that selection. Rendering is left
887898 // to the caller after it releases routingMu.
888899 func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, message string) bool {
889900 n := d.notices.record(domain.Notification{
M internal/usecase/daemon/client_frame_routing.go +50 -134
@@ -8,37 +8,36 @@ import (
88 "github.com/bnema/vev/internal/usecase/command"
99 )
1010
11-const connectionRoleAttempts = 8
11+const connectionSnapshotAttempts = 8
1212
13-// currentConnectionRole retries the lock-free session-to-role snapshot when a
14-// handoff lands between those reads. The role gate still performs the final
13+// currentAttachmentConnection retries the lock-free session-to-role snapshot when a
14+// handoff lands between those reads. The attachment gate still performs the final
1515 // mutation admission after this function returns.
16-func (d *Daemon) currentConnectionRole(ac *attachedClient, tr ports.Transport) (attachmentSession, attachmentRoleToken, bool) {
17- for range connectionRoleAttempts {
16+func (d *Daemon) currentAttachmentConnection(ac *attachedClient, tr ports.Transport) (attachmentSession, attachmentConnectionToken, bool) {
17+ for range connectionSnapshotAttempts {
1818 if !ac.currentTransportIs(tr) {
1919 break
2020 }
2121 sess := ac.currentAttachmentSession()
2222 if sess == nil {
23- return nil, attachmentRoleToken{}, false
23+ return nil, attachmentConnectionToken{}, false
2424 }
2525 if d.afterConnectionSessionSnapshot != nil {
2626 d.afterConnectionSessionSnapshot(sess)
2727 }
2828 token := attachmentToken(sess, ac, tr)
29- if token.role == attachmentDetached || ac.currentAttachmentSession() != sess {
29+ if token.ac == nil || ac.currentAttachmentSession() != sess {
3030 runtime.Gosched()
3131 continue
3232 }
3333 return sess, token, true
3434 }
35- return nil, attachmentRoleToken{}, false
35+ return nil, attachmentConnectionToken{}, false
3636 }
3737
3838 // runConnLoop is the per-connection input router: it pumps client messages
39-// until detach, EOF, or a transport error. Role is resolved after every Recv so
40-// a transport displaced while blocked in Recv immediately adopts its restricted
41-// snatched routing rather than executing one stale active frame.
39+// until detach, EOF, or a transport error. The attachment generation is
40+// resolved after every Recv so a stale transport cannot execute a new frame.
4241 func (d *Daemon) runConnLoop(ac *attachedClient) {
4342 tr := ac.transport()
4443 if tr == nil {
@@ -50,14 +49,12 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
5049 }
5150 f, err := tr.Recv()
5251 if err != nil {
53- for range connectionRoleAttempts {
54- sess, token, ok := d.currentConnectionRole(ac, tr)
52+ for range connectionSnapshotAttempts {
53+ sess, _, ok := d.currentAttachmentConnection(ac, tr)
5554 if !ok {
5655 return
5756 }
58- if token.role == attachmentSnatched {
59- d.parkOrDropSnatchedAttachment(token)
60- } else if proxy, ok := sess.(*proxySession); ok {
57+ if proxy, ok := sess.(*proxySession); ok {
6158 d.detachProxyOnSendError(proxy, ac, tr)
6259 } else if local, ok := localSession(sess); ok {
6360 d.clientGone(local, ac, tr, false)
@@ -71,48 +68,40 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
7168 }
7269 return
7370 }
74- _, token, ok := d.currentConnectionRole(ac, tr)
71+ _, token, ok := d.currentAttachmentConnection(ac, tr)
7572 if !ok {
7673 return
7774 }
78- switch token.role {
79- case attachmentActive:
80- if d.handleActiveClientFrame(token, f) {
81- return
82- }
83- case attachmentSnatched:
84- if d.handleSnatchedClientFrame(token, f) {
85- return
86- }
75+ if d.handleAttachmentClientFrame(token, f) {
76+ return
8777 }
8878 }
8979 }
9080
91-// handleActiveClientFrame owns the ordinary attached-client protocol. Keeping
92-// it separate makes the restricted snatched protocol an explicit allow-list.
93-func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Frame) bool {
94- if !token.activeEffect() {
81+// handleAttachmentClientFrame owns the attached-client protocol.
82+func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f ports.Frame) bool {
83+ if !token.attachmentEffectCurrent() {
9584 return false
9685 }
97- if d.afterActiveFrameDispatch != nil {
98- d.afterActiveFrameDispatch(token)
86+ if d.afterAttachmentFrameDispatch != nil {
87+ d.afterAttachmentFrameDispatch(token)
9988 }
100- ticket, admitted := token.ac.beginRoleEffect(token)
89+ ticket, admitted := token.ac.beginAttachmentEffect(token)
10190 if !admitted {
10291 return false
10392 }
10493 token.effect = ticket
105- defer token.endRoleEffect()
106- if d.afterRoleEffectAdmitted != nil {
107- d.afterRoleEffectAdmitted(token)
94+ defer token.endAttachmentEffect()
95+ if d.afterAttachmentEffectAdmitted != nil {
96+ d.afterAttachmentEffectAdmitted(token)
10897 }
10998 switch f.Type {
11099 case ports.MsgInput:
111100 if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {
112- d.handleSequencedInputForRole(token, in.InputSeq, in.Data)
101+ d.handleSequencedInputForAttachment(token, in.InputSeq, in.Data)
113102 }
114103 case ports.MsgResize:
115- if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.activeEffect() {
104+ if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.attachmentEffectCurrent() {
116105 if sess, ok := localSession(token.sess); ok {
117106 d.requestTransactionalResizeForLease(sess, token.ac, token.lease, rz.Size, false)
118107 } else if proxy, ok := token.sess.(*proxySession); ok {
@@ -121,30 +110,30 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram
121110 }
122111 case ports.MsgTheme:
123112 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {
124- d.applyThemeForRole(token, th)
113+ d.applyThemeForAttachment(token, th)
125114 }
126115 case ports.MsgImagePush:
127116 if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {
128- d.handleSequencedImagePushForRole(token, ip.InputSeq, ip)
117+ d.handleSequencedImagePushForAttachment(token, ip.InputSeq, ip)
129118 }
130119 case ports.MsgClientNotice:
131120 if notice, derr := ports.UnmarshalClientNotice(f.Payload); derr == nil {
132- d.handleClientNoticeForRole(token, notice)
121+ d.handleClientNoticeForAttachment(token, notice)
133122 } else {
134123 d.log.Warn("malformed client notice", "err", derr)
135124 }
136125 case ports.MsgDetach:
137- if token.activeEffect() {
138- d.clientGoneForRole(token, true)
126+ if token.attachmentEffectCurrent() {
127+ d.clientGoneForAttachment(token, true)
139128 return true
140129 }
141130 case ports.MsgAck:
142131 if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
143- d.ackActiveOutput(token, ack.AckedStateNum)
132+ d.ackOutput(token, ack.AckedStateNum)
144133 }
145134 case ports.MsgOutputResetRequest:
146135 if _, derr := ports.UnmarshalOutputResetRequest(f.Payload); derr == nil {
147- d.resetActiveOutput(token)
136+ d.resetOutput(token)
148137 }
149138 case ports.MsgCommand:
150139 if token.ac.proxied {
@@ -153,16 +142,16 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram
153142 return false
154143 }
155144 result := d.executeAttachedCommand(token, request)
156- if err := token.sendActiveControl(frameCommandResult(result)); err != nil {
157- token.endRoleEffect()
158- d.detachOnRoleSendError(token, token.transport.transport)
145+ if err := token.sendControl(frameCommandResult(result)); err != nil {
146+ token.endAttachmentEffect()
147+ d.detachOnAttachmentSendError(token, token.transport.transport)
159148 return true
160149 }
161150 }
162151 case ports.MsgPing:
163- if err := token.sendActiveControl(framePong()); err != nil {
164- token.endRoleEffect()
165- d.detachOnRoleSendError(token, token.transport.transport)
152+ if err := token.sendControl(framePong()); err != nil {
153+ token.endAttachmentEffect()
154+ d.detachOnAttachmentSendError(token, token.transport.transport)
166155 return true
167156 }
168157 default:
@@ -172,7 +161,7 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram
172161 return false
173162 }
174163
175-func (d *Daemon) executeAttachedCommand(token attachmentRoleToken, request ports.CommandRequest) ports.CommandResult {
164+func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request ports.CommandRequest) ports.CommandResult {
176165 result := ports.CommandResult{RequestID: request.RequestID}
177166 if request.Version != ports.ProtocolVersion {
178167 result.Code = ports.ErrInvalidCommandArgs
@@ -200,7 +189,7 @@ func (d *Daemon) executeAttachedCommand(token attachmentRoleToken, request ports
200189 return result
201190 }
202191 sess, ok := localSession(token.sess)
203- if !ok || !token.activeEffect() {
192+ if !ok || !token.attachmentEffectCurrent() {
204193 result.Code = ports.ErrNoSuchTarget
205194 result.Text = "attached session is no longer active"
206195 return result
@@ -227,11 +216,11 @@ func (d *Daemon) executeAttachedCommand(token attachmentRoleToken, request ports
227216 return result
228217 }
229218
230- // The frame's role token is the target capability. No registry/name lookup is
219+ // The frame's attachment token is the target capability. No registry/name lookup is
231220 // performed, so the command cannot escape to another remote session.
232- sess.dispatchMu.Lock()
233- err := cmd.Run(paletteExec{d: d, sess: sess, ac: token.ac, effect: token.effect}, request.Args)
234- sess.dispatchMu.Unlock()
221+ err := sess.runMutation(func() error {
222+ return cmd.Run(paletteExec{d: d, sess: sess, ac: token.ac, effect: token.effect}, request.Args)
223+ })
235224 if err == nil {
236225 result.OK = true
237226 return result
@@ -252,16 +241,16 @@ func proxyAttachedCommandOwnedRemotely(slug string) bool {
252241 return proxyPaletteCommandOwnership(slug) == proxyPaletteRemote
253242 }
254243
255-// resetActiveOutput rebases only the exact proxied role and transport admitted
244+// resetOutput rebases only the exact proxied attachment and transport admitted
256245 // by the frame router. sendMu serializes the rebase with every output send;
257246 // transport revalidation under that lock rejects a link replaced while waiting.
258-func (d *Daemon) resetActiveOutput(token attachmentRoleToken) bool {
247+func (d *Daemon) resetOutput(token attachmentConnectionToken) bool {
259248 ac := token.ac
260249 if ac == nil || !ac.proxied || token.effect == nil || token.effect.ended.Load() {
261250 return false
262251 }
263252 ac.sendMu.Lock()
264- if token.effect.ended.Load() || !ac.proxied || !token.activeCurrent() || ac.output == nil {
253+ if token.effect.ended.Load() || !ac.proxied || !token.attachmentCurrent() || ac.output == nil {
265254 ac.sendMu.Unlock()
266255 return false
267256 }
@@ -274,7 +263,7 @@ func (d *Daemon) resetActiveOutput(token attachmentRoleToken) bool {
274263 })
275264 }
276265
277-func (d *Daemon) ackActiveOutput(token attachmentRoleToken, state uint64) bool {
266+func (d *Daemon) ackOutput(token attachmentConnectionToken, state uint64) bool {
278267 ac := token.ac
279268 if token.effect == nil || token.effect.ended.Load() {
280269 return false
@@ -287,76 +276,3 @@ func (d *Daemon) ackActiveOutput(token attachmentRoleToken, state uint64) bool {
287276 }
288277 return true
289278 }
290-
291-// handleSnatchedClientFrame is intentionally an allow-list. Strict resume and
292-// quit actions are introduced separately; until then ordinary input is ignored.
293-func (d *Daemon) handleSnatchedClientFrame(token attachmentRoleToken, f ports.Frame) bool {
294- if !token.current() {
295- return false
296- }
297- ticket, admitted := token.ac.beginRoleEffect(token)
298- if !admitted {
299- return false
300- }
301- token.effect = ticket
302- defer token.endRoleEffect()
303- if d.afterRoleEffectAdmitted != nil {
304- d.afterRoleEffectAdmitted(token)
305- }
306- switch f.Type {
307- case ports.MsgInput:
308- if in, err := ports.UnmarshalInput(f.Payload); err == nil && d.handleSnatchedInput(token, in) {
309- return true
310- }
311- case ports.MsgResize:
312- if resize, err := ports.UnmarshalResize(f.Payload); err == nil && resize.Size.Valid() {
313- ac := token.ac
314- ac.sendMu.Lock()
315- if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) {
316- ac.sendMu.Unlock()
317- return false
318- }
319- ac.size = resize.Size
320- ac.sendMu.Unlock()
321- if err := d.sendSnatchedPanel(ac, token.transport, token.generation, "", token.effect); err != nil {
322- token.endRoleEffect()
323- d.parkOrDropSnatchedAttachment(token)
324- return true
325- }
326- }
327- case ports.MsgTheme:
328- if theme, err := ports.UnmarshalTheme(f.Payload); err == nil {
329- ac := token.ac
330- ac.sendMu.Lock()
331- if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) {
332- ac.sendMu.Unlock()
333- return false
334- }
335- clientTheme := themeFromMessage(theme)
336- ac.setClientTheme(clientTheme)
337- ac.setAppliedTheme(d.resolveAppliedTheme(clientTheme))
338- ac.sendMu.Unlock()
339- if err := d.sendSnatchedPanel(ac, token.transport, token.generation, "", token.effect); err != nil {
340- token.endRoleEffect()
341- d.parkOrDropSnatchedAttachment(token)
342- return true
343- }
344- }
345- case ports.MsgAck:
346- if ack, err := ports.UnmarshalAck(f.Payload); err == nil {
347- token.ac.ackOutputState(ack.AckedStateNum)
348- }
349- case ports.MsgPing:
350- if err := d.sendSnatchedControl(token, framePong()); err != nil {
351- token.endRoleEffect()
352- d.parkOrDropSnatchedAttachment(token)
353- return true
354- }
355- case ports.MsgDetach:
356- _ = d.sendSnatchedControl(token, frameDetached(ports.ReasonDetach))
357- token.endRoleEffect()
358- d.dropSnatchedAttachment(token)
359- return true
360- }
361- return false
362-}
M internal/usecase/daemon/client_transport_errors.go +26 -20
@@ -5,6 +5,9 @@ import "github.com/bnema/vev/internal/ports"
55 // clientGone detaches ac if it is still the session's current client. The
66 // session remains registered and headless after the client is gone.
77 func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
8+ if sess == nil || ac == nil {
9+ return
10+ }
811 expected := transportSnapshot{}
912 if failed != nil {
1013 expected = ac.transportSnapshot()
@@ -28,14 +31,14 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
2831 d.finishClientGone(sess, ac, failed, explicit)
2932 }
3033
31-func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) bool {
34+func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explicit bool) bool {
3235 if token.effect == nil {
3336 return false
3437 }
3538 token.effect.bindActionEnd(d, "detach")
3639 token.effect.End()
3740 if proxy, ok := token.sess.(*proxySession); ok {
38- if !d.detachIfRoleCurrent(token) {
41+ if !d.detachIfAttachmentCurrent(token) {
3942 return false
4043 }
4144 d.finishProxyClientGone(proxy, token.ac, token.transport.transport, explicit)
@@ -50,7 +53,7 @@ func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) boo
5053 if !explicit {
5154 parkingToken = d.markParkingInFlight(sess, token.ac)
5255 }
53- if !d.detachIfRoleCurrent(token) {
56+ if !d.detachIfAttachmentCurrent(token) {
5457 d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)
5558 return false
5659 }
@@ -59,6 +62,9 @@ func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) boo
5962 }
6063
6164 func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
65+ if sess == nil || ac == nil {
66+ return
67+ }
6268 if d.afterClientGoneDetach != nil {
6369 d.afterClientGoneDetach()
6470 }
@@ -143,8 +149,8 @@ func (d *Daemon) detachProxyOnSendError(p *proxySession, ac *attachedClient, fai
143149 }
144150
145151 // finishProxyClientGone retires external client ownership after the exact proxy
146-// role has been unpublished. Warm-timer publication remains with the caller so
147-// each detach path can arm once, after p.client is nil.
152+// attachment has been unpublished. Warm-timer publication remains with the caller so
153+// each detach path can arm once, after membership is empty.
148154 func (d *Daemon) finishProxyClientGone(p *proxySession, ac *attachedClient, failed ports.Transport, explicit bool) {
149155 if rc := p.coordinator.Load(); rc != nil {
150156 rc.noteDetach(ac)
@@ -160,18 +166,18 @@ func (d *Daemon) finishProxyClientGone(p *proxySession, ac *attachedClient, fail
160166 d.log.Warn("detached proxy client", "host", p.key.Host, "session", p.key.Name, "explicit", explicit)
161167 }
162168
163-func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.Transport) {
164- d.detachOnRoleSendErrorUntil(token, failed, nil)
169+func (d *Daemon) detachOnAttachmentSendError(token attachmentConnectionToken, failed ports.Transport) {
170+ d.detachOnAttachmentSendErrorUntil(token, failed, nil)
165171 }
166172
167-func (d *Daemon) detachOnRoleSendErrorUntil(token attachmentRoleToken, failed ports.Transport, done func() <-chan struct{}) {
173+func (d *Daemon) detachOnAttachmentSendErrorUntil(token attachmentConnectionToken, failed ports.Transport, done func() <-chan struct{}) {
168174 // A delayed sender may report after the client has rebound. Only the exact
169- // transport captured by this role is allowed to detach either lifecycle.
175+ // transport captured by this attachment is allowed to detach either lifecycle.
170176 if failed == nil || failed != token.transport.transport {
171177 return
172178 }
173179 if proxy, ok := token.sess.(*proxySession); ok {
174- if d.detachIfRoleCurrentUntil(token, done) {
180+ if d.detachIfAttachmentCurrentUntil(token, done) {
175181 d.finishProxyClientGone(proxy, token.ac, failed, false)
176182 d.armProxyWarm(proxy)
177183 }
@@ -182,30 +188,30 @@ func (d *Daemon) detachOnRoleSendErrorUntil(token attachmentRoleToken, failed po
182188 return
183189 }
184190 parkingToken := d.markParkingInFlight(sess, token.ac)
185- if d.detachIfRoleCurrentUntil(token, done) {
191+ if d.detachIfAttachmentCurrentUntil(token, done) {
186192 d.finishSendErrorDetach(sess, token.ac, failed)
187193 return
188194 }
189195 d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)
190196 }
191197
192-// reserveRoleSendErrorCleanup accounts for cleanup before End releases the
193-// role gate. This closes the WaitGroup Add/Wait race with terminal teardown;
198+// reserveAttachmentSendErrorCleanup accounts for cleanup before End releases the
199+// attachment gate. This closes the WaitGroup Add/Wait race with terminal teardown;
194200 // the returned launch function must be invoked immediately after ticket End.
195-func (d *Daemon) reserveRoleSendErrorCleanup(token attachmentRoleToken, failed ports.Transport) func() {
201+func (d *Daemon) reserveAttachmentSendErrorCleanup(token attachmentConnectionToken, failed ports.Transport) func() {
196202 d.attachmentCleanupWg.Add(1)
197203 return func() {
198204 go func() {
199205 defer d.attachmentCleanupWg.Done()
200- if d.afterRoleSendErrorCleanup != nil {
201- defer d.afterRoleSendErrorCleanup()
206+ if d.afterAttachmentSendErrorCleanup != nil {
207+ defer d.afterAttachmentSendErrorCleanup()
202208 }
203- if d.beforeRoleSendErrorCleanup != nil {
204- d.beforeRoleSendErrorCleanup(token)
209+ if d.beforeAttachmentSendErrorCleanup != nil {
210+ d.beforeAttachmentSendErrorCleanup(token)
205211 }
206- deadline := newRoleEffectDrainDeadline(d.clock)
212+ deadline := newAttachmentEffectDrainDeadline(d.clock)
207213 defer deadline.stop()
208- d.detachOnRoleSendErrorUntil(token, failed, deadline.Done)
214+ d.detachOnAttachmentSendErrorUntil(token, failed, deadline.Done)
209215 }()
210216 }
211217 }
M internal/usecase/daemon/clipboard.go +39 -40
@@ -26,27 +26,27 @@ var (
2626 clipPasteCloseMarker = []byte("\x1b[201~")
2727 )
2828
29-func (d *Daemon) handleSequencedImagePushForRole(token attachmentRoleToken, _ uint64, ip ports.ImagePush) {
30- if !token.activeEffect() {
29+func (d *Daemon) handleSequencedImagePushForAttachment(token attachmentConnectionToken, _ uint64, ip ports.ImagePush) {
30+ if !token.attachmentEffectCurrent() {
3131 return
3232 }
33- d.handleImagePushForRole(token, ip)
33+ d.handleImagePushForAttachment(token, ip)
3434 }
3535
36-func (d *Daemon) handleImagePushForRole(token attachmentRoleToken, ip ports.ImagePush) {
37- if len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !token.activeEffect() {
36+func (d *Daemon) handleImagePushForAttachment(token attachmentConnectionToken, ip ports.ImagePush) {
37+ if len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !token.attachmentEffectCurrent() {
3838 return
3939 }
40- path, err := d.writeClipboardImageForRole(token, ip)
40+ path, err := d.writeClipboardImageForAttachment(token, ip)
4141 if err != nil {
4242 d.log.Error("writing clipboard image failed", "err", err)
4343 return
4444 }
45- if !token.activeEffect() {
45+ if !token.attachmentEffectCurrent() {
4646 _ = os.Remove(path)
4747 return
4848 }
49- d.injectClipboardPathForRole(token, path)
49+ d.injectClipboardPathForAttachment(token, path)
5050 }
5151
5252 // handleImagePush writes an ImagePush's bytes to a temp file and injects the
@@ -84,7 +84,7 @@ func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string,
8484 return path, nil
8585 }
8686
87-func (d *Daemon) writeClipboardImageForRole(token attachmentRoleToken, ip ports.ImagePush) (string, error) {
87+func (d *Daemon) writeClipboardImageForAttachment(token attachmentConnectionToken, ip ports.ImagePush) (string, error) {
8888 path, err := d.createClipboardImage(ip)
8989 if err != nil {
9090 return "", err
@@ -95,7 +95,7 @@ func (d *Daemon) writeClipboardImageForRole(token attachmentRoleToken, ip ports.
9595 return "", errAttachmentTransition
9696 }
9797 sess.mu.Lock()
98- if !token.activeEffectSessionLocked() {
98+ if !token.attachmentEffectCurrentSessionLocked() {
9999 sess.mu.Unlock()
100100 _ = os.Remove(path)
101101 return "", errAttachmentTransition
@@ -134,16 +134,25 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) {
134134 d.injectClipboardPathToTarget(sess, path, nil)
135135 }
136136
137-func (d *Daemon) injectClipboardPathForRole(token attachmentRoleToken, path string) {
137+func (d *Daemon) injectClipboardPathForAttachment(token attachmentConnectionToken, path string) {
138138 sess, ok := localSession(token.sess)
139- if !ok || !token.activeEffect() {
139+ if !ok || !token.attachmentEffectCurrent() {
140140 return
141141 }
142142 d.injectClipboardPathToTarget(sess, path, &token)
143143 }
144144
145-func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *attachmentRoleToken) {
146- tb := sess.activeTab()
145+func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *attachmentConnectionToken) {
146+ var ac *attachedClient
147+ if token != nil {
148+ ac = token.ac
149+ }
150+ var tb *tab
151+ if ac != nil {
152+ tb = sess.tabForAttachment(ac)
153+ } else {
154+ tb = sess.firstTab()
155+ }
147156 if tb == nil {
148157 return
149158 }
@@ -166,7 +175,7 @@ func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *
166175 wrapped = append(wrapped, clipPasteCloseMarker...)
167176 data = wrapped
168177 }
169- if token != nil && !token.activeEffect() {
178+ if token != nil && !token.attachmentEffectCurrent() {
170179 return
171180 }
172181 d.writeToPane(sess, p, data)
@@ -193,7 +202,7 @@ func clipboardExt(mime string) string {
193202
194203 type clipboardForward struct {
195204 owner paneEffectLease
196- token attachmentRoleToken
205+ token attachmentConnectionToken
197206 seq []byte
198207 }
199208
@@ -222,32 +231,22 @@ func (d *Daemon) forwardClipboardAsync(owner paneEffectLease, b64 string) {
222231 default:
223232 }
224233 }
225- ac := sess.client
234+ attachments := sess.snapshotAttachmentsLocked()
226235 sess.mu.Unlock()
227- if ac == nil {
228- return
229- }
230- transport := ac.transport()
231- token := sess.attachmentToken(ac, transport)
232- if !token.activeCurrent() {
233- return
234- }
235236
236- sess.mu.Lock()
237- if sess.ctx != nil {
238- select {
239- case <-sess.ctx.Done():
240- sess.mu.Unlock()
241- return
242- default:
237+ items := make([]clipboardForward, 0, len(attachments))
238+ for _, ac := range attachments {
239+ transport := ac.transport()
240+ token := sess.attachmentToken(ac, transport)
241+ if token.attachmentCurrent() {
242+ items = append(items, clipboardForward{owner: owner, token: token, seq: seq})
243243 }
244244 }
245- if sess.client != ac || ac.roleGeneration.Load() != token.generation ||
246- !ac.transportSnapshotCurrent(token.transport) || ac.currentSession() != sess {
247- sess.mu.Unlock()
245+ if len(items) == 0 {
248246 return
249247 }
250- sess.clipboardQueue = append(sess.clipboardQueue, clipboardForward{owner: owner, token: token, seq: seq})
248+ sess.mu.Lock()
249+ sess.clipboardQueue = append(sess.clipboardQueue, items...)
251250 if !sess.clipboardWorkerRunning {
252251 sess.clipboardWorkerRunning = true
253252 go d.clipboardWorker(sess)
@@ -255,7 +254,7 @@ func (d *Daemon) forwardClipboardAsync(owner paneEffectLease, b64 string) {
255254 sess.mu.Unlock()
256255 }
257256
258-func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *roleEffectTicket) (ports.Transport, error) {
257+func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *attachmentEffectTicket) (ports.Transport, error) {
259258 token := item.token
260259 if token.ac == nil || ticket == nil || ticket.ended.Load() || token.transport.transport == nil {
261260 return token.transport.transport, errAttachmentTransition
@@ -295,7 +294,7 @@ func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *role
295294 // admission and marks the transport interval while ownership publication is
296295 // excluded. Publication therefore orders either before this check (the send is
297296 // dropped) or after transport admission (the send belongs to the old owner).
298-func beginClipboardOwnerSend(lease paneEffectLease, ticket *roleEffectTicket, expected transportSnapshot) bool {
297+func beginClipboardOwnerSend(lease paneEffectLease, ticket *attachmentEffectTicket, expected transportSnapshot) bool {
299298 if lease.pane == nil || lease.owner == nil || lease.owner.session == nil || lease.owner.tab == nil {
300299 return false
301300 }
@@ -326,7 +325,7 @@ func (d *Daemon) clipboardWorker(sess *session) {
326325 if !item.owner.Current() {
327326 continue
328327 }
329- ticket, admitted := item.token.ac.beginRoleEffect(item.token)
328+ ticket, admitted := item.token.ac.beginAttachmentEffect(item.token)
330329 if !admitted {
331330 continue
332331 }
@@ -336,7 +335,7 @@ func (d *Daemon) clipboardWorker(sess *session) {
336335 }
337336 ticket.End()
338337 if err != nil && item.owner.Current() {
339- d.detachOnRoleSendError(item.token, failed)
338+ d.detachOnAttachmentSendError(item.token, failed)
340339 }
341340 }
342341 }
M internal/usecase/daemon/clipboard_test.go +14 -44
@@ -31,7 +31,7 @@ func TestInjectClipboardPathTargetsVisibleFloatingPane(t *testing.T) {
3131 d, sess, _, _ := newManualSessionWithPTYs(t, normal)
3232 floating := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 5})
3333 floating.screen.Write([]byte("\x1b[?2004h"))
34- installTestFloating(sess.activeTab(), floating, true)
34+ installTestFloating(testAttachmentTab(sess), floating, true)
3535
3636 d.injectClipboardPath(sess, "/tmp/image.png")
3737
@@ -203,15 +203,15 @@ func chunkReadPTY(t *testing.T, chunks ...[]byte) *portsmocks.MockPTY {
203203 }
204204
205205 func publishActiveClipboardCapability(d *Daemon, sess *session, ac *attachedClient, tr ports.Transport) {
206- rc := d.attachCoordinator(sess, nil, ac, true)
206+ rc := d.attachCoordinator(sess, ac, true)
207207 token := sess.attachmentToken(ac, tr)
208208 token.lease = rc.attachmentLease(ac)
209- ac.publishRoleCapability(token)
209+ ac.publishAttachmentCapability(token)
210210 }
211211
212212 func clipboardOwnerLease(t *testing.T, sess *session) paneEffectLease {
213213 t.Helper()
214- tb := sess.activeTab()
214+ tb := testAttachmentTab(sess)
215215 tb.mu.Lock()
216216 p := tb.terminalTargetLocked()
217217 tb.mu.Unlock()
@@ -231,7 +231,7 @@ func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) {
231231 win := newTestTabWithContext(p, sctx, cancel)
232232 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
233233 ac.initOverlays()
234- sess := &session{sessionCore: sessionCore{id: "clip", name: "clip", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
234+ sess := &session{sessionCore: sessionCore{id: "clip", name: "clip", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
235235 publishTiledPaneOwners(sess, win)
236236 ac.setSession(sess)
237237 d.sessions[sess.id] = sess
@@ -265,7 +265,7 @@ func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) {
265265 win := newTestTabWithContext(p, sctx, cancel)
266266 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
267267 ac.initOverlays()
268- sess := &session{sessionCore: sessionCore{id: "clip-big", name: "clip-big", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
268+ sess := &session{sessionCore: sessionCore{id: "clip-big", name: "clip-big", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
269269 publishTiledPaneOwners(sess, win)
270270 ac.setSession(sess)
271271 d.sessions[sess.id] = sess
@@ -290,7 +290,7 @@ func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) {
290290 win := newTestTabWithContext(p, sctx, cancel)
291291 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
292292 ac.initOverlays()
293- sess := &session{sessionCore: sessionCore{id: "clip-bad", name: "clip-bad", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
293+ sess := &session{sessionCore: sessionCore{id: "clip-bad", name: "clip-bad", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
294294 publishTiledPaneOwners(sess, win)
295295 ac.setSession(sess)
296296 d.sessions[sess.id] = sess
@@ -354,7 +354,7 @@ func TestQueuedClipboardAfterPaneMoveDoesNotSendToFormerOwner(t *testing.T) {
354354 sess.mu.Unlock()
355355 d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued")))
356356
357- sourceTab := sess.activeTab()
357+ sourceTab := testAttachmentTab(sess)
358358 p := sourceTab.focusedPane()
359359 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}}
360360 publishPaneOwner(p, destination, &tab{}, 0)
@@ -363,7 +363,7 @@ func TestQueuedClipboardAfterPaneMoveDoesNotSendToFormerOwner(t *testing.T) {
363363 d.clipboardWorker(sess)
364364
365365 require.Zero(t, oldTransport.sendCount(), "clipboard queued by the former pane owner reached its client")
366- require.Same(t, ac, sess.client, "stale clipboard send handling detached the former owner's client")
366+ require.Contains(t, sess.snapshotAttachments(), ac, "stale clipboard send handling detached the former owner's client")
367367 }
368368
369369 type movingClipboardErrorTransport struct {
@@ -419,10 +419,10 @@ func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing
419419 close(workerDone)
420420 }()
421421 require.Eventually(t, func() bool {
422- return ac.roleEffects.inFlightCount() == 1
422+ return ac.attachmentEffects.inFlightCount() == 1
423423 }, time.Second, time.Millisecond, "clipboard send was not admitted before waiting on sendMu")
424424
425- sourceTab := sess.activeTab()
425+ sourceTab := testAttachmentTab(sess)
426426 p := sourceTab.focusedPane()
427427 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}}
428428 publishPaneOwner(p, destination, &tab{}, 0)
@@ -432,7 +432,7 @@ func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing
432432 awaitTestCompletion(t, workerDone, "clipboard worker did not finish")
433433
434434 require.Zero(t, oldTransport.sendCount(), "clipboard send was not revalidated immediately before transport I/O")
435- require.Same(t, ac, sess.client)
435+ require.Contains(t, sess.snapshotAttachments(), ac)
436436 }
437437
438438 func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) {
@@ -453,7 +453,7 @@ func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) {
453453 }()
454454 awaitTestCompletion(t, oldTransport.started, "clipboard send did not start")
455455
456- sourceTab := sess.activeTab()
456+ sourceTab := testAttachmentTab(sess)
457457 p := sourceTab.focusedPane()
458458 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}}
459459 publishPaneOwner(p, destination, &tab{}, 0)
@@ -461,7 +461,7 @@ func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) {
461461 close(oldTransport.release)
462462 awaitTestCompletion(t, workerDone, "clipboard worker did not finish")
463463
464- require.Same(t, ac, sess.client, "a send error from the pane's retired owner detached its client")
464+ require.Contains(t, sess.snapshotAttachments(), ac, "a send error from the pane's retired owner detached its client")
465465 select {
466466 case <-oldTransport.closed:
467467 t.Fatal("a send error from the pane's retired owner closed its client transport")
@@ -469,36 +469,6 @@ func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) {
469469 }
470470 }
471471
472-func TestQueuedClipboardBeforeSnatchDropsExactStaleCapability(t *testing.T) {
473- d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
474- oldTransport := &staleClipboardErrorTransport{}
475- old.replaceTransport(oldTransport)
476- rc := d.attachCoordinator(sess, nil, old, true)
477- token := sess.attachmentToken(old, oldTransport)
478- token.lease = rc.attachmentLease(old)
479- old.publishRoleCapability(token)
480-
481- sess.mu.Lock()
482- sess.clipboardWorkerRunning = true
483- sess.mu.Unlock()
484- d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued")))
485-
486- newTransport := &closeTrackingTransport{}
487- next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size}
488- next.initOverlays()
489- _, err := d.transitionAttachment(attachmentTransitionRequest{
490- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
491- expectedTransport: next.transportSnapshot(), ready: true,
492- })
493- require.NoError(t, err)
494-
495- d.clipboardWorker(sess)
496- require.Zero(t, oldTransport.sendCount(), "stale queued work reached its captured failing transport")
497- require.Empty(t, newTransport.Sends(), "stale queued work was redirected to the replacement")
498- require.Same(t, next, sess.client, "stale clipboard send handling detached the current client")
499- require.False(t, newTransport.Closed())
500-}
501-
502472 func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) {
503473 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
504474 tr := newBlockingClipboardTransport()
M internal/usecase/daemon/config.go +9 -9
@@ -345,16 +345,16 @@ func (d *Daemon) reapplyThemeAllSessions() {
345345 }
346346
347347 func (d *Daemon) reapplyThemeSession(sess *session) {
348- sess.mu.Lock()
349- ac := sess.client
350- sess.mu.Unlock()
351- if ac == nil {
348+ attachments := sess.snapshotAttachments()
349+ if len(attachments) == 0 {
352350 d.applyHostTheme(sess, nil, theme.Theme{}, true)
353351 return
354352 }
355- ac.sendMu.Lock()
356- defer ac.sendMu.Unlock()
357- sess.themeMu.Lock()
358- defer sess.themeMu.Unlock()
359- d.applyHostThemeLocked(sess, ac, ac.getClientTheme(), false)
353+ for _, ac := range attachments {
354+ ac.sendMu.Lock()
355+ sess.themeMu.Lock()
356+ d.applyHostThemeLocked(sess, ac, ac.getClientTheme(), false)
357+ sess.themeMu.Unlock()
358+ ac.sendMu.Unlock()
359+ }
360360 }
M internal/usecase/daemon/control.go +72 -33
@@ -161,10 +161,7 @@ func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, u
161161 if len(locals) != 1 {
162162 return nil, ports.ErrAmbiguousTarget, "several sessions are live; use -s <session> or run from inside a pane"
163163 }
164- for _, sess := range locals {
165- return sess, 0, ""
166- }
167- panic("unreachable")
164+ return locals[0], 0, ""
168165 }
169166
170167 func (s *session) containsStableIDs(tabID, paneID string) bool {
@@ -186,7 +183,6 @@ func (s *session) containsStableIDs(tabID, paneID string) bool {
186183 func resolveControlTarget(sess *session, kind command.TargetKind, request ports.CommandRequest) (*tab, *pane, uint16, string) {
187184 sess.mu.Lock()
188185 tabs := append([]*tab(nil), sess.tabs...)
189- active := sess.active
190186 sess.mu.Unlock()
191187 if request.Self {
192188 for _, tb := range tabs {
@@ -204,10 +200,10 @@ func resolveControlTarget(sess *session, kind command.TargetKind, request ports.
204200 }
205201 return nil, nil, ports.ErrNoSuchTarget, "no such target tab"
206202 }
207- if len(tabs) == 0 || active < 0 || active >= len(tabs) {
208- return nil, nil, ports.ErrNoSuchTarget, "target session has no active tab"
203+ if len(tabs) == 0 {
204+ return nil, nil, ports.ErrNoSuchTarget, "target session has no tabs"
209205 }
210- tb := tabs[active]
206+ tb := tabs[0]
211207 if kind != command.TargetPane {
212208 return tb, nil, 0, ""
213209 }
@@ -249,14 +245,16 @@ const (
249245 )
250246
251247 type daemonActionTarget struct {
252- session *session
253- tab *tab
254- pane *pane
248+ session *session
249+ attachment *attachedClient
250+ tab *tab
251+ pane *pane
255252 }
256253
257254 type daemonActionRequest struct {
258255 kind daemonActionKind
259256 target daemonActionTarget
257+ effect *attachmentEffectTicket
260258 direction layout.Direction
261259 axis layout.Axis
262260 delta int
@@ -268,11 +266,18 @@ type daemonActionRunner interface {
268266 Run(daemonActionRequest) error
269267 }
270268
271-func resolveDaemonActionTarget(sess *session) daemonActionTarget {
269+func resolveDaemonActionTargetForAttachment(sess *session, ac *attachedClient) daemonActionTarget {
272270 if sess == nil {
273271 return daemonActionTarget{}
274272 }
275- tb := sess.activeTab()
273+ if ac != nil {
274+ tb, pane := sess.paneForAttachment(ac)
275+ if tb == nil {
276+ return daemonActionTarget{session: sess, attachment: ac}
277+ }
278+ return daemonActionTarget{session: sess, attachment: ac, tab: tb, pane: pane}
279+ }
280+ tb := sess.firstTab()
276281 if tb == nil {
277282 return daemonActionTarget{session: sess}
278283 }
@@ -291,9 +296,9 @@ func (a daemonActions) Run(request daemonActionRequest) error {
291296 target := request.target
292297 switch request.kind {
293298 case daemonActionCreateTab:
294- return a.d.createTab(target.session, request.viewport)
299+ return a.d.createTabForAttachment(target.session, target.attachment, request.viewport)
295300 case daemonActionCloseTab:
296- return a.d.closeTab(target.session, target.tab, true)
301+ return a.d.closeTabLockedWithEffect(target.session, target.tab, true, request.effect)
297302 case daemonActionSplitPane:
298303 return a.d.splitPaneAt(target.session, target.tab, target.pane, request.direction)
299304 case daemonActionStackPane:
@@ -307,7 +312,7 @@ func (a daemonActions) Run(request daemonActionRequest) error {
307312 if !a.d.hasDaemonActionPaneTarget(target) {
308313 return layout.ErrNotFound
309314 }
310- if err := a.d.closePane(target.session, target.tab, target.pane.id, nil, true); err != nil {
315+ if err := a.d.closePaneLockedWithEffect(target.session, target.tab, target.pane.id, nil, true, request.effect); err != nil {
311316 return err
312317 }
313318 if a.d.hasDaemonActionPaneTarget(target) {
@@ -315,12 +320,12 @@ func (a daemonActions) Run(request daemonActionRequest) error {
315320 }
316321 return nil
317322 case daemonActionFocusPane:
318- _, err := a.d.focusDirAt(target.session, target.tab, target.pane, request.direction)
323+ _, err := a.d.focusDirAt(target.session, target.tab, target.pane, request.direction, target.attachment)
319324 return err
320325 case daemonActionNextTab:
321- return a.switchRelative(target.session, 1)
326+ return a.switchRelative(target.session, target.attachment, 1)
322327 case daemonActionPreviousTab:
323- return a.switchRelative(target.session, -1)
328+ return a.switchRelative(target.session, target.attachment, -1)
324329 case daemonActionRenameSession:
325330 return a.d.renameSession(target.session, request.name)
326331 case daemonActionRenameTab:
@@ -359,9 +364,9 @@ func (d *Daemon) hasDaemonActionPaneTarget(target daemonActionTarget) bool {
359364 target.tab.tree != nil && layout.ContainsLeaf(target.tab.tree.Root, target.pane.id)
360365 }
361366
362-func (a daemonActions) switchRelative(sess *session, delta int) error {
363- if sess.switchRelative(delta) {
364- a.d.activateTab(sess, sess.activeTab())
367+func (a daemonActions) switchRelative(sess *session, ac *attachedClient, delta int) error {
368+ if sess.switchAttachmentRelativeForDispatch(ac, delta) {
369+ a.d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(ac), false, ac, nil)
365370 }
366371 return nil
367372 }
@@ -394,10 +399,7 @@ func (e controlExec) runAction(request daemonActionRequest) error {
394399 return err
395400 }
396401 if e.actions == nil {
397- e.sess.mu.Lock()
398- ac := e.sess.client
399- e.sess.mu.Unlock()
400- finishDaemonActionForClient(e.d, request, ac, "control.go")
402+ finishDaemonActionForClient(e.d, request, request.target.attachment, "control.go")
401403 }
402404 return nil
403405 }
@@ -450,7 +452,17 @@ func (e controlExec) MovePane(destinationSession, destinationTabID string) error
450452 if e.sess == nil || e.tab == nil || e.target.pane == nil {
451453 return errMovePaneInvalid
452454 }
455+ attachment := e.target.attachment
456+ token := attachmentConnectionToken{}
457+ if attachment != nil {
458+ token = e.sess.attachmentToken(attachment, attachment.transport())
459+ if token.ac == nil {
460+ return errMoveStaleTarget
461+ }
462+ }
453463 return e.d.movePane(movePaneRequest{
464+ Attachment: attachment,
465+ AttachmentToken: token,
454466 Source: sessionMoveLocator(e.sess),
455467 SourceTabID: domain.TabStableID(e.tab.stableID),
456468 SourcePaneID: domain.PaneStableID(e.target.pane.stableID),
@@ -467,10 +479,20 @@ func (e controlExec) MoveTab(destinationSession string) error {
467479 if e.sess == nil || e.tab == nil {
468480 return errMovePaneInvalid
469481 }
482+ attachment := e.target.attachment
483+ token := attachmentConnectionToken{}
484+ if attachment != nil {
485+ token = e.sess.attachmentToken(attachment, attachment.transport())
486+ if token.ac == nil {
487+ return errMoveStaleTarget
488+ }
489+ }
470490 return e.d.moveTab(moveTabRequest{
471- Source: sessionMoveLocator(e.sess),
472- SourceTabID: domain.TabStableID(e.tab.stableID),
473- Destination: sessionMoveLocator(destination),
491+ Attachment: attachment,
492+ AttachmentToken: token,
493+ Source: sessionMoveLocator(e.sess),
494+ SourceTabID: domain.TabStableID(e.tab.stableID),
495+ Destination: sessionMoveLocator(destination),
474496 })
475497 }
476498
@@ -657,8 +679,8 @@ func (e controlExec) ListTabs(asJSON bool) (string, error) {
657679 }
658680 e.sess.mu.Lock()
659681 tabs := append([]*tab(nil), e.sess.tabs...)
660- active := e.sess.active
661682 e.sess.mu.Unlock()
683+ active, _ := e.sess.tabIndexForAttachment(e.target.attachment)
662684 rows := make([]row, 0, len(tabs))
663685 for i, tb := range tabs {
664686 tb.mu.Lock()
@@ -686,7 +708,11 @@ func (e controlExec) ListPanes(asJSON bool) (string, error) {
686708 }
687709 tb := e.tab
688710 if tb == nil {
689- tb = e.sess.activeTab()
711+ if e.target.attachment == nil {
712+ tb = e.sess.firstTab()
713+ } else {
714+ tb = e.sess.tabForAttachment(e.target.attachment)
715+ }
690716 }
691717 if tb == nil {
692718 return "", errors.New("target session has no active tab")
@@ -694,8 +720,17 @@ func (e controlExec) ListPanes(asJSON bool) (string, error) {
694720 e.sess.mu.Lock()
695721 fallbackCWD := e.sess.cwd
696722 e.sess.mu.Unlock()
723+ var focus layout.PaneID
724+ var focusStableID domain.PaneStableID
725+ if e.target.attachment != nil {
726+ focusStableID = e.target.attachment.viewSnapshot().paneID
727+ }
697728 tb.mu.Lock()
698- focus := tb.tree.Focus
729+ if e.target.attachment == nil {
730+ if focused := tb.focusedPane(); focused != nil {
731+ focus = focused.id
732+ }
733+ }
699734 panes := tb.panesSnapshot()
700735 tb.mu.Unlock()
701736 rows := make([]row, 0, len(panes))
@@ -713,7 +748,11 @@ func (e controlExec) ListPanes(asJSON bool) (string, error) {
713748 cwd = live
714749 }
715750 }
716- rows = append(rows, row{ID: pane.stableID, Pane: string(pane.id), Size: fmt.Sprintf("%dx%d", size.Cols, size.Rows), CWD: cwd, Focused: pane.id == focus})
751+ focused := pane.id == focus
752+ if e.target.attachment != nil {
753+ focused = domain.PaneStableID(pane.stableID) == focusStableID
754+ }
755+ rows = append(rows, row{ID: pane.stableID, Pane: string(pane.id), Size: fmt.Sprintf("%dx%d", size.Cols, size.Rows), CWD: cwd, Focused: focused})
717756 }
718757 sort.Slice(rows, func(i, j int) bool { return rows[i].Pane < rows[j].Pane })
719758 if asJSON {
M internal/usecase/daemon/control_test.go +59 -38
@@ -37,7 +37,7 @@ func TestPaletteAndControlShareExplicitDaemonActionTarget(t *testing.T) {
3737 t.Run(tt.name, func(t *testing.T) {
3838 d := newTestDaemon(t, nil, stubClock{})
3939 sess := addControlSession(d, "work", "t_work", "p_work")
40- tb := sess.activeTab()
40+ tb := testAttachmentTab(sess)
4141 tb.mu.Lock()
4242 pane := tb.focusedPane()
4343 tb.mu.Unlock()
@@ -78,9 +78,7 @@ func TestConsumeOrExpelControlSelfTargetsNonFocusedPane(t *testing.T) {
7878
7979 require.True(t, result.OK, result.Text)
8080 require.Empty(t, result.Output)
81- h.session.mu.Lock()
82- require.Zero(t, h.session.active, "--self must not change the active tab")
83- h.session.mu.Unlock()
81+ require.Zero(t, testAttachmentTabIndex(h.session), "--self must not change the attachment tab")
8482 h.tab.mu.Lock()
8583 require.Equal(t, layout.PaneID("pane-3"), h.tab.tree.Focus, "a moved explicit target becomes focused")
8684 require.Equal(t, beforeGeneration+1, h.tab.layoutGeneration)
@@ -93,7 +91,7 @@ func TestConsumeOrExpelControlEdgeNoopReturnsOKAndPreservesFocus(t *testing.T) {
9391 ac := &attachedClient{}
9492 ac.setSession(h.session)
9593 h.session.mu.Lock()
96- h.session.client = ac
94+ h.session.registerAttachmentLocked(ac)
9795 h.session.mu.Unlock()
9896 invalidations := make(chan renderInvalidation, 1)
9997 rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
@@ -240,7 +238,7 @@ func TestHandleCommandStableIDsResolveSessionWithoutSelectingTab(t *testing.T) {
240238 second.ctx, second.cancel = context.WithCancel(d.serveCtx)
241239 sess.mu.Lock()
242240 sess.tabs = append(sess.tabs, second)
243- sess.active = 0
241+ selectTestAttachmentTabLocked(sess, 0)
244242 sess.mu.Unlock()
245243
246244 result := sendCommand(t, d, ports.CommandRequest{
@@ -256,7 +254,7 @@ func TestHandleCommandStableIDsResolveSessionWithoutSelectingTab(t *testing.T) {
256254 require.Empty(t, second.name)
257255 second.mu.Unlock()
258256 sess.mu.Lock()
259- require.Zero(t, sess.active)
257+ require.Zero(t, testAttachmentTabIndexLocked(sess))
260258 sess.mu.Unlock()
261259 }
262260
@@ -272,7 +270,7 @@ func TestHandleCommandStableIDsDoNotRedirectSplitFromCurrentFocus(t *testing.T)
272270 second.ctx, second.cancel = context.WithCancel(d.serveCtx)
273271 sess.mu.Lock()
274272 sess.tabs = append(sess.tabs, second)
275- sess.active = 0
273+ selectTestAttachmentTabLocked(sess, 0)
276274 sess.mu.Unlock()
277275
278276 result := sendCommand(t, d, ports.CommandRequest{
@@ -282,7 +280,7 @@ func TestHandleCommandStableIDsDoNotRedirectSplitFromCurrentFocus(t *testing.T)
282280
283281 require.True(t, result.OK, result.Text)
284282 sess.mu.Lock()
285- activeIndex := sess.active
283+ activeIndex := testAttachmentTabIndexLocked(sess)
286284 sess.mu.Unlock()
287285 require.Zero(t, activeIndex)
288286 active.mu.Lock()
@@ -309,7 +307,7 @@ func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) {
309307 invoking.ctx, invoking.cancel = context.WithCancel(d.serveCtx)
310308 sess.mu.Lock()
311309 sess.tabs = append(sess.tabs, invoking)
312- sess.active = 0
310+ selectTestAttachmentTabLocked(sess, 0)
313311 sess.mu.Unlock()
314312
315313 rename := sendCommand(t, d, ports.CommandRequest{
@@ -383,7 +381,7 @@ func TestResizeControlOneShotsTargetDetachedSessions(t *testing.T) {
383381 sess := addControlSession(d, "work", "t_work", "p_work")
384382 require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
385383 require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-down", TargetSession: "work"}).OK)
386- tb := sess.activeTab()
384+ tb := testAttachmentTab(sess)
387385 tb.mu.Lock()
388386 generation := tb.layoutGeneration
389387 tb.mu.Unlock()
@@ -403,7 +401,7 @@ func TestResizeControlOneShotsTargetDetachedSessions(t *testing.T) {
403401 require.Equal(t, snapshotGeneration+1, sess.snapshotGeneration, "one accepted action has one snapshot dirty boundary")
404402 sess.snapshotMu.Unlock()
405403 sess.mu.Lock()
406- require.Nil(t, sess.client, "one-shots must work headless")
404+ require.Empty(t, sess.snapshotAttachmentsLocked(), "one-shots must work headless")
407405 sess.mu.Unlock()
408406 })
409407 }
@@ -422,7 +420,7 @@ func TestResizeControlNonSelfVEVLocatorUsesActiveTarget(t *testing.T) {
422420 locator.ctx, locator.cancel = context.WithCancel(d.serveCtx)
423421 sess.mu.Lock()
424422 sess.tabs = append(sess.tabs, locator)
425- sess.active = 0
423+ selectTestAttachmentTabLocked(sess, 0)
426424 sess.mu.Unlock()
427425 active.mu.Lock()
428426 before := active.layoutGeneration
@@ -439,7 +437,7 @@ func TestResizeControlNonSelfVEVLocatorUsesActiveTarget(t *testing.T) {
439437 require.Zero(t, locator.layoutGeneration)
440438 locator.mu.Unlock()
441439 sess.mu.Lock()
442- require.Zero(t, sess.active)
440+ require.Zero(t, testAttachmentTabIndexLocked(sess))
443441 sess.mu.Unlock()
444442 }
445443
@@ -449,7 +447,7 @@ func TestResizeControlTooSmallUsesStableFailure(t *testing.T) {
449447 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
450448 sess := addControlSession(d, "work", "t_work", "p_work")
451449 require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
452- tb := sess.activeTab()
450+ tb := testAttachmentTab(sess)
453451 tb.mu.Lock()
454452 tb.size.Cols = 41
455453 tb.bumpLayoutGenerationLocked()
@@ -468,7 +466,7 @@ func TestHandleCommandSelfListPanesUsesInvokingTab(t *testing.T) {
468466 invoking.ctx, invoking.cancel = context.WithCancel(d.serveCtx)
469467 sess.mu.Lock()
470468 sess.tabs = append(sess.tabs, invoking)
471- sess.active = 0
469+ selectTestAttachmentTabLocked(sess, 0)
472470 sess.mu.Unlock()
473471
474472 result := sendCommand(t, d, ports.CommandRequest{
@@ -539,7 +537,7 @@ func TestCloseCommandsReportMutationOutcome(t *testing.T) {
539537 second.ctx, second.cancel = context.WithCancel(d.serveCtx)
540538 sess.mu.Lock()
541539 sess.tabs = append(sess.tabs, second)
542- sess.active = 1
540+ selectTestAttachmentTabLocked(sess, 1)
543541 sess.mu.Unlock()
544542
545543 result := sendCommand(t, d, ports.CommandRequest{Slug: "close-tab", TargetSession: "work"})
@@ -547,7 +545,7 @@ func TestCloseCommandsReportMutationOutcome(t *testing.T) {
547545 require.True(t, result.OK, result.Text)
548546 sess.mu.Lock()
549547 require.Len(t, sess.tabs, 1)
550- require.Equal(t, "t_first", sess.tabs[0].stableID)
548+ require.Equal(t, "t_second", sess.tabs[0].stableID)
551549 sess.mu.Unlock()
552550 })
553551 }
@@ -560,7 +558,7 @@ func TestHandleCommandHeadlessMutations(t *testing.T) {
560558 verify func(*testing.T, *Daemon, *session)
561559 }{
562560 {name: "split focused pane", slug: "split-right", verify: func(t *testing.T, _ *Daemon, sess *session) {
563- tb := sess.activeTab()
561+ tb := testAttachmentTab(sess)
564562 tb.mu.Lock()
565563 defer tb.mu.Unlock()
566564 require.Len(t, tb.panes, 2)
@@ -569,10 +567,10 @@ func TestHandleCommandHeadlessMutations(t *testing.T) {
569567 sess.mu.Lock()
570568 defer sess.mu.Unlock()
571569 require.Len(t, sess.tabs, 2)
572- require.Equal(t, 1, sess.active)
570+ require.Equal(t, 0, testAttachmentTabIndexLocked(sess))
573571 }},
574572 {name: "rename tab", slug: "rename-tab", args: []string{"logs"}, verify: func(t *testing.T, _ *Daemon, sess *session) {
575- tb := sess.activeTab()
573+ tb := testAttachmentTab(sess)
576574 tb.mu.Lock()
577575 defer tb.mu.Unlock()
578576 require.Equal(t, "logs", tb.name)
@@ -613,7 +611,7 @@ func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T
613611 d.mu.Unlock()
614612 require.NotNil(t, created)
615613 created.mu.Lock()
616- require.Nil(t, created.client)
614+ require.Empty(t, created.snapshotAttachmentsLocked())
617615 require.Equal(t, "/tmp/work", created.cwd)
618616 require.Equal(t, []string{"INHERITED=yes"}, created.env)
619617 require.True(t, created.terminal.TrueColor)
@@ -622,7 +620,7 @@ func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T
622620 tb.mu.Lock()
623621 require.Equal(t, domain.Size{Cols: 118, Rows: 38}, tb.size)
624622 tb.mu.Unlock()
625- require.Nil(t, source.client)
623+ require.Empty(t, source.snapshotAttachments())
626624
627625 taken := sendCommand(t, d, ports.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})
628626 require.False(t, taken.OK)
@@ -674,7 +672,7 @@ func TestRemoteCatalogJSONOutput(t *testing.T) {
674672 d := newTestDaemon(t, nil, stubClock{})
675673 work := addControlSession(d, "work", "t_work", "p_work")
676674 work.ephemeral = false
677- work.client = &attachedClient{}
675+ work.registerAttachment(&attachedClient{})
678676 work.mruAt.Store(2)
679677 tb := newTabWithStableID("t_work_2", "p_work_2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
680678 tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)
@@ -762,7 +760,7 @@ func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
762760 target.ctx, target.cancel = context.WithCancel(d.serveCtx)
763761 sess.mu.Lock()
764762 sess.tabs = append(sess.tabs, target)
765- sess.active = 0
763+ selectTestAttachmentTabLocked(sess, 0)
766764 sess.mu.Unlock()
767765 original := target.tree.Focus
768766
@@ -808,7 +806,7 @@ func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
808806 require.True(t, awaitCommandResult(t, secondSends).OK)
809807
810808 sess.mu.Lock()
811- require.Zero(t, sess.active, "self commands must not select the invoking tab")
809+ require.Zero(t, testAttachmentTabIndexLocked(sess), "self commands must not select the invoking tab")
812810 sess.mu.Unlock()
813811 target.mu.Lock()
814812 defer target.mu.Unlock()
@@ -816,6 +814,27 @@ func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
816814 require.NotEqual(t, original, target.tree.Focus, "second command must execute after the split creates its right neighbor")
817815 }
818816
817+func TestControlMoveRejectsMissingAttachmentCapability(t *testing.T) {
818+ d := newTestDaemon(t, nil, stubClock{})
819+ source := addControlSession(d, "work", "t_work", "p_work")
820+ destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
821+ tb := source.tabs[0]
822+ tb.mu.Lock()
823+ pane := tb.focusedPane()
824+ tb.mu.Unlock()
825+ ac := &attachedClient{tr: &closeTrackingTransport{}}
826+ ac.setSession(source)
827+
828+ source.mu.Lock()
829+ require.True(t, source.registerAttachmentLocked(ac))
830+ target := daemonActionTarget{session: source, attachment: ac, tab: tb, pane: pane}
831+ require.True(t, source.unregisterAttachmentLocked(ac))
832+ source.mu.Unlock()
833+
834+ err := (controlExec{d: d, sess: source, tab: tb, target: target}).MovePane(destination.name, destination.tabs[0].stableID)
835+ require.ErrorIs(t, err, errMoveStaleTarget)
836+}
837+
819838 func TestHandleCommandMovePaneUsesActiveFocusedSourceWithSessionFlag(t *testing.T) {
820839 d := newTestDaemon(t, nil, stubClock{})
821840 source := addControlSession(d, "work", "t_active", "p_active")
@@ -824,7 +843,7 @@ func TestHandleCommandMovePaneUsesActiveFocusedSourceWithSessionFlag(t *testing.
824843 publishTiledPaneOwners(source, inactive)
825844 source.mu.Lock()
826845 source.tabs = append(source.tabs, inactive)
827- source.active = 0
846+ selectTestAttachmentTabLocked(source, 0)
828847 source.mu.Unlock()
829848 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
830849
@@ -859,7 +878,7 @@ func TestHandleCommandMovePaneSelfUsesStableSourceIDs(t *testing.T) {
859878 publishTiledPaneOwners(source, inactive)
860879 source.mu.Lock()
861880 source.tabs = append(source.tabs, inactive)
862- source.active = 0
881+ selectTestAttachmentTabLocked(source, 0)
863882 source.mu.Unlock()
864883 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
865884
@@ -891,27 +910,29 @@ func TestHandleCommandMovePaneSelfUsesStableSourceIDs(t *testing.T) {
891910 func TestHandleCommandMoveTabUsesActiveTabWithoutSelf(t *testing.T) {
892911 d := newTestDaemon(t, nil, stubClock{})
893912 source := addControlSession(d, "work", "t_first", "p_first")
913+ publishTiledPaneOwners(source, source.tabs[0])
894914 second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
895915 second.ctx, second.cancel = context.WithCancel(d.serveCtx)
896916 publishTiledPaneOwners(source, second)
897917 source.mu.Lock()
898918 source.tabs = append(source.tabs, second)
899- source.active = 1
919+ selectTestAttachmentTabLocked(source, 0)
900920 source.mu.Unlock()
901921 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
902922
903923 result := sendCommand(t, d, ports.CommandRequest{
904- Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work",
924+ Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work", Self: false,
925+ TargetTab: "t_second", TargetPane: "p_second",
905926 })
906927 require.True(t, result.OK, result.Text)
907928
908929 source.mu.Lock()
909930 require.Len(t, source.tabs, 1)
910- require.Equal(t, "t_first", source.tabs[0].stableID)
931+ require.Equal(t, "t_second", source.tabs[0].stableID)
911932 source.mu.Unlock()
912933 destination.mu.Lock()
913934 require.Len(t, destination.tabs, 2)
914- require.Equal(t, "t_second", destination.tabs[1].stableID)
935+ require.Equal(t, "t_first", destination.tabs[1].stableID)
915936 destination.mu.Unlock()
916937 }
917938
@@ -923,7 +944,7 @@ func TestHandleCommandMoveTabSelfUsesStableTab(t *testing.T) {
923944 publishTiledPaneOwners(source, second)
924945 source.mu.Lock()
925946 source.tabs = append(source.tabs, second)
926- source.active = 0
947+ selectTestAttachmentTabLocked(source, 0)
927948 source.mu.Unlock()
928949 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
929950
@@ -1093,12 +1114,12 @@ func TestHandleCommandMovePaneStableIDsLocateSessionWithoutSelfRedirect(t *testi
10931114 publishTiledPaneOwners(source, inactive)
10941115 source.mu.Lock()
10951116 source.tabs = append(source.tabs, inactive)
1096- source.active = 1
1117+ selectTestAttachmentTabLocked(source, 1)
10971118 source.mu.Unlock()
10981119 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
10991120
11001121 result := sendCommand(t, d, ports.CommandRequest{
1101- Slug: "move-pane", Args: []string{"dest", "t_dest"},
1122+ Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
11021123 TargetSession: "old-name", TargetTab: "t_inactive", TargetPane: "p_inactive",
11031124 })
11041125 require.True(t, result.OK, result.Text)
@@ -1126,7 +1147,7 @@ func TestHandleCommandOppositeMoveCommandsDoNotDeadlock(t *testing.T) {
11261147 left.mu.Lock()
11271148 left.name = "left"
11281149 left.tabs[0].stableID = "left-moved"
1129- left.active = 0
1150+ selectTestAttachmentTabLocked(left, 0)
11301151 left.mu.Unlock()
11311152
11321153 right := addMoveTabTestSession(d, "right", "right-stays")
@@ -1135,7 +1156,7 @@ func TestHandleCommandOppositeMoveCommandsDoNotDeadlock(t *testing.T) {
11351156 publishTiledPaneOwners(right, rightMoved)
11361157 right.mu.Lock()
11371158 right.tabs = append(right.tabs, rightMoved)
1138- right.active = 1
1159+ selectTestAttachmentTabLocked(right, 1)
11391160 right.mu.Unlock()
11401161
11411162 leftFrame := commandFrame(t, ports.CommandRequest{
@@ -1179,7 +1200,7 @@ func addNamedMoveDestination(d *Daemon, name, tabID, paneID string) *session {
11791200 tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
11801201 tb.ctx, tb.cancel = context.WithCancel(ctx)
11811202 sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, incarnation: domain.IncarnationID{5},
1182- ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0,
1203+ ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{tb},
11831204 }
11841205 publishTiledPaneOwners(sess, tb)
11851206 d.mu.Lock()
M internal/usecase/daemon/copy_enter_allocation_budget_race_test.go +1 -1
@@ -2,7 +2,7 @@
22
33 package daemon
44
5-// Race instrumentation allocations are not part of the parent benchmark's
5+// Race instrumentation allocations are not part of the non-race benchmark's
66 // allocation baseline. TestCopyEnterAllocationBudget still exercises copy
77 // entry and validates its result under -race.
88 const copyEnterAllocationBudgetEnabled = false
M internal/usecase/daemon/copy_enter_allocation_budget_test.go +1 -1
@@ -3,5 +3,5 @@
33 package daemon
44
55 // copyEnterAllocationBudgetEnabled keeps the allocation gate aligned with the
6-// non-race parent benchmark, whose allocation baseline defines the budget.
6+// non-race benchmark, whose allocation baseline defines the budget.
77 const copyEnterAllocationBudgetEnabled = true
M internal/usecase/daemon/copy_mouse_input.go +1 -1
@@ -179,7 +179,7 @@ func (d *Daemon) handleFreshCopyPointer(sess *session, ac *attachedClient, ev mo
179179 if !ok {
180180 return true
181181 }
182- if !d.publishCopyMode(sess, ac, sess.activeTab(), pointer.pane, pointer.document, func(mode *scopy.Mode) {
182+ if !d.publishCopyMode(sess, ac, sess.tabForAttachment(ac), pointer.pane, pointer.document, func(mode *scopy.Mode) {
183183 mode.StartCharacterSelection(pointer.press)
184184 mode.ExtendCharacterSelection(mapped.pos)
185185 }, func(runtime *overlayRuntime, mode *scopy.Mode) {
M internal/usecase/daemon/copy_mouse_map_test.go +1 -1
@@ -71,7 +71,7 @@ func TestCopyMouseGeometryUsesClientFrameExactlyOnce(t *testing.T) {
7171 p, release := newBlockingPTY(t)
7272 defer release()
7373 d, sess, _, _ := newManualSessionWithPTYs(t, p)
74- tb := sess.activeTab()
74+ tb := testAttachmentTab(sess)
7575
7676 tb.mu.Lock()
7777 geometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), 0, 1)
M internal/usecase/daemon/copymode.go +2 -2
@@ -63,7 +63,7 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
6363 d.invalidateRender(sess, ac, true, "copymode.go")
6464 }
6565 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
66- tb := sess.activeTab()
66+ tb := sess.tabForAttachment(ac)
6767 if tb == nil {
6868 return
6969 }
@@ -114,7 +114,7 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *
114114 if d.beforeCopyModeRevalidate != nil {
115115 d.beforeCopyModeRevalidate()
116116 }
117- valid := sess.activeTab() == tb
117+ valid := sess.tabForAttachment(ac) == tb
118118 if valid {
119119 tb.mu.Lock()
120120 valid = tb.panes[p.id] == p || (tb.floating.state == floatingVisible && tb.floating.pane == p)
M internal/usecase/daemon/copymode_lifecycle_test.go +12 -12
@@ -45,8 +45,8 @@ func TestCopyModeLifecycleActivateTabClearsRuntime(t *testing.T) {
4545 seedCopyInteractionLocked(ac.overlays, ac.overlays.copyPane, ac.overlays.copyDocument)
4646 ac.overlays.copyMu.Unlock()
4747
48- require.True(t, sess.switchTab(1))
49- d.activateTab(sess, sess.activeTab())
48+ require.True(t, selectTestAttachmentTab(sess, 1))
49+ d.activateTab(sess, testAttachmentTab(sess))
5050
5151 require.False(t, ac.overlays.copyActive())
5252 ac.overlays.copyMu.Lock()
@@ -80,7 +80,7 @@ func TestActivateTabAfterResizePathAndCopyModeLifecycle(t *testing.T) {
8080
8181 d.enterCopyMode(sess, ac)
8282 require.True(t, ac.overlays.copyActive())
83- d.activateTabAfterResizeForLease(sess, sess.activeTab(), true, ac, lease)
83+ d.activateTabAfterResizeForLease(sess, testAttachmentTab(sess), true, ac, lease)
8484
8585 require.False(t, ac.overlays.copyActive())
8686 require.Equal(t, tt.leaseBacked, <-path)
@@ -90,7 +90,7 @@ func TestActivateTabAfterResizePathAndCopyModeLifecycle(t *testing.T) {
9090
9191 func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
9292 d, sess, _, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
93- tb := sess.activeTab()
93+ tb := testAttachmentTab(sess)
9494 closingPTY := portsmocks.NewMockPTY(t)
9595 closingPTY.EXPECT().Close().Return(nil).Once()
9696 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
@@ -98,7 +98,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
9898 tb.panes["pane-2"] = closing
9999 ac := &attachedClient{output: newOutputStateStream()}
100100 ac.initOverlays()
101- sess.client = ac
101+ sess.registerAttachment(ac)
102102 ac.setSession(sess)
103103 closing.mu.Lock()
104104 document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame, closing.screen.LineBounds(), nil), domain.DefaultWordSeparators)
@@ -126,7 +126,7 @@ func TestCopyModeLifecycleCloseOnlyPaneInTabClearsRecoveredClientState(t *testin
126126 release()
127127 }
128128 }()
129- tb := sess.activeTab()
129+ tb := testAttachmentTab(sess)
130130 p := tb.focusedPane()
131131 d.enterCopyMode(sess, ac)
132132 require.True(t, ac.overlays.copyActive())
@@ -143,7 +143,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
143143 release()
144144 }
145145 }()
146- tb := sess.activeTab()
146+ tb := testAttachmentTab(sess)
147147 p := tb.focusedPane()
148148 p.mu.Lock()
149149 document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
@@ -185,7 +185,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
185185 renderSnapshot := ac.overlays.SnapshotForRender()
186186 candidateWasRenderable := renderSnapshot.copyActive
187187 renderSnapshot.Unlock()
188- require.True(t, sess.switchTab(1))
188+ require.True(t, selectTestAttachmentTab(sess, 1))
189189 close(resume)
190190 resumed = true
191191
@@ -225,7 +225,7 @@ func TestCopyModeLifecyclePublicationEpochRejectsReleaseAndPaneClose(t *testing.
225225 release()
226226 }
227227 }()
228- tb := sess.activeTab()
228+ tb := testAttachmentTab(sess)
229229 p := tb.focusedPane()
230230
231231 // A real press is required: the motion publication must inherit this
@@ -285,7 +285,7 @@ func TestCopyModeLifecycleFloatingCloseDuringPublicationDoesNotResurrect(t *test
285285 release()
286286 }
287287 }()
288- tb := sess.activeTab()
288+ tb := testAttachmentTab(sess)
289289 floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5})
290290 installTestFloating(tb, floating, true)
291291 tb.mu.Lock()
@@ -356,7 +356,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.
356356 {
357357 name: "inactive tab",
358358 invalidate: func(sess *session, _ *tab, _ *pane) {
359- require.True(t, sess.switchTab(1))
359+ require.True(t, selectTestAttachmentTab(sess, 1))
360360 },
361361 },
362362 } {
@@ -367,7 +367,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.
367367 release()
368368 }
369369 }()
370- tb := sess.activeTab()
370+ tb := testAttachmentTab(sess)
371371 p := tb.focusedPane()
372372 p.mu.Lock()
373373 document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
M internal/usecase/daemon/copymode_mouse_test.go +5 -5
@@ -26,7 +26,7 @@ func mouseCopyHarness(t *testing.T, rows ...string) (*Daemon, *session, *attache
2626 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
2727 clock := &manualMouseClock{now: time.Unix(1, 0)}
2828 d.clock = clock
29- pane := sess.activeTab().focusedPane()
29+ pane := testAttachmentTab(sess).focusedPane()
3030 for i, row := range rows {
3131 copy(pane.screen.Frame.Row(i), testRow(row))
3232 }
@@ -41,7 +41,7 @@ func copyMouseInput(d *Daemon, sess *session, ac *attachedClient, raw string) {
4141 func TestCopyDoubleClick(t *testing.T) {
4242 t.Run("same normalized position within interval selects word", func(t *testing.T) {
4343 d, sess, ac, clock := mouseCopyHarness(t, "a界 beta")
44- pane := sess.activeTab().focusedPane()
44+ pane := testAttachmentTab(sess).focusedPane()
4545 d.exitCopyMode(ac)
4646 pane.mu.Lock()
4747 row := pane.screen.Frame.Row(0)
@@ -324,7 +324,7 @@ func TestCopyWordSelectionYanksExactOSC52(t *testing.T) {
324324 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
325325 clock := &manualMouseClock{now: time.Unix(1, 0)}
326326 d.clock = clock
327- copy(sess.activeTab().focusedPane().screen.Frame.Row(0), testRow("alpha beta"))
327+ copy(testAttachmentTab(sess).focusedPane().screen.Frame.Row(0), testRow("alpha beta"))
328328 d.enterCopyMode(sess, ac)
329329 mustOutputData(t, sends)
330330
@@ -348,11 +348,11 @@ func TestCopyPointerAndClickResetOnCopyExitAndReplacement(t *testing.T) {
348348 {name: "escape", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("\x1bx")) }},
349349 {name: "yank", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("y")) }},
350350 {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) {
351- p := sess.activeTab().focusedPane()
351+ p := testAttachmentTab(sess).focusedPane()
352352 p.mu.Lock()
353353 doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
354354 p.mu.Unlock()
355- require.True(t, d.publishCopyMode(sess, ac, sess.activeTab(), p, doc, nil, nil))
355+ require.True(t, d.publishCopyMode(sess, ac, testAttachmentTab(sess), p, doc, nil, nil))
356356 }},
357357 } {
358358 t.Run(tc.name, func(t *testing.T) {
M internal/usecase/daemon/copymode_test.go +11 -11
@@ -48,7 +48,7 @@ func TestOwnedSynchronousSendReturnsCapturedTransportAcrossReplacement(t *testin
4848 ac := &attachedClient{output: newOutputStateStream()}
4949 failed := &ownedSwapErrorTransport{ac: ac, replacement: replacement, err: sendErr, sent: make(chan ports.Frame, 1)}
5050 ac.replaceTransport(failed)
51- sess := &session{sessionCore: sessionCore{name: "work", client: ac}}
51+ sess := &session{sessionCore: sessionCore{name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}}
5252 ac.setSession(sess)
5353
5454 used, err := d.boundedSendOutputErrTransport(ac, []byte("copy"))
@@ -60,7 +60,7 @@ func TestOwnedSynchronousSendReturnsCapturedTransportAcrossReplacement(t *testin
6060 require.NoError(t, decodeErr)
6161 require.Equal(t, []byte("copy"), out.Data)
6262 d.detachOnSendError(sess, ac, used)
63- require.Same(t, ac, sess.client)
63+ require.Contains(t, sess.snapshotAttachments(), ac)
6464 require.False(t, replacement.Closed())
6565 }
6666
@@ -108,7 +108,7 @@ func TestCopySearchModalGeometry(t *testing.T) {
108108 func TestCopyModeDocumentCarriesPaneRowIDs(t *testing.T) {
109109 p, _ := newBlockingPTY(t)
110110 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
111- pane := sess.activeTab().focusedPane()
111+ pane := testAttachmentTab(sess).focusedPane()
112112 appendHistoryRow(t, pane.history, testRow("history"))
113113 pane.screen.Write([]byte("live"))
114114 historyID := pane.history.View().RowID(0)
@@ -128,7 +128,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
128128 p, release := newBlockingPTY(t)
129129 defer release()
130130 _, sess, _, _ := newManualSessionWithPTYs(t, p)
131- tb := sess.activeTab()
131+ tb := testAttachmentTab(sess)
132132 pane := tb.focusedPane()
133133 pane.mu.Lock()
134134 snap := scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds(), nil)
@@ -162,7 +162,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
162162 _, sess, _, _ := newManualSessionWithPTYs(t, p)
163163 defer release()
164164 sess.name = "work"
165- tb := sess.activeTab()
165+ tb := testAttachmentTab(sess)
166166 tb.focusedPane().screen = vt.NewScreen(12, 3)
167167 tb.focusedPane().screen.Write([]byte("live"))
168168 snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame, tb.focusedPane().screen.LineBounds(), nil)
@@ -441,7 +441,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
441441 if sess == nil {
442442 return false
443443 }
444- win := sess.activeTab()
444+ win := testAttachmentTab(sess)
445445 if win == nil {
446446 return false
447447 }
@@ -458,7 +458,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
458458
459459 sess := firstSession(d)
460460 require.NotNil(t, sess)
461- ac := sess.client
461+ ac := sess.snapshotAttachments()[0]
462462 require.NotNil(t, ac)
463463 d.handleInput(sess, ac, []byte("\x1b "))
464464 awaitCoordinatorOutput(t, sends, clk.timers, "while advancing render clock", "controllable timers did not produce an output frame")
@@ -662,7 +662,7 @@ func TestHandleCopyInputUsesImmutableSnapshotWithoutPaneLock(t *testing.T) {
662662 p, release := newBlockingPTY(t)
663663 defer release()
664664 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
665- pane := sess.activeTab().focusedPane()
665+ pane := testAttachmentTab(sess).focusedPane()
666666 pane.screen.Write([]byte("live"))
667667 d.enterCopyMode(sess, ac)
668668 awaitFrame(t, sends, ports.MsgOutput)
@@ -729,7 +729,7 @@ func installFloatingCopyFixture(t *testing.T, sess *session, size domain.Size) *
729729 floatingPTY, releaseFloating := newBlockingPTY(t)
730730 t.Cleanup(releaseFloating)
731731 fp := newPane("floating", floatingPTY, size)
732- installTestFloating(sess.activeTab(), fp, true)
732+ installTestFloating(testAttachmentTab(sess), fp, true)
733733 return fp
734734 }
735735
@@ -748,7 +748,7 @@ func TestCopyModeCapturesSourceAndRetainsItAcrossFocusMove(t *testing.T) {
748748 normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites)
749749 defer releaseNormal()
750750 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
751- tb := sess.activeTab()
751+ tb := testAttachmentTab(sess)
752752 second := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 5})
753753 tb.mu.Lock()
754754 tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
@@ -856,7 +856,7 @@ func TestFloatingExitClearsCopyModeBeforeRepaint(t *testing.T) {
856856 normal, releaseNormal := newBlockingPTY(t)
857857 defer releaseNormal()
858858 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
859- tb := sess.activeTab()
859+ tb := testAttachmentTab(sess)
860860 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
861861 fp.screen.Write([]byte("flt-live"))
862862
M internal/usecase/daemon/daemon.go +64 -90
@@ -144,45 +144,41 @@ type Daemon struct {
144144 // after publishResizeCommit acquires attachment sendMu and before it acquires
145145 // owner fences. Tests must not perform external work from this callback.
146146 afterResizeCommitSendLocked func()
147- // afterSnatchOverlayFamily is a deterministic structural lock seam. It runs
148- // after one overlay-family mutex is released and before the next is taken.
149- afterSnatchOverlayFamily func(string)
150- // afterSnatchedEscapeAccepted observes a standalone ESC after parser state
151- // is consumed and before its captured role and transport are revalidated.
152- afterSnatchedEscapeAccepted func()
153- // afterSnatchedEscapeAttempt reports whether that callback acquired its exact
154- // role capability after revalidation.
155- afterSnatchedEscapeAttempt func(bool)
156147 // afterConnectionSessionSnapshot is a deterministic test seam after the
157148 // connection loop reads its current session and before it captures that role.
158149 afterConnectionSessionSnapshot func(attachmentSession)
159- // afterActiveFrameDispatch is a deterministic test seam after the connection
160- // loop's first role check and before a decoded active frame takes effect.
161- afterActiveFrameDispatch func(attachmentRoleToken)
162- // afterRoleEffectParticipantsSnapshotted observes immutable role-gate
150+ // afterAttachmentFrameDispatch is a deterministic test seam after the
151+ // connection loop snapshots a token and before a decoded frame takes effect.
152+ afterAttachmentFrameDispatch func(attachmentConnectionToken)
153+ // afterAttachmentEffectParticipantsSnapshotted observes immutable role-gate
163154 // participants after architecture preflight locks are released and before
164155 // the globally ordered freeze begins.
165- afterRoleEffectParticipantsSnapshotted func(string, []*attachedClient)
166- // afterRoleEffectGateFrozen observes each participant after it is frozen in
156+ afterAttachmentEffectParticipantsSnapshotted func(string, []*attachedClient)
157+ // afterAttachmentEffectGateFrozen observes each participant after it is frozen in
167158 // immutable identity order and before drain/publication continues.
168- afterRoleEffectGateFrozen func(string, *attachedClient)
169- // afterRoleEffectsFrozen observes the lock-free boundary after all affected
159+ afterAttachmentEffectGateFrozen func(string, *attachedClient)
160+ // afterAttachmentEffectsFrozen observes the lock-free boundary after all affected
170161 // attachment gates are frozen and drained, before architecture publication.
171- afterRoleEffectsFrozen func()
172- // afterMoveLifecycleReserved and afterMovePaneSourceSnapshot are test-only
173- // seams. They run before role/fence work and after the pre-fence source
162+ afterAttachmentEffectsFrozen func()
163+ // afterAttachmentTransitionCoordinatorsLocked is a deterministic lock-order
164+ // seam used by transition validation tests.
165+ afterAttachmentTransitionCoordinatorsLocked func()
166+ // beforeMoveDispatch, afterMoveLifecycleReserved, and
167+ // afterMovePaneSourceSnapshot are test-only seams. They run before dispatch
168+ // admission, after lifecycle reservation, and after the pre-fence source
174169 // attachment snapshot respectively. beforeMovePaneCommit runs inside the
175170 // non-failing publication section. None is set in production, and none may
176171 // perform external work while locks are held.
172+ beforeMoveDispatch func()
177173 afterMoveLifecycleReserved func()
178174 afterMoveLifecycleGateBeforeTeardownLocks func()
179175 afterMovePaneSourceSnapshot func()
180176 afterMoveTabSourceSnapshot func()
181177 beforeMovePaneCommit func()
182178 beforeMoveTabCommit func()
183- // afterDetachRoleEffectsFrozen observes terminal detach after it wins the
179+ // afterDetachAttachmentEffectsFrozen observes terminal detach after it wins the
184180 // attachment gate but before it checks session ownership.
185- afterDetachRoleEffectsFrozen func()
181+ afterDetachAttachmentEffectsFrozen func()
186182 // beforeClientGoneDetach pauses clientGone after the stale-transport
187183 // precheck and before exact transport/incarnation detach validation.
188184 beforeClientGoneDetach func()
@@ -200,49 +196,38 @@ type Daemon struct {
200196 // afterParkingWaitArmed observes waitParkingInFlight after it has resolved a
201197 // matching in-flight entry and before it blocks on done.
202198 afterParkingWaitArmed func()
203- // afterSnatchedUnrouteBeforePark pauses parkSnatchedAttachment and
204- // cleanupInterruptedSnatchedAttachment after exact snatched ownership is
205- // removed and before parkAttachmentAs. The parking-in-flight marker must
206- // already be published so a concurrent same-token resume can wait the gap.
207- afterSnatchedUnrouteBeforePark func()
208199 // beforeResumeParkedSendMu pauses resumeParked after the initial parked
209200 // lookup and before the attachment send lock, so tests can consume or
210201 // replace the credential while the handshake waits.
211202 beforeResumeParkedSendMu func()
212- // afterDisplacedCleanupStarted observes deferred displaced cleanup before it
213- // synchronizes with the attachment gate.
214- afterDisplacedCleanupStarted func()
215- // beforeReclaimFirstPaint observes a committed reclaim after displaced cleanup
216- // is queued and before its generation-bound reset first paint is admitted.
217- beforeReclaimFirstPaint func(attachmentRoleToken)
218- // afterRoleEffectAdmitted is a deterministic test seam after a frame/paint
203+ // afterAttachmentEffectAdmitted is a deterministic test seam after a frame/paint
219204 // reserves its exact capability and before its first observable mutation.
220- afterRoleEffectAdmitted func(attachmentRoleToken)
205+ afterAttachmentEffectAdmitted func(attachmentConnectionToken)
221206 // beforeFirstPaintSendWait is a test-only seam immediately before a
222207 // transition first paint waits for the attachment send lock.
223- beforeFirstPaintSendWait func(attachmentRoleToken)
208+ beforeFirstPaintSendWait func(attachmentConnectionToken)
224209 // afterDelayedKeyEffectAttempt observes whether a timer callback acquired a
225210 // fresh exact capability before producing PTY, action, or overlay effects.
226211 afterDelayedKeyEffectAttempt func(bool)
227- // afterActionRoleEffectEnded observes action-specific admission release. It
212+ // afterActionAttachmentEffectEnded observes action-specific admission release. It
228213 // is a deterministic seam for proving no role-bound mutation follows release.
229- afterActionRoleEffectEnded func(string)
230- // beforeRoleSendErrorCleanup pauses asynchronous render-failure retirement
214+ afterActionAttachmentEffectEnded func(string)
215+ // beforeAttachmentSendErrorCleanup pauses asynchronous render-failure retirement
231216 // after the render ticket ends and before exact lifecycle validation.
232- beforeRoleSendErrorCleanup func(attachmentRoleToken)
233- afterRoleSendErrorCleanup func()
234- ptys ports.PTYFactory
235- clock ports.Clock
236- log *slog.Logger
237- runtimeObserver ports.RuntimeObserver
238- baseEnv []string
239- shell string
240- shellArgs []string
241- shellOverride bool
242- persistEnabled bool
243- catalogue ports.Catalogue
244- catalogueRecords []domain.CatalogueRecord
245- catalogueRecordsProvided bool
217+ beforeAttachmentSendErrorCleanup func(attachmentConnectionToken)
218+ afterAttachmentSendErrorCleanup func()
219+ ptys ports.PTYFactory
220+ clock ports.Clock
221+ log *slog.Logger
222+ runtimeObserver ports.RuntimeObserver
223+ baseEnv []string
224+ shell string
225+ shellArgs []string
226+ shellOverride bool
227+ persistEnabled bool
228+ catalogue ports.Catalogue
229+ catalogueRecords []domain.CatalogueRecord
230+ catalogueRecordsProvided bool
246231 // snapshotRepository is the sole checkpoint storage contract.
247232 snapshotRepository ports.SnapshotRepository
248233 recovery *recoveryusecase.Coordinator
@@ -339,9 +324,9 @@ type Daemon struct {
339324 type parkedAttachment struct {
340325 sess *session
341326 ac *attachedClient
342- role attachmentRole
343327 pickerGeneration uint64
344328 timer ports.Timer
329+ claimed bool
345330 done chan struct{}
346331 doneOnce sync.Once
347332 }
@@ -1012,7 +997,7 @@ func (d *Daemon) handleList(tr ports.Transport) {
1012997 State: ports.SessionRunning,
1013998 Ephemeral: s.ephemeral,
1014999 Tabs: uint16(len(s.tabs)),
1015- Attached: s.client != nil,
1000+ Attached: len(s.attachments) != 0,
10161001 }
10171002 liveNames[s.name] = struct{}{}
10181003 s.mu.Unlock()
@@ -1119,68 +1104,58 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
11191104 expected := ac.transportSnapshot()
11201105 welcomeToken := sess.attachmentToken(ac, tr)
11211106 welcomeToken.lease = lease
1122- welcomeTicket, admitted := ac.beginRoleEffect(welcomeToken)
1123- validRole := welcomeToken.role == attachmentSnatched || welcomeToken.role == attachmentActive && lease != nil
1124- if expected.transport != tr || !admitted || !validRole {
1107+ welcomeTicket, admitted := ac.beginAttachmentEffect(welcomeToken)
1108+ if expected.transport != tr || !admitted || welcomeToken.ac == nil {
11251109 if admitted {
11261110 welcomeTicket.End()
11271111 }
1128- if welcomeToken.role == attachmentSnatched {
1129- d.parkOrDropSnatchedAttachment(welcomeToken)
1130- } else {
1112+ if !d.abortResumeClaim(ac) {
11311113 d.clientGone(sess, ac, tr, false)
11321114 }
11331115 return
11341116 }
1135- if err := ac.sendExpectedTransportForRole(expected, frameWelcome(sess, ac), welcomeTicket); err != nil {
1117+ if err := ac.sendExpectedTransportForAttachment(expected, frameWelcome(sess, ac), welcomeTicket); err != nil {
11361118 welcomeTicket.End()
1137- if welcomeToken.role == attachmentSnatched {
1138- d.parkOrDropSnatchedAttachment(welcomeToken)
1139- } else {
1119+ if !d.abortResumeClaim(ac) {
1120+ d.clientGone(sess, ac, tr, false)
1121+ }
1122+ return
1123+ }
1124+ if !d.commitResumeClaim(ac) {
1125+ welcomeTicket.End()
1126+ if !d.abortResumeClaim(ac) {
11401127 d.clientGone(sess, ac, tr, false)
11411128 }
11421129 return
11431130 }
1144- // Welcome is the only effect allowed to retain the route-time role. Release
1145- // it before discovering post-handshake authority so a replacement blocked
1146- // behind the send can publish its generation and lease first.
1131+ // Release Welcome's effect before discovering post-handshake authority so a
1132+ // replacement blocked behind the send can publish its generation and lease.
11471133 welcomeTicket.End()
1148- postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentRoleEffect(sess, tr)
1134+ postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentAttachmentEffect(sess, tr)
11491135 if !admitted {
11501136 d.clientGone(sess, ac, tr, false)
11511137 return
11521138 }
1153- if postWelcomeToken.role == attachmentSnatched {
1154- if err := d.sendInitialSnatchedPanel(postWelcomeToken, postWelcomeTicket); err != nil {
1155- postWelcomeTicket.End()
1156- d.parkOrDropSnatchedAttachment(postWelcomeToken)
1157- return
1158- }
1159- postWelcomeTicket.End()
1160- d.runConnLoop(ac)
1161- _ = tr.Close()
1162- return
1163- }
11641139 postWelcomeLease := postWelcomeToken.lease
1165- if postWelcomeToken.role != attachmentActive || rc == nil || postWelcomeLease == nil {
1140+ if postWelcomeToken.ac == nil {
11661141 postWelcomeTicket.End()
11671142 d.clientGone(sess, ac, tr, false)
11681143 return
11691144 }
11701145 if ac.proxied {
1171- meta, ok := sess.sessionMetaSnapshot()
1146+ meta, ok := sess.sessionMetaSnapshotFor(ac)
11721147 metaFrame, metaErr := frameSessionMeta(meta)
1173- if !ok || metaErr != nil || ac.sendExpectedTransportForRole(postWelcomeToken.transport, metaFrame, postWelcomeTicket) != nil {
1148+ if !ok || metaErr != nil || ac.sendExpectedTransportForAttachment(postWelcomeToken.transport, metaFrame, postWelcomeTicket) != nil {
11741149 postWelcomeTicket.End()
11751150 d.clientGone(sess, ac, tr, false)
11761151 return
11771152 }
11781153 ac.markSessionMetaSent(meta)
11791154 }
1180- if !rc.markAttachmentReady(postWelcomeLease) {
1155+ if postWelcomeLease != nil && (rc == nil || !rc.markAttachmentReady(postWelcomeLease)) {
11811156 postWelcomeTicket.End()
1182- // The attachment was displaced or detached while Welcome was in flight;
1183- // never let this stale handshake emit an Output frame.
1157+ // The attachment was detached while Welcome was in flight; never let
1158+ // this stale handshake emit an Output frame.
11841159 d.clientGone(sess, ac, tr, false)
11851160 return
11861161 }
@@ -1223,10 +1198,9 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
12231198 ac := d.prepareAttachedClientLocked(tr, sz, opts)
12241199 d.mu.Unlock()
12251200 result, err := d.transitionAttachment(attachmentTransitionRequest{
1226- target: sess,
1227- next: ac,
1228- expectedRole: attachmentDetached,
1229- targetRole: attachmentActive,
1201+ target: sess,
1202+ next: ac,
1203+
12301204 expectedTransport: ac.transportSnapshot(),
12311205 ready: false,
12321206 })
M internal/usecase/daemon/daemon_test.go +95 -4
@@ -413,7 +413,7 @@ func newManualSessionWithPTYsClockCleanup(t testing.TB, clock ports.Clock, regis
413413 }
414414 tabs = append(tabs, tb)
415415 }
416- sess := &session{sessionCore: sessionCore{id: "manual", name: "work", client: ac}, ctx: sctx, cancel: cancel, tabs: tabs}
416+ sess := &session{sessionCore: sessionCore{id: "manual", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx, cancel: cancel, tabs: tabs}
417417 for _, tb := range tabs {
418418 publishTiledPaneOwners(sess, tb)
419419 }
@@ -439,10 +439,101 @@ func newManualTabSession(t *testing.T, n int) (*Daemon, *session, *attachedClien
439439 return d, sess, ac, sends, releases
440440 }
441441
442-func activeTabIndex(sess *session) int {
442+func testAttachment(sess *session) *attachedClient {
443+ if sess == nil {
444+ return nil
445+ }
446+ attachments := sess.snapshotAttachments()
447+ if len(attachments) == 0 {
448+ return nil
449+ }
450+ return attachments[0]
451+}
452+
453+func testAttachmentTabLocked(sess *session) *tab {
454+ if sess == nil {
455+ return nil
456+ }
457+ var ac *attachedClient
458+ for candidate := range sess.attachments {
459+ ac = candidate
460+ break
461+ }
462+ if ac == nil {
463+ if len(sess.tabs) == 0 {
464+ return nil
465+ }
466+ return sess.tabs[0]
467+ }
468+ view := ac.viewSnapshot()
469+ for _, tb := range sess.tabs {
470+ if domain.TabStableID(tb.stableID) == view.tabID {
471+ return tb
472+ }
473+ }
474+ return nil
475+}
476+
477+func testAttachmentTab(sess *session) *tab {
478+ if ac := testAttachment(sess); ac != nil {
479+ return sess.tabForAttachment(ac)
480+ }
481+ return sess.firstTab()
482+}
483+
484+func selectTestAttachmentTab(sess *session, idx int) bool {
485+ ac := testAttachment(sess)
486+ if ac == nil {
487+ return idx == 0 && testAttachmentTab(sess) != nil
488+ }
489+ return sess.switchAttachmentTab(ac, idx)
490+}
491+
492+// selectTestAttachmentTabLocked is used only by fixtures that already hold
493+// sess.mu; production navigation always goes through selectAttachmentTab.
494+func selectTestAttachmentTabLocked(sess *session, idx int) bool {
495+ if sess == nil {
496+ return false
497+ }
498+ var ac *attachedClient
499+ for candidate := range sess.attachments {
500+ ac = candidate
501+ break
502+ }
503+ if ac == nil {
504+ return idx == 0 && len(sess.tabs) > 0
505+ }
506+ return sess.activateAttachmentViewLocked(ac, idx)
507+}
508+
509+func testAttachmentTabIndexLocked(sess *session) int {
510+ if sess == nil {
511+ return 0
512+ }
513+ var ac *attachedClient
514+ for candidate := range sess.attachments {
515+ ac = candidate
516+ break
517+ }
518+ if ac == nil {
519+ return 0
520+ }
521+ view := ac.viewSnapshot()
522+ for i, tb := range sess.tabs {
523+ if domain.TabStableID(tb.stableID) == view.tabID {
524+ return i
525+ }
526+ }
527+ return 0
528+}
529+
530+func testAttachmentTabIndex(sess *session) int {
531+ if sess == nil {
532+ return 0
533+ }
443534 sess.mu.Lock()
444535 defer sess.mu.Unlock()
445- return sess.active
536+ return testAttachmentTabIndexLocked(sess)
446537 }
447538
448539 func testRow(text string) []renderer.Cell {
@@ -537,7 +628,7 @@ func TestHandleHelloDefersFreshOutputUntilWelcome(t *testing.T) {
537628 require.Equal(t, ports.MsgWelcome, welcome.Type)
538629 sess := firstSession(d)
539630 sess.mu.Lock()
540- ac := sess.client
631+ ac := sess.snapshotAttachmentsLocked()[0]
541632 sess.mu.Unlock()
542633 require.NotNil(t, ac, "route must publish the attachment before Welcome returns")
543634
M internal/usecase/daemon/floating.go +38 -23
@@ -178,14 +178,8 @@ func (d *Daemon) activateTabAfterResizeForLease(sess *session, tb *tab, outerRes
178178 if d == nil || sess == nil || tb == nil {
179179 return false
180180 }
181- // A headless session has no actual terminal destination. Deferring warmup
182- // until firstPaint keeps restored tabs cold and avoids launching children
183- // merely because a tab was manipulated during teardown.
184- if ac == nil {
185- sess.mu.Lock()
186- ac = sess.client
187- sess.mu.Unlock()
188- }
181+ // A headless session has no attachment-local copy mode to clear. Keep its
182+ // geometry work session-wide instead of choosing an arbitrary attachment.
189183 if rc := sess.renderCoordinator(); rc != nil && rc.opts.onActivateTabAfterResize != nil {
190184 rc.opts.onActivateTabAfterResize(lease != nil)
191185 }
@@ -195,11 +189,24 @@ func (d *Daemon) activateTabAfterResizeForLease(sess *session, tb *tab, outerRes
195189 return false
196190 }
197191 }
198- d.exitCopyMode(ac)
192+ d.ensureFloatingWarm(sess, tb)
199193 if ac == nil {
194+ for _, attachment := range sess.snapshotAttachments() {
195+ d.exitCopyMode(attachment)
196+ }
197+ if outerResizeAccepted {
198+ return false
199+ }
200+ tb.mu.Lock()
201+ hasFloating := tb.floating.pane != nil
202+ size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
203+ tb.mu.Unlock()
204+ if hasFloating {
205+ return d.requestTransactionalResize(sess, nil, size, true)
206+ }
200207 return false
201208 }
202- d.ensureFloatingWarm(sess, tb)
209+ d.exitCopyMode(ac)
203210 if outerResizeAccepted {
204211 return false
205212 }
@@ -222,7 +229,10 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error {
222229 if d == nil || sess == nil {
223230 return domain.UserErr(domain.NoticeFloatingSpawn, "couldn't open floating pane: no active session", nil)
224231 }
225- tb := sess.activeTab()
232+ tb := sess.tabForAttachment(ac)
233+ if ac == nil {
234+ tb = sess.firstTab()
235+ }
226236 if tb == nil {
227237 return domain.UserErr(domain.NoticeFloatingSpawn, "couldn't open floating pane: no active tab", layout.ErrNotFound)
228238 }
@@ -478,13 +488,19 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin
478488 d.reapplyThemeSession(sess)
479489 d.startPaneGoroutines(sess, tb, p)
480490 if visible {
481- sess.mu.Lock()
482- ac := sess.client
483- sess.mu.Unlock()
491+ attachments := sess.snapshotAttachments()
492+ if len(attachments) == 0 {
493+ // A headless resize may commit while Open is warming. Reconcile the
494+ // visible floating geometry even without an attachment callback.
495+ d.applyVisibleFloatingLayout(sess, tb, nil)
496+ return
497+ }
484498 tb.mu.Lock()
485499 size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
486500 tb.mu.Unlock()
487- d.requestTransactionalResize(sess, ac, size, true)
501+ for _, ac := range attachments {
502+ d.requestTransactionalResize(sess, ac, size, true)
503+ }
488504 }
489505 }
490506
@@ -515,15 +531,14 @@ func (d *Daemon) reapInstalledFloating(p *pane) {
515531 return
516532 }
517533 closeFloatingPane(p)
518- sess.mu.Lock()
519- ac := sess.client
520- sess.mu.Unlock()
521- if ac != nil {
534+ attachments := sess.snapshotAttachments()
535+ copyCleared := false
536+ for _, ac := range attachments {
522537 ac.pruneCaptureFrames(p)
523- }
524- copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p)
525- if ac != nil && (visible || copyCleared) {
526- d.invalidateRender(sess, ac, true, "floating.go")
538+ copyCleared = ac.overlays.clearCopyModeForPane(p) || copyCleared
539+ if visible || copyCleared {
540+ d.invalidateRender(sess, ac, true, "floating.go")
541+ }
527542 }
528543 return
529544 }
M internal/usecase/daemon/floating_open_test.go +3 -3
@@ -130,11 +130,11 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {
130130 tb.mu.Unlock()
131131 require.NotNil(t, floating)
132132 sess.mu.Lock()
133- sess.client = &attachedClient{}
133+ sess.registerAttachmentLocked(&attachedClient{})
134134 sess.mu.Unlock()
135135 require.Equal(t, tt.desiredVisible, d.paneRenderable(sess, tb, floating), "installed popup renderability must follow its retained visibility")
136136 sess.mu.Lock()
137- sess.client = nil
137+ clearAttachmentsForTestLocked(sess)
138138 sess.mu.Unlock()
139139 select {
140140 case <-pty.readerStarted:
@@ -228,7 +228,7 @@ func TestFloatingOpenErrorReleasesReturnedPTYBeforePublishingFailure(t *testing.
228228 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
229229 ac.initOverlays()
230230 sessCtx, cancelSession := context.WithCancel(t.Context())
231- sess := &session{sessionCore: sessionCore{id: "floating-open-error", name: "work", client: ac}, tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession}
231+ sess := &session{sessionCore: sessionCore{id: "floating-open-error", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession}
232232 ac.setSession(sess)
233233
234234 // Close must be safe to run before failure publication and without
M internal/usecase/daemon/floating_render_test.go +2 -2
@@ -595,7 +595,7 @@ func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
595595 installTestFloating(tb, p, true)
596596 ac := &attachedClient{}
597597 ac.initOverlays()
598- sess := &session{sessionCore: sessionCore{client: ac}, tabs: []*tab{tb}}
598+ sess := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}}
599599 base := barState{}
600600 cfg := domain.FloatingConfig{Width: 100, Height: 100}
601601
@@ -617,7 +617,7 @@ func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
617617 cache := composeCacheInput{}
618618 for range 500 {
619619 ac.sendMu.Lock()
620- state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{bars: base, floatingCfg: cfg})
620+ state, ok := captureRenderState(sess, ac, renderCaptureRequest{bars: base, floatingCfg: cfg})
621621 ac.sendMu.Unlock()
622622 require.True(t, ok)
623623 out := composeFrame(*state, cache)
M internal/usecase/daemon/floating_test.go +4 -4
@@ -406,8 +406,8 @@ func TestFloatingExitUsesCurrentOwnerAfterTabTransfer(t *testing.T) {
406406 sourceClient.initOverlays()
407407 destinationClient := &attachedClient{captureFrames: map[*pane]capturedPaneRenderState{floating: {}}}
408408 destinationClient.initOverlays()
409- source.client = sourceClient
410- destination.client = destinationClient
409+ source.registerAttachment(sourceClient)
410+ destination.registerAttachment(destinationClient)
411411
412412 floating.onExit()
413413 d.sessWg.Wait()
@@ -485,7 +485,7 @@ func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) {
485485 t.Helper()
486486 normalPTY, releaseNormal := newBlockingPTY(t)
487487 d, sess, ac, sends := newManualSessionWithPTYs(t, normalPTY)
488- tb := sess.activeTab()
488+ tb := testAttachmentTab(sess)
489489 require.NotNil(t, tb)
490490 tb.mu.Lock()
491491 normal := tb.focusedPane()
@@ -770,7 +770,7 @@ func TestFloatingAsyncSpawnFailureToastsOnlyForUserOpen(t *testing.T) {
770770 tr, _ := newCapturingTransport(t)
771771 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
772772 ac.initOverlays()
773- sess := &session{sessionCore: sessionCore{id: "floating-spawn", name: "work", client: ac}, tabs: []*tab{tb}, ctx: t.Context()}
773+ sess := &session{sessionCore: sessionCore{id: "floating-spawn", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}, ctx: t.Context()}
774774 ac.setSession(sess)
775775
776776 d.startFloating(sess, tb, tc.userOpen)
M internal/usecase/daemon/input.go +199 -114
@@ -20,11 +20,11 @@ func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint6
2020 d.handleInput(sess, ac, data)
2121 }
2222
23-func (d *Daemon) handleSequencedInputForRole(token attachmentRoleToken, _ uint64, data []byte) {
24- if !token.activeEffect() {
23+func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionToken, _ uint64, data []byte) {
24+ if !token.attachmentEffectCurrent() {
2525 return
2626 }
27- d.handleInputForRole(token, data)
27+ d.handleInputForAttachment(token, data)
2828 }
2929
3030 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
@@ -40,19 +40,19 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
4040 )
4141 }
4242
43-func (d *Daemon) handleInputForRole(token attachmentRoleToken, data []byte) {
43+func (d *Daemon) handleInputForAttachment(token attachmentConnectionToken, data []byte) {
4444 ac := token.ac
4545 ac.initOverlays()
4646 if proxy, ok := token.sess.(*proxySession); ok {
47- handler := proxyKeyHandler{d: d, proxy: proxy, ac: ac, roleToken: token}
47+ handler := proxyKeyHandler{d: d, proxy: proxy, ac: ac, connectionToken: token}
4848 ac.mouseScan.Scan(data,
4949 func(ev mouse.Event) {
50- if token.activeEffect() && !proxyMouseOwnedLocally(ac.overlays) {
50+ if token.attachmentEffectCurrent() && !proxyMouseOwnedLocally(ac.overlays) {
5151 handler.Mouse(ev)
5252 }
5353 },
5454 func(b []byte) {
55- if !token.activeEffect() || ac.overlays.HandleInput(d, b, token.effect) || !token.activeEffect() {
55+ if !token.attachmentEffectCurrent() || ac.overlays.HandleInput(d, b, token.effect) || !token.attachmentEffectCurrent() {
5656 return
5757 }
5858 ac.keys.RouteWithHandler(b, handler)
@@ -62,26 +62,30 @@ func (d *Daemon) handleInputForRole(token attachmentRoleToken, data []byte) {
6262 }
6363 ac.mouseScan.Scan(data,
6464 func(ev mouse.Event) {
65- if token.activeEffect() {
65+ if token.attachmentEffectCurrent() {
6666 d.handleMouse(ac, ev)
6767 }
6868 },
6969 func(b []byte) {
70- if !token.activeEffect() {
70+ if !token.attachmentEffectCurrent() {
7171 return
7272 }
7373 if ac.overlays.HandleInput(d, b, token.effect) {
7474 return
7575 }
76- if !token.activeEffect() {
76+ if !token.attachmentEffectCurrent() {
7777 return
7878 }
79- ac.keys.RouteWithHandler(b, daemonKeyHandler{d: d, ac: ac, roleToken: token})
79+ ac.keys.RouteWithHandler(b, daemonKeyHandler{d: d, ac: ac, connectionToken: token})
8080 },
8181 )
8282 }
8383
8484 func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
85+ d.handleMouseMutation(ac, ev)
86+}
87+
88+func (d *Daemon) handleMouseMutation(ac *attachedClient, ev mouse.Event) {
8589 frameEvent := ev
8690 ac.initOverlays()
8791 rt := ac.overlays
@@ -93,7 +97,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
9397 invalidateRejectedLeftPointer(rt, ev)
9498 return
9599 }
96- tb := sess.activeTab()
100+ tb := sess.tabForAttachment(ac)
97101 if tb == nil {
98102 invalidateRejectedLeftPointer(rt, ev)
99103 return
@@ -108,97 +112,157 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
108112 return
109113 }
110114
111- tb.mu.Lock()
115+ // Copy-mode publication deliberately revalidates outside the dispatch lock:
116+ // a concurrent release or pane close must be able to invalidate its candidate.
117+ var routed mouseRoute
118+ _ = sess.runMutation(func() error {
119+ routed = d.routeMouseMutation(sess, ac, ev)
120+ return nil
121+ })
122+ if routed.handled {
123+ return
124+ }
125+ if routed.p == nil {
126+ invalidateRejectedLeftPointer(rt, ev)
127+ return
128+ }
129+ if ev.Button == mouse.Left && ev.Type == mouse.Press && d.handleFreshCopyPress(sess, ac, routed.tb, routed.p, frameEvent) {
130+ return
131+ }
132+ d.handleTerminalMouse(sess, ac, routed.p, routed.event, routed.translated, routed.hoveredFocused)
133+}
134+
135+type mouseRoute struct {
136+ tb *tab
137+ p *pane
138+ event mouse.Event
139+ translated bool
140+ hoveredFocused bool
141+ handled bool
142+}
143+
144+func (d *Daemon) routeMouseMutation(sess *session, ac *attachedClient, ev mouse.Event) mouseRoute {
145+ route := mouseRoute{event: ev, hoveredFocused: true}
146+ tb := sess.tabForAttachment(ac)
147+ if tb == nil {
148+ route.handled = true
149+ return route
150+ }
151+ route.tb = tb
112152 contentRow := ev.Row - clientTopBarRows
153+ tb.mu.Lock()
113154 floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
114155 if floatingVisible {
115156 if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) {
116157 tb.mu.Unlock()
117- invalidateRejectedLeftPointer(rt, ev)
118- return
158+ route.handled = true
159+ return route
119160 }
120161 tb.mu.Unlock()
121- if ev.Button == mouse.Left && ev.Type == mouse.Press && d.handleFreshCopyPress(sess, ac, tb, floating, frameEvent) {
122- return
123- }
124- ev = translateMouseEvent(ev, floatingGeometry.Inner.X, floatingGeometry.Inner.Y)
125- d.handleTerminalMouse(sess, ac, floating, ev, true, true)
126- return
162+ route.p = floating
163+ route.event = translateMouseEvent(ev, floatingGeometry.Inner.X, floatingGeometry.Inner.Y)
164+ route.translated = true
165+ return route
127166 }
128167 pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow)
129168 multi := len(tb.panes) > 1
130- focusedID := layout.PaneID("")
131- if tb.tree != nil {
132- focusedID = tb.tree.Focus
133- }
169+ focusedID := mouseFocusedPaneIDLocked(ac, tb)
134170 if hit && pointInRect(ev.Col, contentRow, pl.TitleBar) {
135171 if !isMouseFocusPress(ev) {
136172 tb.mu.Unlock()
137- invalidateRejectedLeftPointer(rt, ev)
138- return
173+ route.handled = true
174+ return route
139175 }
140- oldFocus := focusedID
141- layoutChanged := focusPlacementLocked(tb, pl.ID)
142176 tb.mu.Unlock()
143- if layoutChanged {
144- d.applyTabLayout(sess, tb)
177+ _, focused := d.focusMousePane(sess, ac, tb, pl.ID)
178+ if focused == nil {
179+ route.handled = true
180+ return route
145181 }
146- // A title bar never routes to terminal content. Clear any pre-existing
147- // left-button candidate before handling the focus result, including when
148- // this press leaves the same pane focused.
149- invalidateRejectedLeftPointer(rt, ev)
150- if pl.ID != oldFocus {
182+ invalidateRejectedLeftPointer(ac.overlays, ev)
183+ if pl.ID != focusedID {
151184 d.exitCopyMode(ac)
152185 d.refreshPaneTitleOnFocus(sess, pl.ID)
153186 }
154187 d.invalidateRender(sess, ac, true, "input.go")
155- return
188+ route.handled = true
189+ return route
156190 }
157- var p *pane
158- translated := false
159- hoveredFocused := true
160191 if hit && !pl.Collapsed && pointInRect(ev.Col, contentRow, pl.Content) {
161192 oldFocus := focusedID
162- layoutChanged := false
163- if isMouseFocusPress(ev) {
164- layoutChanged = focusPlacementLocked(tb, pl.ID)
165- }
166- p = tb.panes[pl.ID]
167- hoveredFocused = pl.ID == oldFocus
193+ shouldFocus := isMouseFocusPress(ev)
194+ p := tb.panes[pl.ID]
195+ hoveredFocused := pl.ID == oldFocus
168196 tb.mu.Unlock()
169- if layoutChanged {
170- d.applyTabLayout(sess, tb)
197+ if shouldFocus {
198+ _, p = d.focusMousePane(sess, ac, tb, pl.ID)
171199 }
172200 if p == nil {
173- invalidateRejectedLeftPointer(rt, ev)
174- return
201+ route.handled = true
202+ return route
175203 }
176- if isMouseFocusPress(ev) && pl.ID != oldFocus {
204+ if shouldFocus && pl.ID != oldFocus {
177205 d.exitCopyMode(ac)
178206 d.refreshPaneTitleOnFocus(sess, pl.ID)
179207 d.invalidateRender(sess, ac, true, "input.go")
180208 }
209+ route.p = p
210+ route.hoveredFocused = hoveredFocused
181211 if multi {
182- ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)
183- translated = true
184- }
185- } else {
186- if multi {
187- tb.mu.Unlock()
188- invalidateRejectedLeftPointer(rt, ev)
189- return
212+ route.event = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)
213+ route.translated = true
190214 }
191- p = tb.focusedPane()
215+ return route
216+ }
217+ if multi {
192218 tb.mu.Unlock()
193- if p == nil {
194- invalidateRejectedLeftPointer(rt, ev)
195- return
219+ route.handled = true
220+ return route
221+ }
222+ tb.mu.Unlock()
223+ _, route.p = sess.paneForAttachment(ac)
224+ return route
225+}
226+
227+func mouseFocusedPaneIDLocked(ac *attachedClient, tb *tab) layout.PaneID {
228+ if ac != nil && tb != nil {
229+ view := ac.viewSnapshot()
230+ if domain.TabStableID(tb.stableID) == view.tabID {
231+ for _, p := range tb.panes {
232+ if p != nil && domain.PaneStableID(p.stableID) == view.paneID {
233+ return p.id
234+ }
235+ }
196236 }
197237 }
198- if ev.Button == mouse.Left && ev.Type == mouse.Press && d.handleFreshCopyPress(sess, ac, tb, p, frameEvent) {
199- return
238+ if tb == nil || tb.tree == nil {
239+ return ""
240+ }
241+ return tb.tree.Focus
242+}
243+
244+// focusMousePane runs inside the caller's session mutation boundary.
245+func (d *Daemon) focusMousePane(sess *session, ac *attachedClient, tb *tab, id layout.PaneID) (bool, *pane) {
246+ var changed bool
247+ var focused *pane
248+ tb.mu.Lock()
249+ if tb.tree != nil {
250+ focused = tb.panes[id]
251+ if focused != nil {
252+ changed = focusPlacementLocked(tb, id)
253+ }
254+ }
255+ tb.mu.Unlock()
256+ if focused == nil {
257+ return false, nil
200258 }
201- d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused)
259+ sess.mu.Lock()
260+ sess.setAttachmentPaneLocked(ac, tb, focused)
261+ sess.mu.Unlock()
262+ if changed {
263+ d.applyTabLayout(sess, tb)
264+ }
265+ return changed, focused
202266 }
203267
204268 func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) {
@@ -215,18 +279,24 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
215279 if !mouseSGR || ev.Row == 0 || ev.Row > childRows {
216280 return
217281 }
218- if translated {
219- d.writeToPane(sess, p, ev.Raw)
220- } else {
221- d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -clientTopBarRows))
222- }
282+ _ = sess.runMutation(func() error {
283+ if translated {
284+ d.writeToPane(sess, p, ev.Raw)
285+ } else {
286+ d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -clientTopBarRows))
287+ }
288+ return nil
289+ })
223290 return
224291 }
225292
226293 switch ev.Button {
227294 case mouse.WheelUp:
228295 if altScreen {
229- d.writeToPane(sess, p, []byte("\x1b[A\x1b[A\x1b[A"))
296+ _ = sess.runMutation(func() error {
297+ d.writeToPane(sess, p, []byte("\x1b[A\x1b[A\x1b[A"))
298+ return nil
299+ })
230300 return
231301 }
232302 if !hoveredFocused {
@@ -236,7 +306,10 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
236306 d.copyWheel(sess, ac, -3)
237307 case mouse.WheelDown:
238308 if altScreen {
239- d.writeToPane(sess, p, []byte("\x1b[B\x1b[B\x1b[B"))
309+ _ = sess.runMutation(func() error {
310+ d.writeToPane(sess, p, []byte("\x1b[B\x1b[B\x1b[B"))
311+ return nil
312+ })
240313 }
241314 }
242315 }
@@ -315,34 +388,34 @@ func sgrOffset(raw []byte, colDelta, rowDelta int) []byte {
315388 }
316389
317390 type daemonKeyHandler struct {
318- d *Daemon
319- ac *attachedClient
320- actions daemonActionRunner
321- roleToken attachmentRoleToken
391+ d *Daemon
392+ ac *attachedClient
393+ actions daemonActionRunner
394+ connectionToken attachmentConnectionToken
322395 }
323396
324-// acquireRoleEffect preserves a synchronous frame's existing admission and
325-// gives delayed router callbacks a fresh ticket for the exact captured role.
326-// Direct/headless callers without a role token retain their existing behavior.
327-func (h daemonKeyHandler) acquireRoleEffect() (*session, *roleEffectTicket, bool) {
328- if h.roleToken.ac != nil {
329- if effect := h.roleToken.effect; effect != nil && !effect.ended.Load() {
330- sess, _ := localSession(h.roleToken.sess)
397+// acquireAttachmentEffect preserves a synchronous frame's existing admission and
398+// gives delayed router callbacks a fresh ticket for the exact captured attachment.
399+// Direct/headless callers without an attachment token retain their existing behavior.
400+func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) {
401+ if h.connectionToken.ac != nil {
402+ if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {
403+ sess, _ := localSession(h.connectionToken.sess)
331404 return sess, effect, false
332405 }
333- effect, admitted := h.roleToken.ac.beginRoleEffect(h.roleToken)
406+ effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken)
334407 if h.d.afterDelayedKeyEffectAttempt != nil {
335408 h.d.afterDelayedKeyEffectAttempt(admitted)
336409 }
337410 if !admitted {
338411 return nil, nil, false
339412 }
340- if h.d.afterRoleEffectAdmitted != nil {
341- token := h.roleToken
413+ if h.d.afterAttachmentEffectAdmitted != nil {
414+ token := h.connectionToken
342415 token.effect = effect
343- h.d.afterRoleEffectAdmitted(token)
416+ h.d.afterAttachmentEffectAdmitted(token)
344417 }
345- sess, ok := localSession(h.roleToken.sess)
418+ sess, ok := localSession(h.connectionToken.sess)
346419 if !ok {
347420 effect.End()
348421 return nil, nil, false
@@ -350,32 +423,35 @@ func (h daemonKeyHandler) acquireRoleEffect() (*session, *roleEffectTicket, bool
350423 return sess, effect, true
351424 }
352425 sess := h.ac.currentSession()
353- if sess == nil || sess.attachmentRole(h.ac) == attachmentSnatched {
426+ if sess == nil {
354427 return nil, nil, false
355428 }
356429 return sess, nil, false
357430 }
358431
359432 func (h daemonKeyHandler) Forward(data []byte) {
360- sess, effect, owned := h.acquireRoleEffect()
433+ sess, effect, owned := h.acquireAttachmentEffect()
361434 if sess == nil {
362435 return
363436 }
364437 if owned {
365438 defer effect.End()
366439 }
367- tb := sess.activeTab()
368- if tb == nil {
369- return
370- }
371- tb.mu.Lock()
372- p := tb.terminalTargetLocked()
373- tb.mu.Unlock()
374- h.d.writeToPane(sess, p, data)
440+ _ = sess.runMutation(func() error {
441+ tb := sess.tabForAttachment(h.ac)
442+ if tb == nil {
443+ return nil
444+ }
445+ tb.mu.Lock()
446+ p := tb.terminalTargetLocked()
447+ tb.mu.Unlock()
448+ h.d.writeToPane(sess, p, data)
449+ return nil
450+ })
375451 }
376452
377453 func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
378- sess, effect, owned := h.acquireRoleEffect()
454+ sess, effect, owned := h.acquireAttachmentEffect()
379455 if sess == nil {
380456 return
381457 }
@@ -383,12 +459,15 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
383459 defer effect.End()
384460 }
385461 runAction := func(request daemonActionRequest) {
386- request.target = resolveDaemonActionTarget(sess)
462+ request.effect = effect
387463 runner := h.actions
388464 if runner == nil {
389465 runner = daemonActions{d: h.d}
390466 }
391- if err := runner.Run(request); err != nil {
467+ if err := sess.runMutation(func() error {
468+ request.target = resolveDaemonActionTargetForAttachment(sess, h.ac)
469+ return runner.Run(request)
470+ }); err != nil {
392471 if errors.Is(err, errDaemonActionNoChange) {
393472 return
394473 }
@@ -408,10 +487,13 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
408487 h.d.enterPalette(sess, h.ac)
409488 case keys.ActionJumpAttention:
410489 if !proxiedJumpSearchesOtherSessions(h.ac.proxied) {
411- if idx, ok := oldestAttentionTab(sess); ok && sess.switchTab(idx) {
412- h.d.activateTab(sess, sess.activeTab())
413- h.d.invalidateRender(sess, h.ac, true, "input.go")
414- }
490+ _ = sess.runMutation(func() error {
491+ if idx, ok := oldestAttentionTab(sess); ok && sess.switchAttachmentTabForDispatch(h.ac, idx) {
492+ h.d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(h.ac), false, h.ac, nil)
493+ h.d.invalidateRender(sess, h.ac, true, "input.go")
494+ }
495+ return nil
496+ })
415497 return
416498 }
417499 if effect == nil {
@@ -421,13 +503,13 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
421503 return
422504 }
423505 effect.bindActionEnd(h.d, "jump-attention")
424- token := h.roleToken
506+ token := h.connectionToken
425507 token.effect = effect
426- if err := h.d.jumpAttentionForRole(sess, h.ac, token); err != nil {
508+ if err := h.d.jumpAttentionForAttachment(sess, h.ac, token); err != nil {
427509 h.d.reportError(sess, err)
428510 }
429511 case keys.ActionToggleFloatingPane:
430- if err := h.d.toggleFloating(sess, h.ac); err != nil {
512+ if err := sess.runMutation(func() error { return h.d.toggleFloating(sess, h.ac) }); err != nil {
431513 h.d.log.Warn("toggle floating pane failed", "err", err)
432514 h.d.reportError(sess, err)
433515 }
@@ -457,14 +539,17 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
457539 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
458540 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
459541 idx := int(action - keys.ActionSwitchTab1)
460- if sess.switchTab(idx) {
461- h.d.activateTab(sess, sess.activeTab())
462- h.d.invalidateRender(sess, h.ac, true, "input.go")
463- }
542+ _ = sess.runMutation(func() error {
543+ if sess.switchAttachmentTabForDispatch(h.ac, idx) {
544+ h.d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(h.ac), false, h.ac, nil)
545+ h.d.invalidateRender(sess, h.ac, true, "input.go")
546+ }
547+ return nil
548+ })
464549 }
465550 }
466551
467-func (h daemonKeyHandler) focus(sess *session, dir layout.Direction, effect *roleEffectTicket) {
552+func (h daemonKeyHandler) focus(sess *session, dir layout.Direction, effect *attachmentEffectTicket) {
468553 var err error
469554 if h.ac.proxied {
470555 err = h.d.focusDirProxied(sess, h.ac, dir)
M internal/usecase/daemon/input_test.go +96 -288
@@ -27,6 +27,59 @@ import (
2727
2828 // --- test doubles -----------------------------------------------------------
2929
30+type blockingInputActionRunner struct {
31+ admitted chan<- struct{}
32+ release <-chan struct{}
33+ mu *sync.Mutex
34+ order *[]string
35+}
36+
37+func (r *blockingInputActionRunner) Run(daemonActionRequest) error {
38+ close(r.admitted)
39+ <-r.release
40+ r.mu.Lock()
41+ *r.order = append(*r.order, "keyboard")
42+ r.mu.Unlock()
43+ return nil
44+}
45+
46+func TestAttachedKeyboardMutationSharesDispatchBoundaryWithCommand(t *testing.T) {
47+ d := newTestDaemon(t, nil, stubClock{})
48+ sess := addControlSession(d, "work", "t_work", "p_work")
49+ ac := &attachedClient{}
50+ ac.setSession(sess)
51+
52+ var orderMu sync.Mutex
53+ var order []string
54+ admitted := make(chan struct{})
55+ release := make(chan struct{})
56+ runner := &blockingInputActionRunner{admitted: admitted, release: release, mu: &orderMu, order: &order}
57+ handler := daemonKeyHandler{d: d, ac: ac, actions: runner}
58+ keyboardDone := make(chan struct{})
59+ go func() {
60+ handler.Action(keys.ActionGrowPaneWidth, nil)
61+ close(keyboardDone)
62+ }()
63+ <-admitted
64+ require.False(t, sess.dispatchMu.TryLock(), "keyboard mutation must hold the session dispatch boundary")
65+
66+ commandDone := make(chan struct{})
67+ go func() {
68+ _ = sess.runMutation(func() error {
69+ orderMu.Lock()
70+ order = append(order, "command")
71+ orderMu.Unlock()
72+ return nil
73+ })
74+ close(commandDone)
75+ }()
76+ close(release)
77+ <-keyboardDone
78+ <-commandDone
79+
80+ require.Equal(t, []string{"keyboard", "command"}, order)
81+}
82+
3083 func TestConfiguredConsumeOrExpelActionsRouteThroughDaemonInput(t *testing.T) {
3184 tests := []struct {
3285 name string
@@ -53,7 +106,7 @@ func TestConfiguredConsumeOrExpelActionsRouteThroughDaemonInput(t *testing.T) {
53106 ac.setSession(h.session)
54107 ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings)
55108 h.session.mu.Lock()
56- h.session.client = ac
109+ h.session.registerAttachmentLocked(ac)
57110 h.session.mu.Unlock()
58111 invalidations := make(chan renderInvalidation, 1)
59112 rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
@@ -82,7 +135,7 @@ func TestConfiguredConsumeOrExpelEdgeActionIsSilent(t *testing.T) {
82135 ac.setSession(h.session)
83136 ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings)
84137 h.session.mu.Lock()
85- h.session.client = ac
138+ h.session.registerAttachmentLocked(ac)
86139 h.session.mu.Unlock()
87140 invalidations := make(chan renderInvalidation, 1)
88141 rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
@@ -129,254 +182,9 @@ func TestConsumeOrExpelKeyActionPreservesRearrangementWarning(t *testing.T) {
129182 }
130183 }
131184
132-func TestResizeActionAdaptersSubmitEquivalentRequests(t *testing.T) {
133- tests := []struct {
134- name string
135- action keys.Action
136- palette func(paletteExec) error
137- control func(controlExec) error
138- }{
139- {"grow width", keys.ActionGrowPaneWidth, func(e paletteExec) error { return e.GrowPaneWidth() }, func(e controlExec) error { return e.GrowPaneWidth() }},
140- {"shrink width", keys.ActionShrinkPaneWidth, func(e paletteExec) error { return e.ShrinkPaneWidth() }, func(e controlExec) error { return e.ShrinkPaneWidth() }},
141- {"grow height", keys.ActionGrowPaneHeight, func(e paletteExec) error { return e.GrowPaneHeight() }, func(e controlExec) error { return e.GrowPaneHeight() }},
142- {"shrink height", keys.ActionShrinkPaneHeight, func(e paletteExec) error { return e.ShrinkPaneHeight() }, func(e controlExec) error { return e.ShrinkPaneHeight() }},
143- {"equalize", keys.ActionEqualizePanes, func(e paletteExec) error { return e.EqualizePanes() }, func(e controlExec) error { return e.EqualizePanes() }},
144- }
145-
146- for _, tt := range tests {
147- t.Run(tt.name, func(t *testing.T) {
148- d := newTestDaemon(t, nil, stubClock{})
149- sess := addControlSession(d, "work", "t_work", "p_work")
150- ac := &attachedClient{}
151- ac.setSession(sess)
152- target := resolveDaemonActionTarget(sess)
153- keySpy := &actionRunnerSpy{}
154- paletteSpy := &actionRunnerSpy{}
155- controlSpy := &actionRunnerSpy{}
156-
157- daemonKeyHandler{d: d, ac: ac, actions: keySpy}.Action(tt.action, nil)
158- require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, actions: paletteSpy}))
159- require.NoError(t, tt.control(controlExec{d: d, sess: sess, target: target, actions: controlSpy}))
160-
161- require.Len(t, keySpy.requests, 1)
162- require.Equal(t, keySpy.requests, paletteSpy.requests)
163- require.Equal(t, keySpy.requests, controlSpy.requests)
164- require.Same(t, sess, keySpy.requests[0].target.session)
165- require.Same(t, target.tab, keySpy.requests[0].target.tab)
166- require.Same(t, target.pane, keySpy.requests[0].target.pane)
167- })
168- }
169-}
170-
171-func TestResizeActionAdaptersProduceEquivalentGeometry(t *testing.T) {
172- type adapterState struct {
173- tree *layout.Tree
174- placements []layout.Placement
175- ptySizes map[layout.PaneID][]domain.Size
176- generationDelta uint64
177- snapshotDirtyDelta uint64
178- }
179- tests := []struct {
180- name string
181- action keys.Action
182- palette func(paletteExec) error
183- control func(controlExec) error
184- }{
185- {"grow width", keys.ActionGrowPaneWidth, func(e paletteExec) error { return e.GrowPaneWidth() }, func(e controlExec) error { return e.GrowPaneWidth() }},
186- {"shrink width", keys.ActionShrinkPaneWidth, func(e paletteExec) error { return e.ShrinkPaneWidth() }, func(e controlExec) error { return e.ShrinkPaneWidth() }},
187- {"grow height", keys.ActionGrowPaneHeight, func(e paletteExec) error { return e.GrowPaneHeight() }, func(e controlExec) error { return e.GrowPaneHeight() }},
188- {"shrink height", keys.ActionShrinkPaneHeight, func(e paletteExec) error { return e.ShrinkPaneHeight() }, func(e controlExec) error { return e.ShrinkPaneHeight() }},
189- {"equalize", keys.ActionEqualizePanes, func(e paletteExec) error { return e.EqualizePanes() }, func(e controlExec) error { return e.EqualizePanes() }},
190- }
191-
192- for _, tt := range tests {
193- t.Run(tt.name, func(t *testing.T) {
194- run := func(t *testing.T, adapter string, attached bool) adapterState {
195- t.Helper()
196- ptys := map[layout.PaneID]*resizeActionPTY{
197- "pane-1": {}, "pane-2": {}, "pane-3": {},
198- }
199- d, sess, ac, _ := newManualSessionWithPTYs(t, ptys["pane-1"])
200- tb := sess.activeTab()
201- p2 := newPane("pane-2", ptys["pane-2"], tb.size)
202- p3 := newPane("pane-3", ptys["pane-3"], tb.size)
203- tb.mu.Lock()
204- tb.panes[p2.id] = p2
205- tb.panes[p3.id] = p3
206- tb.tree = &layout.Tree{
207- Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
208- layout.NewLeaf("pane-1"),
209- {Kind: layout.Split, Dir: layout.Vertical, Weight: 2, Children: []*layout.Node{
210- layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3"),
211- }},
212- }},
213- Focus: "pane-2",
214- }
215- tb.tree.Root.Children[0].Weight = 1
216- tb.tree.Root.Children[1].Children[0].Weight = 1
217- tb.tree.Root.Children[1].Children[1].Weight = 2
218- generation := tb.layoutGeneration
219- tb.mu.Unlock()
220- sess.snapEligible.Store(true)
221- sess.snapshotMu.Lock()
222- dirty := sess.snapshotGeneration
223- sess.snapshotMu.Unlock()
224-
225- invalidations := make(chan renderInvalidation, 2)
226- rc := newRenderCoordinator(renderCoordinatorOptions{
227- wake: func(renderWake) {},
228- onInvalidate: func(inv renderInvalidation) { invalidations <- inv },
229- })
230- sess.installRenderCoordinator(rc)
231- if attached {
232- rc.attach(ac)
233- } else {
234- sess.mu.Lock()
235- sess.client = nil
236- sess.mu.Unlock()
237- }
238-
239- switch adapter {
240- case "key":
241- daemonKeyHandler{d: d, ac: ac}.Action(tt.action, nil)
242- case "palette":
243- require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, ac: ac}))
244- case "control":
245- target := resolveDaemonActionTarget(sess)
246- require.NoError(t, tt.control(controlExec{d: d, sess: sess, target: target}))
247- default:
248- t.Fatalf("unknown adapter %q", adapter)
249- }
250-
251- tb.mu.Lock()
252- tree := tb.tree.Clone()
253- gotGeneration := tb.layoutGeneration
254- placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
255- tb.mu.Unlock()
256- require.True(t, ok)
257- sess.snapshotMu.Lock()
258- gotDirty := sess.snapshotGeneration
259- sess.snapshotMu.Unlock()
260-
261- gotPTY := make(map[layout.PaneID][]domain.Size, len(ptys))
262- for id, pty := range ptys {
263- gotPTY[id] = pty.Sizes()
264- }
265- for _, placement := range placements {
266- require.Equal(t, []domain.Size{{Cols: placement.Content.Width, Rows: placement.Content.Height}}, gotPTY[placement.ID], "canonical PTY resize for %s", placement.ID)
267- }
268- if attached {
269- awaitInvalidation(t, invalidations)
270- requireNoInvalidation(t, invalidations)
271- } else {
272- requireNoInvalidation(t, invalidations)
273- }
274- return adapterState{tree: tree, placements: placements, ptySizes: gotPTY, generationDelta: gotGeneration - generation, snapshotDirtyDelta: gotDirty - dirty}
275- }
276-
277- keyState := run(t, "key", true)
278- require.Equal(t, uint64(1), keyState.generationDelta)
279- require.Equal(t, uint64(1), keyState.snapshotDirtyDelta)
280- require.Equal(t, keyState, run(t, "palette", true))
281- require.Equal(t, keyState, run(t, "control", true))
282- headless := run(t, "control", false)
283- require.Equal(t, uint64(1), headless.generationDelta)
284- require.Equal(t, uint64(1), headless.snapshotDirtyDelta)
285- require.Equal(t, keyState, headless)
286- })
287- }
288-}
289-
290-type resizeActionPTY struct {
291- mu sync.Mutex
292- sizes []domain.Size
293-}
294-
295-func (*resizeActionPTY) Read([]byte) (int, error) { return 0, nil }
296-func (*resizeActionPTY) Write(b []byte) (int, error) { return len(b), nil }
297-func (*resizeActionPTY) Close() error { return nil }
298-func (*resizeActionPTY) Pid() int { return 0 }
299-func (*resizeActionPTY) ForegroundPgid() (int, error) { return 0, nil }
300-func (p *resizeActionPTY) Resize(size domain.Size) error {
301- p.mu.Lock()
302- p.sizes = append(p.sizes, size)
303- p.mu.Unlock()
304- return nil
305-}
306-
307-func (p *resizeActionPTY) Sizes() []domain.Size {
308- p.mu.Lock()
309- defer p.mu.Unlock()
310- return append([]domain.Size(nil), p.sizes...)
311-}
312-
313185 // stubClock returns timers whose channel never fires, so a scheduler under it
314186 // blocks in its debounce loop until the session context is cancelled. Used by
315187
316-func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) {
317- writes1 := make(chan []byte, 1)
318- writes2 := make(chan []byte, 1)
319- writes3 := make(chan []byte, 1)
320- p1, releasePTY1 := newBlockingPTYWithWrites(t, writes1)
321- p2, releasePTY2 := newBlockingPTYWithWrites(t, writes2)
322- p3, releasePTY3 := newBlockingPTYWithWrites(t, writes3)
323- d := newTestDaemon(t, newFactorySeq(t, p1, p2, p3), stubClock{})
324- tr, sends, releaseConn := newConn(t,
325- mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}),
326- frameInput([]byte("\x1b ")),
327- frameInput([]byte("CNT\r")),
328- frameInput([]byte("\x1b ")),
329- frameInput([]byte("CNT\r")),
330- frameInput([]byte("\x1b1")),
331- frameInput([]byte("A")),
332- frameInput([]byte("\x1b2")),
333- frameInput([]byte("B")),
334- frameInput([]byte("\x1b3")),
335- frameInput([]byte("C")),
336- )
337-
338- var hg sync.WaitGroup
339- hg.Go(func() { d.handleConn(tr) })
340- awaitFrame(t, sends, ports.MsgWelcome)
341- awaitFrame(t, sends, ports.MsgOutput)
342-
343- require.Eventually(t, func() bool {
344- sessions := listSessions(t, d)
345- return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 3
346- }, 2*time.Second, 5*time.Millisecond)
347- require.Eventually(t, func() bool { return len(writes1) == 1 }, 2*time.Second, 5*time.Millisecond)
348- requirePTYWrite(t, writes1, []byte("A"))
349- require.Eventually(t, func() bool { return len(writes2) == 1 }, 2*time.Second, 5*time.Millisecond)
350- requirePTYWrite(t, writes2, []byte("B"))
351- require.Eventually(t, func() bool { return len(writes3) == 1 }, 2*time.Second, 5*time.Millisecond)
352- requirePTYWrite(t, writes3, []byte("C"))
353-
354- d.mu.Lock()
355- var sess *session
356- for _, candidate := range d.sessions {
357- if local, ok := localSession(candidate); ok {
358- sess = local
359- break
360- }
361- }
362- d.mu.Unlock()
363- require.NotNil(t, sess)
364- sess.mu.Lock()
365- tabs := append([]*tab(nil), sess.tabs...)
366- sess.mu.Unlock()
367- require.Len(t, tabs, 3)
368- for _, tb := range tabs {
369- requireFloatingInitialized(t, tb)
370- }
371-
372- releaseConn()
373- releasePTY1()
374- releasePTY2()
375- releasePTY3()
376- hg.Wait()
377- d.sessWg.Wait()
378-}
379-
380188 func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) {
381189 d, sess, ac, sends := newManualSessionWithPTYs(t, nil, nil)
382190 source := sess.tabs[0].focusedPane()
@@ -401,7 +209,7 @@ func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) {
401209 // Use the real key action: it requests the mandatory complete repaint for
402210 // the first target-tab frame.
403211 daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2, nil)
404- require.Equal(t, 1, activeTabIndex(sess))
212+ require.Equal(t, 1, testAttachmentTabIndex(sess))
405213
406214 second := awaitFrame(t, sends, ports.MsgOutput)
407215 secondOutput, err := ports.UnmarshalOutput(second.Payload)
@@ -496,7 +304,7 @@ func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) {
496304 floating := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 5})
497305 floating.ctx = floatingCtx
498306 floating.screen.Write([]byte("popup-content"))
499- installTestFloating(sess.activeTab(), floating, false)
307+ installTestFloating(testAttachmentTab(sess), floating, false)
500308
501309 // Establish the client shadow while the retained popup is hidden.
502310 d.paint(sess, ac, true, nil)
@@ -518,7 +326,7 @@ func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) {
518326 d.handleInput(sess, ac, []byte("\x1bf"))
519327 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
520328 require.NotContains(t, strings.Join(frameRows(client.Frame), "\n"), "popup-content")
521- tb := sess.activeTab()
329+ tb := testAttachmentTab(sess)
522330 tb.mu.Lock()
523331 require.Equal(t, floatingHidden, tb.floating.state)
524332 require.Same(t, floating, tb.floating.pane)
@@ -586,7 +394,7 @@ func TestFloatingKeyboardRoutesVisibleAndHiddenInput(t *testing.T) {
586394 defer releaseNormal()
587395 defer releaseFloating()
588396 d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
589- installTestFloating(sess.activeTab(), newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), tc.visible)
397+ installTestFloating(testAttachmentTab(sess), newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), tc.visible)
590398
591399 daemonKeyHandler{d: d, ac: ac}.Forward(tc.input)
592400 if tc.visible {
@@ -608,7 +416,7 @@ func TestFloatingBracketedPasteAndGlobalActions(t *testing.T) {
608416 defer releaseNormal()
609417 defer releaseFloating()
610418 d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
611- installTestFloating(sess.activeTab(), newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), true)
419+ installTestFloating(testAttachmentTab(sess), newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), true)
612420
613421 paste := []byte("\x1b[200~paste\x1bf\x1b[201~")
614422 d.handleInput(sess, ac, paste)
@@ -626,7 +434,7 @@ func TestFloatingStaysTerminalTargetAfterDirectionalFocus(t *testing.T) {
626434 defer releaseNormal()
627435 defer releaseFloating()
628436 d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
629- tb := sess.activeTab()
437+ tb := testAttachmentTab(sess)
630438 second := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 5})
631439 tb.mu.Lock()
632440 tb.size = domain.Size{Cols: 81, Rows: 5}
@@ -658,7 +466,7 @@ func TestActionFocusPaneAtEdgeStaysSilent(t *testing.T) {
658466 // the user as a notice instead of being silently discarded.
659467 func TestActionFocusPaneGenuineErrorReportsNoticeInternal(t *testing.T) {
660468 d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
661- tb := sess.activeTab()
469+ tb := testAttachmentTab(sess)
662470 tb.mu.Lock()
663471 tb.tree = nil
664472 tb.mu.Unlock()
@@ -675,7 +483,7 @@ func TestActionFocusPaneGenuineErrorReportsNoticeInternal(t *testing.T) {
675483
676484 func TestFloatingVisibilityRemainsIndependentAcrossTabSwitches(t *testing.T) {
677485 d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
678- first := sess.activeTab()
486+ first := testAttachmentTab(sess)
679487 second := newTab(nil, first.size)
680488 installTestFloating(first, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
681489 installTestFloating(second, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), false)
@@ -699,7 +507,7 @@ func TestFloatingMouseTranslatesSGRToInnerCoordinates(t *testing.T) {
699507 floatingPTY := portsmocks.NewMockPTY(t)
700508 floatingPTY.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()
701509 d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
702- tb := sess.activeTab()
510+ tb := testAttachmentTab(sess)
703511 tb.mu.Lock()
704512 tb.size = domain.Size{Cols: 100, Rows: 20}
705513 tb.mu.Unlock()
@@ -716,7 +524,7 @@ func TestFloatingMouseIgnoresBorderAndOutside(t *testing.T) {
716524 normal := portsmocks.NewMockPTY(t)
717525 floatingPTY := portsmocks.NewMockPTY(t)
718526 d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
719- tb := sess.activeTab()
527+ tb := testAttachmentTab(sess)
720528 tb.mu.Lock()
721529 tb.size = domain.Size{Cols: 100, Rows: 20}
722530 tb.mu.Unlock()
@@ -736,7 +544,7 @@ func TestResponsiveDrawerFloatingMouseRoutesOnlyInnerClientCells(t *testing.T) {
736544 floatingPTY := portsmocks.NewMockPTY(t)
737545 floatingPTY.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()
738546 d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
739- tb := sess.activeTab()
547+ tb := testAttachmentTab(sess)
740548 complete := domain.Size{Cols: 79, Rows: 25}
741549 content := tabSize(complete)
742550 geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig())
@@ -760,7 +568,7 @@ func TestResponsiveDrawerCopyMouseMapsInnerAndPinsDragAwayFromChrome(t *testing.
760568 p, release := newBlockingPTY(t)
761569 defer release()
762570 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
763- tb := sess.activeTab()
571+ tb := testAttachmentTab(sess)
764572 complete := domain.Size{Cols: 79, Rows: 25}
765573 content := tabSize(complete)
766574 geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig())
@@ -960,12 +768,12 @@ func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {
960768 release()
961769 }
962770 }()
963- sess.active = tc.start
771+ selectTestAttachmentTab(sess, tc.start)
964772
965773 d.handleInput(sess, ac, []byte("\x1b "))
966774 d.handleInput(sess, ac, tc.query)
967775
968- require.Equal(t, tc.wantIndex, activeTabIndex(sess))
776+ require.Equal(t, tc.wantIndex, testAttachmentTabIndex(sess))
969777 })
970778 }
971779 }
@@ -1015,7 +823,7 @@ func TestBackSessionReportsStaleHandoffOnce(t *testing.T) {
1015823 // A displaced attachment makes the previously valid handoff stale by the
1016824 // time switchToTarget commits it.
1017825 current.mu.Lock()
1018- current.client = nil
826+ clearAttachmentsForTestLocked(current)
1019827 current.mu.Unlock()
1020828
1021829 d.backSession(current, ac)
@@ -1200,14 +1008,14 @@ func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) {
12001008 p2, releasePTY2 := newBlockingPTY(t)
12011009 p3, releasePTY3 := newBlockingPTYWithWrites(t, writes)
12021010 d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2, p3)
1203- sess.active = 1
1011+ selectTestAttachmentTab(sess, 1)
12041012
12051013 d.handleInput(sess, ac, []byte("\x1b "))
12061014 d.handleInput(sess, ac, []byte("CLT\r"))
12071015
12081016 require.Equal(t, 1, sessionCount(d))
12091017 require.Len(t, sess.tabs, 2)
1210- require.Equal(t, 1, activeTabIndex(sess), "closing middle tab selects the next remaining tab")
1018+ require.Equal(t, 1, testAttachmentTabIndex(sess), "closing middle tab selects the next remaining tab")
12111019 d.handleInput(sess, ac, []byte("Z"))
12121020 require.Eventually(t, func() bool { return len(writes) == 1 }, 2*time.Second, 5*time.Millisecond)
12131021 requirePTYWrite(t, writes, []byte("Z"))
@@ -1224,7 +1032,7 @@ func TestAltDDetachesCurrentClient(t *testing.T) {
12241032 d.handleInput(sess, ac, []byte("\x1b "))
12251033 d.handleInput(sess, ac, []byte("DET\r"))
12261034
1227- require.Nil(t, sess.client)
1035+ require.Empty(t, sess.snapshotAttachments())
12281036 f := awaitFrame(t, sends, ports.MsgDetached)
12291037 det, err := ports.UnmarshalDetached(f.Payload)
12301038 require.NoError(t, err)
@@ -1258,7 +1066,7 @@ func TestRNTOpensPromptAndRenamesActiveTab(t *testing.T) {
12581066 release()
12591067 }
12601068 }()
1261- sess.active = 1
1069+ selectTestAttachmentTab(sess, 1)
12621070
12631071 d.handleInput(sess, ac, []byte("\x1b "))
12641072 awaitFrame(t, sends, ports.MsgOutput)
@@ -1639,7 +1447,7 @@ func TestMouseWheelOverUnfocusedPaneDoesNotFocusAndForwardsChildMouse(t *testing
16391447 p2.EXPECT().Write([]byte("\x1b[<64;1;1M")).Return(len("\x1b[<64;1;1M"), nil).Once()
16401448 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
16411449 d.procComm = nil
1642- tb := sess.activeTab()
1450+ tb := testAttachmentTab(sess)
16431451 p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})
16441452 p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
16451453 tb.mu.Lock()
@@ -1663,7 +1471,7 @@ func TestRejectedLeftReleaseInvalidatesFreshPointerBeforeMotion(t *testing.T) {
16631471 {
16641472 name: "split divider",
16651473 setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, _ *[]byte) []byte {
1666- tb := sess.activeTab()
1474+ tb := testAttachmentTab(sess)
16671475 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
16681476 tb.mu.Lock()
16691477 tb.size = domain.Size{Cols: 41, Rows: 10}
@@ -1676,7 +1484,7 @@ func TestRejectedLeftReleaseInvalidatesFreshPointerBeforeMotion(t *testing.T) {
16761484 {
16771485 name: "split title bar",
16781486 setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, _ *[]byte) []byte {
1679- tb := sess.activeTab()
1487+ tb := testAttachmentTab(sess)
16801488 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
16811489 tb.mu.Lock()
16821490 tb.size = domain.Size{Cols: 41, Rows: 10}
@@ -1689,7 +1497,7 @@ func TestRejectedLeftReleaseInvalidatesFreshPointerBeforeMotion(t *testing.T) {
16891497 {
16901498 name: "floating exterior",
16911499 setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, press *[]byte) []byte {
1692- tb := sess.activeTab()
1500+ tb := testAttachmentTab(sess)
16931501 floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5})
16941502 installTestFloating(tb, floating, true)
16951503 tb.mu.Lock()
@@ -1749,7 +1557,7 @@ func TestPressAtFormerStackTitleRowIsTreatedAsExpandedContent(t *testing.T) {
17491557 p, release := newBlockingPTY(t)
17501558 defer release()
17511559 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1752- tb := sess.activeTab()
1560+ tb := testAttachmentTab(sess)
17531561 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 9})
17541562 p3 := newPane("pane-3", nil, domain.Size{Cols: 20, Rows: 1})
17551563 tb.mu.Lock()
@@ -1821,7 +1629,7 @@ func TestMouseDividerAndTitleBarDoNotForwardBogusCoordinates(t *testing.T) {
18211629 p2.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
18221630 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
18231631 d.procComm = nil
1824- tb := sess.activeTab()
1632+ tb := testAttachmentTab(sess)
18251633 tb.focusedPane().screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
18261634 tb.mu.Lock()
18271635 tb.size = tc.size
@@ -1844,7 +1652,7 @@ func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) {
18441652 p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Maybe()
18451653 d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
18461654 d.procComm = nil
1847- tb := sess.activeTab()
1655+ tb := testAttachmentTab(sess)
18481656 for i := range 10 {
18491657 copy(tb.focusedPane().screen.Frame.Row(i), testRow(string(rune('a'+i))))
18501658 }
@@ -1878,7 +1686,7 @@ func TestMouseHitTestFocusesPaneAndTranslatesSGRColumns(t *testing.T) {
18781686 p2.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()
18791687 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
18801688 d.procComm = nil
1881- tb := sess.activeTab()
1689+ tb := testAttachmentTab(sess)
18821690 p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})
18831691 p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
18841692 tb.mu.Lock()
@@ -1905,7 +1713,7 @@ func TestMouseGatedWhileNoticesOverlayActive(t *testing.T) {
19051713 p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
19061714 d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
19071715 d.procComm = nil
1908- tb := sess.activeTab()
1716+ tb := testAttachmentTab(sess)
19091717 p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})
19101718 p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
19111719 tb.mu.Lock()
@@ -1935,7 +1743,7 @@ func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) {
19351743 p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 4}).Return(nil).Maybe()
19361744 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
19371745 d.procComm = nil
1938- tb := sess.activeTab()
1746+ tb := testAttachmentTab(sess)
19391747 p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 3})
19401748 tb.mu.Lock()
19411749 tb.size = domain.Size{Cols: 20, Rows: 5}
@@ -1954,7 +1762,7 @@ func TestActiveCopyPressOnOtherPaneFocusesAndExitsBeforeFutureInput(t *testing.T
19541762 p1, release := newBlockingPTY(t)
19551763 defer release()
19561764 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1957- tb := sess.activeTab()
1765+ tb := testAttachmentTab(sess)
19581766 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
19591767 tb.mu.Lock()
19601768 tb.size = domain.Size{Cols: 41, Rows: 10}
@@ -2053,8 +1861,8 @@ func TestActiveCopyMouseIgnoresStalePointerResets(t *testing.T) {
20531861 name: "press-owned release rejects mapping",
20541862 prepare: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) mouse.Event {
20551863 t.Helper()
2056- p := sess.activeTab().focusedPane()
2057- tb := sess.activeTab()
1864+ p := testAttachmentTab(sess).focusedPane()
1865+ tb := testAttachmentTab(sess)
20581866 tb.mu.Lock()
20591867 geometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), 1, 2)
20601868 tb.mu.Unlock()
@@ -2069,7 +1877,7 @@ func TestActiveCopyMouseIgnoresStalePointerResets(t *testing.T) {
20691877 name: "current geometry disappears",
20701878 prepare: func(t *testing.T, _ *Daemon, sess *session, _ *attachedClient) mouse.Event {
20711879 t.Helper()
2072- tb := sess.activeTab()
1880+ tb := testAttachmentTab(sess)
20731881 tb.mu.Lock()
20741882 tb.tree = nil
20751883 tb.mu.Unlock()
@@ -2080,7 +1888,7 @@ func TestActiveCopyMouseIgnoresStalePointerResets(t *testing.T) {
20801888 name: "fresh press rejects document mapping",
20811889 prepare: func(t *testing.T, _ *Daemon, sess *session, _ *attachedClient) mouse.Event {
20821890 t.Helper()
2083- tb := sess.activeTab()
1891+ tb := testAttachmentTab(sess)
20841892 tb.mu.Lock()
20851893 tb.size.Rows = 40 // valid layout coordinate, outside the old document.
20861894 tb.mu.Unlock()
@@ -2109,7 +1917,7 @@ func TestActiveCopyMouseIgnoresStalePointerResets(t *testing.T) {
21091917 }
21101918 defer func() { d.beforeCopyMouseMap = nil }()
21111919
2112- d.handleActiveCopyMouse(sess, ac, sess.activeTab(), ev)
1920+ d.handleActiveCopyMouse(sess, ac, testAttachmentTab(sess), ev)
21131921
21141922 ac.overlays.copyMu.Lock()
21151923 defer ac.overlays.copyMu.Unlock()
@@ -2128,7 +1936,7 @@ func TestActiveCopyMouseRejectsViewportChangeAfterMappingSnapshot(t *testing.T)
21281936 p, release := newBlockingPTY(t)
21291937 defer release()
21301938 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
2131- pane := sess.activeTab().focusedPane()
1939+ pane := testAttachmentTab(sess).focusedPane()
21321940 for range 8 {
21331941 appendHistoryRow(t, pane.history, testRow("history"))
21341942 }
@@ -2162,13 +1970,13 @@ func TestFreshCopyPressUsesFrameAbsoluteHitTestGeometry(t *testing.T) {
21621970 {
21631971 name: "mono",
21641972 setup: func(_ *testing.T, _ *Daemon, sess *session) (*pane, mouse.Event) {
2165- return sess.activeTab().focusedPane(), mouse.Event{Button: mouse.Left, Type: mouse.Press, Col: 1, Row: 1}
1973+ return testAttachmentTab(sess).focusedPane(), mouse.Event{Button: mouse.Left, Type: mouse.Press, Col: 1, Row: 1}
21661974 },
21671975 },
21681976 {
21691977 name: "split",
21701978 setup: func(_ *testing.T, _ *Daemon, sess *session) (*pane, mouse.Event) {
2171- tb := sess.activeTab()
1979+ tb := testAttachmentTab(sess)
21721980 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
21731981 tb.mu.Lock()
21741982 tb.size = domain.Size{Cols: 41, Rows: 10}
@@ -2182,8 +1990,8 @@ func TestFreshCopyPressUsesFrameAbsoluteHitTestGeometry(t *testing.T) {
21821990 name: "floating",
21831991 setup: func(t *testing.T, d *Daemon, sess *session) (*pane, mouse.Event) {
21841992 p := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5})
2185- installTestFloating(sess.activeTab(), p, true)
2186- tb := sess.activeTab()
1993+ installTestFloating(testAttachmentTab(sess), p, true)
1994+ tb := testAttachmentTab(sess)
21871995 tb.mu.Lock()
21881996 _, geometry, visible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
21891997 tb.mu.Unlock()
@@ -2197,7 +2005,7 @@ func TestFreshCopyPressUsesFrameAbsoluteHitTestGeometry(t *testing.T) {
21972005 defer release()
21982006 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
21992007 wantPane, ev := tc.setup(t, d, sess)
2200- tb := sess.activeTab()
2008+ tb := testAttachmentTab(sess)
22012009 tb.mu.Lock()
22022010 wantGeometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), ev.Col, ev.Row)
22032011 tb.mu.Unlock()
M internal/usecase/daemon/lease_callback_guard_test.go +1 -1
@@ -54,7 +54,7 @@ func TestResizeAndRetryCallbacksRejectSameObjectLeaseReplacement(t *testing.T) {
5454 done := tc.done(t, rc)
5555 require.NotNil(t, done)
5656
57- rc.notePark(ac)
57+ rc.noteDetach(ac)
5858 rc.attach(ac) // resume the exact same object under a new incarnation
5959 timer.ch <- time.Time{}
6060 awaitTestCompletion(t, done, "stale callback did not complete")
A internal/usecase/daemon/lifecycle_fence_test.go +86 -0
@@ -0,0 +1,86 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestValidateAttachmentTransitionLocksSourceCoordinatorBeforeLeaseValidation(t *testing.T) {
11+ d := newTestDaemon(t, nil, stubClock{})
12+ transport := &closeTrackingTransport{}
13+ source := &session{sessionCore: sessionCore{id: domain.SessionID("source")}}
14+ target := &session{sessionCore: sessionCore{id: domain.SessionID("target")}}
15+ ac := &attachedClient{tr: transport}
16+ ac.setSession(source)
17+ source.mu.Lock()
18+ registered := source.registerAttachmentLocked(ac)
19+ source.mu.Unlock()
20+ require.True(t, registered)
21+ sourceCoordinator := newRenderCoordinator(renderCoordinatorOptions{})
22+ source.installRenderCoordinator(sourceCoordinator)
23+ sourceCoordinator.attach(ac)
24+ token := source.attachmentToken(ac, transport)
25+ require.NotNil(t, token.lease)
26+ d.sessions[source.id] = source
27+ d.sessions[target.id] = target
28+
29+ checked := false
30+ coordinatorUnlocked := false
31+ d.afterAttachmentTransitionCoordinatorsLocked = func() {
32+ coordinatorUnlocked = sourceCoordinator.mu.TryLock()
33+ if coordinatorUnlocked {
34+ sourceCoordinator.mu.Unlock()
35+ }
36+ checked = true
37+ }
38+ req := attachmentTransitionRequest{
39+ source: source, target: target, next: ac,
40+ expectedTransport: ac.transportSnapshot(), sourceToken: &token,
41+ preflighted: true, attachmentEffectsFrozen: true,
42+ }
43+ d.mu.Lock()
44+ d.notices.routingMu.Lock()
45+ unlockSessions := lockAttachmentSessions(source, target)
46+ publication, err := d.validateAttachmentTransitionPrelocked(req)
47+ unlockSessions()
48+ d.notices.routingMu.Unlock()
49+ d.mu.Unlock()
50+ if publication != nil {
51+ publication.unlockCoordinators()
52+ }
53+ require.NoError(t, err)
54+ require.False(t, coordinatorUnlocked, "source coordinator was not held during lease validation")
55+ require.True(t, checked)
56+ require.NotNil(t, publication)
57+}
58+
59+func TestAttachmentTokenRevalidatesTheCapturedTransportIncarnation(t *testing.T) {
60+ first := &closeTrackingTransport{}
61+ second := &closeTrackingTransport{}
62+ ac := &attachedClient{tr: first}
63+ sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}}
64+ ac.setSession(sess)
65+ sess.mu.Lock()
66+ registered := sess.registerAttachmentLocked(ac)
67+ sess.mu.Unlock()
68+ require.True(t, registered)
69+
70+ captured := make(chan struct{})
71+ release := make(chan struct{})
72+ ac.beforeAttachmentTokenValidation = func() {
73+ close(captured)
74+ <-release
75+ }
76+ result := make(chan attachmentConnectionToken, 1)
77+ go func() { result <- sess.attachmentToken(ac, first) }()
78+ <-captured
79+ ac.replaceTransport(second)
80+ close(release)
81+
82+ token := <-result
83+ require.Nil(t, token.ac, "a token must not bind a replacement transport")
84+ require.Equal(t, uint64(1), ac.transportSnapshot().incarnation)
85+ require.Same(t, second, ac.transport())
86+}
M internal/usecase/daemon/mouse_panes.go +0 -16
@@ -10,22 +10,6 @@ import (
1010 "github.com/bnema/vev/internal/usecase/mouse"
1111 )
1212
13-func focusedPlacementLocked(tb *tab) (layout.Placement, bool) {
14- if tb == nil || tb.tree == nil {
15- return layout.Placement{}, false
16- }
17- placements, ok := solvedPlacementsLocked(tb)
18- if !ok {
19- return layout.Placement{}, false
20- }
21- for _, pl := range placements {
22- if pl.ID == tb.tree.Focus {
23- return pl, true
24- }
25- }
26- return layout.Placement{}, false
27-}
28-
2913 func hitTestPlacementLocked(tb *tab, col, row int) (layout.Placement, bool) {
3014 if tb == nil || tb.tree == nil || tb.tree.Root == nil || !tb.size.Valid() {
3115 return layout.Placement{}, false
M internal/usecase/daemon/move.go +45 -112
@@ -10,6 +10,8 @@ import (
1010 // immutable identity. Session names are advisory; the ID and incarnation are
1111 // the commit-time authority.
1212 type movePaneRequest struct {
13+ Attachment *attachedClient
14+ AttachmentToken attachmentConnectionToken
1315 Source moveSessionLocator
1416 SourceTabID domain.TabStableID
1517 SourcePaneID domain.PaneStableID
@@ -18,9 +20,11 @@ type movePaneRequest struct {
1820 }
1921
2022 type moveTabRequest struct {
21- Source moveSessionLocator
22- SourceTabID domain.TabStableID
23- Destination moveSessionLocator
23+ Attachment *attachedClient
24+ AttachmentToken attachmentConnectionToken
25+ Source moveSessionLocator
26+ SourceTabID domain.TabStableID
27+ Destination moveSessionLocator
2428 }
2529
2630 // movePane resolves, reserves, validates, and commits one pane relocation. All
@@ -42,6 +46,20 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
4246 return errMoveStaleTarget
4347 }
4448
49+ // Dispatch admission must precede lifecycle reservation. A final close holds
50+ // dispatchMu while it waits for teardown ownership; reserving first would let
51+ // that close wait on a move which is itself blocked on dispatchMu.
52+ if d.beforeMoveDispatch != nil {
53+ d.beforeMoveDispatch()
54+ }
55+ unlockDispatch := lockMoveDispatch(source, destination)
56+ dispatchHeld := true
57+ defer func() {
58+ if dispatchHeld {
59+ unlockDispatch()
60+ }
61+ }()
62+
4563 reservation, err := d.reserveMoveLifecycles(source, destination)
4664 if err != nil {
4765 return errMoveStaleTarget
@@ -56,14 +74,6 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
5674 d.afterMoveLifecycleReserved()
5775 }
5876
59- unlockDispatch := lockMoveDispatch(source, destination)
60- dispatchHeld := true
61- defer func() {
62- if dispatchHeld {
63- unlockDispatch()
64- }
65- }()
66-
6777 // Snapshot the exact live objects and generations before waiting on resize
6878 // fences. The locked commit repeats every authority check before publication.
6979 admission, err := d.snapshotMovePaneAdmission(req, source, destination)
@@ -73,10 +83,6 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
7383 sourceTab := admission.sourceTab
7484 destinationTab := admission.destinationTab
7585 movedPane := admission.movedPane
76- sourceClient := admission.sourceClient
77- destinationClient := admission.destinationClient
78- sourceSnatched := admission.sourceSnatched
79- sourceTabWasActive := admission.sourceTabWasActive
8086 sourceTabInitialGeneration := admission.sourceGeneration
8187 destinationTabInitialGeneration := admission.destinationGeneration
8288 finalSourceTab := admission.finalSourceTab
@@ -84,90 +90,32 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
8490 d.afterMovePaneSourceSnapshot()
8591 }
8692
87- // A final source tab with an active client needs the centralized attachment
88- // transition. Freeze that exact role set before entering the fence callback;
89- // the callback then validates the handoff before changing any topology.
90- var handoffReq attachmentTransitionRequest
91- var handoffParticipants attachmentTransitionParticipants
92- var frozen frozenRoleEffectGates
93- var sourceRolesFrozen bool
94- var handoffFrozen bool
95-
96- if finalSourceTab && source != destination && sourceClient != nil {
97- transport := sourceClient.transportSnapshot()
98- sourceToken := source.attachmentToken(sourceClient, transport.transport)
99- if sourceToken.role != attachmentActive || sourceToken.transport.transport == nil {
100- return errMovePaneInvalid
101- }
102- destinationTabIndex := moveTabIndex(destination, destinationTab)
103- if destinationTabIndex < 0 {
104- return errMovePaneInvalid
105- }
106- handoffReq = attachmentTransitionRequest{
107- source: source, target: destination, next: sourceClient,
108- expectedRole: attachmentActive, targetRole: attachmentActive,
109- expectedTransport: sourceToken.transport, sourceToken: &sourceToken,
110- expectedSourceTab: sourceTab, activateTargetTab: true,
111- targetTabIndex: destinationTabIndex, ready: true,
112- }
113- var err error
114- handoffReq, handoffParticipants, err = d.snapshotAttachmentTransition(handoffReq)
115- if err != nil {
116- return err
117- }
118- // Retiring source snatched clients is part of the same role gate set as
119- // the follower. Their exact membership is checked again in the commit.
120- d.mu.Lock()
121- source.mu.Lock()
122- for _, ac := range sourceSnatched {
123- handoffParticipants.clients = append(handoffParticipants.clients, ac)
124- tr := ac.transportSnapshot()
125- if tr.transport != nil {
126- handoffParticipants.interrupts = append(handoffParticipants.interrupts, roleTransportInterrupt{ac: ac, transport: tr})
93+ // A final source tab is retired without transferring any attachment to the
94+ // destination. Freeze every affected source connection before publication so
95+ // teardown invalidates exact generations rather than racing input or render.
96+ var frozen frozenAttachmentEffectGates
97+ var sourceEffectsFrozen bool
98+ if finalSourceTab && source != destination && len(admission.sourceAttachments) != 0 {
99+ interrupts := make([]attachmentTransportInterrupt, 0, len(admission.sourceAttachments))
100+ for _, ac := range admission.sourceAttachments {
101+ if transport := admission.sourceTransports[ac]; transport.transport != nil {
102+ interrupts = append(interrupts, attachmentTransportInterrupt{ac: ac, transport: transport})
127103 }
128104 }
129- source.mu.Unlock()
130- d.mu.Unlock()
131- frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: handoffParticipants.interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) {
132- if d.afterRoleEffectGateFrozen != nil {
133- d.afterRoleEffectGateFrozen("move-pane", ac)
105+ frozen = freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) {
106+ if d.afterAttachmentEffectGateFrozen != nil {
107+ d.afterAttachmentEffectGateFrozen("move-pane", ac)
134108 }
135- }}, handoffParticipants.clients...)
109+ }}, admission.sourceAttachments...)
136110 if !frozen.acquired || !frozen.drained {
137111 frozen.unfreeze()
138112 return errMovePaneInvalid
139113 }
140- handoffFrozen = true
141- sourceRolesFrozen = true
142- handoffReq.roleEffectsFrozen = true
143- } else if finalSourceTab && source != destination && len(sourceSnatched) > 0 {
144- // A headless source may still have persistent snatched clients. Freeze
145- // their exact role gates before retiring the source registry.
146- d.mu.Lock()
147- source.mu.Lock()
148- participants := append([]*attachedClient(nil), sourceSnatched...)
149- interrupts := make([]roleTransportInterrupt, 0, len(sourceSnatched))
150- for _, ac := range sourceSnatched {
151- if transport := ac.transportSnapshot(); transport.transport != nil {
152- interrupts = append(interrupts, roleTransportInterrupt{ac: ac, transport: transport})
153- }
154- }
155- source.mu.Unlock()
156- d.mu.Unlock()
157- frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) {
158- if d.afterRoleEffectGateFrozen != nil {
159- d.afterRoleEffectGateFrozen("move-pane", ac)
160- }
161- }}, participants...)
162- if !frozen.acquired || !frozen.drained {
163- frozen.unfreeze()
164- return errMovePaneInvalid
165- }
166- sourceRolesFrozen = true
114+ sourceEffectsFrozen = true
167115 }
168- if sourceRolesFrozen {
116+ if sourceEffectsFrozen {
169117 defer func() {
170- if sourceRolesFrozen {
118+ if sourceEffectsFrozen {
171119 frozen.unfreeze()
172120 }
173121 }()
@@ -180,17 +128,14 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
180128 sourceTab: sourceTab,
181129 destinationTab: destinationTab,
182130 movedPane: movedPane,
183- sourceClient: sourceClient,
184- sourceSnatched: sourceSnatched,
131+ sourceAttachments: admission.sourceAttachments,
132+ sourceTransports: admission.sourceTransports,
185133 sourceGeneration: sourceTabInitialGeneration,
186134 destinationGeneration: destinationTabInitialGeneration,
187- handoffFrozen: handoffFrozen,
188- frozenRoles: frozen,
189- handoffReq: handoffReq,
135+ frozenEffects: frozen,
190136 }
191137 fences := newMovePaneResizeFences(source, destination, sourceTab, destinationTab, movedPane)
192138 if !fences.acquire(func() bool { return commit.publishLocked(d) }) {
193- commit.releasePublication()
194139 if commit.err != nil {
195140 return commit.err
196141 }
@@ -205,15 +150,12 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
205150 sourceTab: sourceTab,
206151 destinationTab: destinationTab,
207152 movedPane: movedPane,
208- destinationClient: destinationClient,
209- sourceCleanupToken: commit.sourceCleanupToken,
210- handoffResult: commit.handoffResult,
153+ sourceAttachments: admission.sourceAttachments,
211154 syncCleanup: commit.syncCleanup,
212- frozenRoles: frozen,
213- rolesFrozen: sourceRolesFrozen,
155+ frozenEffects: frozen,
156+ effectsFrozen: sourceEffectsFrozen,
214157 unlockDispatch: unlockDispatch,
215158 reservation: reservation,
216- sourceTabWasActive: sourceTabWasActive,
217159 sourceTabRemoved: commit.sourceTabRemoved,
218160 sourceEmpty: commit.sourceEmpty,
219161 retiredParked: commit.retiredParked,
@@ -225,7 +167,7 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
225167 }
226168 // Transfer cleanup ownership away from the admission defers only after the
227169 // complete postcommit plan exists.
228- sourceRolesFrozen = false
170+ sourceEffectsFrozen = false
229171 dispatchHeld = false
230172 reservationHeld = false
231173 postcommit.execute(d)
@@ -261,15 +203,6 @@ func findMoveTabLocked(sess *session, stableID domain.TabStableID) *tab {
261203 return nil
262204 }
263205
264-func moveTabIndex(sess *session, target *tab) int {
265- if sess == nil || target == nil {
266- return -1
267- }
268- sess.mu.Lock()
269- defer sess.mu.Unlock()
270- return indexMoveTabLocked(sess, target)
271-}
272-
273206 func indexMoveTabLocked(sess *session, target *tab) int {
274207 for i, tb := range sess.tabs {
275208 if tb == target {
M internal/usecase/daemon/move_acceptance_test.go +4 -4
@@ -89,7 +89,7 @@ func TestMovePaneAcceptanceRetainsSourceFocusAndDestinationActivity(t *testing.T
8989
9090 destinationActive := newTabWithStableID("destination-active", "active-pane", activePTY, domain.Size{Cols: 80, Rows: 23})
9191 destinationTab := newTabWithStableID("destination-target", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})
92- destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{destinationActive, destinationTab}, active: 0}
92+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{destinationActive, destinationTab}}
9393 publishTiledPaneOwners(destination, destinationActive)
9494 publishTiledPaneOwners(destination, destinationTab)
9595 d.mu.Lock()
@@ -140,7 +140,7 @@ func TestMovePaneAcceptanceRetainsSourceFocusAndDestinationActivity(t *testing.T
140140 require.Same(t, remaining, remainingPane)
141141 require.Equal(t, layout.PaneID("pane-2"), sourceFocus, "source focus must use the valid fallback leaf")
142142 destination.mu.Lock()
143- destinationActiveTab := destination.tabs[destination.active]
143+ destinationActiveTab := destination.tabs[testAttachmentTabIndexLocked(destination)]
144144 destination.mu.Unlock()
145145 require.Same(t, destinationActive, destinationActiveTab, "destination active tab must remain unchanged")
146146 destinationTab.mu.Lock()
@@ -182,7 +182,7 @@ func TestMovePaneRejectsStaleIncarnationWithoutMutation(t *testing.T) {
182182 sourceTab := source.tabs[0]
183183 sourceTab.stableID = "source-tab"
184184 moved := sourceTab.focusedPane()
185- destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0}
185+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}
186186 destinationTab := destination.tabs[0]
187187 publishTiledPaneOwners(destination, destinationTab)
188188 d.mu.Lock()
@@ -231,7 +231,7 @@ func TestMovePaneRejectsStaleLayoutGenerationBeforeMutation(t *testing.T) {
231231 sourceTab := source.tabs[0]
232232 sourceTab.stableID = "source-tab"
233233 moved := sourceTab.focusedPane()
234- destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0}
234+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}
235235 destinationTab := destination.tabs[0]
236236 publishTiledPaneOwners(destination, destinationTab)
237237 d.mu.Lock()
D internal/usecase/daemon/move_atomicity_test.go +0 -183
@@ -1,183 +0,0 @@
1-package daemon
2-
3-import (
4- "testing"
5-
6- "github.com/stretchr/testify/require"
7-
8- "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/internal/usecase/layout"
10-)
11-
12-type moveCommitObservation struct {
13- sourceRegistered bool
14- sourceTabMember bool
15- sourcePaneMember bool
16- destinationTabMember bool
17- destinationPaneMember bool
18- sourceActive *tab
19- destinationActive *tab
20- sourceFocus layout.PaneID
21- destinationFocus layout.PaneID
22- ownerSession *session
23- ownerTab *tab
24- sourceRole attachmentRole
25- destinationRole attachmentRole
26- sourceClientSession *session
27-}
28-
29-// readMoveCommitObservation follows the same architecture lock order as the
30-// move commit. Every field is therefore one supported reader's coherent view,
31-// rather than a collection of lock-free observations that could straddle a
32-// publication boundary.
33-func readMoveCommitObservation(d *Daemon, source, destination *session, sourceTab, destinationTab *tab, movedPane *pane, sourceClient *attachedClient) moveCommitObservation {
34- d.mu.Lock()
35- d.notices.routingMu.Lock()
36- unlockSessions := lockAttachmentSessions(source, destination)
37- unlockTabs := lockMoveTabs(sourceTab, destinationTab)
38- movedPane.mu.Lock()
39-
40- owner := movedPane.ownerSnapshot()
41- observation := moveCommitObservation{
42- sourceRegistered: d.sessions[source.id] == source,
43- sourceTabMember: moveTabMemberLocked(source, sourceTab),
44- sourcePaneMember: sourceTab.panes[movedPane.id] == movedPane,
45- destinationTabMember: moveTabMemberLocked(destination, destinationTab),
46- destinationPaneMember: destinationTab.panes[movedPane.id] == movedPane,
47- sourceActive: activeMoveTabLocked(source),
48- destinationActive: activeMoveTabLocked(destination),
49- sourceFocus: sourceTab.tree.Focus,
50- destinationFocus: destinationTab.tree.Focus,
51- ownerSession: nil,
52- ownerTab: nil,
53- sourceRole: source.attachmentRoleLocked(sourceClient),
54- destinationRole: destination.attachmentRoleLocked(sourceClient),
55- sourceClientSession: sourceClient.currentSession(),
56- }
57- if owner != nil {
58- observation.ownerSession = owner.session
59- observation.ownerTab = owner.tab
60- }
61- movedPane.mu.Unlock()
62- unlockTabs()
63- unlockSessions()
64- d.notices.routingMu.Unlock()
65- d.mu.Unlock()
66- return observation
67-}
68-
69-func activeMoveTabLocked(sess *session) *tab {
70- if sess == nil || sess.active < 0 || sess.active >= len(sess.tabs) {
71- return nil
72- }
73- return sess.tabs[sess.active]
74-}
75-
76-func TestMovePaneCommitPointHidesPartialPublication(t *testing.T) {
77- p1, release1 := newBlockingPTY(t)
78- p2, release2 := newBlockingPTY(t)
79- defer release1()
80- defer release2()
81-
82- d, source, sourceClient, _ := newManualSessionWithPTYs(t, p1)
83- sourceTab := source.tabs[0]
84- sourceTab.stableID = "source-tab"
85- movedPane := sourceTab.focusedPane()
86-
87- destination := &session{sessionCore: sessionCore{id: "destination",
88- name: "destination",
89- ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})},
90- active: 0,
91- }
92- destinationTab := destination.tabs[0]
93- publishTiledPaneOwners(destination, destinationTab)
94- d.mu.Lock()
95- d.sessions[destination.id] = destination
96- d.mu.Unlock()
97- require.NotNil(t, d.attachCoordinator(source, nil, sourceClient, true))
98-
99- commitEntered := make(chan struct{})
100- releaseCommit := make(chan struct{})
101- d.beforeMovePaneCommit = func() {
102- close(commitEntered)
103- <-releaseCommit
104- }
105-
106- moveDone := make(chan error, 1)
107- go func() {
108- moveDone <- d.movePane(movePaneRequest{
109- Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
110- SourceTabID: domain.TabStableID(sourceTab.stableID),
111- SourcePaneID: domain.PaneStableID(movedPane.stableID),
112- Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
113- DestinationTabID: domain.TabStableID(destinationTab.stableID),
114- })
115- }()
116- <-commitEntered
117-
118- type reader struct {
119- name string
120- read func(moveCommitObservation) bool
121- }
122- readers := []reader{
123- {name: "source membership", read: func(o moveCommitObservation) bool {
124- return o.sourceRegistered || o.sourceTabMember || o.sourcePaneMember
125- }},
126- {name: "destination membership", read: func(o moveCommitObservation) bool { return o.destinationTabMember && !o.destinationPaneMember }},
127- {name: "owner", read: func(o moveCommitObservation) bool { return o.ownerSession == source && o.ownerTab == sourceTab }},
128- {name: "active tab and focus", read: func(o moveCommitObservation) bool {
129- return o.sourceActive == sourceTab || o.sourceFocus != layout.PaneID("pane-1") || o.destinationActive != destinationTab || o.destinationFocus != layout.PaneID("pane-2")
130- }},
131- {name: "attachment roles", read: func(o moveCommitObservation) bool {
132- return o.sourceRole == attachmentActive && o.destinationRole == attachmentDetached && o.sourceClientSession == source
133- }},
134- }
135- started := make(chan string, len(readers))
136- done := make(chan struct {
137- name string
138- observation moveCommitObservation
139- }, len(readers))
140- for _, current := range readers {
141- go func(current reader) {
142- started <- current.name
143- observation := readMoveCommitObservation(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient)
144- done <- struct {
145- name string
146- observation moveCommitObservation
147- }{name: current.name, observation: observation}
148- }(current)
149- }
150- for range readers {
151- <-started
152- }
153- for range readers {
154- select {
155- case result := <-done:
156- t.Fatalf("%s reader completed while the move commit was paused: %#v", result.name, result.observation)
157- default:
158- }
159- }
160-
161- close(releaseCommit)
162- require.NoError(t, <-moveDone)
163- for _, current := range readers {
164- result := <-done
165- require.False(t, current.read(result.observation), "%s observed partial move state: %#v", current.name, result.observation)
166- }
167-
168- observation := readMoveCommitObservation(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient)
169- require.False(t, observation.sourceRegistered)
170- require.False(t, observation.sourceTabMember)
171- require.False(t, observation.sourcePaneMember)
172- require.True(t, observation.destinationTabMember)
173- require.True(t, observation.destinationPaneMember)
174- require.Nil(t, observation.sourceActive)
175- require.Equal(t, layout.PaneID("pane-1"), observation.sourceFocus)
176- require.Same(t, destinationTab, observation.destinationActive)
177- require.Equal(t, layout.PaneID("pane-2"), observation.destinationFocus)
178- require.Same(t, destination, observation.ownerSession)
179- require.Same(t, destinationTab, observation.ownerTab)
180- require.Equal(t, attachmentDetached, observation.sourceRole)
181- require.Equal(t, attachmentActive, observation.destinationRole)
182- require.Same(t, destination, observation.sourceClientSession)
183-}
M internal/usecase/daemon/move_attachments.go +26 -30
@@ -1,46 +1,42 @@
11 package daemon
22
3-// frozenMoveAttachmentRetirement is proof that every source snatched client
4-// retired by a final move has had its exact role-effect gate frozen and drained.
5-// It is prepared while the source session lock is held, before topology writes.
6-type frozenMoveAttachmentRetirement struct {
7- clients []*attachedClient
3+func moveAttachmentTokenCurrentLocked(token attachmentConnectionToken, sess *session) bool {
4+ return token.sess == sess && token.ac != nil && token.generation == token.ac.connectionGeneration.Load() &&
5+ token.ac.currentAttachmentSession() == sess && token.ac.transportSnapshotCurrent(token.transport) &&
6+ attachmentRegisteredLocked(sess, token.ac)
87 }
98
10-func snapshotMoveSnatchedLocked(sess *session) []*attachedClient {
11- clients := make([]*attachedClient, 0, len(sess.snatched))
12- for ac := range sess.snatched {
13- clients = append(clients, ac)
14- }
15- return clients
16-}
17-
18-func sameMoveSnatchedLocked(sess *session, admitted []*attachedClient) bool {
19- if sess == nil || len(sess.snatched) != len(admitted) {
9+// sameMoveAttachmentsLocked reports whether the source membership is unchanged
10+// since admission. Caller holds the source session lock.
11+func sameMoveAttachmentsLocked(sess *session, admitted []*attachedClient) bool {
12+ if sess == nil || len(sess.attachments) != len(admitted) {
2013 return false
2114 }
2215 for _, ac := range admitted {
23- if _, ok := sess.snatched[ac]; !ok {
16+ if !attachmentRegisteredLocked(sess, ac) {
2417 return false
2518 }
2619 }
2720 return true
2821 }
2922
30-func prepareFrozenMoveAttachmentRetirementLocked(sess *session, admitted []*attachedClient, frozen frozenRoleEffectGates) (frozenMoveAttachmentRetirement, bool) {
31- if !sameMoveSnatchedLocked(sess, admitted) {
32- return frozenMoveAttachmentRetirement{}, false
33- }
34- if len(admitted) == 0 {
35- return frozenMoveAttachmentRetirement{}, true
23+// detachMoveAttachmentsLocked invalidates every attachment of a source session
24+// that became empty. Moves never transfer attachment ownership to the target;
25+// each connection is retired independently after the shared content move.
26+// Caller holds the source session lock and the affected attachment gates are
27+// frozen.
28+func detachMoveAttachmentsLocked(sess *session, transports map[*attachedClient]transportSnapshot) []detachedAttachmentSnapshot {
29+ if sess == nil || len(sess.attachments) == 0 {
30+ return nil
3631 }
37- if !frozen.acquired || !frozen.drained {
38- return frozenMoveAttachmentRetirement{}, false
39- }
40- for _, ac := range admitted {
41- if !frozen.contains(ac) {
42- return frozenMoveAttachmentRetirement{}, false
43- }
32+ attachments := sess.snapshotAttachmentsLocked()
33+ retired := make([]detachedAttachmentSnapshot, 0, len(attachments))
34+ for _, ac := range attachments {
35+ retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: transports[ac]})
36+ ac.connectionGeneration.Add(1)
37+ ac.setSession(nil)
38+ ac.invalidateFrozenAttachmentCapability()
4439 }
45- return frozenMoveAttachmentRetirement{clients: append([]*attachedClient(nil), admitted...)}, true
40+ clear(sess.attachments)
41+ return retired
4642 }
M internal/usecase/daemon/move_cleanup_test.go +32 -19
@@ -9,6 +9,23 @@ import (
99 scopy "github.com/bnema/vev/internal/usecase/copy"
1010 )
1111
12+func TestDetachMoveAttachmentsUsesStableSnapshotOrder(t *testing.T) {
13+ first := &attachedClient{clientID: [16]byte{1}}
14+ second := &attachedClient{clientID: [16]byte{2}}
15+ sess := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{second: {}, first: {}}}}
16+ frozen := freezeAttachmentEffectGates(first, second)
17+ require.True(t, frozen.acquired)
18+ defer frozen.unfreeze()
19+
20+ sess.mu.Lock()
21+ retired := detachMoveAttachmentsLocked(sess, nil)
22+ sess.mu.Unlock()
23+
24+ require.Len(t, retired, 2)
25+ require.Same(t, first, retired[0].ac)
26+ require.Same(t, second, retired[1].ac)
27+}
28+
1229 func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {
1330 movedPTY, releaseMoved := newBlockingPTY(t)
1431 remainingPTY, releaseRemaining := newBlockingPTY(t)
@@ -18,7 +35,7 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {
1835 defer releaseDestination()
1936
2037 d, source, client, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY)
21- require.NotNil(t, d.attachCoordinator(source, nil, client, true))
38+ require.NotNil(t, d.attachCoordinator(source, client, true))
2239 sourceTab := source.tabs[0]
2340 sourceTab.stableID = "source-tab"
2441 moved := sourceTab.focusedPane()
@@ -26,9 +43,7 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {
2643 client.captureFrames = map[*pane]capturedPaneRenderState{moved: {}}
2744 client.sendMu.Unlock()
2845
29- destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
30- active: 0,
31- }
46+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}
3247 destinationTab := destination.tabs[0]
3348 publishTiledPaneOwners(destination, destinationTab)
3449 d.mu.Lock()
@@ -43,7 +58,7 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {
4358 require.NotNil(t, rc)
4459 lease := rc.attachmentLease(client)
4560 epoch := rc.recordResizeRequestForLease(client.size, client, lease)
46- ticket, admitted := beginActiveLeaseEffect(source, client, lease)
61+ ticket, admitted := beginAttachmentLeaseEffect(source, client, lease)
4762 require.True(t, admitted)
4863 defer ticket.End()
4964
@@ -102,20 +117,18 @@ func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) {
102117 sourceTab.stableID = "source-tab"
103118 remainingTab.stableID = "remaining-tab"
104119 moved := sourceTab.focusedPane()
105- require.NotNil(t, d.attachCoordinator(source, nil, displaced, true))
120+ require.NotNil(t, d.attachCoordinator(source, displaced, true))
106121
107- destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
108- active: 0,
109- }
122+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}
110123 destinationTab := destination.tabs[0]
111124 publishTiledPaneOwners(destination, destinationTab)
112125 d.mu.Lock()
113126 d.sessions[destination.id] = destination
114127 d.mu.Unlock()
115128
116- // Both clients carry state tied specifically to the pane. The displaced
117- // client was captured before the fence wait; the replacement is the only
118- // attachment that may be cleaned after commit.
129+ // Both clients carry state tied specifically to the pane. The existing
130+ // attachment and the concurrently registered attachment remain independent;
131+ // moving one exact attachment must not clean the other.
119132 displaced.overlays.copyMu.Lock()
120133 displaced.overlays.copyPane = moved
121134 displaced.overlays.copyMode = &scopy.Mode{}
@@ -163,27 +176,27 @@ func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) {
163176 awaitTestCompletion(t, snapshot, "move did not capture its pre-fence source attachment")
164177
165178 source.mu.Lock()
166- source.client = replacement
179+ source.registerAttachmentLocked(replacement)
167180 source.mu.Unlock()
168181 close(releaseSnapshot)
169182 source.layoutApplyMu.Unlock()
170183 require.NoError(t, awaitTestValue(t, moveDone, "move did not finish after source fence release"))
171184
172185 displaced.overlays.copyMu.Lock()
173- require.NotNil(t, displaced.overlays.copyMode, "displaced source client was incorrectly cleaned")
174- require.Same(t, moved, displaced.overlays.copyPane)
186+ require.Nil(t, displaced.overlays.copyMode, "moved attachment copy mode was not cleaned")
187+ require.Nil(t, displaced.overlays.copyPane)
175188 displaced.overlays.copyMu.Unlock()
176189 displaced.sendMu.Lock()
177190 _, displacedCaptured := displaced.captureFrames[moved]
178191 displaced.sendMu.Unlock()
179- require.True(t, displacedCaptured, "displaced source capture was incorrectly pruned")
192+ require.False(t, displacedCaptured, "moved attachment capture was not pruned")
180193
181194 replacement.overlays.copyMu.Lock()
182- require.Nil(t, replacement.overlays.copyMode, "current source client copy mode was not cleaned")
183- require.Nil(t, replacement.overlays.copyPane)
195+ require.NotNil(t, replacement.overlays.copyMode, "independent attachment copy mode was cleaned")
196+ require.Same(t, moved, replacement.overlays.copyPane)
184197 replacement.overlays.copyMu.Unlock()
185198 replacement.sendMu.Lock()
186199 _, replacementCaptured := replacement.captureFrames[moved]
187200 replacement.sendMu.Unlock()
188- require.False(t, replacementCaptured, "current source capture was not pruned")
201+ require.True(t, replacementCaptured, "independent attachment capture was pruned")
189202 }
D internal/usecase/daemon/move_final_source_attachment_race_test.go +0 -211
@@ -1,211 +0,0 @@
1-package daemon
2-
3-import (
4- "testing"
5-
6- "github.com/stretchr/testify/require"
7-
8- "github.com/bnema/vev/internal/domain"
9-)
10-
11-type finalMoveAttachmentMutation int
12-
13-const (
14- installActiveAfterFinalMoveAdmission finalMoveAttachmentMutation = iota
15- installSnatchedAfterFinalMoveAdmission
16- replaceSnatchedAfterFinalMoveAdmission
17-)
18-
19-func newFinalMoveRaceClient(sess *session) *attachedClient {
20- ac := &attachedClient{
21- tr: &closeTrackingTransport{},
22- output: newOutputStateStream(),
23- size: domain.Size{Cols: 80, Rows: 24},
24- }
25- ac.initOverlays()
26- ac.setSession(sess)
27- return ac
28-}
29-
30-func makeFinalMoveSourceHeadless(source *session, original *attachedClient) {
31- source.mu.Lock()
32- source.client = nil
33- source.mu.Unlock()
34- original.setSession(nil)
35-}
36-
37-func mutateFinalMoveAttachments(source *session, mutation finalMoveAttachmentMutation) (current, replaced *attachedClient) {
38- current = newFinalMoveRaceClient(source)
39- source.mu.Lock()
40- switch mutation {
41- case installActiveAfterFinalMoveAdmission:
42- source.client = current
43- case installSnatchedAfterFinalMoveAdmission:
44- source.addSnatchedLocked(current)
45- case replaceSnatchedAfterFinalMoveAdmission:
46- for ac := range source.snatched {
47- replaced = ac
48- delete(source.snatched, ac)
49- ac.setSession(nil)
50- break
51- }
52- source.addSnatchedLocked(current)
53- }
54- source.mu.Unlock()
55- return current, replaced
56-}
57-
58-func assertRejectedFinalMoveAttachmentPreserved(t *testing.T, source *session, current, replaced *attachedClient, mutation finalMoveAttachmentMutation) {
59- t.Helper()
60- require.Same(t, source, current.currentSession(), "the attachment admitted during the race must not be orphaned")
61- switch mutation {
62- case installActiveAfterFinalMoveAdmission:
63- require.Equal(t, attachmentActive, source.attachmentRole(current))
64- case installSnatchedAfterFinalMoveAdmission, replaceSnatchedAfterFinalMoveAdmission:
65- require.Equal(t, attachmentSnatched, source.attachmentRole(current))
66- }
67- if replaced != nil {
68- require.Nil(t, replaced.currentSession())
69- require.Equal(t, attachmentDetached, source.attachmentRole(replaced))
70- }
71-}
72-
73-func TestMovePaneFinalHeadlessRejectsAttachmentChangesAfterAdmissionAtomically(t *testing.T) {
74- for _, tc := range []struct {
75- name string
76- mutation finalMoveAttachmentMutation
77- existing bool
78- }{
79- {name: "new active", mutation: installActiveAfterFinalMoveAdmission},
80- {name: "new snatched", mutation: installSnatchedAfterFinalMoveAdmission},
81- {name: "count preserving snatched replacement", mutation: replaceSnatchedAfterFinalMoveAdmission, existing: true},
82- } {
83- t.Run(tc.name, func(t *testing.T) {
84- d, source, sourceTab, moved, destination, destinationTab, release := newMoveGateRaceFixture(t)
85- defer release()
86- original := source.client
87- makeFinalMoveSourceHeadless(source, original)
88- if tc.existing {
89- existing := newFinalMoveRaceClient(source)
90- source.mu.Lock()
91- source.addSnatchedLocked(existing)
92- source.mu.Unlock()
93- }
94-
95- sourceGeneration := sourceTab.layoutGeneration
96- destinationGeneration := destinationTab.layoutGeneration
97- destinationPaneAtMovedID := destinationTab.panes[moved.id]
98- owner := moved.ownerSnapshot()
99- barrier := make(chan struct{})
100- releaseMove := make(chan struct{})
101- d.afterMovePaneSourceSnapshot = func() {
102- close(barrier)
103- <-releaseMove
104- }
105- defer func() { d.afterMovePaneSourceSnapshot = nil }()
106-
107- moveDone := make(chan error, 1)
108- go func() {
109- moveDone <- d.movePane(moveGateRaceRequest(source, sourceTab, moved, destination, destinationTab))
110- }()
111- waitMoveRace(t, barrier, "move pane admission")
112- current, replaced := mutateFinalMoveAttachments(source, tc.mutation)
113- currentGeneration := current.roleGeneration.Load()
114- close(releaseMove)
115- err := waitMoveRace(t, moveDone, "move pane rejection")
116-
117- require.ErrorIs(t, err, errMoveStaleTarget)
118- d.mu.Lock()
119- require.Same(t, source, d.sessions[source.id])
120- require.Same(t, destination, d.sessions[destination.id])
121- d.mu.Unlock()
122- source.mu.Lock()
123- require.Equal(t, []*tab{sourceTab}, source.tabs)
124- source.mu.Unlock()
125- sourceTab.mu.Lock()
126- require.Same(t, moved, sourceTab.panes[moved.id])
127- require.Equal(t, sourceGeneration, sourceTab.layoutGeneration)
128- sourceTab.mu.Unlock()
129- destinationTab.mu.Lock()
130- require.Same(t, destinationPaneAtMovedID, destinationTab.panes[moved.id])
131- require.Equal(t, destinationGeneration, destinationTab.layoutGeneration)
132- destinationTab.mu.Unlock()
133- require.Equal(t, owner, moved.ownerSnapshot())
134- require.Equal(t, currentGeneration, current.roleGeneration.Load())
135- assertRejectedFinalMoveAttachmentPreserved(t, source, current, replaced, tc.mutation)
136- })
137- }
138-}
139-
140-func TestMoveTabFinalHeadlessRejectsAttachmentChangesAfterAdmissionAtomically(t *testing.T) {
141- for _, tc := range []struct {
142- name string
143- mutation finalMoveAttachmentMutation
144- existing bool
145- }{
146- {name: "new active", mutation: installActiveAfterFinalMoveAdmission},
147- {name: "new snatched", mutation: installSnatchedAfterFinalMoveAdmission},
148- {name: "count preserving snatched replacement", mutation: replaceSnatchedAfterFinalMoveAdmission, existing: true},
149- } {
150- t.Run(tc.name, func(t *testing.T) {
151- d, source, original, _ := newManualSessionWithPTYs(t, newQuietPTY())
152- moved := source.tabs[0]
153- moved.stableID = "moved-tab"
154- makeFinalMoveSourceHeadless(source, original)
155- if tc.existing {
156- existing := newFinalMoveRaceClient(source)
157- source.mu.Lock()
158- source.addSnatchedLocked(existing)
159- source.mu.Unlock()
160- }
161- destination := addMoveTabTestSession(d, "destination", "destination-tab")
162- destinationTabs := append([]*tab(nil), destination.tabs...)
163- sourceGeneration := moved.layoutGeneration
164- owners := make(map[*pane]*paneOwner, len(moved.panes))
165- for _, p := range moved.panes {
166- owners[p] = p.ownerSnapshot()
167- }
168-
169- barrier := make(chan struct{})
170- releaseMove := make(chan struct{})
171- d.afterMoveTabSourceSnapshot = func() {
172- close(barrier)
173- <-releaseMove
174- }
175- defer func() { d.afterMoveTabSourceSnapshot = nil }()
176- moveDone := make(chan error, 1)
177- go func() {
178- moveDone <- d.moveTab(moveTabRequest{
179- Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
180- SourceTabID: domain.TabStableID(moved.stableID),
181- Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
182- })
183- }()
184- waitMoveRace(t, barrier, "move tab admission")
185- current, replaced := mutateFinalMoveAttachments(source, tc.mutation)
186- currentGeneration := current.roleGeneration.Load()
187- close(releaseMove)
188- err := waitMoveRace(t, moveDone, "move tab rejection")
189-
190- require.ErrorIs(t, err, errMoveStaleTarget)
191- d.mu.Lock()
192- require.Same(t, source, d.sessions[source.id])
193- require.Same(t, destination, d.sessions[destination.id])
194- d.mu.Unlock()
195- source.mu.Lock()
196- require.Equal(t, []*tab{moved}, source.tabs)
197- source.mu.Unlock()
198- destination.mu.Lock()
199- require.Equal(t, destinationTabs, destination.tabs)
200- destination.mu.Unlock()
201- moved.mu.Lock()
202- require.Equal(t, sourceGeneration, moved.layoutGeneration)
203- moved.mu.Unlock()
204- for p, owner := range owners {
205- require.Equal(t, owner, p.ownerSnapshot())
206- }
207- require.Equal(t, currentGeneration, current.roleGeneration.Load())
208- assertRejectedFinalMoveAttachmentPreserved(t, source, current, replaced, tc.mutation)
209- })
210- }
211-}
M internal/usecase/daemon/move_gate_race_test.go +141 -10
@@ -19,11 +19,9 @@ func newMoveGateRaceFixture(t *testing.T) (*Daemon, *session, *tab, *pane, *sess
1919 sourceTab := source.tabs[0]
2020 sourceTab.stableID = "source-tab"
2121 moved := sourceTab.focusedPane()
22- require.NotNil(t, d.attachCoordinator(source, nil, client, true))
22+ require.NotNil(t, d.attachCoordinator(source, client, true))
2323
24- destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
25- active: 0,
26- }
24+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}
2725 destinationTab := destination.tabs[0]
2826 publishTiledPaneOwners(destination, destinationTab)
2927 d.mu.Lock()
@@ -76,7 +74,7 @@ func TestMovePaneGateReservationRaceKillWinsWithoutDeadlock(t *testing.T) {
7674 killFrozen := make(chan struct{})
7775 releaseKill := make(chan struct{})
7876 var killFrozenOnce sync.Once
79- d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) {
77+ d.afterAttachmentEffectGateFrozen = func(action string, _ *attachedClient) {
8078 if action != "" {
8179 return
8280 }
@@ -85,7 +83,7 @@ func TestMovePaneGateReservationRaceKillWinsWithoutDeadlock(t *testing.T) {
8583 }
8684 defer func() {
8785 d.afterMoveLifecycleReserved = nil
88- d.afterRoleEffectGateFrozen = nil
86+ d.afterAttachmentEffectGateFrozen = nil
8987 }()
9088
9189 moveDone := make(chan error, 1)
@@ -113,6 +111,139 @@ func TestMovePaneGateReservationRaceKillWinsWithoutDeadlock(t *testing.T) {
113111 require.NotSame(t, moved, destination.tabs[0].panes[moved.id], "kill winner must not publish a partial destination move")
114112 }
115113
114+func TestFinalCloseDoesNotDeadlockWithMoveDispatchAdmission(t *testing.T) {
115+ tests := []struct {
116+ name string
117+ close func(*Daemon, *session, *tab, *pane) error
118+ move func(*Daemon, *session, *tab, *pane, *session, *tab) error
119+ }{
120+ {name: "pane", close: func(d *Daemon, source *session, sourceTab *tab, _ *pane) error {
121+ return d.closeTab(source, sourceTab, true)
122+ }, move: func(d *Daemon, source *session, sourceTab *tab, moved *pane, destination *session, destinationTab *tab) error {
123+ return d.movePane(moveGateRaceRequest(source, sourceTab, moved, destination, destinationTab))
124+ }},
125+ {name: "pane-reap", close: func(d *Daemon, _ *session, _ *tab, moved *pane) error {
126+ d.reapPaneOwner(moved)
127+ return nil
128+ }, move: func(d *Daemon, source *session, sourceTab *tab, moved *pane, destination *session, destinationTab *tab) error {
129+ return d.movePane(moveGateRaceRequest(source, sourceTab, moved, destination, destinationTab))
130+ }},
131+ {name: "tab", close: func(d *Daemon, source *session, sourceTab *tab, _ *pane) error {
132+ return d.closeTab(source, sourceTab, true)
133+ }, move: func(d *Daemon, source *session, sourceTab *tab, _ *pane, destination *session, _ *tab) error {
134+ return d.moveTab(moveTabRequest{
135+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(sourceTab.stableID),
136+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
137+ })
138+ }},
139+ }
140+ for _, test := range tests {
141+ t.Run(test.name, func(t *testing.T) {
142+ d, source, sourceTab, moved, destination, destinationTab, releasePTYs := newMoveGateRaceFixture(t)
143+ defer releasePTYs()
144+
145+ closeReady := make(chan struct{})
146+ releaseClose := make(chan struct{})
147+ d.afterAttachmentEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) {
148+ if action != "" {
149+ return
150+ }
151+ close(closeReady)
152+ <-releaseClose
153+ }
154+ moveDispatchReady := make(chan struct{})
155+ d.beforeMoveDispatch = func() { close(moveDispatchReady) }
156+ defer func() {
157+ d.afterAttachmentEffectParticipantsSnapshotted = nil
158+ d.beforeMoveDispatch = nil
159+ }()
160+
161+ closeDone := make(chan error, 1)
162+ go func() { closeDone <- test.close(d, source, sourceTab, moved) }()
163+ waitMoveRace(t, closeReady, "final close participant snapshot")
164+
165+ moveDone := make(chan error, 1)
166+ go func() { moveDone <- test.move(d, source, sourceTab, moved, destination, destinationTab) }()
167+ waitMoveRace(t, moveDispatchReady, "move dispatch admission")
168+ close(releaseClose)
169+
170+ require.NoError(t, waitMoveRace(t, closeDone, "final close"))
171+ require.ErrorIs(t, waitMoveRace(t, moveDone, "move after final close"), errMoveStaleTarget)
172+ d.mu.Lock()
173+ _, sourceLive := d.sessions[source.id]
174+ _, destinationLive := d.sessions[destination.id]
175+ d.mu.Unlock()
176+ require.False(t, sourceLive, "final close must retire the source")
177+ require.True(t, destinationLive)
178+ if test.name == "pane" || test.name == "pane-reap" {
179+ require.NotSame(t, moved, destinationTab.panes[moved.id], "a rejected move must not publish into the destination")
180+ }
181+ })
182+ }
183+}
184+
185+func TestPaletteFinalCloseEndsCurrentEffectBeforeDrainingPeers(t *testing.T) {
186+ d, sess, current, _ := newManualSessionWithPTYs(t, newQuietPTY())
187+ d.attachCoordinator(sess, current, true)
188+ otherTransport := &closeTrackingTransport{}
189+ other := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: current.size}
190+ other.initOverlays()
191+ other.setSession(sess)
192+ sess.mu.Lock()
193+ require.True(t, sess.registerAttachmentLocked(other))
194+ sess.mu.Unlock()
195+ d.attachCoordinator(sess, other, true)
196+
197+ currentToken := sess.attachmentToken(current, current.transport())
198+ currentEffect, admitted := current.beginAttachmentEffect(currentToken)
199+ require.True(t, admitted)
200+ t.Cleanup(currentEffect.End)
201+ otherToken := sess.attachmentToken(other, otherTransport)
202+ otherEffect, admitted := other.beginAttachmentEffect(otherToken)
203+ require.True(t, admitted)
204+ t.Cleanup(otherEffect.End)
205+
206+ currentEnded := make(chan struct{})
207+ currentReleasedBeforePeerFreeze := make(chan struct{})
208+ otherFrozen := make(chan struct{})
209+ d.afterActionAttachmentEffectEnded = func(action string) {
210+ if action == "close-tab" {
211+ close(currentEnded)
212+ }
213+ }
214+ d.afterAttachmentEffectGateFrozen = func(action string, ac *attachedClient) {
215+ if action == "close-tab" && ac == other {
216+ if currentEffect.ended.Load() {
217+ close(currentReleasedBeforePeerFreeze)
218+ }
219+ close(otherFrozen)
220+ }
221+ }
222+ defer func() {
223+ d.afterActionAttachmentEffectEnded = nil
224+ d.afterAttachmentEffectGateFrozen = nil
225+ }()
226+
227+ closeDone := make(chan error, 1)
228+ go func() {
229+ sess.dispatchMu.Lock()
230+ defer sess.dispatchMu.Unlock()
231+ closeDone <- (paletteExec{d: d, sess: sess, ac: current, effect: currentEffect}).CloseTab()
232+ }()
233+ waitMoveRace(t, otherFrozen, "peer effect gate freeze")
234+ waitMoveRace(t, currentReleasedBeforePeerFreeze, "current effect release before peer drain")
235+ waitMoveRace(t, currentEnded, "current palette effect completion")
236+ select {
237+ case err := <-closeDone:
238+ t.Fatalf("final close drained its own peer before release: %v", err)
239+ default:
240+ }
241+ otherEffect.End()
242+ require.NoError(t, waitMoveRace(t, closeDone, "palette final close"))
243+ requireAttachmentEffectGateRetired(t, current)
244+ requireAttachmentEffectGateRetired(t, other)
245+}
246+
116247 func TestMovePaneGateReservationRaceMoveWinsWithoutDeadlock(t *testing.T) {
117248 d, source, sourceTab, moved, destination, destinationTab, releasePTYs := newMoveGateRaceFixture(t)
118249 defer releasePTYs()
@@ -120,7 +251,7 @@ func TestMovePaneGateReservationRaceMoveWinsWithoutDeadlock(t *testing.T) {
120251 moveGateFrozen := make(chan struct{})
121252 releaseMove := make(chan struct{})
122253 var moveGateFrozenOnce sync.Once
123- d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) {
254+ d.afterAttachmentEffectGateFrozen = func(action string, _ *attachedClient) {
124255 if action != "move-pane" {
125256 return
126257 }
@@ -129,14 +260,14 @@ func TestMovePaneGateReservationRaceMoveWinsWithoutDeadlock(t *testing.T) {
129260 }
130261 killSnapshotted := make(chan struct{})
131262 var killSnapshottedOnce sync.Once
132- d.afterRoleEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) {
263+ d.afterAttachmentEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) {
133264 if action == "" {
134265 killSnapshottedOnce.Do(func() { close(killSnapshotted) })
135266 }
136267 }
137268 defer func() {
138- d.afterRoleEffectGateFrozen = nil
139- d.afterRoleEffectParticipantsSnapshotted = nil
269+ d.afterAttachmentEffectGateFrozen = nil
270+ d.afterAttachmentEffectParticipantsSnapshotted = nil
140271 }()
141272
142273 moveDone := make(chan error, 1)
D internal/usecase/daemon/move_handoff_failure_test.go +0 -336
@@ -1,336 +0,0 @@
1-package daemon
2-
3-import (
4- "context"
5- "maps"
6- "testing"
7-
8- "github.com/stretchr/testify/require"
9-
10- "github.com/bnema/vev/internal/domain"
11- "github.com/bnema/vev/internal/usecase/layout"
12-)
13-
14-type moveTabState struct {
15- ptr *tab
16- stableID string
17- tree *layout.Tree
18- panes map[layout.PaneID]*pane
19- nextPaneID int
20- layoutGeneration uint64
21- floatingState floatingState
22- floatingPane *pane
23- floatingVisible bool
24- floatingGeneration uint64
25- ctxDone bool
26-}
27-
28-type moveSessionState struct {
29- ptr *session
30- id domain.SessionID
31- name string
32- incarnation domain.IncarnationID
33- tabs []*tab
34- active int
35- client *attachedClient
36- snatched map[*attachedClient]struct{}
37- moveReservations uint
38- snapshotGeneration uint64
39- snapshotPublished uint64
40- snapshotDirty bool
41- snapshotEligible bool
42- ctxDone bool
43-}
44-
45-type moveLeaseState struct {
46- lease *attachmentLease
47- attachment *attachedClient
48- ready bool
49- active bool
50-}
51-
52-type moveClientState struct {
53- transport transportSnapshot
54- roleGeneration uint64
55- currentSession *session
56- sourceRole attachmentRole
57- destinationRole attachmentRole
58- sourceLease moveLeaseState
59- destinationLease moveLeaseState
60- roleEffectPhase roleEffectPhase
61-}
62-
63-type movePaneState struct {
64- id layout.PaneID
65- stableID string
66- owner *paneOwner
67- ownerGeneration uint64
68- ctxDone bool
69-}
70-
71-type moveState struct {
72- closing bool
73- registry map[domain.SessionID]attachmentSession
74- source moveSessionState
75- destination moveSessionState
76- sourceTab moveTabState
77- destinationTab moveTabState
78- movedPane movePaneState
79- client moveClientState
80-}
81-
82-func captureMoveState(d *Daemon, source, destination *session, sourceTab, destinationTab *tab, movedPane *pane, client *attachedClient) moveState {
83- clientState := captureMoveClientState(source, destination, client)
84- d.mu.Lock()
85- d.notices.routingMu.Lock()
86- unlockSessions := lockAttachmentSessions(source, destination)
87- unlockTabs := lockMoveTabs(sourceTab, destinationTab)
88- movedPane.mu.Lock()
89-
90- registry := make(map[domain.SessionID]attachmentSession, len(d.sessions))
91- maps.Copy(registry, d.sessions)
92- state := moveState{
93- closing: d.closing,
94- registry: registry,
95- source: captureMoveSessionStateLocked(source),
96- destination: captureMoveSessionStateLocked(destination),
97- sourceTab: captureMoveTabStateLocked(sourceTab),
98- destinationTab: captureMoveTabStateLocked(destinationTab),
99- movedPane: movePaneState{
100- id: movedPane.id,
101- stableID: movedPane.stableID,
102- owner: movedPane.ownerSnapshot(),
103- ownerGeneration: movedPane.ownerGeneration,
104- ctxDone: moveContextDone(movedPane.ctx),
105- },
106- client: clientState,
107- }
108-
109- movedPane.mu.Unlock()
110- unlockTabs()
111- unlockSessions()
112- d.notices.routingMu.Unlock()
113- d.mu.Unlock()
114- return state
115-}
116-
117-func captureMoveSessionStateLocked(sess *session) moveSessionState {
118- snatched := make(map[*attachedClient]struct{}, len(sess.snatched))
119- maps.Copy(snatched, sess.snatched)
120- return moveSessionState{
121- ptr: sess,
122- id: sess.id,
123- name: sess.name,
124- incarnation: sess.incarnation,
125- tabs: append([]*tab(nil), sess.tabs...),
126- active: sess.active,
127- client: sess.client,
128- snatched: snatched,
129- moveReservations: sess.moveReservations,
130- snapshotGeneration: sess.snapshotGeneration,
131- snapshotPublished: sess.snapshotPublishedGeneration,
132- snapshotDirty: sess.snapDirty.Load(),
133- snapshotEligible: sess.snapEligible.Load(),
134- ctxDone: moveContextDone(sess.ctx),
135- }
136-}
137-
138-func captureMoveTabStateLocked(tb *tab) moveTabState {
139- panes := make(map[layout.PaneID]*pane, len(tb.panes))
140- maps.Copy(panes, tb.panes)
141- return moveTabState{
142- ptr: tb,
143- stableID: tb.stableID,
144- tree: tb.tree.Clone(),
145- panes: panes,
146- nextPaneID: tb.nextPaneID,
147- layoutGeneration: tb.layoutGeneration,
148- floatingState: tb.floating.state,
149- floatingPane: tb.floating.pane,
150- floatingVisible: tb.floating.desiredVisible,
151- floatingGeneration: tb.floating.generation,
152- ctxDone: moveContextDone(tb.ctx),
153- }
154-}
155-
156-// captureMoveClientState snapshots coordinator and role-effect state before
157-// callers acquire daemon/session/tab move-state locks. This keeps coordinator
158-// and role-gate locks above, never beneath, the outer move fixture locks.
159-func captureMoveClientState(source, destination *session, client *attachedClient) moveClientState {
160- sourceLease := captureMoveLeaseState(source, client)
161- destinationLease := captureMoveLeaseState(destination, client)
162- roleEffectPhase := captureMoveRoleEffectPhase(client)
163- return moveClientState{
164- transport: client.transportSnapshot(),
165- roleGeneration: client.roleGeneration.Load(),
166- currentSession: client.currentSession(),
167- sourceRole: source.attachmentRole(client),
168- destinationRole: destination.attachmentRole(client),
169- sourceLease: sourceLease,
170- destinationLease: destinationLease,
171- roleEffectPhase: roleEffectPhase,
172- }
173-}
174-
175-func captureMoveLeaseState(sess *session, client *attachedClient) moveLeaseState {
176- coordinator := sess.renderCoordinator()
177- if coordinator == nil {
178- return moveLeaseState{}
179- }
180- coordinator.mu.Lock()
181- defer coordinator.mu.Unlock()
182- lease := coordinator.lease
183- if lease == nil || lease.attachment != client {
184- return moveLeaseState{lease: lease}
185- }
186- return moveLeaseState{lease: lease, attachment: lease.attachment, ready: lease.ready, active: lease.active}
187-}
188-
189-func captureMoveRoleEffectPhase(client *attachedClient) roleEffectPhase {
190- client.roleEffects.mu.Lock()
191- defer client.roleEffects.mu.Unlock()
192- return client.roleEffects.phase
193-}
194-
195-func moveContextDone(ctx context.Context) bool {
196- return ctx != nil && ctx.Err() != nil
197-}
198-
199-func TestMovePaneFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) {
200- p1, release1 := newBlockingPTY(t)
201- p2, release2 := newBlockingPTY(t)
202- defer release1()
203- defer release2()
204-
205- d, source, sourceClient, _ := newManualSessionWithPTYs(t, p1)
206- sourceTab := source.tabs[0]
207- sourceTab.stableID = "source-tab"
208- movedPane := sourceTab.focusedPane()
209-
210- destination := &session{sessionCore: sessionCore{id: "destination",
211- name: "destination",
212- ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})},
213- active: 0,
214- }
215- destinationTab := destination.tabs[0]
216- publishTiledPaneOwners(destination, destinationTab)
217- d.mu.Lock()
218- d.sessions[destination.id] = destination
219- d.mu.Unlock()
220- require.NotNil(t, d.attachCoordinator(source, nil, sourceClient, true))
221-
222- before := captureMoveState(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient)
223- originalTransport := before.client.transport
224- staleTransport := &closeTrackingTransport{}
225- staleInjected := make(chan struct{})
226- d.afterRoleEffectGateFrozen = func(action string, client *attachedClient) {
227- if action != "move-pane" || client != sourceClient {
228- return
229- }
230- client.replaceTransport(staleTransport)
231- close(staleInjected)
232- }
233-
234- moveDone := make(chan error, 1)
235- go func() {
236- moveDone <- d.movePane(movePaneRequest{
237- Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
238- SourceTabID: domain.TabStableID(sourceTab.stableID),
239- SourcePaneID: domain.PaneStableID(movedPane.stableID),
240- Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
241- DestinationTabID: domain.TabStableID(destinationTab.stableID),
242- })
243- }()
244- <-staleInjected
245- err := <-moveDone
246-
247- // Restore the exact test-injected transport incarnation before comparing the
248- // complete state. The move itself must not be able to alter this state.
249- sourceClient.linkMu.Lock()
250- sourceClient.tr = originalTransport.transport
251- sourceClient.transportIncarnation = originalTransport.incarnation
252- sourceClient.linkMu.Unlock()
253- d.afterRoleEffectGateFrozen = nil
254-
255- require.ErrorIs(t, err, errAttachmentTransition)
256- after := captureMoveState(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient)
257- require.Equal(t, before, after, "stale handoff validation must reject before any move publication")
258-}
259-
260-type moveTabHandoffState struct {
261- closing bool
262- registry map[domain.SessionID]attachmentSession
263- source moveSessionState
264- destination moveSessionState
265- movedTab moveTabState
266- follower moveClientState
267-}
268-
269-func captureMoveTabHandoffState(d *Daemon, source, destination *session, moved *tab, follower *attachedClient) moveTabHandoffState {
270- followerState := captureMoveClientState(source, destination, follower)
271- d.mu.Lock()
272- d.notices.routingMu.Lock()
273- unlockSessions := lockAttachmentSessions(source, destination)
274- moved.mu.Lock()
275-
276- registry := make(map[domain.SessionID]attachmentSession, len(d.sessions))
277- maps.Copy(registry, d.sessions)
278- state := moveTabHandoffState{
279- closing: d.closing,
280- registry: registry,
281- source: captureMoveSessionStateLocked(source),
282- destination: captureMoveSessionStateLocked(destination),
283- movedTab: captureMoveTabStateLocked(moved),
284- follower: followerState,
285- }
286-
287- moved.mu.Unlock()
288- unlockSessions()
289- d.notices.routingMu.Unlock()
290- d.mu.Unlock()
291- return state
292-}
293-
294-func TestMoveTabFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) {
295- d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t)
296- floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8})
297- floating.ctx, floating.cancel = context.WithCancel(d.paneProcessCtx)
298- t.Cleanup(floating.cancel)
299- moved.mu.Lock()
300- moved.floating = floatingSlot{state: floatingVisible, pane: floating, desiredVisible: true, generation: 9}
301- moved.mu.Unlock()
302- publishPaneOwner(floating, source, moved, 9)
303-
304- before := captureMoveTabHandoffState(d, source, destination, moved, follower)
305- originalTransport := before.follower.transport
306- staleTransport := &closeTrackingTransport{}
307- staleInjected := make(chan struct{})
308- d.afterRoleEffectGateFrozen = func(action string, client *attachedClient) {
309- if action != "move-tab" || client != follower {
310- return
311- }
312- client.replaceTransport(staleTransport)
313- close(staleInjected)
314- }
315-
316- moveDone := make(chan error, 1)
317- go func() {
318- moveDone <- d.moveTab(moveTabRequest{
319- Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
320- SourceTabID: domain.TabStableID(moved.stableID),
321- Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
322- })
323- }()
324- <-staleInjected
325- err := <-moveDone
326-
327- follower.linkMu.Lock()
328- follower.tr = originalTransport.transport
329- follower.transportIncarnation = originalTransport.incarnation
330- follower.linkMu.Unlock()
331- d.afterRoleEffectGateFrozen = nil
332-
333- require.ErrorIs(t, err, errAttachmentTransition)
334- after := captureMoveTabHandoffState(d, source, destination, moved, follower)
335- require.Equal(t, before, after, "stale move-tab handoff validation must reject before any move publication")
336-}
M internal/usecase/daemon/move_pane_admission.go +17 -18
@@ -1,8 +1,6 @@
11 package daemon
22
3-import (
4- "github.com/bnema/vev/internal/usecase/layout"
5-)
3+import "github.com/bnema/vev/internal/usecase/layout"
64
75 // movePaneAdmission is an immutable snapshot of the exact live objects and
86 // generations a move may later commit. Resize fences are acquired only after
@@ -13,13 +11,11 @@ type movePaneAdmission struct {
1311 sourceTab *tab
1412 destinationTab *tab
1513 movedPane *pane
16- sourceClient *attachedClient
17- destinationClient *attachedClient
18- sourceSnatched []*attachedClient
14+ sourceAttachments []*attachedClient
15+ sourceTransports map[*attachedClient]transportSnapshot
1916 sourceGeneration uint64
2017 destinationGeneration uint64
2118 finalSourceTab bool
22- sourceTabWasActive bool
2319 }
2420
2521 func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destination *session) (*movePaneAdmission, error) {
@@ -33,6 +29,12 @@ func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destinat
3329 if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) {
3430 return nil, errMoveStaleTarget
3531 }
32+ if req.Attachment != nil && !attachmentRegisteredLocked(source, req.Attachment) {
33+ return nil, errMoveStaleTarget
34+ }
35+ if req.AttachmentToken.ac != nil && (req.AttachmentToken.ac != req.Attachment || !moveAttachmentTokenCurrentLocked(req.AttachmentToken, source)) {
36+ return nil, errMoveStaleTarget
37+ }
3638
3739 sourceTab := findMoveTabLocked(source, req.SourceTabID)
3840 destinationTab := findMoveTabLocked(destination, req.DestinationTabID)
@@ -56,19 +58,16 @@ func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destinat
5658 return nil, errMoveStaleTarget
5759 }
5860
61+ attachments := source.snapshotAttachmentsLocked()
62+ transports := make(map[*attachedClient]transportSnapshot, len(attachments))
63+ for _, ac := range attachments {
64+ transports[ac] = ac.transportSnapshot()
65+ }
5966 return &movePaneAdmission{
60- source: source,
61- destination: destination,
62- sourceTab: sourceTab,
63- destinationTab: destinationTab,
64- movedPane: movedPane,
65- sourceClient: source.client,
66- destinationClient: destination.client,
67- sourceSnatched: snapshotMoveSnatchedLocked(source),
68- sourceGeneration: sourceTab.layoutGeneration,
69- destinationGeneration: destinationTab.layoutGeneration,
67+ source: source, destination: destination, sourceTab: sourceTab, destinationTab: destinationTab,
68+ movedPane: movedPane, sourceAttachments: attachments, sourceTransports: transports,
69+ sourceGeneration: sourceTab.layoutGeneration, destinationGeneration: destinationTab.layoutGeneration,
7070 finalSourceTab: len(source.tabs) == 1 && source.tabs[0] == sourceTab &&
7171 sourceTab.tree != nil && sourceTab.tree.Root != nil && len(layout.LeafIDs(sourceTab.tree.Root)) == 1,
72- sourceTabWasActive: source.active >= 0 && source.active < len(source.tabs) && source.tabs[source.active] == sourceTab,
7372 }, nil
7473 }
M internal/usecase/daemon/move_pane_commit.go +18 -59
@@ -13,19 +13,14 @@ type movePaneCommit struct {
1313 source, destination *session
1414 sourceTab, destinationTab *tab
1515 movedPane *pane
16- sourceClient *attachedClient
17- sourceSnatched []*attachedClient
16+ sourceAttachments []*attachedClient
17+ sourceTransports map[*attachedClient]transportSnapshot
1818 sourceGeneration uint64
1919 destinationGeneration uint64
20- handoffFrozen bool
21- frozenRoles frozenRoleEffectGates
22- handoffReq attachmentTransitionRequest
23- handoffPublication *attachmentPublication
20+ frozenEffects frozenAttachmentEffectGates
2421 err error
2522
26- handoffResult attachmentTransitionResult
2723 syncCleanup syncTimerCleanup
28- sourceCleanupToken attachmentRoleToken
2924 sourceEmpty bool
3025 sourceTabRemoved bool
3126 retiredParked []parkedAttachmentRetirement
@@ -36,16 +31,8 @@ type movePaneCommit struct {
3631 destinationMetadataValid bool
3732 }
3833
39-func (c *movePaneCommit) releasePublication() {
40- if c != nil && c.handoffPublication != nil {
41- c.handoffPublication.unlockCoordinators()
42- c.handoffPublication = nil
43- }
44-}
45-
4634 func (c *movePaneCommit) publishLocked(d *Daemon) bool {
4735 var candidate *movePaneCandidate
48- var retirement frozenMoveAttachmentRetirement
4936 var err error
5037 d.mu.Lock()
5138 if d.closing || d.sessions[c.source.id] != c.source || d.sessions[c.destination.id] != c.destination {
@@ -61,18 +48,11 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool {
6148 if !moveSessionLocatorCurrentLocked(c.source, c.req.Source) || !moveSessionLocatorCurrentLocked(c.destination, c.req.Destination) {
6249 return false
6350 }
64- if c.handoffFrozen {
65- if c.source.client != c.sourceClient || !sameMoveSnatchedLocked(c.source, c.sourceSnatched) ||
66- c.handoffReq.targetTabIndex < 0 || c.handoffReq.targetTabIndex >= len(c.destination.tabs) ||
67- c.destination.tabs[c.handoffReq.targetTabIndex] != c.destinationTab {
68- return false
69- }
70- c.handoffPublication, err = d.validateAttachmentTransitionPrelocked(c.handoffReq)
71- if err != nil {
72- c.err = err
73- return false
74- }
75- defer c.releasePublication()
51+ if c.req.Attachment != nil && !attachmentRegisteredLocked(c.source, c.req.Attachment) {
52+ return false
53+ }
54+ if c.req.AttachmentToken.ac != nil && (c.req.AttachmentToken.ac != c.req.Attachment || !moveAttachmentTokenCurrentLocked(c.req.AttachmentToken, c.source)) {
55+ return false
7656 }
7757
7858 unlockTabs := lockMoveTabs(c.sourceTab, c.destinationTab)
@@ -101,34 +81,22 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool {
10181 }
10282
10383 sourceWillEmpty := candidate.removeSourceTab && len(c.source.tabs) == 1
104- if sourceWillEmpty && c.source != c.destination {
105- if c.source.client != c.sourceClient || !sameMoveSnatchedLocked(c.source, c.sourceSnatched) ||
106- (c.sourceClient != nil) != c.handoffFrozen {
107- return false
108- }
109- var retirementOK bool
110- retirement, retirementOK = prepareFrozenMoveAttachmentRetirementLocked(c.source, c.sourceSnatched, c.frozenRoles)
111- if !retirementOK {
112- return false
113- }
84+ if sourceWillEmpty && c.source != c.destination && !sameMoveAttachmentsLocked(c.source, c.sourceAttachments) {
85+ return false
11486 }
11587
116- // Everything above is fallible. Topology, owner, lifecycle registry, and
117- // optional attachment roles now become visible under one lock section.
88+ // Everything above is fallible. Shared topology and owner publication are
89+ // committed once; attachment views are repaired after these locks release.
11890 if candidate.removeSourceTab {
11991 idx := indexMoveTabLocked(c.source, c.sourceTab)
12092 if idx < 0 {
12193 return false
12294 }
95+ if len(c.source.tabs) > 1 {
96+ c.source.prepareAttachmentViewsForRemovedTabLocked(c.sourceTab, idx)
97+ }
12398 c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...)
12499 c.sourceTabRemoved = true
125- if len(c.source.tabs) == 0 {
126- c.source.active = -1
127- } else if c.source.active > idx {
128- c.source.active--
129- } else if c.source.active >= len(c.source.tabs) {
130- c.source.active = len(c.source.tabs) - 1
131- }
132100 } else {
133101 c.sourceTab.tree = candidate.sourceTree
134102 c.sourceTab.bumpLayoutGenerationLocked()
@@ -143,21 +111,12 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool {
143111 c.destinationTab.bumpLayoutGenerationLocked()
144112 c.movedPane.id = candidate.destinationID
145113 oldOwner, newOwner := c.movedPane.publishOwnerLocked(c.destination, c.destinationTab, 0)
146- if c.handoffPublication != nil {
147- c.handoffResult = d.publishAttachmentTransitionPrelocked(c.handoffPublication)
148- c.handoffPublication.unlockCoordinators()
149- c.handoffPublication = nil
150- }
151114 c.syncCleanup.append(d.migratePaneSyncOwnerLocked(c.movedPane, oldOwner, newOwner))
152- if c.handoffResult.published.ac != nil {
153- c.sourceCleanupToken = c.handoffResult.published
154- } else if c.source.client != nil {
155- c.sourceCleanupToken = c.source.attachmentTokenLocked(c.source.client)
156- }
157115
158- if candidate.removeSourceTab && len(c.source.tabs) == 0 {
116+ if sourceWillEmpty {
159117 d.unregisterSessionLocked(c.source)
160- c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement)
118+ c.retiredAttachments = detachMoveAttachmentsLocked(c.source, c.sourceTransports)
119+ c.source.tabs = nil
161120 d.purgeParkingForSessionLocked(c.source)
162121 c.retiredParked = d.purgeParkedForSessionLocked(c.source)
163122 }
M internal/usecase/daemon/move_pane_postcommit.go +35 -78
@@ -20,25 +20,22 @@ func firstMovePane(panes []*pane) *pane {
2020 }
2121
2222 type movePanePostcommitPlan struct {
23- source *session
24- destination *session
25- sourceTab *tab
26- destinationTab *tab
27- movedPane *pane
28- movedPanes []*pane
29- destinationClient *attachedClient
30- sourceCleanupToken attachmentRoleToken
31- handoffResult attachmentTransitionResult
32- syncCleanup syncTimerCleanup
33- frozenRoles frozenRoleEffectGates
34- rolesFrozen bool
35- unlockDispatch func()
36- reservation *moveLifecycleReservation
37- oldTabCancel context.CancelFunc
23+ source *session
24+ destination *session
25+ sourceTab *tab
26+ destinationTab *tab
27+ movedPane *pane
28+ movedPanes []*pane
29+ sourceAttachments []*attachedClient
30+ syncCleanup syncTimerCleanup
31+ frozenEffects frozenAttachmentEffectGates
32+ effectsFrozen bool
33+ unlockDispatch func()
34+ reservation *moveLifecycleReservation
35+ oldTabCancel context.CancelFunc
3836
39- sourceTabWasActive bool
40- sourceTabRemoved bool
41- sourceEmpty bool
37+ sourceTabRemoved bool
38+ sourceEmpty bool
4239
4340 retiredParked []parkedAttachmentRetirement
4441 retiredAttachments []detachedAttachmentSnapshot
@@ -57,12 +54,11 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
5754 } else if p.sourceTabRemoved && p.sourceTab != nil && p.sourceTab.cancel != nil {
5855 p.sourceTab.cancel()
5956 }
60- if p.sourceCleanupToken.current() {
61- cleanupClient := p.sourceCleanupToken.ac
62- panes := p.movedPanes
63- if len(panes) == 0 && p.movedPane != nil {
64- panes = []*pane{p.movedPane}
65- }
57+ panes := p.movedPanes
58+ if len(panes) == 0 && p.movedPane != nil {
59+ panes = []*pane{p.movedPane}
60+ }
61+ for _, cleanupClient := range p.sourceAttachments {
6662 for _, movedPane := range panes {
6763 if cleanupClient.overlays != nil {
6864 cleanupClient.overlays.clearCopyModeForPane(movedPane)
@@ -78,19 +74,23 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
7874 markSnapshotDirty(p.source)
7975 }
8076 p.syncCleanup.finish()
81- if p.rolesFrozen {
82- p.frozenRoles.unfreeze()
77+ if p.effectsFrozen {
78+ p.frozenEffects.unfreeze()
8379 }
8480 p.unlockDispatch()
8581 p.reservation.Release()
8682
87- if p.sourceTabWasActive && !moveSessionRetired(d, p.source) {
88- d.activateTab(p.source, p.source.activeTab())
83+ // Repair attachment-local stable targets after shared topology publication
84+ // and after the move dispatch locks have been released.
85+ p.source.repairAttachmentViews()
86+ if p.destination != p.source {
87+ p.destination.repairAttachmentViews()
8988 }
89+
9090 if !moveSessionRetired(d, p.source) {
9191 sourceLayoutTab := p.sourceTab
9292 if p.sourceTabRemoved {
93- sourceLayoutTab = p.source.activeTab()
93+ sourceLayoutTab = p.source.firstTab()
9494 }
9595 if sourceLayoutTab != nil && sourceLayoutTab != p.destinationTab {
9696 d.applyTabLayout(p.source, sourceLayoutTab)
@@ -103,18 +103,13 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
103103 if p.destinationMetadataValid {
104104 d.markCatalogueDirty(p.destinationMetadata)
105105 }
106- if p.destinationClient != nil && sessionClientIs(p.destination, p.destinationClient) && destinationTabIsActive(p.destination, p.destinationTab) {
107- d.invalidateRender(p.destination, p.destinationClient, true, "move.go")
106+ for _, ac := range p.destination.snapshotAttachments() {
107+ d.invalidateRender(p.destination, ac, true, "move.go")
108108 }
109- if p.sourceCleanupToken.ac != nil && !p.sourceEmpty && p.sourceTabWasActive && sessionClientIs(p.source, p.sourceCleanupToken.ac) {
110- d.invalidateRender(p.source, p.sourceCleanupToken.ac, true, "move.go")
111- }
112- if p.handoffResult.published.ac != nil {
113- follower := p.handoffResult.published.ac
114- d.applyHostTheme(p.destination, follower, follower.getClientTheme(), false)
115- follower.recordPreviousSession(p.source)
116- d.deferAttachmentTransitionCleanups(p.handoffResult)
117- d.firstPaintForTransition(p.handoffResult.published)
109+ if !p.sourceEmpty && p.source != p.destination {
110+ for _, ac := range p.source.snapshotAttachments() {
111+ d.invalidateRender(p.source, ac, true, "move.go")
112+ }
118113 }
119114 if p.sourceEmpty {
120115 retireEmptySessionAfterMove(d, p.source)
@@ -142,26 +137,6 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
142137 }
143138 }
144139
145-// retireEmptyMoveSessionLocked removes all source-owned attachment roles that
146-// were not transferred. It runs inside the move commit, after the moved pane
147-// has already acquired its destination owner, and performs no external work.
148-func retireEmptyMoveSessionLocked(sess *session, retirement frozenMoveAttachmentRetirement) []detachedAttachmentSnapshot {
149- if sess == nil || sess.client != nil {
150- return nil
151- }
152- retired := make([]detachedAttachmentSnapshot, 0, len(retirement.clients))
153- for _, ac := range retirement.clients {
154- retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: ac.transportSnapshot()})
155- delete(sess.snatched, ac)
156- ac.roleGeneration.Add(1)
157- ac.setSession(nil)
158- ac.invalidateFrozenRoleCapability()
159- }
160- sess.tabs = nil
161- sess.active = -1
162- return retired
163-}
164-
165140 func moveSessionRetired(d *Daemon, sess *session) bool {
166141 if d == nil || sess == nil {
167142 return true
@@ -172,24 +147,6 @@ func moveSessionRetired(d *Daemon, sess *session) bool {
172147 return !live
173148 }
174149
175-func destinationTabIsActive(sess *session, target *tab) bool {
176- if sess == nil || target == nil {
177- return false
178- }
179- sess.mu.Lock()
180- defer sess.mu.Unlock()
181- return sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == target
182-}
183-
184-func sessionClientIs(sess *session, client *attachedClient) bool {
185- if sess == nil || client == nil {
186- return false
187- }
188- sess.mu.Lock()
189- defer sess.mu.Unlock()
190- return sess.client == client
191-}
192-
193150 // retireEmptySessionAfterMove completes only source-owned lifecycle work. The
194151 // moved pane has already been removed from source.tabs, so stopInMemoryLifecycle
195152 // cannot cancel or close the transferred PTY.
M internal/usecase/daemon/move_persistence_test.go +1 -1
@@ -177,7 +177,7 @@ func TestMoveTabPersistenceMatrix(t *testing.T) {
177177 }
178178 d, source, client, _ := newManualSessionWithPTYs(t, ptys...)
179179 if tt.finalSource {
180- source.client = nil
180+ clearAttachmentsForTest(source)
181181 client.setSession(nil)
182182 }
183183 source.incarnation = domain.IncarnationID{1}
M internal/usecase/daemon/move_picker.go +24 -13
@@ -16,8 +16,11 @@ func (d *Daemon) movePickerSourceError(source moveSourceLocator) error {
1616 }
1717 sess.mu.Lock()
1818 defer sess.mu.Unlock()
19- if source.Client != nil && sess.client != source.Client {
20- return domain.UserWarn(domain.NoticeSessionUnavailable, "Source client is no longer active.", errMovePaneInvalid)
19+ if source.AttachmentToken.ac != nil && !moveAttachmentTokenCurrentLocked(source.AttachmentToken, sess) {
20+ return domain.UserWarn(domain.NoticeSessionUnavailable, "Source attachment is no longer active.", errMovePaneInvalid)
21+ }
22+ if source.Attachment != nil && !attachmentRegisteredLocked(sess, source.Attachment) {
23+ return domain.UserWarn(domain.NoticeSessionUnavailable, "Source attachment is no longer active.", errMovePaneInvalid)
2124 }
2225 tb := findMoveTabLocked(sess, source.TabID)
2326 if tb == nil {
@@ -49,10 +52,11 @@ type moveSessionLocator struct {
4952 }
5053
5154 type moveSourceLocator struct {
52- Session moveSessionLocator
53- TabID domain.TabStableID
54- PaneID domain.PaneStableID
55- Client *attachedClient
55+ Session moveSessionLocator
56+ TabID domain.TabStableID
57+ PaneID domain.PaneStableID
58+ Attachment *attachedClient
59+ AttachmentToken attachmentConnectionToken
5660 }
5761
5862 // enterPickerForIntent returns errNoMoveDestination only for move intents.
@@ -60,7 +64,10 @@ func (d *Daemon) enterPickerForIntent(sess *session, ac *attachedClient, intent
6064 if intent != pickerNavigate && !sess.capabilities().yieldsMoves() {
6165 return errSessionCannotYieldMoves
6266 }
63- model := d.newPickerModel(sess, intent, source, picker.SourceFilter{})
67+ if source.Attachment != nil && source.AttachmentToken.ac == nil {
68+ source.AttachmentToken = sess.attachmentToken(source.Attachment, source.Attachment.transport())
69+ }
70+ model := d.newPickerModel(sess, ac, intent, source, picker.SourceFilter{})
6471 if intent != pickerNavigate {
6572 if _, ok := model.Selected(); !ok {
6673 return errNoMoveDestination
@@ -90,6 +97,8 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc
9097 return errMovePaneInvalid
9198 }
9299 return d.movePane(movePaneRequest{
100+ Attachment: source.Attachment,
101+ AttachmentToken: source.AttachmentToken,
93102 Source: source.Session,
94103 SourceTabID: source.TabID,
95104 SourcePaneID: source.PaneID,
@@ -98,9 +107,11 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc
98107 })
99108 case pickerMoveTab:
100109 return d.moveTab(moveTabRequest{
101- Source: source.Session,
102- SourceTabID: source.TabID,
103- Destination: destination,
110+ Attachment: source.Attachment,
111+ AttachmentToken: source.AttachmentToken,
112+ Source: source.Session,
113+ SourceTabID: source.TabID,
114+ Destination: destination,
104115 })
105116 default:
106117 return errMovePaneInvalid
@@ -121,10 +132,10 @@ func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*sess
121132 return nil, nil
122133 }
123134 if intent == pickerMoveTab {
124- if sess.active < 0 || sess.active >= len(sess.tabs) {
135+ if len(sess.tabs) == 0 {
125136 return nil, nil
126137 }
127- return sess, sess.tabs[sess.active]
138+ return sess, sess.tabs[0]
128139 }
129140 if target.TabID != "" {
130141 for _, tb := range sess.tabs {
@@ -170,7 +181,7 @@ func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions
170181 }
171182 }
172183 rt.pickerMu.Unlock()
173- model := d.newPickerModel(sess, intent, source, current)
184+ model := d.newPickerModel(sess, ac, intent, source, current)
174185 if rt.afterPickerRefreshBuild != nil {
175186 rt.afterPickerRefreshBuild(model)
176187 }
M internal/usecase/daemon/move_picker_test.go +7 -66
@@ -1,7 +1,6 @@
11 package daemon
22
33 import (
4- "sync/atomic"
54 "testing"
65 "time"
76
@@ -36,7 +35,7 @@ func setupMovePickerSessionsWithClock(t *testing.T, clock ports.Clock, extraDest
3635 releases = append(releases, releaseDest)
3736 destinationTab := newTabWithStableID("destination-tab", "destination-pane", destPTY, domain.Size{Cols: 80, Rows: 23})
3837 publishTiledPaneOwners(source, sourceTab)
39- destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{destinationTab}, active: 0}
38+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{destinationTab}}
4039 publishTiledPaneOwners(destination, destinationTab)
4140 for range extraDestinationTabs {
4241 extraPTY, releaseExtra := newBlockingPTY(t)
@@ -68,7 +67,7 @@ func TestPaletteMovePaneCapturesSourceAndOpensPicker(t *testing.T) {
6867 require.Equal(t, moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, captured.Session)
6968 require.Equal(t, domain.TabStableID("source-tab"), captured.TabID)
7069 require.Equal(t, domain.PaneStableID("source-pane"), captured.PaneID)
71- require.Same(t, ac, captured.Client)
70+ require.Same(t, ac, captured.Attachment)
7271 }
7372
7473 func TestPaletteMoveTabCapturesActiveTabAndOpensPicker(t *testing.T) {
@@ -87,7 +86,7 @@ func TestPaletteMoveTabCapturesActiveTabAndOpensPicker(t *testing.T) {
8786 require.Equal(t, pickerMoveTab, intent)
8887 require.Equal(t, domain.TabStableID("active-tab"), captured.TabID)
8988 require.Equal(t, domain.PaneStableID(""), captured.PaneID)
90- require.Same(t, ac, captured.Client)
89+ require.Same(t, ac, captured.Attachment)
9190 }
9291
9392 func TestPaletteMoveWithoutDestinationShowsToastAndNoPicker(t *testing.T) {
@@ -110,7 +109,7 @@ func TestMovePickerEnterCommitsMovePane(t *testing.T) {
110109 d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0)
111110 defer releaseAll(releases)
112111 source.mu.Lock()
113- source.client = nil
112+ clearAttachmentsForTestLocked(source)
114113 source.mu.Unlock()
115114 sourceTab := source.tabs[0]
116115 moved := sourceTab.focusedPane()
@@ -131,7 +130,7 @@ func TestMovePickerEnterCommitsMovePaneViaSharedAPI(t *testing.T) {
131130 d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0)
132131 defer releaseAll(releases)
133132 source.mu.Lock()
134- source.client = nil
133+ clearAttachmentsForTestLocked(source)
135134 source.mu.Unlock()
136135 sourceTab := source.tabs[0]
137136 moved := sourceTab.focusedPane()
@@ -158,7 +157,7 @@ func TestMovePickerEnterCommitsMoveTab(t *testing.T) {
158157 d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
159158 defer releaseAll(releases)
160159 source.mu.Lock()
161- source.client = nil
160+ clearAttachmentsForTestLocked(source)
162161 source.mu.Unlock()
163162 movedTab := source.tabs[0]
164163 movedTab.stableID = "moved-tab"
@@ -257,64 +256,6 @@ func TestMovePickerStaleSourceTabReportsPreciseFeedback(t *testing.T) {
257256 require.Equal(t, "Tab no longer exists.", history[0].Message)
258257 }
259258
260-func TestMovePickerSourceClientReplacementInvalidatesAction(t *testing.T) {
261- d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
262- defer releaseAll(releases)
263-
264- require.NoError(t, paletteExec{d: d, sess: source, ac: ac}.OpenMovePanePicker())
265- replacement := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: ac.size}
266- replacement.initOverlays()
267- replacement.setSession(source)
268- source.mu.Lock()
269- source.client = replacement
270- source.mu.Unlock()
271-
272- d.handlePickerInput(ac, []byte("\r"))
273-
274- require.True(t, ac.overlays.pickerActive())
275- require.Len(t, source.tabs, 1)
276- require.Len(t, destination.tabs, 1)
277- history := d.notices.history()
278- require.NotEmpty(t, history)
279- require.Equal(t, "Source client is no longer active.", history[0].Message)
280-}
281-
282-func TestMovePickerFinalSourceClosesBeforeSnatchFinalization(t *testing.T) {
283- d, source, follower, destination, _, releases := setupMovePickerSessions(t, 0)
284- defer releaseAll(releases)
285- follower.replaceTransport(&closeTrackingTransport{})
286- require.NotNil(t, d.attachCoordinator(source, nil, follower, true))
287-
288- displacedTransport := &closeTrackingTransport{}
289- displaced := &attachedClient{tr: displacedTransport, output: newOutputStateStream(), size: follower.size}
290- displaced.initOverlays()
291- displaced.setSession(destination)
292- destination.mu.Lock()
293- destination.client = displaced
294- destination.mu.Unlock()
295- require.NotNil(t, d.attachCoordinator(destination, nil, displaced, true))
296-
297- var pickerClosed atomic.Bool
298- d.afterDisplacedCleanupStarted = func() {
299- require.False(t, follower.overlays.pickerActive(), "picker must close before snatch finalization")
300- pickerClosed.Store(true)
301- }
302- defer func() { d.afterDisplacedCleanupStarted = nil }()
303-
304- require.NoError(t, d.enterPickerForIntent(source, follower, pickerMovePane, moveSourceLocator{
305- Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
306- TabID: "source-tab", PaneID: "source-pane",
307- }))
308- d.handlePickerInput(follower, []byte("\r"))
309- d.attachmentCleanupWg.Wait()
310-
311- require.True(t, pickerClosed.Load())
312- require.False(t, follower.overlays.pickerActive())
313- frames := displacedTransport.Sends()
314- require.NotEmpty(t, frames)
315- require.Equal(t, ports.MsgOutput, frames[0].Type)
316-}
317-
318259 func TestMovePickerRefreshCloseKeepsReplacementPicker(t *testing.T) {
319260 d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
320261 defer releaseAll(releases)
@@ -343,7 +284,7 @@ func TestMovePickerRefreshCloseKeepsReplacementPicker(t *testing.T) {
343284 t.Fatal("timed out waiting for move-picker refresh rebuild")
344285 }
345286
346- replacement := d.newPickerModel(source, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
287+ replacement := d.newPickerModel(source, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
347288 d.publishPicker(source, ac, replacement, pickerNavigate, moveSourceLocator{})
348289 close(allowClose)
349290 select {
M internal/usecase/daemon/move_stale_destination_test.go +1 -1
@@ -18,7 +18,7 @@ func TestMovePaneRejectsStaleDestinationIncarnationWithoutMutation(t *testing.T)
1818 sourceTab := source.tabs[0]
1919 sourceTab.stableID = "source-tab"
2020 moved := sourceTab.focusedPane()
21- destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0}
21+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}
2222 destinationTab := destination.tabs[0]
2323 publishTiledPaneOwners(destination, destinationTab)
2424 d.mu.Lock()
M internal/usecase/daemon/move_tab.go +65 -145
@@ -10,8 +10,8 @@ import (
1010 type moveTabAdmission struct {
1111 tab *tab
1212 sourceIndex int
13- sourceClient *attachedClient
14- sourceSnatched []*attachedClient
13+ sourceAttachments []*attachedClient
14+ sourceTransports map[*attachedClient]transportSnapshot
1515 layoutGeneration uint64
1616 floatingState floatingState
1717 floatingPane *pane
@@ -27,14 +27,9 @@ type moveTabCommit struct {
2727 source *session
2828 destination *session
2929 admission moveTabAdmission
30- handoffFrozen bool
31- sourceRolesFrozen bool
32- frozenRoles frozenRoleEffectGates
33- handoffReq attachmentTransitionRequest
34- handoffPublication *attachmentPublication
35- handoffResult attachmentTransitionResult
30+ sourceEffectsFrozen bool
31+ frozenEffects frozenAttachmentEffectGates
3632 syncCleanup syncTimerCleanup
37- sourceCleanupToken attachmentRoleToken
3833 oldTabCancel context.CancelFunc
3934 sourceEmpty bool
4035 retiredParked []parkedAttachmentRetirement
@@ -46,27 +41,6 @@ type moveTabCommit struct {
4641 err error
4742 }
4843
49-func (d *Daemon) snapshotSnatchedRoleParticipants(sess *session, admitted []*attachedClient) ([]*attachedClient, []roleTransportInterrupt) {
50- if sess == nil || len(admitted) == 0 {
51- return nil, nil
52- }
53- d.mu.Lock()
54- defer d.mu.Unlock()
55- sess.mu.Lock()
56- defer sess.mu.Unlock()
57- clients := append([]*attachedClient(nil), admitted...)
58- interrupts := make([]roleTransportInterrupt, 0, len(clients))
59- for _, ac := range clients {
60- if ac == nil {
61- continue
62- }
63- if transport := ac.transportSnapshot(); transport.transport != nil {
64- interrupts = append(interrupts, roleTransportInterrupt{ac: ac, transport: transport})
65- }
66- }
67- return clients, interrupts
68-}
69-
7044 func (d *Daemon) moveTab(req moveTabRequest) (result error) {
7145 defer func() { result = normalizeMoveRejection(result) }()
7246 if d == nil || req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID {
@@ -82,6 +56,20 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
8256 if source == destination {
8357 return errMovePaneInvalid
8458 }
59+ // Dispatch admission must precede lifecycle reservation. A final close holds
60+ // dispatchMu while it waits for teardown ownership; reserving first would let
61+ // that close wait on a move which is itself blocked on dispatchMu.
62+ if d.beforeMoveDispatch != nil {
63+ d.beforeMoveDispatch()
64+ }
65+ unlockDispatch := lockMoveDispatch(source, destination)
66+ dispatchHeld := true
67+ defer func() {
68+ if dispatchHeld {
69+ unlockDispatch()
70+ }
71+ }()
72+
8573 reservation, err := d.reserveMoveLifecycles(source, destination)
8674 if err != nil {
8775 return errMoveStaleTarget
@@ -95,13 +83,6 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
9583 if d.afterMoveLifecycleReserved != nil {
9684 d.afterMoveLifecycleReserved()
9785 }
98- unlockDispatch := lockMoveDispatch(source, destination)
99- dispatchHeld := true
100- defer func() {
101- if dispatchHeld {
102- unlockDispatch()
103- }
104- }()
10586
10687 admission, err := d.snapshotMoveTabAdmission(req, source, destination)
10788 if err != nil {
@@ -111,59 +92,35 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
11192 d.afterMoveTabSourceSnapshot()
11293 }
11394 commit := moveTabCommit{req: req, source: source, destination: destination, admission: *admission}
114- var frozen frozenRoleEffectGates
115- if admission.finalSource && admission.sourceClient != nil {
116- transport := admission.sourceClient.transportSnapshot()
117- token := source.attachmentToken(admission.sourceClient, transport.transport)
118- if token.role != attachmentActive || token.transport.transport == nil {
119- return errMovePaneInvalid
120- }
121- commit.handoffReq = attachmentTransitionRequest{
122- source: source, target: destination, next: admission.sourceClient,
123- expectedRole: attachmentActive, targetRole: attachmentActive,
124- expectedTransport: token.transport, sourceToken: &token,
125- expectedSourceTab: admission.tab, transferExpectedSourceTab: true, ready: true, action: "move-tab",
126- }
127- var participants attachmentTransitionParticipants
128- commit.handoffReq, participants, err = d.snapshotAttachmentTransition(commit.handoffReq)
129- if err != nil {
130- return err
131- }
132- snatchedClients, snatchedInterrupts := d.snapshotSnatchedRoleParticipants(source, admission.sourceSnatched)
133- participants.clients = append(participants.clients, snatchedClients...)
134- participants.interrupts = append(participants.interrupts, snatchedInterrupts...)
135- frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) {
136- if d.afterRoleEffectGateFrozen != nil {
137- d.afterRoleEffectGateFrozen("move-tab", ac)
95+ var frozen frozenAttachmentEffectGates
96+ if admission.finalSource && len(admission.sourceAttachments) != 0 {
97+ interrupts := make([]attachmentTransportInterrupt, 0, len(admission.sourceAttachments))
98+ for _, ac := range admission.sourceAttachments {
99+ if transport := admission.sourceTransports[ac]; transport.transport != nil {
100+ interrupts = append(interrupts, attachmentTransportInterrupt{ac: ac, transport: transport})
138101 }
139- }}, participants.clients...)
140- if !frozen.acquired || !frozen.drained {
141- frozen.unfreeze()
142- return errMovePaneInvalid
143102 }
144- commit.handoffFrozen = true
145- commit.sourceRolesFrozen = true
146- commit.handoffReq.roleEffectsFrozen = true
147- } else if admission.finalSource && len(admission.sourceSnatched) > 0 {
148- clients, interrupts := d.snapshotSnatchedRoleParticipants(source, admission.sourceSnatched)
149- frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: interrupts, nonblocking: true}, clients...)
103+ frozen = freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) {
104+ if d.afterAttachmentEffectGateFrozen != nil {
105+ d.afterAttachmentEffectGateFrozen("move-tab", ac)
106+ }
107+ }}, admission.sourceAttachments...)
150108 if !frozen.acquired || !frozen.drained {
151109 frozen.unfreeze()
152110 return errMovePaneInvalid
153111 }
154- commit.sourceRolesFrozen = true
112+ commit.sourceEffectsFrozen = true
155113 }
156- commit.frozenRoles = frozen
157- rolesFrozen := commit.sourceRolesFrozen
114+ commit.frozenEffects = frozen
115+ effectsFrozen := commit.sourceEffectsFrozen
158116 defer func() {
159- if rolesFrozen {
117+ if effectsFrozen {
160118 frozen.unfreeze()
161119 }
162120 }()
163121
164122 fences := newMoveTabResizeFences(source, destination, admission.tab)
165123 if !fences.acquire(func() bool { return commit.publishLocked(d, fences.panes) }) {
166- commit.releasePublication()
167124 if commit.err != nil {
168125 return commit.err
169126 }
@@ -177,15 +134,13 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
177134 destinationTab: admission.tab,
178135 movedPane: firstMovePane(admission.panes),
179136 movedPanes: admission.panes,
180- sourceCleanupToken: commit.sourceCleanupToken,
181- handoffResult: commit.handoffResult,
137+ sourceAttachments: admission.sourceAttachments,
182138 syncCleanup: commit.syncCleanup,
183- frozenRoles: frozen,
184- rolesFrozen: rolesFrozen,
139+ frozenEffects: frozen,
140+ effectsFrozen: effectsFrozen,
185141 unlockDispatch: unlockDispatch,
186142 reservation: reservation,
187143 oldTabCancel: commit.oldTabCancel,
188- sourceTabWasActive: !commit.sourceEmpty,
189144 sourceTabRemoved: true,
190145 sourceEmpty: commit.sourceEmpty,
191146 retiredParked: commit.retiredParked,
@@ -195,7 +150,7 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
195150 destinationMetadata: commit.destMetadata,
196151 destinationMetadataValid: commit.destMetadataValid,
197152 }
198- rolesFrozen = false
153+ effectsFrozen = false
199154 dispatchHeld = false
200155 reservationHeld = false
201156 postcommit.execute(d)
@@ -213,11 +168,17 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio
213168 if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) {
214169 return nil, errMoveStaleTarget
215170 }
171+ if req.Attachment != nil && !attachmentRegisteredLocked(source, req.Attachment) {
172+ return nil, errMoveStaleTarget
173+ }
174+ if req.AttachmentToken.ac != nil && (req.AttachmentToken.ac != req.Attachment || !moveAttachmentTokenCurrentLocked(req.AttachmentToken, source)) {
175+ return nil, errMoveStaleTarget
176+ }
216177 moved := findMoveTabLocked(source, req.SourceTabID)
217- if moved == nil || destination.active < 0 || destination.active >= len(destination.tabs) {
178+ if moved == nil || len(destination.tabs) == 0 {
218179 return nil, errMoveStaleTarget
219180 }
220- destinationActive := destination.tabs[destination.active]
181+ destinationActive := destination.tabs[0]
221182 destinationActive.mu.Lock()
222183 destinationSize := destinationActive.size
223184 destinationActive.mu.Unlock()
@@ -240,9 +201,13 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio
240201 panes = append(panes, moved.floating.pane)
241202 }
242203 idx := indexMoveTabLocked(source, moved)
204+ attachments := source.snapshotAttachmentsLocked()
205+ transports := make(map[*attachedClient]transportSnapshot, len(attachments))
206+ for _, ac := range attachments {
207+ transports[ac] = ac.transportSnapshot()
208+ }
243209 return &moveTabAdmission{
244- tab: moved, sourceIndex: idx,
245- sourceClient: source.client, sourceSnatched: snapshotMoveSnatchedLocked(source),
210+ tab: moved, sourceIndex: idx, sourceAttachments: attachments, sourceTransports: transports,
246211 layoutGeneration: moved.layoutGeneration, floatingState: moved.floating.state,
247212 floatingPane: moved.floating.pane, floatingGeneration: moved.floating.generation,
248213 panes: panes, destinationActive: destinationActive, destinationSize: destinationSize,
@@ -250,13 +215,6 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio
250215 }, nil
251216 }
252217
253-func (c *moveTabCommit) releasePublication() {
254- if c.handoffPublication != nil {
255- c.handoffPublication.unlockCoordinators()
256- c.handoffPublication = nil
257- }
258-}
259-
260218 func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
261219 d.mu.Lock()
262220 if d.closing || d.sessions[c.source.id] != c.source || d.sessions[c.destination.id] != c.destination {
@@ -271,39 +229,21 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
271229 if !moveSessionLocatorCurrentLocked(c.source, c.req.Source) || !moveSessionLocatorCurrentLocked(c.destination, c.req.Destination) {
272230 return false
273231 }
232+ if c.req.Attachment != nil && !attachmentRegisteredLocked(c.source, c.req.Attachment) {
233+ return false
234+ }
235+ if c.req.AttachmentToken.ac != nil && (c.req.AttachmentToken.ac != c.req.Attachment || !moveAttachmentTokenCurrentLocked(c.req.AttachmentToken, c.source)) {
236+ return false
237+ }
274238 moved := c.admission.tab
275239 if indexMoveTabLocked(c.source, moved) != c.admission.sourceIndex || findMoveTabLocked(c.destination, c.req.SourceTabID) != nil ||
276240 len(c.source.tabs) == 1 != c.admission.finalSource {
277241 return false
278242 }
279- // Attachment validation inspects the expected source tab and therefore must
280- // precede moved.mu. The resize fences and ordered session locks keep topology
281- // stable until the tab/pane checks below complete.
282- if c.handoffFrozen {
283- if c.source.client != c.admission.sourceClient || !sameMoveSnatchedLocked(c.source, c.admission.sourceSnatched) {
284- return false
285- }
286- publication, err := d.validateAttachmentTransitionPrelocked(c.handoffReq)
287- if err != nil {
288- c.err = err
289- return false
290- }
291- c.handoffPublication = publication
292- defer c.releasePublication()
293- }
294- var retirement frozenMoveAttachmentRetirement
295- if c.admission.finalSource {
296- if c.source.client != c.admission.sourceClient || !sameMoveSnatchedLocked(c.source, c.admission.sourceSnatched) ||
297- (c.admission.sourceClient != nil) != c.handoffFrozen {
298- return false
299- }
300- var retirementOK bool
301- retirement, retirementOK = prepareFrozenMoveAttachmentRetirementLocked(c.source, c.admission.sourceSnatched, c.frozenRoles)
302- if !retirementOK {
303- return false
304- }
243+ if c.admission.finalSource && !sameMoveAttachmentsLocked(c.source, c.admission.sourceAttachments) {
244+ return false
305245 }
306- if c.destination.active < 0 || c.destination.active >= len(c.destination.tabs) || c.destination.tabs[c.destination.active] != c.admission.destinationActive {
246+ if len(c.destination.tabs) == 0 || c.destination.tabs[0] != c.admission.destinationActive {
307247 return false
308248 }
309249 c.admission.destinationActive.mu.Lock()
@@ -329,17 +269,10 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
329269 }
330270
331271 idx := c.admission.sourceIndex
332- c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...)
333- if len(c.source.tabs) == 0 {
334- c.source.active = -1
335- } else if c.source.active > idx {
336- c.source.active--
337- } else if c.source.active == idx {
338- if idx >= len(c.source.tabs) {
339- idx = len(c.source.tabs) - 1
340- }
341- c.source.active = idx
272+ if len(c.source.tabs) > 1 {
273+ c.source.prepareAttachmentViewsForRemovedTabLocked(moved, idx)
342274 }
275+ c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...)
343276 c.destination.tabs = append(c.destination.tabs, moved)
344277 moved.size = c.admission.destinationSize
345278 moved.bumpLayoutGenerationLocked()
@@ -366,26 +299,13 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
366299 if d.beforeMoveTabCommit != nil {
367300 d.beforeMoveTabCommit()
368301 }
369- if c.handoffPublication != nil {
370- c.destination.active = len(c.destination.tabs) - 1
371- c.handoffResult = d.publishAttachmentTransitionPrelocked(c.handoffPublication)
372- c.handoffPublication.unlockCoordinators()
373- c.handoffPublication = nil
374- }
375- // Attachment publication retains source/destination coordinator locks.
376- // Release those first, then migrate synchronized-output batches exactly as
377- // Move pane does, while pane parsing remains fenced.
378302 for _, change := range ownerChanges {
379303 c.syncCleanup.append(d.migratePaneSyncOwnerLocked(change.pane, change.oldOwner, change.newOwner))
380304 }
381- if c.handoffResult.published.ac != nil {
382- c.sourceCleanupToken = c.handoffResult.published
383- } else if c.source.client != nil {
384- c.sourceCleanupToken = c.source.attachmentTokenLocked(c.source.client)
385- }
386305 if len(c.source.tabs) == 0 {
387306 d.unregisterSessionLocked(c.source)
388- c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement)
307+ c.retiredAttachments = detachMoveAttachmentsLocked(c.source, c.admission.sourceTransports)
308+ c.source.tabs = nil
389309 d.purgeParkingForSessionLocked(c.source)
390310 c.retiredParked = d.purgeParkedForSessionLocked(c.source)
391311 }
D internal/usecase/daemon/move_tab_handoff_test.go +0 -339
@@ -1,339 +0,0 @@
1-package daemon
2-
3-import (
4- "testing"
5-
6- "github.com/stretchr/testify/require"
7-
8- "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/internal/ports"
10- scopy "github.com/bnema/vev/internal/usecase/copy"
11-)
12-
13-func setupMoveTabFinalSourceHandoff(t *testing.T) (*Daemon, *session, *attachedClient, *session, *attachedClient, *closeTrackingTransport, *tab) {
14- t.Helper()
15- d, source, follower, _ := newManualSessionWithPTYs(t, newQuietPTY())
16- follower.replaceTransport(&closeTrackingTransport{})
17- moved := source.tabs[0]
18- moved.stableID = "moved-tab"
19- require.NotNil(t, d.attachCoordinator(source, nil, follower, true))
20-
21- destination := addMoveTabTestSession(d, "destination", "destination-tab")
22- displacedTransport := &closeTrackingTransport{}
23- displaced := &attachedClient{
24- tr: displacedTransport, output: newOutputStateStream(), size: follower.size,
25- }
26- displaced.initOverlays()
27- displaced.setSession(destination)
28- destination.mu.Lock()
29- destination.client = displaced
30- destination.mu.Unlock()
31- require.NotNil(t, d.attachCoordinator(destination, nil, displaced, true))
32-
33- return d, source, follower, destination, displaced, displacedTransport, moved
34-}
35-
36-func moveTabFinalSource(t *testing.T, d *Daemon, source, destination *session, moved *tab) {
37- t.Helper()
38- require.NoError(t, d.moveTab(moveTabRequest{
39- Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
40- SourceTabID: domain.TabStableID(moved.stableID),
41- Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
42- }))
43-}
44-
45-func detachedWithReason(frames []ports.Frame, reason uint8) bool {
46- for _, frame := range frames {
47- if frame.Type != ports.MsgDetached {
48- continue
49- }
50- detached, err := ports.UnmarshalDetached(frame.Payload)
51- if err == nil && detached.Reason == reason {
52- return true
53- }
54- }
55- return false
56-}
57-
58-func TestMoveTabFinalSourceSnatchesDestinationActiveClient(t *testing.T) {
59- d, source, follower, destination, displaced, displacedTransport, moved := setupMoveTabFinalSourceHandoff(t)
60-
61- moveTabFinalSource(t, d, source, destination, moved)
62- d.attachmentCleanupWg.Wait()
63-
64- destination.mu.Lock()
65- require.Same(t, follower, destination.client)
66- require.Contains(t, destination.snatched, displaced)
67- require.Same(t, moved, destination.tabs[destination.active])
68- destination.mu.Unlock()
69- require.Same(t, destination, follower.currentSession())
70- require.Equal(t, attachmentActive, destination.attachmentRole(follower))
71- require.Equal(t, attachmentSnatched, destination.attachmentRole(displaced))
72-
73- frames := displacedTransport.Sends()
74- require.Len(t, frames, 1)
75- require.Equal(t, ports.MsgOutput, frames[0].Type)
76- output, err := ports.UnmarshalOutput(frames[0].Payload)
77- require.NoError(t, err)
78- require.Contains(t, string(output.Data), "Session snatched")
79- require.False(t, detachedWithReason(frames, ports.ReasonReplaced))
80- require.False(t, displacedTransport.Closed())
81-}
82-
83-func TestMoveTabFinalSourcePreservesDestinationSnatchedWaiters(t *testing.T) {
84- d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t)
85-
86- waitingTransport := &closeTrackingTransport{}
87- waiting := &attachedClient{tr: waitingTransport, output: newOutputStateStream(), size: follower.size}
88- waiting.initOverlays()
89- waiting.setSession(destination)
90- destination.mu.Lock()
91- destination.addSnatchedLocked(waiting)
92- destination.mu.Unlock()
93-
94- moveTabFinalSource(t, d, source, destination, moved)
95- d.attachmentCleanupWg.Wait()
96-
97- destination.mu.Lock()
98- require.Contains(t, destination.snatched, displaced)
99- require.Contains(t, destination.snatched, waiting)
100- require.Len(t, destination.snatched, 2)
101- destination.mu.Unlock()
102- requireSingleOwnerAndLease(t, destination, follower, displaced, waiting)
103-}
104-
105-func TestMoveTabFinalSourceRetiresSourceParkedAndSnatchedClients(t *testing.T) {
106- d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t)
107-
108- parkedTransport := &closeTrackingTransport{}
109- parked := &attachedClient{tr: parkedTransport, output: newOutputStateStream(), size: follower.size, resumeCapable: true}
110- parked.initOverlays()
111- require.True(t, d.parkAttachment(source, parked))
112-
113- sourceSnatchedTransport := &closeTrackingTransport{}
114- sourceSnatched := &attachedClient{tr: sourceSnatchedTransport, output: newOutputStateStream(), size: follower.size}
115- sourceSnatched.initOverlays()
116- sourceSnatched.setSession(source)
117- source.mu.Lock()
118- source.addSnatchedLocked(sourceSnatched)
119- source.mu.Unlock()
120-
121- moveTabFinalSource(t, d, source, destination, moved)
122- d.attachmentCleanupWg.Wait()
123- d.waitNotifies()
124-
125- d.mu.Lock()
126- require.Empty(t, d.parked)
127- d.mu.Unlock()
128- require.True(t, parkedTransport.Closed())
129- require.True(t, sourceSnatchedTransport.Closed())
130- require.Nil(t, sourceSnatched.currentSession())
131-}
132-
133-func TestMoveTabFinalSourceClearsFollowerCopyCapturePreservesDestinationWaiterOverlays(t *testing.T) {
134- d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t)
135- movedPane := moved.focusedPane()
136-
137- follower.overlays.copyMu.Lock()
138- follower.overlays.copyPane = movedPane
139- follower.overlays.copyMode = &scopy.Mode{}
140- follower.overlays.copyMu.Unlock()
141- follower.sendMu.Lock()
142- follower.captureFrames = map[*pane]capturedPaneRenderState{movedPane: {title: "stale-source-capture"}}
143- follower.sendMu.Unlock()
144-
145- otherPane := destination.tabs[0].focusedPane()
146- waiting := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: follower.size}
147- waiting.initOverlays()
148- waiting.setSession(destination)
149- waiting.overlays.copyMu.Lock()
150- waiting.overlays.copyPane = otherPane
151- waiting.overlays.copyMode = &scopy.Mode{}
152- waiting.overlays.copyMu.Unlock()
153- waiting.sendMu.Lock()
154- waiting.captureFrames = map[*pane]capturedPaneRenderState{otherPane: {}}
155- waiting.sendMu.Unlock()
156- destination.mu.Lock()
157- destination.addSnatchedLocked(waiting)
158- destination.mu.Unlock()
159-
160- moveTabFinalSource(t, d, source, destination, moved)
161-
162- follower.overlays.copyMu.Lock()
163- require.Nil(t, follower.overlays.copyMode)
164- require.Nil(t, follower.overlays.copyPane)
165- follower.overlays.copyMu.Unlock()
166- follower.sendMu.Lock()
167- followerCapture, followerCaptured := follower.captureFrames[movedPane]
168- follower.sendMu.Unlock()
169- if followerCaptured {
170- require.NotEqual(t, "stale-source-capture", followerCapture.title, "source capture survived destination first paint")
171- }
172-
173- waiting.overlays.copyMu.Lock()
174- require.NotNil(t, waiting.overlays.copyMode)
175- require.Same(t, otherPane, waiting.overlays.copyPane)
176- waiting.overlays.copyMu.Unlock()
177- waiting.sendMu.Lock()
178- _, waitingCaptured := waiting.captureFrames[otherPane]
179- waiting.sendMu.Unlock()
180- require.True(t, waitingCaptured)
181-}
182-
183-func TestMoveTabFinalSourceFirstPaintNotBlockedByDisplacedPanelCleanup(t *testing.T) {
184- d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t)
185- followerRebased := make(chan struct{})
186- follower.renderStages.handoffRebase = func() { close(followerRebased) }
187-
188- displaced.sendMu.Lock()
189- panelCleanupStarted := make(chan struct{})
190- d.afterDisplacedCleanupStarted = func() { close(panelCleanupStarted) }
191- defer func() { d.afterDisplacedCleanupStarted = nil }()
192-
193- moveDone := make(chan struct{})
194- go func() {
195- moveTabFinalSource(t, d, source, destination, moved)
196- close(moveDone)
197- }()
198-
199- awaitTestCompletion(t, panelCleanupStarted, "displaced panel cleanup did not start")
200- awaitTestCompletion(t, followerRebased, "follower reset first paint waited for displaced panel cleanup")
201- awaitTestCompletion(t, moveDone, "move-tab handoff did not finish after follower first paint")
202-
203- displaced.sendMu.Unlock()
204- d.attachmentCleanupWg.Wait()
205-}
206-
207-func TestMoveTabFinalSourceConcurrentReclaimKeepsNewestOwnerAndLease(t *testing.T) {
208- d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t)
209-
210- paintAdmitted := make(chan struct{})
211- releasePaint := make(chan struct{})
212- d.afterRoleEffectAdmitted = func(token attachmentRoleToken) {
213- if token.ac == follower && token.role == attachmentActive {
214- close(paintAdmitted)
215- <-releasePaint
216- }
217- }
218- defer func() { d.afterRoleEffectAdmitted = nil }()
219-
220- moveDone := make(chan error, 1)
221- go func() {
222- moveDone <- d.moveTab(moveTabRequest{
223- Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
224- SourceTabID: domain.TabStableID(moved.stableID),
225- Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
226- })
227- }()
228- awaitTestCompletion(t, paintAdmitted, "move-tab follower first paint was not admitted")
229-
230- displacedToken := destination.attachmentToken(displaced, displaced.transportSnapshot().transport)
231- reclaimDone := make(chan struct{})
232- go func() {
233- d.handleSnatchedClientFrame(displacedToken, ports.Frame{
234- Type: ports.MsgInput,
235- Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
236- })
237- close(reclaimDone)
238- }()
239-
240- close(releasePaint)
241- require.NoError(t, <-moveDone)
242- awaitTestCompletion(t, reclaimDone, "concurrent destination reclaim did not finish")
243- d.attachmentCleanupWg.Wait()
244-
245- requireSingleOwnerAndLease(t, destination, follower, displaced)
246- destination.mu.Lock()
247- require.Same(t, displaced, destination.client)
248- destination.mu.Unlock()
249- require.Equal(t, attachmentSnatched, destination.attachmentRole(follower))
250- require.Equal(t, attachmentActive, destination.attachmentRole(displaced))
251-}
252-
253-func TestMoveTabFinalSourcePostCommitPaintFailureDoesNotRollback(t *testing.T) {
254- d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t)
255-
256- d.afterRoleEffectAdmitted = func(token attachmentRoleToken) {
257- if token.ac == follower {
258- token.ac.roleGeneration.Add(1)
259- }
260- }
261- defer func() { d.afterRoleEffectAdmitted = nil }()
262-
263- beforeTabs := append([]*tab(nil), destination.tabs...)
264- moveTabFinalSource(t, d, source, destination, moved)
265-
266- require.Nil(t, source.tabs)
267- require.Same(t, destination, follower.currentSession())
268- require.Same(t, follower, destination.client)
269- require.Equal(t, append(beforeTabs, moved), destination.tabs)
270- require.Same(t, moved, destination.tabs[destination.active])
271-}
272-
273-func TestMoveTabFinalSourceHandoffCommitHidesPartialPublication(t *testing.T) {
274- d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t)
275-
276- commitEntered := make(chan struct{})
277- releaseCommit := make(chan struct{})
278- d.beforeMoveTabCommit = func() {
279- close(commitEntered)
280- <-releaseCommit
281- }
282- defer func() { d.beforeMoveTabCommit = nil }()
283-
284- moveDone := make(chan error, 1)
285- go func() {
286- moveDone <- d.moveTab(moveTabRequest{
287- Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
288- SourceTabID: domain.TabStableID(moved.stableID),
289- Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
290- })
291- }()
292- <-commitEntered
293-
294- type observation struct {
295- sourceRegistered bool
296- movedInSource bool
297- movedInDestination bool
298- followerOnDestination bool
299- displacedSnatched bool
300- movedActive bool
301- }
302- read := func() observation {
303- d.mu.Lock()
304- _, sourceLive := d.sessions[source.id]
305- d.mu.Unlock()
306- source.mu.Lock()
307- movedInSource := indexMoveTabLocked(source, moved) >= 0
308- source.mu.Unlock()
309- destination.mu.Lock()
310- obs := observation{
311- sourceRegistered: sourceLive,
312- movedInSource: movedInSource,
313- movedInDestination: indexMoveTabLocked(destination, moved) >= 0,
314- followerOnDestination: destination.client == follower,
315- displacedSnatched: false,
316- movedActive: destination.active >= 0 && destination.tabs[destination.active] == moved,
317- }
318- if displaced != nil {
319- _, obs.displacedSnatched = destination.snatched[displaced]
320- }
321- destination.mu.Unlock()
322- return obs
323- }
324-
325- require.False(t, d.mu.TryLock(), "daemon observers must be excluded from partial move-tab publication")
326- require.False(t, source.mu.TryLock(), "source observers must be excluded from partial move-tab publication")
327- require.False(t, destination.mu.TryLock(), "destination observers must be excluded from partial move-tab publication")
328-
329- close(releaseCommit)
330- require.NoError(t, <-moveDone)
331-
332- final := read()
333- require.False(t, final.sourceRegistered)
334- require.False(t, final.movedInSource)
335- require.True(t, final.movedInDestination)
336- require.True(t, final.followerOnDestination)
337- require.True(t, final.displacedSnatched)
338- require.True(t, final.movedActive)
339-}
M internal/usecase/daemon/move_tab_test.go +14 -93
@@ -27,13 +27,13 @@ func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) {
2727 moved.name = "preserved-name"
2828 remaining := source.tabs[1]
2929 remaining.stableID = "remaining-tab"
30- source.active = 0
30+ selectTestAttachmentTab(source, 0)
3131
3232 destinationCtx, destinationCancel := context.WithCancel(d.serveCtx)
3333 t.Cleanup(destinationCancel)
3434 destinationTab := newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 100, Rows: 30})
3535 destinationTab.ctx, destinationTab.cancel = context.WithCancel(destinationCtx)
36- destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{9}, ephemeral: destinationEphemeral}, ctx: destinationCtx, cancel: destinationCancel, tabs: []*tab{destinationTab}, active: 0}
36+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{9}, ephemeral: destinationEphemeral}, ctx: destinationCtx, cancel: destinationCancel, tabs: []*tab{destinationTab}}
3737 publishTiledPaneOwners(destination, destinationTab)
3838
3939 floating := newPaneWithStableID("floating", "floating-stable", floatingPTY, domain.Size{Cols: 20, Rows: 8})
@@ -53,7 +53,7 @@ func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) {
5353 oldWorkerCtx := moved.ctx
5454 oldTree := moved.tree
5555 oldPanes := moved.panes
56- beforeDestinationActive := destination.tabs[destination.active]
56+ beforeDestinationActive := destination.tabs[testAttachmentTabIndex(destination)]
5757
5858 err := d.moveTab(moveTabRequest{
5959 Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
@@ -63,10 +63,10 @@ func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) {
6363 require.NoError(t, err)
6464
6565 require.Equal(t, []*tab{remaining}, source.tabs)
66- require.Zero(t, source.active)
67- require.Same(t, remaining, source.tabs[source.active])
66+ require.Zero(t, testAttachmentTabIndex(source))
67+ require.Same(t, remaining, source.tabs[testAttachmentTabIndex(source)])
6868 require.Equal(t, []*tab{destinationTab, moved}, destination.tabs)
69- require.Same(t, beforeDestinationActive, destination.tabs[destination.active], "moved tab stays in the background")
69+ require.Same(t, beforeDestinationActive, destination.tabs[testAttachmentTabIndex(destination)], "moved tab stays in the background")
7070 require.Equal(t, destinationTab.size, moved.size, "destination geometry becomes authoritative")
7171 require.Equal(t, "preserved-name", moved.name)
7272 require.Same(t, oldTree, moved.tree)
@@ -87,85 +87,6 @@ func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) {
8787 }
8888 }
8989
90-func TestMoveTabFinalSourceVisibleFloatingPreservesRuntimeAndFollowsClient(t *testing.T) {
91- d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
92- moved := source.tabs[0]
93- moved.stableID = "moved-tab"
94- require.NotNil(t, d.attachCoordinator(source, nil, client, true))
95- destination := addMoveTabTestSession(d, "destination", "destination-tab")
96-
97- floatingPTY := newQuietPTY()
98- floating := newPaneWithStableID("floating", "floating-stable", floatingPTY, domain.Size{Cols: 20, Rows: 8})
99- floatingCtx, floatingCancel := context.WithCancel(d.paneProcessCtx)
100- t.Cleanup(floatingCancel)
101- floating.ctx, floating.cancel = floatingCtx, floatingCancel
102- moved.mu.Lock()
103- moved.floating = floatingSlot{state: floatingVisible, pane: floating, desiredVisible: true, generation: 7}
104- moved.mu.Unlock()
105- publishPaneOwner(floating, source, moved, 7)
106- d.applyTabLayout(source, moved)
107-
108- beforeScreen := floating.screen
109- beforeHistory := floating.history
110- beforeContext := floating.ctx
111- beforeOwnerGeneration := floating.ownerGeneration
112-
113- require.NoError(t, d.moveTab(moveTabRequest{
114- Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
115- Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
116- }))
117-
118- require.Same(t, moved, destination.tabs[destination.active])
119- require.Same(t, client, destination.client)
120- require.Same(t, destination, client.currentSession())
121- moved.mu.Lock()
122- require.Equal(t, floatingVisible, moved.floating.state)
123- require.True(t, moved.floating.desiredVisible)
124- require.Equal(t, uint64(7), moved.floating.generation)
125- require.Same(t, floating, moved.floating.pane)
126- moved.mu.Unlock()
127- expectedGeometry := calculateContentFloatingGeometry(moved.size, d.currentFloatingConfig())
128- floating.mu.Lock()
129- require.Same(t, floatingPTY, floating.pty)
130- require.Same(t, beforeScreen, floating.screen)
131- require.Same(t, beforeHistory, floating.history)
132- require.Equal(t, beforeContext, floating.ctx)
133- require.Equal(t, expectedGeometry, floating.popupGeometry)
134- require.Equal(t, expectedGeometry.Inner, floating.rect)
135- require.Equal(t, beforeOwnerGeneration+1, floating.ownerGeneration)
136- floating.mu.Unlock()
137- owner := floating.ownerSnapshot()
138- require.Same(t, destination, owner.session)
139- require.Same(t, moved, owner.tab)
140- require.Equal(t, floating.ownerGeneration, owner.generation)
141- require.Equal(t, uint64(7), owner.floatingSlotGeneration)
142- require.NoError(t, floating.ctx.Err(), "floating process context remains live")
143-}
144-
145-func TestMoveTabFinalSourceClientFollowsAndActivatesMovedTab(t *testing.T) {
146- d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
147- moved := source.tabs[0]
148- moved.stableID = "moved-tab"
149- require.NotNil(t, d.attachCoordinator(source, nil, client, true))
150- rebased := false
151- client.renderStages.handoffRebase = func() { rebased = true }
152- destination := addMoveTabTestSession(d, "destination", "destination-tab")
153- oldDestinationActive := destination.tabs[0]
154-
155- require.NoError(t, d.moveTab(moveTabRequest{
156- Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
157- Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
158- }))
159-
160- require.Nil(t, source.tabs)
161- require.Same(t, destination, client.currentSession())
162- require.Same(t, client, destination.client)
163- require.Equal(t, attachmentActive, destination.attachmentRole(client))
164- require.Equal(t, []*tab{oldDestinationActive, moved}, destination.tabs)
165- require.Same(t, moved, destination.tabs[destination.active], "final-source follower activates the moved tab")
166- require.True(t, rebased)
167-}
168-
16990 func TestMoveTabRejectsSameSessionStaleMembershipAndWarming(t *testing.T) {
17091 tests := []struct {
17192 name string
@@ -231,7 +152,7 @@ func TestMoveTabFinalSourceVisibleFloatingRejectsStaleSlotTransitions(t *testing
231152 d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
232153 moved := source.tabs[0]
233154 moved.stableID = "moved-tab"
234- require.NotNil(t, d.attachCoordinator(source, nil, client, true))
155+ require.NotNil(t, d.attachCoordinator(source, client, true))
235156 destination := addMoveTabTestSession(d, "destination", "destination-tab")
236157 floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8})
237158 moved.mu.Lock()
@@ -241,7 +162,7 @@ func TestMoveTabFinalSourceVisibleFloatingRejectsStaleSlotTransitions(t *testing
241162
242163 source.layoutApplyMu.Lock()
243164 admitted := make(chan struct{})
244- d.afterRoleEffectGateFrozen = func(action string, ac *attachedClient) {
165+ d.afterAttachmentEffectGateFrozen = func(action string, ac *attachedClient) {
245166 if action == "move-tab" && ac == client {
246167 close(admitted)
247168 }
@@ -262,7 +183,7 @@ func TestMoveTabFinalSourceVisibleFloatingRejectsStaleSlotTransitions(t *testing
262183 require.Error(t, awaitTestValue(t, moveDone, "stale floating transfer did not return"))
263184 require.NotContains(t, destination.tabs, moved)
264185 require.Same(t, source, floating.ownerSnapshot().session)
265- d.afterRoleEffectGateFrozen = nil
186+ d.afterAttachmentEffectGateFrozen = nil
266187 })
267188 }
268189 }
@@ -487,12 +408,12 @@ func (r *moveTabPurgeRepository) DeleteIncarnation(_ context.Context, id domain.
487408 sourceFenceUnlocked := r.source.layoutApplyMu.TryLock()
488409 destinationFenceUnlocked := r.destination.layoutApplyMu.TryLock()
489410 tabFenceUnlocked := r.moved.layoutApplyMu.TryLock()
490- roleUnlocked := r.client.roleEffects.mu.TryLock()
491- roleStable := roleUnlocked && r.client.roleEffects.phase == roleEffectsStable
411+ roleUnlocked := r.client.attachmentEffects.mu.TryLock()
412+ roleStable := roleUnlocked && r.client.attachmentEffects.phase == attachmentEffectsStable
492413 r.outside = daemonUnlocked && routingUnlocked && sourceUnlocked && destinationUnlocked && tabUnlocked && paneUnlocked &&
493414 sourceFenceUnlocked && destinationFenceUnlocked && tabFenceUnlocked && roleStable
494415 if roleUnlocked {
495- r.client.roleEffects.mu.Unlock()
416+ r.client.attachmentEffects.mu.Unlock()
496417 }
497418 if tabFenceUnlocked {
498419 r.moved.layoutApplyMu.Unlock()
@@ -532,7 +453,7 @@ func (r *moveTabPurgeRepository) DeleteIncarnation(_ context.Context, id domain.
532453
533454 func TestMoveTabFinalNamedSourcePurgesAfterDestinationAdmissionAndFailureDoesNotRollback(t *testing.T) {
534455 d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
535- require.NotNil(t, d.attachCoordinator(source, nil, client, true))
456+ require.NotNil(t, d.attachCoordinator(source, client, true))
536457 source.incarnation = domain.IncarnationID{4}
537458 moved := source.tabs[0]
538459 moved.stableID = "moved-tab"
@@ -572,7 +493,7 @@ func addMoveTabTestSession(d *Daemon, id domain.SessionID, tabID string) *sessio
572493 ctx, cancel := context.WithCancel(d.serveCtx)
573494 tb := newTabWithStableID(tabID, tabID+"-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
574495 tb.ctx, tb.cancel = context.WithCancel(ctx)
575- sess := &session{sessionCore: sessionCore{id: id, name: string(id), incarnation: domain.IncarnationID{5}, ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0}
496+ sess := &session{sessionCore: sessionCore{id: id, name: string(id), incarnation: domain.IncarnationID{5}, ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{tb}}
576497 publishTiledPaneOwners(sess, tb)
577498 d.mu.Lock()
578499 d.sessions[sess.id] = sess
M internal/usecase/daemon/move_test.go +6 -52
@@ -3,7 +3,6 @@ package daemon
33 import (
44 "context"
55 "os"
6- "sync/atomic"
76 "testing"
87
98 "github.com/stretchr/testify/require"
@@ -49,7 +48,7 @@ func TestMovePaneMutationCrossSession(t *testing.T) {
4948 defer release2()
5049 d, source, _, _ := newManualSessionWithPTYs(t, p1)
5150 source.mu.Lock()
52- source.client = nil
51+ clearAttachmentsForTestLocked(source)
5352 source.mu.Unlock()
5453 sourceTab := source.tabs[0]
5554 sourceTab.stableID = "source-tab"
@@ -57,7 +56,7 @@ func TestMovePaneMutationCrossSession(t *testing.T) {
5756 movedCtx, movedCancel := context.WithCancel(d.paneProcessCtx)
5857 moved.ctx, moved.cancel = movedCtx, movedCancel
5958
60- destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0}
59+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}}
6160 destinationTab := destination.tabs[0]
6261 destinationTab.stableID = "destination-tab"
6362 publishTiledPaneOwners(destination, destinationTab)
@@ -81,47 +80,6 @@ func TestMovePaneMutationCrossSession(t *testing.T) {
8180 require.NoError(t, moved.ctx.Err(), "retiring the source must not cancel the transferred pane process")
8281 }
8382
84-func TestMovePaneFinalSourceClientFollowsDestination(t *testing.T) {
85- p1, release1 := newBlockingPTY(t)
86- p2, release2 := newBlockingPTY(t)
87- defer release1()
88- defer release2()
89- d, source, client, _ := newManualSessionWithPTYs(t, p1)
90- sourceTab := source.tabs[0]
91- sourceTab.stableID = "source-tab"
92- moved := sourceTab.focusedPane()
93- movedTabCtx, movedTabCancel := context.WithCancel(d.paneProcessCtx)
94- moved.ctx, moved.cancel = movedTabCtx, movedTabCancel
95-
96- rc := d.attachCoordinator(source, nil, client, true)
97- require.NotNil(t, rc)
98- var rebased atomic.Bool
99- client.renderStages.handoffRebase = func() { rebased.Store(true) }
100- destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0}
101- destinationTab := destination.tabs[0]
102- destinationTab.stableID = "destination-tab"
103- publishTiledPaneOwners(destination, destinationTab)
104- d.mu.Lock()
105- d.sessions[destination.id] = destination
106- d.mu.Unlock()
107-
108- err := d.movePane(movePaneRequest{
109- Source: moveSessionLocator{ID: source.id},
110- SourceTabID: domain.TabStableID(sourceTab.stableID),
111- SourcePaneID: domain.PaneStableID(moved.stableID),
112- Destination: moveSessionLocator{ID: destination.id},
113- DestinationTabID: domain.TabStableID(destinationTab.stableID),
114- })
115- require.NoError(t, err)
116- require.Nil(t, source.tabs)
117- require.Same(t, destination, client.currentSession())
118- require.Same(t, client, destination.client)
119- require.Equal(t, attachmentActive, destination.attachmentRole(client))
120- require.Same(t, destinationTab, destination.tabs[destination.active])
121- require.True(t, rebased.Load(), "final-source follower must rebase output before its first paint")
122- require.NoError(t, moved.ctx.Err(), "transferred pane process must remain alive")
123-}
124-
12583 func TestMovePaneRetiresEmptySourceWithoutClosingTransferredResources(t *testing.T) {
12684 movedPTY := newQuietPTY()
12785 d, source, active, _ := newManualSessionWithPTYs(t, movedPTY)
@@ -129,7 +87,7 @@ func TestMovePaneRetiresEmptySourceWithoutClosingTransferredResources(t *testing
12987 require.NoError(t, err)
13088 require.NoError(t, clip.Close())
13189 source.mu.Lock()
132- source.client = nil
90+ clearAttachmentsForTestLocked(source)
13391 source.clipFiles = []string{clip.Name()}
13492 source.mu.Unlock()
13593 active.setSession(nil)
@@ -140,9 +98,7 @@ func TestMovePaneRetiresEmptySourceWithoutClosingTransferredResources(t *testing
14098 movedTab.stableID = "source-tab"
14199
142100 destinationPTY := newQuietPTY()
143- destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
144- active: 0,
145- }
101+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}
146102 publishTiledPaneOwners(destination, destination.tabs[0])
147103 d.mu.Lock()
148104 d.sessions[destination.id] = destination
@@ -179,7 +135,7 @@ func TestMovePaneRetiresSourceParkedClients(t *testing.T) {
179135 movedPTY := newQuietPTY()
180136 d, source, _, _ := newManualSessionWithPTYs(t, movedPTY)
181137 source.mu.Lock()
182- source.client = nil
138+ clearAttachmentsForTestLocked(source)
183139 source.mu.Unlock()
184140 moved := source.tabs[0].focusedPane()
185141 movedTab := source.tabs[0]
@@ -190,9 +146,7 @@ func TestMovePaneRetiresSourceParkedClients(t *testing.T) {
190146 parked.initOverlays()
191147 require.True(t, d.parkAttachment(source, parked))
192148 destinationPTY := newQuietPTY()
193- destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
194- active: 0,
195- }
149+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}
196150 publishTiledPaneOwners(destination, destination.tabs[0])
197151 d.mu.Lock()
198152 d.sessions[destination.id] = destination
A internal/usecase/daemon/multi_attachment_callers_test.go +119 -0
@@ -0,0 +1,119 @@
1+package daemon
2+
3+import (
4+ "encoding/json"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/usecase/layout"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestDaemonActionTargetsUseEachInitiatingAttachmentView(t *testing.T) {
13+ sess := registryTestSession()
14+ first, second := registryTestAttachment(1), registryTestAttachment(2)
15+ require.True(t, sess.registerAttachment(first))
16+ require.True(t, sess.registerAttachment(second))
17+ require.True(t, sess.selectAttachmentTab(second, domain.TabStableID(sess.tabs[1].stableID)))
18+
19+ firstTarget := resolveDaemonActionTargetForAttachment(sess, first)
20+ secondTarget := resolveDaemonActionTargetForAttachment(sess, second)
21+ require.Same(t, sess.tabs[0], firstTarget.tab)
22+ require.Same(t, sess.tabs[1], secondTarget.tab)
23+ require.Same(t, sess.tabs[0].focusedPane(), firstTarget.pane)
24+ require.Same(t, sess.tabs[1].focusedPane(), secondTarget.pane)
25+}
26+
27+func TestAttachmentsKeepDifferentPaneFocusForActionsRenderingAndMetadata(t *testing.T) {
28+ sess, firstPane, secondPane := multiPaneAttachmentSession(t)
29+ first, second := registryTestAttachment(1), registryTestAttachment(2)
30+ require.True(t, sess.registerAttachment(first))
31+ require.True(t, sess.registerAttachment(second))
32+ require.True(t, sess.updateAttachmentView(second, func(view *attachmentView) {
33+ view.paneID = domain.PaneStableID(secondPane.stableID)
34+ }))
35+
36+ firstTarget := resolveDaemonActionTargetForAttachment(sess, first)
37+ secondTarget := resolveDaemonActionTargetForAttachment(sess, second)
38+ require.Same(t, firstPane, firstTarget.pane)
39+ require.Same(t, secondPane, secondTarget.pane)
40+
41+ firstState, ok := captureLocalRenderState(sess, first, renderCaptureRequest{})
42+ require.True(t, ok)
43+ secondState, ok := captureLocalRenderState(sess, second, renderCaptureRequest{})
44+ require.True(t, ok)
45+ require.Equal(t, firstPane.id, firstState.layout.focus)
46+ require.Equal(t, secondPane.id, secondState.layout.focus)
47+
48+ d := newTestDaemon(t, nil, stubClock{})
49+ var rows []struct {
50+ Pane string `json:"pane"`
51+ Focused bool `json:"focused"`
52+ }
53+ output, err := (controlExec{d: d, sess: sess, tab: sess.tabs[0], target: daemonActionTarget{attachment: second}}).ListPanes(true)
54+ require.NoError(t, err)
55+ require.NoError(t, json.Unmarshal([]byte(output), &rows))
56+ for _, row := range rows {
57+ require.Equal(t, row.Pane == string(secondPane.id), row.Focused)
58+ }
59+}
60+
61+func TestAttachmentFocusActionUpdatesOnlyInitiatingPaneView(t *testing.T) {
62+ sess, firstPane, secondPane := multiPaneAttachmentSession(t)
63+ first, second := registryTestAttachment(1), registryTestAttachment(2)
64+ require.True(t, sess.registerAttachment(first))
65+ require.True(t, sess.registerAttachment(second))
66+
67+ d := newTestDaemon(t, nil, stubClock{})
68+ require.NoError(t, d.focusDir(sess, second, layout.Right, nil))
69+ require.Equal(t, domain.PaneStableID(firstPane.stableID), first.viewSnapshot().paneID)
70+ require.Equal(t, domain.PaneStableID(secondPane.stableID), second.viewSnapshot().paneID)
71+}
72+
73+func TestPaneRemovalRepairsEachAttachmentViewIndependently(t *testing.T) {
74+ sess, firstPane, secondPane := multiPaneAttachmentSession(t)
75+ first, second := registryTestAttachment(1), registryTestAttachment(2)
76+ require.True(t, sess.registerAttachment(first))
77+ require.True(t, sess.registerAttachment(second))
78+ require.True(t, sess.updateAttachmentView(second, func(view *attachmentView) {
79+ view.paneID = domain.PaneStableID(secondPane.stableID)
80+ }))
81+
82+ require.NoError(t, sess.runMutation(func() error {
83+ sess.mu.Lock()
84+ defer sess.mu.Unlock()
85+ tb := sess.tabs[0]
86+ tb.mu.Lock()
87+ closeErr := tb.tree.Close(firstPane.id)
88+ if closeErr == nil {
89+ delete(tb.panes, firstPane.id)
90+ tb.bumpLayoutGenerationLocked()
91+ }
92+ tb.mu.Unlock()
93+ if closeErr != nil {
94+ return closeErr
95+ }
96+ sess.repairAttachmentViewsLocked()
97+ return nil
98+ }))
99+
100+ require.Equal(t, domain.PaneStableID(secondPane.stableID), first.viewSnapshot().paneID)
101+ require.Equal(t, domain.PaneStableID(secondPane.stableID), second.viewSnapshot().paneID)
102+}
103+
104+func multiPaneAttachmentSession(t *testing.T) (*session, *pane, *pane) {
105+ t.Helper()
106+ sess := registryTestSession()
107+ tb := sess.tabs[0]
108+ firstPane := tb.panes[layout.PaneID("pane-1")]
109+ secondPane := newPaneWithStableID("pane-2", "stable-pane-b", nil, domain.Size{Cols: 80, Rows: 23})
110+ tb.mu.Lock()
111+ tb.panes[secondPane.id] = secondPane
112+ tb.tree = &layout.Tree{
113+ Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(firstPane.id), layout.NewLeaf(secondPane.id)}},
114+ Focus: firstPane.id,
115+ }
116+ tb.bumpLayoutGenerationLocked()
117+ tb.mu.Unlock()
118+ return sess, firstPane, secondPane
119+}
R internal/usecase/daemon/navigation_role_handoff_test.go → internal/usecase/daemon/navigation_attachment_handoff_test.go +25 -26 renamed, 73% similar
@@ -16,31 +16,31 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
1616 tests := []struct {
1717 name string
1818 action string
19- run func(*Daemon, *session, *session, *attachedClient, *roleEffectTicket) error
19+ run func(*Daemon, *session, *session, *attachedClient, *attachmentEffectTicket) error
2020 check func(*testing.T, *Daemon, *session, *session)
2121 }{
2222 {
2323 name: "picker selection", action: "picker-select",
24- run: func(d *Daemon, source, target *session, ac *attachedClient, effect *roleEffectTicket) error {
24+ run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error {
2525 d.enterPicker(source, ac)
26- return d.switchToTargetForRole(effect.roleToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "picker-select")
26+ return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "picker-select")
2727 },
2828 },
2929 {
3030 name: "palette session", action: "palette-session",
31- run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *roleEffectTicket) error {
32- return d.switchToTargetForRole(effect.roleToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session")
31+ run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffectTicket) error {
32+ return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session")
3333 },
3434 },
3535 {
3636 name: "palette recent session", action: "palette-recent-session",
37- run: func(d *Daemon, source, target *session, ac *attachedClient, effect *roleEffectTicket) error {
37+ run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error {
3838 return paletteExec{d: d, sess: source, ac: ac, recent: []recentSession{{id: target.id}}, effect: effect}.JumpRecentSession(1)
3939 },
4040 },
4141 {
4242 name: "session overflow", action: "overflow-session",
43- run: func(d *Daemon, source, target *session, ac *attachedClient, effect *roleEffectTicket) error {
43+ run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error {
4444 source.mu.Lock()
4545 source.name = "alpha"
4646 source.mu.Unlock()
@@ -53,13 +53,13 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
5353 },
5454 {
5555 name: "stopped session resume", action: "stopped-session",
56- run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *roleEffectTicket) error {
56+ run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffectTicket) error {
5757 d.mu.Lock()
5858 delete(d.sessions, target.id)
5959 d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 9, state: ports.SessionStopped}
6060 d.mu.Unlock()
6161 expectedCreatedAt := int64(9)
62- return d.switchToTargetForRole(effect.roleToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "stopped-session")
62+ return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "stopped-session")
6363 },
6464 check: func(t *testing.T, d *Daemon, _, _ *session) {
6565 d.mu.Lock()
@@ -72,8 +72,8 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
7272 },
7373 {
7474 name: "transition prompt creation", action: "create-session",
75- run: func(d *Daemon, _ *session, _ *session, _ *attachedClient, effect *roleEffectTicket) error {
76- return d.createSessionAndSwitchForRole(effect.roleToken(), "created")
75+ run: func(d *Daemon, _ *session, _ *session, _ *attachedClient, effect *attachmentEffectTicket) error {
76+ return d.createSessionAndSwitchForAttachment(effect.connectionToken(), "created")
7777 },
7878 check: func(t *testing.T, d *Daemon, _, _ *session) {
7979 d.mu.Lock()
@@ -97,17 +97,17 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
9797 d.sessions[target.id] = target
9898 d.mu.Unlock()
9999
100- rc := d.attachCoordinator(source, nil, old, true)
100+ rc := d.attachCoordinator(source, old, true)
101101 token := source.attachmentToken(old, old.transport())
102102 token.lease = rc.attachmentLease(old)
103- old.publishRoleCapability(token)
104- effect, admitted := old.beginRoleEffect(token)
103+ old.publishAttachmentCapability(token)
104+ effect, admitted := old.beginAttachmentEffect(token)
105105 require.True(t, admitted)
106106
107107 admissionEnded := make(chan struct{})
108108 releaseAction := make(chan struct{})
109109 var admissionEndedOnce sync.Once
110- d.afterActionRoleEffectEnded = func(action string) {
110+ d.afterActionAttachmentEffectEnded = func(action string) {
111111 if action == tt.action {
112112 admissionEndedOnce.Do(func() { close(admissionEnded) })
113113 <-releaseAction
@@ -123,7 +123,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
123123
124124 replacement, err := d.transitionAttachment(attachmentTransitionRequest{
125125 source: source, target: source, next: old,
126- expectedRole: attachmentActive, targetRole: attachmentActive,
126+
127127 expectedTransport: old.transportSnapshot(), ready: true,
128128 })
129129 require.NoError(t, err)
@@ -132,11 +132,11 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
132132
133133 require.Same(t, source, old.currentSession())
134134 source.mu.Lock()
135- require.Same(t, old, source.client)
135+ require.Contains(t, source.snapshotAttachmentsLocked(), old)
136136 source.mu.Unlock()
137137 target.mu.Lock()
138- require.Nil(t, target.client, "stale handoff attached to the target")
139- require.Zero(t, target.active, "stale handoff changed target tab focus")
138+ require.Empty(t, target.snapshotAttachmentsLocked(), "stale handoff attached to the target")
139+ require.Zero(t, testAttachmentTabIndexLocked(target), "stale handoff changed target tab focus")
140140 target.mu.Unlock()
141141 if tt.check != nil {
142142 tt.check(t, d, source, target)
@@ -152,17 +152,17 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
152152 d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 7, state: ports.SessionStopped}
153153 d.mu.Unlock()
154154
155- rc := d.attachCoordinator(source, nil, old, true)
155+ rc := d.attachCoordinator(source, old, true)
156156 token := source.attachmentToken(old, old.transport())
157157 token.lease = rc.attachmentLease(old)
158- old.publishRoleCapability(token)
159- effect, admitted := old.beginRoleEffect(token)
158+ old.publishAttachmentCapability(token)
159+ effect, admitted := old.beginAttachmentEffect(token)
160160 require.True(t, admitted)
161161
162162 ended := make(chan struct{})
163163 release := make(chan struct{})
164164 var endedOnce sync.Once
165- d.afterActionRoleEffectEnded = func(action string) {
165+ d.afterActionAttachmentEffectEnded = func(action string) {
166166 if action == "picker-stopped" {
167167 endedOnce.Do(func() { close(ended) })
168168 <-release
@@ -171,7 +171,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
171171 expectedCreatedAt := int64(7)
172172 done := make(chan error, 1)
173173 go func() {
174- done <- d.switchToTargetForRole(effect.roleToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "picker-stopped")
174+ done <- d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "picker-stopped")
175175 }()
176176 select {
177177 case <-ended:
@@ -182,8 +182,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
182182 next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
183183 next.initOverlays()
184184 replacement, err := d.transitionAttachment(attachmentTransitionRequest{
185- target: source, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
186- expectedTransport: next.transportSnapshot(), ready: true,
185+ target: source, next: next, expectedTransport: next.transportSnapshot(), ready: true,
187186 })
188187 require.NoError(t, err)
189188 close(release)
R internal/usecase/daemon/navigation_role_incarnation_test.go → internal/usecase/daemon/navigation_attachment_incarnation_test.go +9 -9 renamed, 80% similar
@@ -18,7 +18,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
1818 rebind: func(t *testing.T, d *Daemon, source *session, ac *attachedClient) {
1919 result, err := d.transitionAttachment(attachmentTransitionRequest{
2020 source: source, target: source, next: ac,
21- expectedRole: attachmentActive, targetRole: attachmentActive,
21+
2222 expectedTransport: ac.transportSnapshot(), ready: true,
2323 })
2424 require.NoError(t, err)
@@ -31,7 +31,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
3131 ac.replaceTransport(&closeTrackingTransport{})
3232 current := source.attachmentToken(ac, ac.transport())
3333 current.lease = source.renderCoordinator().attachmentLease(ac)
34- ac.publishRoleCapability(current)
34+ ac.publishAttachmentCapability(current)
3535 },
3636 },
3737 }
@@ -47,16 +47,16 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
4747 d.sessions[target.id] = target
4848 d.mu.Unlock()
4949
50- rc := d.attachCoordinator(source, nil, ac, true)
50+ rc := d.attachCoordinator(source, ac, true)
5151 token := source.attachmentToken(ac, ac.transport())
5252 token.lease = rc.attachmentLease(ac)
53- ac.publishRoleCapability(token)
54- effect, admitted := ac.beginRoleEffect(token)
53+ ac.publishAttachmentCapability(token)
54+ effect, admitted := ac.beginAttachmentEffect(token)
5555 require.True(t, admitted)
5656
5757 ended := make(chan struct{})
5858 release := make(chan struct{})
59- d.afterActionRoleEffectEnded = func(action string) {
59+ d.afterActionAttachmentEffectEnded = func(action string) {
6060 if action == "test-handoff" {
6161 close(ended)
6262 <-release
@@ -64,7 +64,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
6464 }
6565 done := make(chan error, 1)
6666 go func() {
67- done <- d.switchToTargetForRole(effect.roleToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "test-handoff")
67+ done <- d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "test-handoff")
6868 }()
6969 awaitTestCompletion(t, ended, "test handoff did not release its role ticket")
7070 tt.rebind(t, d, source, ac)
@@ -73,8 +73,8 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
7373
7474 require.Same(t, source, ac.currentSession())
7575 target.mu.Lock()
76- require.Nil(t, target.client)
77- require.Zero(t, target.active)
76+ require.Empty(t, target.snapshotAttachmentsLocked())
77+ require.Zero(t, testAttachmentTabIndexLocked(target))
7878 target.mu.Unlock()
7979 })
8080 }
M internal/usecase/daemon/navigation_overflow.go +19 -14
@@ -53,13 +53,13 @@ func resolveOverflow(dir layout.Direction, cfg domain.NavConfig, position, count
5353 // overflowSourceEligible validates that directional overflow still originates
5454 // from the active tiled tab. The session-to-tab lock order matches tab overflow
5555 // commit and leaves no lock held across a session handoff.
56-func overflowSourceEligible(sess *session, expectedSource *tab) bool {
56+func overflowSourceEligible(sess *session, ac *attachedClient, expectedSource *tab) bool {
5757 if sess == nil || expectedSource == nil {
5858 return false
5959 }
6060 sess.mu.Lock()
6161 defer sess.mu.Unlock()
62- if sess.active < 0 || sess.active >= len(sess.tabs) || sess.tabs[sess.active] != expectedSource {
62+ if ac == nil || !attachmentRegisteredLocked(sess, ac) || domain.TabStableID(expectedSource.stableID) != ac.viewSnapshot().tabID {
6363 return false
6464 }
6565 expectedSource.mu.Lock()
@@ -134,18 +134,26 @@ type tabOverflowCandidate struct {
134134 targetOldFocus layout.PaneID
135135 }
136136
137-// prepareTabOverflow captures tab identities under the session lock and uses
138-// EntryPane's pure lookup before any active-tab state is changed.
139-func (d *Daemon) prepareTabOverflow(sess *session, expectedSource *tab, dir layout.Direction, span domain.Rect, delta int) (tabOverflowCandidate, bool) {
137+func (d *Daemon) prepareTabOverflowForAttachment(sess *session, ac *attachedClient, expectedSource *tab, dir layout.Direction, span domain.Rect, delta int) (tabOverflowCandidate, bool) {
140138 if sess == nil || expectedSource == nil || (delta != -1 && delta != 1) {
141139 return tabOverflowCandidate{}, false
142140 }
143141 sess.mu.Lock()
144- if sess.active < 0 || sess.active >= len(sess.tabs) || sess.tabs[sess.active] != expectedSource {
142+ if ac != nil && (!attachmentRegisteredLocked(sess, ac) || domain.TabStableID(expectedSource.stableID) != ac.viewSnapshot().tabID) {
143+ sess.mu.Unlock()
144+ return tabOverflowCandidate{}, false
145+ }
146+ sourceIndex := -1
147+ for i, tb := range sess.tabs {
148+ if tb == expectedSource {
149+ sourceIndex = i
150+ break
151+ }
152+ }
153+ if sourceIndex < 0 {
145154 sess.mu.Unlock()
146155 return tabOverflowCandidate{}, false
147156 }
148- sourceIndex := sess.active
149157 targetIndex := sourceIndex + delta
150158 if targetIndex < 0 || targetIndex >= len(sess.tabs) {
151159 sess.mu.Unlock()
@@ -189,17 +197,14 @@ func (d *Daemon) prepareTabOverflow(sess *session, expectedSource *tab, dir layo
189197 }, true
190198 }
191199
192-// commitTabOverflow atomically revalidates the source and target tab entries.
193-// The target layout and selected pane are revalidated while locked before the
194-// focus and active-tab mutations become visible together.
195-func (d *Daemon) commitTabOverflow(sess *session, candidate tabOverflowCandidate) bool {
200+func (d *Daemon) commitTabOverflowForAttachment(sess *session, ac *attachedClient, candidate tabOverflowCandidate) bool {
196201 if sess == nil || candidate.source == nil || candidate.target == nil {
197202 return false
198203 }
199204 sess.mu.Lock()
200205 targetIndex := candidate.sourceIndex + candidate.delta
201- if candidate.sourceIndex < 0 || candidate.sourceIndex >= len(sess.tabs) || targetIndex < 0 || targetIndex >= len(sess.tabs) ||
202- sess.active != candidate.sourceIndex || sess.tabs[candidate.sourceIndex] != candidate.source || sess.tabs[targetIndex] != candidate.target {
206+ if (ac != nil && (!attachmentRegisteredLocked(sess, ac) || domain.TabStableID(candidate.source.stableID) != ac.viewSnapshot().tabID)) || candidate.sourceIndex < 0 || candidate.sourceIndex >= len(sess.tabs) || targetIndex < 0 || targetIndex >= len(sess.tabs) ||
207+ sess.tabs[candidate.sourceIndex] != candidate.source || sess.tabs[targetIndex] != candidate.target {
203208 sess.mu.Unlock()
204209 return false
205210 }
@@ -234,10 +239,10 @@ func (d *Daemon) commitTabOverflow(sess *session, candidate tabOverflowCandidate
234239 }
235240 candidate.target.tree = targetTree
236241 candidate.target.bumpLayoutGenerationLocked()
237- sess.active = targetIndex
238242 candidate.target.mu.Unlock()
239243 candidate.source.mu.Unlock()
240244 sess.mu.Unlock()
245+ sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID))
241246 d.applyTabLayout(sess, candidate.target)
242247 return true
243248 }
M internal/usecase/daemon/navigation_overflow_test.go +33 -53
@@ -42,6 +42,20 @@ func TestResolveOverflowObeysAxisConfigurationAndWalls(t *testing.T) {
4242 }
4343 }
4444
45+func TestPrepareTabOverflowRejectsMissingExpectedSource(t *testing.T) {
46+ d, sess, _, _ := newManualSessionWithPTYs(t, nil, nil)
47+ target := sess.tabs[1]
48+ target.mu.Lock()
49+ target.size = domain.Size{Cols: 41, Rows: 10}
50+ target.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"}
51+ target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
52+ target.mu.Unlock()
53+
54+ missing := &tab{stableID: "missing-source"}
55+ _, ok := d.prepareTabOverflowForAttachment(sess, nil, missing, layout.Right, domain.Rect{Width: 20, Height: 10}, 1)
56+ require.False(t, ok)
57+}
58+
4559 func TestKeyboardHorizontalOverflowLandsOnFacingEdge(t *testing.T) {
4660 tests := []struct {
4761 name string
@@ -58,7 +72,7 @@ func TestKeyboardHorizontalOverflowLandsOnFacingEdge(t *testing.T) {
5872 t.Run(tt.name, func(t *testing.T) {
5973 d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil)
6074 sess.mu.Lock()
61- sess.active = tt.start
75+ selectTestAttachmentTabLocked(sess, tt.start)
6276 target := sess.tabs[tt.wantActive]
6377 source := sess.tabs[tt.start]
6478 sess.mu.Unlock()
@@ -71,11 +85,11 @@ func TestKeyboardHorizontalOverflowLandsOnFacingEdge(t *testing.T) {
7185
7286 daemonKeyHandler{d: d, ac: ac}.Action(tt.action, nil)
7387
74- require.Equal(t, tt.wantActive, activeTabIndex(sess))
88+ require.Equal(t, tt.wantActive, testAttachmentTabIndex(sess))
7589 target.mu.Lock()
7690 require.Equal(t, tt.wantFocus, target.tree.Focus)
7791 target.mu.Unlock()
78- require.NotSame(t, source, sess.activeTab())
92+ require.NotSame(t, source, testAttachmentTab(sess))
7993 })
8094 }
8195 }
@@ -93,7 +107,7 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t
93107 target.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: focus}
94108 target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
95109 target.mu.Unlock()
96- return &session{sessionCore: sessionCore{id: domain.SessionID(id), name: name}, ctx: t.Context(), cancel: func() {}, tabs: tabs, active: active}
110+ return &session{sessionCore: sessionCore{id: domain.SessionID(id), name: name}, ctx: t.Context(), cancel: func() {}, tabs: tabs}
97111 }
98112 charlie := newSession("live-charlie", "charlie", 1, "pane-2")
99113 echo := newSession("live-echo", "echo", 1, "pane-2")
@@ -130,7 +144,7 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t
130144 }
131145
132146 for _, target := range []*session{charlie, echo} {
133- require.Equal(t, 1, activeTabIndex(target), "switch preserves the target active tab")
147+ require.Equal(t, 0, testAttachmentTabIndex(target), "new attachment starts at the deterministic first tab")
134148 target.tabs[1].mu.Lock()
135149 require.Equal(t, layout.PaneID("pane-2"), target.tabs[1].tree.Focus, "switch preserves the target pane focus")
136150 target.tabs[1].mu.Unlock()
@@ -149,11 +163,11 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t
149163 }
150164 d.mu.Unlock()
151165 alpha.mu.Lock()
152- attached := alpha.client
166+ attached := alpha.snapshotAttachmentsLocked()
153167 alpha.mu.Unlock()
154168 require.True(t, stopped, "vertical overflow leaves the stopped session stopped")
155169 require.False(t, bravoLive, "the stopped session is absent from the live registry")
156- require.Same(t, ac, attached, "the source session owns the genuinely attached client after returning")
170+ require.Contains(t, attached, ac, "the source session owns the genuinely attached client after returning")
157171 }
158172
159173 func TestKeyboardVerticalOverflowRefusesVisibleFloatingSource(t *testing.T) {
@@ -172,10 +186,10 @@ func TestKeyboardVerticalOverflowRefusesVisibleFloatingSource(t *testing.T) {
172186
173187 require.Same(t, alpha, ac.currentSession())
174188 alpha.mu.Lock()
175- require.Same(t, ac, alpha.client)
189+ require.Contains(t, alpha.snapshotAttachmentsLocked(), ac)
176190 alpha.mu.Unlock()
177191 charlie.mu.Lock()
178- require.Nil(t, charlie.client)
192+ require.Empty(t, charlie.snapshotAttachmentsLocked())
179193 charlie.mu.Unlock()
180194 }
181195
@@ -196,10 +210,10 @@ func TestVerticalOverflowRevalidatesFloatingSourceBeforeHandoff(t *testing.T) {
196210 require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor)
197211 require.Same(t, alpha, ac.currentSession())
198212 alpha.mu.Lock()
199- require.Same(t, ac, alpha.client)
213+ require.Contains(t, alpha.snapshotAttachmentsLocked(), ac)
200214 alpha.mu.Unlock()
201215 charlie.mu.Lock()
202- require.Nil(t, charlie.client)
216+ require.Empty(t, charlie.snapshotAttachmentsLocked())
203217 charlie.mu.Unlock()
204218 }
205219
@@ -208,7 +222,7 @@ func TestVerticalOverflowRejectsStaleSourceTab(t *testing.T) {
208222 alpha.mu.Lock()
209223 alpha.name = "alpha"
210224 expectedSource := alpha.tabs[0]
211- alpha.active = 1
225+ selectTestAttachmentTabLocked(alpha, 1)
212226 alpha.mu.Unlock()
213227 charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
214228 d.mu.Lock()
@@ -218,10 +232,10 @@ func TestVerticalOverflowRejectsStaleSourceTab(t *testing.T) {
218232 require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, expectedSource, picker.Target{Session: charlie.id, TabIndex: -1}), errNoNeighbor)
219233 require.Same(t, alpha, ac.currentSession())
220234 alpha.mu.Lock()
221- require.Same(t, ac, alpha.client)
235+ require.Contains(t, alpha.snapshotAttachmentsLocked(), ac)
222236 alpha.mu.Unlock()
223237 charlie.mu.Lock()
224- require.Nil(t, charlie.client)
238+ require.Empty(t, charlie.snapshotAttachmentsLocked())
225239 charlie.mu.Unlock()
226240 }
227241
@@ -251,16 +265,16 @@ func TestVerticalOverflowTreatsDisplacedSourceClientAsNoNeighbor(t *testing.T) {
251265 require.True(t, ok)
252266 replacement := &attachedClient{}
253267 alpha.mu.Lock()
254- alpha.client = replacement
268+ alpha.registerAttachmentLocked(replacement)
255269 alpha.mu.Unlock()
256270
257271 require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor)
258272 require.Same(t, alpha, ac.currentSession())
259273 alpha.mu.Lock()
260- require.Same(t, replacement, alpha.client)
274+ require.Contains(t, alpha.snapshotAttachmentsLocked(), replacement)
261275 alpha.mu.Unlock()
262276 charlie.mu.Lock()
263- require.Nil(t, charlie.client)
277+ require.Empty(t, charlie.snapshotAttachmentsLocked())
264278 charlie.mu.Unlock()
265279 }
266280
@@ -314,7 +328,7 @@ func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) {
314328 continue
315329 }
316330 candidate.mu.Lock()
317- if candidate.client == ac {
331+ if attachmentRegisteredLocked(candidate, ac) {
318332 owners = append(owners, candidate)
319333 }
320334 candidate.mu.Unlock()
@@ -351,41 +365,7 @@ func TestKeyboardHorizontalOverflowRespectsDefaultsWallsFailedEntryAndFloating(t
351365
352366 daemonKeyHandler{d: d, ac: ac}.Action(tt.dir, nil)
353367
354- require.Equal(t, 0, activeTabIndex(sess))
355- })
356- }
357-}
358-
359-func TestCommitTabOverflowRevalidatesTabPointerIdentities(t *testing.T) {
360- tests := []struct {
361- name string
362- mutate func(sess *session, candidate tabOverflowCandidate)
363- }{
364- {name: "source no longer active", mutate: func(sess *session, _ tabOverflowCandidate) {
365- sess.mu.Lock()
366- sess.active = 1
367- sess.mu.Unlock()
368- }},
369- {name: "target entry replaced", mutate: func(sess *session, candidate tabOverflowCandidate) {
370- sess.mu.Lock()
371- sess.tabs[1] = newTab(nil, candidate.target.size)
372- sess.mu.Unlock()
373- }},
374- {name: "source gains visible floating pane", mutate: func(_ *session, candidate tabOverflowCandidate) {
375- installTestFloating(candidate.source, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
376- }},
377- }
378-
379- for _, tt := range tests {
380- t.Run(tt.name, func(t *testing.T) {
381- d, sess, _, _ := newManualSessionWithPTYs(t, nil, nil)
382- candidate, ok := d.prepareTabOverflow(sess, sess.tabs[0], layout.Right, domain.Rect{Width: 80, Height: 23}, 1)
383- require.True(t, ok)
384- tt.mutate(sess, candidate)
385- activeBeforeCommit := activeTabIndex(sess)
386-
387- require.False(t, d.commitTabOverflow(sess, candidate))
388- require.Equal(t, activeBeforeCommit, activeTabIndex(sess))
368+ require.Equal(t, 0, testAttachmentTabIndex(sess))
389369 })
390370 }
391371 }
M internal/usecase/daemon/notices_overlay_test.go +2 -2
@@ -434,7 +434,7 @@ func TestYankLastNotificationWithEmptyHistoryShowsWarnToast(t *testing.T) {
434434 func TestProxyYankLastNotificationEmptyHistoryUsesAttachmentScope(t *testing.T) {
435435 d, proxy, ac, _, handler := newProxyInputHarness(t)
436436 handler.enterPalette()
437- d.handlePaletteInput(ac, []byte("YLN\r"), handler.roleToken.effect)
437+ d.handlePaletteInput(ac, []byte("YLN\r"), handler.connectionToken.effect)
438438
439439 history := d.notices.history()
440440 require.Len(t, history, 1)
@@ -444,7 +444,7 @@ func TestProxyYankLastNotificationEmptyHistoryUsesAttachmentScope(t *testing.T)
444444
445445 func TestProxyNoticeYankSendFailureDetachesAndArmsWarmExpiry(t *testing.T) {
446446 d, proxy, ac, _, handler := newProxyInputHarness(t)
447- handler.roleToken.effect.End()
447+ handler.connectionToken.effect.End()
448448 client, ok := ac.transport().(*proxyTestTransport)
449449 require.True(t, ok)
450450 client.sendFails.Store(true)
M internal/usecase/daemon/notify.go +15 -22
@@ -267,9 +267,9 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No
267267 if sess != nil {
268268 d.notices.routingMu.Lock()
269269 sess.mu.Lock()
270- ac := sess.client
270+ attachments := sess.snapshotAttachmentsLocked()
271271 sess.mu.Unlock()
272- if ac == nil {
272+ if len(attachments) == 0 {
273273 d.notices.queueGlobal(n)
274274 d.notices.routingMu.Unlock()
275275 return
@@ -277,11 +277,14 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No
277277 if d.notices.beforeSessionDelivery != nil {
278278 d.notices.beforeSessionDelivery()
279279 }
280- // Publish the toast while detach is excluded, then release routing before
281- // repainting. Rendering can report another notice and re-enter routing.
282- published := d.publishToast(ac, n)
280+ published := make([]*attachedClient, 0, len(attachments))
281+ for _, ac := range attachments {
282+ if d.publishToast(ac, n) {
283+ published = append(published, ac)
284+ }
285+ }
283286 d.notices.routingMu.Unlock()
284- if published {
287+ for _, ac := range published {
285288 d.repaintForNotice(ac)
286289 }
287290 return
@@ -308,14 +311,12 @@ func (d *Daemon) deliverGlobal(n domain.Notification) {
308311 }
309312 targets := make([]*attachedClient, 0, len(sessions))
310313 for _, s := range sessions {
311- core := s.core()
312- core.mu.Lock()
313- ac := core.client
314- core.mu.Unlock()
315- if ac == nil || !d.publishToast(ac, n) {
316- continue
314+ for _, ac := range snapshotAttachmentSession(s) {
315+ if !d.publishToast(ac, n) {
316+ continue
317+ }
318+ targets = append(targets, ac)
317319 }
318- targets = append(targets, ac)
319320 }
320321 if len(targets) == 0 {
321322 if d.notices.beforeQueueGlobal != nil {
@@ -338,7 +339,7 @@ func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) {
338339 d.notices.routingMu.Lock()
339340
340341 sess.mu.Lock()
341- current := sess.client == ac
342+ current := attachmentRegisteredLocked(sess, ac)
342343 sess.mu.Unlock()
343344 if !current {
344345 d.notices.routingMu.Unlock()
@@ -367,14 +368,6 @@ func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode,
367368 d.notify(nil, sev, code, msg, cause)
368369 }
369370
370-// showToast publishes a notice into one client's toast stack and repaints only
371-// after the state mutation is complete.
372-func (d *Daemon) showToast(ac *attachedClient, n domain.Notification) {
373- if d.publishToast(ac, n) {
374- d.repaintForNotice(ac)
375- }
376-}
377-
378371 // publishToast durably mutates one client's toast state without repainting.
379372 // Routing paths call it while routingMu excludes detach, then release routingMu
380373 // before rendering so a render failure can safely route another notice.
M internal/usecase/daemon/notify_test.go +17 -148
@@ -55,102 +55,9 @@ func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing.
5555 }
5656 }
5757
58-func TestHandleClientNoticeSerializesReplacement(t *testing.T) {
59- d, sess, old, _ := newNoticeFixture(t, newNoticeClock())
60-
61- enteredMutation := make(chan struct{})
62- releaseMutation := make(chan struct{})
63- d.notices.beforeClientNoticeMutation = func() {
64- close(enteredMutation)
65- <-releaseMutation
66- }
67- t.Cleanup(func() {
68- select {
69- case <-releaseMutation:
70- default:
71- close(releaseMutation)
72- }
73- })
74-
75- noticeDone := make(chan struct{})
76- go func() {
77- d.handleClientNotice(sess, old, ports.ClientNotice{Action: ports.ClientNoticeClipboardFallback})
78- close(noticeDone)
79- }()
80- <-enteredMutation // old has validated ownership and holds routingMu
81- if d.notices.routingMu.TryLock() {
82- d.notices.routingMu.Unlock()
83- t.Fatal("client notice mutation must retain routingMu through publication")
84- }
85-
86- tr, _ := newCapturingTransport(t)
87- replaced := make(chan *attachedClient, 1)
88- go func() {
89- current, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
90- replaced <- current
91- }()
92-
93- // Releasing the mutation lets the already-validated notice publish before
94- // replacement can own the session. Snatch cleanup then clears the displaced
95- // attachment's presentation state without moving the notice to the new one.
96- close(releaseMutation)
97- <-noticeDone
98- current := <-replaced
99- d.attachmentCleanupWg.Wait()
100-
101- history := d.notices.history()
102- require.Len(t, history, 1)
103- require.Equal(t, domain.NoticeClipboard, history[0].Code)
104- oldToasts, _ := visibleToasts(old)
105- require.Empty(t, oldToasts)
106- newToasts, _ := visibleToasts(current)
107- require.Empty(t, newToasts)
108- sess.mu.Lock()
109- require.Same(t, current, sess.client)
110- sess.mu.Unlock()
111-}
112-
113-func TestHandleClientNoticeRejectsReplacedClient(t *testing.T) {
114- for _, tt := range []struct {
115- name string
116- setup func(*Daemon, *attachedClient, domain.SessionID)
117- notice ports.ClientNotice
118- }{
119- {
120- name: "record",
121- notice: ports.ClientNotice{Action: ports.ClientNoticeClipboardFallback},
122- },
123- {
124- name: "dismiss",
125- setup: func(d *Daemon, ac *attachedClient, sid domain.SessionID) {
126- d.showToast(ac, notice(domain.NoticeConnection, "connection degraded", sid))
127- },
128- notice: ports.ClientNotice{Action: ports.ClientNoticeLinkConnected},
129- },
130- } {
131- t.Run(tt.name, func(t *testing.T) {
132- d, sess, old, _ := newNoticeFixture(t, newNoticeClock())
133- if tt.setup != nil {
134- tt.setup(d, old, sess.id)
135- }
136- tr, _ := newCapturingTransport(t)
137- current, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
138- d.attachmentCleanupWg.Wait()
139-
140- d.handleClientNotice(sess, old, tt.notice)
141-
142- require.Empty(t, d.notices.history(), "a replaced client must not record notices")
143- oldToasts, _ := visibleToasts(old)
144- require.Empty(t, oldToasts, "snatch cleanup must remove displaced notice presentation")
145- currentToasts, _ := visibleToasts(current)
146- require.Empty(t, currentToasts)
147- })
148- }
149-}
150-
15158 func TestMalformedClientNoticeIsIgnored(t *testing.T) {
15259 d, sess, ac, _ := newNoticeFixture(t, &noticeClock{})
153- d.attachCoordinator(sess, nil, ac, true)
60+ d.attachCoordinator(sess, ac, true)
15461 tr, ok := ac.tr.(*portsmocks.MockTransport)
15562 require.True(t, ok, "attached client transport must be a MockTransport")
15663 frames := []ports.Frame{
@@ -379,7 +286,7 @@ func newNoticeFixture(t *testing.T, clk ports.Clock) (*Daemon, *session, *attach
379286 for _, pane := range tb.panes {
380287 pane.ctx, pane.cancel = wctx, wcancel
381288 }
382- sess := &session{sessionCore: sessionCore{id: "manual", name: "work", client: ac}, ctx: sctx, cancel: cancel, tabs: []*tab{tb}}
289+ sess := &session{sessionCore: sessionCore{id: "manual", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx, cancel: cancel, tabs: []*tab{tb}}
383290 ac.setSession(sess)
384291 d.sessions[sess.id] = sess
385292 t.Cleanup(cancel)
@@ -521,7 +428,7 @@ func TestNotifyRoutesToSessionClientOnly(t *testing.T) {
521428
522429 other := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
523430 other.initOverlays()
524- sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", client: other}, ctx: sess.ctx, cancel: func() {}}
431+ sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", attachments: map[*attachedClient]struct{}{other: {}}}, ctx: sess.ctx, cancel: func() {}}
525432 other.setSession(sess2)
526433 d.mu.Lock()
527434 d.sessions[sess2.id] = sess2
@@ -606,7 +513,7 @@ func TestNotifySessionScopedReleasesRoutingBeforeRepaint(t *testing.T) {
606513 func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) {
607514 d, sessA, oldA, _ := newNoticeFixture(t, newNoticeClock())
608515 sessA.mu.Lock()
609- sessA.client = nil
516+ clearAttachmentsForTestLocked(sessA)
610517 sessA.mu.Unlock()
611518
612519 clientB := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
@@ -628,7 +535,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) {
628535 require.Empty(t, oldToasts, "a detached session must not paint a toast")
629536
630537 sessB.mu.Lock()
631- sessB.client = clientB
538+ sessB.registerAttachmentLocked(clientB)
632539 sessB.mu.Unlock()
633540 d.drainPendingForFirstPaint(sessB, clientB)
634541 bToasts, _ := visibleToasts(clientB)
@@ -638,7 +545,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) {
638545 require.Equal(t, "global announcement", bToasts[0].Message)
639546
640547 sessA.mu.Lock()
641- sessA.client = oldA
548+ sessA.registerAttachmentLocked(oldA)
642549 sessA.mu.Unlock()
643550 d.drainPendingForFirstPaint(sessA, oldA)
644551 aToasts, _ := visibleToasts(oldA)
@@ -652,7 +559,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) {
652559 secondA.initOverlays()
653560 secondA.setSession(sessA)
654561 sessA.mu.Lock()
655- sessA.client = secondA
562+ sessA.registerAttachmentLocked(secondA)
656563 sessA.mu.Unlock()
657564 d.drainPendingForFirstPaint(sessA, secondA)
658565 secondToasts, _ := visibleToasts(secondA)
@@ -664,7 +571,7 @@ func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) {
664571
665572 second := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
666573 second.initOverlays()
667- sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", client: second}, ctx: sess.ctx, cancel: func() {}}
574+ sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", attachments: map[*attachedClient]struct{}{second: {}}}, ctx: sess.ctx, cancel: func() {}}
668575 second.setSession(sess2)
669576 d.mu.Lock()
670577 d.sessions[sess2.id] = sess2
@@ -682,7 +589,7 @@ func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) {
682589 func TestNotifyGlobalDoesNotStrandNoticeWhenFirstPaintRacesQueue(t *testing.T) {
683590 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
684591 sess.mu.Lock()
685- sess.client = nil
592+ clearAttachmentsForTestLocked(sess)
686593 sess.mu.Unlock()
687594
688595 routeObserved := make(chan struct{})
@@ -701,7 +608,7 @@ func TestNotifyGlobalDoesNotStrandNoticeWhenFirstPaintRacesQueue(t *testing.T) {
701608 // Publish the attachment and start firstPaint while global routing still
702609 // owns its gate. firstPaint must wait for the queue, then drain it.
703610 sess.mu.Lock()
704- sess.client = ac
611+ sess.registerAttachmentLocked(ac)
705612 sess.mu.Unlock()
706613 paintDone := make(chan struct{})
707614 go func() {
@@ -744,52 +651,14 @@ func TestNotifyGlobalSerializesDetachWithDelivery(t *testing.T) {
744651 toasts, _ := visibleToasts(ac)
745652 require.Len(t, toasts, 1)
746653 sess.mu.Lock()
747- require.Nil(t, sess.client)
748- sess.mu.Unlock()
749-}
750-
751-func TestNotifyGlobalSerializesReplacementWithDelivery(t *testing.T) {
752- d, sess, old, _ := newNoticeFixture(t, newNoticeClock())
753-
754- routeObserved := make(chan struct{})
755- releaseRoute := make(chan struct{})
756- d.notices.beforeGlobalDelivery = func() {
757- close(routeObserved)
758- <-releaseRoute
759- }
760- globalDone := make(chan struct{})
761- go func() {
762- d.NotifyGlobal(domain.NoticeWarn, domain.NoticeConfigReload, "config reload failed", nil)
763- close(globalDone)
764- }()
765- <-routeObserved // old is selected and routingMu is held
766-
767- tr, _ := newCapturingTransport(t)
768- replaced := make(chan *attachedClient, 1)
769- go func() {
770- ac, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
771- replaced <- ac
772- }()
773- close(releaseRoute)
774- <-globalDone
775- current := <-replaced
776- d.attachmentCleanupWg.Wait()
777-
778- // Replacement publishes only after delivery, then snatch cleanup removes
779- // the displaced attachment's presentation state.
780- oldToasts, _ := visibleToasts(old)
781- require.Empty(t, oldToasts)
782- newToasts, _ := visibleToasts(current)
783- require.Empty(t, newToasts)
784- sess.mu.Lock()
785- require.Same(t, current, sess.client)
654+ require.Empty(t, sess.snapshotAttachmentsLocked())
786655 sess.mu.Unlock()
787656 }
788657
789658 func TestDetachedNoticeCoalescingPreservesDistinctContentAndSeverity(t *testing.T) {
790659 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
791660 sess.mu.Lock()
792- sess.client = nil
661+ clearAttachmentsForTestLocked(sess)
793662 sess.mu.Unlock()
794663
795664 d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "build started", nil)
@@ -797,7 +666,7 @@ func TestDetachedNoticeCoalescingPreservesDistinctContentAndSeverity(t *testing.
797666 d.notify(sess, domain.NoticeWarn, domain.NoticeUser, "build delayed", nil)
798667
799668 sess.mu.Lock()
800- sess.client = ac
669+ sess.registerAttachmentLocked(ac)
801670 sess.mu.Unlock()
802671 d.drainPendingForFirstPaint(sess, ac)
803672
@@ -813,7 +682,7 @@ func TestDetachedNoticeCoalescingPreservesDistinctContentAndSeverity(t *testing.
813682 func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) {
814683 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
815684 sess.mu.Lock()
816- sess.client = nil
685+ clearAttachmentsForTestLocked(sess)
817686 sess.mu.Unlock()
818687
819688 d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotWrite, "snapshot write failed", nil)
@@ -824,7 +693,7 @@ func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) {
824693
825694 // Re-attach and let firstPaint drain the pending queue.
826695 sess.mu.Lock()
827- sess.client = ac
696+ sess.registerAttachmentLocked(ac)
828697 sess.mu.Unlock()
829698 d.firstPaint(sess, ac, domain.Size{})
830699
@@ -841,7 +710,7 @@ func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) {
841710 func TestNotifyGlobalPersistDisabledDrainsAtFirstAttach(t *testing.T) {
842711 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
843712 sess.mu.Lock()
844- sess.client = nil
713+ clearAttachmentsForTestLocked(sess)
845714 sess.mu.Unlock()
846715
847716 cause := errors.New("open store.db: permission denied")
@@ -854,7 +723,7 @@ func TestNotifyGlobalPersistDisabledDrainsAtFirstAttach(t *testing.T) {
854723
855724 // Re-attach and let firstPaint drain the pending queue.
856725 sess.mu.Lock()
857- sess.client = ac
726+ sess.registerAttachmentLocked(ac)
858727 sess.mu.Unlock()
859728 d.firstPaint(sess, ac, domain.Size{})
860729
M internal/usecase/daemon/output_state_stream.go +38 -7
@@ -1,6 +1,8 @@
11 package daemon
22
33 import (
4+ "sync/atomic"
5+
46 "github.com/bnema/vev/internal/ports"
57 "github.com/bnema/vev/pkg/renderer"
68 )
@@ -15,11 +17,13 @@ import (
1517 // Callers serialize access with attachedClient.sendMu. A prepared transaction
1618 // must be sent or committed as a no-op before the next transaction is prepared.
1719 type outputStateStream struct {
18- renderer *renderer.Renderer
19- next uint64
20- acked uint64
21- maxOutstanding uint64
22- forceSnapshot bool
20+ renderer *renderer.Renderer
21+ next uint64
22+ acked uint64
23+ maxOutstanding uint64
24+ outstandingAtomic atomic.Uint64
25+ maxOutstandingAtomic atomic.Uint64
26+ forceSnapshot bool
2327 }
2428
2529 func newOutputStateStream(windowSize ...uint8) *outputStateStream {
@@ -27,10 +31,13 @@ func newOutputStateStream(windowSize ...uint8) *outputStateStream {
2731 if len(windowSize) > 0 {
2832 window = normalizeOutputWindow(windowSize[0])
2933 }
30- return &outputStateStream{
34+ stream := &outputStateStream{
3135 renderer: renderer.New(renderer.Capabilities{}),
3236 maxOutstanding: uint64(window),
3337 }
38+ stream.maxOutstandingAtomic.Store(uint64(window))
39+ stream.publishOutstanding()
40+ return stream
3441 }
3542
3643 type preparedOutput struct {
@@ -73,6 +80,7 @@ func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame
7380 }
7481 p.commit()
7582 p.stream.next = p.next
83+ p.stream.publishOutstanding()
7684 return nil
7785 }
7886
@@ -98,16 +106,39 @@ func (s *outputStateStream) ack(state uint64) {
98106 return
99107 }
100108 s.acked = state
109+ s.publishOutstanding()
101110 }
102111
103112 func (s *outputStateStream) rebase() {
104113 s.acked = s.next
114+ s.publishOutstanding()
115+ s.maxOutstandingAtomic.Store(s.maxOutstanding)
105116 s.renderer.Reset()
106117 s.forceSnapshot = true
107118 }
108119
120+func (s *outputStateStream) publishOutstanding() {
121+ if s == nil {
122+ return
123+ }
124+ s.outstandingAtomic.Store(s.next - s.acked)
125+}
126+
127+// syncCapacityLocked refreshes the lock-free readiness snapshot after a
128+// caller mutates the legacy fields under attachedClient.sendMu.
129+func (s *outputStateStream) syncCapacityLocked() {
130+ if s == nil {
131+ return
132+ }
133+ s.maxOutstandingAtomic.Store(s.maxOutstanding)
134+ s.publishOutstanding()
135+}
136+
109137 func (s *outputStateStream) atCapacity() bool {
110- return s.outstanding() >= s.maxOutstanding
138+ if s == nil {
139+ return false
140+ }
141+ return s.outstandingAtomic.Load() >= s.maxOutstandingAtomic.Load()
111142 }
112143
113144 func (s *outputStateStream) outstanding() uint64 { return s.next - s.acked }
M internal/usecase/daemon/output_state_stream_test.go +39 -0
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "errors"
5+ "runtime"
56 "testing"
67
78 "github.com/bnema/vev/internal/ports"
@@ -35,6 +36,44 @@ func TestOutputStateStreamBuildsPipelinedDependencyChain(t *testing.T) {
3536 require.Equal(t, uint64(2), stream.outstanding())
3637 }
3738
39+func TestOutputStateStreamCapacityProbeDoesNotRaceWithSend(t *testing.T) {
40+ stream := newOutputStateStream(1)
41+ frame := renderer.NewFrame(3, 1)
42+ fillOutputStateRows(frame, []string{"abc"})
43+ prepared, err := stream.prepare(frame, nil, true)
44+ require.NoError(t, err)
45+
46+ entered := make(chan struct{})
47+ release := make(chan struct{})
48+ sendDone := make(chan error, 1)
49+ go func() {
50+ sendDone <- prepared.send(prepared.data, 0, func(ports.Frame) error {
51+ close(entered)
52+ <-release
53+ return nil
54+ })
55+ }()
56+ <-entered
57+
58+ probed := make(chan struct{})
59+ go func() {
60+ for range 100_000 {
61+ _ = stream.atCapacity()
62+ runtime.Gosched()
63+ }
64+ close(probed)
65+ }()
66+ close(release)
67+ require.NoError(t, <-sendDone)
68+ <-probed
69+ require.True(t, stream.atCapacity())
70+
71+ stream.ack(1)
72+ require.False(t, stream.atCapacity())
73+ stream.rebase()
74+ require.False(t, stream.atCapacity())
75+}
76+
3877 func TestOutputStateStreamFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T) {
3978 stream := newOutputStateStream()
4079 initial := renderer.NewFrame(3, 1)
M internal/usecase/daemon/output_state_sync_test.go +3 -56
@@ -285,7 +285,7 @@ func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) {
285285 p := newResizeReaderPTY([]byte("\x1b[1;81H" + strings.Repeat("B", 40)))
286286 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
287287 ac.output.maxOutstanding = tc.window
288- pane := sess.activeTab().focusedPane()
288+ pane := testAttachmentTab(sess).focusedPane()
289289 p.applying = func() bool {
290290 pane.mu.Lock()
291291 defer pane.mu.Unlock()
@@ -299,7 +299,7 @@ func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) {
299299
300300 d.sessWg.Add(1)
301301 pane.onExit = func() {}
302- go d.ptyReader(sess, sess.activeTab(), pane)
302+ go d.ptyReader(sess, testAttachmentTab(sess), pane)
303303 defer func() { p.close(); d.sessWg.Wait() }()
304304
305305 // The resize deadline drives coordinator prepare/apply/commit. No test
@@ -387,59 +387,6 @@ func TestResizeBurstFlushesOnlyLatestGeometry(t *testing.T) {
387387 awaitTestCompletion(t, latestDone, "latest resize callback did not complete")
388388 awaitFrame(t, sends, ports.MsgOutput)
389389 require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size)
390- require.Equal(t, 120, sess.activeTab().focusedPane().screen.Frame.Width)
390+ require.Equal(t, 120, testAttachmentTab(sess).focusedPane().screen.Frame.Width)
391391 requireNoOutputFrame(t, sends)
392392 }
393-
394-func TestDatagramWindowOneCoalescesPaintsUntilMsgAck(t *testing.T) {
395- p, releasePTY := newBlockingPTY(t)
396- defer releasePTY()
397- d, sess, _, _ := newManualSessionWithPTYs(t, p)
398- tr := newDatagramTestTransport()
399- _, ac, err := d.route(ports.Hello{
400- Version: ports.ProtocolVersion,
401- Intent: ports.IntentAttach,
402- Name: sess.name,
403- Size: domain.Size{Cols: 80, Rows: 24},
404- MaxOutputInFlight: 1,
405- }, tr)
406- require.NoError(t, err)
407- require.True(t, sess.renderCoordinator().markAttachmentReady(sess.renderCoordinator().attachmentLease(ac)))
408-
409- pane := sess.tabs[0].focusedPane()
410- pane.screen.Write([]byte("A"))
411- d.paint(sess, ac, false, nil)
412- first := awaitFrame(t, tr.sends, ports.MsgOutput)
413- firstOutput, err := ports.UnmarshalOutput(first.Payload)
414- require.NoError(t, err)
415- require.Equal(t, uint64(1), firstOutput.NewStateNum)
416- for range 100 {
417- pane.screen.Write([]byte("x"))
418- d.invalidateRender(sess, ac, false, "output_state_sync_test.go")
419- }
420- pane.screen.Write([]byte("LATEST"))
421- d.invalidateRender(sess, ac, false, "output_state_sync_test.go")
422- requireNoOutputFrame(t, tr.sends)
423- require.Equal(t, uint64(1), ac.output.outstanding(), "only one state-bearing datagram output may be unacknowledged")
424-
425- // Side-effect output is control-like: it must pass while the state window is
426- // full without consuming another state number.
427- require.NoError(t, d.boundedSendOutputErr(ac, []byte("side-effect")))
428- sideEffect := awaitFrame(t, tr.sends, ports.MsgOutput)
429- sideEffectOutput, err := ports.UnmarshalOutput(sideEffect.Payload)
430- require.NoError(t, err)
431- require.Zero(t, sideEffectOutput.BaseStateNum)
432- require.Zero(t, sideEffectOutput.NewStateNum)
433- require.Equal(t, uint64(1), ac.output.outstanding())
434-
435- tr.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: firstOutput.NewStateNum})}
436- require.NoError(t, tr.Close())
437- d.runConnLoop(ac)
438- second := awaitFrame(t, tr.sends, ports.MsgOutput)
439- secondOutput, err := ports.UnmarshalOutput(second.Payload)
440- require.NoError(t, err)
441- require.Equal(t, firstOutput.NewStateNum, secondOutput.BaseStateNum)
442- require.Equal(t, uint64(2), secondOutput.NewStateNum)
443- require.Contains(t, string(secondOutput.Data), "LATEST", "cumulative ACK must release the latest coalesced state")
444- require.Equal(t, uint64(1), ac.output.outstanding())
445-}
M internal/usecase/daemon/overlay_runtime.go +3 -109
@@ -48,7 +48,7 @@ type overlayRuntime struct {
4848 promptMu sync.Mutex
4949 prompt *promptui.Model
5050 promptSubmit func(string) error
51- promptTransitionSubmit func(string, attachmentRoleToken) error
51+ promptTransitionSubmit func(string, attachmentConnectionToken) error
5252 promptPending []byte
5353
5454 resizeMu sync.Mutex
@@ -242,118 +242,12 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
242242 return active || prePublication
243243 }
244244
245-// clearForSnatch clears each overlay family independently. Every family
246-// revalidates the displaced role generation while holding only its own mutex,
247-// so cleanup can neither participate in cross-family lock inversion nor erase
248-// state published by a later promotion.
249-func (d *Daemon) clearForSnatch(token attachmentRoleToken) bool {
250- if token.ac == nil || token.ac.overlays == nil {
251- return false
252- }
253- rt := token.ac.overlays
254- current := func() bool {
255- return token.ac.roleGeneration.Load() == token.generation && token.ac.transportSnapshotCurrent(token.transport)
256- }
257- afterFamily := func(family string) {
258- if d.afterSnatchOverlayFamily != nil {
259- d.afterSnatchOverlayFamily(family)
260- }
261- }
262-
263- rt.pickerMu.Lock()
264- if !current() {
265- rt.pickerMu.Unlock()
266- return false
267- }
268- previewSession := rt.pickerPreviewSession
269- previewGeneration := rt.pickerPreviewGeneration
270- pickerInstance := remotePickerInstance{ac: token.ac, generation: rt.pickerGeneration, model: rt.picker}
271- rt.picker = nil
272- rt.pickerIntent = pickerNavigate
273- rt.pickerSource = moveSourceLocator{}
274- rt.pickerPending = nil
275- rt.pickerESC.stop()
276- rt.pickerPreviewGeneration++
277- rt.pickerPreview = nil
278- rt.pickerPreviewSession = nil
279- rt.pickerMu.Unlock()
280- afterFamily("picker")
281- // Subscription teardown may enter a coordinator and therefore runs outside
282- // every overlay-family lock.
283- d.teardownPreviewSubscription(token.ac, previewSession, previewGeneration)
284- if pickerInstance.model != nil {
285- d.remotePickerClosed(pickerInstance)
286- }
287-
288- rt.paletteMu.Lock()
289- if !current() {
290- rt.paletteMu.Unlock()
291- return false
292- }
293- token.ac.clearPaletteLocked()
294- rt.paletteMu.Unlock()
295- afterFamily("palette")
296-
297- rt.promptMu.Lock()
298- if !current() {
299- rt.promptMu.Unlock()
300- return false
301- }
302- rt.prompt = nil
303- rt.promptSubmit = nil
304- rt.promptTransitionSubmit = nil
305- rt.promptPending = nil
306- rt.promptMu.Unlock()
307- afterFamily("prompt")
308-
309- rt.resizeMu.Lock()
310- if !current() {
311- rt.resizeMu.Unlock()
312- return false
313- }
314- rt.resizeActive = false
315- rt.resizePending = nil
316- rt.resizeESC.stop()
317- rt.resizeMu.Unlock()
318- afterFamily("resize")
319-
320- rt.copyMu.Lock()
321- if !current() {
322- rt.copyMu.Unlock()
323- return false
324- }
325- d.stopCopyPendingTimerLocked(token.ac)
326- rt.copyPending = nil
327- rt.clearCopyModeLocked()
328- rt.statusFeedback = ""
329- rt.copyMu.Unlock()
330- afterFamily("copy")
331-
332- rt.noticeMu.Lock()
333- if !current() {
334- rt.noticeMu.Unlock()
335- return false
336- }
337- for i := range rt.noticeToasts {
338- rt.noticeToasts[i].timer.stop()
339- }
340- rt.noticeToasts = nil
341- rt.noticeOverflow = 0
342- rt.noticeSeq++
343- rt.noticesOverlay = nil
344- rt.noticesPending = nil
345- rt.noticesESC.stop()
346- rt.noticeMu.Unlock()
347- afterFamily("notice")
348- return true
349-}
350-
351-func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*roleEffectTicket) bool {
245+func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*attachmentEffectTicket) bool {
352246 if rt == nil || rt.ac == nil {
353247 return false
354248 }
355249 ac := rt.ac
356- var effect *roleEffectTicket
250+ var effect *attachmentEffectTicket
357251 if len(effects) != 0 {
358252 effect = effects[0]
359253 }
M internal/usecase/daemon/palette.go +38 -27
@@ -174,7 +174,7 @@ func (d *Daemon) recordPaletteUse(code string) {
174174 d.paletteRecent = recent
175175 }
176176
177-func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {
177+func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
178178 entry := ac.currentAttachmentSession()
179179 if entry == nil {
180180 return
@@ -189,7 +189,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
189189 var generation uint64
190190 var rawQuery string
191191 changed, cancel, execute := false, false, false
192- var effect *roleEffectTicket
192+ var effect *attachmentEffectTicket
193193 if len(effects) != 0 {
194194 effect = effects[0]
195195 }
@@ -305,7 +305,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
305305 }
306306 var err error
307307 if effect != nil {
308- err = d.switchToTargetForRole(effect.roleToken(), target, sessionHandoffGuard{}, "palette-session")
308+ err = d.switchToTargetForAttachment(effect.connectionToken(), target, sessionHandoffGuard{}, "palette-session")
309309 } else if local {
310310 err = d.switchToTarget(sess, ac, target)
311311 } else {
@@ -389,10 +389,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
389389 }
390390 case "back-session":
391391 if effect != nil {
392- err = d.backSessionForRole(effect.roleToken())
392+ err = d.backSessionForAttachment(effect.connectionToken())
393393 }
394394 case "detach":
395- if effect != nil && !d.clientGoneForRole(effect.roleToken(), true) {
395+ if effect != nil && !d.clientGoneForAttachment(effect.connectionToken(), true) {
396396 err = errAttachmentTransition
397397 }
398398 case "session-picker":
@@ -413,17 +413,17 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
413413 }
414414 return
415415 }
416- roleHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach"
417- if !roleHandoff && !ac.closeExecutedPalette(generation, rawQuery) {
416+ attachmentHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach"
417+ if !attachmentHandoff && !ac.closeExecutedPalette(generation, rawQuery) {
418418 return
419419 }
420420 sess.dispatchMu.Lock()
421421 err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, effect: effect, redrawClosedPalette: true}, args)
422422 sess.dispatchMu.Unlock()
423- if roleHandoff {
423+ if attachmentHandoff {
424424 if current := ac.currentSession(); current != nil {
425425 currentToken := current.attachmentToken(ac, ac.transport())
426- fresh, admitted := ac.beginRoleEffect(currentToken)
426+ fresh, admitted := ac.beginAttachmentEffect(currentToken)
427427 if ac.closeExecutedPalette(generation, rawQuery) {
428428 d.invalidateRender(current, ac, true, "palette.go")
429429 }
@@ -495,13 +495,14 @@ type paletteExec struct {
495495 ac *attachedClient
496496 recent []recentSession
497497 actions daemonActionRunner
498- effect *roleEffectTicket
498+ effect *attachmentEffectTicket
499499 redrawClosedPalette bool
500500 }
501501
502502 func (e paletteExec) runAction(request daemonActionRequest) error {
503+ request.effect = e.effect
503504 if request.target.session == nil {
504- request.target = resolveDaemonActionTarget(e.sess)
505+ request.target = resolveDaemonActionTargetForAttachment(e.sess, e.ac)
505506 }
506507 runner := e.actions
507508 if runner == nil {
@@ -532,7 +533,7 @@ func (e paletteExec) CreateSession() error {
532533 if entry == nil {
533534 entry = e.sess
534535 }
535- e.d.enterTransitionPrompt(entry, e.ac, " Create session ", "", func(name string, token attachmentRoleToken) error {
536+ e.d.enterTransitionPrompt(entry, e.ac, " Create session ", "", func(name string, token attachmentConnectionToken) error {
536537 if token.ac == nil {
537538 sess, ok := e.localSession()
538539 if !ok {
@@ -540,7 +541,7 @@ func (e paletteExec) CreateSession() error {
540541 }
541542 return e.d.createSessionAndSwitch(sess, e.ac, name)
542543 }
543- return e.d.createSessionAndSwitchForRole(token, name)
544+ return e.d.createSessionAndSwitchForAttachment(token, name)
544545 })
545546 return nil
546547 }
@@ -580,27 +581,27 @@ func (e paletteExec) ClosePane() error {
580581 }
581582
582583 func (e paletteExec) OpenMovePanePicker() error {
583- target := resolveDaemonActionTarget(e.sess)
584+ target := resolveDaemonActionTargetForAttachment(e.sess, e.ac)
584585 if target.tab == nil || target.pane == nil {
585586 return errMovePaneInvalid
586587 }
587588 return e.d.enterPickerForIntent(e.sess, e.ac, pickerMovePane, moveSourceLocator{
588- Session: sessionMoveLocator(e.sess),
589- TabID: domain.TabStableID(target.tab.stableID),
590- PaneID: domain.PaneStableID(target.pane.stableID),
591- Client: e.ac,
589+ Session: sessionMoveLocator(e.sess),
590+ TabID: domain.TabStableID(target.tab.stableID),
591+ PaneID: domain.PaneStableID(target.pane.stableID),
592+ Attachment: e.ac,
592593 })
593594 }
594595
595596 func (e paletteExec) OpenMoveTabPicker() error {
596- target := resolveDaemonActionTarget(e.sess)
597+ target := resolveDaemonActionTargetForAttachment(e.sess, e.ac)
597598 if target.tab == nil {
598599 return errMovePaneInvalid
599600 }
600601 return e.d.enterPickerForIntent(e.sess, e.ac, pickerMoveTab, moveSourceLocator{
601- Session: sessionMoveLocator(e.sess),
602- TabID: domain.TabStableID(target.tab.stableID),
603- Client: e.ac,
602+ Session: sessionMoveLocator(e.sess),
603+ TabID: domain.TabStableID(target.tab.stableID),
604+ Attachment: e.ac,
604605 })
605606 }
606607
@@ -639,7 +640,7 @@ func (e paletteExec) ToggleFloatingPane() error {
639640
640641 func (e paletteExec) BackSession() error {
641642 if e.effect != nil {
642- return e.d.backSessionForRole(e.effect.roleToken())
643+ return e.d.backSessionForAttachment(e.effect.connectionToken())
643644 }
644645 e.d.backSession(e.sess, e.ac)
645646 return nil
@@ -647,7 +648,7 @@ func (e paletteExec) BackSession() error {
647648
648649 func (e paletteExec) Detach() error {
649650 if e.effect != nil {
650- if !e.d.clientGoneForRole(e.effect.roleToken(), true) {
651+ if !e.d.clientGoneForAttachment(e.effect.connectionToken(), true) {
651652 return errAttachmentTransition
652653 }
653654 return nil
@@ -672,12 +673,22 @@ func (e paletteExec) RenameSession() error {
672673 }
673674
674675 func (e paletteExec) RenameTab() error {
675- tb := e.sess.activeTab()
676+ tb := e.sess.tabForAttachment(e.ac)
677+ if e.ac == nil {
678+ tb = e.sess.firstTab()
679+ }
676680 if tb == nil {
677681 return nil
678682 }
679683 e.sess.mu.Lock()
680- currentName := tabDisplayName(tb, e.sess.active)
684+ index := 0
685+ for i, candidate := range e.sess.tabs {
686+ if candidate == tb {
687+ index = i
688+ break
689+ }
690+ }
691+ currentName := tabDisplayName(tb, index)
681692 e.sess.mu.Unlock()
682693 e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error {
683694 return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, target: daemonActionTarget{session: e.sess, tab: tb}, name: name})
@@ -733,7 +744,7 @@ func (e paletteExec) JumpRecentSession(rank int) error {
733744 }
734745 var err error
735746 if e.effect != nil {
736- err = e.d.switchToTargetForRole(e.effect.roleToken(), picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "palette-recent-session")
747+ err = e.d.switchToTargetForAttachment(e.effect.connectionToken(), picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "palette-recent-session")
737748 } else {
738749 sess, ok := e.localSession()
739750 if !ok {
M internal/usecase/daemon/palette_test.go +24 -24
@@ -94,7 +94,7 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
9494 p2, release2 := newBlockingPTY(t)
9595 d, sess, ac, sends := newManualSessionWithPTYs(t, p1, p2)
9696 sess.mu.Lock()
97- sess.client = ac
97+ sess.registerAttachmentLocked(ac)
9898 sess.mu.Unlock()
9999 d.ptys = newBlockingOpenFactory(t, d)
100100 defer release1()
@@ -109,8 +109,8 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
109109
110110 d.handleInput(sess, ac, []byte("NXT\r"))
111111 require.False(t, ac.overlays.paletteActive())
112- require.Equal(t, 1, activeTabIndex(sess))
113- requireFloatingInitialized(t, sess.activeTab())
112+ require.Equal(t, 1, testAttachmentTabIndex(sess))
113+ requireFloatingInitialized(t, testAttachmentTab(sess))
114114 awaitFrame(t, sends, ports.MsgOutput)
115115
116116 d.handleInput(sess, ac, []byte("\x1b "))
@@ -138,7 +138,7 @@ func TestRemotePaletteClosesBeforeOpeningLocalOverlay(t *testing.T) {
138138 t.Run(test.name, func(t *testing.T) {
139139 d, _, ac, link, handler := newProxyInputHarness(t)
140140 handler.enterPalette()
141- d.handlePaletteInput(ac, []byte(test.code+"\r"), handler.roleToken.effect)
141+ d.handlePaletteInput(ac, []byte(test.code+"\r"), handler.connectionToken.effect)
142142
143143 require.False(t, ac.overlays.paletteActive(), "stale remote palette remained active")
144144 require.True(t, test.active(ac.overlays), "replacement local overlay was not opened")
@@ -317,7 +317,7 @@ func TestPaletteSelectedActiveSessionSwitchesWithoutRecordingCommandRecency(t *t
317317 d.handleInput(current, ac, []byte("recent\r"))
318318
319319 require.Same(t, target, ac.currentSession())
320- require.Same(t, ac, target.client, "canonical handoff reuses the attached client")
320+ require.Contains(t, target.snapshotAttachments(), ac, "canonical handoff reuses the attached client")
321321 require.False(t, ac.overlays.paletteActive())
322322 require.Empty(t, d.paletteRecent, "session selections are not command recency")
323323 awaitFrame(t, sends, ports.MsgOutput)
@@ -340,7 +340,7 @@ func TestPaletteStoppedSessionResumeFailureKeepsPaletteAndSourceAttachment(t *te
340340
341341 require.Same(t, current, ac.currentSession(), "failed resume must retain the source attachment")
342342 current.mu.Lock()
343- require.Same(t, ac, current.client)
343+ require.Contains(t, current.snapshotAttachmentsLocked(), ac)
344344 current.mu.Unlock()
345345 require.True(t, ac.overlays.paletteActive())
346346 ac.overlays.paletteMu.Lock()
@@ -361,14 +361,14 @@ func TestPaletteSelectedStoppedSessionResumesAndSwitches(t *testing.T) {
361361
362362 d.handleInput(current, ac, []byte("\x1b "))
363363 awaitFrame(t, sends, ports.MsgOutput)
364- generation := ac.roleGeneration.Load()
364+ generation := ac.connectionGeneration.Load()
365365 d.handleInput(current, ac, []byte("stopped\r"))
366366
367367 resumed := ac.currentSession()
368368 require.Equal(t, "stopped", resumed.name)
369369 require.Equal(t, int64(42), resumed.createdAt)
370- require.Equal(t, attachmentActive, resumed.attachmentRole(ac))
371- require.Greater(t, ac.roleGeneration.Load(), generation, "stopped-session handoff must publish through the attachment transition")
370+ require.Equal(t, true, resumed.attachmentRegistered(ac))
371+ require.Greater(t, ac.connectionGeneration.Load(), generation, "stopped-session handoff must publish through the attachment transition")
372372 require.False(t, ac.overlays.paletteActive())
373373 firstPaint := awaitFrame(t, sends, ports.MsgOutput)
374374 firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload)
@@ -440,7 +440,7 @@ func TestPaletteFuzzySelectedStaticCommandExecutes(t *testing.T) {
440440 d.handleInput(sess, ac, []byte("next\r"))
441441
442442 require.False(t, ac.overlays.paletteActive())
443- require.Equal(t, 1, activeTabIndex(sess))
443+ require.Equal(t, 1, testAttachmentTabIndex(sess))
444444 awaitFrame(t, sends, ports.MsgOutput)
445445 }
446446
@@ -486,7 +486,7 @@ func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) {
486486 invalidations := installPaletteInvalidationObserver(current)
487487 d.enterPalette(current, ac)
488488 d.handlePaletteInput(ac, []byte("BSK"))
489- d.afterActionRoleEffectEnded = func(action string) {
489+ d.afterActionAttachmentEffectEnded = func(action string) {
490490 if action == "back-session" {
491491 d.mu.Lock()
492492 delete(d.sessions, target.core().id)
@@ -495,7 +495,7 @@ func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) {
495495 }
496496
497497 token := current.attachmentToken(ac, ac.transport())
498- effect, admitted := ac.beginRoleEffect(token)
498+ effect, admitted := ac.beginAttachmentEffect(token)
499499 require.True(t, admitted)
500500 d.handlePaletteInput(ac, []byte("\r"), effect)
501501
@@ -505,7 +505,7 @@ func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) {
505505 require.True(t, invalidation.reset)
506506 }
507507
508-func TestPaletteDeniedPostHandoffRoleEffectClosesAndInvalidates(t *testing.T) {
508+func TestPaletteDeniedPostHandoffAttachmentEffectClosesAndInvalidates(t *testing.T) {
509509 p, release := newBlockingPTY(t)
510510 defer release()
511511 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
@@ -514,10 +514,10 @@ func TestPaletteDeniedPostHandoffRoleEffectClosesAndInvalidates(t *testing.T) {
514514 d.enterPalette(sess, ac)
515515 d.handlePaletteInput(ac, []byte("BSK"))
516516 // Make the attachment token detached while retaining currentSession. The
517- // no-op BSK command succeeds, but its post-execution beginRoleEffect is
517+ // no-op BSK command succeeds, but its post-execution beginAttachmentEffect is
518518 // deterministically denied.
519519 sess.mu.Lock()
520- sess.client = nil
520+ clearAttachmentsForTestLocked(sess)
521521 sess.mu.Unlock()
522522
523523 d.handlePaletteInput(ac, []byte("\r"))
@@ -639,7 +639,7 @@ func TestPaletteFLTExecutesFloatingToggle(t *testing.T) {
639639 p, release := newBlockingPTY(t)
640640 defer release()
641641 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
642- tb := sess.activeTab()
642+ tb := testAttachmentTab(sess)
643643 installTestFloating(tb, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), false)
644644
645645 d.handleInput(sess, ac, []byte("\x1b "))
@@ -671,7 +671,7 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
671671 require.True(t, ac.overlays.promptActive())
672672 require.Contains(t, string(promptOutput.Data), "Create session")
673673
674- generation := ac.roleGeneration.Load()
674+ generation := ac.connectionGeneration.Load()
675675 d.handleInput(sess, ac, []byte("scratch\r"))
676676 // The submit first paints the newly attached session while the prompt is
677677 // still open, then handlePromptInput closes the prompt and repaints the
@@ -685,15 +685,15 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
685685 require.NoError(t, err)
686686 require.False(t, ac.overlays.promptActive())
687687 require.Equal(t, 2, sessionCount(d))
688- require.Nil(t, sess.client)
688+ require.Empty(t, sess.snapshotAttachments())
689689 newSess := ac.currentSession()
690690 require.NotNil(t, newSess)
691691 require.NotSame(t, sess, newSess)
692692 require.Equal(t, "scratch", newSess.name)
693693 require.False(t, newSess.ephemeral)
694- require.Same(t, ac, newSess.client)
695- require.Equal(t, attachmentActive, newSess.attachmentRole(ac))
696- require.Greater(t, ac.roleGeneration.Load(), generation, "new-session handoff must publish through the attachment transition")
694+ require.Contains(t, newSess.snapshotAttachments(), ac)
695+ require.Equal(t, true, newSess.attachmentRegistered(ac))
696+ require.Greater(t, ac.connectionGeneration.Load(), generation, "new-session handoff must publish through the attachment transition")
697697 require.Contains(t, string(finalOutput.Data), "scratch")
698698 require.NotContains(t, string(finalOutput.Data), "Create session")
699699 }
@@ -701,7 +701,7 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
701701 func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) {
702702 d, sess, ac, sends, releases := newManualTabSession(t, 2)
703703 sess.mu.Lock()
704- sess.client = ac
704+ sess.registerAttachmentLocked(ac)
705705 sess.mu.Unlock()
706706 defer releases[0]()
707707 defer releases[1]()
@@ -1125,9 +1125,9 @@ func TestPaletteExecMethods(t *testing.T) {
11251125 exec := paletteExec{d: d, sess: sess, ac: ac}
11261126
11271127 require.NoError(t, exec.NextTab())
1128- require.Equal(t, 1, activeTabIndex(sess))
1128+ require.Equal(t, 1, testAttachmentTabIndex(sess))
11291129 require.NoError(t, exec.PrevTab())
1130- require.Equal(t, 0, activeTabIndex(sess))
1130+ require.Equal(t, 0, testAttachmentTabIndex(sess))
11311131 sess.ephemeral = true
11321132 require.NoError(t, exec.RenameSession())
11331133 require.True(t, ac.overlays.promptActive())
M internal/usecase/daemon/pane_actions.go +145 -45
@@ -10,7 +10,7 @@ import (
1010 )
1111
1212 func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Direction) error {
13- target := resolveDaemonActionTarget(sess)
13+ target := resolveDaemonActionTargetForAttachment(sess, ac)
1414 err := d.splitPaneAt(sess, target.tab, target.pane, dir)
1515 if err == nil && ac != nil {
1616 d.invalidateRender(sess, ac, true, "pane_actions.go")
@@ -131,7 +131,7 @@ func placementContent(placements []layout.Placement, id layout.PaneID) domain.Re
131131 func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Rows: r.Height} }
132132
133133 func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {
134- target := resolveDaemonActionTarget(sess)
134+ target := resolveDaemonActionTargetForAttachment(sess, ac)
135135 err := d.stackPaneAt(sess, target.tab, target.pane)
136136 if err == nil && ac != nil {
137137 d.invalidateRender(sess, ac, true, "pane_actions.go")
@@ -146,7 +146,7 @@ func (d *Daemon) stackPaneAt(sess *session, tb *tab, target *pane) error {
146146 }
147147
148148 func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
149- target := resolveDaemonActionTarget(sess)
149+ target := resolveDaemonActionTargetForAttachment(sess, ac)
150150 err := d.toggleStackAt(sess, target.tab, target.pane)
151151 if err == nil && ac != nil {
152152 d.invalidateRender(sess, ac, true, "pane_actions.go")
@@ -190,18 +190,15 @@ func (d *Daemon) closeFocusedPane(sess *session, ac *attachedClient) error {
190190 }
191191 return nil
192192 }
193- tb := sess.activeTab()
193+ target := resolveDaemonActionTargetForAttachment(sess, ac)
194+ tb := target.tab
194195 if tb == nil {
195196 return layout.ErrNotFound
196197 }
197- tb.mu.Lock()
198- if tb.tree == nil {
199- tb.mu.Unlock()
198+ if target.pane == nil {
200199 return layout.ErrNotFound
201200 }
202- id := tb.tree.Focus
203- tb.mu.Unlock()
204- return d.closePane(sess, tb, id, ac, true)
201+ return d.closePane(sess, tb, target.pane.id, ac, true)
205202 }
206203
207204 // reapPane retains the explicit-owner entry point for focused close tests. PTY
@@ -244,6 +241,11 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
244241 return true
245242 }
246243 sess, tb := owner.session, owner.tab
244+ // EOF teardown is a shared mutation just like an explicit close. Hold the
245+ // dispatch boundary before any session/tab lock, then use the locked close
246+ // helpers below without re-entering it.
247+ sess.dispatchMu.Lock()
248+ defer sess.dispatchMu.Unlock()
247249 sess.mu.Lock()
248250 ownedTab := slices.Contains(sess.tabs, tb)
249251 if p.owner.Load() != owner {
@@ -254,7 +256,7 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
254256 sess.mu.Unlock()
255257 return true
256258 }
257- ac := sess.client
259+ attachments := sess.snapshotAttachmentsLocked()
258260 sessionName := sess.name
259261 tb.mu.Lock()
260262 if p.owner.Load() != owner {
@@ -288,15 +290,23 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
288290 p.clearOwnerLocked()
289291 p.mu.Unlock()
290292 tb.mu.Unlock()
293+ sess.invalidateViewsLocked()
291294 sess.mu.Unlock()
292295
293296 if finalPane {
294- _ = d.closeTab(sess, tb, true)
297+ for _, ac := range attachments {
298+ if ac.overlays != nil {
299+ ac.overlays.clearCopyModeForPane(p)
300+ }
301+ }
302+ _ = d.closeTabLocked(sess, tb, true)
295303 return true
296304 }
297305 d.applyTabLayout(sess, tb)
298- if ac != nil {
299- ac.overlays.clearCopyModeForPane(p)
306+ for _, ac := range attachments {
307+ if ac.overlays != nil {
308+ ac.overlays.clearCopyModeForPane(p)
309+ }
300310 ac.pruneCaptureFrames(p)
301311 }
302312 if rc := sess.renderCoordinator(); rc != nil {
@@ -304,21 +314,36 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
304314 }
305315 closePaneProcess(p)
306316 d.log.Info("pane closed", "session", sessionName, "pane", p.id)
307- if ac != nil {
317+ for _, ac := range attachments {
308318 d.invalidateRender(sess, ac, true, "pane_actions.go")
309319 }
310320 return true
311321 }
312322
323+// closePane is the public close boundary for callers that are not already
324+// inside session.runMutation. Mutation dispatchers use closePaneLocked.
313325 func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool) error {
314- if tb == nil {
326+ if sess == nil {
315327 return layout.ErrNotFound
316328 }
317- if ac == nil {
318- sess.mu.Lock()
319- ac = sess.client
320- sess.mu.Unlock()
329+ sess.dispatchMu.Lock()
330+ defer sess.dispatchMu.Unlock()
331+ return d.closePaneLocked(sess, tb, id, ac, repaint)
332+}
333+
334+// closePaneLocked requires sess.dispatchMu before entering any architecture
335+// lock. It is used by existing runMutation callers to avoid nested dispatch
336+// locking.
337+func (d *Daemon) closePaneLocked(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool) error {
338+ return d.closePaneLockedWithEffect(sess, tb, id, ac, repaint, nil)
339+}
340+
341+func (d *Daemon) closePaneLockedWithEffect(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool, effect *attachmentEffectTicket) error {
342+ if tb == nil {
343+ return layout.ErrNotFound
321344 }
345+ // A nil attachment denotes a session-wide close with an explicit pane target.
346+ attachments := sess.snapshotAttachments()
322347 tb.mu.Lock()
323348 p := tb.panes[id]
324349 if p == nil || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, id) {
@@ -327,10 +352,13 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
327352 }
328353 if len(layout.LeafIDs(tb.tree.Root)) <= 1 {
329354 tb.mu.Unlock()
330- if ac != nil {
331- ac.overlays.clearCopyModeForPane(p)
355+ for _, viewer := range attachments {
356+ if viewer.overlays != nil {
357+ viewer.overlays.clearCopyModeForPane(p)
358+ }
359+ viewer.pruneCaptureFrames(p)
332360 }
333- return d.closeTab(sess, tb, repaint)
361+ return d.closeTabLockedWithEffect(sess, tb, repaint, effect)
334362 }
335363 if err := tb.tree.Close(id); err != nil {
336364 tb.mu.Unlock()
@@ -339,11 +367,16 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
339367 delete(tb.panes, id)
340368 tb.bumpLayoutGenerationLocked()
341369 tb.mu.Unlock()
370+ sess.mu.Lock()
371+ sess.invalidateViewsLocked()
372+ sess.mu.Unlock()
342373 d.applyTabLayout(sess, tb)
343374
344- if ac != nil {
345- ac.overlays.clearCopyModeForPane(p)
346- ac.pruneCaptureFrames(p)
375+ for _, viewer := range attachments {
376+ if viewer.overlays != nil {
377+ viewer.overlays.clearCopyModeForPane(p)
378+ }
379+ viewer.pruneCaptureFrames(p)
347380 }
348381
349382 if rc := sess.renderCoordinator(); rc != nil {
@@ -354,18 +387,22 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
354387 if repaint {
355388 if ac != nil {
356389 d.invalidateRender(sess, ac, true, "pane_actions.go")
390+ } else {
391+ for _, viewer := range attachments {
392+ d.invalidateRender(sess, viewer, true, "pane_actions.go")
393+ }
357394 }
358395 }
359396 return nil
360397 }
361398
362-func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction, effect *roleEffectTicket) error {
363- target := resolveDaemonActionTarget(sess)
399+func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction, effect *attachmentEffectTicket) error {
400+ target := resolveDaemonActionTargetForAttachment(sess, ac)
364401 oldFocus := layout.PaneID("")
365402 if target.pane != nil {
366403 oldFocus = target.pane.id
367404 }
368- span, err := d.focusDirAt(sess, target.tab, target.pane, dir)
405+ span, err := d.focusDirAt(sess, target.tab, target.pane, dir, ac)
369406 if err == nil {
370407 if ac != nil {
371408 d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "pane_actions.go")
@@ -375,7 +412,7 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
375412 if !errors.Is(err, errNoNeighbor) || target.tab == nil {
376413 return err
377414 }
378- if !overflowSourceEligible(sess, target.tab) {
415+ if !overflowSourceEligible(sess, ac, target.tab) {
379416 return errNoNeighbor
380417 }
381418
@@ -385,24 +422,22 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
385422 return errNoNeighbor
386423 }
387424 if effect != nil {
388- return d.switchToTargetForRole(effect.roleToken(), sessionTarget, sessionHandoffGuard{expectedSource: target.tab}, "overflow-session")
425+ return d.switchToTargetForAttachment(effect.connectionToken(), sessionTarget, sessionHandoffGuard{expectedSource: target.tab}, "overflow-session")
389426 }
390427 return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)
391428 }
392429
393- sess.mu.Lock()
394- position, count := sess.active, len(sess.tabs)
395- sess.mu.Unlock()
430+ position, count := sess.tabIndexForAttachment(ac)
396431 step := resolveOverflow(dir, cfg, position, count)
397432 if step.kind != overflowTabs {
398433 return err
399434 }
400- candidate, ok := d.prepareTabOverflow(sess, target.tab, dir, span, step.delta)
401- if !ok || !d.commitTabOverflow(sess, candidate) {
435+ candidate, ok := d.prepareTabOverflowForAttachment(sess, ac, target.tab, dir, span, step.delta)
436+ if !ok || !d.commitTabOverflowForAttachment(sess, ac, candidate) {
402437 return errNoNeighbor
403438 }
404- d.activateTab(sess, candidate.target)
405439 if ac != nil {
440+ sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID))
406441 d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "pane_actions.go")
407442 }
408443 return nil
@@ -412,23 +447,77 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
412447 // successful directional focus move. Keep copy-mode exit before the optional
413448 // stack title refresh, and both before render invalidation.
414449 func (d *Daemon) finishPaneFocusForClient(sess *session, ac *attachedClient, tb *tab, oldFocus layout.PaneID, producer string) {
415- tb.mu.Lock()
416- newFocus := tb.tree.Focus
417- pl, hasPlacement := focusedPlacementLocked(tb)
418- tb.mu.Unlock()
419- if newFocus != oldFocus {
450+ var newFocus layout.PaneID
451+ var newPane *pane
452+ var pl layout.Placement
453+ var hasPlacement bool
454+ beforePaneID := domain.PaneStableID("")
455+ if ac != nil {
456+ beforePaneID = ac.viewSnapshot().paneID
457+ }
458+ if tb != nil {
459+ tb.mu.Lock()
460+ if ac != nil {
461+ view := ac.viewSnapshot()
462+ if domain.TabStableID(tb.stableID) == view.tabID {
463+ for _, candidate := range tb.panes {
464+ if candidate != nil && domain.PaneStableID(candidate.stableID) == view.paneID {
465+ newPane = candidate
466+ break
467+ }
468+ }
469+ }
470+ } else {
471+ newPane = tb.focusedPane()
472+ if newPane != nil {
473+ newFocus = newPane.id
474+ }
475+ }
476+ if newPane != nil {
477+ newFocus = newPane.id
478+ pl, hasPlacement = panePlacementLocked(tb, newFocus)
479+ }
480+ tb.mu.Unlock()
481+ }
482+
483+ moved := newFocus != oldFocus
484+ if ac != nil && newPane != nil {
485+ sess.mu.Lock()
486+ sess.setAttachmentPaneLocked(ac, tb, newPane)
487+ sess.mu.Unlock()
488+ after := ac.viewSnapshot()
489+ moved = beforePaneID != after.paneID
490+ }
491+ if moved {
420492 d.exitCopyMode(ac)
421493 if hasPlacement && pl.InStack {
422- d.refreshPaneTitleOnFocus(sess, newFocus)
494+ d.refreshPaneTitleOnFocus(sess, newFocus, tb)
423495 }
424496 }
425497 d.invalidateRender(sess, ac, true, producer)
426498 }
427499
428-func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction) (domain.Rect, error) {
500+func panePlacementLocked(tb *tab, id layout.PaneID) (layout.Placement, bool) {
501+ placements, ok := solvedPlacementsLocked(tb)
502+ if !ok {
503+ return layout.Placement{}, false
504+ }
505+ for _, placement := range placements {
506+ if placement.ID == id {
507+ return placement, true
508+ }
509+ }
510+ return layout.Placement{}, false
511+}
512+
513+func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction, initiating ...*attachedClient) (domain.Rect, error) {
429514 if tb == nil || target == nil {
430515 return domain.Rect{}, layout.ErrNotFound
431516 }
517+ var ac *attachedClient
518+ if len(initiating) > 0 {
519+ ac = initiating[0]
520+ }
432521 tb.mu.Lock()
433522 if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) {
434523 tb.mu.Unlock()
@@ -439,13 +528,24 @@ func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Dir
439528 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
440529 span, _ := candidate.FocusSpan(area)
441530 err := candidate.FocusDir(dir, area)
442- committed := err == nil && candidate.Focus != tb.tree.Focus
531+ currentFocus := tb.focusedPane()
532+ committed := err == nil && currentFocus != nil && candidate.Focus != currentFocus.id
533+ var focusedPane *pane
443534 if committed {
535+ focusedPane = tb.panes[candidate.Focus]
444536 tb.tree = candidate
445537 tb.bumpLayoutGenerationLocked()
446538 }
447539 tb.mu.Unlock()
448540 if committed {
541+ if ac != nil {
542+ d.exitCopyMode(ac)
543+ }
544+ if sess != nil && ac != nil && focusedPane != nil {
545+ sess.mu.Lock()
546+ sess.setAttachmentPaneLocked(ac, tb, focusedPane)
547+ sess.mu.Unlock()
548+ }
449549 d.applyTabLayout(sess, tb)
450550 }
451551 if errors.Is(err, layout.ErrNoPane) {
M internal/usecase/daemon/pane_actions_test.go +137 -24
@@ -78,7 +78,7 @@ func TestLayoutRetryIsBoundedDeduplicatedAndCanceled(t *testing.T) {
7878 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
7979 clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 16)}
8080 d.clock = clock
81- tb := sess.activeTab()
81+ tb := testAttachmentTab(sess)
8282 tb.mu.Lock()
8383 tb.size = domain.Size{Cols: 100, Rows: 20}
8484 tb.bumpLayoutGenerationLocked()
@@ -118,7 +118,7 @@ func TestLayoutRetryIsBoundedDeduplicatedAndCanceled(t *testing.T) {
118118 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
119119 clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 16)}
120120 d.clock = clock
121- tb := sess.activeTab()
121+ tb := testAttachmentTab(sess)
122122 tb.mu.Lock()
123123 tb.size = domain.Size{Cols: 100, Rows: 20}
124124 tb.bumpLayoutGenerationLocked()
@@ -145,7 +145,7 @@ func TestLayoutRetryIsBoundedDeduplicatedAndCanceled(t *testing.T) {
145145 func TestFocusDirAtDoesNotApplyUncommittedCandidate(t *testing.T) {
146146 pty := &transactionalResizePTY{}
147147 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
148- tb := sess.activeTab()
148+ tb := testAttachmentTab(sess)
149149 second := newPane("pane-2", nil, domain.Size{Cols: 80, Rows: 23})
150150 tb.mu.Lock()
151151 tb.panes[second.id] = second
@@ -164,7 +164,7 @@ func TestLayoutApplicationRetriesOnlyAcceptedFailedGeometry(t *testing.T) {
164164 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
165165 clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 16)}
166166 d.clock = clock
167- tb, p := sess.activeTab(), sess.activeTab().focusedPane()
167+ tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
168168 tb.mu.Lock()
169169 tb.size = domain.Size{Cols: 100, Rows: 20}
170170 tb.bumpLayoutGenerationLocked()
@@ -206,7 +206,7 @@ func TestDelayedRetryUsesFreshLayoutAfterPaneResize(t *testing.T) {
206206 failed := &transactionalResizePTY{errs: []error{errors.New("first resize fails")}}
207207 d, sess, ac, _ := newManualSessionWithPTYs(t, failed)
208208 d.clock = clock.clock
209- tb := sess.activeTab()
209+ tb := testAttachmentTab(sess)
210210 first := tb.focusedPane()
211211 second := newPane("pane-2", nil, domain.Size{Cols: 80, Rows: 23})
212212 tb.mu.Lock()
@@ -260,7 +260,7 @@ func TestDelayedRetryUsesFreshLayoutAfterPaneResize(t *testing.T) {
260260
261261 func TestLayoutApplicationDoesNotHoldTabLock(t *testing.T) {
262262 d, sess, pty, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
263- tb := sess.activeTab()
263+ tb := testAttachmentTab(sess)
264264 tb.focusedPane().rect = domain.Rect{Width: 20, Height: 10}
265265 entered := make(chan struct{})
266266 release := make(chan struct{})
@@ -290,7 +290,7 @@ func TestLayoutApplicationDoesNotHoldTabLock(t *testing.T) {
290290
291291 func TestLayoutApplicationRejectsStalePaneIdentity(t *testing.T) {
292292 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
293- tb := sess.activeTab()
293+ tb := testAttachmentTab(sess)
294294 oldPane := tb.focusedPane()
295295 oldPane.rect = domain.Rect{Width: 20, Height: 10}
296296 oldPane.screen.Resize(20, 10)
@@ -341,7 +341,7 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) {
341341 first, entered, release := newBlockedPTY()
342342 second := &transactionalResizePTY{}
343343 d, sess, _, _ := newManualSessionWithPTYs(t, first)
344- tb := sess.activeTab()
344+ tb := testAttachmentTab(sess)
345345 p1 := tb.focusedPane()
346346 p2 := newPane("pane-2", second, domain.Size{Cols: 80, Rows: 23})
347347 tb.mu.Lock()
@@ -383,7 +383,7 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) {
383383 first, entered, release := newBlockedPTY()
384384 second := &transactionalResizePTY{}
385385 d, sess, _, _ := newManualSessionWithPTYs(t, first)
386- tb := sess.activeTab()
386+ tb := testAttachmentTab(sess)
387387 p1 := tb.focusedPane()
388388 p2 := newPane("pane-2", second, domain.Size{Cols: 80, Rows: 23})
389389 tb.mu.Lock()
@@ -417,7 +417,7 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) {
417417 t.Run("client size rejects and retries a blocked plan", func(t *testing.T) {
418418 pty, entered, release := newBlockedPTY()
419419 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
420- tb := sess.activeTab()
420+ tb := testAttachmentTab(sess)
421421 p := tb.focusedPane()
422422 p.rect = domain.Rect{Width: 20, Height: 10}
423423
@@ -476,7 +476,7 @@ func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) {
476476
477477 require.NoError(t, d.splitPane(sess, nil, tt.dir))
478478
479- tb := sess.activeTab()
479+ tb := testAttachmentTab(sess)
480480 tb.mu.Lock()
481481 defer tb.mu.Unlock()
482482 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
@@ -492,7 +492,7 @@ func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) {
492492
493493 func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) {
494494 d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 4})
495- tb := sess.activeTab()
495+ tb := testAttachmentTab(sess)
496496 stackPTY := portsmocks.NewMockPTY(t)
497497 stackPane := newPane("pane-2", stackPTY, domain.Size{Cols: 41, Rows: 2})
498498 tb.panes[stackPane.id] = stackPane
@@ -520,7 +520,7 @@ func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) {
520520 cause := errors.New("open failed")
521521 factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(nil, cause).Once()
522522
523- tb := sess.activeTab()
523+ tb := testAttachmentTab(sess)
524524 tb.mu.Lock()
525525 beforeRoot, beforeFocus, beforeNext, beforePaneCount := tb.tree.Root.Leaf, tb.tree.Focus, tb.nextPaneID, len(tb.panes)
526526 tb.mu.Unlock()
@@ -547,7 +547,7 @@ func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) {
547547
548548 func TestApplyLayoutResizesPTYsAndScreens(t *testing.T) {
549549 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
550- tb := sess.activeTab()
550+ tb := testAttachmentTab(sess)
551551 newPTY := portsmocks.NewMockPTY(t)
552552 newPane := newPane("pane-2", newPTY, domain.Size{Cols: 41, Rows: 10})
553553 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
@@ -564,7 +564,7 @@ func TestApplyLayoutResizesPTYsAndScreens(t *testing.T) {
564564
565565 func TestResizeReflowsAllPanes(t *testing.T) {
566566 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
567- tb := sess.activeTab()
567+ tb := testAttachmentTab(sess)
568568 newPTY := portsmocks.NewMockPTY(t)
569569 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
570570 tb.panes["pane-2"] = newPane("pane-2", newPTY, domain.Size{Cols: 20, Rows: 10})
@@ -596,7 +596,7 @@ func TestFocusDirAtReturnsDepartingSpanAndMapsNoNeighbor(t *testing.T) {
596596 for _, tt := range tests {
597597 t.Run(tt.name, func(t *testing.T) {
598598 d, sess, _, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
599- tb := sess.activeTab()
599+ tb := testAttachmentTab(sess)
600600 target := tb.panes["pane-1"]
601601 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"}
602602 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
@@ -614,7 +614,7 @@ func TestFocusDirAtReturnsDepartingSpanAndMapsNoNeighbor(t *testing.T) {
614614
615615 func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) {
616616 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
617- tb := sess.activeTab()
617+ tb := testAttachmentTab(sess)
618618 newPTY := portsmocks.NewMockPTY(t)
619619 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"}
620620 tb.panes["pane-2"] = newPane("pane-2", newPTY, domain.Size{Cols: 20, Rows: 10})
@@ -623,6 +623,8 @@ func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) {
623623 tr, _ := newCapturingTransport(t)
624624 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 41, Rows: 12}}
625625 ac.initOverlays()
626+ ac.setSession(sess)
627+ require.True(t, sess.registerAttachment(ac))
626628 ac.overlays.copyMode = &scopy.Mode{}
627629
628630 require.NoError(t, d.focusDir(sess, ac, layout.Right, nil))
@@ -633,9 +635,119 @@ func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) {
633635 newPTY.AssertNotCalled(t, "Resize", mock.Anything)
634636 }
635637
638+func TestClosePaneRepaintFanoutRespectsAttachmentScope(t *testing.T) {
639+ for _, tt := range []struct {
640+ name string
641+ attachment *attachedClient
642+ wantFirst bool
643+ wantSecond bool
644+ }{
645+ {name: "nil attachment fans out", wantFirst: true, wantSecond: true},
646+ {name: "non-nil attachment stays targeted"},
647+ } {
648+ t.Run(tt.name, func(t *testing.T) {
649+ d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
650+ tb := testAttachmentTab(sess)
651+ closingPTY := portsmocks.NewMockPTY(t)
652+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
653+ tb.panes["pane-2"] = newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10})
654+ tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
655+ tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
656+ oldPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Return(nil).Once()
657+ closingPTY.EXPECT().Close().Return(nil).Once()
658+
659+ clients := make([]*attachedClient, 2)
660+ sends := make([]chan ports.Frame, 2)
661+ for i := range clients {
662+ tr, sent := newCapturingTransport(t)
663+ clients[i] = &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 41, Rows: 12}}
664+ clients[i].clientID[0] = byte(i + 1)
665+ clients[i].initOverlays()
666+ clients[i].setSession(sess)
667+ require.True(t, sess.registerAttachment(clients[i]))
668+ sends[i] = sent
669+ }
670+ if tt.name == "non-nil attachment stays targeted" {
671+ tt.attachment = clients[0]
672+ tt.wantFirst = true
673+ }
674+
675+ require.NoError(t, d.closePane(sess, tb, "pane-2", tt.attachment, true))
676+ for i, want := range []bool{tt.wantFirst, tt.wantSecond} {
677+ select {
678+ case frame := <-sends[i]:
679+ require.True(t, want, "attachment %d unexpectedly repainted: %#v", i, frame)
680+ require.Equal(t, ports.MsgOutput, frame.Type)
681+ default:
682+ require.False(t, want, "attachment %d was not repainted", i)
683+ }
684+ }
685+ })
686+ }
687+}
688+
689+func TestFinalPaneReapClearsCopyModeForEveryAttachment(t *testing.T) {
690+ d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
691+ tb := testAttachmentTab(sess)
692+ other := newTab(nil, domain.Size{Cols: 41, Rows: 10})
693+ sess.tabs = append(sess.tabs, other)
694+ d.sessions[sess.id] = sess
695+ pane := tb.focusedPane()
696+ publishTiledPaneOwners(sess, tb)
697+ oldPTY.EXPECT().Close().Return(nil).Once()
698+
699+ for i := range 2 {
700+ tr, _ := newCapturingTransport(t)
701+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 41, Rows: 12}}
702+ ac.clientID[0] = byte(i + 1)
703+ ac.initOverlays()
704+ ac.setSession(sess)
705+ require.True(t, sess.registerAttachment(ac))
706+ ac.overlays.copyMode = &scopy.Mode{}
707+ ac.overlays.copyPane = pane
708+ }
709+
710+ d.reapPaneOwner(pane)
711+
712+ require.Len(t, sess.tabs, 1)
713+ for _, ac := range sess.snapshotAttachments() {
714+ require.Nil(t, ac.overlays.copyMode, "final-pane reap left copy mode active for attachment %d", ac.clientID[0])
715+ require.Nil(t, ac.overlays.copyPane, "final-pane reap left copy pane active for attachment %d", ac.clientID[0])
716+ }
717+}
718+
719+func TestClosePaneActionDoesNotReenterDispatchBoundary(t *testing.T) {
720+ d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
721+ tb := testAttachmentTab(sess)
722+ closingPTY := portsmocks.NewMockPTY(t)
723+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
724+ tb.panes["pane-2"] = newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10})
725+ tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
726+ tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
727+ d.sessions[sess.id] = sess
728+ oldPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Return(nil).Once()
729+ closingPTY.EXPECT().Close().Return(nil).Once()
730+
731+ done := make(chan error, 1)
732+ go func() {
733+ done <- sess.runMutation(func() error {
734+ return (daemonActions{d: d}).Run(daemonActionRequest{
735+ kind: daemonActionClosePane,
736+ target: daemonActionTarget{session: sess, tab: tb, pane: tb.panes["pane-2"]},
737+ })
738+ })
739+ }()
740+ select {
741+ case err := <-done:
742+ require.NoError(t, err)
743+ case <-time.After(time.Second):
744+ t.Fatal("close pane action re-entered dispatch boundary")
745+ }
746+}
747+
636748 func TestCloseFocusedPaneRemovesPaneReflowsAndIsIdempotent(t *testing.T) {
637749 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
638- tb := sess.activeTab()
750+ tb := testAttachmentTab(sess)
639751 closingPTY := portsmocks.NewMockPTY(t)
640752 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
641753 tb.panes["pane-2"] = newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10})
@@ -654,7 +766,7 @@ func TestCloseFocusedPaneRemovesPaneReflowsAndIsIdempotent(t *testing.T) {
654766
655767 func TestReapPaneSharesClosePathAndIsIdempotent(t *testing.T) {
656768 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
657- tb := sess.activeTab()
769+ tb := testAttachmentTab(sess)
658770 reapedPTY := portsmocks.NewMockPTY(t)
659771 reaped := newPane("pane-2", reapedPTY, domain.Size{Cols: 20, Rows: 10})
660772 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
@@ -673,7 +785,7 @@ func TestReapPaneSharesClosePathAndIsIdempotent(t *testing.T) {
673785
674786 func TestClosePaneKeepsTabOpenWhenPendingSpawnLeafExists(t *testing.T) {
675787 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
676- tb := sess.activeTab()
788+ tb := testAttachmentTab(sess)
677789 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
678790 oldPTY.EXPECT().Close().Return(nil).Once()
679791
@@ -705,7 +817,7 @@ func TestStackPaneCreatesStackAndToggleRestoresSplit(t *testing.T) {
705817 factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once()
706818
707819 require.NoError(t, d.stackPane(sess, nil))
708- tb := sess.activeTab()
820+ tb := testAttachmentTab(sess)
709821 require.Equal(t, layout.Stack, tb.tree.Root.Kind)
710822 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
711823 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded)
@@ -728,7 +840,7 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) {
728840
729841 require.NoError(t, d.focusDir(sess, nil, layout.Up, nil))
730842
731- tb := sess.activeTab()
843+ tb := testAttachmentTab(sess)
732844 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
733845 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Root.Expanded)
734846 thirdPTY := portsmocks.NewMockPTY(t)
@@ -773,7 +885,7 @@ func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) {
773885 factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()
774886
775887 require.NoError(t, d.splitPane(sess, nil, layout.Right))
776- tb := sess.activeTab()
888+ tb := testAttachmentTab(sess)
777889 tb.mu.Lock()
778890 tb.tree.Focus = "pane-1"
779891 tb.mu.Unlock()
@@ -785,6 +897,7 @@ func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) {
785897 ac := &attachedClient{}
786898 ac.initOverlays()
787899 ac.setSession(sess)
900+ require.True(t, sess.registerAttachment(ac))
788901 daemonKeyHandler{d: d, ac: ac}.Forward([]byte("Z"))
789902 require.Equal(t, []byte("Z"), <-writes)
790903 tb.focusedPane().screen.Write([]byte("survivor"))
@@ -844,7 +957,7 @@ func TestSplitPaneUsesExactAuthoritativeEnvironment(t *testing.T) {
844957 ).Once()
845958
846959 require.NoError(t, d.splitPane(sess, nil, layout.Right))
847- tb := sess.activeTab()
960+ tb := testAttachmentTab(sess)
848961 tb.mu.Lock()
849962 paneID := tb.panes["pane-2"].stableID
850963 tabID := tb.stableID
M internal/usecase/daemon/pane_lifetime_test.go +0 -128
@@ -64,12 +64,6 @@ func (f *lifetimePTYFactory) Open(ctx context.Context, _ string, _ []string, _ [
6464 return p, nil
6565 }
6666
67-func (f *lifetimePTYFactory) opened(index int) (*lifetimePTY, context.Context) {
68- f.mu.Lock()
69- defer f.mu.Unlock()
70- return f.ptys[index], f.contexts[index]
71-}
72-
7367 func TestPaneOpenErrorsReleaseReturnedPTYWithoutPublication(t *testing.T) {
7468 type openCase struct {
7569 name string
@@ -211,125 +205,3 @@ func (f *paneErrorFactory) Open(ctx context.Context, _ string, _ []string, _ []s
211205 }
212206 return f.pty, f.err
213207 }
214-
215-func TestPaneProcessLifetimeIsDaemonRooted(t *testing.T) {
216- type setupFunc func(*testing.T, *Daemon) (*pane, func(), func())
217- tests := []struct {
218- name string
219- setup setupFunc
220- }{
221- {
222- name: "initial pane",
223- setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
224- sess, err := createSessionForTest(d, "work", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)
225- require.NoError(t, err)
226- return sess.activeTab().focusedPane(), sess.cancel, func() {
227- _ = d.killSession(sess, ports.ReasonSessionKilled, false)
228- }
229- },
230- },
231- {
232- name: "created tab",
233- setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
234- sessCtx, cancelSession := context.WithCancel(t.Context())
235- base := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
236- base.ctx, base.cancel = context.WithCancel(sessCtx)
237- sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{base}}
238- d.mu.Lock()
239- d.sessions[sess.id] = sess
240- d.mu.Unlock()
241- require.NoError(t, d.createTab(sess, domain.Size{Cols: 80, Rows: 24}))
242- created := sess.activeTab()
243- return created.focusedPane(), cancelSession, created.closeAllPanes
244- },
245- },
246- {
247- name: "split pane",
248- setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
249- sessCtx, cancelSession := context.WithCancel(t.Context())
250- tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
251- tb.ctx, tb.cancel = context.WithCancel(sessCtx)
252- sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}
253- target := tb.focusedPane()
254- require.NoError(t, d.splitPaneAt(sess, tb, target, layout.Right))
255- tb.mu.Lock()
256- p := tb.panes["pane-2"]
257- tb.mu.Unlock()
258- require.NotNil(t, p)
259- return p, cancelSession, tb.closeAllPanes
260- },
261- },
262- {
263- name: "floating pane",
264- setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
265- sessCtx, cancelSession := context.WithCancel(t.Context())
266- tb := newFloatingTestTab(t)
267- tb.ctx, tb.cancel = context.WithCancel(sessCtx)
268- sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}
269- tb.mu.Lock()
270- generation := tb.beginFloatingWarmLocked(true)
271- tb.mu.Unlock()
272- d.openAndInstallFloating(sess, tb, floatingLaunchSpec{
273- sessionName: "work", size: domain.Size{Cols: 20, Rows: 8},
274- geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}},
275- paneStableID: "p_floating", parentCtx: tb.ctx,
276- }, generation)
277- tb.mu.Lock()
278- p := tb.floating.pane
279- tb.mu.Unlock()
280- require.NotNil(t, p)
281- return p, cancelSession, func() { d.teardownFloating(tb, nil) }
282- },
283- },
284- {
285- name: "restored pane",
286- setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) {
287- restoreCtx, cancelRestore := context.WithCancel(t.Context())
288- sessCtx, cancelSession := context.WithCancel(t.Context())
289- tabs, err := d.restoreSnapshotTabs(restoreCtx, sessCtx, restoreAcceptanceSession(t, "work"))
290- require.NoError(t, err)
291- require.Len(t, tabs, 1)
292- return tabs[0].focusedPane(), cancelSession, func() {
293- cancelRestore()
294- closeRestoredTabs(tabs)
295- }
296- },
297- },
298- }
299-
300- for _, tt := range tests {
301- t.Run(tt.name, func(t *testing.T) {
302- factory := &lifetimePTYFactory{}
303- d := newTestDaemon(t, factory, stubClock{})
304- p, cancelSession, teardown := tt.setup(t, d)
305- defer func() {
306- if teardown != nil {
307- teardown()
308- }
309- }()
310-
311- require.NotNil(t, p)
312- pty, openCtx := factory.opened(0)
313- require.NotNil(t, p.ctx)
314- require.Same(t, openCtx, p.ctx)
315- require.NotNil(t, p.cancel)
316-
317- cancelSession()
318- select {
319- case <-openCtx.Done():
320- t.Fatal("session cancellation killed a published pane process")
321- default:
322- }
323-
324- teardown()
325- teardown = nil
326- select {
327- case <-openCtx.Done():
328- case <-time.After(time.Second):
329- t.Fatal("teardown did not cancel the pane process")
330- }
331- d.sessWg.Wait()
332- require.Equal(t, int32(1), pty.closes.Load())
333- })
334- }
335-}
M internal/usecase/daemon/pane_owner_reader_test.go +1 -66
@@ -51,81 +51,16 @@ func (*ownerRoutingPTY) ForegroundPgid() (int, error) {
5151 return 0, nil
5252 }
5353
54-func installTiledPaneOwnerForTest(sess *session, tb *tab, p *pane) {
55- tb.mu.Lock()
56- old := tb.panes[layout.PaneID("pane-1")]
57- delete(tb.panes, layout.PaneID("pane-1"))
58- if old != nil {
59- old.clearOwnerForTest()
60- }
61- p.id = layout.PaneID("pane-1")
62- tb.panes[p.id] = p
63- tb.tree = &layout.Tree{Root: layout.NewLeaf(p.id), Focus: p.id}
64- publishPaneOwner(p, sess, tb, 0)
65- tb.mu.Unlock()
66-}
67-
6854 func (p *pane) clearOwnerForTest() {
6955 p.mu.Lock()
7056 p.clearOwnerLocked()
7157 p.mu.Unlock()
7258 }
7359
74-func TestPTYReaderRoutesEffectsToOwnerPublishedForEachRead(t *testing.T) {
75- pty := newOwnerRoutingPTY()
76- d, source, _, _ := newManualSessionWithPTYs(t, pty)
77- pane := source.activeTab().focusedPane()
78- pane.onExit = func() {}
79- source.snapEligible.Store(true)
80-
81- destinationTab := newTab(nil, domain.Size{Cols: 80, Rows: 23})
82- destination := &session{sessionCore: sessionCore{id: domain.SessionID("destination"),
83- name: "destination",
84-
85- client: &attachedClient{}}, tabs: []*tab{destinationTab},
86-
87- snapEligible: atomic.Bool{},
88- }
89- destination.snapEligible.Store(true)
90- destination.client.initOverlays()
91- destination.client.setSession(destination)
92- d.sessions[destination.id] = destination
93-
94- sourceInvalidations := make(chan renderInvalidation, 2)
95- destinationInvalidations := make(chan renderInvalidation, 2)
96- sourceCoordinator := d.attachCoordinator(source, nil, source.client, true)
97- sourceCoordinator.opts.onInvalidate = func(invalidation renderInvalidation) { sourceInvalidations <- invalidation }
98- destinationCoordinator := d.attachCoordinator(destination, nil, destination.client, true)
99- destinationCoordinator.opts.onInvalidate = func(invalidation renderInvalidation) { destinationInvalidations <- invalidation }
100-
101- d.startPaneGoroutines(source, source.activeTab(), pane)
102- pty.steps <- channelPTYStep{data: []byte("source\a")}
103- <-pty.processed
104- require.Len(t, sourceInvalidations, 1)
105- require.Empty(t, destinationInvalidations)
106- require.Equal(t, uint64(1), source.snapshotGeneration)
107- require.Zero(t, destination.snapshotGeneration)
108- require.True(t, source.activeTab().attention)
109-
110- installTiledPaneOwnerForTest(destination, destinationTab, pane)
111- pty.steps <- channelPTYStep{data: []byte("destination\a")}
112- <-pty.processed
113-
114- require.Len(t, sourceInvalidations, 1, "output after owner publication must not invalidate the source")
115- require.Len(t, destinationInvalidations, 1, "output after owner publication must invalidate the destination")
116- require.Equal(t, uint64(1), source.snapshotGeneration, "source snapshot must not be dirtied after transfer")
117- require.Equal(t, uint64(1), destination.snapshotGeneration)
118- require.True(t, destinationTab.attention)
119- require.Equal(t, int32(1), pty.maxReaders.Load(), "owner publication must not start another PTY reader")
120-
121- pty.steps <- channelPTYStep{err: io.EOF}
122- d.sessWg.Wait()
123-}
124-
12560 func TestPTYReaderExitReapsExactlyCurrentOwnerAfterPublication(t *testing.T) {
12661 pty := newOwnerRoutingPTY()
12762 d, source, _, _ := newManualSessionWithPTYs(t, pty)
128- sourceTab := source.activeTab()
63+ sourceTab := testAttachmentTab(source)
12964 moved := sourceTab.focusedPane()
13065
13166 destinationTab := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
M internal/usecase/daemon/pane_rearrange_test.go +2 -110
@@ -2,7 +2,6 @@ package daemon
22
33 import (
44 "context"
5- "errors"
65 "io"
76 "maps"
87 "sync"
@@ -185,7 +184,7 @@ func TestPaneRearrangeEdgeNoopHasExactSilentInvariants(t *testing.T) {
185184 ac := &attachedClient{}
186185 ac.setSession(h.session)
187186 h.session.mu.Lock()
188- h.session.client = ac
187+ h.session.registerAttachmentLocked(ac)
189188 h.session.mu.Unlock()
190189 invalidations := make(chan renderInvalidation, 2)
191190 rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
@@ -265,113 +264,6 @@ func TestPaneRearrangeMapsUnsupportedAndTooSmallToUserNoticesAtomically(t *testi
265264 }
266265 }
267266
268-func TestPaneRearrangeChangedActionPublishesOnce(t *testing.T) {
269- h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
270- ac := &attachedClient{}
271- ac.setSession(h.session)
272- h.session.mu.Lock()
273- h.session.client = ac
274- h.session.mu.Unlock()
275- invalidations := make(chan renderInvalidation, 2)
276- rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
277- rc.attach(ac)
278- h.session.installRenderCoordinator(rc)
279-
280- err := (controlExec{d: h.daemon, sess: h.session, target: h.target("pane-1")}).runAction(daemonActionRequest{
281- kind: daemonActionConsumeOrExpelPane, direction: layout.Right,
282- })
283-
284- require.NoError(t, err)
285- h.tab.mu.Lock()
286- require.Equal(t, uint64(1), h.tab.layoutGeneration)
287- h.tab.mu.Unlock()
288- h.session.snapshotMu.Lock()
289- require.Equal(t, uint64(1), h.session.snapshotGeneration)
290- h.session.snapshotMu.Unlock()
291- require.NotZero(t, h.totalResizes())
292- awaitInvalidation(t, invalidations)
293- requireNoInvalidation(t, invalidations)
294-}
295-
296-func TestPaneRearrangeAdaptersOwnDirectionalRequestsAndNormalizeOnlyNoChange(t *testing.T) {
297- genuine := errors.New("genuine rearrange failure")
298- directions := []struct {
299- name string
300- direction layout.Direction
301- keyAction keys.Action
302- paletteRun func(paletteExec) error
303- controlRun func(controlExec) error
304- }{
305- {
306- name: "left", direction: layout.Left, keyAction: keys.ActionConsumeOrExpelPaneLeft,
307- paletteRun: func(e paletteExec) error { return e.ConsumeOrExpelPaneLeft() },
308- controlRun: func(e controlExec) error { return e.ConsumeOrExpelPaneLeft() },
309- },
310- {
311- name: "right", direction: layout.Right, keyAction: keys.ActionConsumeOrExpelPaneRight,
312- paletteRun: func(e paletteExec) error { return e.ConsumeOrExpelPaneRight() },
313- controlRun: func(e controlExec) error { return e.ConsumeOrExpelPaneRight() },
314- },
315- }
316- outcomes := []struct {
317- name string
318- err error
319- wantErr error
320- wantNotice bool
321- }{
322- {name: "silent no-change", err: errDaemonActionNoChange},
323- {name: "genuine error", err: genuine, wantErr: genuine, wantNotice: true},
324- }
325-
326- for _, direction := range directions {
327- t.Run(direction.name, func(t *testing.T) {
328- for _, outcome := range outcomes {
329- t.Run(outcome.name, func(t *testing.T) {
330- d := newTestDaemon(t, nil, stubClock{})
331- sess := addControlSession(d, "work", "t_work", "p_work")
332- ac := &attachedClient{}
333- ac.setSession(sess)
334- target := resolveDaemonActionTarget(sess)
335- paletteSpy := &actionRunnerSpy{err: outcome.err}
336- controlSpy := &actionRunnerSpy{err: outcome.err}
337- keySpy := &actionRunnerSpy{err: outcome.err}
338-
339- paletteErr := direction.paletteRun(paletteExec{d: d, sess: sess, ac: ac, actions: paletteSpy})
340- controlErr := direction.controlRun(controlExec{d: d, sess: sess, target: target, actions: controlSpy})
341- daemonKeyHandler{d: d, ac: ac, actions: keySpy}.Action(direction.keyAction, nil)
342-
343- if outcome.wantErr == nil {
344- require.NoError(t, paletteErr)
345- require.NoError(t, controlErr)
346- } else {
347- require.ErrorIs(t, paletteErr, outcome.wantErr)
348- require.ErrorIs(t, controlErr, outcome.wantErr)
349- }
350- if outcome.wantNotice {
351- require.Len(t, d.notices.history(), 1, "the key adapter owns reporting its genuine error")
352- } else {
353- require.Empty(t, d.notices.history(), "no-change is silent in the key adapter")
354- }
355-
356- for adapter, spy := range map[string]*actionRunnerSpy{
357- "palette": paletteSpy,
358- "control": controlSpy,
359- "key": keySpy,
360- } {
361- require.Len(t, spy.requests, 1, "%s adapter request count", adapter)
362- request := spy.requests[0]
363- require.Equal(t, daemonActionConsumeOrExpelPane, request.kind, "%s adapter action kind", adapter)
364- require.Equal(t, direction.direction, request.direction, "%s adapter direction", adapter)
365- require.Same(t, target.session, request.target.session, "%s adapter target session", adapter)
366- require.Same(t, target.tab, request.target.tab, "%s adapter target tab", adapter)
367- require.Same(t, target.pane, request.target.pane, "%s adapter target pane", adapter)
368- }
369- })
370- }
371- })
372- }
373-}
374-
375267 func TestPaneRearrangeNoChangeAdapterSideEffects(t *testing.T) {
376268 tests := []struct {
377269 name string
@@ -406,7 +298,7 @@ func TestPaneRearrangeNoChangeAdapterSideEffects(t *testing.T) {
406298 ac := &attachedClient{}
407299 ac.setSession(h.session)
408300 h.session.mu.Lock()
409- h.session.client = ac
301+ h.session.registerAttachmentLocked(ac)
410302 h.session.mu.Unlock()
411303 invalidations := make(chan renderInvalidation, 2)
412304 rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
D internal/usecase/daemon/pane_sync_owner_test.go +0 -175
@@ -1,175 +0,0 @@
1-package daemon
2-
3-import (
4- "sync"
5- "testing"
6- "time"
7-
8- "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/internal/usecase/layout"
10- "github.com/bnema/vev/pkg/renderer"
11- "github.com/stretchr/testify/require"
12-)
13-
14-type paneSyncOwnerFixture struct {
15- d *Daemon
16- pane *pane
17- source, destination *session
18- sourceTab, targetTab *tab
19- sourceCoordinator *renderCoordinator
20- targetCoordinator *renderCoordinator
21- sourceClock *coordinatorMockClock
22- targetClock *coordinatorMockClock
23-}
24-
25-func newPaneSyncOwnerFixture(t *testing.T) paneSyncOwnerFixture {
26- t.Helper()
27- size := domain.Size{Cols: 80, Rows: 23}
28- sourceTab := newTab(newQuietPTY(), size)
29- targetTab := newTab(newQuietPTY(), size)
30- source := &session{sessionCore: sessionCore{id: "sync-source", client: &attachedClient{}}, tabs: []*tab{sourceTab}, active: 0}
31- destination := &session{sessionCore: sessionCore{id: "sync-destination", client: &attachedClient{}}, tabs: []*tab{targetTab}, active: 0}
32- d := &Daemon{sessions: map[domain.SessionID]attachmentSession{source.id: source, destination.id: destination}}
33- p := sourceTab.focusedPane()
34- publishPaneOwner(p, source, sourceTab, 0)
35-
36- // Model the membership commit that accompanies owner publication. Keeping
37- // the source membership is harmless here; callbacks must route by owner.
38- targetTab.mu.Lock()
39- targetTab.panes[p.id] = p
40- targetTab.tree.Root = layout.NewLeaf(p.id)
41- targetTab.tree.Focus = p.id
42- targetTab.mu.Unlock()
43-
44- sourceClock := newCoordinatorMockClock(t, 8)
45- targetClock := newCoordinatorMockClock(t, 8)
46- sourceCoordinator := newRenderCoordinator(renderCoordinatorOptions{clock: sourceClock.clock})
47- targetCoordinator := newRenderCoordinator(renderCoordinatorOptions{clock: targetClock.clock})
48- source.installRenderCoordinator(sourceCoordinator)
49- destination.installRenderCoordinator(targetCoordinator)
50- return paneSyncOwnerFixture{
51- d: d, pane: p, source: source, destination: destination,
52- sourceTab: sourceTab, targetTab: targetTab,
53- sourceCoordinator: sourceCoordinator, targetCoordinator: targetCoordinator,
54- sourceClock: sourceClock, targetClock: targetClock,
55- }
56-}
57-
58-func (f paneSyncOwnerFixture) armSourceBatch(t *testing.T, active bool, force func()) (*coordinatorMockTimer, paneEffectLease) {
59- t.Helper()
60- f.pane.mu.Lock()
61- if active {
62- f.pane.screen.Write([]byte(renderer.SyncStartCSI))
63- }
64- generation := f.source.syncGen.Add(1)
65- f.pane.syncGen = generation
66- lease := f.pane.effectLeaseLocked()
67- f.pane.mu.Unlock()
68- f.sourceCoordinator.noteSyncBeginWithRenderability(f.pane, generation, lease.Current, force)
69- return awaitCoordinatorScheduledTimer(t, f.sourceClock), lease
70-}
71-
72-func TestMigratePaneSynchronizedOutputOwner(t *testing.T) {
73- t.Run("active batch moves to destination and only destination consumes sync end", func(t *testing.T) {
74- fixture := newPaneSyncOwnerFixture(t)
75- forceEntered := make(chan struct{})
76- var enterOnce sync.Once
77- var oldLease paneEffectLease
78- oldTimer, oldLease := fixture.armSourceBatch(t, true, func() {
79- enterOnce.Do(func() { close(forceEntered) })
80- fixture.pane.mu.Lock()
81- if fixture.pane.owner.Load() == oldLease.owner && fixture.pane.screen.SyncUpdateActive() {
82- fixture.pane.screen.ForceSyncEnd()
83- }
84- fixture.pane.mu.Unlock()
85- })
86- fixture.sourceCoordinator.mu.Lock()
87- oldWorkerDone := fixture.sourceCoordinator.syncBatches[fixture.pane].lane.token.done
88- fixture.sourceCoordinator.mu.Unlock()
89-
90- // Select the old watchdog while the pane parsing fence is held. The
91- // migration must detach it without waiting for its blocked callback.
92- fixture.pane.mu.Lock()
93- oldTimer.ch <- time.Time{}
94- <-forceEntered
95- oldOwner, newOwner := fixture.pane.publishOwnerLocked(fixture.destination, fixture.targetTab, 0)
96- cleanup := fixture.d.migratePaneSyncOwnerLocked(fixture.pane, oldOwner, newOwner)
97- oldTimer.mock.AssertNotCalled(t, "Stop")
98- fixture.sourceCoordinator.mu.Lock()
99- require.NotContains(t, fixture.sourceCoordinator.syncBatches, fixture.pane)
100- fixture.sourceCoordinator.mu.Unlock()
101- fixture.targetCoordinator.mu.Lock()
102- targetBatch := fixture.targetCoordinator.syncBatches[fixture.pane]
103- fixture.targetCoordinator.mu.Unlock()
104- require.NotNil(t, targetBatch)
105- require.Equal(t, fixture.destination.syncGen.Load(), targetBatch.generation)
106- fixture.pane.mu.Unlock()
107- cleanup.finish()
108- oldTimer.mock.AssertNumberOfCalls(t, "Stop", 1)
109- <-oldWorkerDone
110-
111- fixture.pane.mu.Lock()
112- require.True(t, fixture.pane.screen.SyncUpdateActive(), "selected old watchdog must become a stale no-op")
113- fixture.pane.mu.Unlock()
114-
115- fixture.d.processPanePTYData(fixture.pane, []byte(renderer.SyncEndCSI), false)
116- fixture.sourceCoordinator.mu.Lock()
117- require.NotContains(t, fixture.sourceCoordinator.syncBatches, fixture.pane)
118- fixture.sourceCoordinator.mu.Unlock()
119- fixture.targetCoordinator.mu.Lock()
120- require.NotContains(t, fixture.targetCoordinator.syncBatches, fixture.pane)
121- fixture.targetCoordinator.mu.Unlock()
122- fixture.pane.mu.Lock()
123- require.False(t, fixture.pane.screen.SyncUpdateActive())
124- fixture.pane.mu.Unlock()
125- })
126-
127- t.Run("destination callbacks reject a newer owner generation", func(t *testing.T) {
128- fixture := newPaneSyncOwnerFixture(t)
129- _, _ = fixture.armSourceBatch(t, true, nil)
130- fixture.pane.mu.Lock()
131- oldOwner, newOwner := fixture.pane.publishOwnerLocked(fixture.destination, fixture.targetTab, 0)
132- cleanup := fixture.d.migratePaneSyncOwnerLocked(fixture.pane, oldOwner, newOwner)
133- fixture.pane.mu.Unlock()
134- cleanup.finish()
135- _ = awaitCoordinatorScheduledTimer(t, fixture.targetClock)
136-
137- fixture.targetCoordinator.mu.Lock()
138- targetBatch := fixture.targetCoordinator.syncBatches[fixture.pane]
139- fixture.targetCoordinator.mu.Unlock()
140- require.NotNil(t, targetBatch)
141- require.True(t, targetBatch.renderable())
142-
143- fixture.pane.mu.Lock()
144- fixture.pane.publishOwnerLocked(fixture.source, fixture.sourceTab, 0)
145- fixture.pane.mu.Unlock()
146- require.False(t, targetBatch.renderable(), "renderability must validate the destination owner generation")
147- targetBatch.force()
148- fixture.pane.mu.Lock()
149- require.True(t, fixture.pane.screen.SyncUpdateActive(), "force-end must validate the destination owner generation")
150- fixture.pane.mu.Unlock()
151- fixture.targetCoordinator.noteSyncPaneRemoved(fixture.pane)
152- })
153-
154- t.Run("inactive screen removes source batch without creating destination state", func(t *testing.T) {
155- fixture := newPaneSyncOwnerFixture(t)
156- oldTimer, _ := fixture.armSourceBatch(t, false, nil)
157-
158- fixture.pane.mu.Lock()
159- oldOwner, newOwner := fixture.pane.publishOwnerLocked(fixture.destination, fixture.targetTab, 0)
160- cleanup := fixture.d.migratePaneSyncOwnerLocked(fixture.pane, oldOwner, newOwner)
161- require.Zero(t, fixture.pane.syncGen)
162- fixture.pane.mu.Unlock()
163- cleanup.finish()
164-
165- fixture.sourceCoordinator.mu.Lock()
166- require.NotContains(t, fixture.sourceCoordinator.syncBatches, fixture.pane)
167- fixture.sourceCoordinator.mu.Unlock()
168- fixture.targetCoordinator.mu.Lock()
169- require.NotContains(t, fixture.targetCoordinator.syncBatches, fixture.pane)
170- fixture.targetCoordinator.mu.Unlock()
171- require.Zero(t, fixture.destination.syncGen.Load())
172- require.Empty(t, fixture.targetClock.timers)
173- oldTimer.mock.AssertNumberOfCalls(t, "Stop", 1)
174- })
175-}
M internal/usecase/daemon/pane_titles.go +27 -16
@@ -137,32 +137,43 @@ func (d *Daemon) paneTitleFallback() string {
137137 return fallback
138138 }
139139
140-func (d *Daemon) refreshPaneTitle(sess *session, id layout.PaneID) string {
141- return d.refreshPaneTitleCached(sess, id, false)
140+func (d *Daemon) refreshPaneTitle(sess *session, id layout.PaneID, owningTab ...*tab) string {
141+ return d.refreshPaneTitleCached(sess, id, false, owningTab...)
142142 }
143143
144-func (d *Daemon) refreshPaneTitleOnFocus(sess *session, id layout.PaneID) string {
145- return d.refreshPaneTitleCached(sess, id, true)
144+func (d *Daemon) refreshPaneTitleOnFocus(sess *session, id layout.PaneID, owningTab ...*tab) string {
145+ return d.refreshPaneTitleCached(sess, id, true, owningTab...)
146146 }
147147
148-// refreshPaneTitleCached retains the ID lookup used by normal layout panes.
149-func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force bool) string {
148+// refreshPaneTitleCached resolves the pane in its owning tab. A caller with a
149+// current tab supplies it explicitly; direct callers refresh every matching
150+// tab because layout pane IDs are local to a tab and may repeat.
151+func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force bool, owningTab ...*tab) string {
150152 fallback := d.paneTitleFallback()
151153 if sess == nil {
152154 return fallback
153155 }
154- tb := sess.activeTab()
155- if tb == nil {
156- return fallback
156+ tabs := owningTab
157+ if len(tabs) == 0 || tabs[0] == nil {
158+ sess.mu.Lock()
159+ tabs = append([]*tab(nil), sess.tabs...)
160+ sess.mu.Unlock()
157161 }
158- tb.mu.Lock()
159- p := tb.panes[id]
160- tb.mu.Unlock()
161- if p == nil {
162- return fallback
162+ title := fallback
163+ for _, tb := range tabs {
164+ if tb == nil {
165+ continue
166+ }
167+ tb.mu.Lock()
168+ p := tb.panes[id]
169+ tb.mu.Unlock()
170+ if p == nil {
171+ continue
172+ }
173+ p.setDisplayFallback(fallback)
174+ title = d.refreshPaneDisplayTitle(sess, p, force)
163175 }
164- p.setDisplayFallback(fallback)
165- return d.refreshPaneDisplayTitle(sess, p, force)
176+ return title
166177 }
167178
168179 // refreshPaneDisplayTitle refreshes only the process-name portion of p's
M internal/usecase/daemon/pane_titles_test.go +24 -3
@@ -78,7 +78,7 @@ func TestRefreshPaneTitleUsesForegroundProcessComm(t *testing.T) {
7878 title := d.refreshPaneTitle(sess, "pane-1")
7979 require.Equal(t, "vim", title)
8080
81- p := sess.activeTab().focusedPane()
81+ p := testAttachmentTab(sess).focusedPane()
8282 p.mu.Lock()
8383 p.screen.Write([]byte("\x1b]2;project/main.go\a"))
8484 p.refreshTerminalTitleLocked()
@@ -89,6 +89,27 @@ func TestRefreshPaneTitleUsesForegroundProcessComm(t *testing.T) {
8989 require.Equal(t, "vim", p.title.processName)
9090 }
9191
92+func TestRefreshPaneTitleUsesProvidedOwningTab(t *testing.T) {
93+ secondPTY := portsmocks.NewMockPTY(t)
94+ secondPTY.EXPECT().ForegroundPgid().Return(222, nil).Once()
95+ _, sess, _, _ := newManualSessionWithPTYs(t, nil)
96+ second := newTabWithStableID("second-tab", "second-pane", secondPTY, domain.Size{Cols: 80, Rows: 23})
97+ sess.mu.Lock()
98+ sess.tabs = append(sess.tabs, second)
99+ sess.mu.Unlock()
100+ d := newTestDaemon(t, nil, stubClock{})
101+ d.procComm = func(pid int) (string, error) {
102+ require.Equal(t, 222, pid)
103+ return "vim", nil
104+ }
105+
106+ require.Equal(t, "vim", d.refreshPaneTitle(sess, "pane-1", second))
107+ secondPane := second.panes["pane-1"]
108+ secondPane.mu.Lock()
109+ require.Equal(t, "vim", secondPane.title.processName)
110+ secondPane.mu.Unlock()
111+}
112+
92113 func TestRefreshPaneTitleCachesByTTLAndRefreshesOnFocus(t *testing.T) {
93114 pty := portsmocks.NewMockPTY(t)
94115 pty.EXPECT().ForegroundPgid().Return(1234, nil).Twice()
@@ -142,7 +163,7 @@ func TestRefreshFloatingPaneTitleUsesShellFallbackForEmptyCommand(t *testing.T)
142163
143164 func TestRefreshPaneTitleUpdatesNormalPaneShellFallback(t *testing.T) {
144165 d, sess, _, _ := newManualSessionWithPTYs(t, nil)
145- p := sess.activeTab().focusedPane()
166+ p := testAttachmentTab(sess).focusedPane()
146167
147168 d.shell = "/usr/bin/fish"
148169 require.Equal(t, "fish", d.refreshPaneTitle(sess, "pane-1"))
@@ -170,7 +191,7 @@ func TestRefreshPaneTitleLookupFailureKeepsProcessNameEmpty(t *testing.T) {
170191 d := newTestDaemon(t, nil, clk)
171192 d.shell = "/usr/bin/fish"
172193 d.procComm = func(int) (string, error) { return "", errors.New("unused") }
173- p := sess.activeTab().focusedPane()
194+ p := testAttachmentTab(sess).focusedPane()
174195
175196 p.mu.Lock()
176197 p.screen.Write([]byte("\x1b]2;terminal\a"))
M internal/usecase/daemon/performance_benchmark_test.go +11 -11
@@ -1247,19 +1247,19 @@ func TestLivePaintAllocationBudget(t *testing.T) {
12471247 }
12481248 }
12491249
1250-// TestCopyEnterAllocationBudget protects the parent baseline plus 10%. Copy
1251-// rendering borrows sealed VT history rows; allocating a copy per viewport row
1252-// is a production regression even though the capture itself remains immutable.
1250+// TestCopyEnterAllocationBudget protects the attachment-aware baseline plus
1251+// 10%. Copy rendering borrows sealed VT history rows; allocating a copy per
1252+// viewport row is a production regression even though capture is immutable.
12531253 func TestCopyEnterAllocationBudget(t *testing.T) {
12541254 for _, tt := range []struct {
12551255 name string
12561256 tabs, panes, max int
12571257 }{
1258- {name: "1tab-1pane", tabs: 1, panes: 1, max: 38},
1259- {name: "1tab-4panes", tabs: 1, panes: 4, max: 43},
1260- {name: "4tabs-1pane", tabs: 4, panes: 1, max: 42},
1261- {name: "4tabs-4panes", tabs: 4, panes: 4, max: 48},
1262- {name: "8tabs-1pane", tabs: 8, panes: 1, max: 50},
1258+ {name: "1tab-1pane", tabs: 1, panes: 1, max: 55},
1259+ {name: "1tab-4panes", tabs: 1, panes: 4, max: 55},
1260+ {name: "4tabs-1pane", tabs: 4, panes: 1, max: 60},
1261+ {name: "4tabs-4panes", tabs: 4, panes: 4, max: 60},
1262+ {name: "8tabs-1pane", tabs: 8, panes: 1, max: 68},
12631263 } {
12641264 t.Run(tt.name, func(t *testing.T) {
12651265 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: 10_000})
@@ -1278,7 +1278,7 @@ func TestCopyEnterAllocationBudget(t *testing.T) {
12781278 }
12791279
12801280 allocs := testing.AllocsPerRun(20, run)
1281- require.LessOrEqual(t, allocs, float64(tt.max), "copy enter must stay within 10%% of the parent allocation baseline")
1281+ require.LessOrEqual(t, allocs, float64(tt.max), "copy enter must stay within 10% of the attachment-aware allocation baseline")
12821282 })
12831283 }
12841284 }
@@ -1488,7 +1488,7 @@ func newPerformanceFixtureWithCleanup(t testing.TB, config performanceConfig, re
14881488 compose: func() { fixture.renderCompositions++ },
14891489 emit: func() { fixture.renderEmissions++ },
14901490 }
1491- d.attachCoordinator(sess, nil, ac, true)
1491+ d.attachCoordinator(sess, ac, true)
14921492
14931493 // Prime the real renderer shadow before measurements. Subsequent paints use
14941494 // actual production diffs rather than the initial full frame.
@@ -1565,7 +1565,7 @@ func performanceFullWidthRow(width, tabIndex, row int) string {
15651565
15661566 func (f *performanceFixture) findActivePane() *pane {
15671567 f.t.Helper()
1568- tb := f.sess.activeTab()
1568+ tb := testAttachmentTab(f.sess)
15691569 require.NotNil(f.t, tb)
15701570 tb.mu.Lock()
15711571 defer tb.mu.Unlock()
M internal/usecase/daemon/picker.go +86 -71
@@ -52,7 +52,7 @@ func (d *Daemon) togglePickerSort() {
5252 // publishes its model, including an empty one; only move entry can fail for a
5353 // missing destination.
5454 func (d *Daemon) enterPicker(sess attachmentSession, ac *attachedClient) {
55- model := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
55+ model := d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
5656 d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
5757 }
5858
@@ -83,7 +83,7 @@ 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) ([]picker.SessionView, picker.SourceFilter) {
86+func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picker.SessionView, picker.SourceFilter) {
8787 d.mu.Lock()
8888 sessions := make([]attachmentSession, 0, len(d.sessions))
8989 proxies := make(map[domain.SessionID]*proxySession)
@@ -119,13 +119,23 @@ func (d *Daemon) pickerViews(cur attachmentSession) ([]picker.SessionView, picke
119119 live := make([]liveSnapshot, 0, len(sessions))
120120 var current picker.SourceFilter
121121 for _, entry := range sessions {
122+ if entry == cur && ac != nil {
123+ if local, ok := localSession(entry); ok {
124+ local.repairAttachmentView(ac)
125+ }
126+ }
122127 snap := entry.snapshotView(opts)
123128 if entry == cur {
124129 if proxy, ok := entry.(*proxySession); ok {
125130 key := proxy.key
126131 current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, RemoteKey: &key}
127- } else if snap.active >= 0 && snap.active < len(snap.tabs) {
128- current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.active].id}
132+ } else if _, ok := localSession(entry); ok && ac != nil {
133+ view := ac.viewSnapshot()
134+ if view.tabID != "" {
135+ current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: view.tabID}
136+ }
137+ } else if len(snap.tabs) > 0 {
138+ current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.defaultTab].id}
129139 }
130140 }
131141 live = append(live, liveSnapshot{entry: entry, view: snap})
@@ -201,8 +211,8 @@ func (d *Daemon) pickerViews(cur attachmentSession) ([]picker.SessionView, picke
201211 return views, current
202212 }
203213
204-func (d *Daemon) newPickerModel(cur attachmentSession, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model {
205- views, attachedCurrent := d.pickerViews(cur)
214+func (d *Daemon) newPickerModel(cur attachmentSession, ac *attachedClient, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model {
215+ views, attachedCurrent := d.pickerViews(cur, ac)
206216 if intent == pickerNavigate && current == (picker.SourceFilter{}) {
207217 current = attachedCurrent
208218 }
@@ -248,7 +258,7 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
248258 }
249259 }
250260
251-func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {
261+func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
252262 sess := ac.currentAttachmentSession()
253263 if sess == nil {
254264 return
@@ -293,7 +303,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
293303 return
294304 }
295305 if result.action == 'x' {
296- var effect *roleEffectTicket
306+ var effect *attachmentEffectTicket
297307 if len(effects) != 0 {
298308 effect = effects[0]
299309 }
@@ -301,7 +311,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
301311 var err error
302312 if effect != nil {
303313 effect.bindActionEnd(d, "picker-delete")
304- err = d.killPickerTargetForRole(target, effect.roleToken())
314+ err = d.killPickerTargetForAttachment(target, effect.connectionToken())
305315 } else {
306316 err = d.killPickerTarget(target)
307317 }
@@ -310,7 +320,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
310320 }
311321 }
312322 if effect != nil && effect.ended.Load() {
313- fresh, admitted := ac.beginRoleEffect(effect.token)
323+ fresh, admitted := ac.beginAttachmentEffect(effect.token)
314324 if !admitted {
315325 return
316326 }
@@ -349,7 +359,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
349359 }
350360 var err error
351361 if len(effects) != 0 && effects[0] != nil {
352- err = d.switchToTargetForRole(effects[0].roleToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select")
362+ err = d.switchToTargetForAttachment(effects[0].connectionToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select")
353363 } else if local, ok := localSession(sess); ok {
354364 d.closePicker(ac)
355365 err = d.switchToTarget(local, ac, target)
@@ -413,7 +423,7 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
413423 if targetSess == ac.currentAttachmentSession() {
414424 return
415425 }
416- rc := d.attachCoordinator(targetSess, nil, nil, false)
426+ rc := d.attachCoordinator(targetSess, nil, false)
417427 rc.subscribePreviewFor(ac, generation, func(renderWake) {
418428 if pickerPreviewCurrent(ac, targetSess, next, generation) {
419429 if owner := ac.currentAttachmentSession(); owner != nil {
@@ -489,9 +499,7 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
489499 continue
490500 }
491501 sess.mu.Lock()
492- if sess.client != nil {
493- clients = append(clients, sess.client)
494- }
502+ clients = append(clients, sess.snapshotAttachmentsLocked()...)
495503 sess.mu.Unlock()
496504 }
497505 d.mu.Unlock()
@@ -519,6 +527,9 @@ func (d *Daemon) closePicker(ac *attachedClient) {
519527 // generation guards terminal parked cleanup even when a refresh replaced the
520528 // model within that generation.
521529 func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, generation uint64) bool {
530+ if ac == nil || ac.overlays == nil {
531+ return false
532+ }
522533 ac.overlays.pickerMu.Lock()
523534 if model != nil && (ac.overlays.picker != model || ac.overlays.pickerGeneration != generation) || model == nil && generation != 0 && ac.overlays.pickerGeneration != generation {
524535 ac.overlays.pickerMu.Unlock()
@@ -573,12 +584,12 @@ type sessionHandoffGuard struct {
573584 closePicker bool
574585 }
575586
576-// switchActiveTargetForRole hands a frame-bound navigation request to the
587+// switchActiveTargetForAttachment hands a frame-bound navigation request to the
577588 // centralized transition. The transition releases admission at its freeze seam
578589 // and revalidates the exact initiating token before changing target focus or
579590 // attachment ownership.
580-func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target picker.Target) error {
581- return d.switchActiveTargetForRoleGuarded(token, target, sessionHandoffGuard{}, "jump-attention")
591+func (d *Daemon) switchActiveTargetForAttachment(token attachmentConnectionToken, target picker.Target) error {
592+ return d.switchActiveTargetForAttachmentGuarded(token, target, sessionHandoffGuard{}, "jump-attention")
582593 }
583594
584595 func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence {
@@ -603,15 +614,15 @@ func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence
603614 return fence
604615 }
605616
606-func (d *Daemon) switchActiveTargetForRoleGuarded(token attachmentRoleToken, target picker.Target, guard sessionHandoffGuard, action string) error {
607- if token.sess == nil || token.ac == nil || token.role != attachmentActive {
617+func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
618+ if token.sess == nil || token.ac == nil {
608619 return nil
609620 }
610621 d.mu.Lock()
611622 targetSess, ok := localSession(d.sessions[target.Session])
612623 d.mu.Unlock()
613624 if !ok {
614- if !token.activeCurrent() {
625+ if !token.attachmentCurrent() {
615626 return nil
616627 }
617628 d.invalidateRender(token.sess, token.ac, true, "picker.go")
@@ -623,21 +634,21 @@ func (d *Daemon) switchActiveTargetForRoleGuarded(token attachmentRoleToken, tar
623634
624635 transition, err := d.transitionAttachment(attachmentTransitionRequest{
625636 source: token.sess, target: targetSess, next: token.ac,
626- expectedRole: attachmentActive, targetRole: attachmentActive,
637+
627638 expectedTransport: token.transport, sourceToken: &token, action: action,
628639 expectedTargetLifecycle: pickerTargetLifecycleFence(target),
629640 activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,
630641 })
631642 if err != nil {
632- // Losing the exact source role is a benign stale action, not a notice for
643+ // Losing the exact source attachment is a benign stale action, not a notice for
633644 // whichever attachment replaced the initiator.
634- if !token.activeCurrent() {
645+ if !token.attachmentCurrent() {
635646 return nil
636647 }
637648 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
638649 }
639650 if guard.closePicker {
640- fresh, admitted := token.ac.beginRoleEffect(transition.published)
651+ fresh, admitted := token.ac.beginAttachmentEffect(transition.published)
641652 if admitted {
642653 d.closePicker(token.ac)
643654 fresh.End()
@@ -657,34 +668,34 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
657668 return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})
658669 }
659670
660-// switchToTargetForRole is the sole client-originated navigation entry. The
671+// switchToTargetForAttachment is the sole client-originated navigation entry. The
661672 // intent captures the exact initiating capability before admission is released;
662673 // every active, stopped, and same-session target then uses transitionAttachment
663674 // for frozen, atomic source-token preflight.
664-func (d *Daemon) switchToTargetForRole(token attachmentRoleToken, target picker.Target, guard sessionHandoffGuard, action string) error {
665- if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.effect == nil {
675+func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
676+ if token.sess == nil || token.ac == nil || token.effect == nil {
666677 return nil
667678 }
668679 token.effect.bindActionEnd(d, action)
669680 token.effect.End()
670681 if target.RemoteKey != nil {
671- return d.switchToRemoteTargetForRole(token, target, *target.RemoteKey, guard, action)
682+ return d.switchToRemoteTargetForAttachment(token, target, *target.RemoteKey, guard, action)
672683 }
673684 if token.sess.isProxy() && !target.Stopped {
674- return d.switchActiveTargetForRoleGuarded(token, target, guard, action)
685+ return d.switchActiveTargetForAttachmentGuarded(token, target, guard, action)
675686 }
676687 sess, ok := localSession(token.sess)
677688 if !ok {
678689 return errAttachmentTransition
679690 }
680- return d.switchToTargetGuardedForRole(sess, token.ac, target, guard, &token, action)
691+ return d.switchToTargetGuardedForAttachment(sess, token.ac, target, guard, &token, action)
681692 }
682693
683-// switchToRemoteTargetForRole performs dialing after the initiating effect has
684-// ended and without role or architecture locks. transitionAttachment then
685-// freezes and revalidates the exact source role and exact structured-key proxy
694+// switchToRemoteTargetForAttachment performs dialing after the initiating effect has
695+// ended and without attachment-effect or architecture locks. transitionAttachment then
696+// freezes and revalidates the exact source attachment and exact structured-key proxy
686697 // before publishing ownership.
687-func (d *Daemon) switchToRemoteTargetForRole(token attachmentRoleToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, action string) error {
698+func (d *Daemon) switchToRemoteTargetForAttachment(token attachmentConnectionToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, action string) error {
688699 if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" {
689700 return errAttachmentTransition
690701 }
@@ -702,7 +713,7 @@ func (d *Daemon) switchToRemoteTargetForRole(token attachmentRoleToken, target p
702713 ownsCandidate := !alreadyPublished && !constructionInFlight
703714 proxy, err := d.openProxySession(ctx, key, token.ac.size)
704715 if err != nil {
705- if !token.activeCurrent() {
716+ if !token.attachmentCurrent() {
706717 return errAttachmentTransition
707718 }
708719 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't connect to that remote session", err)
@@ -716,9 +727,9 @@ func (d *Daemon) switchToRemoteTargetForRole(token attachmentRoleToken, target p
716727
717728 transition, err := d.transitionAttachment(attachmentTransitionRequest{
718729 source: token.sess, target: proxy, next: token.ac,
719- expectedRole: attachmentActive, targetRole: attachmentActive,
730+
720731 expectedTransport: token.transport, sourceToken: &token, action: action,
721- preserveRole: proxy == token.sess, ready: true,
732+ preserveAttachment: proxy == token.sess, ready: true,
722733 })
723734 if err != nil {
724735 if ownsCandidate {
@@ -727,7 +738,7 @@ func (d *Daemon) switchToRemoteTargetForRole(token attachmentRoleToken, target p
727738 return errAttachmentTransition
728739 }
729740 if guard.closePicker {
730- fresh, admitted := token.ac.beginRoleEffect(transition.published)
741+ fresh, admitted := token.ac.beginAttachmentEffect(transition.published)
731742 if admitted {
732743 d.closePicker(token.ac)
733744 fresh.End()
@@ -759,7 +770,7 @@ func (d *Daemon) discardUnattachedProxyGeneration(proxy *proxySession, generatio
759770 return false
760771 }
761772 proxy.sessionCore.mu.Lock()
762- if proxy.client != nil {
773+ if len(proxy.attachments) != 0 {
763774 proxy.sessionCore.mu.Unlock()
764775 d.mu.Unlock()
765776 return false
@@ -810,17 +821,17 @@ func (d *Daemon) discardUnattachedProxyGeneration(proxy *proxySession, generatio
810821
811822 // switchToTargetGuarded is retained for daemon-internal and headless callers.
812823 func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error {
813- return d.switchToTargetGuardedForRole(from, ac, target, guard, nil, "")
824+ return d.switchToTargetGuardedForAttachment(from, ac, target, guard, nil, "")
814825 }
815826
816-func (d *Daemon) switchToTargetGuardedForRole(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentRoleToken, action string) error {
827+func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentConnectionToken, action string) error {
817828 if target.Name != "" {
818829 ctx := d.serveCtx
819830 if ctx == nil {
820831 ctx = context.Background()
821832 }
822833 if err := d.waitForTargetRestore(ctx, target.Name); err != nil {
823- if sourceToken != nil && !sourceToken.activeCurrent() {
834+ if sourceToken != nil && !sourceToken.attachmentCurrent() {
824835 return errAttachmentTransition
825836 }
826837 d.invalidateRender(from, ac, true, "picker.go")
@@ -854,7 +865,7 @@ func (d *Daemon) switchToTargetGuardedForRole(from *session, ac *attachedClient,
854865 d.mu.Unlock()
855866
856867 if !switched {
857- if sourceToken != nil && !sourceToken.activeCurrent() {
868+ if sourceToken != nil && !sourceToken.attachmentCurrent() {
858869 return errAttachmentTransition
859870 }
860871 if guard.expectedSource != nil {
@@ -865,7 +876,7 @@ func (d *Daemon) switchToTargetGuardedForRole(from *session, ac *attachedClient,
865876 }
866877 if guard.closePicker {
867878 if sourceToken != nil {
868- fresh, admitted := ac.beginRoleEffect(transition.published)
879+ fresh, admitted := ac.beginAttachmentEffect(transition.published)
869880 if admitted {
870881 d.closePicker(ac)
871882 fresh.End()
@@ -877,14 +888,14 @@ func (d *Daemon) switchToTargetGuardedForRole(from *session, ac *attachedClient,
877888 d.deferAttachmentTransitionCleanups(transition)
878889 if targetSess == from {
879890 if sourceToken != nil {
880- fresh, admitted := ac.beginRoleEffect(transition.published)
891+ fresh, admitted := ac.beginAttachmentEffect(transition.published)
881892 if admitted {
882- d.activateTab(from, from.activeTab())
893+ d.activateTabAfterResizeForLease(from, from.tabForAttachment(ac), false, ac, transition.published.lease)
883894 d.invalidateRender(from, ac, true, "picker.go")
884895 fresh.End()
885896 }
886897 } else {
887- d.activateTab(from, from.activeTab())
898+ d.activateTabAfterResizeForLease(from, from.tabForAttachment(ac), false, ac, nil)
888899 d.invalidateRender(from, ac, true, "picker.go")
889900 }
890901 } else {
@@ -915,7 +926,7 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi
915926
916927 // switchToActiveTargetLocked commits an active target handoff through the
917928 // centralized attachment transition. Caller holds d.mu.
918-func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentRoleToken, action string) (*session, attachmentTransitionResult, bool) {
929+func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentConnectionToken, action string) (*session, attachmentTransitionResult, bool) {
919930 if d.sessions[target.Session] != targetSess {
920931 return nil, attachmentTransitionResult{}, false
921932 }
@@ -923,30 +934,35 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
923934 if sourceToken == nil {
924935 targetSess.mu.Lock()
925936 defer targetSess.mu.Unlock()
926- if targetSess.client != ac || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {
937+ _, attached := targetSess.attachments[ac]
938+ if !attached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {
939+ return nil, attachmentTransitionResult{}, false
940+ }
941+ if !targetSess.activateAttachmentViewLocked(ac, target.TabIndex) {
927942 return nil, attachmentTransitionResult{}, false
928943 }
929- targetSess.active = target.TabIndex
930944 return targetSess, attachmentTransitionResult{}, true
931945 }
932946 d.mu.Unlock()
933947 transition, err := d.transitionAttachment(attachmentTransitionRequest{
934948 source: from, target: targetSess, next: ac,
935- expectedRole: attachmentActive, targetRole: attachmentActive,
949+
936950 expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,
937- activateTargetTab: true, targetTabIndex: target.TabIndex, preserveRole: true,
951+ activateTargetTab: true, targetTabIndex: target.TabIndex, preserveAttachment: true,
938952 })
939953 d.mu.Lock()
940954 return targetSess, transition, err == nil
941955 }
942956
943957 unlock := lockAttachmentSessions(from, targetSess)
944- if from.client != ac || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) {
958+ _, sourceAttached := from.attachments[ac]
959+ if !sourceAttached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) {
945960 unlock()
946961 return nil, attachmentTransitionResult{}, false
947962 }
948963 if guard.expectedSource != nil {
949- if from.active < 0 || from.active >= len(from.tabs) || from.tabs[from.active] != guard.expectedSource {
964+ view := ac.viewSnapshot()
965+ if domain.TabStableID(guard.expectedSource.stableID) != view.tabID {
950966 unlock()
951967 return nil, attachmentTransitionResult{}, false
952968 }
@@ -968,11 +984,10 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
968984 expectedTransport = sourceToken.transport
969985 }
970986 transition, err := d.transitionAttachment(attachmentTransitionRequest{
971- source: from,
972- target: targetSess,
973- next: ac,
974- expectedRole: attachmentActive,
975- targetRole: attachmentActive,
987+ source: from,
988+ target: targetSess,
989+ next: ac,
990+
976991 expectedTransport: expectedTransport,
977992 sourceToken: sourceToken,
978993 action: action,
@@ -994,7 +1009,7 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
9941009 // resumeStoppedAndSwitchLocked creates the stopped representation and commits
9951010 // the handoff while d.mu is held. Creation failure leaves the source client
9961011 // and stopped record untouched.
997-func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession, sourceToken *attachmentRoleToken, guard sessionHandoffGuard, action string) (*session, attachmentTransitionResult, bool, error) {
1012+func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession, sourceToken *attachmentConnectionToken, guard sessionHandoffGuard, action string) (*session, attachmentTransitionResult, bool, error) {
9981013 if stopped.record.Name != "" && stopped.state == ports.SessionBroken {
9991014 return nil, attachmentTransitionResult{}, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name}
10001015 }
@@ -1003,7 +1018,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
10031018 var targetSess *session
10041019 d.mu.Unlock()
10051020 transition, err := d.transitionAttachment(attachmentTransitionRequest{
1006- source: from, next: ac, expectedRole: attachmentActive, targetRole: attachmentActive,
1021+ source: from, next: ac,
10071022 expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,
10081023 expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true,
10091024 createTargetLocked: func() (*session, error) {
@@ -1034,7 +1049,8 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
10341049 }
10351050
10361051 from.mu.Lock()
1037- if from.client != ac {
1052+ _, attached := from.attachments[ac]
1053+ if !attached {
10381054 from.mu.Unlock()
10391055 return nil, attachmentTransitionResult{}, false, nil
10401056 }
@@ -1051,7 +1067,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
10511067 d.mu.Unlock()
10521068 transition, err := d.transitionAttachment(attachmentTransitionRequest{
10531069 source: from, target: targetSess, next: ac,
1054- expectedRole: attachmentActive, targetRole: attachmentActive,
1070+
10551071 expectedTransport: ac.transportSnapshot(), ready: true,
10561072 })
10571073 d.mu.Lock()
@@ -1069,15 +1085,14 @@ func targetMatchesLifecycle(target picker.Target, name string, createdAt int64)
10691085
10701086 // stealClientForTarget is retained for direct-ID callers and tests. Named
10711087 // targets must use switchToTarget so resolution and commit share d.mu.
1072-func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {
1088+func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) {
10731089 d.mu.Lock()
10741090 _, transition, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{}, nil, "")
10751091 d.mu.Unlock()
10761092 if !switched {
1077- return nil
1093+ return
10781094 }
10791095 d.deferAttachmentTransitionCleanups(transition)
1080- return transition.displaced.ac
10811096 }
10821097
10831098 // resumeStoppedAndSwitch is retained for direct callers and tests. It resolves
@@ -1102,7 +1117,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
11021117 return true
11031118 }
11041119
1105-func (d *Daemon) killPickerTargetForRole(target picker.Target, token attachmentRoleToken) error {
1120+func (d *Daemon) killPickerTargetForAttachment(target picker.Target, token attachmentConnectionToken) error {
11061121 if token.ac == nil {
11071122 return d.killPickerTarget(target)
11081123 }
@@ -1113,9 +1128,9 @@ func (d *Daemon) killPickerTargetForRole(target picker.Target, token attachmentR
11131128 if target.Stopped {
11141129 token.effect.bindActionEnd(d, "picker-delete")
11151130 token.effect.End()
1116- frozen := freezeRoleEffectGates(token.ac)
1131+ frozen := freezeAttachmentEffectGates(token.ac)
11171132 defer frozen.unfreeze()
1118- if !d.sourceRoleTokenCurrentFrozen(token) {
1133+ if !d.sourceAttachmentTokenCurrentFrozen(token) {
11191134 return nil
11201135 }
11211136 return d.killPickerTarget(target)
@@ -1126,7 +1141,7 @@ func (d *Daemon) killPickerTargetForRole(target picker.Target, token attachmentR
11261141 if !ok {
11271142 return nil
11281143 }
1129- return d.killSessionForRole(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete")
1144+ return d.killSessionForAttachment(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete")
11301145 }
11311146
11321147 func (d *Daemon) killPickerTarget(target picker.Target) error {
M internal/usecase/daemon/picker_test.go +38 -191
@@ -14,7 +14,6 @@ import (
1414 "github.com/bnema/vev/internal/persist"
1515 "github.com/bnema/vev/internal/ports"
1616 portsmocks "github.com/bnema/vev/internal/ports/mocks"
17- "github.com/bnema/vev/internal/usecase/keys"
1817 "github.com/bnema/vev/internal/usecase/layout"
1918 "github.com/bnema/vev/internal/usecase/picker"
2019 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
@@ -79,7 +78,7 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
7978 d.sessions[current.id] = current
8079 d.sessions[ringing.id] = ringing
8180
82- views, currentSelection := d.pickerViews(current)
81+ views, currentSelection := d.pickerViews(current, nil)
8382
8483 require.Equal(t, picker.SourceFilter{Session: current.id}, currentSelection)
8584 require.Len(t, views, 2)
@@ -104,7 +103,7 @@ func TestPickerViewsIncludesEphemeralSessions(t *testing.T) {
104103 d.sessions[current.id] = current
105104 d.sessions[ephemeral.id] = ephemeral
106105
107- views, _ := d.pickerViews(current)
106+ views, _ := d.pickerViews(current, nil)
108107
109108 require.Len(t, views, 2)
110109 var ephemeralView picker.SessionView
@@ -132,7 +131,7 @@ func TestPickerViewsOrdersByMRUWithEphemeralInterleaved(t *testing.T) {
132131 d.stopped["halted-old"] = stoppedSession{name: "halted-old", createdAt: 10, lastUsedSeq: 1}
133132 d.stopped["halted-new"] = stoppedSession{name: "halted-new", createdAt: 11, lastUsedSeq: 2}
134133
135- views, _ := d.pickerViews(recent)
134+ views, _ := d.pickerViews(recent, nil)
136135
137136 names := make([]string, 0, len(views))
138137 for _, v := range views {
@@ -152,7 +151,7 @@ func TestPickerViewsMRUTieBreaksAlphabetically(t *testing.T) {
152151 d.sessions[b.id] = b
153152 d.sessions[a.id] = a
154153
155- views, _ := d.pickerViews(a)
154+ views, _ := d.pickerViews(a, nil)
156155
157156 require.Equal(t, "alpha", views[0].Name)
158157 require.Equal(t, "bravo", views[1].Name)
@@ -172,7 +171,7 @@ func TestPickerViewsGroupedModePutsNamedBeforeEphemeral(t *testing.T) {
172171 d.sessions[named2.id] = named2
173172 d.stopped["halted"] = stoppedSession{name: "halted", createdAt: 9, lastUsedSeq: 9}
174173
175- views, _ := d.pickerViews(named)
174+ views, _ := d.pickerViews(named, nil)
176175
177176 names := make([]string, 0, len(views))
178177 for _, v := range views {
@@ -200,7 +199,7 @@ func TestPickerSortToggleFlipsModeAndKeepsSelection(t *testing.T) {
200199 d, sess, ac, sends, releases := newManualTabSession(t, 2)
201200 defer releaseAll(releases)
202201 sess.mu.Lock()
203- sess.client = ac
202+ sess.registerAttachmentLocked(ac)
204203 secondTabID := domain.TabStableID(sess.tabs[1].stableID)
205204 sess.mu.Unlock()
206205 sess.mruAt.Store(1)
@@ -283,7 +282,7 @@ func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) {
283282 d.sessions[active.id] = active
284283 d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 24}
285284
286- views, _ := d.pickerViews(active)
285+ views, _ := d.pickerViews(active, nil)
287286
288287 require.Len(t, views, 2)
289288 require.NotNil(t, views[0].ExpectedCreatedAt)
@@ -294,7 +293,7 @@ func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) {
294293
295294 func TestPickerCanonicalViewsLeaveMoveFilteringToModel(t *testing.T) {
296295 d := newTestDaemon(t, nil, stubClock{})
297- source := &session{sessionCore: sessionCore{id: "source", name: "source", incarnation: domain.IncarnationID{1}}, active: 0,
296+ source := &session{sessionCore: sessionCore{id: "source", name: "source", incarnation: domain.IncarnationID{1}},
298297 tabs: []*tab{
299298 {stableID: "source-tab", panes: map[layout.PaneID]*pane{}},
300299 {stableID: "sibling-tab", panes: map[layout.PaneID]*pane{}},
@@ -310,7 +309,7 @@ func TestPickerCanonicalViewsLeaveMoveFilteringToModel(t *testing.T) {
310309 PaneID: "source-pane",
311310 }
312311
313- views, current := d.pickerViews(source)
312+ views, current := d.pickerViews(source, nil)
314313 require.Len(t, views, 3, "canonical capture includes active and stopped lifecycles")
315314 require.Equal(t, picker.SourceFilter{Session: source.id, Incarnation: source.incarnation, TabID: "source-tab"}, current)
316315 var sourceView picker.SessionView
@@ -322,14 +321,14 @@ func TestPickerCanonicalViewsLeaveMoveFilteringToModel(t *testing.T) {
322321 require.Equal(t, []picker.TabEntry{{TabID: "source-tab", Name: "1"}, {TabID: "sibling-tab", Name: "2"}}, sourceView.Tabs,
323322 "daemon capture does not remove the move source")
324323
325- paneModel := d.newPickerModel(source, pickerMovePane, sourceLocator, picker.SourceFilter{
324+ paneModel := d.newPickerModel(source, nil, pickerMovePane, sourceLocator, picker.SourceFilter{
326325 Session: source.id, Incarnation: source.incarnation, TabID: "sibling-tab",
327326 })
328327 paneTarget, ok := paneModel.Selected()
329328 require.True(t, ok)
330329 require.Equal(t, domain.TabStableID("sibling-tab"), paneTarget.TabID)
331330
332- tabModel := d.newPickerModel(source, pickerMoveTab, sourceLocator, picker.SourceFilter{})
331+ tabModel := d.newPickerModel(source, nil, pickerMoveTab, sourceLocator, picker.SourceFilter{})
333332 tabTarget, ok := tabModel.Selected()
334333 require.True(t, ok)
335334 require.Equal(t, destination.id, tabTarget.Session)
@@ -445,7 +444,7 @@ func TestPickerMoveTabPreviewsDestinationActiveTabWithoutActivatingIt(t *testing
445444 sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1}
446445 delete(d.sessions, domain.SessionID("manual"))
447446 d.sessions[sourceSession.id] = sourceSession
448- target := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, active: 1,
447+ target := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true},
449448 ctx: sourceSession.ctx, cancel: func() {},
450449 tabs: []*tab{
451450 newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel),
@@ -463,7 +462,7 @@ func TestPickerMoveTabPreviewsDestinationActiveTabWithoutActivatingIt(t *testing
463462
464463 ac.overlays.pickerMu.Lock()
465464 selected, ok := ac.overlays.picker.Selected()
466- require.Same(t, target.tabs[1], ac.overlays.pickerPreview)
465+ require.Same(t, target.tabs[0], ac.overlays.pickerPreview)
467466 ac.overlays.pickerMu.Unlock()
468467 require.True(t, ok)
469468 require.Equal(t, target.id, selected.Session)
@@ -471,7 +470,7 @@ func TestPickerMoveTabPreviewsDestinationActiveTabWithoutActivatingIt(t *testing
471470 require.Equal(t, target.name, selected.Name)
472471 require.Equal(t, -1, selected.TabIndex)
473472 require.Nil(t, selected.ExpectedCreatedAt)
474- require.Equal(t, 1, activeTabIndex(target), "preview must not mutate destination focus")
473+ require.Equal(t, 0, testAttachmentTabIndex(target), "preview must not mutate destination view")
475474 d.closePicker(ac)
476475 }
477476
@@ -575,7 +574,7 @@ func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) {
575574 original := &session{sessionCore: sessionCore{id: "ephemeral-1", name: "2", ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
576575 d.sessions[original.id] = original
577576
578- views, _ := d.pickerViews(current)
577+ views, _ := d.pickerViews(current, nil)
579578 var originalView picker.SessionView
580579 for _, view := range views {
581580 if view.ID == original.id {
@@ -595,18 +594,6 @@ func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) {
595594 require.Same(t, current, ac.currentSession())
596595 }
597596
598-func TestSameSessionSwitchRejectsReplacedClient(t *testing.T) {
599- p1, release1 := newBlockingPTY(t)
600- p2, release2 := newBlockingPTY(t)
601- defer release1()
602- defer release2()
603- d, current, ac, _ := newManualSessionWithPTYs(t, p1, p2)
604- current.client = &attachedClient{}
605-
606- require.Error(t, d.switchToTarget(current, ac, picker.Target{Session: current.id, TabIndex: 1}))
607- require.Equal(t, 0, current.active)
608-}
609-
610597 func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {
611598 p1, releasePTY1 := newBlockingPTY(t)
612599 p2, releasePTY2 := newBlockingPTY(t)
@@ -626,7 +613,7 @@ func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {
626613 sess.tabs[1].attention = true
627614 sess.mu.Unlock()
628615
629- views, _ := d.pickerViews(sess)
616+ views, _ := d.pickerViews(sess, nil)
630617
631618 require.Len(t, views, 1)
632619 require.Equal(t, []picker.TabEntry{
@@ -653,7 +640,7 @@ func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) {
653640
654641 d.ApplyConfig(domain.Config{Tabs: domain.TabsConfig{TerminalTitle: false}})
655642
656- views, _ := d.pickerViews(sess)
643+ views, _ := d.pickerViews(sess, nil)
657644
658645 require.Len(t, views, 1)
659646 require.Equal(t, []picker.TabEntry{
@@ -829,7 +816,7 @@ func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
829816 func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
830817 d, sess, ac, sends, releases := newManualTabSession(t, 2)
831818 sess.mu.Lock()
832- sess.client = ac
819+ sess.registerAttachmentLocked(ac)
833820 sess.mu.Unlock()
834821 d.ptys = newBlockingOpenFactory(t, d)
835822 defer func() {
@@ -844,8 +831,8 @@ func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
844831 awaitFrame(t, sends, ports.MsgOutput)
845832 d.handleInput(sess, ac, []byte("\r"))
846833
847- require.Equal(t, 1, activeTabIndex(sess))
848- requireFloatingInitialized(t, sess.activeTab())
834+ require.Equal(t, 1, testAttachmentTabIndex(sess))
835+ requireFloatingInitialized(t, testAttachmentTab(sess))
849836 awaitFrame(t, sends, ports.MsgOutput)
850837 d.enterPicker(sess, ac)
851838 awaitFrame(t, sends, ports.MsgOutput)
@@ -886,7 +873,7 @@ func TestPickerSplitArrowNavigatesWithoutExiting(t *testing.T) {
886873 }
887874 require.True(t, ac.overlays.pickerActive())
888875 d.handleInput(sess, ac, []byte("\r"))
889- require.Equal(t, tc.wantActive, activeTabIndex(sess))
876+ require.Equal(t, tc.wantActive, testAttachmentTabIndex(sess))
890877 })
891878 }
892879 }
@@ -946,95 +933,6 @@ func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) {
946933 require.Contains(t, frame, "TARGET", "first target reset must immediately show clean target VT state")
947934 }
948935
949-func TestPickerCrossSessionSwitchSnatchesExistingClient(t *testing.T) {
950- p1, releasePTY1 := newBlockingPTY(t)
951- p2, releasePTY2 := newBlockingPTY(t)
952- defer releasePTY1()
953- defer releasePTY2()
954- d := newTestDaemon(t, nil, stubClock{})
955- tr1, sends1 := newCapturingTransport(t)
956- tr2, sends2 := newCapturingTransport(t)
957- ac1 := &attachedClient{tr: tr1, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
958- ac1.initOverlays()
959- ac2 := &attachedClient{tr: tr2, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
960- ac2.initOverlays()
961- sctx1, cancel1 := context.WithCancel(d.serveCtx)
962- sctx2, cancel2 := context.WithCancel(d.serveCtx)
963- defer cancel1()
964- defer cancel2()
965- sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", ephemeral: true, client: ac1}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}}
966- sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}
967- ac1.setSession(sess1)
968- ac2.setSession(sess2)
969- ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}, nil)
970- ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2}, nil)
971- d.sessions[sess1.id] = sess1
972- d.sessions[sess2.id] = sess2
973-
974- d.enterPicker(sess1, ac1)
975- awaitFrame(t, sends1, ports.MsgOutput)
976- d.handleInput(sess1, ac1, []byte("j"))
977- awaitFrame(t, sends1, ports.MsgOutput)
978- d.handleInput(sess1, ac1, []byte("\r"))
979-
980- require.Same(t, sess2, ac1.currentSession())
981- require.Same(t, ac1, sess2.client)
982- require.Nil(t, sess1.client)
983- require.Equal(t, 2, sessionCount(d), "old ephemeral session remains alive after picker switch")
984-
985- d.attachmentCleanupWg.Wait()
986- var displacedFrames []ports.Frame
987- for len(sends2) > 0 {
988- displacedFrames = append(displacedFrames, <-sends2)
989- }
990- require.Len(t, displacedFrames, 1, "healthy displaced client receives only its snatched panel")
991- require.Equal(t, ports.MsgOutput, displacedFrames[0].Type)
992- displacedOutput, err := ports.UnmarshalOutput(displacedFrames[0].Payload)
993- require.NoError(t, err)
994- require.Contains(t, string(displacedOutput.Data), "Session snatched")
995- require.Same(t, sess2, ac2.currentSession())
996- require.Equal(t, attachmentSnatched, sess2.attachmentRole(ac2))
997-
998- movingFrame := awaitFrame(t, sends1, ports.MsgOutput)
999- movingOutput, err := ports.UnmarshalOutput(movingFrame.Payload)
1000- require.NoError(t, err)
1001- require.Zero(t, movingOutput.BaseStateNum, "moving client must reset before painting the destination")
1002-}
1003-
1004-func TestPickerDisplacementCancelsSupersededResize(t *testing.T) {
1005- p1, releasePTY1 := newBlockingPTY(t)
1006- p2, releasePTY2 := newBlockingPTY(t)
1007- defer releasePTY1()
1008- defer releasePTY2()
1009- clock := &signalClock{timers: make(chan *signalTimer, 2)}
1010- d := newTestDaemon(t, nil, clock)
1011- tr1, _ := newCapturingTransport(t)
1012- tr2, _ := newCapturingTransport(t)
1013- ac1 := &attachedClient{tr: tr1, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
1014- ac2 := &attachedClient{tr: tr2, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
1015- sctx1, cancel1 := context.WithCancel(d.serveCtx)
1016- sctx2, cancel2 := context.WithCancel(d.serveCtx)
1017- defer cancel1()
1018- defer cancel2()
1019- sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", client: ac1}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}}
1020- sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}
1021- ac1.setSession(sess1)
1022- ac2.setSession(sess2)
1023- d.sessions[sess1.id], d.sessions[sess2.id] = sess1, sess2
1024-
1025- d.resize(sess2, ac2, domain.Size{Cols: 100, Rows: 24})
1026- timer := <-clock.timers
1027- done := captureResizeCallbackDone(t, sess2.renderCoordinator())
1028- before := sess2.renderCoordinator().resizeSnapshot().epoch
1029-
1030- require.Same(t, ac2, d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id}))
1031- // The target coordinator invalidates its scheduled epoch during handoff;
1032- // no attachment-local timer survives the transfer.
1033- require.Equal(t, before, sess2.renderCoordinator().resizeSnapshot().epoch)
1034- timer.ch <- time.Time{}
1035- awaitTestCompletion(t, done, "superseded resize callback did not complete")
1036-}
1037-
1038936 func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
1039937 p1, releasePTY1 := newBlockingPTY(t)
1040938 p2, releasePTY2 := newBlockingPTY(t)
@@ -1051,8 +949,8 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
1051949 sctx2, cancel2 := context.WithCancel(d.serveCtx)
1052950 defer cancel1()
1053951 defer cancel2()
1054- sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", ephemeral: true, client: ac1}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}}
1055- sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}
952+ sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", ephemeral: true, attachments: map[*attachedClient]struct{}{ac1: {}}}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}}
953+ sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", attachments: map[*attachedClient]struct{}{ac2: {}}}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}
1056954 ac1.setSession(sess1)
1057955 ac2.setSession(sess2)
1058956 d.sessions[sess1.id] = sess1
@@ -1102,7 +1000,7 @@ func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing
11021000 sctx2, cancel2 := context.WithCancel(d.serveCtx)
11031001 defer cancel1()
11041002 defer cancel2()
1105- sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", client: ac}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}}
1003+ sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}}
11061004 sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta"}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}
11071005 ac.setSession(sess1)
11081006 d.sessions[sess1.id] = sess1
@@ -1120,11 +1018,10 @@ func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing
11201018 switchErr := make(chan error, 1)
11211019 go func() {
11221020 result, err := d.transitionAttachment(attachmentTransitionRequest{
1123- source: sess1,
1124- target: sess2,
1125- next: ac,
1126- expectedRole: attachmentActive,
1127- targetRole: attachmentActive,
1021+ source: sess1,
1022+ target: sess2,
1023+ next: ac,
1024+
11281025 expectedTransport: ac.transportSnapshot(),
11291026 ready: true,
11301027 })
@@ -1139,7 +1036,7 @@ func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing
11391036 case <-time.After(2 * time.Second):
11401037 t.Fatal("session switch waited for in-flight transport send")
11411038 }
1142- require.True(t, result.published.activeCurrent())
1039+ require.True(t, result.published.attachmentCurrent())
11431040 select {
11441041 case <-handoffAtSendMu:
11451042 t.Fatal("output rebase ran during architecture publication")
@@ -1153,56 +1050,6 @@ func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing
11531050 }
11541051 }
11551052
1156-func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
1157- p1, releasePTY1 := newBlockingPTY(t)
1158- p2, releasePTY2 := newBlockingPTY(t)
1159- p3, releasePTY3 := newBlockingPTY(t)
1160- defer releasePTY1()
1161- defer releasePTY2()
1162- defer releasePTY3()
1163- var openedEnv []string
1164- var openedCommand string
1165- f := portsmocks.NewMockPTYFactory(t)
1166- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1167- func(_ context.Context, command string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) {
1168- if sz != (domain.Size{Cols: 80, Rows: 22}) {
1169- return newQuietPTY(), nil
1170- }
1171- openedCommand = command
1172- openedEnv = append([]string(nil), env...)
1173- return p3, nil
1174- },
1175- ).Maybe()
1176- d := newTestDaemon(t, f, stubClock{})
1177- tr1, _ := newCapturingTransport(t)
1178- tr2, _ := newCapturingTransport(t)
1179- ac1 := &attachedClient{tr: tr1, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
1180- ac2 := &attachedClient{tr: tr2, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
1181- sctx1, cancel1 := context.WithCancel(d.serveCtx)
1182- sctx2, cancel2 := context.WithCancel(d.serveCtx)
1183- defer cancel1()
1184- defer cancel2()
1185- sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", client: ac1}, cwd: t.TempDir(), ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, terminal: terminalEnv{}, env: []string{"SECRET=from-active-session", "SHELL=/usr/bin/fish", "PAIR=a=b"}}
1186- sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, cwd: t.TempDir(), ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, terminal: terminalEnv{TrueColor: true}, env: []string{"STALE=destination-only", "SHELL=/bin/stale", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=stale", "VEV=stale"}}
1187- ac1.setSession(sess1)
1188- ac2.setSession(sess2)
1189- d.sessions[sess1.id] = sess1
1190- d.sessions[sess2.id] = sess2
1191-
1192- old := d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id})
1193-
1194- require.Same(t, ac2, old)
1195- require.False(t, sess2.terminal.TrueColor)
1196- require.NoError(t, d.createTab(sess2, domain.Size{Cols: 80, Rows: 24}))
1197- sess2.mu.Lock()
1198- futureTabID := sess2.tabs[1].stableID
1199- futurePaneID := sess2.tabs[1].focusedPane().stableID
1200- sess2.mu.Unlock()
1201- require.Equal(t, "/usr/bin/fish", openedCommand)
1202- require.Equal(t, []string{"SECRET=from-active-session", "SHELL=/usr/bin/fish", "PAIR=a=b", "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=beta,tab=" + futureTabID + ",pane=" + futurePaneID}, openedEnv)
1203- require.NotContains(t, openedEnv, "STALE=destination-only")
1204-}
1205-
12061053 func TestPickerPreviewGenerationRejectsStaleSubscriptionReplacement(t *testing.T) {
12071054 rc := newRenderCoordinator(renderCoordinatorOptions{})
12081055 viewer := &attachedClient{}
@@ -1549,7 +1396,7 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
15491396 require.Contains(t, opens[1], "COLORTERM=truecolor")
15501397 require.Contains(t, opens[1], "TERM_PROGRAM=vev")
15511398 require.Eventually(t, func() bool {
1552- tb := got.activeTab()
1399+ tb := testAttachmentTab(got)
15531400 if tb == nil {
15541401 return false
15551402 }
@@ -1640,8 +1487,9 @@ func TestPickerNavigationRefreshAfterDeleteSelectsReplacingRow(t *testing.T) {
16401487 t.Run(tt.name, func(t *testing.T) {
16411488 d, current, ac, _, currentReleases := newManualTabSession(t, 2)
16421489 defer releaseAll(currentReleases)
1643- current.id, current.name, current.active = "current", tt.currentName, 1
1490+ current.id, current.name = "current", tt.currentName
16441491 current.tabs[0].stableID, current.tabs[1].stableID = "current-first", "current-active"
1492+ selectTestAttachmentTab(current, 1)
16451493 delete(d.sessions, domain.SessionID("manual"))
16461494 d.sessions[current.id] = current
16471495
@@ -1687,7 +1535,7 @@ func TestPickerNavigationRefreshAfterDeleteSelectsReplacingRow(t *testing.T) {
16871535 }
16881536 }
16891537
1690-func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T) {
1538+func TestPickerAttachmentEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T) {
16911539 d, current, ac, _, currentReleases := newManualTabSession(t, 1)
16921540 defer releaseAll(currentReleases)
16931541 current.id, current.name = "current", "z-current"
@@ -1700,9 +1548,9 @@ func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T)
17001548 target := &session{sessionCore: sessionCore{id: "target", name: "a-target", ephemeral: true}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}}
17011549 d.sessions[target.id] = target
17021550 current.mu.Lock()
1703- current.client = ac
1551+ current.registerAttachmentLocked(ac)
17041552 current.mu.Unlock()
1705- require.NotNil(t, d.attachCoordinator(current, nil, ac, true))
1553+ require.NotNil(t, d.attachCoordinator(current, ac, true))
17061554
17071555 d.enterPicker(current, ac)
17081556 d.handlePickerInput(ac, []byte("k"))
@@ -1714,8 +1562,8 @@ func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T)
17141562
17151563 token := current.attachmentToken(ac, ac.transport())
17161564 token.lease = current.renderCoordinator().attachmentLease(ac)
1717- ac.publishRoleCapability(token)
1718- effect, admitted := ac.beginRoleEffect(token)
1565+ ac.publishAttachmentCapability(token)
1566+ effect, admitted := ac.beginAttachmentEffect(token)
17191567 require.True(t, admitted)
17201568 d.handlePickerInput(ac, []byte("x"), effect)
17211569
@@ -1811,8 +1659,7 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry
18111659 require.True(t, stopped.purging, "an uncertain deletion stays fenced from restore")
18121660
18131661 from.mu.Lock()
1814- activeTab := from.tabs[from.active]
1815- activeTabID := activeTab.stableID
1662+ activeTabID := testAttachmentTabLocked(from).stableID
18161663 from.mu.Unlock()
18171664 ac.overlays.pickerMu.Lock()
18181665 selected, selectedOK := ac.overlays.picker.Selected()
M internal/usecase/daemon/prompt.go +5 -5
@@ -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, attachmentRoleToken) error) {
26+func (d *Daemon) enterTransitionPrompt(sess attachmentSession, 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)
@@ -51,7 +51,7 @@ func promptValidationError(err error) bool {
5151 errors.Is(err, errProxyKillConfirmation)
5252 }
5353
54-func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {
54+func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
5555 sess := ac.currentAttachmentSession()
5656 if sess == nil {
5757 return
@@ -67,7 +67,7 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*
6767 changed := false
6868 exit := false
6969 var submit func(string) error
70- var transitionSubmit func(string, attachmentRoleToken) error
70+ var transitionSubmit func(string, attachmentConnectionToken) error
7171 var submittedPrompt *promptui.Model
7272 var value string
7373
@@ -95,9 +95,9 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*
9595 if submit != nil || transitionSubmit != nil {
9696 var err error
9797 if transitionSubmit != nil {
98- var token attachmentRoleToken
98+ var token attachmentConnectionToken
9999 if len(effects) != 0 && effects[0] != nil {
100- token = effects[0].roleToken()
100+ token = effects[0].connectionToken()
101101 }
102102 err = transitionSubmit(value, token)
103103 } else {
M internal/usecase/daemon/proxied_contract_test.go +20 -18
@@ -57,9 +57,10 @@ func TestProxiedHandshakeOrdersWelcomeMetadataAndBaseZeroContent(t *testing.T) {
5757
5858 sess := firstSession(d)
5959 require.NotNil(t, sess)
60- sess.activeTab().mu.Lock()
61- actualContentSize := sess.activeTab().size
62- sess.activeTab().mu.Unlock()
60+ tb := sess.tabs[0]
61+ tb.mu.Lock()
62+ actualContentSize := tb.size
63+ tb.mu.Unlock()
6364 require.Equal(t, contentSize, actualContentSize, "proxied geometry must not subtract chrome a second time")
6465
6566 release()
@@ -94,9 +95,10 @@ func TestOrdinaryHandshakeRetainsChromeAndNoMetadata(t *testing.T) {
9495
9596 sess := firstSession(d)
9697 require.NotNil(t, sess)
97- sess.activeTab().mu.Lock()
98- actualContentSize := sess.activeTab().size
99- sess.activeTab().mu.Unlock()
98+ tb := sess.tabs[0]
99+ tb.mu.Lock()
100+ actualContentSize := tb.size
101+ tb.mu.Unlock()
100102 require.Equal(t, tabSize(viewport), actualContentSize)
101103 requireNoOutputFrame(t, sends)
102104
@@ -127,7 +129,7 @@ func TestProxiedRemotePaintSendsMetadataBeforeScreen(t *testing.T) {
127129 require.True(t, applyTestScreenText(proxy, 0, 0, "remote"))
128130
129131 ac.sendMu.Lock()
130- state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: true})
132+ state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: true})
131133 require.True(t, ok)
132134 composed := composeFrame(*state, composeCacheInput{})
133135 require.True(t, d.emitFrame(proxy, ac, state, composed))
@@ -143,7 +145,7 @@ func TestProxiedRemotePaintSendsMetadataBeforeScreen(t *testing.T) {
143145 func TestProxiedPaintPublishesAbsoluteVisibleCursorStyle(t *testing.T) {
144146 d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
145147 ac.proxied = true
146- pane := sess.activeTab().focusedPane()
148+ pane := testAttachmentTab(sess).focusedPane()
147149 pane.mu.Lock()
148150 pane.screen.Write([]byte("\x1b[3;6H\x1b[0 q"))
149151 pane.mu.Unlock()
@@ -203,13 +205,13 @@ func TestProxyResizeUpdatesAttachmentViewport(t *testing.T) {
203205 func TestProxiedTransactionalResizeUsesReceivedContentGeometry(t *testing.T) {
204206 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
205207 ac.proxied = true
206- rc := d.attachCoordinator(sess, nil, ac, true)
208+ rc := d.attachCoordinator(sess, ac, true)
207209 lease := rc.attachmentLease(ac)
208210 require.NotNil(t, lease)
209211
210212 want := domain.Size{Cols: 100, Rows: 12}
211213 require.True(t, d.requestTransactionalResizeForLease(sess, ac, lease, want, true))
212- tb := sess.activeTab()
214+ tb := testAttachmentTab(sess)
213215 tb.mu.Lock()
214216 require.Equal(t, want, tb.size)
215217 tb.mu.Unlock()
@@ -260,7 +262,7 @@ func TestProxiedModeCannotChangeAcrossResume(t *testing.T) {
260262 func TestOutputResetRebasesFullWindowAndSchedulesBaseZeroPaint(t *testing.T) {
261263 d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
262264 ac.proxied = true
263- rc := d.attachCoordinator(sess, nil, ac, true)
265+ rc := d.attachCoordinator(sess, ac, true)
264266 lease := rc.attachmentLease(ac)
265267 require.NotNil(t, lease)
266268
@@ -270,7 +272,7 @@ func TestOutputResetRebasesFullWindowAndSchedulesBaseZeroPaint(t *testing.T) {
270272 ac.sendMu.Unlock()
271273
272274 token := sess.attachmentToken(ac, ac.transport())
273- require.False(t, d.handleActiveClientFrame(token, ports.Frame{
275+ require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{
274276 Type: ports.MsgOutputResetRequest,
275277 Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
276278 }))
@@ -302,16 +304,16 @@ func TestOutputResetRequiresProxiedActiveRoleAndStrictPayload(t *testing.T) {
302304 t.Run(tt.name, func(t *testing.T) {
303305 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
304306 ac.proxied = tt.proxied
305- d.attachCoordinator(sess, nil, ac, true)
307+ d.attachCoordinator(sess, ac, true)
306308 ac.sendMu.Lock()
307309 ac.output.next, ac.output.acked = 4, 0
308310 ac.sendMu.Unlock()
309311
310312 token := sess.attachmentToken(ac, ac.transport())
311313 if tt.staleRole {
312- token.role = attachmentSnatched
314+ token.generation++
313315 }
314- require.False(t, d.handleActiveClientFrame(token, ports.Frame{Type: ports.MsgOutputResetRequest, Payload: tt.payload}))
316+ require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgOutputResetRequest, Payload: tt.payload}))
315317 ac.sendMu.Lock()
316318 acked := ac.output.acked
317319 ac.sendMu.Unlock()
@@ -323,7 +325,7 @@ func TestOutputResetRequiresProxiedActiveRoleAndStrictPayload(t *testing.T) {
323325 func TestOutputResetRevalidatesTransportUnderSendLock(t *testing.T) {
324326 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
325327 ac.proxied = true
326- d.attachCoordinator(sess, nil, ac, true)
328+ d.attachCoordinator(sess, ac, true)
327329 ac.sendMu.Lock()
328330 ac.output.next, ac.output.acked = 4, 0
329331 ac.sendMu.Unlock()
@@ -331,11 +333,11 @@ func TestOutputResetRevalidatesTransportUnderSendLock(t *testing.T) {
331333
332334 admitted := make(chan struct{})
333335 var once sync.Once
334- d.afterRoleEffectAdmitted = func(attachmentRoleToken) { once.Do(func() { close(admitted) }) }
336+ d.afterAttachmentEffectAdmitted = func(attachmentConnectionToken) { once.Do(func() { close(admitted) }) }
335337 ac.sendMu.Lock()
336338 done := make(chan bool, 1)
337339 go func() {
338- done <- d.handleActiveClientFrame(token, ports.Frame{
340+ done <- d.handleAttachmentClientFrame(token, ports.Frame{
339341 Type: ports.MsgOutputResetRequest,
340342 Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
341343 })
M internal/usecase/daemon/proxy_input.go +25 -27
@@ -42,36 +42,36 @@ func (p *proxySession) sendInput(raw []byte) error {
4242 // proxy. Local UI actions never reach the link. Every remote-owned action is
4343 // sent using the exact bytes supplied by keys.Router.
4444 type proxyKeyHandler struct {
45- d *Daemon
46- proxy *proxySession
47- ac *attachedClient
48- roleToken attachmentRoleToken
45+ d *Daemon
46+ proxy *proxySession
47+ ac *attachedClient
48+ connectionToken attachmentConnectionToken
4949 }
5050
51-func (h proxyKeyHandler) acquireRoleEffect() (*roleEffectTicket, bool, bool) {
51+func (h proxyKeyHandler) acquireAttachmentEffect() (*attachmentEffectTicket, bool, bool) {
5252 if h.proxy == nil || h.ac == nil {
5353 return nil, false, false
5454 }
55- if h.roleToken.ac == nil {
55+ if h.connectionToken.ac == nil {
5656 return nil, false, false
5757 }
58- if h.roleToken.sess != h.proxy || h.roleToken.ac != h.ac {
58+ if h.connectionToken.sess != h.proxy || h.connectionToken.ac != h.ac {
5959 return nil, false, false
6060 }
61- if effect := h.roleToken.effect; effect != nil && !effect.ended.Load() {
61+ if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {
6262 return effect, false, true
6363 }
64- effect, admitted := h.roleToken.ac.beginRoleEffect(h.roleToken)
64+ effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken)
6565 if h.d != nil && h.d.afterDelayedKeyEffectAttempt != nil {
6666 h.d.afterDelayedKeyEffectAttempt(admitted)
6767 }
6868 if !admitted {
6969 return nil, false, false
7070 }
71- if h.d != nil && h.d.afterRoleEffectAdmitted != nil {
72- token := h.roleToken
71+ if h.d != nil && h.d.afterAttachmentEffectAdmitted != nil {
72+ token := h.connectionToken
7373 token.effect = effect
74- h.d.afterRoleEffectAdmitted(token)
74+ h.d.afterAttachmentEffectAdmitted(token)
7575 }
7676 return effect, true, true
7777 }
@@ -86,7 +86,7 @@ func (h proxyKeyHandler) sendOwned(raw []byte) {
8686 }
8787
8888 func (h proxyKeyHandler) send(raw []byte) {
89- effect, owned, ok := h.acquireRoleEffect()
89+ effect, owned, ok := h.acquireAttachmentEffect()
9090 if !ok {
9191 return
9292 }
@@ -101,14 +101,14 @@ func (h proxyKeyHandler) Forward(raw []byte) { h.send(raw) }
101101 func (h proxyKeyHandler) Mouse(event mouse.Event) { h.send(event.Raw) }
102102
103103 func (h proxyKeyHandler) Action(action keys.Action, raw []byte) {
104- effect, owned, ok := h.acquireRoleEffect()
104+ effect, owned, ok := h.acquireAttachmentEffect()
105105 if !ok {
106106 return
107107 }
108108 if owned {
109109 defer effect.End()
110110 }
111- h.roleToken.effect = effect
111+ h.connectionToken.effect = effect
112112 switch action {
113113 case keys.ActionOpenPalette:
114114 h.enterPalette()
@@ -176,7 +176,7 @@ func (h proxyKeyHandler) enterPalette() {
176176 // navigation. A pending tab on the proxy remains remote-owned; without one,
177177 // the local daemon may move the client to another local session.
178178 func (h proxyKeyHandler) jumpLocalAttention() {
179- if h.d == nil || h.roleToken.ac == nil || h.roleToken.effect == nil {
179+ if h.d == nil || h.connectionToken.ac == nil || h.connectionToken.effect == nil {
180180 return
181181 }
182182 target, ok := h.d.oldestOtherSessionAttention(nil)
@@ -190,12 +190,12 @@ func (h proxyKeyHandler) jumpLocalAttention() {
190190 return
191191 }
192192
193- token := h.roleToken
193+ token := h.connectionToken
194194 token.effect.bindActionEnd(h.d, "proxy-jump-attention")
195195 token.effect.End()
196196 transition, err := h.d.transitionAttachment(attachmentTransitionRequest{
197197 source: h.proxy, target: targetSession, next: h.ac,
198- expectedRole: attachmentActive, targetRole: attachmentActive,
198+
199199 expectedTransport: token.transport, sourceToken: &token,
200200 action: "proxy-jump-attention", activateTargetTab: true,
201201 targetTabIndex: target.tabIndex, ready: true,
@@ -227,34 +227,32 @@ func proxiedJumpSearchesOtherSessions(proxied bool) bool { return !proxied }
227227 // but never follows OverflowSessions. The local proxy daemon is the only owner
228228 // allowed to change the selected session.
229229 func (d *Daemon) focusDirProxied(sess *session, ac *attachedClient, dir layout.Direction) error {
230- target := resolveDaemonActionTarget(sess)
230+ target := resolveDaemonActionTargetForAttachment(sess, ac)
231231 oldFocus := layout.PaneID("")
232232 if target.pane != nil {
233233 oldFocus = target.pane.id
234234 }
235- span, err := d.focusDirAt(sess, target.tab, target.pane, dir)
235+ span, err := d.focusDirAt(sess, target.tab, target.pane, dir, ac)
236236 if err == nil {
237237 if ac != nil {
238238 d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "proxy_input.go")
239239 }
240240 return nil
241241 }
242- if !errors.Is(err, errNoNeighbor) || target.tab == nil || !overflowSourceEligible(sess, target.tab) {
242+ if !errors.Is(err, errNoNeighbor) || target.tab == nil || !overflowSourceEligible(sess, ac, target.tab) {
243243 return err
244244 }
245245
246- sess.mu.Lock()
247- position, count := sess.active, len(sess.tabs)
248- sess.mu.Unlock()
246+ position, count := sess.tabIndexForAttachment(ac)
249247 step := resolveOverflow(dir, proxiedNavConfig(d.currentNavConfig()), position, count)
250248 if step.kind != overflowTabs {
251249 return errNoNeighbor
252250 }
253- candidate, ok := d.prepareTabOverflow(sess, target.tab, dir, span, step.delta)
254- if !ok || !d.commitTabOverflow(sess, candidate) {
251+ candidate, ok := d.prepareTabOverflowForAttachment(sess, ac, target.tab, dir, span, step.delta)
252+ if !ok || !d.commitTabOverflowForAttachment(sess, ac, candidate) {
255253 return errNoNeighbor
256254 }
257- d.activateTab(sess, candidate.target)
255+ sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID))
258256 if ac != nil {
259257 d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "proxy_input.go")
260258 }
M internal/usecase/daemon/proxy_input_test.go +75 -75
@@ -35,11 +35,11 @@ func newProxyInputHarness(t *testing.T) (*Daemon, *proxySession, *attachedClient
3535 proxy.mu.Unlock()
3636 ac := &attachedClient{size: domain.Size{Cols: 80, Rows: 24}, output: newOutputStateStream()}
3737 ac.initOverlays()
38- token := roleEffectForTest(t, attachProxyInputRole(t, d, proxy, ac))
39- return d, proxy, ac, link, proxyKeyHandler{d: d, proxy: proxy, ac: ac, roleToken: token}
38+ token := attachmentEffectForTest(t, attachProxyInputRole(t, d, proxy, ac))
39+ return d, proxy, ac, link, proxyKeyHandler{d: d, proxy: proxy, ac: ac, connectionToken: token}
4040 }
4141
42-func attachProxyInputRole(t *testing.T, d *Daemon, proxy *proxySession, ac *attachedClient) attachmentRoleToken {
42+func attachProxyInputRole(t *testing.T, d *Daemon, proxy *proxySession, ac *attachedClient) attachmentConnectionToken {
4343 t.Helper()
4444 clientTransport := newProxyTestTransport()
4545 ac.replaceTransport(clientTransport)
@@ -49,7 +49,7 @@ func attachProxyInputRole(t *testing.T, d *Daemon, proxy *proxySession, ac *atta
4949 d.mu.Unlock()
5050 transition, err := d.transitionAttachment(attachmentTransitionRequest{
5151 target: proxy, next: ac,
52- expectedRole: attachmentDetached, targetRole: attachmentActive,
52+
5353 expectedTransport: ac.transportSnapshot(), ready: true,
5454 })
5555 require.NoError(t, err)
@@ -57,9 +57,9 @@ func attachProxyInputRole(t *testing.T, d *Daemon, proxy *proxySession, ac *atta
5757 return transition.published
5858 }
5959
60-func roleEffectForTest(t *testing.T, token attachmentRoleToken) attachmentRoleToken {
60+func attachmentEffectForTest(t *testing.T, token attachmentConnectionToken) attachmentConnectionToken {
6161 t.Helper()
62- effect, admitted := token.ac.beginRoleEffect(token)
62+ effect, admitted := token.ac.beginAttachmentEffect(token)
6363 require.True(t, admitted)
6464 token.effect = effect
6565 t.Cleanup(effect.End)
@@ -151,22 +151,22 @@ func TestProxyInputRouterPreservesCustomAndDelayedActionBytes(t *testing.T) {
151151
152152 t.Run("retained escape reacquires its original role", func(t *testing.T) {
153153 d, proxy, ac, link, initial := newProxyInputHarness(t)
154- base := initial.roleToken
154+ base := initial.connectionToken
155155 base.effect.End()
156156 base.effect = nil
157157
158- firstEffect, admitted := ac.beginRoleEffect(base)
158+ firstEffect, admitted := ac.beginAttachmentEffect(base)
159159 require.True(t, admitted)
160160 first := base
161161 first.effect = firstEffect
162- ac.keys.RouteWithHandler([]byte{keys.ESC}, proxyKeyHandler{d: d, proxy: proxy, ac: ac, roleToken: first})
162+ ac.keys.RouteWithHandler([]byte{keys.ESC}, proxyKeyHandler{d: d, proxy: proxy, ac: ac, connectionToken: first})
163163 firstEffect.End()
164164
165- secondEffect, admitted := ac.beginRoleEffect(base)
165+ secondEffect, admitted := ac.beginAttachmentEffect(base)
166166 require.True(t, admitted)
167167 second := base
168168 second.effect = secondEffect
169- ac.keys.RouteWithHandler([]byte{'j'}, proxyKeyHandler{d: d, proxy: proxy, ac: ac, roleToken: second})
169+ ac.keys.RouteWithHandler([]byte{'j'}, proxyKeyHandler{d: d, proxy: proxy, ac: ac, connectionToken: second})
170170 secondEffect.End()
171171
172172 requireProxyInputFrame(t, link, 1, []byte{keys.ESC, 'j'})
@@ -175,15 +175,15 @@ func TestProxyInputRouterPreservesCustomAndDelayedActionBytes(t *testing.T) {
175175
176176 func TestProxyInputLocalPaletteNeverForwards(t *testing.T) {
177177 d, _, ac, link, handler := newProxyInputHarness(t)
178- token := handler.roleToken
178+ token := handler.connectionToken
179179 handler.Action(keys.ActionOpenPalette, []byte{keys.ESC, ' '})
180180 require.True(t, ac.overlays.paletteActive())
181181 requireNoProxyFrame(t, link)
182182
183183 // Keyboard and mouse navigation are consumed by the local overlay before
184184 // either the proxy router or proxy mouse sender can observe them.
185- d.handleInputForRole(token, []byte("x"))
186- d.handleInputForRole(token, []byte("\x1b[<0;12;8M"))
185+ d.handleInputForAttachment(token, []byte("x"))
186+ d.handleInputForAttachment(token, []byte("\x1b[<0;12;8M"))
187187 requireNoProxyFrame(t, link)
188188 }
189189
@@ -259,7 +259,7 @@ func TestProxyPaletteCanonicalLocalCommandsNeverReachRemote(t *testing.T) {
259259 t.Run("new session opens local transition prompt", func(t *testing.T) {
260260 d, proxy, ac, link, handler := newProxyInputHarness(t)
261261 handler.enterPalette()
262- d.handlePaletteInput(ac, []byte("CNS\r"), handler.roleToken.effect)
262+ d.handlePaletteInput(ac, []byte("CNS\r"), handler.connectionToken.effect)
263263
264264 require.False(t, ac.overlays.paletteActive())
265265 require.True(t, ac.overlays.promptActive())
@@ -276,7 +276,7 @@ func TestProxyPaletteCanonicalLocalCommandsNeverReachRemote(t *testing.T) {
276276 for len(client.sent) > 0 {
277277 <-client.sent
278278 }
279- d.handlePaletteInput(ac, []byte("YLN\r"), handler.roleToken.effect)
279+ d.handlePaletteInput(ac, []byte("YLN\r"), handler.connectionToken.effect)
280280
281281 require.False(t, ac.overlays.paletteActive())
282282 require.Same(t, proxy, ac.currentAttachmentSession())
@@ -514,15 +514,15 @@ func TestProxyAttachedCommandRejectsTargetsAndExecutesExactActiveSession(t *test
514514 publishTiledPaneOwners(sess, second)
515515 sess.mu.Lock()
516516 sess.tabs = append(sess.tabs, second)
517- sess.active = 0
517+ selectTestAttachmentTabLocked(sess, 0)
518518 sess.mu.Unlock()
519- d.attachCoordinator(sess, nil, ac, true)
519+ d.attachCoordinator(sess, ac, true)
520520
521521 send := func(request ports.CommandRequest) ports.CommandResult {
522522 payload, err := ports.MarshalCommandRequest(request)
523523 require.NoError(t, err)
524524 token := sess.attachmentToken(ac, ac.transport())
525- require.False(t, d.handleActiveClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload}))
525+ require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload}))
526526 frame := awaitFrame(t, sends, ports.MsgCommandResult)
527527 result, err := ports.UnmarshalCommandResult(frame.Payload)
528528 require.NoError(t, err)
@@ -532,13 +532,13 @@ func TestProxyAttachedCommandRejectsTargetsAndExecutesExactActiveSession(t *test
532532 result := send(ports.CommandRequest{Version: ports.ProtocolVersion, RequestID: 1, Attached: true, Slug: "next-tab"})
533533 require.True(t, result.OK)
534534 sess.mu.Lock()
535- require.Equal(t, 1, sess.active)
535+ require.Equal(t, 1, testAttachmentTabIndexLocked(sess))
536536 sess.mu.Unlock()
537537
538538 result = send(ports.CommandRequest{Version: ports.ProtocolVersion, RequestID: 2, Attached: true, Slug: "previous-tab", TargetSession: "other"})
539539 require.False(t, result.OK)
540540 sess.mu.Lock()
541- require.Equal(t, 1, sess.active, "target override must not execute against any session")
541+ require.Equal(t, 1, testAttachmentTabIndexLocked(sess), "target override must not execute against any session")
542542 sess.mu.Unlock()
543543
544544 result = send(ports.CommandRequest{Version: ports.ProtocolVersion, RequestID: 3, Attached: true, Slug: "rename-tab"})
@@ -553,7 +553,7 @@ func TestProxyAttachedCommandRemoteErrorCreatesLocalNotice(t *testing.T) {
553553 handler.enterPalette()
554554 done := make(chan struct{})
555555 go func() {
556- d.handlePaletteInput(ac, []byte("NXT\r"), handler.roleToken.effect)
556+ d.handlePaletteInput(ac, []byte("NXT\r"), handler.connectionToken.effect)
557557 close(done)
558558 }()
559559 request := requireProxyCommandRequest(t, link)
@@ -620,13 +620,13 @@ func TestProxyPickerSelectionDialsOutsideLocksAndRevalidatesExactRoleAndKey(t *t
620620 dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once()
621621 d.remoteDialerFactory = factory
622622 d.remoteTransportMode = ports.RemoteTransportUDP
623- d.attachCoordinator(source, nil, ac, true)
623+ d.attachCoordinator(source, ac, true)
624624
625625 token := source.attachmentToken(ac, ac.transport())
626- effect, admitted := ac.beginRoleEffect(token)
626+ effect, admitted := ac.beginAttachmentEffect(token)
627627 require.True(t, admitted)
628628 token.effect = effect
629- err := d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
629+ err := d.switchToTargetForAttachment(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
630630 require.NoError(t, err)
631631 require.True(t, daemonLockAvailable)
632632 require.True(t, sourceLockAvailable)
@@ -637,26 +637,26 @@ func TestProxyPickerSelectionDialsOutsideLocksAndRevalidatesExactRoleAndKey(t *t
637637
638638 wrong := domain.RemoteSessionKey{Host: "arch", Name: "other"}
639639 currentToken := attachmentToken(proxy, ac, ac.transport())
640- currentEffect, admitted := ac.beginRoleEffect(currentToken)
640+ currentEffect, admitted := ac.beginAttachmentEffect(currentToken)
641641 require.True(t, admitted)
642642 currentToken.effect = currentEffect
643- err = d.switchToTargetForRole(currentToken, picker.Target{Session: key.ID(), RemoteKey: &wrong, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
643+ err = d.switchToTargetForAttachment(currentToken, picker.Target{Session: key.ID(), RemoteKey: &wrong, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
644644 require.ErrorIs(t, err, errAttachmentTransition)
645645 require.Same(t, proxy, ac.currentAttachmentSession(), "mismatched structured key must not redirect attachment")
646646
647647 staleToken := attachmentToken(proxy, ac, ac.transport())
648- staleEffect, admitted := ac.beginRoleEffect(staleToken)
648+ staleEffect, admitted := ac.beginAttachmentEffect(staleToken)
649649 require.True(t, admitted)
650650 staleToken.effect = staleEffect
651- ac.roleGeneration.Add(1) // model a concurrent role replacement after selection
652- err = d.switchToTargetForRole(staleToken, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
651+ ac.connectionGeneration.Add(1) // model a concurrent role replacement after selection
652+ err = d.switchToTargetForAttachment(staleToken, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
653653 require.ErrorIs(t, err, errAttachmentTransition)
654654 require.Same(t, proxy, ac.currentAttachmentSession(), "stale initiating role must not republish attachment")
655655 }
656656
657657 func TestRemotePickerEnterRoutesStructuredKeyThroughProxyOwnership(t *testing.T) {
658658 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
659- d.attachCoordinator(local, nil, ac, true)
659+ d.attachCoordinator(local, ac, true)
660660 key := domain.RemoteSessionKey{Host: "arch", Name: "enter"}
661661 transport := newProxyTestTransport()
662662 transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
@@ -669,14 +669,14 @@ func TestRemotePickerEnterRoutesStructuredKeyThroughProxyOwnership(t *testing.T)
669669 Sessions: []ports.RemoteCatalogSession{{Name: key.Name, State: "running"}},
670670 }})
671671
672- model := d.newPickerModel(local, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key})
672+ model := d.newPickerModel(local, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key})
673673 d.publishPicker(local, ac, model, pickerNavigate, moveSourceLocator{})
674674 target, selectable := model.Selected()
675675 require.True(t, selectable)
676676 require.Equal(t, key, *target.RemoteKey)
677677
678678 token := local.attachmentToken(ac, ac.transport())
679- effect, admitted := ac.beginRoleEffect(token)
679+ effect, admitted := ac.beginAttachmentEffect(token)
680680 require.True(t, admitted)
681681 d.handlePickerInput(ac, []byte("\r"), effect)
682682 proxy, ok := ac.currentAttachmentSession().(*proxySession)
@@ -694,7 +694,7 @@ func TestRemotePickerSwitchesFromProxyBackToLocalSession(t *testing.T) {
694694 require.True(t, d.registerSessionLocked(local))
695695 d.mu.Unlock()
696696
697- model := d.newPickerModel(proxy, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(tb.stableID)})
697+ model := d.newPickerModel(proxy, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(tb.stableID)})
698698 d.publishPicker(proxy, ac, model, pickerNavigate, moveSourceLocator{})
699699 target, selectable := model.Selected()
700700 require.True(t, selectable)
@@ -702,17 +702,17 @@ func TestRemotePickerSwitchesFromProxyBackToLocalSession(t *testing.T) {
702702 require.Equal(t, local.name, target.Name)
703703 require.False(t, target.Stopped)
704704
705- require.NoError(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{closePicker: true}, "picker-select"))
705+ require.NoError(t, d.switchToTargetForAttachment(handler.connectionToken, target, sessionHandoffGuard{closePicker: true}, "picker-select"))
706706 require.Same(t, local, ac.currentSession())
707707 require.False(t, ac.overlays.pickerActive(), "successful handoff must close the picker")
708708
709709 d.remoteDialerFactory = portsmocks.NewMockRemoteDialerFactory(t)
710- localToken := roleEffectForTest(t, local.attachmentToken(ac, ac.transport()))
711- require.NoError(t, d.backSessionForRole(localToken))
710+ localToken := attachmentEffectForTest(t, local.attachmentToken(ac, ac.transport()))
711+ require.NoError(t, d.backSessionForAttachment(localToken))
712712 require.Same(t, proxy, ac.currentAttachmentSession(), "back-session must return to the previous warm proxy")
713713
714- proxyToken := roleEffectForTest(t, attachmentToken(proxy, ac, ac.transport()))
715- require.NoError(t, d.switchToTargetForRole(proxyToken, target, sessionHandoffGuard{}, "picker-select"))
714+ proxyToken := attachmentEffectForTest(t, attachmentToken(proxy, ac, ac.transport()))
715+ require.NoError(t, d.switchToTargetForAttachment(proxyToken, target, sessionHandoffGuard{}, "picker-select"))
716716 require.Same(t, local, ac.currentSession())
717717 d.backSession(local, ac)
718718 require.Same(t, proxy, ac.currentAttachmentSession(), "legacy back-session must preserve the previous proxy identity")
@@ -720,7 +720,7 @@ func TestRemotePickerSwitchesFromProxyBackToLocalSession(t *testing.T) {
720720
721721 func TestRemotePickerReportsUnavailableLocalTarget(t *testing.T) {
722722 d, proxy, ac, _, handler := newProxyInputHarness(t)
723- err := d.switchToTargetForRole(handler.roleToken, picker.Target{Session: "missing", TabIndex: 0}, sessionHandoffGuard{}, "picker-select")
723+ err := d.switchToTargetForAttachment(handler.connectionToken, picker.Target{Session: "missing", TabIndex: 0}, sessionHandoffGuard{}, "picker-select")
724724 require.Error(t, err)
725725 require.Same(t, proxy, ac.currentAttachmentSession())
726726 }
@@ -734,18 +734,18 @@ func TestRemotePickerRejectsReplacedLocalLifecycle(t *testing.T) {
734734 require.True(t, d.registerSessionLocked(local))
735735 d.mu.Unlock()
736736
737- model := d.newPickerModel(proxy, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(tb.stableID)})
737+ model := d.newPickerModel(proxy, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(tb.stableID)})
738738 target, selectable := model.Selected()
739739 require.True(t, selectable)
740- d.afterRoleEffectsFrozen = func() {
741- d.afterRoleEffectsFrozen = nil
740+ d.afterAttachmentEffectsFrozen = func() {
741+ d.afterAttachmentEffectsFrozen = nil
742742 local.mu.Lock()
743743 local.name = "replacement"
744744 local.createdAt++
745745 local.mu.Unlock()
746746 }
747747
748- require.Error(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{}, "picker-select"))
748+ require.Error(t, d.switchToTargetForAttachment(handler.connectionToken, target, sessionHandoffGuard{}, "picker-select"))
749749 require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker lifecycle must not redirect the attachment")
750750 }
751751
@@ -759,17 +759,17 @@ func TestRemotePickerRejectsReplacedLocalIncarnation(t *testing.T) {
759759 require.True(t, d.registerSessionLocked(local))
760760 d.mu.Unlock()
761761
762- model := d.newPickerModel(proxy, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, Incarnation: local.incarnation, TabID: domain.TabStableID(tb.stableID)})
762+ model := d.newPickerModel(proxy, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, Incarnation: local.incarnation, TabID: domain.TabStableID(tb.stableID)})
763763 target, selectable := model.Selected()
764764 require.True(t, selectable)
765- d.afterRoleEffectsFrozen = func() {
766- d.afterRoleEffectsFrozen = nil
765+ d.afterAttachmentEffectsFrozen = func() {
766+ d.afterAttachmentEffectsFrozen = nil
767767 local.mu.Lock()
768768 local.incarnation = domain.IncarnationID{2}
769769 local.mu.Unlock()
770770 }
771771
772- require.Error(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{}, "picker-select"))
772+ require.Error(t, d.switchToTargetForAttachment(handler.connectionToken, target, sessionHandoffGuard{}, "picker-select"))
773773 require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker incarnation must not redirect the attachment")
774774 }
775775
@@ -786,24 +786,24 @@ func TestRemotePickerRejectsReplacedLocalTab(t *testing.T) {
786786 require.True(t, d.registerSessionLocked(local))
787787 d.mu.Unlock()
788788
789- model := d.newPickerModel(proxy, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(second.stableID)})
789+ model := d.newPickerModel(proxy, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(second.stableID)})
790790 target, selectable := model.Selected()
791791 require.True(t, selectable)
792792 require.Equal(t, domain.TabStableID(second.stableID), target.TabID)
793- d.afterRoleEffectsFrozen = func() {
794- d.afterRoleEffectsFrozen = nil
793+ d.afterAttachmentEffectsFrozen = func() {
794+ d.afterAttachmentEffectsFrozen = nil
795795 local.mu.Lock()
796796 local.tabs[0], local.tabs[1] = local.tabs[1], local.tabs[0]
797797 local.mu.Unlock()
798798 }
799799
800- require.Error(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{}, "picker-select"))
800+ require.Error(t, d.switchToTargetForAttachment(handler.connectionToken, target, sessionHandoffGuard{}, "picker-select"))
801801 require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker tab must not redirect the attachment")
802802 }
803803
804804 func TestRemotePickerCanReselectWarmProxyAfterReturningLocal(t *testing.T) {
805805 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
806- d.attachCoordinator(local, nil, ac, true)
806+ d.attachCoordinator(local, ac, true)
807807 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
808808 remote := newProxyTestTransport()
809809 remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
@@ -812,23 +812,23 @@ func TestRemotePickerCanReselectWarmProxyAfterReturningLocal(t *testing.T) {
812812 d.remoteDialerFactory = newProxyConstructionFactory(remote)
813813 d.remoteTransportMode = ports.RemoteTransportUDP
814814
815- localToken := roleEffectForTest(t, local.attachmentToken(ac, ac.transport()))
816- require.NoError(t, d.switchToTargetForRole(localToken, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
815+ localToken := attachmentEffectForTest(t, local.attachmentToken(ac, ac.transport()))
816+ require.NoError(t, d.switchToTargetForAttachment(localToken, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
817817 proxy, ok := ac.currentAttachmentSession().(*proxySession)
818818 require.True(t, ok)
819819 t.Cleanup(func() { stopProxy(t, proxy) })
820820
821- proxyToken := roleEffectForTest(t, attachmentToken(proxy, ac, ac.transport()))
822- require.NoError(t, d.switchToTargetForRole(proxyToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select"))
821+ proxyToken := attachmentEffectForTest(t, attachmentToken(proxy, ac, ac.transport()))
822+ require.NoError(t, d.switchToTargetForAttachment(proxyToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select"))
823823 require.Same(t, local, ac.currentSession())
824824
825- model := d.newPickerModel(local, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key})
825+ model := d.newPickerModel(local, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key})
826826 d.publishPicker(local, ac, model, pickerNavigate, moveSourceLocator{})
827827 target, selectable := model.Selected()
828828 require.True(t, selectable)
829829 require.Empty(t, target.Name, "live proxy rows must route only by their structured remote key")
830- localToken = roleEffectForTest(t, local.attachmentToken(ac, ac.transport()))
831- require.NoError(t, d.switchToTargetForRole(localToken, target, sessionHandoffGuard{closePicker: true}, "picker-select"))
830+ localToken = attachmentEffectForTest(t, local.attachmentToken(ac, ac.transport()))
831+ require.NoError(t, d.switchToTargetForAttachment(localToken, target, sessionHandoffGuard{closePicker: true}, "picker-select"))
832832 require.Same(t, proxy, ac.currentAttachmentSession())
833833 require.False(t, ac.overlays.pickerActive())
834834 }
@@ -838,8 +838,8 @@ func TestConnectionRoleResolutionStopsAfterUnstableSnapshots(t *testing.T) {
838838 client := newProxyTestTransport()
839839 ac := &attachedClient{}
840840 ac.replaceTransport(client)
841- first := &session{sessionCore: sessionCore{client: ac}}
842- second := &session{sessionCore: sessionCore{client: ac}}
841+ first := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{ac: {}}}}
842+ second := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{ac: {}}}}
843843 ac.setSession(first)
844844
845845 const unstableSnapshots = 100
@@ -856,7 +856,7 @@ func TestConnectionRoleResolutionStopsAfterUnstableSnapshots(t *testing.T) {
856856 }
857857 }
858858
859- _, _, ok := d.currentConnectionRole(ac, client)
859+ _, _, ok := d.currentAttachmentConnection(ac, client)
860860 require.False(t, ok)
861861 require.Less(t, attempts, unstableSnapshots)
862862 }
@@ -866,14 +866,14 @@ func TestConnectionLoopStopsWhenReceiveErrorCleanupMakesNoProgress(t *testing.T)
866866 client := newProxyTestTransport()
867867 ac := &attachedClient{}
868868 ac.replaceTransport(client)
869- local := &session{sessionCore: sessionCore{client: ac}}
869+ local := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{ac: {}}}}
870870 ac.setSession(local)
871871
872872 cleanupAttempts := 0
873873 d.beforeClientGoneDetach = func() {
874874 cleanupAttempts++
875875 local.mu.Lock()
876- local.client = &attachedClient{}
876+ local.registerAttachmentLocked(&attachedClient{})
877877 local.mu.Unlock()
878878 }
879879 snapshots := 0
@@ -895,7 +895,7 @@ func TestConnectionLoopRetriesRoleResolutionAcrossHandoff(t *testing.T) {
895895 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
896896 client := newProxyTestTransport()
897897 ac.replaceTransport(client)
898- d.attachCoordinator(local, nil, ac, true)
898+ d.attachCoordinator(local, ac, true)
899899
900900 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
901901 remote := newProxyTestTransport()
@@ -904,13 +904,13 @@ func TestConnectionLoopRetriesRoleResolutionAcrossHandoff(t *testing.T) {
904904 remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
905905 d.remoteDialerFactory = newProxyConstructionFactory(remote)
906906 d.remoteTransportMode = ports.RemoteTransportUDP
907- token := roleEffectForTest(t, local.attachmentToken(ac, client))
907+ token := attachmentEffectForTest(t, local.attachmentToken(ac, client))
908908 snapshots := make(chan attachmentSession, 1)
909909 transitionErr := make(chan error, 1)
910910 d.afterConnectionSessionSnapshot = func(snapshot attachmentSession) {
911911 d.afterConnectionSessionSnapshot = nil
912912 snapshots <- snapshot
913- transitionErr <- d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
913+ transitionErr <- d.switchToTargetForAttachment(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
914914 }
915915
916916 loopDone := make(chan struct{})
@@ -934,7 +934,7 @@ func TestConnectionLoopCleansCurrentProxyAfterHandoffReceiveError(t *testing.T)
934934 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
935935 client := newProxyTestTransport()
936936 ac.replaceTransport(client)
937- d.attachCoordinator(local, nil, ac, true)
937+ d.attachCoordinator(local, ac, true)
938938
939939 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
940940 remote := newProxyTestTransport()
@@ -951,8 +951,8 @@ func TestConnectionLoopCleansCurrentProxyAfterHandoffReceiveError(t *testing.T)
951951 }()
952952 require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
953953
954- token := roleEffectForTest(t, local.attachmentToken(ac, client))
955- require.NoError(t, d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
954+ token := attachmentEffectForTest(t, local.attachmentToken(ac, client))
955+ require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
956956 proxy, ok := ac.currentAttachmentSession().(*proxySession)
957957 require.True(t, ok)
958958 t.Cleanup(func() { stopProxy(t, proxy) })
@@ -980,7 +980,7 @@ func TestConnectionLoopFollowsClientFromProxyBackToLocal(t *testing.T) {
980980 }()
981981 require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
982982
983- require.NoError(t, d.switchToTargetForRole(handler.roleToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select"))
983+ require.NoError(t, d.switchToTargetForAttachment(handler.connectionToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select"))
984984 require.Same(t, local, ac.currentSession())
985985 client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}}
986986 awaitTestCompletion(t, loopDone, "connection loop did not follow the client back to its local session")
@@ -991,7 +991,7 @@ func TestConnectionLoopFollowsClientFromLocalToRemoteProxy(t *testing.T) {
991991 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
992992 client := newProxyTestTransport()
993993 ac.replaceTransport(client)
994- d.attachCoordinator(local, nil, ac, true)
994+ d.attachCoordinator(local, ac, true)
995995
996996 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
997997 remote := newProxyTestTransport()
@@ -1009,10 +1009,10 @@ func TestConnectionLoopFollowsClientFromLocalToRemoteProxy(t *testing.T) {
10091009 require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
10101010
10111011 token := local.attachmentToken(ac, client)
1012- effect, admitted := ac.beginRoleEffect(token)
1012+ effect, admitted := ac.beginAttachmentEffect(token)
10131013 require.True(t, admitted)
10141014 token.effect = effect
1015- require.NoError(t, d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
1015+ require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
10161016 proxy, ok := ac.currentAttachmentSession().(*proxySession)
10171017 require.True(t, ok)
10181018 t.Cleanup(func() { stopProxy(t, proxy) })
M internal/usecase/daemon/proxy_kill.go +2 -2
@@ -47,12 +47,12 @@ func (d *Daemon) enterRemoteKillConfirmation(owner attachmentSession, ac *attach
4747 if unreachable {
4848 title = " Type " + key.Display() + " to kill (remote unreachable) "
4949 }
50- d.enterTransitionPrompt(owner, ac, title, "", func(value string, token attachmentRoleToken) error {
50+ d.enterTransitionPrompt(owner, ac, title, "", func(value string, token attachmentConnectionToken) error {
5151 if value != key.Display() {
5252 return errProxyKillConfirmation
5353 }
5454 if token.ac != nil {
55- if !token.activeCurrent() {
55+ if !token.attachmentCurrent() {
5656 return errAttachmentTransition
5757 }
5858 token.effect.bindActionEnd(d, "remote-picker-delete")
M internal/usecase/daemon/proxy_lifecycle.go +12 -10
@@ -39,8 +39,8 @@ func (t *proxyWarmTimer) stop() {
3939 // proxyAttachmentTransitionCommitted applies lifecycle policy only after the
4040 // attachment publication has released every architecture lock. Each helper
4141 // performs its own exact pointer/client revalidation before changing a timer.
42-func (d *Daemon) proxyAttachmentTransitionCommitted(source, target attachmentSession, ac *attachedClient, preserveRole bool) {
43- if d == nil || preserveRole || source == target {
42+func (d *Daemon) proxyAttachmentTransitionCommitted(source, target attachmentSession, ac *attachedClient, preserveAttachment bool) {
43+ if d == nil || preserveAttachment || source == target {
4444 return
4545 }
4646 if proxy, ok := source.(*proxySession); ok {
@@ -66,7 +66,7 @@ func (d *Daemon) cancelProxyWarmForAttachment(p *proxySession, ac *attachedClien
6666 return false
6767 }
6868 p.sessionCore.mu.Lock()
69- if p.client != ac {
69+ if !attachmentRegisteredLocked(attachmentSession(p), ac) {
7070 p.sessionCore.mu.Unlock()
7171 d.mu.Unlock()
7272 return false
@@ -100,7 +100,7 @@ func (d *Daemon) armProxyWarm(p *proxySession) bool {
100100 return false
101101 }
102102 p.sessionCore.mu.Lock()
103- if p.client != nil {
103+ if len(p.attachments) != 0 {
104104 p.sessionCore.mu.Unlock()
105105 d.mu.Unlock()
106106 return false
@@ -126,7 +126,7 @@ func (d *Daemon) armProxyWarm(p *proxySession) bool {
126126 valid := d.sessions[p.id] == p && !d.closing
127127 if valid {
128128 p.sessionCore.mu.Lock()
129- valid = p.client == nil
129+ valid = len(p.attachments) == 0
130130 if valid {
131131 p.mu.Lock()
132132 valid = p.generation == generation && p.warm == old
@@ -174,7 +174,7 @@ func (d *Daemon) expireWarmProxy(p *proxySession, token *proxyWarmTimer) bool {
174174 return false
175175 }
176176 p.sessionCore.mu.Lock()
177- if p.client != nil {
177+ if len(p.attachments) != 0 {
178178 p.sessionCore.mu.Unlock()
179179 d.mu.Unlock()
180180 return false
@@ -245,12 +245,14 @@ func (d *Daemon) withProxyLocked(p *proxySession, update func() bool) bool {
245245 p.sessionCore.mu.Lock()
246246 p.mu.Lock()
247247 current := update()
248- ac := p.client
248+ attachments := append([]*attachedClient(nil), p.snapshotAttachmentsLocked()...)
249249 p.mu.Unlock()
250250 p.sessionCore.mu.Unlock()
251251 d.mu.Unlock()
252- if current && ac != nil {
253- d.invalidateRender(p, ac, false, "proxy_lifecycle.go")
252+ if current {
253+ for _, ac := range attachments {
254+ d.invalidateRender(p, ac, false, "proxy_lifecycle.go")
255+ }
254256 }
255257 return current
256258 }
@@ -317,7 +319,7 @@ func (d *Daemon) markProxyReplaced(p *proxySession, generation uint64, transport
317319 p.warm.generation = p.generation
318320 }
319321 }
320- needsWarm = current && p.client == nil && p.warm == nil
322+ needsWarm = current && len(p.attachments) == 0 && p.warm == nil
321323 return current
322324 })
323325 if needsWarm {
M internal/usecase/daemon/proxy_lifecycle_test.go +66 -67
@@ -174,7 +174,7 @@ func TestProxyWarmLifecycleFollowsAttachmentTransitions(t *testing.T) {
174174 clock := newProxyLifecycleClock()
175175 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
176176 d.clock = clock
177- localCoordinator := d.attachCoordinator(local, nil, ac, true)
177+ localCoordinator := d.attachCoordinator(local, ac, true)
178178 localToken := attachmentToken(local, ac, ac.transport())
179179 localToken.lease = localCoordinator.attachmentLease(ac)
180180
@@ -186,7 +186,7 @@ func TestProxyWarmLifecycleFollowsAttachmentTransitions(t *testing.T) {
186186
187187 toProxy, err := d.transitionAttachment(attachmentTransitionRequest{
188188 source: local, target: proxy, next: ac,
189- expectedRole: attachmentActive, targetRole: attachmentActive,
189+
190190 expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
191191 action: "lifecycle test", ready: true,
192192 })
@@ -198,7 +198,7 @@ func TestProxyWarmLifecycleFollowsAttachmentTransitions(t *testing.T) {
198198
199199 _, err = d.transitionAttachment(attachmentTransitionRequest{
200200 source: proxy, target: local, next: ac,
201- expectedRole: attachmentActive, targetRole: attachmentActive,
201+
202202 expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published,
203203 action: "lifecycle test", ready: true,
204204 })
@@ -255,7 +255,7 @@ func TestProxyWarmReattachCancelsAndStaleTimerCannotRemoveProxy(t *testing.T) {
255255
256256 ac := &attachedClient{}
257257 proxy.sessionCore.mu.Lock()
258- proxy.client = ac
258+ proxy.sessionCore.registerAttachmentLocked(ac)
259259 proxy.sessionCore.mu.Unlock()
260260 d.cancelProxyWarmForAttachment(proxy, ac)
261261 awaitTestCompletion(t, firstToken.done, "first proxy warm lifecycle did not complete")
@@ -266,7 +266,9 @@ func TestProxyWarmReattachCancelsAndStaleTimerCannotRemoveProxy(t *testing.T) {
266266 d.mu.Unlock()
267267
268268 proxy.sessionCore.mu.Lock()
269- proxy.client = nil
269+ for _, attachment := range proxy.sessionCore.snapshotAttachmentsLocked() {
270+ proxy.sessionCore.unregisterAttachmentLocked(attachment)
271+ }
270272 proxy.sessionCore.mu.Unlock()
271273 d.armProxyWarm(proxy)
272274 secondTimer := awaitTestValue(t, clock.timers, "second proxy timer was not registered")
@@ -464,42 +466,43 @@ func TestProxyLinkStateDisplayAndExactGeneration(t *testing.T) {
464466 require.Equal(t, proxy.name+" [dead]", proxy.statusSegments(false).session)
465467 }
466468
467-func TestIncomingDirectAttachTerminatesProxiedRemoteAttachment(t *testing.T) {
469+func TestIncomingDirectAttachPreservesProxiedRemoteAttachment(t *testing.T) {
468470 d := newTestDaemon(t, nil, newProxyLifecycleClock())
469471 sess := &session{sessionCore: sessionCore{id: "work", name: "work"}}
470472 oldTransport := newProxyTestTransport()
471473 old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), proxied: true}
472474 old.initOverlays()
473475 old.setSession(sess)
474- sess.client = old
476+ sess.registerAttachment(old)
475477 d.mu.Lock()
476478 require.True(t, d.registerSessionLocked(sess))
477479 d.mu.Unlock()
478- d.attachCoordinator(sess, nil, old, true)
480+ d.attachCoordinator(sess, old, true)
479481
480482 newTransport := newProxyTestTransport()
481483 next := &attachedClient{tr: newTransport, output: newOutputStateStream()}
482484 next.initOverlays()
483485 result, err := d.transitionAttachment(attachmentTransitionRequest{
484486 target: sess, next: next,
485- expectedRole: attachmentDetached, targetRole: attachmentActive,
487+
486488 expectedTransport: next.transportSnapshot(), ready: true,
487489 })
488490 require.NoError(t, err)
489- require.Nil(t, result.displaced.ac, "a proxied attachment must never enter the snatched role")
490- require.Same(t, next, sess.client)
491- require.Nil(t, old.currentAttachmentSession())
491+ require.NotNil(t, result.published.ac)
492+ require.Contains(t, sess.snapshotAttachments(), next)
493+ require.Same(t, sess, old.currentAttachmentSession())
492494
493495 d.deferAttachmentTransitionCleanups(result)
494496 d.waitNotifies()
495- frame := awaitTestValue(t, oldTransport.sent, "replacement did not notify old transport")
496- detached, err := ports.UnmarshalDetached(frame.Payload)
497- require.NoError(t, err)
498- require.Equal(t, ports.ReasonReplaced, detached.Reason)
497+ select {
498+ case <-oldTransport.sent:
499+ t.Fatal("publishing an attachment displaced the existing connection")
500+ default:
501+ }
499502 select {
500503 case <-oldTransport.closed:
504+ t.Fatal("publishing an attachment closed the existing connection")
501505 default:
502- t.Fatal("terminal proxied replacement did not close its transport")
503506 }
504507 }
505508
@@ -532,7 +535,7 @@ func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T)
532535 clock := newProxyLifecycleClock()
533536 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
534537 d.clock = clock
535- coordinator := d.attachCoordinator(local, nil, ac, true)
538+ coordinator := d.attachCoordinator(local, ac, true)
536539 localToken := attachmentToken(local, ac, ac.transport())
537540 localToken.lease = coordinator.attachmentLease(ac)
538541
@@ -550,7 +553,7 @@ func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T)
550553
551554 toProxy, err := d.transitionAttachment(attachmentTransitionRequest{
552555 source: local, target: proxy, next: ac,
553- expectedRole: attachmentActive, targetRole: attachmentActive,
556+
554557 expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
555558 action: "replaced picker regression", ready: true,
556559 })
@@ -565,7 +568,7 @@ func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T)
565568
566569 _, err = d.transitionAttachment(attachmentTransitionRequest{
567570 source: proxy, target: local, next: ac,
568- expectedRole: attachmentActive, targetRole: attachmentActive,
571+
569572 expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published,
570573 action: "replaced picker regression", ready: true,
571574 })
@@ -579,7 +582,7 @@ func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T)
579582 require.True(t, ok)
580583 require.Equal(t, proxyWarmDuration, warmTimer.delay)
581584
582- views, _ := d.pickerViews(local)
585+ views, _ := d.pickerViews(local, nil)
583586 var expiredView picker.SessionView
584587 for _, view := range views {
585588 if view.RemoteKey != nil && *view.RemoteKey == key {
@@ -595,7 +598,7 @@ func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T)
595598 require.Equal(t, key.Display()+" [expired]", proxy.lifecycleDisplayName())
596599
597600 d.enterPicker(local, ac)
598- forced := d.newPickerModel(local, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{
601+ forced := d.newPickerModel(local, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{
599602 Session: key.ID(), RemoteKey: &key,
600603 })
601604 ac.overlays.pickerMu.Lock()
@@ -611,10 +614,10 @@ func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T)
611614 require.Same(t, local, ac.currentAttachmentSession(), "picker activation reattached the terminal proxy")
612615
613616 token := attachmentToken(local, ac, ac.transport())
614- effect, admitted := ac.beginRoleEffect(token)
617+ effect, admitted := ac.beginAttachmentEffect(token)
615618 require.True(t, admitted)
616619 token.effect = effect
617- err = d.switchToTargetForRole(token, cursor, sessionHandoffGuard{}, "replaced picker regression")
620+ err = d.switchToTargetForAttachment(token, cursor, sessionHandoffGuard{}, "replaced picker regression")
618621 require.Error(t, err)
619622 require.Same(t, local, ac.currentAttachmentSession(), "defensive activation reused the terminal proxy")
620623
@@ -1085,7 +1088,7 @@ func TestProxyKillConfirmationRequiresExactRemoteDisplay(t *testing.T) {
10851088 require.Equal(t, " Type work@arch to kill ", ac.overlays.prompt.Title())
10861089 submit := ac.overlays.promptTransitionSubmit
10871090 ac.overlays.promptMu.Unlock()
1088- require.Error(t, submit("work", attachmentRoleToken{}))
1091+ require.Error(t, submit("work", attachmentConnectionToken{}))
10891092 d.mu.Lock()
10901093 require.Same(t, proxy, d.sessions[proxy.id])
10911094 d.mu.Unlock()
@@ -1199,7 +1202,7 @@ func TestOpenProxySessionSerializesSameKeyAttachConstruction(t *testing.T) {
11991202 require.Equal(t, 1, awaitTestValue(t, factory.calls, "leader did not dial"))
12001203 _ = requireProxyHello(t, first)
12011204
1202- waitCtx, cancelWait := context.WithCancel(context.Background())
1205+ waitCtx, cancelWait := context.WithCancel(t.Context())
12031206 defer cancelWait()
12041207 observed := &proxyConstructionWaitContext{Context: waitCtx, observed: make(chan struct{})}
12051208 waiter := openProxyForTest(d, observed, key)
@@ -1322,7 +1325,7 @@ func proxyWarmToken(p *proxySession) (*proxyWarmTimer, uint64) {
13221325
13231326 func setProxyLifecycleClient(p *proxySession, ac *attachedClient) {
13241327 p.sessionCore.mu.Lock()
1325- p.client = ac
1328+ p.sessionCore.registerAttachmentLocked(ac)
13261329 p.sessionCore.mu.Unlock()
13271330 }
13281331
@@ -1354,37 +1357,33 @@ func armWarmTimer(t *testing.T, d *Daemon, p *proxySession, clock *warmHookClock
13541357 func TestArmProxyWarmKeepsExpiryPathWhenPublicationFails(t *testing.T) {
13551358 tests := []struct {
13561359 name string
1357- race func(d *Daemon, p *proxySession)
1358- undo func(d *Daemon, p *proxySession)
1360+ race func(*Daemon, *proxySession) func()
13591361 }{
13601362 {
1361- name: "client attaches during arm",
1362- race: func(_ *Daemon, p *proxySession) { setProxyLifecycleClient(p, &attachedClient{}) },
1363- undo: func(_ *Daemon, p *proxySession) { setProxyLifecycleClient(p, nil) },
1364- },
1365- {
1366- name: "replacement rekeys the lifecycle generation",
1367- race: func(_ *Daemon, p *proxySession) {
1368- p.mu.Lock()
1369- p.expired = true
1370- p.generation++
1371- if p.warm != nil {
1372- p.warm.generation = p.generation
1363+ name: "fresh attachment publishes during arm",
1364+ race: func(_ *Daemon, p *proxySession) func() {
1365+ ac := &attachedClient{}
1366+ p.sessionCore.mu.Lock()
1367+ p.sessionCore.registerAttachmentLocked(ac)
1368+ p.sessionCore.mu.Unlock()
1369+ return func() {
1370+ p.sessionCore.mu.Lock()
1371+ p.sessionCore.unregisterAttachmentLocked(ac)
1372+ p.sessionCore.mu.Unlock()
13731373 }
1374- p.mu.Unlock()
13751374 },
13761375 },
13771376 {
1378- name: "daemon starts closing during arm",
1379- race: func(d *Daemon, _ *proxySession) {
1377+ name: "daemon shutdown publishes during arm",
1378+ race: func(d *Daemon, _ *proxySession) func() {
13801379 d.mu.Lock()
13811380 d.closing = true
13821381 d.mu.Unlock()
1383- },
1384- undo: func(d *Daemon, _ *proxySession) {
1385- d.mu.Lock()
1386- d.closing = false
1387- d.mu.Unlock()
1382+ return func() {
1383+ d.mu.Lock()
1384+ d.closing = false
1385+ d.mu.Unlock()
1386+ }
13881387 },
13891388 },
13901389 }
@@ -1393,7 +1392,8 @@ func TestArmProxyWarmKeepsExpiryPathWhenPublicationFails(t *testing.T) {
13931392 d, p, clock := newProxyLifecycleFixture(t)
13941393 token, timer := armWarmTimer(t, d, p, clock)
13951394
1396- clock.setHook(func() { tt.race(d, p) })
1395+ var undo func()
1396+ clock.setHook(func() { undo = tt.race(d, p) })
13971397 require.False(t, d.armProxyWarm(p), "raced re-arm must not publish a replacement")
13981398 awaitTestValue(t, clock.timers, "raced re-arm never reached its clock")
13991399
@@ -1403,9 +1403,8 @@ func TestArmProxyWarmKeepsExpiryPathWhenPublicationFails(t *testing.T) {
14031403 require.Equal(t, generation, retained.generation, "retained timer no longer owns the lifecycle generation")
14041404 require.True(t, proxyRegistered(d, p), "a failed re-arm must not unregister the proxy")
14051405
1406- if tt.undo != nil {
1407- tt.undo(d, p)
1408- }
1406+ require.NotNil(t, undo, "publication race did not run")
1407+ undo()
14091408 timer.ch <- time.Time{}
14101409 awaitTestCompletion(t, token.done, "retained warm timer never ran to completion")
14111410 require.False(t, proxyRegistered(d, p), "retained warm timer did not expire the dormant proxy")
@@ -1544,20 +1543,20 @@ func TestExpireWarmProxyIgnoresSupersededToken(t *testing.T) {
15441543 require.False(t, proxyRegistered(d, p))
15451544 }
15461545
1547-func newProxyRoleDetachFixture(t *testing.T) (*Daemon, *proxySession, *attachedClient, *closeTrackingTransport, *warmHookClock, attachmentRoleToken, *renderCoordinator) {
1546+func newProxyRoleDetachFixture(t *testing.T) (*Daemon, *proxySession, *attachedClient, *closeTrackingTransport, *warmHookClock, attachmentConnectionToken, *renderCoordinator) {
15481547 t.Helper()
15491548 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
15501549 clock := &warmHookClock{timers: make(chan *signalTimer, 4)}
15511550 d.clock = clock
15521551 clientTransport := &closeTrackingTransport{}
15531552 ac.replaceTransport(clientTransport)
1554- localCoordinator := d.attachCoordinator(local, nil, ac, true)
1553+ localCoordinator := d.attachCoordinator(local, ac, true)
15551554 localToken := attachmentToken(local, ac, clientTransport)
15561555 localToken.lease = localCoordinator.attachmentLease(ac)
15571556 proxy := registerLifecycleProxy(t, d, domain.RemoteSessionKey{Host: "arch", Name: "detach"})
15581557 transition, err := d.transitionAttachment(attachmentTransitionRequest{
15591558 source: local, target: proxy, next: ac,
1560- expectedRole: attachmentActive, targetRole: attachmentActive,
1559+
15611560 expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
15621561 action: "proxy detach fixture", ready: true,
15631562 })
@@ -1568,21 +1567,21 @@ func newProxyRoleDetachFixture(t *testing.T) (*Daemon, *proxySession, *attachedC
15681567 func TestProxyRoleDetachRetiresExactClientAndOwnsOneWarmTimer(t *testing.T) {
15691568 tests := []struct {
15701569 name string
1571- detach func(*Daemon, attachmentRoleToken, ports.Transport) bool
1570+ detach func(*Daemon, attachmentConnectionToken, ports.Transport) bool
15721571 }{
15731572 {
15741573 name: "explicit",
1575- detach: func(d *Daemon, token attachmentRoleToken, _ ports.Transport) bool {
1576- effect, admitted := token.ac.beginRoleEffect(token)
1574+ detach: func(d *Daemon, token attachmentConnectionToken, _ ports.Transport) bool {
1575+ effect, admitted := token.ac.beginAttachmentEffect(token)
15771576 require.True(t, admitted)
15781577 token.effect = effect
1579- return d.clientGoneForRole(token, true)
1578+ return d.clientGoneForAttachment(token, true)
15801579 },
15811580 },
15821581 {
15831582 name: "send error",
1584- detach: func(d *Daemon, token attachmentRoleToken, failed ports.Transport) bool {
1585- d.detachOnRoleSendError(token, failed)
1583+ detach: func(d *Daemon, token attachmentConnectionToken, failed ports.Transport) bool {
1584+ d.detachOnAttachmentSendError(token, failed)
15861585 return token.ac.currentAttachmentSession() == nil
15871586 },
15881587 },
@@ -1620,11 +1619,11 @@ func TestProxyRoleDetachRetiresExactClientAndOwnsOneWarmTimer(t *testing.T) {
16201619 func TestProxyRoleDetachRejectsStaleTransportAndRole(t *testing.T) {
16211620 tests := []struct {
16221621 name string
1623- stale func(*attachedClient, *attachmentRoleToken) *closeTrackingTransport
1622+ stale func(*attachedClient, *attachmentConnectionToken) *closeTrackingTransport
16241623 }{
16251624 {
16261625 name: "transport",
1627- stale: func(ac *attachedClient, _ *attachmentRoleToken) *closeTrackingTransport {
1626+ stale: func(ac *attachedClient, _ *attachmentConnectionToken) *closeTrackingTransport {
16281627 current := &closeTrackingTransport{}
16291628 ac.replaceTransport(current)
16301629 return current
@@ -1632,8 +1631,8 @@ func TestProxyRoleDetachRejectsStaleTransportAndRole(t *testing.T) {
16321631 },
16331632 {
16341633 name: "role generation",
1635- stale: func(ac *attachedClient, _ *attachmentRoleToken) *closeTrackingTransport {
1636- ac.roleGeneration.Add(1)
1634+ stale: func(ac *attachedClient, _ *attachmentConnectionToken) *closeTrackingTransport {
1635+ ac.connectionGeneration.Add(1)
16371636 return nil
16381637 },
16391638 },
@@ -1642,7 +1641,7 @@ func TestProxyRoleDetachRejectsStaleTransportAndRole(t *testing.T) {
16421641 t.Run(tt.name, func(t *testing.T) {
16431642 d, proxy, ac, failed, clock, token, _ := newProxyRoleDetachFixture(t)
16441643 current := tt.stale(ac, &token)
1645- d.detachOnRoleSendError(token, failed)
1644+ d.detachOnAttachmentSendError(token, failed)
16461645 require.Same(t, proxy, ac.currentAttachmentSession())
16471646 if current != nil {
16481647 require.False(t, current.Closed(), "stale transport error closed the rebound client")
M internal/usecase/daemon/proxy_link.go +24 -12
@@ -651,18 +651,27 @@ func (d *Daemon) runProxyTransport(ctx context.Context, p *proxySession, generat
651651 }
652652 }
653653
654-// handleProxySideEffect forwards a stateless remote effect to the exact local
655-// attachment that currently owns this proxy. Role admission and transport
656-// incarnation checks make a stale handoff a harmless drop rather than a send
657-// to the client's next session.
654+// handleProxySideEffect fans out a stateless remote effect to every attachment
655+// currently owning this proxy. Each attachment gets an independent capability,
656+// effect ticket, output snapshot, and exact transport-incarnation fence.
658657 func (d *Daemon) handleProxySideEffect(p *proxySession, generation uint64, out ports.Output) error {
659658 if p == nil || !p.currentLinkGeneration(generation) {
660659 return nil
661660 }
662- p.sessionCore.mu.Lock()
663- ac := p.client
664- p.sessionCore.mu.Unlock()
665- if ac == nil || ac.output == nil {
661+ var sendErr error
662+ for _, ac := range snapshotAttachmentSession(p) {
663+ if !p.currentLinkGeneration(generation) {
664+ break
665+ }
666+ if err := d.sendProxySideEffect(p, generation, ac, out); err != nil && sendErr == nil {
667+ sendErr = err
668+ }
669+ }
670+ return sendErr
671+}
672+
673+func (d *Daemon) sendProxySideEffect(p *proxySession, generation uint64, ac *attachedClient, out ports.Output) error {
674+ if p == nil || ac == nil || ac.output == nil || !p.currentLinkGeneration(generation) {
666675 return nil
667676 }
668677 expected := ac.transportSnapshot()
@@ -670,19 +679,22 @@ func (d *Daemon) handleProxySideEffect(p *proxySession, generation uint64, out p
670679 return nil
671680 }
672681 token := attachmentToken(p, ac, expected.transport)
673- if token.role != attachmentActive || token.sess != p {
682+ if token.sess != p || !token.current() {
674683 return nil
675684 }
676- ticket, admitted := ac.beginRoleEffect(token)
685+ ticket, admitted := ac.beginAttachmentEffect(token)
677686 if !admitted {
678687 return nil
679688 }
680689 defer ticket.End()
681- if !p.currentLinkGeneration(generation) {
690+ if !p.currentLinkGeneration(generation) || !token.current() {
682691 return nil
683692 }
684693 frame := ac.output.sideEffect(out.Data, ac.echoAck.Load())
685- if err := ac.sendExpectedTransportForRole(expected, frame, ticket); err != nil {
694+ if !p.currentLinkGeneration(generation) || !token.current() {
695+ return nil
696+ }
697+ if err := ac.sendExpectedTransportForAttachment(expected, frame, ticket); err != nil {
686698 if errors.Is(err, errAttachmentTransition) {
687699 return nil
688700 }
M internal/usecase/daemon/proxy_output.go +3 -4
@@ -91,10 +91,9 @@ func (d *Daemon) handleProxyScreenUpdate(p *proxySession, generation uint64, upd
9191 return nil
9292 }
9393
94- p.sessionCore.mu.Lock()
95- ac := p.client
96- p.sessionCore.mu.Unlock()
97- d.invalidateRender(p, ac, false, "proxy_output.go")
94+ for _, ac := range snapshotAttachmentSession(p) {
95+ d.invalidateRender(p, ac, false, "proxy_output.go")
96+ }
9897 return nil
9998 }
10099
M internal/usecase/daemon/proxy_output_test.go +65 -2
@@ -987,6 +987,69 @@ func TestProxySideEffectForwardsOSC52ToCurrentThinClient(t *testing.T) {
987987 }
988988 }
989989
990+func TestProxySideEffectFansOutToEveryActiveAttachment(t *testing.T) {
991+ d := newTestDaemon(t, nil, stubClock{})
992+ remote := newProxyTestTransport()
993+ firstTransport := newProxyTestTransport()
994+ proxy, first := newAttachedProxyFixture(t, d, firstTransport, remote)
995+ secondTransport := newProxyTestTransport()
996+ second := &attachedClient{tr: secondTransport, output: newOutputStateStream()}
997+ second.setSession(proxy)
998+ proxy.sessionCore.mu.Lock()
999+ require.True(t, proxy.sessionCore.registerAttachmentLocked(second))
1000+ proxy.sessionCore.mu.Unlock()
1001+ first.echoAck.Store(11)
1002+ second.echoAck.Store(22)
1003+
1004+ result, err := d.handleLinkFrame(proxy, 1, proxySideEffectFrame([]byte("effect")))
1005+ require.Equal(t, proxyLinkResume, result)
1006+ require.NoError(t, err)
1007+ for _, tc := range []struct {
1008+ name string
1009+ ch <-chan ports.Frame
1010+ ack uint64
1011+ }{
1012+ {name: "first", ch: firstTransport.sent, ack: 11},
1013+ {name: "second", ch: secondTransport.sent, ack: 22},
1014+ } {
1015+ t.Run(tc.name, func(t *testing.T) {
1016+ frame := awaitTestValue(t, tc.ch, tc.name+" attachment did not receive side effect")
1017+ out, err := ports.UnmarshalOutput(frame.Payload)
1018+ require.NoError(t, err)
1019+ require.Equal(t, tc.ack, out.EchoAck)
1020+ require.Equal(t, []byte("effect"), out.Data)
1021+ })
1022+ }
1023+}
1024+
1025+func TestProxySideEffectSkipsStaleAttachmentAndForwardsLiveAttachment(t *testing.T) {
1026+ d := newTestDaemon(t, nil, stubClock{})
1027+ remote := newProxyTestTransport()
1028+ staleTransport := newProxyTestTransport()
1029+ proxy, stale := newAttachedProxyFixture(t, d, staleTransport, remote)
1030+ liveTransport := newProxyTestTransport()
1031+ live := &attachedClient{tr: liveTransport, output: newOutputStateStream()}
1032+ live.setSession(proxy)
1033+ proxy.sessionCore.mu.Lock()
1034+ require.True(t, proxy.sessionCore.registerAttachmentLocked(live))
1035+ proxy.sessionCore.mu.Unlock()
1036+ staleToken := attachmentToken(proxy, stale, stale.transport())
1037+ staleEffect, admitted := stale.beginAttachmentEffect(staleToken)
1038+ require.True(t, admitted)
1039+ staleEffect.End()
1040+ stale.connectionGeneration.Add(1)
1041+
1042+ result, err := d.handleLinkFrame(proxy, 1, proxySideEffectFrame([]byte("effect")))
1043+ require.Equal(t, proxyLinkResume, result)
1044+ require.NoError(t, err)
1045+ awaitTestValue(t, liveTransport.sent, "live attachment did not receive side effect")
1046+ select {
1047+ case frame := <-staleTransport.sent:
1048+ t.Fatalf("stale attachment received side effect: %v", frame.Type)
1049+ default:
1050+ }
1051+}
1052+
9901053 func TestProxySideEffectRejectsStateBearingAndMalformedMsgOutput(t *testing.T) {
9911054 tests := []struct {
9921055 name string
@@ -1031,10 +1094,10 @@ func TestProxySideEffectDropsStaleGenerationAndRole(t *testing.T) {
10311094 }
10321095
10331096 token := attachmentToken(proxy, ac, ac.transport())
1034- ticket, admitted := ac.beginRoleEffect(token)
1097+ ticket, admitted := ac.beginAttachmentEffect(token)
10351098 require.True(t, admitted)
10361099 ticket.End()
1037- ac.roleGeneration.Add(1)
1100+ ac.connectionGeneration.Add(1)
10381101 result, err = d.handleLinkFrame(proxy, 1, frame)
10391102 require.Equal(t, proxyLinkResume, result)
10401103 require.NoError(t, err)
M internal/usecase/daemon/proxy_render.go +4 -4
@@ -17,11 +17,11 @@ type proxyCapture struct {
1717 cursor capturedCursorInputs
1818 }
1919
20-// capturePrimary snapshots the remote VT while holding only proxy.mu. The
20+// captureRenderState snapshots the remote VT while holding only proxy.mu. The
2121 // snapshot is then composed by the ordinary local frame pipeline, which owns
2222 // the bars, overlays, cursor, and renderer shadow. Remote ANSI is therefore
2323 // never handed to a renderer.
24-func (p *proxySession) capturePrimary(ac *attachedClient, request primaryCaptureRequest) (*capturedRenderState, bool) {
24+func (p *proxySession) captureRenderState(ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) {
2525 if p == nil || ac == nil {
2626 return nil, false
2727 }
@@ -35,7 +35,7 @@ func (p *proxySession) capturePrimary(ac *attachedClient, request primaryCapture
3535 capture := &ac.proxyCapture
3636 damage := screen.CaptureDamage()
3737 if capture.screen != screen {
38- // A replacement screen has no relationship to the retained frame.
38+ // A new screen has no relationship to the retained frame.
3939 capture.frame = renderer.Frame{}
4040 }
4141 if capture.screen != screen || capture.generation != damage.Generation || capture.frame.Validate() != nil {
@@ -152,7 +152,7 @@ func (p *proxySession) resize(size domain.Size) (changed, sent bool) {
152152 p.screen.ResizePlaceholder(content)
153153 p.contentSize = content
154154 // A resize invalidates the local replay chain, but the last applied state
155- // remains the moving snapshot floor for the replacement geometry.
155+ // remains the moving snapshot floor for the new geometry.
156156 p.screenReady = false
157157 p.resetRequested = false
158158 p.screen.stateNum = 0
M internal/usecase/daemon/proxy_render_test.go +13 -13
@@ -25,7 +25,7 @@ func TestProxyCaptureRetainsOwnedFrameAcrossSafeDamage(t *testing.T) {
2525 proxy.mu.Unlock()
2626
2727 ac := &attachedClient{}
28- state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{})
28+ state, ok := proxy.captureRenderState(ac, renderCaptureRequest{})
2929 require.True(t, ok)
3030 require.Equal(t, "abcdefghijkl", proxyFrameText(state.panes[0].frame))
3131 commitDamageReceipts(state.receipts)
@@ -43,7 +43,7 @@ func TestProxyCaptureRetainsOwnedFrameAcrossSafeDamage(t *testing.T) {
4343 proxy.mu.Unlock()
4444
4545 cellsBefore := &ac.proxyCapture.frame.Cells[0]
46- state, ok = proxy.capturePrimary(ac, primaryCaptureRequest{})
46+ state, ok = proxy.captureRenderState(ac, renderCaptureRequest{})
4747 require.True(t, ok)
4848 require.Equal(t, "abcdeZghijkl", proxyFrameText(state.panes[0].frame))
4949 require.Equal(t, cellsBefore, &ac.proxyCapture.frame.Cells[0], "safe span damage must update the retained frame")
@@ -59,7 +59,7 @@ func TestProxyRenderComposesRemoteVTUnderExactlyOneLocalChrome(t *testing.T) {
5959 changed := applyTestScreenText(proxy, 1, 2, "remote")
6060 require.True(t, changed)
6161
62- state, ok := proxy.capturePrimary(&attachedClient{}, primaryCaptureRequest{
62+ state, ok := proxy.captureRenderState(&attachedClient{}, renderCaptureRequest{
6363 bars: barState{status: proxy.statusSegments(false)},
6464 reset: true,
6565 })
@@ -91,7 +91,7 @@ func TestProxyRenderPaintsAfterLocalToRemoteHandoff(t *testing.T) {
9191 ac := &attachedClient{tr: transport, output: newOutputStateStream(), size: domain.Size{Cols: 16, Rows: 6}}
9292 ac.setSession(proxy)
9393 proxy.sessionCore.mu.Lock()
94- proxy.client = ac
94+ proxy.sessionCore.registerAttachmentLocked(ac)
9595 proxy.sessionCore.mu.Unlock()
9696
9797 d.paint(proxy, ac, true, nil)
@@ -101,7 +101,7 @@ func TestProxyRenderPaintsAfterLocalToRemoteHandoff(t *testing.T) {
101101
102102 func TestProxyRenderTransitionsPaintBothLocalAndRemoteSessions(t *testing.T) {
103103 d, local, ac, sent := newManualSessionWithPTYs(t, newQuietPTY())
104- localCoordinator := d.attachCoordinator(local, nil, ac, true)
104+ localCoordinator := d.attachCoordinator(local, ac, true)
105105 localToken := attachmentToken(local, ac, ac.transport())
106106 localToken.lease = localCoordinator.attachmentLease(ac)
107107
@@ -118,7 +118,7 @@ func TestProxyRenderTransitionsPaintBothLocalAndRemoteSessions(t *testing.T) {
118118
119119 toProxy, err := d.transitionAttachment(attachmentTransitionRequest{
120120 source: local, target: proxy, next: ac,
121- expectedRole: attachmentActive, targetRole: attachmentActive,
121+
122122 expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
123123 action: "test transition", ready: true,
124124 })
@@ -129,7 +129,7 @@ func TestProxyRenderTransitionsPaintBothLocalAndRemoteSessions(t *testing.T) {
129129
130130 toLocal, err := d.transitionAttachment(attachmentTransitionRequest{
131131 source: proxy, target: local, next: ac,
132- expectedRole: attachmentActive, targetRole: attachmentActive,
132+
133133 expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published,
134134 action: "test transition", ready: true,
135135 })
@@ -269,7 +269,7 @@ func TestProxyResizeUsesReducedRemoteGeometryOnce(t *testing.T) {
269269 default:
270270 }
271271
272- state, ok := proxy.capturePrimary(&attachedClient{}, primaryCaptureRequest{reset: true})
272+ state, ok := proxy.captureRenderState(&attachedClient{}, renderCaptureRequest{reset: true})
273273 require.True(t, ok)
274274 composed := composeFrame(*state, composeCacheInput{})
275275 require.Equal(t, 10, composed.frame.Height, "local viewport remains full-sized")
@@ -296,7 +296,7 @@ func newAttachedProxyFixture(t *testing.T, d *Daemon, clientTr ports.Transport,
296296 ac := &attachedClient{tr: clientTr, output: newOutputStateStream(), size: domain.Size{Cols: 16, Rows: 6}}
297297 ac.setSession(proxy)
298298 proxy.sessionCore.mu.Lock()
299- proxy.client = ac
299+ proxy.sessionCore.registerAttachmentLocked(ac)
300300 proxy.sessionCore.mu.Unlock()
301301 return proxy, ac
302302 }
@@ -318,7 +318,7 @@ func TestProxyStructuredSendFailureRetainsProxyDamage(t *testing.T) {
318318 require.True(t, applyTestScreenText(proxy, 0, 0, "pending"))
319319
320320 ac.sendMu.Lock()
321- state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: true})
321+ state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: true})
322322 require.True(t, ok)
323323 composed := composeFrame(*state, composeCacheInput{})
324324 require.True(t, d.emitFrame(proxy, ac, state, composed))
@@ -373,7 +373,7 @@ func TestProxyPrepareFailureRepaintsWithoutLocalSession(t *testing.T) {
373373 proxy, ac := newAttachedProxyFixture(t, d, clientTr, newProxyTestTransport())
374374
375375 ac.sendMu.Lock() // emitFrame releases the transaction lock.
376- state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{bars: barState{status: proxy.statusSegments(false)}, reset: true})
376+ state, ok := proxy.captureRenderState(ac, renderCaptureRequest{bars: barState{status: proxy.statusSegments(false)}, reset: true})
377377 require.True(t, ok)
378378 composed := composeFrame(*state, composeCacheInput{})
379379 // A malformed frame is what makes outputStateStream.prepare fail.
@@ -394,9 +394,9 @@ func TestProxySendErrorDetachesProxyAttachment(t *testing.T) {
394394 d.paint(proxy, ac, true, nil)
395395
396396 proxy.sessionCore.mu.Lock()
397- client := proxy.client
397+ attachments := proxy.sessionCore.snapshotAttachmentsLocked()
398398 proxy.sessionCore.mu.Unlock()
399- require.Nil(t, client, "a failed send must release the proxy attachment")
399+ require.Empty(t, attachments, "a failed send must release the proxy attachment")
400400 require.Nil(t, ac.currentAttachmentSession(), "the client must no longer point at the proxy")
401401 }
402402
M internal/usecase/daemon/proxy_session.go +4 -7
@@ -122,10 +122,7 @@ func (d *Daemon) applyProxySessionMeta(p *proxySession, generation uint64, meta
122122 p.mu.Unlock()
123123
124124 d.pokeAttentionTicker()
125- p.sessionCore.mu.Lock()
126- ac := p.client
127- p.sessionCore.mu.Unlock()
128- if ac != nil {
125+ for _, ac := range snapshotAttachmentSession(p) {
129126 d.invalidateRender(p, ac, false, "proxy_session.go")
130127 }
131128 return true, nil
@@ -151,12 +148,12 @@ func (p *proxySession) snapshotView(opts viewOptions) sessionView {
151148 expired: expired,
152149 createdAt: p.createdAt,
153150 mruAt: p.mruAt.Load(),
154- active: int(meta.Active),
151+ defaultTab: 0,
155152 tabCount: len(meta.Tabs),
156153 cannotAcceptMoves: true,
157154 }
158155 p.sessionCore.mu.Lock()
159- view.attached = p.client != nil
156+ view.attached = len(p.attachments) != 0
160157 p.sessionCore.mu.Unlock()
161158 if opts.tabDetails {
162159 view.tabs = make([]tabView, len(meta.Tabs))
@@ -194,7 +191,7 @@ func (p *proxySession) statusSegments(_ bool) statusSnapshot {
194191 return snapshot
195192 }
196193
197-func (p *proxySession) activateTargetLocked(tabIndex int) bool {
194+func (p *proxySession) validTargetTabLocked(tabIndex int) bool {
198195 if tabIndex < 0 {
199196 return true
200197 }
M internal/usecase/daemon/remote_picker_test.go +22 -56
@@ -83,7 +83,7 @@ func TestRemotePickerMerge(t *testing.T) {
8383 d.remoteCatalog.status["learned"] = remoteHostFresh
8484 d.remoteCatalog.mu.Unlock()
8585
86- views, _ := d.pickerViews(local)
86+ views, _ := d.pickerViews(local, nil)
8787
8888 require.Equal(t, []string{"local", "work@pinned", "alpha@pinned", "alpha@learned", "stopped"}, pickerViewNames(views))
8989 require.False(t, views[0].Stopped)
@@ -113,7 +113,7 @@ func TestRemotePickerDedupeUsesExactKeyAndKeepsLiveID(t *testing.T) {
113113 },
114114 }})
115115
116- views, _ := d.pickerViews(nil)
116+ views, _ := d.pickerViews(nil, nil)
117117
118118 require.Len(t, views, 1)
119119 require.Equal(t, key.ID(), views[0].ID)
@@ -132,7 +132,7 @@ func TestRemotePickerSnapshotsDoNotRetainProxyLock(t *testing.T) {
132132 }
133133 d.sessions[key.ID()] = proxy
134134
135- views, _ := d.pickerViews(nil)
135+ views, _ := d.pickerViews(nil, nil)
136136
137137 require.Len(t, views, 1)
138138 require.True(t, proxy.mu.TryLock(), "picker construction must release the proxy leaf lock")
@@ -162,7 +162,7 @@ func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
162162 d.remoteCatalog.status["arch"] = test.status
163163 d.remoteCatalog.mu.Unlock()
164164
165- views, _ := d.pickerViews(nil)
165+ views, _ := d.pickerViews(nil, nil)
166166
167167 require.Len(t, views, 1)
168168 require.Equal(t, test.want, views[0].RemoteAvailability)
@@ -182,7 +182,7 @@ func TestRemotePickerStaleDetailUsesLastSuccessfulFetch(t *testing.T) {
182182 d.remoteCatalog.failure["arch"] = failedAt
183183 d.remoteCatalog.mu.Unlock()
184184
185- views, _ := d.pickerViews(nil)
185+ views, _ := d.pickerViews(nil, nil)
186186
187187 require.Len(t, views, 1)
188188 require.Equal(t, "stale since "+fetchedAt.Format(time.RFC3339), views[0].RemoteDetail)
@@ -206,7 +206,7 @@ func TestRemotePickerNoCacheHostFailuresRemainVisible(t *testing.T) {
206206 d.remoteCatalog.failure["arch"] = time.Unix(50, 0)
207207 d.remoteCatalog.mu.Unlock()
208208
209- views, _ := d.pickerViews(nil)
209+ views, _ := d.pickerViews(nil, nil)
210210
211211 require.Len(t, views, 1)
212212 require.Equal(t, "arch", views[0].Name)
@@ -233,7 +233,7 @@ func TestRemotePickerGroupedSortTreatsProxiesAsLiveSessions(t *testing.T) {
233233 d.sessions[proxy.id] = proxy
234234 d.sessions[named.id] = named
235235
236- views, _ := d.pickerViews(nil)
236+ views, _ := d.pickerViews(nil, nil)
237237
238238 require.Equal(t, []string{"named", "work@arch", "1"}, pickerViewNames(views))
239239 }
@@ -539,7 +539,7 @@ func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID) (
539539 ac.initOverlays()
540540 tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
541541 tb.stableID = string(id) + "-tab"
542- sess := &session{sessionCore: sessionCore{id: id, name: string(id), client: ac}, tabs: []*tab{tb}, active: 0}
542+ sess := &session{sessionCore: sessionCore{id: id, name: string(id), attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}}
543543 publishTiledPaneOwners(sess, tb)
544544 ac.setSession(sess)
545545 d.sessions[id] = sess
@@ -555,7 +555,7 @@ func TestRemoteRefreshUpdatesAllOpenPickersPreservingSelection(t *testing.T) {
555555
556556 for _, owner := range []*attachedClient{first, second} {
557557 owner.overlays.pickerMu.Lock()
558- owner.overlays.picker = d.newPickerModel(firstSession, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: "second", TabID: "second-tab"})
558+ owner.overlays.picker = d.newPickerModel(firstSession, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: "second", TabID: "second-tab"})
559559 owner.overlays.pickerIntent = pickerNavigate
560560 owner.overlays.pickerGeneration++
561561 instance := remotePickerInstance{ac: owner, generation: owner.overlays.pickerGeneration, model: owner.overlays.picker}
@@ -600,7 +600,7 @@ func TestRemotePickerTeardownLifecycle(t *testing.T) {
600600 d := newRemotePickerDaemon(nil)
601601 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
602602 ac.resumeCapable = test.resumeCapable
603- model := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
603+ model := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
604604 d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
605605 ac.overlays.pickerMu.Lock()
606606 generation := ac.overlays.pickerGeneration
@@ -639,7 +639,7 @@ func TestParkedPickerExpiryClosesCapturedGenerationAndCancelsRefresh(t *testing.
639639 d.clock = clock
640640 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
641641 ac.resumeCapable = true
642- d.publishPicker(sess, ac, d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
642+ d.publishPicker(sess, ac, d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
643643 request := receiveRemotePicker(t, catalog.requests, "catalog request")
644644
645645 require.True(t, d.parkAttachment(sess, ac))
@@ -659,7 +659,7 @@ func TestParkedPickerRetirementClosesCapturedGenerationOnly(t *testing.T) {
659659 d := newRemotePickerDaemon(nil)
660660 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
661661 ac.resumeCapable = true
662- d.publishPicker(sess, ac, d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
662+ d.publishPicker(sess, ac, d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
663663 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
664664 ac.overlays.pickerMu.Lock()
665665 refreshed := ac.overlays.picker
@@ -686,10 +686,10 @@ func TestParkedPickerTokenReplacementRetiresPreviousGeneration(t *testing.T) {
686686 firstSession, first, _ := addRemoteRefreshPickerOwner(t, d, "first")
687687 secondSession, second, _ := addRemoteRefreshPickerOwner(t, d, "second")
688688 first.resumeCapable, second.resumeCapable = true, true
689- d.publishPicker(firstSession, first, d.newPickerModel(firstSession, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
689+ d.publishPicker(firstSession, first, d.newPickerModel(firstSession, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
690690 require.True(t, d.parkAttachment(firstSession, first))
691691 token := first.resumeToken
692- d.publishPicker(secondSession, second, d.newPickerModel(secondSession, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
692+ d.publishPicker(secondSession, second, d.newPickerModel(secondSession, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
693693 second.resumeToken = token
694694 require.True(t, d.parkAttachment(secondSession, second))
695695
@@ -706,7 +706,7 @@ func TestParkedPickerResumePreservesGeneration(t *testing.T) {
706706 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
707707 ac.resumeCapable = true
708708 ac.clientID = [16]byte{1, 2, 3, 4}
709- d.publishPicker(sess, ac, d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
709+ d.publishPicker(sess, ac, d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
710710 ac.overlays.pickerMu.Lock()
711711 model, generation := ac.overlays.picker, ac.overlays.pickerGeneration
712712 ac.overlays.pickerMu.Unlock()
@@ -728,7 +728,7 @@ func TestStaleParkedPickerExpiryPreservesNewGeneration(t *testing.T) {
728728 d := newRemotePickerDaemon(nil)
729729 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
730730 ac.resumeCapable = true
731- d.publishPicker(sess, ac, d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
731+ d.publishPicker(sess, ac, d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
732732 require.True(t, d.parkAttachment(sess, ac))
733733 token := ac.resumeToken
734734 d.mu.Lock()
@@ -736,7 +736,7 @@ func TestStaleParkedPickerExpiryPreservesNewGeneration(t *testing.T) {
736736 d.mu.Unlock()
737737 require.NotNil(t, parked)
738738
739- newer := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
739+ newer := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
740740 d.publishPicker(sess, ac, newer, pickerNavigate, moveSourceLocator{})
741741 d.expireParked(token, parked)
742742 ac.overlays.pickerMu.Lock()
@@ -752,7 +752,7 @@ func TestRemoteRefreshPreservesRemoteCursorAcrossCacheToProxyUpgrade(t *testing.
752752 Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: key.Name, State: "running"}},
753753 }})
754754 owner.overlays.pickerMu.Lock()
755- owner.overlays.picker = d.newPickerModel(ownerSession, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key})
755+ owner.overlays.picker = d.newPickerModel(ownerSession, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key})
756756 owner.overlays.pickerIntent = pickerNavigate
757757 before, ok := owner.overlays.picker.Cursor()
758758 _, beforeSelectable := owner.overlays.picker.Selected()
@@ -861,44 +861,10 @@ func TestRemoteRefreshCannotStartAfterLastPickerCloseWins(t *testing.T) {
861861 }
862862 }
863863
864-func TestRemotePickerPublishCannotRegisterAfterSnatchClear(t *testing.T) {
865- d := newRemotePickerDaemon(nil)
866- sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
867- ac.roleGeneration.Store(1)
868- model := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
869- registrationReached := make(chan struct{})
870- allowRegistration := make(chan struct{})
871- registrationWait := make(chan error, 1)
872- ac.overlays.beforeRemotePickerRegistration = func() {
873- close(registrationReached)
874- registrationWait <- waitRemotePickerClose(allowRegistration, "picker registration release")
875- }
876- published := make(chan struct{})
877- go func() {
878- d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
879- close(published)
880- }()
881- receiveRemotePickerClose(t, registrationReached, "picker registration")
882-
883- token := attachmentRoleToken{
884- sess: sess, ac: ac, role: attachmentSnatched,
885- generation: 1, transport: ac.transportSnapshot(),
886- }
887- require.True(t, d.clearForSnatch(token))
888- close(allowRegistration)
889- require.NoError(t, receiveRemotePicker(t, registrationWait, "picker registration release"))
890- receiveRemotePickerClose(t, published, "picker publication")
891-
892- d.remoteCatalog.mu.Lock()
893- _, registered := d.remoteCatalog.pickers[ac]
894- d.remoteCatalog.mu.Unlock()
895- require.False(t, registered, "a picker cleared before delayed registration must not become a refresh owner")
896-}
897-
898864 func TestRemotePickerStaleAfterCloseCannotRemoveReopenedPicker(t *testing.T) {
899865 d := newRemotePickerDaemon(nil)
900866 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
901- first := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
867+ first := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
902868 d.publishPicker(sess, ac, first, pickerNavigate, moveSourceLocator{})
903869
904870 staleClose := d.pickerListInputState(ac)
@@ -906,7 +872,7 @@ func TestRemotePickerStaleAfterCloseCannotRemoveReopenedPicker(t *testing.T) {
906872 staleClose.closeLocked()
907873 ac.overlays.pickerMu.Unlock()
908874
909- reopened := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
875+ reopened := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
910876 d.publishPicker(sess, ac, reopened, pickerNavigate, moveSourceLocator{})
911877 staleClose.afterClose()
912878
@@ -922,7 +888,7 @@ func TestRemotePickerStaleAfterCloseCannotRemoveReopenedPicker(t *testing.T) {
922888 func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {
923889 d := newRemotePickerDaemon(nil)
924890 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
925- first := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
891+ first := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
926892 d.publishPicker(sess, ac, first, pickerNavigate, moveSourceLocator{})
927893
928894 rebuildReached := make(chan struct{})
@@ -940,7 +906,7 @@ func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {
940906 receiveRemotePickerClose(t, rebuildReached, "picker rebuild")
941907
942908 d.closePicker(ac)
943- reopened := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
909+ reopened := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
944910 d.publishPicker(sess, ac, reopened, pickerNavigate, moveSourceLocator{})
945911 close(allowPublication)
946912 require.NoError(t, receiveRemotePicker(t, publicationWait, "picker publication release"))
M internal/usecase/daemon/render.go +24 -25
@@ -26,8 +26,8 @@ func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool {
2626 return false
2727 }
2828 sess.mu.Lock()
29- active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
30- attached := sess.client != nil
29+ active := sess.attachmentViewsTabLocked(tb)
30+ attached := len(sess.attachments) != 0
3131 sess.mu.Unlock()
3232
3333 // The normal attached render path needs no cross-session picker lookup.
@@ -63,17 +63,16 @@ func (d *Daemon) tabIsPickerPreview(tb *tab) bool {
6363 sessions := localSessionsSnapshot(d.sessions)
6464 d.mu.Unlock()
6565 for _, sess := range sessions {
66- sess.mu.Lock()
67- ac := sess.client
68- sess.mu.Unlock()
69- if ac == nil || ac.overlays == nil {
70- continue
71- }
72- ac.overlays.pickerMu.Lock()
73- preview := ac.overlays.pickerPreview == tb
74- ac.overlays.pickerMu.Unlock()
75- if preview {
76- return true
66+ for _, ac := range sess.snapshotAttachments() {
67+ if ac == nil || ac.overlays == nil {
68+ continue
69+ }
70+ ac.overlays.pickerMu.Lock()
71+ preview := ac.overlays.pickerPreview == tb
72+ ac.overlays.pickerMu.Unlock()
73+ if preview {
74+ return true
75+ }
7776 }
7877 }
7978 return false
@@ -259,9 +258,9 @@ func (d *Daemon) observeRuntime(kind ports.RuntimeMarkKind, bytes uint64, valid
259258 // paint owns attachment sendMu while it captures, composes, and emits; JSONL
260259 // observer I/O must therefore happen only after that ownership is released.
261260 type runtimeMarkBatch struct {
262- observer ports.RuntimeObserver
263- marks []ports.RuntimeMark
264- roleEffect *roleEffectTicket
261+ observer ports.RuntimeObserver
262+ marks []ports.RuntimeMark
263+ attachmentEffect *attachmentEffectTicket
265264 }
266265
267266 func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch {
@@ -305,22 +304,22 @@ func (b *runtimeMarkBatch) flush() {
305304 func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, lease *attachmentLease) {
306305 // Local sessions retain their tab/PTY preparation below. Proxy sessions
307306 // enter the same attachment pipeline but supply their VT snapshot through
308- // attachmentSession.capturePrimary.
307+ // attachmentSession.captureRenderState.
309308 sess, local := localSession(entry)
310309 marks := d.newRuntimeMarkBatch()
311310 if lease != nil {
312311 token := attachmentToken(entry, ac, ac.transport())
313312 token.lease = lease
314- ticket, admitted := ac.beginRoleEffect(token)
313+ ticket, admitted := ac.beginAttachmentEffect(token)
315314 if !admitted {
316315 return
317316 }
318- marks.roleEffect = ticket
317+ marks.attachmentEffect = ticket
319318 defer ticket.End()
320319 }
321320 var tb *tab
322321 if local {
323- tb = sess.activeTab()
322+ tb = sess.tabForAttachment(ac)
324323 if tb == nil {
325324 return
326325 }
@@ -331,7 +330,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
331330 // published identity while holding it so a deadline captured before an
332331 // attach/replace cannot emit on either the old or new output chain.
333332 entry.core().mu.Lock()
334- owned := entry.core().client == ac
333+ _, owned := entry.core().attachments[ac]
335334 entry.core().mu.Unlock()
336335 if !owned || ac.currentAttachmentSession() != entry {
337336 ac.sendMu.Unlock()
@@ -382,12 +381,12 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
382381 if statusFeedback == "" && overlays.resizeActive {
383382 statusFeedback = "resize: h/j/k/l or arrows · = equalize · q/esc/enter exit"
384383 }
385- bars := d.barStateForAttachmentPaletteHints(entry, statusFeedback, overlays.paletteHints, overlays.paletteRecent)
384+ bars := d.barStateForAttachmentPaletteHintsFor(entry, ac, statusFeedback, overlays.paletteHints, overlays.paletteRecent)
386385 applied := ac.getAppliedTheme()
387386 bars.theme = applied.Raw
388387 if local {
389388 attentionVisible := pulseVisible(bars.attentionFrame)
390- repaintAttachedClients = sess.ackAttention(tb, attentionVisible)
389+ repaintAttachedClients = sess.ackAttention(tb, ac, attentionVisible)
391390 }
392391
393392 paletteCfg := d.currentPaletteConfig()
@@ -405,7 +404,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
405404 hasFloating := tb.floating.state == floatingVisible && floating != nil
406405 tb.mu.Unlock()
407406 for _, id := range titleIDs {
408- d.refreshPaneTitle(sess, id)
407+ d.refreshPaneTitle(sess, id, tb)
409408 }
410409 if hasFloating {
411410 d.refreshPaneDisplayTitle(sess, floating, false)
@@ -418,7 +417,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
418417 resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,
419418 }
420419 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
421- state, ok := entry.capturePrimary(ac, primaryCaptureRequest{
420+ state, ok := entry.captureRenderState(ac, renderCaptureRequest{
422421 bars: bars,
423422 overlays: capturedOverlays,
424423 preview: preview,
M internal/usecase/daemon/render_capture.go +21 -14
@@ -182,7 +182,7 @@ func uncertainDamage(damage []renderer.Damage, width, height int) bool {
182182 return false
183183 }
184184
185-type primaryCaptureRequest struct {
185+type renderCaptureRequest struct {
186186 bars barState
187187 overlays capturedOverlayRenderState
188188 preview picker.Preview
@@ -193,14 +193,14 @@ type primaryCaptureRequest struct {
193193 lease *attachmentLease
194194 }
195195
196-// captureLocalPrimaryRenderState is the ownership boundary for a local primary
196+// captureLocalRenderState is the ownership boundary for a local attachment
197197 // render transaction. Callers hold attachment sendMu; this function then follows
198198 // session -> tab -> pane lock order. ACK-blocked capture returns before touching
199199 // VT damage, and every captured pane records a receipt for successful emission.
200-func captureLocalPrimaryRenderState(
200+func captureLocalRenderState(
201201 sess *session,
202202 ac *attachedClient,
203- request primaryCaptureRequest,
203+ request renderCaptureRequest,
204204 ) (*capturedRenderState, bool) {
205205 bars := request.bars
206206 overlays := request.overlays
@@ -212,12 +212,18 @@ func captureLocalPrimaryRenderState(
212212 return nil, false
213213 }
214214 sess.mu.Lock()
215- if sess.client != ac || sess.active < 0 || sess.active >= len(sess.tabs) {
216- sess.mu.Unlock()
215+ _, owned := sess.attachments[ac]
216+ sess.mu.Unlock()
217+ if !owned {
218+ return nil, false
219+ }
220+ tb, focusedPane := sess.paneForAttachment(ac)
221+ if tb == nil {
222+ tb = sess.tabForAttachment(ac)
223+ }
224+ if tb == nil {
217225 return nil, false
218226 }
219- tb := sess.tabs[sess.active]
220- sess.mu.Unlock()
221227
222228 scratch := &ac.renderScratch
223229 scratch.statusTabs = append(scratch.statusTabs[:0], bars.status.tabs...)
@@ -238,8 +244,8 @@ func captureLocalPrimaryRenderState(
238244 defer tb.mu.Unlock()
239245 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
240246 var focus layout.PaneID
241- if tb.tree != nil {
242- focus = tb.tree.Focus
247+ if focusedPane != nil && tb.panes[focusedPane.id] == focusedPane {
248+ focus = focusedPane.id
243249 }
244250 layoutSnap := tabLayoutSnapshot{
245251 area: area, focus: focus, placements: scratch.placements,
@@ -247,6 +253,7 @@ func captureLocalPrimaryRenderState(
247253 }
248254 if scratch.layoutTab != tb || scratch.layoutGeneration != tb.layoutGeneration || scratch.layoutArea != area || scratch.layoutFocus != focus {
249255 layoutSnap = solveTabLayoutLocked(tb)
256+ layoutSnap.focus = focus
250257 scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)
251258 scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...)
252259 scratch.layoutTab = tb
@@ -324,10 +331,10 @@ func captureLocalPrimaryRenderState(
324331 return state, true
325332 }
326333
327-// capturePrimaryRenderState preserves the focused local test seam. Production
328-// rendering delegates through attachmentSession.capturePrimary.
329-func capturePrimaryRenderState(sess *session, ac *attachedClient, request primaryCaptureRequest) (*capturedRenderState, bool) {
330- return captureLocalPrimaryRenderState(sess, ac, request)
334+// captureRenderState preserves the focused local test seam. Production
335+// rendering delegates through attachmentSession.captureRenderState.
336+func captureRenderState(sess *session, ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) {
337+ return captureLocalRenderState(sess, ac, request)
331338 }
332339
333340 // copyRankedRecentInto preserves the non-nil empty slice that selects
M internal/usecase/daemon/render_coordinator.go +395 -169
@@ -21,13 +21,27 @@ type renderCoordinator struct {
2121 // enqueue until fire consumes the coalesced work.
2222 queueMarked bool
2323 queueCorrelation ports.RuntimeCorrelation
24- // pendingPreview is accounted separately from the target primary wake:
25- // viewers may consume a coalesced target snapshot while that target is
24+ // pendingPreview is accounted separately from the attachment wake:
25+ // viewers may consume a coalesced target snapshot while output is
2626 // still ACK-blocked.
2727 pendingPreview bool
28- // ackDeferred records that an expired deadline or watchdog was blocked by
29- // output-window capacity. ACK notifications may flush only this state.
30- ackDeferred bool
28+ // ackDeferred records that at least one attachment's render wake was
29+ // blocked by output-window capacity. ACK notifications use ackDeferredFor
30+ // to flush only the acknowledging attachment's work.
31+ ackDeferred bool
32+ ackDeferredFor map[*attachedClient]bool
33+ // pendingGeneration identifies the latest coalesced session mutation.
34+ // delivered records which attachment incarnations have consumed it, so a
35+ // blocked peer can catch up without replaying the wake to healthy peers.
36+ pendingGeneration uint64
37+ delivered map[*attachedClient]uint64
38+ // pendingShared is set by session-owned producers. Attachment-owned
39+ // invalidations retain only their exact target lease here.
40+ pendingShared bool
41+ 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
3145 // ackBlocked retains the first rejected ACK-capacity probe until the
3246 // matching consume or lifecycle closure releases that exact interval.
3347 ackBlocked *ackBlockedSpan
@@ -40,17 +54,16 @@ type renderCoordinator struct {
4054 // batch perpetually reset its own timer.
4155 outputPressure int
4256
43- // previewWake receives the same coalesced wakes for the legacy single
44- // subscriber. previewWakes tracks picker subscriptions by viewer, so one
45- // inactive session cannot replace another viewer's live preview.
46- previewWake func(renderWake)
57+ // previewWakes tracks picker subscriptions by viewer, so one inactive session
58+ // cannot replace another viewer's live preview.
4759 previewWakes map[*attachedClient]previewSubscription
4860
49- // lease is the sole primary attachment lifecycle state. A revoked lease is
50- // retained after detach so headless preview delivery remains distinguishable
51- // from an unbound test coordinator.
52- lease *attachmentLease
53- torndown bool
61+ // attachments and leases are coordinator-owned lifecycle snapshots. Shared
62+ // render work is not tied to any one attachment; leases only fence callbacks
63+ // for the attachment that captured them.
64+ attachments map[*attachedClient]struct{}
65+ leases map[*attachedClient]*attachmentLease
66+ torndown bool
5467
5568 resize resizeRequestMetadata
5669 // Each lane owns one complete immutable worker token at a time.
@@ -107,13 +120,13 @@ func (c *renderCoordinator) detachSyncTimersLocked() []*timerToken {
107120
108121 // invalidate publishes a coordinator-owned producer transition. Callers that
109122 // carry an attachment identity must use invalidateForAttachment instead.
110-func (c *renderCoordinator) invalidate(inv renderInvalidation) {
111- c.invalidateForAttachment(nil, inv)
123+func (c *renderCoordinator) invalidate(inv renderInvalidation) bool {
124+ return c.invalidateForAttachment(nil, inv)
112125 }
113126
114-// invalidateForAttachment publishes only while source is still the bound
115-// attachment. This check shares c.mu with replacement, making a stale
116-// attachment's timer callback unable to enqueue work for its replacement.
127+// invalidateForAttachment publishes only while source is still registered.
128+// This check shares c.mu with attachment lifecycle changes, making a stale
129+// timer callback unable to enqueue work for a newer attachment incarnation.
117130 // A nil source represents an internal pane/session invalidation.
118131 func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv renderInvalidation) bool {
119132 return c.invalidateForAttachmentAtResizeEpoch(source, 0, inv)
@@ -155,17 +168,21 @@ type renderInvalidationReservation struct {
155168 func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) (*renderInvalidationReservation, bool) {
156169 c.mu.Lock()
157170 defer c.mu.Unlock()
158- detachedPreviewOnly := c.primaryDetachedLocked() && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0)
159171 if c.torndown || (lease != nil && (!c.leaseCurrentLocked(lease, true) || lease.attachment != source)) ||
160- (c.primaryDetachedLocked() && !detachedPreviewOnly) || (source != nil && c.lease != nil && c.lease.attachment != source) ||
172+ (source != nil && !c.attachmentRegisteredLocked(source)) ||
161173 (epoch != 0 && (c.resize.epoch != epoch || c.resize.source != source || (lease != nil && c.resize.lease != lease))) {
162174 return nil, false
163175 }
164176 reservation := &renderInvalidationReservation{coordinator: c, invalidation: inv, onInvalidate: c.opts.onInvalidate}
165177 c.metrics.invalidations.Add(1)
178+ c.pendingGeneration++
179+ if c.pendingGeneration == 0 {
180+ c.pendingGeneration = 1
181+ }
166182 wasPending, wasUrgent, wasPreviewPending := c.pending, c.pendingUrgent, c.pendingPreview
167183 if !wasPending {
168184 c.ackDeferred = false
185+ c.ackDeferredFor = nil
169186 c.deadlineDue = false
170187 if c.opts.observer != nil {
171188 c.queueMarked = true
@@ -175,11 +192,26 @@ func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpoch(source *att
175192 }
176193 }
177194 c.pending = true
195+ if source == nil {
196+ c.pendingShared = true
197+ c.pendingTargets = nil
198+ } else if !c.pendingShared {
199+ if c.pendingTargets == nil {
200+ c.pendingTargets = make(map[*attachedClient]*attachmentLease)
201+ }
202+ targetLease := lease
203+ if targetLease == nil {
204+ targetLease = c.leases[source]
205+ }
206+ if targetLease != nil {
207+ c.pendingTargets[source] = targetLease
208+ }
209+ }
178210 c.pendingReset = c.pendingReset || inv.reset
179211 c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent
180- c.pendingPreview = c.pendingPreview || c.previewWake != nil || len(c.previewWakes) != 0
212+ c.pendingPreview = c.pendingPreview || len(c.previewWakes) != 0
181213 c.coalesced++
182- reservation.arm = !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue
214+ reservation.arm = !wasPending || !c.armed || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue
183215 if reservation.arm {
184216 _, reservation.old = c.normalLane.replaceLocked()
185217 }
@@ -240,8 +272,8 @@ func (r *renderInvalidationReservation) finish() {
240272 }
241273
242274 // invalidateForLeaseAtResizeEpoch is the attachment-callback variant: it
243-// rejects a same-object replacement whose source pointer still matches but
244-// whose lifecycle lease has been revoked.
275+// rejects a callback whose source pointer still matches but whose lifecycle
276+// lease has been revoked.
245277 func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) bool {
246278 reservation, ok := c.reserveInvalidationForLeaseAtResizeEpoch(source, lease, epoch, inv)
247279 if !ok {
@@ -252,20 +284,25 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie
252284 }
253285
254286 // notifyAck reports that the client acknowledged an output state, releasing
255-// at most one deadline/watchdog-deferred wake. It never bypasses an unexpired
256-// normal or urgent deadline.
287+// at most one attachment's blocked wake. It never bypasses an unexpired normal
288+// or urgent deadline.
257289 func (c *renderCoordinator) notifyAck() { c.notifyAckForLease(nil) }
258290
259291 // notifyAckForLease prevents an acknowledgement captured by an obsolete active
260-// frame from flushing the replacement attachment's render queue. A nil lease is
292+// frame from flushing another attachment's render queue. A nil lease is
261293 // reserved for coordinator-owned/direct test callers.
262294 func (c *renderCoordinator) notifyAckForLease(lease *attachmentLease) {
263295 c.mu.Lock()
264- if lease != nil && !c.leaseCurrentLocked(lease, true) {
296+ if lease != nil {
297+ if !c.leaseCurrentLocked(lease, true) || !c.ackDeferredFor[lease.attachment] {
298+ c.mu.Unlock()
299+ return
300+ }
301+ } else if !c.ackDeferred && !c.deadlineDue {
265302 c.mu.Unlock()
266303 return
267304 }
268- if (!c.ackDeferred && !c.deadlineDue) || !c.pending {
305+ if !c.pending {
269306 c.mu.Unlock()
270307 return
271308 }
@@ -482,16 +519,6 @@ func (c *renderCoordinator) noteSyncPaneRemoved(p *pane) {
482519 stopDetachedTimer(worker)
483520 }
484521
485-// subscribePreview installs fn as the preview observer for coalesced wakes.
486-func (c *renderCoordinator) subscribePreview(fn func(renderWake)) {
487- c.mu.Lock()
488- c.previewWake = fn
489- c.mu.Unlock()
490-}
491-
492-// teardownPreview removes the legacy preview subscription.
493-func (c *renderCoordinator) teardownPreview() { c.mu.Lock(); c.previewWake = nil; c.mu.Unlock() }
494-
495522 type previewSubscription struct {
496523 generation uint64
497524 fn func(renderWake)
@@ -519,7 +546,7 @@ func (c *renderCoordinator) subscribePreviewFor(viewer *attachedClient, generati
519546 }
520547
521548 // teardownPreviewFor removes viewer's observer only when it still belongs to
522-// generation. A delayed teardown cannot clear a replacement subscription.
549+// generation. A delayed teardown cannot clear a newer subscription.
523550 func (c *renderCoordinator) teardownPreviewFor(viewer *attachedClient, generation uint64) {
524551 c.mu.Lock()
525552 if current, ok := c.previewWakes[viewer]; ok && current.generation == generation {
@@ -528,29 +555,54 @@ func (c *renderCoordinator) teardownPreviewFor(viewer *attachedClient, generatio
528555 c.mu.Unlock()
529556 }
530557
531-// primaryDetachedLocked distinguishes an explicitly detached primary from an
532-// unbound coordinator used by headless tests. Preview subscribers remain live
533-// for the former, but it can never regain a primary transport without a lease.
534-func (c *renderCoordinator) primaryDetachedLocked() bool { return c.lease != nil && !c.lease.active }
558+func (c *renderCoordinator) attachmentRegisteredLocked(ac *attachedClient) bool {
559+ if ac == nil {
560+ return false
561+ }
562+ _, ok := c.attachments[ac]
563+ return ok
564+}
535565
536566 func (c *renderCoordinator) installLeaseLocked(ac *attachedClient, ready bool) *attachmentLease {
537- if c.lease != nil {
538- c.lease.active = false
567+ if ac == nil {
568+ return nil
539569 }
540- c.lease = &attachmentLease{attachment: ac, ready: ready, active: ac != nil}
541- return c.lease
570+ if c.attachments == nil {
571+ c.attachments = make(map[*attachedClient]struct{})
572+ }
573+ if c.leases == nil {
574+ c.leases = make(map[*attachedClient]*attachmentLease)
575+ }
576+ c.attachments[ac] = struct{}{}
577+ if lease := c.leases[ac]; lease != nil && lease.active {
578+ lease.ready = ready
579+ return lease
580+ }
581+ lease := &attachmentLease{attachment: ac, ready: ready, active: true}
582+ c.leases[ac] = lease
583+ if c.delivered != nil {
584+ delete(c.delivered, ac)
585+ }
586+ if c.ackDeferredFor != nil {
587+ delete(c.ackDeferredFor, ac)
588+ }
589+ return lease
542590 }
543591
544592 // attachmentLease returns the exact currently published lease for ac. Welcome
545-// continuations capture this before their transport write and cannot later
546-// bless a same-object park/resume replacement.
593+// continuations capture this before their transport write and revalidate it at
594+// every side-effect boundary.
547595 func (c *renderCoordinator) attachmentLease(ac *attachedClient) *attachmentLease {
548596 c.mu.Lock()
549597 defer c.mu.Unlock()
550- if c.torndown || c.lease == nil || !c.lease.active || c.lease.attachment != ac {
598+ if c.torndown {
599+ return nil
600+ }
601+ lease := c.leases[ac]
602+ if lease == nil || !lease.active {
551603 return nil
552604 }
553- return c.lease
605+ return lease
554606 }
555607
556608 func (c *renderCoordinator) leaseCurrent(lease *attachmentLease, ready bool) bool {
@@ -563,23 +615,45 @@ func (c *renderCoordinator) leaseCurrent(lease *attachmentLease, ready bool) boo
563615 // must not be used to retain c.mu across input routing, transport operations,
564616 // or other arbitrary handler work.
565617 func (c *renderCoordinator) leaseCurrentLocked(lease *attachmentLease, ready bool) bool {
566- return !c.torndown && lease != nil && c.lease == lease && lease.active && (!ready || lease.ready)
618+ return !c.torndown && lease != nil && lease.active &&
619+ c.leases[lease.attachment] == lease && (!ready || lease.ready)
567620 }
568621
569622 // attach binds an already-handshaken internal attachment. Route/resume use
570623 // attachWithReadiness(..., false) until their Welcome frame is accepted.
571624 func (c *renderCoordinator) attach(ac *attachedClient) { c.attachWithReadiness(ac, true) }
572625
573-func (c *renderCoordinator) attachWithReadiness(ac *attachedClient, ready bool) {
626+func (c *renderCoordinator) attachWithReadiness(ac *attachedClient, ready bool) *attachmentLease {
574627 c.mu.Lock()
575- if !c.torndown && (c.lease == nil || !c.lease.active || c.lease.attachment != ac) {
576- c.installLeaseLocked(ac, ready)
628+ defer c.mu.Unlock()
629+ return c.attachWithReadinessLocked(ac, ready)
630+}
631+
632+// attachWithReadinessLocked publishes one attachment while the coordinator
633+// lock is already held by an ordered transition publication.
634+func (c *renderCoordinator) attachWithReadinessLocked(ac *attachedClient, ready bool) *attachmentLease {
635+ if c.torndown {
636+ return nil
577637 }
578- c.mu.Unlock()
638+ return c.installLeaseLocked(ac, ready)
639+}
640+
641+// rebindAttachmentWithReadinessLocked retires the previous lease before
642+// publishing a new one for the same attachment object. A queued wake carries
643+// the old lease pointer, so reusing it would let that wake paint a rebound
644+// transport.
645+func (c *renderCoordinator) rebindAttachmentWithReadinessLocked(ac *attachedClient, ready bool) *attachmentLease {
646+ if c.torndown {
647+ return nil
648+ }
649+ if lease := c.leases[ac]; lease != nil {
650+ lease.active = false
651+ delete(c.leases, ac)
652+ }
653+ return c.installLeaseLocked(ac, ready)
579654 }
580655
581656 // markAttachmentReady completes only the captured attachment incarnation.
582-// A stale Welcome must never bless a replacement that reused its client object.
583657 func (c *renderCoordinator) markAttachmentReady(lease *attachmentLease) bool {
584658 c.mu.Lock()
585659 defer c.mu.Unlock()
@@ -590,12 +664,13 @@ func (c *renderCoordinator) markAttachmentReady(lease *attachmentLease) bool {
590664 return true
591665 }
592666
593-// wakeCurrent validates a dispatched wake. paint repeats this check after
594-// sendMu is acquired, so a reused attachedClient cannot revive an old wake.
667+// wakeCurrent validates an attachment-specific wake. Shared wakes have no
668+// lease and remain valid while the coordinator is live; their paint callback
669+// rechecks each attachment's connection fence.
595670 func (c *renderCoordinator) wakeCurrent(w renderWake) bool {
596671 c.mu.Lock()
597672 defer c.mu.Unlock()
598- return c.leaseCurrentLocked(w.lease, true)
673+ return !c.torndown && (w.lease == nil || c.leaseCurrentLocked(w.lease, true))
599674 }
600675
601676 type renderLifecycleCleanup struct {
@@ -629,12 +704,28 @@ func (c *renderCoordinator) beginDetach(ac *attachedClient) renderLifecycleClean
629704 // beginDetachLocked is the prelocked lifecycle seam used when registry,
630705 // current-session, and lease publication must share one commit boundary.
631706 func (c *renderCoordinator) beginDetachLocked(ac *attachedClient) renderLifecycleCleanup {
632- var cleanup renderLifecycleCleanup
633- if c.lease != nil && c.lease.active && c.lease.attachment == ac {
634- c.lease.active = false
635- cleanup = c.resetAttachmentLifecycleLocked()
707+ if ac == nil || !c.attachmentRegisteredLocked(ac) {
708+ return renderLifecycleCleanup{}
636709 }
637- return cleanup
710+ delete(c.attachments, ac)
711+ if lease := c.leases[ac]; lease != nil {
712+ lease.active = false
713+ delete(c.leases, ac)
714+ }
715+ delete(c.delivered, ac)
716+ delete(c.ackDeferredFor, ac)
717+ // Shared pending output belongs to the session, not the detached
718+ // attachment. Keep it alive while another attachment remains registered.
719+ if len(c.attachments) != 0 {
720+ var cleanup renderLifecycleCleanup
721+ if c.resize.source == ac {
722+ _, resizeTimer := c.resizeLane.replaceLocked()
723+ _, retryTimer := c.retryLane.replaceLocked()
724+ cleanup.tokens = []*timerToken{resizeTimer, retryTimer}
725+ }
726+ return cleanup
727+ }
728+ return c.resetAttachmentLifecycleLocked()
638729 }
639730
640731 func (c *renderCoordinator) resetAttachmentLifecycleLocked() renderLifecycleCleanup {
@@ -642,7 +733,11 @@ func (c *renderCoordinator) resetAttachmentLifecycleLocked() renderLifecycleClea
642733 c.pendingReset = false
643734 c.pendingUrgent = false
644735 c.ackDeferred = false
736+ c.ackDeferredFor = nil
737+ c.delivered = nil
645738 c.pendingPreview = false
739+ c.pendingShared = false
740+ c.pendingTargets = nil
646741 c.coalesced = 0
647742 _, timer := c.normalLane.replaceLocked()
648743 c.armed = false
@@ -664,45 +759,6 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) {
664759 c.beginDetach(ac).finish()
665760 }
666761
667-// beginReplace hands the coordinator from old to replacement without waiting
668-// for detached workers.
669-func (c *renderCoordinator) beginReplace(old, replacement *attachedClient, ready bool) renderLifecycleCleanup {
670- c.mu.Lock()
671- cleanup, _ := c.beginReplaceLocked(old, replacement, ready)
672- c.mu.Unlock()
673- return cleanup
674-}
675-
676-// beginReplaceLocked requires c.mu. Callers validate canReplaceLocked before
677-// their ownership commit, making lease installation non-failing afterward.
678-func (c *renderCoordinator) beginReplaceLocked(old, replacement *attachedClient, ready bool) (renderLifecycleCleanup, *attachmentLease) {
679- if !c.canReplaceLocked(old, replacement) {
680- return renderLifecycleCleanup{}, nil
681- }
682- lease := c.installLeaseLocked(replacement, ready)
683- return c.resetAttachmentLifecycleLocked(), lease
684-}
685-
686-func (c *renderCoordinator) canReplaceLocked(old, replacement *attachedClient) bool {
687- // A coordinator may be installed while replacing a legacy attachment which
688- // predated coordinator ownership. Any inactive lease is detached historical
689- // state; a resume may also replace the same client object because registry and
690- // transport validation proved a new role incarnation.
691- return !c.torndown && (c.lease == nil || !c.lease.active || c.lease.attachment == old || c.lease.attachment == replacement)
692-}
693-
694-// noteReplace is for callers that hold no outer lifecycle locks.
695-func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readiness ...bool) {
696- ready := true
697- if len(readiness) != 0 {
698- ready = readiness[0]
699- }
700- c.beginReplace(old, replacement, ready).finish()
701-}
702-
703-// notePark invalidates pending wakes when the attachment parks for resume.
704-func (c *renderCoordinator) notePark(ac *attachedClient) { c.noteDetach(ac) }
705-
706762 // beginSessionTeardown is terminal phase one. It rejects future worker
707763 // registration and atomically detaches every complete lane token.
708764 func (c *renderCoordinator) beginSessionTeardown() renderLifecycleCleanup {
@@ -712,16 +768,22 @@ func (c *renderCoordinator) beginSessionTeardown() renderLifecycleCleanup {
712768 return renderLifecycleCleanup{}
713769 }
714770 c.torndown = true
715- if c.lease != nil {
716- c.lease.active = false
771+ for ac, lease := range c.leases {
772+ lease.active = false
773+ delete(c.attachments, ac)
717774 }
718- c.previewWake = nil
775+ c.leases = nil
776+ c.attachments = nil
719777 c.previewWakes = nil
720778 c.pending = false
721779 _, resizeTimer := c.resizeLane.replaceLocked()
722780 _, retryTimer := c.retryLane.replaceLocked()
723781 c.ackDeferred = false
782+ c.ackDeferredFor = nil
783+ c.delivered = nil
724784 c.pendingPreview = false
785+ c.pendingShared = false
786+ c.pendingTargets = nil
725787 _, timer := c.normalLane.replaceLocked()
726788 c.armed = false
727789 workers := c.detachSyncTimersLocked()
@@ -747,7 +809,19 @@ func (c *renderCoordinator) waitForTimerWorkers() { c.supervisor.wait() }
747809 // compositor; attached sessions always schedule through their coordinator.
748810 func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, reset bool, producer string) {
749811 if rc := attachmentRenderCoordinator(entry); rc != nil {
750- rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})
812+ invalidation := renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}
813+ if rc.invalidateForAttachment(ac, invalidation) {
814+ return
815+ }
816+ // Additional attachments do not borrow another attachment's lease.
817+ // Their shared mutation still schedules the session wake, whose fanout
818+ // paints every live attachment with its own output state.
819+ if ac != nil && attachmentRegistered(entry, ac) && ac.currentAttachmentSession() == entry {
820+ if rc.invalidate(invalidation) {
821+ return
822+ }
823+ d.paint(entry, ac, reset, nil)
824+ }
751825 return
752826 }
753827 if ac != nil {
@@ -760,8 +834,13 @@ func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, r
760834 // the required first full frame never depends on a debounce timer.
761835 func (d *Daemon) invalidateRenderNow(entry attachmentSession, ac *attachedClient, reset bool, producer string) {
762836 if rc := attachmentRenderCoordinator(entry); rc != nil {
763- rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})
764- rc.fireCurrent(false)
837+ if rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}) {
838+ rc.fireCurrent(false)
839+ return
840+ }
841+ if ac != nil && attachmentRegistered(entry, ac) && ac.currentAttachmentSession() == entry {
842+ d.paint(entry, ac, reset, nil)
843+ }
765844 return
766845 }
767846 if ac != nil {
@@ -780,27 +859,73 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
780859 c.fireWithTimerToken(token, gen, watchdog, deadline)
781860 }
782861
862+// fireCurrentForLease makes the selected attachment's wake synchronous. The
863+// handshake must not finish before that attachment's first output is emitted,
864+// while other attachments remain independently asynchronous.
865+func (c *renderCoordinator) fireCurrentForLease(lease *attachmentLease) {
866+ c.mu.Lock()
867+ gen := c.normalLane.generation
868+ token := c.normalLane.token
869+ c.mu.Unlock()
870+ c.fireWithTimerTokenAndLease(token, gen, false, false, lease)
871+}
872+
783873 // fireFromTimer consumes a normal deadline using its exact immutable token.
784874 func (c *renderCoordinator) fireFromTimer(token *timerToken, watchdog, deadline bool) {
785875 c.fireWithTimerToken(token, token.generation, watchdog, deadline)
786876 }
787877
788878 func (c *renderCoordinator) fireWithTimerToken(token *timerToken, gen uint64, watchdog, deadline bool) {
789- // Readiness probes enter the attachment send path, which may in turn read
790- // coordinator metadata. Never hold c.mu across that external callback.
879+ c.fireWithTimerTokenAndLease(token, gen, watchdog, deadline, nil)
880+}
881+
882+func (c *renderCoordinator) attachmentAckReady(ac *attachedClient) bool {
883+ if c.ackReadyFor != nil {
884+ return c.ackReadyFor(ac)
885+ }
886+ if c.opts.ackReady != nil {
887+ return c.opts.ackReady()
888+ }
889+ return true
890+}
891+
892+func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen uint64, watchdog, deadline bool, syncLease *attachmentLease) {
893+ // Readiness probes enter each attachment's send path, which may in turn
894+ // read coordinator metadata. Never hold c.mu across those callbacks.
791895 c.mu.Lock()
792896 if !c.fireValidLocked(token, gen, watchdog) {
793897 c.mu.Unlock()
794898 return
795899 }
796- // Welcome holds attachment.sendMu while its transport Send is in flight.
797- // Do not probe ACK capacity through that lock before this incarnation is
798- // ready: a deadline that expires during the handshake must complete and
799- // leave work pending, rather than wedging behind Welcome.
800- handshakePending := c.lease != nil && c.lease.active && !c.lease.ready
801- ackReady := c.opts.ackReady
900+ if c.pendingGeneration == 0 {
901+ c.pendingGeneration = 1
902+ }
903+ leases := make(map[*attachedClient]*attachmentLease, len(c.leases))
904+ for ac, lease := range c.leases {
905+ if lease != nil && lease.active {
906+ leases[ac] = lease
907+ }
908+ }
909+ previewOnly := len(leases) == 0 && len(c.previewWakes) != 0
910+ legacyAckReady := c.opts.ackReady
802911 c.mu.Unlock()
803- ready := !handshakePending && (ackReady == nil || ackReady())
912+
913+ ready := make(map[*attachmentLease]bool, len(leases))
914+ if len(leases) != 0 {
915+ for ac, lease := range leases {
916+ // Welcome holds attachment.sendMu while its transport Send is in
917+ // flight. Do not probe an incarnation before it is ready.
918+ if lease.ready {
919+ ready[lease] = c.attachmentAckReady(ac)
920+ }
921+ }
922+ } else if !previewOnly && legacyAckReady != nil {
923+ // Keep coordinator-only/headless callers compatible; production always
924+ // has attachment leases and uses the per-attachment callback above.
925+ ready[nil] = legacyAckReady()
926+ } else {
927+ ready[nil] = true
928+ }
804929
805930 // The readiness callback can detach, replace, or publish a sync batch.
806931 // syncGateOpenLocked retries predicate evaluation on every registry change
@@ -818,67 +943,171 @@ func (c *renderCoordinator) fireWithTimerToken(token *timerToken, gen uint64, wa
818943 c.mu.Unlock()
819944 return
820945 }
821- // A detached target has no primary transport or ACK window. Its pending
822- // state belongs exclusively to picker previews and must be consumed after
823- // notification rather than accumulating ackDeferred forever. Do not pass
824- // its revoked primary lease to preview consumers.
825- headlessPreviewOnly := c.primaryDetachedLocked()
826- lease := c.lease
827- if headlessPreviewOnly {
828- lease = nil
829- }
830- w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog, lease: lease}
831- if !headlessPreviewOnly && (!ready || (c.lease != nil && c.lease.active && !c.lease.ready)) {
832- var ackStart *ackBlockedSpan
833- if !ready && deadline {
834- c.ackDeferred = true
835- if c.ackBlocked == nil && c.opts.observer != nil {
836- // Install the span while coordinator state is owned. Its Start
837- // publication runs unlocked, and its own handoff defers End until
838- // that call has returned.
839- ackStart = newACKBlockedSpan(c.opts.observer)
840- c.ackBlocked = ackStart
946+
947+ // Re-snapshot after every external readiness probe. A replacement may have
948+ // retired one of the leases we probed while the callback was unlocked.
949+ leases = make(map[*attachedClient]*attachmentLease, len(c.leases))
950+ for ac, lease := range c.leases {
951+ if lease != nil && lease.active {
952+ leases[ac] = lease
953+ }
954+ }
955+ var attachmentLeases map[*attachedClient]*attachmentLease
956+ if len(leases) != 0 {
957+ attachmentLeases = make(map[*attachedClient]*attachmentLease, len(leases))
958+ }
959+ blockedByAck := false
960+ allDelivered := true
961+ if len(leases) != 0 {
962+ for ac, lease := range leases {
963+ if !c.pendingShared && len(c.pendingTargets) != 0 {
964+ targetLease, targeted := c.pendingTargets[ac]
965+ if !targeted || targetLease != lease {
966+ continue
967+ }
968+ }
969+ if c.delivered[ac] == c.pendingGeneration {
970+ continue
971+ }
972+ allDelivered = false
973+ if !lease.ready {
974+ continue
975+ }
976+ canReady, probed := ready[lease]
977+ if !probed {
978+ continue
979+ }
980+ if canReady {
981+ attachmentLeases[ac] = lease
982+ if c.ackDeferredFor != nil {
983+ delete(c.ackDeferredFor, ac)
984+ }
985+ continue
841986 }
987+ blockedByAck = true
988+ if c.ackDeferredFor == nil {
989+ c.ackDeferredFor = make(map[*attachedClient]bool)
990+ }
991+ c.ackDeferredFor[ac] = true
992+ }
993+ } else if !ready[nil] {
994+ blockedByAck = true
995+ allDelivered = false
996+ }
997+ legacyOutput := len(leases) == 0 && ready[nil] && (c.pendingShared || len(c.pendingTargets) == 0)
998+ outputReady := len(attachmentLeases) != 0 || legacyOutput
999+ if len(leases) != 0 {
1000+ for ac, lease := range leases {
1001+ if !c.pendingShared && len(c.pendingTargets) != 0 {
1002+ targetLease, targeted := c.pendingTargets[ac]
1003+ if !targeted || targetLease != lease {
1004+ continue
1005+ }
1006+ }
1007+ if c.delivered[ac] != c.pendingGeneration {
1008+ allDelivered = false
1009+ break
1010+ }
1011+ }
1012+ }
1013+ if len(leases) == 0 && legacyOutput {
1014+ allDelivered = true
1015+ }
1016+ w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, attachmentLeases: attachmentLeases, watchdog: watchdog}
1017+ if syncLease != nil {
1018+ if selected, ok := attachmentLeases[syncLease.attachment]; ok && selected == syncLease {
1019+ w.lease = syncLease
8421020 }
843- preview, previews := c.takePendingPreviewsLocked()
1021+ }
1022+ previews := c.takePendingPreviewsLocked()
1023+ worker := c.detachNormalTimerLocked()
1024+ c.armed = false
1025+
1026+ var ackStart *ackBlockedSpan
1027+ if blockedByAck && deadline && c.ackBlocked == nil && c.opts.observer != nil {
1028+ // Install the span while coordinator state is owned. Its Start
1029+ // publication runs unlocked, and its own handoff defers End until that
1030+ // call has returned.
1031+ ackStart = newACKBlockedSpan(c.opts.observer)
1032+ c.ackBlocked = ackStart
1033+ }
1034+ c.ackDeferred = len(c.ackDeferredFor) != 0 || (len(leases) == 0 && blockedByAck && deadline)
1035+ if !outputReady && !allDelivered {
8441036 c.mu.Unlock()
1037+ stopDetachedTimer(worker)
8451038 if ackStart != nil {
8461039 ackStart.publishStart()
8471040 }
848- c.notifyPreviews(w, preview, previews)
1041+ c.notifyPreviews(w, previews)
8491042 return
8501043 }
851- preview, previews := c.takePendingPreviewsLocked()
852- c.metrics.wakes.Add(1)
853- c.metrics.coalesced.Add(uint64(w.coalesced))
854- if !w.urgent {
855- if w.coalesced > 1 {
856- c.outputPressure += w.coalesced - 1
857- if c.outputPressure > int((maxOutputRenderDeadline-minOutputRenderDeadline)/time.Millisecond) {
858- c.outputPressure = int((maxOutputRenderDeadline - minOutputRenderDeadline) / time.Millisecond)
1044+
1045+ if outputReady {
1046+ for ac := range attachmentLeases {
1047+ if c.delivered == nil {
1048+ c.delivered = make(map[*attachedClient]uint64)
1049+ }
1050+ c.delivered[ac] = c.pendingGeneration
1051+ }
1052+ }
1053+ if len(leases) != 0 {
1054+ allDelivered = true
1055+ for ac, lease := range leases {
1056+ if !c.pendingShared && len(c.pendingTargets) != 0 {
1057+ targetLease, targeted := c.pendingTargets[ac]
1058+ if !targeted || targetLease != lease {
1059+ continue
1060+ }
1061+ }
1062+ if c.delivered[ac] != c.pendingGeneration {
1063+ allDelivered = false
1064+ break
1065+ }
1066+ }
1067+ }
1068+ complete := allDelivered || (len(leases) == 0 && outputReady)
1069+ if complete {
1070+ c.pending, c.pendingReset, c.pendingUrgent, c.ackDeferred, c.deadlineDue, c.coalesced, c.armed = false, false, false, false, false, 0, false
1071+ c.ackDeferredFor = nil
1072+ c.delivered = nil
1073+ c.pendingShared = false
1074+ c.pendingTargets = nil
1075+ }
1076+ if outputReady {
1077+ c.metrics.wakes.Add(1)
1078+ c.metrics.coalesced.Add(uint64(w.coalesced))
1079+ if !w.urgent {
1080+ if w.coalesced > 1 {
1081+ c.outputPressure += w.coalesced - 1
1082+ if c.outputPressure > int((maxOutputRenderDeadline-minOutputRenderDeadline)/time.Millisecond) {
1083+ c.outputPressure = int((maxOutputRenderDeadline - minOutputRenderDeadline) / time.Millisecond)
1084+ }
1085+ } else if c.outputPressure > 0 {
1086+ c.outputPressure--
8591087 }
860- } else if c.outputPressure > 0 {
861- c.outputPressure--
8621088 }
8631089 }
864- worker := c.detachNormalTimerLocked()
865- c.pending, c.pendingReset, c.pendingUrgent, c.ackDeferred, c.deadlineDue, c.coalesced, c.armed = false, false, false, false, false, 0, false
8661090 observer, queueCorrelation, queueMarked := c.opts.observer, c.queueCorrelation, c.queueMarked
8671091 ackBlocked := c.ackBlocked
868- c.queueMarked, c.ackBlocked = false, nil
1092+ if complete {
1093+ c.queueMarked, c.ackBlocked = false, nil
1094+ }
8691095 wake := c.opts.wake
8701096 c.mu.Unlock()
8711097 stopDetachedTimer(worker)
872- if queueMarked && observer != nil {
1098+ if ackStart != nil {
1099+ ackStart.publishStart()
1100+ }
1101+ if complete && queueMarked && observer != nil {
8731102 observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueDequeued, 0, true))
8741103 }
875- if ackBlocked != nil {
1104+ if complete && ackBlocked != nil {
8761105 ackBlocked.finish(true)
8771106 }
878- if wake != nil && !headlessPreviewOnly {
1107+ if outputReady && wake != nil {
8791108 wake(w)
8801109 }
881- c.notifyPreviews(w, preview, previews)
1110+ c.notifyPreviews(w, previews)
8821111 }
8831112
8841113 func (c *renderCoordinator) fireValidLocked(token *timerToken, gen uint64, watchdog bool) bool {
@@ -920,24 +1149,21 @@ func (c *renderCoordinator) syncGateOpenLocked() bool {
9201149 }
9211150 }
9221151
923-// takePendingPreviewsLocked snapshots and consumes the preview delivery for
924-// this target generation. Unlike the target primary, this is never ACK-gated.
925-func (c *renderCoordinator) takePendingPreviewsLocked() (func(renderWake), []func(renderWake)) {
1152+// takePendingPreviewsLocked snapshots and consumes picker preview delivery
1153+// for this target generation. Preview callbacks are never ACK-gated.
1154+func (c *renderCoordinator) takePendingPreviewsLocked() []func(renderWake) {
9261155 if !c.pendingPreview {
927- return nil, nil
1156+ return nil
9281157 }
9291158 c.pendingPreview = false
9301159 previews := make([]func(renderWake), 0, len(c.previewWakes))
9311160 for _, subscription := range c.previewWakes {
9321161 previews = append(previews, subscription.fn)
9331162 }
934- return c.previewWake, previews
1163+ return previews
9351164 }
9361165
937-func (c *renderCoordinator) notifyPreviews(w renderWake, preview func(renderWake), previews []func(renderWake)) {
938- if preview != nil {
939- preview(w)
940- }
1166+func (c *renderCoordinator) notifyPreviews(w renderWake, previews []func(renderWake)) {
9411167 for _, fn := range previews {
9421168 fn(w)
9431169 }
M internal/usecase/daemon/render_coordinator_handshake_test.go +132 -2
@@ -1,12 +1,55 @@
11 package daemon
22
33 import (
4+ "io"
5+ "sync"
46 "testing"
57 "time"
68
79 "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
813 )
914
15+// fanoutBlockingTransport blocks only the first output send. The paired
16+// healthy attachment must still receive its frame while this peer is stuck.
17+type fanoutBlockingTransport struct {
18+ sent chan ports.Frame
19+ entered chan struct{}
20+ release chan struct{}
21+ done chan struct{}
22+ enteredOne sync.Once
23+ releaseOne sync.Once
24+ doneOne sync.Once
25+}
26+
27+func newFanoutBlockingTransport() *fanoutBlockingTransport {
28+ return &fanoutBlockingTransport{
29+ sent: make(chan ports.Frame, 8),
30+ entered: make(chan struct{}),
31+ release: make(chan struct{}),
32+ done: make(chan struct{}),
33+ }
34+}
35+
36+func (t *fanoutBlockingTransport) Send(frame ports.Frame) error {
37+ t.sent <- frame
38+ if frame.Type == ports.MsgOutput {
39+ t.enteredOne.Do(func() { close(t.entered) })
40+ <-t.release
41+ }
42+ t.doneOne.Do(func() { close(t.done) })
43+ return nil
44+}
45+
46+func (t *fanoutBlockingTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
47+
48+func (t *fanoutBlockingTransport) Close() error {
49+ t.releaseOne.Do(func() { close(t.release) })
50+ return nil
51+}
52+
1053 // Fresh producer state may arrive after route has published an attachment but
1154 // before the transport has accepted Welcome. It must remain pending until the
1255 // exact attachment incarnation completes that handshake.
@@ -32,10 +75,97 @@ func TestRenderCoordinatorFreshWakeWaitsForWelcome(t *testing.T) {
3275 wake := awaitWake(t, h.wakes)
3376 require.True(t, wake.reset)
3477 require.Equal(t, 1, wake.coalesced)
35- require.Same(t, ac, wake.lease.attachment)
78+ require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
3679 requireNoWake(t, h.wakes)
3780 }
3881
82+func TestRenderCoordinatorFanoutDoesNotWaitForSlowTransport(t *testing.T) {
83+ d, sess, healthy, healthySends, _ := newManualTabSession(t, 1)
84+ slowTransport := newFanoutBlockingTransport()
85+ slow := &attachedClient{
86+ tr: slowTransport,
87+ output: newOutputStateStream(),
88+ size: domain.Size{Cols: 80, Rows: 24},
89+ }
90+ slow.initOverlays()
91+ slow.setSession(sess)
92+ sess.core().mu.Lock()
93+ sess.core().attachments[slow] = struct{}{}
94+ sess.core().mu.Unlock()
95+
96+ rc := d.ensureRenderCoordinator(sess)
97+ rc.opts.clock = nil // publish, then fire synchronously below
98+ rc.attach(healthy)
99+ rc.attach(slow)
100+ rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "fanout"})
101+ rc.fireCurrent(false)
102+
103+ select {
104+ case frame := <-healthySends:
105+ require.Equal(t, ports.MsgOutput, frame.Type, "healthy attachment did not receive output")
106+ case <-time.After(2 * time.Second):
107+ t.Fatal("healthy attachment remained blocked behind the slow transport")
108+ }
109+ select {
110+ case <-slowTransport.entered:
111+ case <-time.After(2 * time.Second):
112+ t.Fatal("slow transport did not enter its blocked output send")
113+ }
114+ slowTransport.releaseOne.Do(func() { close(slowTransport.release) })
115+ select {
116+ case <-slowTransport.done:
117+ case <-time.After(2 * time.Second):
118+ t.Fatal("slow output send did not finish after release")
119+ }
120+}
121+
122+func TestRenderCoordinatorWelcomeReadinessIsAttachmentScoped(t *testing.T) {
123+ h := newCoordinatorHarness(t)
124+ slow, healthy := &attachedClient{}, &attachedClient{}
125+ h.rc.attachWithReadiness(slow, false)
126+ h.rc.attachWithReadiness(healthy, true)
127+ slowLease := h.rc.attachmentLease(slow)
128+ require.NotNil(t, slowLease)
129+
130+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true, producer: "pty"})
131+ h.rc.fireCurrent(false)
132+ wake := awaitTestValue(t, h.wakes, "ready attachment did not receive output while peer handshakes")
133+ require.Contains(t, wake.attachmentLeases, healthy)
134+ _, slowSelected := wake.attachmentLeases[slow]
135+ require.False(t, slowSelected)
136+ h.rc.mu.Lock()
137+ require.True(t, h.rc.pending, "the handshaking peer must retain the shared mutation")
138+ h.rc.mu.Unlock()
139+
140+ require.True(t, h.rc.markAttachmentReady(slowLease))
141+ h.rc.fireCurrentForLease(slowLease)
142+ wake = awaitTestValue(t, h.wakes, "handshaken attachment did not receive deferred output")
143+ require.Contains(t, wake.attachmentLeases, slow)
144+ _, healthySelected := wake.attachmentLeases[healthy]
145+ require.False(t, healthySelected, "a deferred peer must not replay output to a healthy peer")
146+ h.rc.mu.Lock()
147+ require.False(t, h.rc.pending, "the shared mutation must clear after the handshake catches up")
148+ h.rc.mu.Unlock()
149+}
150+
151+// A wake captured for an old attachment generation must not become valid after
152+// the same attachment object is rebound to a replacement connection.
153+func TestRenderCoordinatorStaleGenerationWakeIsFenced(t *testing.T) {
154+ h := newCoordinatorHarness(t)
155+ ac := &attachedClient{}
156+ h.rc.attachWithReadiness(ac, true)
157+ oldLease := h.rc.attachmentLease(ac)
158+ require.NotNil(t, oldLease)
159+ h.rc.invalidateForLease(ac, oldLease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "old"})
160+ h.rc.fireCurrentForLease(oldLease)
161+ wake := awaitTestValue(t, h.wakes, "old attachment wake was not published")
162+ require.True(t, h.rc.wakeCurrent(wake))
163+
164+ h.rc.noteDetach(ac)
165+ h.rc.attachWithReadiness(ac, true)
166+ require.False(t, h.rc.wakeCurrent(wake), "a stale generation wake must not target the replacement connection")
167+}
168+
39169 // Parking and resuming the same client object is a new incarnation: a failed
40170 // Welcome on the replacement transport must not revive readiness from before
41171 // the park.
@@ -45,7 +175,7 @@ func TestRenderCoordinatorParkedResumeRequiresNewWelcome(t *testing.T) {
45175 h.rc.attachWithReadiness(ac, false)
46176 firstLease := h.rc.attachmentLease(ac)
47177 require.True(t, h.rc.markAttachmentReady(firstLease))
48- h.rc.notePark(ac)
178+ h.rc.noteDetach(ac)
49179 h.rc.attachWithReadiness(ac, false)
50180 resumedLease := h.rc.attachmentLease(ac)
51181 require.NotNil(t, resumedLease)
M internal/usecase/daemon/render_coordinator_publication_test.go +2 -1
@@ -14,7 +14,8 @@ func TestCoordinatorRejectsStaleAttachmentInvalidationAfterReplacement(t *testin
1414 old := &attachedClient{}
1515 replacement := &attachedClient{}
1616 rc.attach(old)
17- rc.noteReplace(old, replacement)
17+ rc.noteDetach(old)
18+ rc.attach(replacement)
1819
1920 // This models a stale resize callback that passed its sendMu generation
2021 // check immediately before a replacement was published.
M internal/usecase/daemon/render_coordinator_test.go +69 -69
@@ -181,7 +181,9 @@ func (h *coordinatorHarness) armedTimers(t *testing.T) []*coordinatorMockTimer {
181181 // deterministic contract failure, not a slow behavior to poll for.
182182 func awaitWake(t *testing.T, ch chan renderWake) renderWake {
183183 t.Helper()
184- return awaitTestValue(t, ch, "coordinator did not publish a wake after fake-clock advancement")
184+ wake := awaitTestValue(t, ch, "coordinator did not publish a wake after fake-clock advancement")
185+ wake.attachmentLeases = nil
186+ return wake
185187 }
186188
187189 func requireNoWake(t *testing.T, ch chan renderWake) {
@@ -376,6 +378,37 @@ func TestRenderCoordinatorUrgentDeadlineCannotBeExtended(t *testing.T) {
376378
377379 // --- ACK gating ----------------------------------------------------------------
378380
381+func TestRenderCoordinatorAckReadinessIsAttachmentScoped(t *testing.T) {
382+ slow, healthy := &attachedClient{}, &attachedClient{}
383+ var slowReady atomic.Bool
384+ slowReady.Store(false)
385+ wakes := make(chan renderWake, 2)
386+ rc := newRenderCoordinator(renderCoordinatorOptions{
387+ wake: func(w renderWake) { wakes <- w },
388+ })
389+ rc.ackReadyFor = func(ac *attachedClient) bool { return ac == healthy || slowReady.Load() }
390+ rc.attach(slow)
391+ rc.attach(healthy)
392+ rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"})
393+ rc.fireCurrent(false)
394+
395+ wake := awaitTestValue(t, wakes, "healthy attachment did not receive an independent wake")
396+ require.Contains(t, wake.attachmentLeases, healthy)
397+ _, slowSelected := wake.attachmentLeases[slow]
398+ require.False(t, slowSelected)
399+ rc.mu.Lock()
400+ require.True(t, rc.pending, "slow attachment must retain the shared mutation until its ACK window opens")
401+ rc.mu.Unlock()
402+
403+ slowReady.Store(true)
404+ rc.notifyAckForLease(rc.attachmentLease(slow))
405+ wake = awaitTestValue(t, wakes, "slow attachment did not receive its deferred wake")
406+ require.Contains(t, wake.attachmentLeases, slow)
407+ rc.mu.Lock()
408+ require.False(t, rc.pending, "the shared mutation must clear after every attachment catches up")
409+ rc.mu.Unlock()
410+}
411+
379412 func TestRenderCoordinatorAckReadinessReentersWithoutBlockingResize(t *testing.T) {
380413 owner := &attachedClient{}
381414 wakes := make(chan renderWake, 1)
@@ -407,8 +440,7 @@ func TestRenderCoordinatorAckReadinessReentersWithoutBlockingResize(t *testing.T
407440 epoch := awaitTestValue(t, resizeDone, "ack readiness re-entry blocked resize metadata")
408441 require.Equal(t, uint64(1), epoch)
409442 wake := awaitWake(t, wakes)
410- require.Same(t, owner, wake.lease.attachment)
411- wake.lease = nil
443+ require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
412444 require.Equal(t, renderWake{coalesced: 1}, wake)
413445 awaitTestCompletion(t, fireDone, "fire did not return after wake")
414446 }
@@ -749,7 +781,8 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) {
749781
750782 func TestRenderCoordinatorPreviewSubscription(t *testing.T) {
751783 h := newCoordinatorHarness(t)
752- h.rc.subscribePreview(func(w renderWake) { h.previews <- w })
784+ viewer := &attachedClient{}
785+ h.rc.subscribePreviewFor(viewer, 1, func(w renderWake) { h.previews <- w })
753786
754787 h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
755788 timers := h.armedTimers(t)
@@ -760,7 +793,7 @@ func TestRenderCoordinatorPreviewSubscription(t *testing.T) {
760793 require.Equal(t, owner, preview, "a subscribed preview observes the same coalesced wake")
761794 requireNoWake(t, h.previews)
762795
763- h.rc.teardownPreview()
796+ h.rc.teardownPreviewFor(viewer, 1)
764797 h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
765798 timers = h.armedTimers(t)
766799 require.NotEmpty(t, timers, "invalidate must arm a deadline timer")
@@ -795,24 +828,24 @@ func TestRenderCoordinatorPreviewWakesDoNotWaitForTargetAck(t *testing.T) {
795828 timers[0].ch <- time.Time{}
796829
797830 preview := awaitWake(t, h.previews)
798- if tc.attach {
799- require.NotNil(t, preview.lease.attachment)
800- }
801- preview.lease = nil
802831 require.Equal(t, renderWake{coalesced: 1}, preview,
803832 "the viewer preview must receive target output without target ACK capacity")
804- requireNoWake(t, h.wakes)
805833 requireNoWake(t, h.previews)
834+ if tc.attach {
835+ requireNoWake(t, h.wakes)
836+ } else {
837+ wake := awaitWake(t, h.wakes)
838+ require.Equal(t, renderWake{coalesced: 1}, wake)
839+ require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
840+ }
806841
807842 h.ackReady.Store(true)
808843 h.rc.notifyAck()
809- wake := awaitWake(t, h.wakes)
810844 if tc.attach {
811- require.NotNil(t, wake.lease.attachment)
845+ wake := awaitWake(t, h.wakes)
846+ require.Equal(t, renderWake{coalesced: 1}, wake,
847+ "the target frame remains pending for its ACK")
812848 }
813- wake.lease = nil
814- require.Equal(t, renderWake{coalesced: 1}, wake,
815- "the target primary frame remains pending for its own ACK")
816849 requireNoWake(t, h.previews)
817850 })
818851 }
@@ -837,9 +870,10 @@ func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *tes
837870 require.Len(t, timers, 1)
838871 timers[0].ch <- time.Time{}
839872 preview := awaitWake(t, h.previews)
840- require.Nil(t, preview.lease, "headless preview wakes must not retain a revoked primary lease")
841873 require.Equal(t, renderWake{coalesced: 1}, preview)
842- requireNoWake(t, h.wakes)
874+ wake := awaitWake(t, h.wakes)
875+ require.Equal(t, renderWake{coalesced: 1}, wake)
876+ require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
843877
844878 h.rc.mu.Lock()
845879 require.False(t, h.rc.pending)
@@ -847,9 +881,7 @@ func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *tes
847881 require.False(t, h.rc.ackDeferred)
848882 require.False(t, h.rc.deadlineDue)
849883 require.Zero(t, h.rc.coalesced)
850- require.True(t, h.rc.primaryDetachedLocked())
851- require.NotNil(t, h.rc.lease)
852- require.False(t, h.rc.lease.active)
884+ require.Empty(t, h.rc.attachments)
853885 h.rc.mu.Unlock()
854886 }
855887
@@ -863,7 +895,7 @@ func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *tes
863895 timers[0].ch <- time.Time{}
864896 wake := awaitWake(t, h.wakes)
865897 require.True(t, wake.reset)
866- require.Same(t, replacement, wake.lease.attachment)
898+ require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
867899 }
868900
869901 func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) {
@@ -872,7 +904,10 @@ func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) {
872904 transition func(*renderCoordinator, *attachedClient, *attachedClient)
873905 }{
874906 {"target detach", func(rc *renderCoordinator, target, _ *attachedClient) { rc.noteDetach(target) }},
875- {"target replacement", func(rc *renderCoordinator, target, replacement *attachedClient) { rc.noteReplace(target, replacement) }},
907+ {"target detach and attach", func(rc *renderCoordinator, target, replacement *attachedClient) {
908+ rc.noteDetach(target)
909+ rc.attach(replacement)
910+ }},
876911 {"target teardown", func(rc *renderCoordinator, _ *attachedClient, _ *attachedClient) { rc.beginSessionTeardown().finish() }},
877912 }
878913 for _, tc := range cases {
@@ -920,7 +955,6 @@ func TestRenderCoordinatorLifecycleDropsStaleWakes(t *testing.T) {
920955 teardown func(rc *renderCoordinator, owner *attachedClient)
921956 }{
922957 {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},
923- {"park", func(rc *renderCoordinator, owner *attachedClient) { rc.notePark(owner) }},
924958 {"session teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.beginSessionTeardown().finish() }},
925959 }
926960 for _, tc := range cases {
@@ -955,7 +989,7 @@ func TestRenderCoordinatorLifecycleDropsStaleWakes(t *testing.T) {
955989 stale := h.armedTimers(t)
956990 require.NotEmpty(t, stale, "invalidate must arm a deadline timer")
957991
958- h.rc.noteReplace(owner, replacement)
992+ h.rc.noteDetach(owner)
959993 h.rc.attach(replacement)
960994 for _, timer := range stale {
961995 timer.ch <- time.Time{}
@@ -1035,11 +1069,11 @@ func TestRenderCoordinatorResizeMetadata(t *testing.T) {
10351069 case "detach":
10361070 h.rc.noteDetach(staleOwner)
10371071 case "park":
1038- h.rc.notePark(staleOwner)
1072+ h.rc.noteDetach(staleOwner)
10391073 case "session teardown":
10401074 h.rc.beginSessionTeardown().finish()
10411075 case "replacement":
1042- h.rc.noteReplace(staleOwner, freshOwner)
1076+ h.rc.noteDetach(staleOwner)
10431077 h.rc.attach(freshOwner)
10441078 require.Equal(t, uint64(2), h.rc.recordResizeRequest(sz(100, 50), freshOwner))
10451079 }
@@ -1116,7 +1150,7 @@ func TestProducerInvalidations(t *testing.T) {
11161150 name: "floating toggle to visible",
11171151 run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
11181152 fp := newPaneWithStableID(layout.PaneID("floating"), "float-producer", newQuietPTY(), domain.Size{Cols: 40, Rows: 10})
1119- tb := sess.activeTab()
1153+ tb := testAttachmentTab(sess)
11201154 tb.mu.Lock()
11211155 tb.floating.pane = fp
11221156 tb.floating.state = floatingHidden
@@ -1134,7 +1168,7 @@ func TestProducerInvalidations(t *testing.T) {
11341168 ac.proxied = true
11351169 sess.tabs[1].attention = true
11361170 daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionJumpAttention, nil)
1137- require.Equal(t, 1, activeTabIndex(sess))
1171+ require.Equal(t, 1, testAttachmentTabIndex(sess))
11381172 },
11391173 },
11401174 {
@@ -1275,7 +1309,7 @@ func TestConcurrentPaintInitializesOverlayUnderSendOwnership(t *testing.T) {
12751309 func TestStartPaneGoroutinesAccountsForOneReader(t *testing.T) {
12761310 p, release := newBlockingPTY(t)
12771311 d, sess, _, _ := newManualSessionWithPTYs(t, p)
1278- tb := sess.activeTab()
1312+ tb := testAttachmentTab(sess)
12791313 require.NotNil(t, tb)
12801314 d.startPaneGoroutines(sess, tb, tb.focusedPane())
12811315 release()
@@ -1291,41 +1325,6 @@ func requireWorkerExit(t *testing.T, done <-chan struct{}) {
12911325 awaitTestCompletion(t, done, "cancelled coordinator timer worker did not exit")
12921326 }
12931327
1294-func TestCoordinatorDeadlineCannotPaintPublishedReplacementBeforeOwnershipInstall(t *testing.T) {
1295- p, releasePTY := newBlockingPTY(t)
1296- defer releasePTY()
1297- d, sess, owner, ownerSends := newManualSessionWithPTYs(t, p)
1298- replacementTransport, replacementSends := newCapturingTransport(t)
1299- replacement := &attachedClient{tr: replacementTransport, output: newOutputStateStream(), size: owner.size}
1300- replacement.initOverlays()
1301- replacement.setSession(sess)
1302-
1303- clock := newCoordinatorMockClock(t, 2)
1304- d.clock = clock.clock
1305- rc := newRenderCoordinator(renderCoordinatorOptions{
1306- clock: clock.clock,
1307- wake: func(w renderWake) {
1308- // This is the production ownership boundary: composition must use
1309- // the coordinator's captured attachment, never sess.client.
1310- d.paint(sess, w.lease.attachment, w.reset, w.lease)
1311- },
1312- ackReady: func() bool { return true },
1313- })
1314- rc.attach(owner)
1315- sess.installRenderCoordinator(rc)
1316- rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true})
1317- timer := awaitCoordinatorScheduledTimer(t, clock)
1318-
1319- // Model attachClient's publication window exactly: sess.client is new,
1320- // but coordinator replacement has not yet invalidated the old deadline.
1321- sess.mu.Lock()
1322- sess.client = replacement
1323- sess.mu.Unlock()
1324- timer.ch <- time.Time{}
1325- requireNoCoordinatorOutputFrame(t, replacementSends)
1326- requireNoCoordinatorOutputFrame(t, ownerSends)
1327-}
1328-
13291328 func TestRenderCoordinatorResizeLaneRejectsStaleToken(t *testing.T) {
13301329 rc := newRenderCoordinator(renderCoordinatorOptions{})
13311330 newer := &timerToken{generation: 2, timer: &portsmocks.MockTimer{}, cancel: make(chan struct{}), done: make(chan struct{})}
@@ -1388,7 +1387,6 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
13881387 run func(*renderCoordinator, *attachedClient)
13891388 }{
13901389 {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }},
1391- {"park", func(rc *renderCoordinator, ac *attachedClient) { rc.notePark(ac) }},
13921390 } {
13931391 t.Run(transition.name, func(t *testing.T) {
13941392 h := newCoordinatorHarness(t)
@@ -1417,10 +1415,11 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
14171415 h.rc.mu.Unlock()
14181416 h.rc.noteSyncEnd(p, 1)
14191417 preview := awaitWake(t, h.previews)
1420- preview.lease = nil
14211418 require.Equal(t, renderWake{urgent: true, coalesced: 1}, preview)
14221419 requireNoWake(t, h.previews)
1423- requireNoWake(t, h.wakes)
1420+ wake := awaitWake(t, h.wakes)
1421+ require.Equal(t, renderWake{urgent: true, coalesced: 1}, wake)
1422+ require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
14241423 })
14251424 }
14261425 })
@@ -1431,7 +1430,8 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
14311430 h.rc.attach(old)
14321431 h.rc.noteSyncBegin(p, 1)
14331432 watchdog := h.armedTimers(t)[0]
1434- h.rc.noteReplace(old, replacement, false)
1433+ h.rc.noteDetach(old)
1434+ h.rc.attachWithReadiness(replacement, false)
14351435 require.False(t, h.rc.markAttachmentReady(h.rc.attachmentLease(old)), "old attachment Welcome is stale")
14361436 require.True(t, h.rc.markAttachmentReady(h.rc.attachmentLease(replacement)))
14371437
@@ -1455,7 +1455,7 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
14551455 wake := awaitWake(t, h.wakes)
14561456 require.True(t, wake.reset)
14571457 require.True(t, wake.urgent)
1458- require.Same(t, replacement, wake.lease.attachment)
1458+ require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
14591459 requireNoWake(t, h.wakes)
14601460 })
14611461
M internal/usecase/daemon/render_coordinator_types.go +9 -7
@@ -51,17 +51,19 @@ type renderWake struct {
5151 reset bool
5252 urgent bool
5353 coalesced int
54- // lease is snapshotted under coordinator ownership. The wake consumer must
55- // compose only for this exact active attachment incarnation, never by
56- // rereading session.client after an attach publication.
57- lease *attachmentLease
54+ // lease is optional coordinator-owned lifecycle safety for an attachment
55+ // specific wake. Shared wakes leave it nil and snapshot live attachment
56+ // leases at dispatch time; each paint then revalidates its exact connection
57+ // incarnation.
58+ lease *attachmentLease
59+ attachmentLeases map[*attachedClient]*attachmentLease
5860 // watchdog marks a flush forced by the synchronized-output watchdog.
5961 watchdog bool
6062 }
6163
62-// attachmentLease is the sole lifecycle identity for a primary render
63-// attachment. It is only mutated while renderCoordinator.mu is held; callbacks
64-// carry its pointer and must revalidate it before every side effect.
64+// attachmentLease is the lifecycle identity for one render attachment. It is
65+// only mutated while renderCoordinator.mu is held; callbacks carry its pointer
66+// and must revalidate it before every side effect.
6567 type attachmentLease struct {
6668 attachment *attachedClient
6769 ready bool
M internal/usecase/daemon/render_damage_receipt_test.go +6 -6
@@ -27,7 +27,7 @@ func TestPrimaryCaptureAloneRecordsDamageReceipts(t *testing.T) {
2727 p.mu.Unlock()
2828
2929 ac.sendMu.Lock()
30- state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{
30+ state, ok := captureRenderState(sess, ac, renderCaptureRequest{
3131 bars: barState{},
3232 overlays: capturedOverlayRenderState{},
3333 preview: pickerPreviewEmpty(),
@@ -61,7 +61,7 @@ func TestCaptureComposeEmitAcknowledgesCollapsedPaneDamageOnlyAfterEmission(t *t
6161 collapsed.screen.Write([]byte("hidden damage"))
6262
6363 ac.sendMu.Lock()
64- state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{bars: barState{}})
64+ state, ok := captureRenderState(sess, ac, renderCaptureRequest{bars: barState{}})
6565 require.True(t, ok)
6666 collapsedReceipt := false
6767 for _, receipt := range state.receipts {
@@ -95,7 +95,7 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T
9595 fail: func(_ *composedRenderFrame, ac *attachedClient) { ac.replaceTransport(cacheFailTransport{}) },
9696 restore: func(sess *session, ac *attachedClient, healthy ports.Transport) {
9797 sess.mu.Lock()
98- sess.client = ac
98+ sess.registerAttachmentLocked(ac)
9999 sess.mu.Unlock()
100100 ac.setSession(sess)
101101 ac.replaceTransport(healthy)
@@ -107,7 +107,7 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T
107107 // Prepare failures now schedule recovery through the coordinator;
108108 // retain the failed transaction here until this test explicitly retries.
109109 d.clock = newNoticeClock()
110- d.attachCoordinator(sess, nil, ac, true)
110+ d.attachCoordinator(sess, ac, true)
111111 healthy := ac.transport()
112112 p := sess.tabs[0].focusedPane()
113113
@@ -143,7 +143,7 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T
143143 func TestPrepareFailureNotifiesAndSchedulesRecovery(t *testing.T) {
144144 clock := newNoticeClock()
145145 d, sess, ac, _ := newNoticeFixture(t, clock)
146- rc := d.attachCoordinator(sess, nil, ac, true)
146+ rc := d.attachCoordinator(sess, ac, true)
147147 var producers []string
148148 rc.opts.onInvalidate = func(inv renderInvalidation) {
149149 // Both the notice repaint and the explicit recovery must re-enter only
@@ -223,7 +223,7 @@ func TestRenderDamageReceiptStaleGenerationForcesFullRedraw(t *testing.T) {
223223 func captureComposeForReceiptTest(t *testing.T, sess *session, ac *attachedClient) (*capturedRenderState, composedRenderFrame) {
224224 t.Helper()
225225 ac.sendMu.Lock() // emitFrame releases the transaction lock.
226- state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{
226+ state, ok := captureRenderState(sess, ac, renderCaptureRequest{
227227 bars: barState{},
228228 overlays: capturedOverlayRenderState{},
229229 preview: pickerPreviewEmpty(),
M internal/usecase/daemon/render_pipeline.go +29 -21
@@ -59,7 +59,7 @@ type composedRenderFrame struct {
5959
6060 // composeFrame is pure with respect to daemon ownership: it consumes only the
6161 // capture, the last committed cache, and an independent attachment-owned
62-// scratch cache. It returns a replacement cache without mutating committed.
62+// scratch cache. It returns a new cache without mutating committed.
6363 const inactivePaneForegroundDimming = 55
6464
6565 func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...composeCacheInput) composedRenderFrame {
@@ -459,7 +459,7 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
459459 return false
460460 }
461461 entry.core().mu.Lock()
462- owned := entry.core().client == ac
462+ _, owned := entry.core().attachments[ac]
463463 entry.core().mu.Unlock()
464464 if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry {
465465 ac.sendMu.Unlock()
@@ -532,13 +532,13 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
532532 sendTr = sendTransport.transport
533533 if ac.proxied {
534534 if sess, ok := localSession(entry); ok {
535- sendErr = ac.sendSessionMetaIfChanged(sess, sendTransport, marks.roleEffect)
535+ sendErr = ac.sendSessionMetaIfChanged(sess, sendTransport, marks.attachmentEffect)
536536 } else if proxy, ok := entry.(*proxySession); ok {
537537 meta, metaOK := proxy.sessionMetaSnapshot()
538538 if !metaOK {
539539 sendErr = errSessionMetaUnavailable
540540 } else {
541- sendErr = ac.sendSessionMetaSnapshot(meta, sendTransport, marks.roleEffect)
541+ sendErr = ac.sendSessionMetaSnapshot(meta, sendTransport, marks.attachmentEffect)
542542 }
543543 }
544544 }
@@ -548,21 +548,29 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
548548 sendErr = errors.New("client transport is nil")
549549 }
550550 if sendErr == nil {
551- send := sendTr.Send
552- if async, ok := sendTr.(ports.AsyncTransport); ok {
553- send = async.SendAsync
551+ interruptible := false
552+ if marks.attachmentEffect != nil {
553+ interruptible = marks.attachmentEffect.beginTransportSend(sendTransport)
554+ if !interruptible {
555+ sendErr = errAttachmentTransition
556+ }
554557 }
555- interruptible := marks.roleEffect != nil && marks.roleEffect.beginTransportSend(sendTransport)
556- if ac.proxied {
557- sendErr = preparedScreen.send(send)
558- } else {
559- sendErr = preparedANSI.send(data, ac.echoAck.Load(), send)
558+ if sendErr == nil {
559+ send := sendTr.Send
560+ if async, ok := sendTr.(ports.AsyncTransport); ok {
561+ send = async.SendAsync
562+ }
563+ if ac.proxied {
564+ sendErr = preparedScreen.send(send)
565+ } else {
566+ sendErr = preparedANSI.send(data, ac.echoAck.Load(), send)
567+ }
560568 }
561- if interruptible {
569+ if marks.attachmentEffect != nil && interruptible {
562570 if sendErr != nil {
563- marks.roleEffect.reportTransportFailure(sendTransport)
571+ marks.attachmentEffect.reportTransportFailure(sendTransport)
564572 }
565- marks.roleEffect.endTransportSend()
573+ marks.attachmentEffect.endTransportSend()
566574 }
567575 }
568576 endEmit(uint64(len(data)), sendErr == nil)
@@ -611,7 +619,7 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
611619 // A transport failure may invalidate the role gate. Release this render's
612620 // admission first. Detachment freezes the gate and therefore cannot mutate
613621 // ownership until any enclosing admitted operation has also ended.
614- if marks.roleEffect == nil {
622+ if marks.attachmentEffect == nil {
615623 if sess, ok := localSession(entry); ok {
616624 d.detachOnSendError(sess, ac, sendTr)
617625 } else if proxy, ok := entry.(*proxySession); ok {
@@ -619,11 +627,11 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
619627 }
620628 } else {
621629 // Capture the exact admitted capability, including its coordinator
622- // lease, before End permits a replacement publication. Reserve cleanup
623- // accounting before End so terminal Wait cannot race a later Add.
624- token := marks.roleEffect.roleToken()
625- launchCleanup := d.reserveRoleSendErrorCleanup(token, sendTr)
626- marks.roleEffect.End()
630+ // lease, before End permits a new attachment publication. Reserve
631+ // cleanup accounting before End so terminal Wait cannot race a later Add.
632+ token := marks.attachmentEffect.connectionToken()
633+ launchCleanup := d.reserveAttachmentSendErrorCleanup(token, sendTr)
634+ marks.attachmentEffect.End()
627635 launchCleanup()
628636 }
629637 }
M internal/usecase/daemon/render_pipeline_cache_test.go +1 -1
@@ -57,7 +57,7 @@ func TestPipelineCachePublishesOnlyAfterEmission(t *testing.T) {
5757 // its original healthy transport, then retry the pending state.
5858 if failure.name == "send" {
5959 sess.mu.Lock()
60- sess.client = ac
60+ sess.registerAttachmentLocked(ac)
6161 sess.mu.Unlock()
6262 ac.setSession(sess)
6363 ac.replaceTransport(healthy)
M internal/usecase/daemon/render_pipeline_test.go +27 -2
@@ -127,10 +127,11 @@ func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) {
127127 pty, release := newBlockingPTY(t)
128128 t.Cleanup(release)
129129 d, sess, _, sends := newManualSessionWithPTYs(t, pty)
130- ac := sess.client
130+ ac := sess.snapshotAttachments()[0]
131131 ac.sendMu.Lock()
132132 ac.output.next = ac.output.maxOutstanding
133133 ac.output.acked = 0
134+ ac.output.syncCapacityLocked()
134135 ac.sendMu.Unlock()
135136 p := sess.tabs[0].focusedPane()
136137 p.mu.Lock()
@@ -240,7 +241,7 @@ func TestEmitFrameFailedSendDoesNotPublishCursorOrOutputState(t *testing.T) {
240241 require.NotEmpty(t, probe.Bytes(), "failed output must not commit the renderer shadow")
241242
242243 sess.mu.Lock()
243- sess.client = ac
244+ sess.registerAttachmentLocked(ac)
244245 sess.mu.Unlock()
245246 ac.setSession(sess)
246247 ac.replaceTransport(healthy)
@@ -674,3 +675,27 @@ func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) {
674675 require.Equal(t, warn, got.BoxWarn)
675676 require.NotEqual(t, got.BoxInfo, got.BoxWarn)
676677 }
678+
679+func TestEmitFrameSkipsTransportSendWhenAttachmentEffectFenceRejects(t *testing.T) {
680+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
681+ token := sess.attachmentToken(ac, ac.transport())
682+ effect, admitted := ac.beginAttachmentEffect(token)
683+ require.True(t, admitted)
684+ ac.attachmentEffects.mu.Lock()
685+ ac.attachmentEffects.capability.transport = transportSnapshot{}
686+ ac.attachmentEffects.mu.Unlock()
687+
688+ state := cacheState("stale", 1)
689+ state.attachment = ac
690+ composed := composeFrame(state, composeCacheInput{})
691+ ac.sendMu.Lock()
692+ require.True(t, d.emitFrame(sess, ac, &state, composed, &runtimeMarkBatch{attachmentEffect: effect}))
693+ d.attachmentCleanupWg.Wait()
694+
695+ require.Zero(t, ac.output.next, "rejected transport effect must not commit output state")
696+ select {
697+ case frame := <-sends:
698+ t.Fatalf("rejected transport effect was sent: %v", frame.Type)
699+ default:
700+ }
701+}
M internal/usecase/daemon/render_test.go +51 -172
@@ -79,7 +79,7 @@ func TestFirstPaintRetainedFloatingPaneEmitsOneReset(t *testing.T) {
7979 // outer terminal also changes, its completed transaction must cover
8080 // that popup rather than emitting a second reset-producing frame.
8181 floating := newPane(layout.PaneID("floating"), nil, domain.Size{Cols: 20, Rows: 8})
82- installTestFloating(sess.activeTab(), floating, true)
82+ installTestFloating(testAttachmentTab(sess), floating, true)
8383
8484 d.firstPaint(sess, ac, tc.clientSize)
8585
@@ -313,7 +313,7 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
313313 d, sess, ac, sends := newManualSessionWithPTYs(t, activePTY, inactivePTY)
314314 clock := newCoordinatorMockClock(t, 8)
315315 d.clock = clock.clock
316- rc := d.attachCoordinator(sess, nil, ac, true)
316+ rc := d.attachCoordinator(sess, ac, true)
317317
318318 d.sessWg.Add(1)
319319 go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
@@ -321,7 +321,7 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
321321 awaitPTYReadProcessed(t, inactiveProcessed)
322322
323323 sess.mu.Lock()
324- sess.active = 1
324+ selectTestAttachmentTabLocked(sess, 1)
325325 sess.mu.Unlock()
326326 d.invalidateRender(sess, ac, true, "sync activation")
327327 fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), urgentRenderDeadline)
@@ -349,7 +349,7 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
349349 d, sess, ac, sends := newManualSessionWithPTYs(t, oldPTY, newPTY, parkedPTY)
350350 clock := newCoordinatorMockClock(t, 8)
351351 d.clock = clock.clock
352- rc := d.attachCoordinator(sess, nil, ac, true)
352+ rc := d.attachCoordinator(sess, ac, true)
353353
354354 d.sessWg.Add(2)
355355 go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane())
@@ -358,7 +358,7 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
358358 awaitPTYReadProcessed(t, oldProcessed)
359359
360360 sess.mu.Lock()
361- sess.active = 1
361+ selectTestAttachmentTabLocked(sess, 1)
362362 sess.mu.Unlock()
363363 newSteps <- channelPTYStep{data: []byte("newly active")}
364364 awaitPTYReadProcessed(t, newProcessed)
@@ -379,111 +379,13 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
379379 })
380380 }
381381
382-func TestPTYReaderRestoresPrimaryScreenImmediatelyAfterDEC1049Exit(t *testing.T) {
383- for _, tc := range []struct {
384- name string
385- window uint64
386- exitBeforeACK bool
387- exitSuffix string
388- overlay bool
389- }{
390- {name: "datagram output window (1): exit waits for ACK", window: 1},
391- {name: "datagram output window (1): exit and prompt arrive before ACK", window: 1, exitBeforeACK: true, exitSuffix: "PROMPT> "},
392- {name: "local default output window (8): exit waits for ACK", window: maxUnackedOutputStates},
393- {name: "local default output window (8): exit and prompt arrive before ACK", window: maxUnackedOutputStates, exitBeforeACK: true, exitSuffix: "PROMPT> "},
394- {name: "local default output window (8): palette overlay with bars", window: maxUnackedOutputStates, overlay: true},
395- } {
396- t.Run(tc.name, func(t *testing.T) {
397- pty, steps, processed := newChannelPTY(t)
398- d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
399- // Datagram clients use a one-frame window; local clients retain the
400- // default eight-frame window. Both ACK orderings stay deterministic.
401- ac.output.maxOutstanding = tc.window
402- clock := newCoordinatorMockClock(t, 8)
403- d.clock = clock.clock
404- rc := d.attachCoordinator(sess, nil, ac, true)
405- client := vt.NewScreen(80, 25)
406- pane := sess.tabs[0].focusedPane()
407-
408- d.sessWg.Add(1)
409- go d.ptyReader(sess, sess.tabs[0], pane)
410- t.Cleanup(func() {
411- steps <- channelPTYStep{err: io.EOF}
412- d.sessWg.Wait()
413- d.waitNotifies()
414- })
415-
416- replay := func(data []byte, deadline time.Duration) ports.Output {
417- t.Helper()
418- steps <- channelPTYStep{data: data}
419- awaitPTYReadProcessed(t, processed)
420- fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), deadline)
421- return mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))
422- }
423- acknowledge := func(out ports.Output) {
424- ac.ackOutputState(out.NewStateNum)
425- rc.notifyAck()
426- }
427-
428- if tc.overlay {
429- d.enterPalette(sess, ac)
430- fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), urgentRenderDeadline)
431- acknowledge(mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends)))
432- }
433- acknowledge(replay([]byte("PRIMARY"), minOutputRenderDeadline))
434- primary := client.Frame.Clone()
435-
436- // The reported PTY stream is deliberately split into enter, body, and
437- // exit reads. MATRIX distinguishes alternate cells in the replayed
438- // terminal model from all primary, chrome, and overlay cells.
439- acknowledge(replay([]byte("\x1b[?1049h"), minOutputRenderDeadline))
440- alternate := replay([]byte(strings.Repeat("MATRIX", 300)), minOutputRenderDeadline)
441- pane.mu.Lock()
442- require.Contains(t, strings.Join(frameRows(pane.screen.Frame), "\n"), "MATRIX", "fixture must make alternate cells visible to the production renderer")
443- pane.mu.Unlock()
444-
445- exitData := []byte("\x1b[?1049l" + tc.exitSuffix)
446- var exit ports.Output
447- if tc.exitBeforeACK {
448- steps <- channelPTYStep{data: exitData}
449- awaitPTYReadProcessed(t, processed)
450- fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline)
451- if tc.window == 1 {
452- requireNoCoordinatorOutputFrame(t, sends)
453- acknowledge(alternate)
454- exit = mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))
455- } else {
456- exit = mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))
457- require.Equal(t, alternate.NewStateNum, exit.BaseStateNum,
458- "local exit must be rendered before the alternate-frame ACK")
459- acknowledge(alternate)
460- }
461- } else {
462- acknowledge(alternate)
463- steps <- channelPTYStep{data: exitData}
464- awaitPTYReadProcessed(t, processed)
465- fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline)
466- exit = mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))
467- }
468- require.NotEmpty(t, exit.Data, "the first post-exit output must actively remove alternate cells")
469- rendered := strings.Join(frameRows(client.Frame), "\n")
470- require.NotContains(t, rendered, "MATRIX", "the first post-exit frame must not retain alternate cells")
471- if tc.exitSuffix == "" {
472- require.Equal(t, frameRows(primary), frameRows(client.Frame), "the first post-exit output must restore the primary screen")
473- } else {
474- require.Contains(t, rendered, tc.exitSuffix, "bytes immediately after DEC 1049 exit must appear in the first post-exit frame")
475- }
476- })
477- }
478-}
479-
480382 func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *testing.T) {
481383 t.Run("detached target completes cross-session preview without later PTY output", func(t *testing.T) {
482384 pty, steps, processed := newChannelPTY(t)
483385 d, target, targetClient, _ := newManualSessionWithPTYs(t, pty)
484386 clock := newCoordinatorMockClock(t, 8)
485387 d.clock = clock.clock
486- rc := d.attachCoordinator(target, nil, targetClient, true)
388+ rc := d.attachCoordinator(target, targetClient, true)
487389
488390 // A viewer in a different session is previewing the headless target.
489391 viewer := &attachedClient{}
@@ -491,7 +393,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
491393 viewer.overlays.pickerMu.Lock()
492394 viewer.overlays.pickerPreview = target.tabs[0]
493395 viewer.overlays.pickerMu.Unlock()
494- d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", client: viewer}}
396+ d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", attachments: map[*attachedClient]struct{}{viewer: {}}}}
495397 previews := make(chan renderWake, 2)
496398 rc.subscribePreviewFor(viewer, 1, func(w renderWake) { previews <- w })
497399
@@ -502,7 +404,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
502404 drainCoordinatorTimers(clock) // detach must clear the pending output work.
503405
504406 target.mu.Lock()
505- target.client = nil
407+ clearAttachmentsForTestLocked(target)
506408 target.mu.Unlock()
507409 rc.noteDetach(targetClient)
508410 steps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")}
@@ -522,45 +424,42 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
522424 d.sessWg.Wait()
523425 })
524426
525- t.Run("replacement receives one complete reset frame without later PTY output", func(t *testing.T) {
427+ t.Run("remaining attachment receives shared output after peer detach", func(t *testing.T) {
526428 pty, steps, processed := newChannelPTY(t)
527- d, target, oldClient, _ := newManualSessionWithPTYs(t, pty)
429+ d, target, detached, _ := newManualSessionWithPTYs(t, pty)
528430 clock := newCoordinatorMockClock(t, 8)
529431 d.clock = clock.clock
530- rc := d.attachCoordinator(target, nil, oldClient, true)
432+ rc := d.attachCoordinator(target, detached, true)
433+
434+ secondTransport, secondSends := newCapturingTransport(t)
435+ remaining := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: detached.size}
436+ remaining.initOverlays()
437+ remaining.setSession(target)
438+ target.mu.Lock()
439+ require.True(t, target.registerAttachmentLocked(remaining))
440+ target.mu.Unlock()
441+ rc.attach(remaining)
531442
532- wakes := make(chan renderWake, 2)
533- rc.opts.wake = func(w renderWake) { wakes <- w }
534443 d.sessWg.Add(1)
535444 go d.ptyReader(target, target.tabs[0], target.tabs[0].focusedPane())
536- steps <- channelPTYStep{data: []byte("\x1b[?2026hpending")}
445+ steps <- channelPTYStep{data: []byte("before detach")}
537446 awaitPTYReadProcessed(t, processed)
538- drainCoordinatorTimers(clock)
447+ timers := drainCoordinatorTimers(clock)
539448
540- replacement := &attachedClient{output: newOutputStateStream()}
541- rc.noteReplace(oldClient, replacement, true)
449+ // Detaching one attachment must not discard the session-shared pending
450+ // invalidation or stop the coordinator from painting the remaining one.
542451 target.mu.Lock()
543- target.client = replacement
452+ require.True(t, target.unregisterAttachmentLocked(detached))
544453 target.mu.Unlock()
545- // The replacement's initial full paint remains gated by the batch. Its
546- // reset must survive completion and coalesce with the completion wake.
547- rc.invalidateForAttachment(replacement, renderInvalidation{class: invalidateUrgent, reset: true, producer: "replacement first paint"})
548- replacementTimers := drainCoordinatorTimers(clock)
549- steps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")}
550- awaitPTYReadProcessed(t, processed)
551- requireNoWake(t, wakes)
454+ rc.noteDetach(detached)
455+ fireCoordinatorTimer(t, rc, timers, minOutputRenderDeadline)
456+ require.NotEmpty(t, secondSends, "the remaining attachment received no shared output after its peer detached")
457+ before := len(secondSends)
552458
553- fireCoordinatorTimer(t, rc, replacementTimers, urgentRenderDeadline)
554- wake := <-wakes
555- require.True(t, wake.urgent)
556- require.True(t, wake.reset, "the replacement's cleared batch must repaint a complete frame")
557- require.Equal(t, 2, wake.coalesced, "completion coalesces only with the replacement's fresh reset batch")
558- require.Same(t, replacement, wake.lease.attachment)
559- select {
560- case duplicate := <-wakes:
561- t.Fatalf("sync completion must publish exactly one replacement wake: %#v", duplicate)
562- default:
563- }
459+ steps <- channelPTYStep{data: []byte("after detach")}
460+ awaitPTYReadProcessed(t, processed)
461+ fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline)
462+ require.Greater(t, len(secondSends), before, "shared output stopped after the peer detached")
564463
565464 steps <- channelPTYStep{err: io.EOF}
566465 d.sessWg.Wait()
@@ -571,11 +470,11 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
571470 d, target, client, _ := newManualSessionWithPTYs(t, pty)
572471 clock := newCoordinatorMockClock(t, 8)
573472 d.clock = clock.clock
574- rc := d.attachCoordinator(target, nil, client, true)
473+ rc := d.attachCoordinator(target, client, true)
575474 wakes := make(chan renderWake, 1)
576475 rc.opts.wake = func(w renderWake) { wakes <- w }
577476 target.mu.Lock()
578- target.client = nil
477+ clearAttachmentsForTestLocked(target)
579478 target.mu.Unlock()
580479 rc.noteDetach(client)
581480
@@ -601,7 +500,7 @@ func TestPaneRenderableActiveAttachmentDoesNotScanPickerPreviews(t *testing.T) {
601500 p, release := newBlockingPTY(t)
602501 defer release()
603502 d, sess, _, _ := newManualSessionWithPTYs(t, p)
604- tb := sess.activeTab()
503+ tb := testAttachmentTab(sess)
605504 pane := tb.focusedPane()
606505
607506 // Holding daemon ownership makes a picker scan block. An active attached
@@ -628,11 +527,11 @@ func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) {
628527 name string
629528 setup func(*Daemon, *session, *tab, *pane)
630529 }{
631- {name: "headless", setup: func(_ *Daemon, sess *session, _ *tab, _ *pane) { sess.client = nil }},
530+ {name: "headless", setup: func(_ *Daemon, sess *session, _ *tab, _ *pane) { clearAttachmentsForTest(sess) }},
632531 {name: "inactive tab", setup: func(_ *Daemon, sess *session, tb *tab, _ *pane) {
633532 other := newTab(nil, domain.Size{Cols: 80, Rows: 23})
634533 sess.tabs = append([]*tab{other}, sess.tabs...)
635- sess.active = 0
534+ selectTestAttachmentTab(sess, 0)
636535 require.NotSame(t, other, tb)
637536 }},
638537 {name: "collapsed", setup: func(_ *Daemon, _ *session, tb *tab, p *pane) {
@@ -667,39 +566,19 @@ func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) {
667566 require.NotEmpty(t, p.screen.Damage(), "active pane damage belongs to coordinator composition")
668567 p.screen.ClearDamage()
669568
670- sess.client = nil
569+ clearAttachmentsForTest(sess)
671570 viewer := &attachedClient{}
672571 viewer.initOverlays()
673572 viewer.overlays.pickerMu.Lock()
674573 viewer.overlays.pickerPreview = tb
675574 viewer.overlays.pickerMu.Unlock()
676- d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", client: viewer}}
575+ d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", attachments: map[*attachedClient]struct{}{viewer: {}}}}
677576 p.screen.Write([]byte("preview"))
678577 _ = d.paneRenderable(sess, tb, p)
679578 require.NotEmpty(t, p.screen.Damage(), "picker preview damage must remain for coordinator composition")
680579 })
681580 }
682581
683-func TestPTYWriteErrorIsLogged(t *testing.T) {
684- var logs bytes.Buffer
685- d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logs, nil)))
686- errBoom := errors.New("boom")
687- p := portsmocks.NewMockPTY(t)
688- p.EXPECT().Write([]byte("input")).Return(0, errBoom).Once()
689- win := newTab(p, domain.Size{Cols: 80, Rows: 23})
690- sess := &session{sessionCore: sessionCore{id: "manual", name: "work"}, tabs: []*tab{win}}
691- ac := &attachedClient{}
692- ac.initOverlays()
693- ac.setSession(sess)
694-
695- daemonKeyHandler{d: d, ac: ac}.Forward([]byte("input"))
696-
697- got := logs.String()
698- if !strings.Contains(got, "pty write failed") || !strings.Contains(got, "boom") || !strings.Contains(got, "work") {
699- t.Fatalf("log output %q does not contain PTY write failure details", got)
700- }
701-}
702-
703582 func TestAltXClosesFinalTabAndDetaches(t *testing.T) {
704583 d, sess, ac, sends, releases := newManualTabSession(t, 1)
705584 defer releases[0]()
@@ -719,7 +598,7 @@ func TestPTYEOFClosesActiveNonFinalTabAndRepaintsRemaining(t *testing.T) {
719598 p2, releasePTY2 := newBlockingPTY(t)
720599 d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)
721600 defer releasePTY2()
722- sess.active = 0
601+ selectTestAttachmentTab(sess, 0)
723602 sess.tabs[1].focusedPane().screen.Write([]byte("remaining"))
724603
725604 d.sessWg.Add(1)
@@ -728,7 +607,7 @@ func TestPTYEOFClosesActiveNonFinalTabAndRepaintsRemaining(t *testing.T) {
728607
729608 require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)
730609 require.Equal(t, 1, sessionCount(d))
731- require.Equal(t, 0, activeTabIndex(sess))
610+ require.Equal(t, 0, testAttachmentTabIndex(sess))
732611 f := awaitFrame(t, sends, ports.MsgOutput)
733612 out, err := ports.UnmarshalOutput(f.Payload)
734613 require.NoError(t, err)
@@ -745,7 +624,7 @@ func TestPTYEOFClosesInactiveNonFinalTabAndRepaintsStatus(t *testing.T) {
745624 p2, releasePTY2 := newBlockingPTY(t)
746625 d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)
747626 defer releasePTY1()
748- sess.active = 0
627+ selectTestAttachmentTab(sess, 0)
749628 sess.tabs[0].focusedPane().screen.Write([]byte("active"))
750629
751630 d.sessWg.Add(1)
@@ -754,7 +633,7 @@ func TestPTYEOFClosesInactiveNonFinalTabAndRepaintsStatus(t *testing.T) {
754633
755634 require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)
756635 require.Equal(t, 1, sessionCount(d))
757- require.Equal(t, 0, activeTabIndex(sess))
636+ require.Equal(t, 0, testAttachmentTabIndex(sess))
758637 f := awaitFrame(t, sends, ports.MsgOutput)
759638 out, err := ports.UnmarshalOutput(f.Payload)
760639 require.NoError(t, err)
@@ -802,7 +681,7 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
802681 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
803682 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
804683 ac.initOverlays()
805- sess := &session{sessionCore: sessionCore{id: "s", name: "s", client: ac}, tabs: []*tab{win}}
684+ sess := &session{sessionCore: sessionCore{id: "s", name: "s", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}}
806685 ac.setSession(sess)
807686
808687 // Client rows are one more than the equivalent case in a single-bar
@@ -1062,7 +941,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
1062941 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
1063942 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
1064943 ac.initOverlays()
1065- sess := &session{sessionCore: sessionCore{id: "s", name: "s", client: ac}, tabs: []*tab{win}}
944+ sess := &session{sessionCore: sessionCore{id: "s", name: "s", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}}
1066945 ac.setSession(sess)
1067946
1068947 d.resize(sess, ac, newSize)
@@ -1091,7 +970,7 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) {
1091970 sctx, cancel := context.WithCancel(context.Background())
1092971 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
1093972 ac.initOverlays()
1094- sess := &session{sessionCore: sessionCore{id: "e", name: "0", ephemeral: true, client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
973+ sess := &session{sessionCore: sessionCore{id: "e", name: "0", ephemeral: true, attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
1095974 ac.setSession(sess)
1096975 d.sessions[sess.id] = sess
1097976
@@ -1103,7 +982,7 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) {
1103982
1104983 require.Equal(t, 1, sessionCount(d), "ephemeral session survives failed client send")
1105984 sess.mu.Lock()
1106- require.Nil(t, sess.client)
985+ require.Empty(t, sess.snapshotAttachmentsLocked())
1107986 sess.mu.Unlock()
1108987
1109988 _ = d.killSession(sess, ports.ReasonServerShutdown, false)
@@ -1135,7 +1014,7 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
11351014 win := newTestTabWithContext(p, sctx, cancel)
11361015 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
11371016 ac.initOverlays()
1138- sess := &session{sessionCore: sessionCore{id: "query", name: "query", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
1017+ sess := &session{sessionCore: sessionCore{id: "query", name: "query", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
11391018 ac.setSession(sess)
11401019 d.sessions[sess.id] = sess
11411020 d.sessWg.Add(1)
@@ -1198,7 +1077,7 @@ func TestPaintAlignsFloatingCursorWithCommittedGeometry(t *testing.T) {
11981077 floating := newPane("floating", nil, rectSize(committed.Inner))
11991078 floating.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'F'})
12001079 floating.popupGeometry = committed
1201- installTestFloating(sess.activeTab(), floating, true)
1080+ installTestFloating(testAttachmentTab(sess), floating, true)
12021081
12031082 d.paint(sess, ac, true, nil)
12041083 data := mustOutputData(t, sends)
@@ -1300,7 +1179,7 @@ func TestPaintComposesCopyBodyAboveFloating(t *testing.T) {
13001179 fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3})
13011180 appendHistoryRow(t, fp.history, testRow("flt-old"))
13021181 fp.screen.Write([]byte("flt-live"))
1303- installTestFloating(sess.activeTab(), fp, true)
1182+ installTestFloating(testAttachmentTab(sess), fp, true)
13041183
13051184 d.enterCopyMode(sess, ac)
13061185 data := mustOutputData(t, sends)
M internal/usecase/daemon/render_timer.go +1 -1
@@ -81,7 +81,7 @@ func (s *timerSupervisor) startLocked(token *timerToken, timerC <-chan time.Time
8181 func (s *timerSupervisor) wait() { s.workers.Wait() }
8282
8383 // stopDetachedTimer is intentionally non-blocking. Ordinary lifecycle and
84-// replacement transitions may cancel/stop stale workers but callback stacks
84+// attachment transitions may cancel/stop stale workers but callback stacks
8585 // never wait for a worker to finish.
8686 func stopDetachedTimer(token *timerToken) {
8787 if token != nil && token.timer != nil {
M internal/usecase/daemon/resize_mode.go +5 -3
@@ -47,7 +47,7 @@ func (d *Daemon) enterResizeMode(sess *session, ac *attachedClient) error {
4747 if sess == nil || ac == nil {
4848 return layout.ErrNotFound
4949 }
50- target := resolveDaemonActionTarget(sess)
50+ target := resolveDaemonActionTargetForAttachment(sess, ac)
5151 if target.tab == nil || target.pane == nil {
5252 return layout.ErrNotFound
5353 }
@@ -131,8 +131,10 @@ func (d *Daemon) handleResizeInput(ac *attachedClient, data []byte) {
131131 return
132132 }
133133 for _, request := range requests {
134- request.target = resolveDaemonActionTarget(sess)
135- if err := (daemonActions{d: d}).Run(request); err != nil {
134+ if err := sess.runMutation(func() error {
135+ request.target = resolveDaemonActionTargetForAttachment(sess, ac)
136+ return (daemonActions{d: d}).Run(request)
137+ }); err != nil {
136138 d.reportError(sess, resizeUserError(err))
137139 continue
138140 }
M internal/usecase/daemon/resize_mode_test.go +3 -239
@@ -6,9 +6,7 @@ import (
66
77 "github.com/bnema/vev/internal/domain"
88 "github.com/bnema/vev/internal/ports"
9- scopy "github.com/bnema/vev/internal/usecase/copy"
109 "github.com/bnema/vev/internal/usecase/layout"
11- "github.com/bnema/vev/internal/usecase/mouse"
1210 "github.com/stretchr/testify/require"
1311 )
1412
@@ -64,7 +62,7 @@ func TestResizeControlHeadlessAndErrors(t *testing.T) {
6462 require.Equal(t, "pane is not in a split", tooEarly.Text)
6563
6664 require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
67- tb := sess.activeTab()
65+ tb := testAttachmentTab(sess)
6866 tb.mu.Lock()
6967 beforeFocus := tb.tree.Focus
7068 beforeGeneration := tb.layoutGeneration
@@ -94,67 +92,6 @@ func TestResizeControlHeadlessAndErrors(t *testing.T) {
9492 require.Empty(t, equalized.Output)
9593 }
9694
97-func TestEnterResizeModeRejectsPaneOutsideSplit(t *testing.T) {
98- factory := &controlPTYFactory{}
99- d := newTestDaemon(t, factory, stubClock{})
100- t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
101- sess := addControlSession(d, "work", "t_work", "p_work")
102- ac := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
103- ac.initOverlays()
104- ac.setSession(sess)
105-
106- err := d.enterResizeMode(sess, ac)
107- require.ErrorIs(t, err, layout.ErrNotInSplit)
108- require.False(t, ac.overlays.resizeModeActive())
109-}
110-
111-func TestResizeModeStateRenderingAndMouseConsumption(t *testing.T) {
112- factory := &controlPTYFactory{}
113- d := newTestDaemon(t, factory, stubClock{})
114- t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
115- sess := addControlSession(d, "work", "t_work", "p_work")
116- require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
117-
118- ac := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
119- ac.initOverlays()
120- ac.setSession(sess)
121- require.NoError(t, d.enterResizeMode(sess, ac))
122- require.True(t, ac.overlays.resizeModeActive())
123- d.handleResizeInput(ac, []byte("\x1b"))
124- require.True(t, ac.overlays.resizeModeActive(), "a bare ESC read must wait for a possible arrow suffix")
125- d.handleResizeInput(ac, []byte("[D"))
126- require.True(t, ac.overlays.resizeModeActive())
127-
128- snapshot := ac.overlays.SnapshotForRender()
129- require.True(t, snapshot.resizeActive)
130- snapshot.Unlock()
131- require.False(t, (capturedOverlayRenderState{resizeActive: true}).active(), "resize must not create a modal render layer")
132-
133- tb := sess.activeTab()
134- tb.mu.Lock()
135- focused := tb.focusedPane()
136- tb.mu.Unlock()
137- focused.mu.Lock()
138- cursor := captureCursorInputsLocked(focused, domain.Rect{Width: 40, Height: 10}, capturedOverlayRenderState{resizeActive: true})
139- focused.mu.Unlock()
140- require.False(t, cursor.hiddenByOverlay, "resize must leave the pane cursor live")
141-
142- ac.overlays.copyMu.Lock()
143- ac.overlays.copyPointer.valid = true
144- ac.overlays.copyMu.Unlock()
145- d.handleMouse(ac, mouse.Event{Button: mouse.Left, Type: mouse.Release})
146- ac.overlays.copyMu.Lock()
147- require.True(t, ac.overlays.copyPointer.valid, "resize mode must consume mouse input before copy/terminal routing")
148- ac.overlays.copyMu.Unlock()
149-
150- ac.overlays.copyMu.Lock()
151- ac.overlays.copyMode = &scopy.Mode{}
152- ac.overlays.copyMu.Unlock()
153- require.True(t, ac.overlays.HandleInput(d, []byte("q")))
154- require.False(t, ac.overlays.resizeModeActive())
155- require.True(t, ac.overlays.copyActive(), "resize input must take priority over copy mode")
156-}
157-
15895 func TestRouteOverlayDecodesHorizontalArrows(t *testing.T) {
15996 var left, right int
16097 pending := []byte(nil)
@@ -170,66 +107,10 @@ func TestRouteOverlayDecodesHorizontalArrows(t *testing.T) {
170107 // TestResizeModeKeysAndEscapes exercises the modal parser through the real
171108 // action seam. Every directional spelling must commit exactly one mutation;
172109 // parser prefixes are deliberately split across reads.
173-func TestResizeModeKeysAndEscapes(t *testing.T) {
174- tests := []struct {
175- name string
176- chunks [][]byte
177- change bool
178- exit bool
179- equalize bool
180- }{
181- {"h", [][]byte{[]byte("h")}, true, false, false},
182- {"j", [][]byte{[]byte("j")}, true, false, false},
183- {"k", [][]byte{[]byte("k")}, true, false, false},
184- {"l", [][]byte{[]byte("l")}, true, false, false},
185- {"CSI left split", [][]byte{[]byte("\x1b["), []byte("D")}, true, false, false},
186- {"CSI right", [][]byte{[]byte("\x1b[C")}, true, false, false},
187- {"CSI up", [][]byte{[]byte("\x1b[A")}, true, false, false},
188- {"CSI down", [][]byte{[]byte("\x1b[B")}, true, false, false},
189- {"SS3 left", [][]byte{[]byte("\x1bOD")}, true, false, false},
190- {"SS3 right split", [][]byte{[]byte("\x1bO"), []byte("C")}, true, false, false},
191- {"SS3 up", [][]byte{[]byte("\x1bOA")}, true, false, false},
192- {"SS3 down", [][]byte{[]byte("\x1bOB")}, true, false, false},
193- {"equalize", [][]byte{[]byte("=")}, true, false, true},
194- {"ignored", [][]byte{[]byte("z\x00")}, false, false, false},
195- {"q exits", [][]byte{[]byte("q")}, false, true, false},
196- {"enter exits", [][]byte{[]byte("\r")}, false, true, false},
197- {"escape exits", [][]byte{[]byte("\x1b"), []byte("x")}, false, true, false},
198- }
199- for _, tt := range tests {
200- t.Run(tt.name, func(t *testing.T) {
201- d, sess, ac := resizeModeFixture(t)
202- tb := sess.activeTab()
203- tb.mu.Lock()
204- before := tb.layoutGeneration
205- if tt.equalize {
206- tb.tree.Root.Children[0].Weight = 3
207- }
208- tb.mu.Unlock()
209- for _, chunk := range tt.chunks {
210- d.handleResizeInput(ac, chunk)
211- }
212- tb.mu.Lock()
213- after := tb.layoutGeneration
214- if tt.equalize {
215- for _, child := range tb.tree.Root.Children {
216- require.Zero(t, child.Weight)
217- }
218- }
219- tb.mu.Unlock()
220- if tt.change {
221- require.Equal(t, before+1, after)
222- } else {
223- require.Equal(t, before, after)
224- }
225- require.Equal(t, !tt.exit, ac.overlays.resizeModeActive())
226- })
227- }
228-}
229110
230111 func TestResizeModeRendersGuidanceWithoutHidingContentOrCursor(t *testing.T) {
231112 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
232- tb := sess.activeTab()
113+ tb := testAttachmentTab(sess)
233114 second := newPane("pane-2", nil, domain.Size{Cols: 39, Rows: 23})
234115 tb.mu.Lock()
235116 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
@@ -243,108 +124,6 @@ func TestResizeModeRendersGuidanceWithoutHidingContentOrCursor(t *testing.T) {
243124 require.Contains(t, data, "\x1b[?25h", "resize leaves the focused pane cursor visible")
244125 }
245126
246-func TestResizeModeOverlayPriority(t *testing.T) {
247- tests := []struct {
248- name string
249- input []byte
250- setup func(*Daemon, *session, *attachedClient)
251- check func(*testing.T, *attachedClient)
252- }{
253- {"prompt before palette picker notices resize and copy", []byte("q"), func(d *Daemon, sess *session, ac *attachedClient) {
254- d.enterCopyMode(sess, ac)
255- d.enterNotices(sess, ac)
256- d.enterPicker(sess, ac)
257- d.enterPalette(sess, ac)
258- d.enterPrompt(sess, ac, "prompt", "", func(string) error { return nil })
259- }, func(t *testing.T, ac *attachedClient) {
260- require.True(t, ac.overlays.promptActive())
261- require.True(t, ac.overlays.resizeModeActive())
262- }},
263- {"palette before picker notices resize and copy", []byte("q"), func(d *Daemon, sess *session, ac *attachedClient) {
264- d.enterCopyMode(sess, ac)
265- d.enterPicker(sess, ac)
266- d.enterPalette(sess, ac)
267- }, func(t *testing.T, ac *attachedClient) {
268- require.True(t, ac.overlays.paletteActive())
269- require.True(t, ac.overlays.resizeModeActive())
270- }},
271- {"picker before resize and copy", []byte("j"), func(d *Daemon, sess *session, ac *attachedClient) {
272- d.enterCopyMode(sess, ac)
273- d.enterPicker(sess, ac)
274- }, func(t *testing.T, ac *attachedClient) {
275- require.True(t, ac.overlays.pickerActive())
276- require.True(t, ac.overlays.resizeModeActive())
277- }},
278- {"notices before resize and copy", []byte("q"), func(d *Daemon, sess *session, ac *attachedClient) {
279- d.enterCopyMode(sess, ac)
280- d.enterNotices(sess, ac)
281- }, func(t *testing.T, ac *attachedClient) {
282- require.False(t, ac.overlays.noticesActive(), "q must be consumed by notices")
283- require.True(t, ac.overlays.resizeModeActive())
284- }},
285- }
286- for _, tt := range tests {
287- t.Run(tt.name, func(t *testing.T) {
288- d, sess, ac := resizeModeFixture(t)
289- tt.setup(d, sess, ac)
290- require.True(t, ac.overlays.HandleInput(d, tt.input))
291- tt.check(t, ac)
292- })
293- }
294-}
295-
296-func TestResizeModeWarningPersistsAndActionsShareTransaction(t *testing.T) {
297- t.Run("too small warning leaves mode active", func(t *testing.T) {
298- d, sess, ac := resizeModeFixture(t)
299- tb := sess.activeTab()
300- tb.mu.Lock()
301- tb.size.Cols = 41 // two minimum-width panes and their separator
302- tb.bumpLayoutGenerationLocked()
303- tb.mu.Unlock()
304- d.handleResizeInput(ac, []byte("l"))
305- require.True(t, ac.overlays.resizeModeActive())
306- history := d.notices.history()
307- require.NotEmpty(t, history)
308- require.Equal(t, "pane cannot be resized further", history[len(history)-1].Message)
309- })
310-
311- t.Run("palette control and modal have the same committed weights", func(t *testing.T) {
312- type invoke func(*Daemon, *session, *attachedClient) error
313- invocations := []struct {
314- name string
315- run invoke
316- }{
317- {"palette", func(d *Daemon, sess *session, ac *attachedClient) error {
318- return (paletteExec{d: d, sess: sess, ac: ac}).GrowPaneWidth()
319- }},
320- {"control", func(d *Daemon, sess *session, _ *attachedClient) error {
321- target := resolveDaemonActionTarget(sess)
322- return (controlExec{d: d, sess: sess, tab: target.tab, target: target}).GrowPaneWidth()
323- }},
324- {"modal", func(d *Daemon, _ *session, ac *attachedClient) error {
325- d.handleResizeInput(ac, []byte("l"))
326- return nil
327- }},
328- }
329- var want []float64
330- for _, tt := range invocations {
331- t.Run(tt.name, func(t *testing.T) {
332- d, sess, ac := resizeModeFixture(t)
333- require.NoError(t, tt.run(d, sess, ac))
334- tb := sess.activeTab()
335- tb.mu.Lock()
336- got := []float64{tb.tree.Root.Children[0].Weight, tb.tree.Root.Children[1].Weight}
337- tb.mu.Unlock()
338- if want == nil {
339- want = got
340- } else {
341- require.Equal(t, want, got)
342- }
343- })
344- }
345- })
346-}
347-
348127 type resizeLockProbePTY struct {
349128 once sync.Once
350129 entered chan struct{}
@@ -372,7 +151,7 @@ func TestResizeModeReleasesTabAndOverlayLocksBeforePTY(t *testing.T) {
372151 // Build a live tree directly so the probe is not concurrently owned by a
373152 // pane reader; this isolates the lock boundary being asserted.
374153 d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
375- tb := sess.activeTab()
154+ tb := testAttachmentTab(sess)
376155 probe := &resizeLockProbePTY{entered: make(chan struct{}), release: make(chan struct{})}
377156 second := newPane("pane-2", probe, domain.Size{Cols: 39, Rows: 23})
378157 tb.mu.Lock()
@@ -398,18 +177,3 @@ func TestResizeModeReleasesTabAndOverlayLocksBeforePTY(t *testing.T) {
398177 close(probe.release)
399178 <-done
400179 }
401-
402-func resizeModeFixture(t *testing.T) (*Daemon, *session, *attachedClient) {
403- t.Helper()
404- factory := &controlPTYFactory{}
405- d := newTestDaemon(t, factory, stubClock{})
406- t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
407- sess := addControlSession(d, "work", "t_work", "p_work")
408- require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
409- require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-down", TargetSession: "work"}).OK)
410- ac := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
411- ac.initOverlays()
412- ac.setSession(sess)
413- require.NoError(t, d.enterResizeMode(sess, ac))
414- return d, sess, ac
415-}
M internal/usecase/daemon/resize_publication.go +11 -13
@@ -11,7 +11,7 @@ type resizeCommitPublication struct {
1111 attachment *attachedClient
1212 lease *attachmentLease
1313 epoch uint64
14- role attachmentRoleToken
14+ connection attachmentConnectionToken
1515 coordinator *renderCoordinator
1616 observer ports.RuntimeObserver
1717 mark ports.RuntimeMark
@@ -24,21 +24,19 @@ func (p resizeCommitPublication) current() bool {
2424 if p.attachment == nil {
2525 return true
2626 }
27- // Attached resize publication is admitted only through a live role ticket.
28- if p.role.effect == nil {
27+ // Attached resize publication is admitted only through a live attachment effect ticket.
28+ if p.connection.effect == nil {
2929 return false
3030 }
31- if p.role.effect.ended.Load() || p.role.sess != p.session ||
32- p.role.ac != p.attachment || p.role.role != attachmentActive || p.role.lease != p.lease ||
33- p.role.generation != p.attachment.roleGeneration.Load() ||
34- !p.attachment.transportSnapshotCurrent(p.role.transport) ||
31+ if p.connection.effect.ended.Load() || p.connection.sess != p.session ||
32+ p.connection.ac != p.attachment || p.connection.lease != p.lease ||
33+ p.connection.generation != p.attachment.connectionGeneration.Load() ||
34+ !p.attachment.transportSnapshotCurrent(p.connection.transport) ||
3535 p.attachment.currentSession() != p.session || p.coordinator == nil {
3636 return false
3737 }
38- p.session.mu.Lock()
39- active := p.session.attachmentRoleLocked(p.attachment) == attachmentActive
40- p.session.mu.Unlock()
41- return active && p.coordinator.resizeCurrentForLease(p.epoch, p.attachment, p.lease, false)
38+ registered := attachmentRegistered(p.session, p.attachment)
39+ return registered && p.coordinator.resizeCurrentForLease(p.epoch, p.attachment, p.lease, false)
4240 }
4341
4442 // emit performs observer work only after every resize-owner fence and
@@ -59,7 +57,7 @@ func (p resizeCommitPublication) emit() bool {
5957 // reservation with pane ownership. sendMu is acquired before resize fences, so
6058 // the canonical rule never acquires an attachment lock while a pane fence is
6159 // held. Observer callbacks run only after both lock families are released.
62-func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, ticket *roleEffectTicket, size domain.Size) bool {
60+func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, ticket *attachmentEffectTicket, size domain.Size) bool {
6361 if ac != nil && ticket == nil {
6462 return false
6563 }
@@ -87,7 +85,7 @@ func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *
8785 mark: ports.NewRuntimeMark("daemon", ports.RuntimeResizeCommitted, 0, true),
8886 }
8987 if ticket != nil {
90- publication.role = ticket.roleToken()
88+ publication.connection = ticket.connectionToken()
9189 publication.coordinator = sess.renderCoordinator()
9290 }
9391 if !publication.current() {
M internal/usecase/daemon/resume.go +130 -55
@@ -37,8 +37,8 @@ func (d *Daemon) nextResumeTokenLocked() uint64 {
3737 // can wait out the detach→park gap. Callers must publish this before clearing
3838 // the live seat so resume never observes both registries empty for a still-valid
3939 // credential. Under d.mu then sess.mu it advertises only when the daemon still
40-// registers the exact session and ac is still the exact active or snatched
41-// owner; otherwise it returns 0 and never creates a marker after terminal
40+// registers the exact session and ac remains a registered attachment; otherwise
41+// it returns 0 and never creates a marker after terminal
4242 // cleanup. Returns the resume token, or 0 when parking cannot be advertised.
4343 func (d *Daemon) markParkingInFlight(sess *session, ac *attachedClient) uint64 {
4444 if sess == nil || ac == nil || !ac.resumeCapable {
@@ -50,10 +50,9 @@ func (d *Daemon) markParkingInFlight(sess *session, ac *attachedClient) uint64 {
5050 return 0
5151 }
5252 sess.mu.Lock()
53- role := sess.attachmentRoleLocked(ac)
54- owner := role == attachmentActive || role == attachmentSnatched
53+ registered := attachmentRegisteredLocked(sess, ac)
5554 sess.mu.Unlock()
56- if !owner {
55+ if !registered {
5756 return 0
5857 }
5958 if ac.resumeToken == 0 {
@@ -96,8 +95,8 @@ func (d *Daemon) clearParkingInFlight(token uint64, ac *attachedClient) {
9695 }
9796
9897 // clearParkingInFlightIfAbandoned drops a pre-detach parking marker when this
99-// teardown lost the seat while still the live owner (stale transport fence for
100-// active or snatched). If another path already detached/unrouted, that path
98+// teardown lost the seat while still the live owner (stale transport fence).
99+// If another path already detached/unrouted, that path
101100 // owns park publication.
102101 func (d *Daemon) clearParkingInFlightIfAbandoned(sess *session, ac *attachedClient, token uint64) {
103102 if token == 0 || ac == nil {
@@ -111,8 +110,7 @@ func (d *Daemon) clearParkingInFlightIfAbandoned(sess *session, ac *attachedClie
111110 stillOwner := false
112111 if sess != nil {
113112 sess.mu.Lock()
114- role := sess.attachmentRoleLocked(ac)
115- stillOwner = role == attachmentActive || role == attachmentSnatched
113+ stillOwner = attachmentRegisteredLocked(sess, ac)
116114 sess.mu.Unlock()
117115 }
118116 if !stillOwner {
@@ -204,10 +202,6 @@ func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {
204202 }
205203
206204 func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
207- return d.parkAttachmentAs(sess, ac, attachmentActive)
208-}
209-
210-func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attachmentRole) bool {
211205 if !d.prepareParkAttachment(sess, ac) {
212206 return false
213207 }
@@ -228,13 +222,6 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach
228222 d.mu.Unlock()
229223 return false
230224 }
231- if role == attachmentActive {
232- sess.mu.Lock()
233- if sess.client != nil {
234- role = attachmentSnatched
235- }
236- sess.mu.Unlock()
237- }
238225 token := ac.resumeToken
239226 var (
240227 oldRetirement *parkedAttachmentRetirement
@@ -250,7 +237,7 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach
250237 }
251238 grace := d.resumeParkGrace
252239 timer := d.clock.NewTimer(grace)
253- parked := &parkedAttachment{sess: sess, ac: ac, role: role, pickerGeneration: pickerGeneration, timer: timer, done: make(chan struct{})}
240+ parked := &parkedAttachment{sess: sess, ac: ac, pickerGeneration: pickerGeneration, timer: timer, done: make(chan struct{})}
254241 ac.parked = true
255242 d.parked[token] = parked
256243 d.clearParkingInFlightLocked(token, ac)
@@ -265,20 +252,31 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach
265252 oldSame.closeDone()
266253 }
267254 d.log.Info("client parked for resume", "session", sess.name, "grace", grace)
255+ d.watchParkedTimer(token, parked)
256+ return true
257+}
268258
269- go func(token uint64, parked *parkedAttachment) {
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() {
270265 select {
271266 case <-timer.C():
272267 d.expireParked(token, parked)
273268 case <-parked.done:
274269 }
275- }(token, parked)
276- return true
270+ }()
277271 }
278272
279273 func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
280274 d.mu.Lock()
281275 if d.parked[token] == parked {
276+ if parked.claimed {
277+ d.mu.Unlock()
278+ return
279+ }
282280 d.removeParkedLocked(token, parked)
283281 d.mu.Unlock()
284282 d.closePickerIfCurrent(parked.ac, nil, parked.pickerGeneration)
@@ -308,14 +306,81 @@ func (p *parkedAttachment) closeDone() {
308306 p.doneOnce.Do(func() { close(p.done) })
309307 }
310308
311-// demoteParkedActiveForSessionLocked preserves resumable predecessors when a
312-// new active owner is published. Caller holds d.mu.
313-func (d *Daemon) demoteParkedActiveForSessionLocked(sess *session) {
314- for _, parked := range d.parked {
315- if parked.sess == sess && parked.role == attachmentActive {
316- parked.role = attachmentSnatched
317- }
309+// commitResumeClaim consumes the old credential only after Welcome has been
310+// written successfully. The claim itself is serialized by d.mu, so a racing
311+// resume cannot acquire the same parked attachment.
312+func (d *Daemon) commitResumeClaim(ac *attachedClient) bool {
313+ if d == nil || ac == nil {
314+ return false
315+ }
316+ d.mu.Lock()
317+ defer d.mu.Unlock()
318+ token := ac.resumeClaimToken
319+ if token == 0 {
320+ return true
321+ }
322+ parked := d.parked[token]
323+ if parked == nil || parked.ac != ac || !parked.claimed {
324+ return false
325+ }
326+ delete(d.parked, token)
327+ parked.claimed = false
328+ if parked.timer != nil {
329+ parked.timer.Stop()
330+ }
331+ parked.closeDone()
332+ ac.resumeClaimToken = 0
333+ return true
334+}
335+
336+// abortResumeClaim restores a parked credential when the transport could not
337+// complete the pre-claim Welcome handshake. It invalidates the claimed link
338+// generation before making the credential available to another winner.
339+func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
340+ if d == nil || ac == nil {
341+ return false
318342 }
343+ d.mu.Lock()
344+ token := ac.resumeClaimToken
345+ parked := d.parked[token]
346+ if token == 0 || parked == nil || parked.ac != ac || !parked.claimed {
347+ d.mu.Unlock()
348+ return false
349+ }
350+ parked.claimed = false
351+ ac.resumeClaimToken = 0
352+ ac.resumeToken = token
353+ ac.parked = true
354+ ac.connectionGeneration.Add(1)
355+ sess := parked.sess
356+ if sess != nil {
357+ sess.mu.Lock()
358+ sess.unregisterAttachmentLocked(ac)
359+ sess.mu.Unlock()
360+ }
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().transport
377+ ac.setSession(nil)
378+ d.mu.Unlock()
379+ d.watchParkedTimer(token, rearmed)
380+ if captured != nil {
381+ _ = ac.closeCapturedTransport(ac.revokeTransport(captured))
382+ }
383+ return true
319384 }
320385
321386 type parkedAttachmentRetirement struct {
@@ -329,7 +394,7 @@ func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttach
329394 parked.ac.clearPreviousSession()
330395 parked.ac.resumeToken = 0
331396 parked.ac.parked = false
332- parked.ac.roleGeneration.Add(1)
397+ parked.ac.connectionGeneration.Add(1)
333398 parked.ac.setSession(nil)
334399 return parkedAttachmentRetirement{
335400 parked: parked,
@@ -398,11 +463,11 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
398463 )
399464 if sess != nil {
400465 sess.mu.Lock()
401- ac = sess.client
402- // resumeToken writes run under d.mu; read the live credential here
403- // before releasing the daemon lock, then revalidate via the exact
404- // transport incarnation through detachIfCurrentTransport.
405- if ac != nil && ac.resumeCapable && ac.resumeToken == h.ResumeToken {
466+ for candidate := range sess.attachments {
467+ if !candidate.resumeCapable || candidate.resumeToken != h.ResumeToken {
468+ continue
469+ }
470+ ac = candidate
406471 if ac.clientID != h.ClientID {
407472 clientMismatch = true
408473 } else if ac.proxied != h.Proxied {
@@ -411,6 +476,7 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
411476 oldSnap = ac.transportSnapshot()
412477 credentialMatch = oldSnap.transport != nil
413478 }
479+ break
414480 }
415481 sess.mu.Unlock()
416482 }
@@ -494,19 +560,29 @@ func (d *Daemon) resumeParked(h ports.Hello, tr ports.Transport, sz domain.Size)
494560 d.beforeResumeParkedSendMu()
495561 }
496562 ac.sendMu.Lock()
497- defer ac.sendMu.Unlock()
498563 d.mu.Lock()
499- defer d.mu.Unlock()
500564 if d.closing {
565+ d.mu.Unlock()
566+ ac.sendMu.Unlock()
501567 return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
502568 }
503- if d.parked[h.ResumeToken] != parked {
569+ if d.parked[h.ResumeToken] != parked || parked.claimed {
504570 if d.sessions[parked.sess.id] == parked.sess {
571+ d.mu.Unlock()
572+ ac.sendMu.Unlock()
505573 return nil, nil, false, errResumeTokenLifecycleRace
506574 }
575+ d.mu.Unlock()
576+ ac.sendMu.Unlock()
507577 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
508578 }
509- return d.resumeParkedLocked(h, tr, sz)
579+ sess, resumed, ok, err := d.resumeParkedLocked(h, tr, sz)
580+ d.mu.Unlock()
581+ ac.sendMu.Unlock()
582+ if err != nil && resumed != nil {
583+ d.abortResumeClaim(resumed)
584+ }
585+ return sess, resumed, ok, err
510586 }
511587
512588 // resumeParkedLocked completes a validated resume. Caller holds both ac.sendMu
@@ -523,13 +599,10 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
523599 d.log.Warn("resume rejected", "session", sess.name, "registered", false)
524600 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
525601 }
526- sess.mu.Lock()
527- active := sess.client != nil
528- sess.mu.Unlock()
529- if active && parked.role == attachmentActive {
530- parked.role = attachmentSnatched
531- }
532602 ac := parked.ac
603+ if parked.claimed {
604+ return nil, nil, false, errResumeTokenLifecycleRace
605+ }
533606 if ac.clientID != h.ClientID || ac.proxied != h.Proxied {
534607 reason := "client id mismatch"
535608 if ac.proxied != h.Proxied {
@@ -538,14 +611,17 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
538611 d.log.Warn("resume rejected", "session", sess.name, "err", reason)
539612 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
540613 }
541- delete(d.parked, h.ResumeToken)
542- parked.timer.Stop()
543- parked.closeDone()
614+ // Claim the attachment while retaining the old credential until Welcome is
615+ // accepted. A failed handshake can therefore restore this exact parked
616+ // entry; another resume sees claimed and loses atomically.
617+ parked.claimed = true
618+ ac.resumeClaimToken = h.ResumeToken
544619 // The parked attachment has no session owner and cannot paint. Retire the
545620 // abandoned transport's output chain before binding the replacement so the
546621 // mandatory first paint cannot be blocked by ACKs that died with the link.
547622 ac.rebaseOutput()
548623 ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight))
624+ ac.output.syncCapacityLocked()
549625 ac.replaceTransport(tr)
550626 ac.size = sz
551627 ac.resumeToken = d.nextResumeTokenLocked()
@@ -562,17 +638,16 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
562638 d.mu.Unlock()
563639 ac.sendMu.Unlock()
564640 transition, err := d.transitionAttachment(attachmentTransitionRequest{
565- target: sess,
566- next: ac,
567- expectedRole: attachmentDetached,
568- targetRole: parked.role,
641+ target: sess,
642+ next: ac,
643+
569644 expectedTransport: ac.transportSnapshot(),
570645 ready: false,
571646 })
572647 ac.sendMu.Lock()
573648 d.mu.Lock()
574649 if err != nil {
575- return nil, nil, false, &protoErr{ports.ErrInternal, "resume attachment transition failed"}
650+ return sess, ac, false, &protoErr{ports.ErrInternal, "resume attachment transition failed"}
576651 }
577652 d.deferAttachmentTransitionCleanups(transition)
578653 d.touchMRU(sess)
M internal/usecase/daemon/resume_test.go +79 -523
@@ -5,7 +5,6 @@ import (
55 "errors"
66 "strings"
77 "sync"
8- "sync/atomic"
98 "testing"
109 "time"
1110
@@ -98,6 +97,53 @@ func helloResumeCapable(intent uint8, name string, token uint64) ports.Hello {
9897 }
9998 }
10099
100+func TestFailedResumeRearmsParkExpiryAfterClaimTimerFires(t *testing.T) {
101+ clock := &signalClock{timers: make(chan *signalTimer, 8)}
102+ pty, release := newBlockingPTY(t)
103+ defer release()
104+ d := newTestDaemon(t, newFactory(t, pty), clock)
105+
106+ oldTransport := &closeTrackingTransport{}
107+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
108+ require.NoError(t, err)
109+ token := ac.resumeToken
110+ d.clientGone(sess, ac, oldTransport, false)
111+ parkTimer := <-clock.timers
112+ d.mu.Lock()
113+ parked := d.parked[token]
114+ d.mu.Unlock()
115+ require.NotNil(t, parked)
116+
117+ resumed, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, defaultSize)
118+ require.NoError(t, err)
119+ require.True(t, ok)
120+ require.Same(t, sess, resumed)
121+ require.Same(t, ac, resumedAC)
122+
123+ // Drive the original fake timer after the claim. The old entry remains
124+ // claimable only until abort; abort must install a fresh expiry rather than
125+ // restoring a credential whose timer has already fired.
126+ parkTimer.ch <- time.Time{}
127+ d.expireParked(token, parked)
128+ require.True(t, d.abortResumeClaim(ac))
129+ rearmedTimer := <-clock.timers
130+ require.NotSame(t, parkTimer, rearmedTimer)
131+ d.mu.Lock()
132+ rearmed := d.parked[token]
133+ d.mu.Unlock()
134+ require.NotNil(t, rearmed)
135+ require.False(t, rearmed.claimed)
136+ rearmedTimer.ch <- time.Time{}
137+ d.expireParked(token, rearmed)
138+ d.mu.Lock()
139+ _, retained := d.parked[token]
140+ d.mu.Unlock()
141+ require.False(t, retained, "rearmed credential must still expire")
142+
143+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
144+ d.sessWg.Wait()
145+}
146+
101147 func TestNamedLinkLossParks(t *testing.T) {
102148 pty, release := newBlockingPTY(t)
103149 defer release()
@@ -111,7 +157,7 @@ func TestNamedLinkLossParks(t *testing.T) {
111157
112158 d.clientGone(sess, ac, ac.transport(), false)
113159 require.Equal(t, 1, sessionCount(d), "named session survives parked link loss")
114- require.Nil(t, sess.client)
160+ require.Empty(t, sess.snapshotAttachments())
115161 d.mu.Lock()
116162 _, parked := d.parked[token]
117163 d.mu.Unlock()
@@ -215,7 +261,7 @@ func TestHandleHelloResumeDefersFreshOutputUntilWelcome(t *testing.T) {
215261 welcome := <-tr.sends
216262 require.Equal(t, ports.MsgWelcome, welcome.Type)
217263 sess.mu.Lock()
218- resumed := sess.client
264+ resumed := sess.snapshotAttachmentsLocked()[0]
219265 sess.mu.Unlock()
220266 require.Same(t, ac, resumed)
221267
@@ -254,7 +300,7 @@ func TestEphemeralLinkLossParksAndResumes(t *testing.T) {
254300
255301 d.clientGone(sess, ac, ac.transport(), false)
256302 require.Equal(t, 1, sessionCount(d), "ephemeral link loss keeps session alive")
257- require.Nil(t, sess.client)
303+ require.Empty(t, sess.snapshotAttachments())
258304 d.mu.Lock()
259305 _, parked := d.parked[token]
260306 d.mu.Unlock()
@@ -266,7 +312,7 @@ func TestEphemeralLinkLossParksAndResumes(t *testing.T) {
266312 require.Same(t, sess, resumedSess)
267313 require.Same(t, ac, resumedAC)
268314 require.NotEqual(t, token, resumedAC.resumeToken, "resume rotates token")
269- require.Same(t, resumedAC, sess.client)
315+ require.Contains(t, sess.snapshotAttachments(), resumedAC)
270316 }
271317
272318 // TestResumeParkedTokenReplacedDuringWaitFailsClosed covers the lifecycle race
@@ -284,7 +330,7 @@ func TestResumeParkedTokenReplacedDuringWaitFailsClosed(t *testing.T) {
284330 token := ac.resumeToken
285331 require.NotZero(t, token)
286332 d.clientGone(sess, ac, oldTr, false)
287- require.Nil(t, sess.client)
333+ require.Empty(t, sess.snapshotAttachments())
288334 d.mu.Lock()
289335 parked := d.parked[token]
290336 sessionsBefore := len(d.sessions)
@@ -330,7 +376,7 @@ func TestResumeParkedTokenReplacedDuringWaitFailsClosed(t *testing.T) {
330376 require.ErrorAs(t, got.err, &pe)
331377 require.Equal(t, ports.ErrNoSuchSession, pe.code)
332378 require.Contains(t, pe.Error(), "resume token is no longer valid")
333- require.Nil(t, sess.client, "lifecycle-race resume must not take over the named session")
379+ require.Empty(t, sess.snapshotAttachments(), "lifecycle-race resume must not take over the named session")
334380 require.Empty(t, resumeTr.Sends(), "failed resume must not complete a Welcome handshake")
335381 d.mu.Lock()
336382 _, stillParked := d.parked[token]
@@ -355,7 +401,7 @@ func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) {
355401 require.NoError(t, err)
356402 token := ac.resumeToken
357403 require.NotZero(t, token)
358- require.Same(t, ac, sess.client)
404+ require.Contains(t, sess.snapshotAttachments(), ac)
359405 d.mu.Lock()
360406 _, parkedAtStart := d.parked[token]
361407 sessionsBefore := len(d.sessions)
@@ -404,7 +450,7 @@ func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) {
404450 require.ErrorAs(t, got.err, &pe)
405451 require.Equal(t, ports.ErrNoSuchSession, pe.code)
406452 require.Contains(t, pe.Error(), "resume token is no longer valid")
407- require.Nil(t, sess.client, "losing live resume must not take over the named session")
453+ require.Empty(t, sess.snapshotAttachments(), "losing live resume must not take over the named session")
408454 require.Empty(t, resumeTr.Sends(), "failed live resume must not complete a Welcome handshake")
409455 d.mu.Lock()
410456 _, stillParked := d.parked[token]
@@ -418,70 +464,6 @@ func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) {
418464 // reconnect race: IntentResume arrives with the live attachment's token before
419465 // transport teardown has parked it. Same client must recover; unknown and
420466 // mismatched credentials stay fail-closed.
421-func TestResumeActiveTokenBeforeParkRecoversSameAttachment(t *testing.T) {
422- pty, release := newBlockingPTY(t)
423- defer release()
424- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
425-
426- oldTr := &closeTrackingTransport{}
427- sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
428- require.NoError(t, err)
429- token := ac.resumeToken
430- require.NotZero(t, token)
431- require.True(t, ac.resumeCapable)
432- require.Same(t, ac, sess.client)
433- d.mu.Lock()
434- _, parked := d.parked[token]
435- d.mu.Unlock()
436- require.False(t, parked, "fixture: live attachment must not be parked yet")
437-
438- _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{})
439- require.Error(t, err)
440- var unknownErr *protoErr
441- require.ErrorAs(t, err, &unknownErr)
442- require.Equal(t, ports.ErrNoSuchSession, unknownErr.code)
443- require.Contains(t, unknownErr.Error(), "resume token is no longer valid")
444- require.Same(t, ac, sess.client)
445- require.Equal(t, token, ac.resumeToken)
446- require.Same(t, oldTr, ac.transport())
447-
448- wrongClient := helloResumeCapable(ports.IntentResume, "work", token)
449- wrongClient.ClientID = [16]byte{9, 9, 9, 9}
450- _, _, err = d.route(wrongClient, &closeTrackingTransport{})
451- require.Error(t, err)
452- var mismatchErr *protoErr
453- require.ErrorAs(t, err, &mismatchErr)
454- require.Equal(t, ports.ErrNoSuchSession, mismatchErr.code)
455- require.Contains(t, mismatchErr.Error(), "resume token is no longer valid")
456- require.Same(t, ac, sess.client)
457- require.Equal(t, token, ac.resumeToken)
458- require.Same(t, oldTr, ac.transport())
459-
460- newTr := &closeTrackingTransport{}
461- resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr)
462- require.NoError(t, err, "same-token same-client IntentResume must recover before parking")
463- require.Same(t, sess, resumedSess)
464- require.Same(t, ac, resumedAC)
465- require.Same(t, ac, sess.client)
466- require.Same(t, newTr, ac.transport())
467- require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential")
468- d.mu.Lock()
469- _, oldTokenParked := d.parked[token]
470- d.mu.Unlock()
471- require.False(t, oldTokenParked, "consumed live credential must not remain resumeable")
472-
473- d.clientGone(sess, ac, oldTr, false)
474- require.Same(t, ac, sess.client, "retired pre-park transport must not detach the rebound attachment")
475- require.Same(t, newTr, ac.transport())
476- require.False(t, newTr.Closed())
477-}
478-
479-// TestResumeDuringTeardownBeforeParkRecoversSameAttachment covers the opposite
480-// interleaving from live-before-park recovery: old-link teardown wins
481-// detachIfCurrentTransport, then pauses before parkAttachment publishes the
482-// resume token. The parking-in-flight marker must already be visible in that
483-// gap (published before detach). Same-client IntentResume must wait it out and
484-// recover; unknown tokens stay fail-closed.
485467 func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
486468 pty, release := newBlockingPTY(t)
487469 defer release()
@@ -492,7 +474,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
492474 require.NoError(t, err)
493475 token := ac.resumeToken
494476 require.NotZero(t, token)
495- require.Same(t, ac, sess.client)
477+ require.Contains(t, sess.snapshotAttachments(), ac)
496478
497479 reachedGap := make(chan struct{})
498480 releaseGap := make(chan struct{})
@@ -510,7 +492,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
510492 }()
511493 awaitTestCompletion(t, reachedGap, "teardown did not pause after detach before park")
512494
513- require.Nil(t, sess.client, "fixture: detach must have cleared the live owner")
495+ require.Empty(t, sess.snapshotAttachments(), "fixture: detach must have cleared the live owner")
514496 d.mu.Lock()
515497 parkingInGap := d.parking[token]
516498 _, parkedInGap := d.parked[token]
@@ -558,9 +540,10 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
558540 require.NoError(t, got.err, "same-client token must recover across detach-before-park")
559541 require.Same(t, sess, got.sess)
560542 require.Same(t, ac, got.ac)
561- require.Same(t, ac, sess.client)
543+ require.Contains(t, sess.snapshotAttachments(), ac)
562544 require.Same(t, newTr, ac.transport())
563545 require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential")
546+ require.True(t, d.commitResumeClaim(ac), "successful resume must consume its parked credential")
564547 require.True(t, oldTr.Closed(), "teardown/resume must retire the old transport")
565548 require.False(t, newTr.Closed(), "rebound transport must survive")
566549 d.mu.Lock()
@@ -571,7 +554,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
571554 require.False(t, stillParking, "parking marker must be consumed after park/resume")
572555
573556 d.clientGone(sess, ac, oldTr, false)
574- require.Same(t, ac, sess.client, "stale old-link cleanup must not detach the rebound attachment")
557+ require.Contains(t, sess.snapshotAttachments(), ac, "stale old-link cleanup must not detach the rebound attachment")
575558 require.Same(t, newTr, ac.transport())
576559 require.False(t, newTr.Closed())
577560 }
@@ -590,7 +573,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
590573 require.NoError(t, err)
591574 token := ac.resumeToken
592575 require.NotZero(t, token)
593- require.Same(t, ac, sess.client)
576+ require.Contains(t, sess.snapshotAttachments(), ac)
594577
595578 reachedGap := make(chan struct{})
596579 releaseGap := make(chan struct{})
@@ -614,7 +597,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
614597 }()
615598 awaitTestCompletion(t, reachedGap, "first live resume did not pause after detach before park")
616599
617- require.Nil(t, sess.client, "fixture: winning resume must have cleared the live owner")
600+ require.Empty(t, sess.snapshotAttachments(), "fixture: winning resume must have cleared the live owner")
618601 d.mu.Lock()
619602 parkingInGap := d.parking[token]
620603 _, parkedInGap := d.parked[token]
@@ -660,7 +643,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
660643
661644 require.Same(t, sess, winner.sess)
662645 require.Same(t, ac, winner.ac)
663- require.Same(t, ac, sess.client)
646+ require.Contains(t, sess.snapshotAttachments(), ac)
664647 require.Same(t, winnerTr, ac.transport())
665648 require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential")
666649 require.True(t, oldTr.Closed(), "winning resume must retire the old transport")
@@ -672,6 +655,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
672655 require.Contains(t, loserErr.Error(), "resume token is no longer valid")
673656 require.True(t, loserTr.Closed() || len(loserTr.Sends()) == 0, "losing resume must not complete Welcome")
674657
658+ require.True(t, d.commitResumeClaim(ac), "successful concurrent resume must consume its parked credential")
675659 d.mu.Lock()
676660 _, oldTokenParked := d.parked[token]
677661 _, stillParking := d.parking[token]
@@ -680,370 +664,11 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
680664 require.False(t, stillParking, "parking marker must be consumed after park/resume")
681665
682666 d.clientGone(sess, ac, oldTr, false)
683- require.Same(t, ac, sess.client, "stale old-link cleanup must not detach the rebound attachment")
667+ require.Contains(t, sess.snapshotAttachments(), ac, "stale old-link cleanup must not detach the rebound attachment")
684668 require.Same(t, winnerTr, ac.transport())
685669 require.False(t, winnerTr.Closed())
686670 }
687671
688-func setupResumableSnatchedAttachment(t *testing.T, d *Daemon) (*session, *attachedClient, *attachedClient, *closeTrackingTransport, uint64) {
689- t.Helper()
690- oldTr := &closeTrackingTransport{}
691- sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
692- require.NoError(t, err)
693- token := waiting.resumeToken
694- require.NotZero(t, token)
695-
696- _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{})
697- require.NoError(t, err)
698- d.attachmentCleanupWg.Wait()
699- require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting))
700- require.Same(t, active, sess.client)
701- return sess, waiting, active, oldTr, token
702-}
703-
704-// TestSnatchedResumeDuringTeardownBeforeParkRecoversSameAttachment covers the
705-// snatched unroute→park gap: parkSnatchedAttachment publishes the parking
706-// marker before ownership removal, then pauses before parkAttachmentAs.
707-// Same-client IntentResume must wait it out and recover as snatched without
708-// replacing the active owner; unknown/mismatched tokens stay fail-closed.
709-func TestSnatchedResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
710- pty, release := newBlockingPTY(t)
711- defer release()
712- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
713-
714- sess, waiting, active, oldTr, token := setupResumableSnatchedAttachment(t, d)
715- roleToken := sess.attachmentToken(waiting, oldTr)
716-
717- reachedGap := make(chan struct{})
718- releaseGap := make(chan struct{})
719- var releaseOnce sync.Once
720- t.Cleanup(func() { releaseOnce.Do(func() { close(releaseGap) }) })
721- d.afterSnatchedUnrouteBeforePark = func() {
722- close(reachedGap)
723- <-releaseGap
724- }
725-
726- parkDone := make(chan bool, 1)
727- go func() {
728- parkDone <- d.parkSnatchedAttachment(roleToken)
729- }()
730- awaitTestCompletion(t, reachedGap, "snatched park did not pause after unroute before park")
731-
732- require.Equal(t, attachmentDetached, sess.attachmentRole(waiting), "fixture: unroute must have cleared snatched ownership")
733- d.mu.Lock()
734- parkingInGap := d.parking[token]
735- _, parkedInGap := d.parked[token]
736- d.mu.Unlock()
737- require.NotNil(t, parkingInGap, "fixture: parking marker must precede snatched ownership removal")
738- require.Same(t, waiting, parkingInGap.ac)
739- require.Same(t, sess, parkingInGap.sess)
740- require.False(t, parkedInGap, "fixture: park must not have published yet")
741- require.Same(t, active, sess.client, "fixture: active owner must remain undisturbed")
742-
743- _, _, err := d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{})
744- require.Error(t, err)
745- var unknownErr *protoErr
746- require.ErrorAs(t, err, &unknownErr)
747- require.Equal(t, ports.ErrNoSuchSession, unknownErr.code)
748- require.Contains(t, unknownErr.Error(), "resume token is no longer valid")
749-
750- wrongClient := helloResumeCapable(ports.IntentResume, "work", token)
751- wrongClient.ClientID = [16]byte{9, 9, 9, 9}
752- _, _, err = d.route(wrongClient, &closeTrackingTransport{})
753- require.Error(t, err)
754- var mismatchErr *protoErr
755- require.ErrorAs(t, err, &mismatchErr)
756- require.Equal(t, ports.ErrNoSuchSession, mismatchErr.code)
757- require.Contains(t, mismatchErr.Error(), "resume token is no longer valid")
758-
759- newTr := &closeTrackingTransport{}
760- type routeResult struct {
761- sess *session
762- ac *attachedClient
763- err error
764- }
765- waiterArmed := make(chan struct{})
766- d.afterParkingWaitArmed = func() { close(waiterArmed) }
767- result := make(chan routeResult, 1)
768- go func() {
769- routedSess, routedAC, routeErr := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr)
770- result <- routeResult{sess: routedSess, ac: routedAC, err: routeErr}
771- }()
772- awaitTestCompletion(t, waiterArmed, "resume waiter did not arm on snatched parking marker before park")
773-
774- releaseOnce.Do(func() { close(releaseGap) })
775- require.True(t, awaitTestValue(t, parkDone, "snatched park did not finish after release"), "parkSnatchedAttachment must succeed")
776- got := awaitTestValue(t, result, "IntentResume did not finish after snatched teardown-before-park gap")
777-
778- require.NoError(t, got.err, "same-client snatched token must recover across unroute-before-park")
779- require.Same(t, sess, got.sess)
780- require.Same(t, waiting, got.ac)
781- require.Same(t, active, sess.client, "snatched resume must not replace the active owner")
782- require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting))
783- require.Same(t, newTr, waiting.transport())
784- require.NotEqual(t, token, waiting.resumeToken, "successful resume rotates the credential")
785- require.True(t, oldTr.Closed(), "teardown/resume must retire the old transport")
786- require.False(t, newTr.Closed(), "rebound transport must survive")
787- d.mu.Lock()
788- _, oldTokenParked := d.parked[token]
789- _, stillParking := d.parking[token]
790- d.mu.Unlock()
791- require.False(t, oldTokenParked, "consumed credential must not remain resumeable")
792- require.False(t, stillParking, "parking marker must be consumed after park/resume")
793-}
794-
795-// TestConcurrentSnatchedResumesWaitParkingMarkerBeforePark covers two same-token
796-// IntentResume handshakes while parkSnatchedAttachment pauses after unroute and
797-// before park. The parking marker must already be published so losers wait
798-// instead of treating the credential as unknown across that gap.
799-func TestConcurrentSnatchedResumesWaitParkingMarkerBeforePark(t *testing.T) {
800- pty, release := newBlockingPTY(t)
801- defer release()
802- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
803-
804- sess, waiting, active, oldTr, token := setupResumableSnatchedAttachment(t, d)
805- roleToken := sess.attachmentToken(waiting, oldTr)
806-
807- reachedGap := make(chan struct{})
808- releaseGap := make(chan struct{})
809- var releaseOnce sync.Once
810- t.Cleanup(func() { releaseOnce.Do(func() { close(releaseGap) }) })
811- d.afterSnatchedUnrouteBeforePark = func() {
812- close(reachedGap)
813- <-releaseGap
814- }
815-
816- parkDone := make(chan bool, 1)
817- go func() {
818- parkDone <- d.parkSnatchedAttachment(roleToken)
819- }()
820- awaitTestCompletion(t, reachedGap, "snatched park did not pause after unroute before park")
821-
822- require.Equal(t, attachmentDetached, sess.attachmentRole(waiting), "fixture: unroute must have cleared snatched ownership")
823- d.mu.Lock()
824- parkingInGap := d.parking[token]
825- _, parkedInGap := d.parked[token]
826- d.mu.Unlock()
827- require.NotNil(t, parkingInGap, "fixture: parking marker must precede unroute for concurrent resumes")
828- require.Same(t, waiting, parkingInGap.ac)
829- require.False(t, parkedInGap, "fixture: park must not have published yet")
830-
831- _, _, err := d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{})
832- require.Error(t, err)
833- var unknownErr *protoErr
834- require.ErrorAs(t, err, &unknownErr)
835- require.Equal(t, ports.ErrNoSuchSession, unknownErr.code)
836-
837- type routeResult struct {
838- sess *session
839- ac *attachedClient
840- err error
841- }
842- firstTr := &closeTrackingTransport{}
843- secondTr := &closeTrackingTransport{}
844- bothWaitersArmed := make(chan struct{})
845- var armedCount atomic.Int32
846- d.afterParkingWaitArmed = func() {
847- if armedCount.Add(1) == 2 {
848- close(bothWaitersArmed)
849- }
850- }
851- firstResult := make(chan routeResult, 1)
852- secondResult := make(chan routeResult, 1)
853- go func() {
854- routedSess, routedAC, routeErr := d.route(helloResumeCapable(ports.IntentResume, "work", token), firstTr)
855- firstResult <- routeResult{sess: routedSess, ac: routedAC, err: routeErr}
856- }()
857- go func() {
858- routedSess, routedAC, routeErr := d.route(helloResumeCapable(ports.IntentResume, "work", token), secondTr)
859- secondResult <- routeResult{sess: routedSess, ac: routedAC, err: routeErr}
860- }()
861- awaitTestCompletion(t, bothWaitersArmed, "both snatched resume waiters did not arm on parking marker before park")
862-
863- releaseOnce.Do(func() { close(releaseGap) })
864- require.True(t, awaitTestValue(t, parkDone, "snatched park did not finish after release"))
865- first := awaitTestValue(t, firstResult, "first concurrent snatched IntentResume did not finish")
866- second := awaitTestValue(t, secondResult, "second concurrent snatched IntentResume did not finish")
867-
868- var winner, loser routeResult
869- var winnerTr, loserTr *closeTrackingTransport
870- switch {
871- case first.err == nil && second.err != nil:
872- winner, loser = first, second
873- winnerTr, loserTr = firstTr, secondTr
874- case second.err == nil && first.err != nil:
875- winner, loser = second, first
876- winnerTr, loserTr = secondTr, firstTr
877- default:
878- t.Fatalf("expected exactly one concurrent snatched resume to succeed; first=%v second=%v", first.err, second.err)
879- }
880-
881- require.Same(t, sess, winner.sess)
882- require.Same(t, waiting, winner.ac)
883- require.Same(t, active, sess.client, "snatched resume must not replace the active owner")
884- require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting))
885- require.Same(t, winnerTr, waiting.transport())
886- require.NotEqual(t, token, waiting.resumeToken, "successful resume rotates the credential")
887- require.True(t, oldTr.Closed(), "winning resume must retire the old transport")
888- require.False(t, winnerTr.Closed(), "winning rebound transport must survive")
889-
890- var loserErr *protoErr
891- require.ErrorAs(t, loser.err, &loserErr)
892- require.Equal(t, ports.ErrNoSuchSession, loserErr.code)
893- require.Contains(t, loserErr.Error(), "resume token is no longer valid")
894- require.True(t, loserTr.Closed() || len(loserTr.Sends()) == 0, "losing resume must not complete Welcome")
895-
896- d.mu.Lock()
897- _, oldTokenParked := d.parked[token]
898- _, stillParking := d.parking[token]
899- d.mu.Unlock()
900- require.False(t, oldTokenParked, "consumed credential must not remain resumeable")
901- require.False(t, stillParking, "parking marker must be consumed after park/resume")
902-}
903-
904-// TestStaleSnatchedParkClearsOnlyAbandonedParkingMarker covers a stale
905-// incarnation that publishes a parking marker then loses exact unroute. Only
906-// that attachment's abandoned marker is cleared; another attachment's marker
907-// and the fresh snatched incarnation remain intact.
908-func TestStaleSnatchedParkClearsOnlyAbandonedParkingMarker(t *testing.T) {
909- workPTY, releaseWork := newBlockingPTY(t)
910- defer releaseWork()
911- otherPTY, releaseOther := newBlockingPTY(t)
912- defer releaseOther()
913- // Separate PTYs so work teardown cannot cascade-close the other session.
914- d := newTestDaemon(t, newFactorySeq(t, workPTY, otherPTY), stubClock{})
915-
916- sess, waiting, active, oldTr, token := setupResumableSnatchedAttachment(t, d)
917- stale := sess.attachmentToken(waiting, oldTr)
918-
919- otherTr := &closeTrackingTransport{}
920- otherSess, otherAC, err := d.route(helloResumeCapable(ports.IntentNew, "other", 0), otherTr)
921- require.NoError(t, err)
922- otherToken := d.markParkingInFlight(otherSess, otherAC)
923- require.NotZero(t, otherToken)
924- require.NotEqual(t, token, otherToken)
925-
926- fresh := &closeTrackingTransport{}
927- waiting.replaceTransport(fresh)
928-
929- require.False(t, d.parkSnatchedAttachment(stale))
930- d.mu.Lock()
931- _, parked := d.parked[token]
932- _, stillParking := d.parking[token]
933- otherMarker := d.parking[otherToken]
934- d.mu.Unlock()
935- require.False(t, parked)
936- require.False(t, stillParking, "stale unroute must clear only the abandoned same-attachment marker")
937- require.NotNil(t, otherMarker, "stale unroute must not clear another attachment's marker")
938- require.Same(t, otherAC, otherMarker.ac)
939- require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting))
940- require.Same(t, fresh, waiting.transport())
941- require.False(t, fresh.Closed())
942- require.Same(t, active, sess.client)
943- require.Equal(t, token, waiting.resumeToken)
944-}
945-
946-// TestTerminalSnatchedRemovalClearsOrphanedParkingMarker covers the race where a
947-// park path publishes a same-attachment parking-in-flight marker, then terminal
948-// snatched removal wins before park completes. Only that attachment's marker is
949-// retired and waiters unblock; other markers stay intact and the stale token
950-// fails closed. A subsequent legitimate snatched park remains resumable.
951-func TestTerminalSnatchedRemovalClearsOrphanedParkingMarker(t *testing.T) {
952- workPTY, releaseWork := newBlockingPTY(t)
953- defer releaseWork()
954- otherPTY, releaseOther := newBlockingPTY(t)
955- defer releaseOther()
956- legitPTY, releaseLegit := newBlockingPTY(t)
957- defer releaseLegit()
958- // Separate PTYs so session teardown cannot cascade across marker fixtures.
959- d := newTestDaemon(t, newFactorySeq(t, workPTY, otherPTY, legitPTY), stubClock{})
960-
961- sess, waiting, active, oldTr, token := setupResumableSnatchedAttachment(t, d)
962- roleToken := sess.attachmentToken(waiting, oldTr)
963-
964- otherTr := &closeTrackingTransport{}
965- otherSess, otherAC, err := d.route(helloResumeCapable(ports.IntentNew, "other", 0), otherTr)
966- require.NoError(t, err)
967- otherToken := d.markParkingInFlight(otherSess, otherAC)
968- require.NotZero(t, otherToken)
969- require.NotEqual(t, token, otherToken)
970-
971- // Simulate the losing park path publishing before terminal unroute wins.
972- require.Equal(t, token, d.markParkingInFlight(sess, waiting))
973- d.mu.Lock()
974- orphaned := d.parking[token]
975- otherMarker := d.parking[otherToken]
976- d.mu.Unlock()
977- require.NotNil(t, orphaned, "fixture: park path must publish the parking marker")
978- require.Same(t, waiting, orphaned.ac)
979- require.NotNil(t, otherMarker, "fixture: other attachment marker must remain published")
980- require.Same(t, otherAC, otherMarker.ac)
981-
982- waiterArmed := make(chan struct{})
983- d.afterParkingWaitArmed = func() { close(waiterArmed) }
984- waiterDone := make(chan bool, 1)
985- go func() {
986- waiterDone <- d.waitParkingInFlight(helloResumeCapable(ports.IntentResume, "work", token))
987- }()
988- awaitTestCompletion(t, waiterArmed, "parking waiter did not arm on the orphaned snatched marker")
989-
990- require.True(t, d.removeSnatchedAttachment(roleToken), "terminal snatched removal must win exact unroute")
991- require.Equal(t, attachmentDetached, sess.attachmentRole(waiting))
992- require.Same(t, active, sess.client, "terminal snatched removal must not disturb the active owner")
993-
994- waited := awaitTestValue(t, waiterDone, "parking waiter stayed blocked after terminal snatched removal")
995- require.True(t, waited, "waiter must observe the same-attachment marker before it is cleared")
996-
997- d.mu.Lock()
998- _, stillParking := d.parking[token]
999- otherAfter := d.parking[otherToken]
1000- _, parked := d.parked[token]
1001- d.mu.Unlock()
1002- require.False(t, stillParking, "terminal winner must clear the same attachment's orphaned marker")
1003- require.False(t, parked, "terminal removal must not publish parked ownership")
1004- require.NotNil(t, otherAfter, "terminal winner must not clear another attachment's marker")
1005- require.Same(t, otherAC, otherAfter.ac)
1006-
1007- _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{})
1008- require.Error(t, err)
1009- var unknownErr *protoErr
1010- require.ErrorAs(t, err, &unknownErr)
1011- require.Equal(t, ports.ErrNoSuchSession, unknownErr.code)
1012- require.Contains(t, unknownErr.Error(), "resume token is no longer valid")
1013-
1014- _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{})
1015- require.Error(t, err)
1016- var retiredErr *protoErr
1017- require.ErrorAs(t, err, &retiredErr)
1018- require.Equal(t, ports.ErrNoSuchSession, retiredErr.code)
1019- require.Contains(t, retiredErr.Error(), "resume token is no longer valid")
1020-
1021- // A later legitimate snatched park on a fresh attachment must still resume.
1022- legitTr := &closeTrackingTransport{}
1023- legitSess, legitWaiting, err := d.route(helloResumeCapable(ports.IntentNew, "legit", 0), legitTr)
1024- require.NoError(t, err)
1025- legitToken := legitWaiting.resumeToken
1026- require.NotZero(t, legitToken)
1027- _, _, err = d.route(helloResumeCapable(ports.IntentAttach, "legit", 0), &closeTrackingTransport{})
1028- require.NoError(t, err)
1029- d.attachmentCleanupWg.Wait()
1030- require.Equal(t, attachmentSnatched, legitSess.attachmentRole(legitWaiting))
1031- require.True(t, d.parkSnatchedAttachment(legitSess.attachmentToken(legitWaiting, legitTr)))
1032-
1033- resumeTr := &closeTrackingTransport{}
1034- routedSess, routedAC, err := d.route(helloResumeCapable(ports.IntentResume, "legit", legitToken), resumeTr)
1035- require.NoError(t, err)
1036- require.Same(t, legitSess, routedSess)
1037- require.Same(t, legitWaiting, routedAC)
1038- require.Equal(t, attachmentSnatched, legitSess.attachmentRole(legitWaiting))
1039- require.Same(t, resumeTr, legitWaiting.transport())
1040-}
1041-
1042-// TestExplicitDetachClearsOrphanedSameAttachmentParkingMarker covers the race
1043-// where non-explicit teardown publishes a parking-in-flight marker, then
1044-// explicit detach wins the seat. The winner must clear only that attachment's
1045-// orphaned marker and unblock waiters; other attachments' markers and a later
1046-// parked lifecycle must stay intact. Unknown tokens stay fail-closed.
1047672 func TestExplicitDetachClearsOrphanedSameAttachmentParkingMarker(t *testing.T) {
1048673 workPTY, releaseWork := newBlockingPTY(t)
1049674 defer releaseWork()
@@ -1085,7 +710,7 @@ func TestExplicitDetachClearsOrphanedSameAttachmentParkingMarker(t *testing.T) {
1085710 awaitTestCompletion(t, waiterArmed, "parking waiter did not arm on the orphaned marker")
1086711
1087712 d.clientGone(sess, ac, oldTr, true)
1088- require.Nil(t, sess.client, "explicit detach must clear the live owner")
713+ require.Empty(t, sess.snapshotAttachments(), "explicit detach must clear the live owner")
1089714
1090715 waited := awaitTestValue(t, waiterDone, "parking waiter stayed blocked after explicit detach")
1091716 require.True(t, waited, "waiter must observe the same-attachment marker before it is cleared")
@@ -1144,7 +769,7 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) {
1144769 win: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, oldTr *closeTrackingTransport, token uint64) {
1145770 t.Helper()
1146771 d.clientGone(sess, ac, oldTr, true)
1147- require.Nil(t, sess.client, "explicit detach must clear the live owner before late mark")
772+ require.Empty(t, sess.snapshotAttachments(), "explicit detach must clear the live owner before late mark")
1148773 },
1149774 },
1150775 {
@@ -1170,7 +795,7 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) {
1170795 require.NoError(t, err)
1171796 token := ac.resumeToken
1172797 require.NotZero(t, token)
1173- require.Same(t, ac, sess.client)
798+ require.Contains(t, sess.snapshotAttachments(), ac)
1174799
1175800 validated := make(chan struct{})
1176801 releaseMark := make(chan struct{})
@@ -1227,7 +852,7 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) {
1227852 require.NotZero(t, token)
1228853
1229854 require.True(t, d.detachIfCurrentTransport(sess, ac, ac.transportSnapshot()))
1230- require.Nil(t, sess.client)
855+ require.Empty(t, sess.snapshotAttachments())
1231856 require.Zero(t, d.markParkingInFlight(sess, ac), "detached active attachment must not publish a late marker")
1232857
1233858 d.mu.Lock()
@@ -1249,46 +874,6 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) {
1249874 // TestMarkParkingInFlightRequiresExactLiveOwnership covers lock-safe rejection
1250875 // when the attachment is no longer the exact active or snatched owner, while
1251876 // still advertising for both live roles.
1252-func TestMarkParkingInFlightRequiresExactLiveOwnership(t *testing.T) {
1253- pty, release := newBlockingPTY(t)
1254- defer release()
1255- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
1256-
1257- oldTr := &closeTrackingTransport{}
1258- sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
1259- require.NoError(t, err)
1260- token := waiting.resumeToken
1261- require.NotZero(t, token)
1262-
1263- require.Equal(t, token, d.markParkingInFlight(sess, waiting), "active owner must publish")
1264- d.clearParkingInFlight(token, waiting)
1265-
1266- _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{})
1267- require.NoError(t, err)
1268- d.attachmentCleanupWg.Wait()
1269- require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting))
1270- require.Same(t, active, sess.client)
1271-
1272- require.Equal(t, token, d.markParkingInFlight(sess, waiting), "snatched owner must publish")
1273- d.clearParkingInFlight(token, waiting)
1274-
1275- roleToken := sess.attachmentToken(waiting, oldTr)
1276- require.True(t, d.unrouteSnatchedAttachment(roleToken, false))
1277- require.Equal(t, attachmentDetached, sess.attachmentRole(waiting))
1278- require.Zero(t, d.markParkingInFlight(sess, waiting), "detached snatched attachment must not publish")
1279-
1280- d.mu.Lock()
1281- _, stillParking := d.parking[token]
1282- d.mu.Unlock()
1283- require.False(t, stillParking)
1284-
1285- require.Equal(t, active.resumeToken, d.markParkingInFlight(sess, active), "active owner must still publish after snatched unroute")
1286- d.clearParkingInFlight(active.resumeToken, active)
1287-}
1288-
1289-// TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment covers
1290-// the TOCTOU where clientGone passes the old-transport precheck, then a live
1291-// resume rebinds the same attachment before detach runs.
1292877 func TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment(t *testing.T) {
1293878 pty, release := newBlockingPTY(t)
1294879 defer release()
@@ -1326,7 +911,7 @@ func TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment(t *tes
1326911 releaseOnce.Do(func() { close(releaseDetach) })
1327912 awaitTestCompletion(t, goneDone, "stale clientGone did not finish after resume rebound")
1328913
1329- require.Same(t, ac, sess.client, "stale cleanup must not detach the rebound attachment")
914+ require.Contains(t, sess.snapshotAttachments(), ac, "stale cleanup must not detach the rebound attachment")
1330915 require.Same(t, newTr, ac.transport())
1331916 require.False(t, newTr.Closed(), "rebound transport must survive stale old-link cleanup")
1332917 require.True(t, oldTr.Closed(), "live resume retires the captured old transport")
@@ -1346,7 +931,7 @@ func TestResumeLiveAttachmentParkFailureRetiresOldTransport(t *testing.T) {
1346931 token := ac.resumeToken
1347932 require.NotZero(t, token)
1348933
1349- d.afterDetachRoleEffectsFrozen = func() {
934+ d.afterDetachAttachmentEffectsFrozen = func() {
1350935 d.mu.Lock()
1351936 d.closing = true
1352937 d.mu.Unlock()
@@ -1357,7 +942,7 @@ func TestResumeLiveAttachmentParkFailureRetiresOldTransport(t *testing.T) {
1357942 var pe *protoErr
1358943 require.ErrorAs(t, err, &pe)
1359944 require.Equal(t, ports.ErrServerShutdown, pe.code)
1360- require.Nil(t, sess.client, "failed live park must leave the session without an owner")
945+ require.Empty(t, sess.snapshotAttachments(), "failed live park must leave the session without an owner")
1361946 require.Nil(t, ac.transport(), "failed live park must revoke the captured old transport")
1362947 require.True(t, oldTr.Closed(), "failed live park must close the captured old transport")
1363948 d.mu.Lock()
@@ -1383,7 +968,7 @@ func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) {
1383968 require.Same(t, sess, resumedSess)
1384969 require.Same(t, ac, resumedAC)
1385970 require.NotEqual(t, oldToken, resumedAC.resumeToken, "resume rotates token")
1386- require.Same(t, resumedAC, sess.client)
971+ require.Contains(t, sess.snapshotAttachments(), resumedAC)
1387972 }
1388973
1389974 func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {
@@ -1458,7 +1043,7 @@ func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing.
14581043 token := ac.resumeToken
14591044 require.True(t, sess.detachIfCurrent(ac))
14601045 require.True(t, d.parkAttachment(sess, ac))
1461- generation := ac.roleGeneration.Load()
1046+ generation := ac.connectionGeneration.Load()
14621047
14631048 newTr := &closeTrackingTransport{}
14641049 resumedSess, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), newTr, domain.Size{Cols: 80, Rows: 24})
@@ -1466,7 +1051,7 @@ func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing.
14661051 require.True(t, ok)
14671052 require.Same(t, sess, resumedSess)
14681053 require.Same(t, ac, resumedAC)
1469- require.Greater(t, ac.roleGeneration.Load(), generation, "resume must publish active ownership through the attachment transition")
1054+ require.Greater(t, ac.connectionGeneration.Load(), generation, "resume must publish active ownership through the attachment transition")
14701055
14711056 _ = ac.closeCapturedTransport(oldTr)
14721057 require.True(t, oldTr.Closed(), "old transport is closed")
@@ -1490,8 +1075,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
14901075 resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr)
14911076 require.NoError(t, err)
14921077 require.Same(t, ac, resumedAC)
1493- require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedSess.renderCoordinator().attachmentLease(resumedAC)))
1494- d.firstPaint(resumedSess, resumedAC, resumedAC.size)
1078+ d.paint(resumedSess, resumedAC, true, nil)
14951079
14961080 sends := newTr.Sends()
14971081 require.Len(t, sends, 1)
@@ -1538,7 +1122,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
15381122 require.True(t, sess.detachIfCurrent(ac))
15391123 require.True(t, d.parkAttachment(sess, ac))
15401124
1541- // Headless close cannot find the parked attachment through sess.client.
1125+ // Headless close cannot find the parked attachment through sess.snapshotAttachments()[0].
15421126 // Its capture must already have been released before the attachment parked.
15431127 require.NoError(t, d.closePane(sess, tb, closed.id, nil, false))
15441128 ac.sendMu.Lock()
@@ -1734,7 +1318,7 @@ func TestEphemeralParkExpiryKeepsSession(t *testing.T) {
17341318
17351319 require.Equal(t, 1, sessionCount(d), "token expiry does not kill ephemeral session")
17361320 sess.mu.Lock()
1737- require.Nil(t, sess.client)
1321+ require.Empty(t, sess.snapshotAttachmentsLocked())
17381322 sess.mu.Unlock()
17391323 }
17401324
@@ -1828,34 +1412,6 @@ func TestKilledSessionUnblocksParkingWaiterFailsClosed(t *testing.T) {
18281412 require.Contains(t, killedErr.Error(), "resume token is no longer valid")
18291413 }
18301414
1831-func TestParkedPredecessorResumesSnatchedWithoutStealingActiveAttachment(t *testing.T) {
1832- pty, release := newBlockingPTY(t)
1833- defer release()
1834- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
1835- tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
1836- sess, oldAC, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
1837- require.NoError(t, err)
1838- token := oldAC.resumeToken
1839- d.clientGone(sess, oldAC, oldAC.transport(), false)
1840-
1841- activeTr := &closeTrackingTransport{}
1842- _, activeAC, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTr)
1843- require.NoError(t, err)
1844- require.NotSame(t, oldAC, activeAC)
1845-
1846- d.mu.Lock()
1847- parked := d.parked[token]
1848- d.mu.Unlock()
1849- require.NotNil(t, parked, "normal attach preserves the parked predecessor")
1850- require.Equal(t, attachmentSnatched, parked.role)
1851- _, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
1852- require.NoError(t, err)
1853- require.True(t, ok)
1854- require.Same(t, oldAC, resumedAC)
1855- require.Equal(t, attachmentSnatched, sess.attachmentRole(resumedAC))
1856- require.Same(t, activeAC, sess.client)
1857-}
1858-
18591415 func TestStaleClientGoneDoesNotDetachOrCloseFreshTransport(t *testing.T) {
18601416 p, release := newBlockingPTY(t)
18611417 defer release()
@@ -1869,7 +1425,7 @@ func TestStaleClientGoneDoesNotDetachOrCloseFreshTransport(t *testing.T) {
18691425
18701426 d.clientGone(sess, ac, oldTr, false)
18711427
1872- require.Same(t, ac, sess.client, "stale connection must not detach current client")
1428+ require.Contains(t, sess.snapshotAttachments(), ac, "stale connection must not detach current client")
18731429 require.False(t, oldTr.Closed(), "stale transport is owned by its own loop/handler")
18741430 require.False(t, freshTr.Closed(), "fresh resumed transport must not be closed by stale loop")
18751431 }
M internal/usecase/daemon/resume_wake_incarnation_test.go +42 -0
@@ -66,3 +66,45 @@ func TestParkedResumeRejectsDispatchedWakeFromPriorAttachment(t *testing.T) {
6666 d.firstPaint(sess, ac, ac.size)
6767 require.NotEmpty(t, newTransport.Sends())
6868 }
69+
70+func TestSameAttachmentRebindRejectsQueuedWakeAndFansOutFreshGeneration(t *testing.T) {
71+ d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
72+ rc := d.attachCoordinator(sess, ac, true)
73+ oldLease := rc.attachmentLease(ac)
74+ require.NotNil(t, oldLease)
75+
76+ originalWake := rc.opts.wake
77+ wakeDispatched := make(chan struct{})
78+ releaseWake := make(chan struct{})
79+ rc.opts.wake = func(w renderWake) {
80+ close(wakeDispatched)
81+ <-releaseWake
82+ originalWake(w)
83+ }
84+ wakeDone := make(chan struct{})
85+ go func() {
86+ d.invalidateRenderNow(sess, ac, true, "same-attachment-rebind")
87+ close(wakeDone)
88+ }()
89+ <-wakeDispatched
90+
91+ oldGeneration := ac.connectionGeneration.Load()
92+ result, err := d.transitionAttachment(attachmentTransitionRequest{
93+ source: sess, target: sess, next: ac,
94+
95+ expectedTransport: ac.transportSnapshot(), ready: true,
96+ })
97+ require.NoError(t, err)
98+ d.deferAttachmentTransitionCleanups(result)
99+ require.Greater(t, ac.connectionGeneration.Load(), oldGeneration)
100+ freshLease := rc.attachmentLease(ac)
101+ require.NotSame(t, oldLease, freshLease)
102+
103+ close(releaseWake)
104+ <-wakeDone
105+ require.Empty(t, sends, "a queued wake from the prior generation must not paint the rebound transport")
106+
107+ rc.opts.wake = originalWake
108+ d.invalidateRenderNow(sess, ac, true, "fresh-generation-fanout")
109+ require.Greater(t, len(sends), 0, "the current generation must receive fanout output")
110+}
D internal/usecase/daemon/role_aware_parking_test.go +0 -482
@@ -1,482 +0,0 @@
1-package daemon
2-
3-import (
4- "errors"
5- "sync"
6- "testing"
7-
8- "github.com/stretchr/testify/require"
9-
10- "github.com/bnema/vev/internal/domain"
11- "github.com/bnema/vev/internal/ports"
12- themeui "github.com/bnema/vev/internal/usecase/theme"
13- "github.com/bnema/vev/pkg/renderer"
14-)
15-
16-func TestActiveLinkLossParksActiveAndDetachesCoordinator(t *testing.T) {
17- pty, release := newBlockingPTY(t)
18- defer release()
19- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
20-
21- transport := &closeTrackingTransport{}
22- sess, active, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), transport)
23- require.NoError(t, err)
24- token := active.resumeToken
25- rc := sess.renderCoordinator()
26- require.NotNil(t, rc.attachmentLease(active))
27-
28- d.clientGone(sess, active, transport, false)
29-
30- d.mu.Lock()
31- parked := d.parked[token]
32- d.mu.Unlock()
33- require.NotNil(t, parked)
34- require.Equal(t, attachmentActive, parked.role)
35- require.Nil(t, sess.client)
36- require.Nil(t, rc.attachmentLease(active), "active loss should detach coordinator ownership")
37-}
38-
39-func TestSnatchedLinkLossParksWithoutDisturbingActiveOwner(t *testing.T) {
40- pty, release := newBlockingPTY(t)
41- defer release()
42- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
43-
44- oldTransport := &closeTrackingTransport{}
45- sess, old, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
46- require.NoError(t, err)
47- oldToken := old.resumeToken
48-
49- activeTransport := &closeTrackingTransport{}
50- _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport)
51- require.NoError(t, err)
52- d.attachmentCleanupWg.Wait()
53- lease := sess.renderCoordinator().attachmentLease(active)
54- require.NotNil(t, lease)
55-
56- d.runConnLoop(old)
57-
58- d.mu.Lock()
59- parked := d.parked[oldToken]
60- d.mu.Unlock()
61- require.NotNil(t, parked, "resumable snatched attachment should survive link loss")
62- require.Equal(t, attachmentSnatched, parked.role)
63- require.Same(t, active, sess.client)
64- require.Same(t, lease, sess.renderCoordinator().attachmentLease(active))
65- require.False(t, activeTransport.Closed())
66-}
67-
68-type sendErrorTransport struct {
69- closeTrackingTransport
70-}
71-
72-func (*sendErrorTransport) Send(ports.Frame) error { return errors.New("send failed") }
73-
74-func TestSnatchedPanelSendFailureParksResumableAttachment(t *testing.T) {
75- pty, release := newBlockingPTY(t)
76- defer release()
77- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
78-
79- failed := &sendErrorTransport{}
80- sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), failed)
81- require.NoError(t, err)
82- token := waiting.resumeToken
83-
84- _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{})
85- require.NoError(t, err)
86- d.attachmentCleanupWg.Wait()
87-
88- d.mu.Lock()
89- parked := d.parked[token]
90- d.mu.Unlock()
91- require.NotNil(t, parked, "failed snatched panel should park a resumable attachment")
92- require.Equal(t, attachmentSnatched, parked.role)
93- require.Nil(t, waiting.transport(), "parking should revoke the exact failed transport")
94- require.True(t, failed.Closed())
95- require.Same(t, active, sess.client)
96-}
97-
98-func TestStaleSnatchedTransportCannotParkFreshIncarnation(t *testing.T) {
99- pty, release := newBlockingPTY(t)
100- defer release()
101- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
102-
103- oldTransport := &closeTrackingTransport{}
104- sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
105- require.NoError(t, err)
106- _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{})
107- require.NoError(t, err)
108- d.attachmentCleanupWg.Wait()
109- stale := sess.attachmentToken(waiting, oldTransport)
110- fresh := &closeTrackingTransport{}
111- waiting.replaceTransport(fresh)
112-
113- require.False(t, d.parkOrDropSnatchedAttachment(stale))
114- d.mu.Lock()
115- _, parked := d.parked[waiting.resumeToken]
116- d.mu.Unlock()
117- require.False(t, parked)
118- require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting))
119- require.Same(t, fresh, waiting.transport())
120- require.False(t, fresh.Closed())
121- require.Same(t, active, sess.client)
122-}
123-
124-func TestSnatchedParkExpiryDoesNotAffectActiveOwner(t *testing.T) {
125- pty, release := newBlockingPTY(t)
126- defer release()
127- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
128-
129- waitingTransport := &closeTrackingTransport{}
130- sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), waitingTransport)
131- require.NoError(t, err)
132- token := waiting.resumeToken
133- _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{})
134- require.NoError(t, err)
135- d.attachmentCleanupWg.Wait()
136- lease := sess.renderCoordinator().attachmentLease(active)
137-
138- d.runConnLoop(waiting)
139- d.mu.Lock()
140- parked := d.parked[token]
141- d.mu.Unlock()
142- require.NotNil(t, parked)
143-
144- d.expireParked(token, parked)
145-
146- d.mu.Lock()
147- _, retained := d.parked[token]
148- d.mu.Unlock()
149- require.False(t, retained)
150- require.Same(t, active, sess.client)
151- require.Same(t, lease, sess.renderCoordinator().attachmentLease(active))
152-}
153-
154-func TestNonzeroResumeTokenOverridesOrdinaryRoutingFallbacks(t *testing.T) {
155- pty, release := newBlockingPTY(t)
156- defer release()
157- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
158-
159- work, active, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), &closeTrackingTransport{})
160- require.NoError(t, err)
161- const unknownToken = uint64(0xdecafbad)
162- for _, tc := range []struct {
163- name string
164- intent uint8
165- target string
166- }{
167- {name: "attach existing", intent: ports.IntentAttach, target: "work"},
168- {name: "create unique", intent: ports.IntentNew, target: "fallback"},
169- {name: "create ephemeral", intent: ports.IntentEphemeral},
170- } {
171- t.Run(tc.name, func(t *testing.T) {
172- h := helloResumeCapable(tc.intent, tc.target, unknownToken)
173- routedSession, routedClient, routeErr := d.route(h, &closeTrackingTransport{})
174- require.Error(t, routeErr)
175- require.Nil(t, routedSession)
176- require.Nil(t, routedClient)
177- var protocolErr *protoErr
178- require.ErrorAs(t, routeErr, &protocolErr)
179- require.Equal(t, ports.ErrNoSuchSession, protocolErr.code)
180- require.Same(t, active, work.client)
181- d.mu.Lock()
182- require.Len(t, d.sessions, 1)
183- require.Nil(t, d.findByNameLocked("fallback"))
184- d.mu.Unlock()
185- })
186- }
187-
188- _, replacement, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{})
189- require.NoError(t, err, "a zero token keeps ordinary attach semantics")
190- require.NotSame(t, active, replacement)
191- require.Same(t, replacement, work.client)
192- require.Equal(t, attachmentSnatched, work.attachmentRole(active))
193-}
194-
195-func TestHandleHelloExpiredSnatchedResumeFailsClosedAfterSessionEnds(t *testing.T) {
196- workPTY, releaseWork := newBlockingPTY(t)
197- keeperPTY, releaseKeeper := newBlockingPTY(t)
198- fallbackPTY, releaseFallback := newBlockingPTY(t)
199- defer releaseWork()
200- defer releaseKeeper()
201- defer releaseFallback()
202- d := newTestDaemon(t, newFactorySeq(t, workPTY, keeperPTY, fallbackPTY), stubClock{})
203-
204- oldTransport := &closeTrackingTransport{}
205- work, old, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
206- require.NoError(t, err)
207- oldToken := old.resumeToken
208-
209- _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{})
210- require.NoError(t, err)
211- d.attachmentCleanupWg.Wait()
212- require.Equal(t, attachmentSnatched, work.attachmentRole(old))
213- require.Same(t, active, work.client)
214-
215- d.runConnLoop(old)
216- d.mu.Lock()
217- parked := d.parked[oldToken]
218- d.mu.Unlock()
219- require.NotNil(t, parked)
220- require.Equal(t, attachmentSnatched, parked.role)
221-
222- keeper, _, err := d.route(helloResumeCapable(ports.IntentNew, "keeper", 0), &closeTrackingTransport{})
223- require.NoError(t, err)
224- require.NoError(t, d.killSession(work, ports.ReasonSessionKilled, false))
225-
226- d.mu.Lock()
227- _, tokenRetained := d.parked[oldToken]
228- stoppedWork, lifecycleRetained := d.stopped["work"]
229- d.mu.Unlock()
230- require.False(t, tokenRetained, "terminal lifecycle teardown must purge the parked token")
231- require.True(t, lifecycleRetained, "the stopped lifecycle makes attach fallback observable")
232-
233- reconnect := &closeTrackingTransport{}
234- d.handleHello(reconnect, ports.Frame{
235- Type: ports.MsgHello,
236- Payload: ports.MarshalHello(helloResumeCapable(ports.IntentResume, "work", oldToken)),
237- })
238-
239- frames := reconnect.Sends()
240- require.Len(t, frames, 1)
241- require.Equal(t, ports.MsgError, frames[0].Type)
242- wireErr, err := ports.UnmarshalErrorMsg(frames[0].Payload)
243- require.NoError(t, err)
244- require.Equal(t, ports.ErrNoSuchSession, wireErr.Code)
245- require.True(t, reconnect.Closed())
246- d.mu.Lock()
247- require.Same(t, keeper, d.findByNameLocked("keeper"))
248- require.Nil(t, d.findByNameLocked("work"), "expired resume must not recreate the stopped session")
249- require.Equal(t, stoppedWork, d.stopped["work"])
250- require.Len(t, d.sessions, 1)
251- d.mu.Unlock()
252- require.Nil(t, work.client, "expired resume must not reclaim the ended lifecycle")
253- require.Nil(t, active.currentSession())
254-}
255-
256-func TestSnatchedResumeHandshakeSendsWelcomeAndPanelWithoutReplacingOwner(t *testing.T) {
257- pty, release := newBlockingPTY(t)
258- defer release()
259- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
260-
261- oldTransport := &closeTrackingTransport{}
262- sess, old, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
263- require.NoError(t, err)
264- token := old.resumeToken
265- d.clientGone(sess, old, oldTransport, false)
266-
267- _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{})
268- require.NoError(t, err)
269-
270- resumedTransport := &closeTrackingTransport{}
271- hello := helloResumeCapable(ports.IntentResume, "work", token)
272- d.handleHello(resumedTransport, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
273-
274- frames := resumedTransport.Sends()
275- require.GreaterOrEqual(t, len(frames), 2)
276- require.Equal(t, ports.MsgWelcome, frames[0].Type)
277- welcome, err := ports.UnmarshalWelcome(frames[0].Payload)
278- require.NoError(t, err)
279- newToken := welcome.ResumeToken
280- require.NotZero(t, newToken)
281- require.NotEqual(t, token, newToken, "successful snatched resume rotates the credential")
282- require.Equal(t, newToken, old.resumeToken, "the Welcome publishes the one rotated credential")
283- require.Equal(t, ports.MsgOutput, frames[1].Type)
284- output, err := ports.UnmarshalOutput(frames[1].Payload)
285- require.NoError(t, err)
286- require.Contains(t, string(output.Data), "Session snatched")
287- d.mu.Lock()
288- _, oldTokenParked := d.parked[token]
289- parked := d.parked[newToken]
290- d.mu.Unlock()
291- require.False(t, oldTokenParked, "the consumed credential cannot be reused")
292- require.NotNil(t, parked)
293- require.Equal(t, attachmentSnatched, parked.role, "the rotated credential remains bound to the snatched role")
294- require.Same(t, active, sess.client)
295-
296- replay := &closeTrackingTransport{}
297- d.handleHello(replay, ports.Frame{
298- Type: ports.MsgHello,
299- Payload: ports.MarshalHello(helloResumeCapable(ports.IntentResume, "work", token)),
300- })
301- replayFrames := replay.Sends()
302- require.Len(t, replayFrames, 1)
303- require.Equal(t, ports.MsgError, replayFrames[0].Type)
304- replayErr, err := ports.UnmarshalErrorMsg(replayFrames[0].Payload)
305- require.NoError(t, err)
306- require.Equal(t, ports.ErrNoSuchSession, replayErr.Code)
307- require.True(t, replay.Closed())
308- require.Same(t, active, sess.client, "replaying the old credential must not replace active ownership")
309- d.mu.Lock()
310- require.Same(t, parked, d.parked[newToken])
311- d.mu.Unlock()
312-}
313-
314-func TestResumeWelcomeRechecksRoleAfterReplacement(t *testing.T) {
315- pty, release := newBlockingPTY(t)
316- defer release()
317- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
318-
319- oldTransport := &closeTrackingTransport{}
320- sess, resumed, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
321- require.NoError(t, err)
322- token := resumed.resumeToken
323- d.clientGone(sess, resumed, oldTransport, false)
324-
325- blocked := newWelcomeBlockingTransport(t)
326- handshakeDone := make(chan struct{})
327- go func() {
328- hello := helloResumeCapable(ports.IntentResume, "work", token)
329- d.handleHello(blocked.tr, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
330- close(handshakeDone)
331- }()
332- awaitTestCompletion(t, blocked.welcomeEntered, "resume did not start Welcome")
333- welcome := awaitFrame(t, blocked.sends, ports.MsgWelcome)
334- require.Equal(t, ports.MsgWelcome, welcome.Type)
335- requireNoCoordinatorOutputFrame(t, blocked.sends)
336- resumedLease := sess.renderCoordinator().attachmentLease(resumed)
337- require.NotNil(t, resumedLease)
338- require.False(t, resumedLease.ready, "blocked Welcome must keep the resumed lease unready")
339-
340- resumeGateFrozen := make(chan struct{})
341- var frozenOnce sync.Once
342- d.afterRoleEffectGateFrozen = func(_ string, ac *attachedClient) {
343- if ac == resumed {
344- frozenOnce.Do(func() { close(resumeGateFrozen) })
345- }
346- }
347- type attachResult struct {
348- ac *attachedClient
349- err error
350- }
351- replacementResult := make(chan attachResult, 1)
352- go func() {
353- _, replacement, routeErr := d.route(
354- helloResumeCapable(ports.IntentAttach, "work", 0),
355- &closeTrackingTransport{},
356- )
357- replacementResult <- attachResult{ac: replacement, err: routeErr}
358- }()
359- awaitTestCompletion(t, resumeGateFrozen, "replacement did not freeze the resumed Welcome role")
360-
361- blocked.release()
362- replacement := awaitTestValue(t, replacementResult, "replacement did not finish after Welcome")
363- require.NoError(t, replacement.err)
364- d.attachmentCleanupWg.Wait()
365- panelFrame := awaitFrame(t, blocked.sends, ports.MsgOutput)
366- panel, err := ports.UnmarshalOutput(panelFrame.Payload)
367- require.NoError(t, err)
368- require.Zero(t, panel.BaseStateNum, "post-Welcome snatched panel must reset output state")
369- require.Contains(t, string(panel.Data), "Session snatched")
370- requireNoCoordinatorOutputFrame(t, blocked.sends)
371-
372- require.Same(t, replacement.ac, sess.client)
373- require.Equal(t, attachmentSnatched, sess.attachmentRole(resumed))
374- rc := sess.renderCoordinator()
375- rc.mu.Lock()
376- require.False(t, resumedLease.ready, "replacement during Welcome must not ready the stale active lease")
377- rc.mu.Unlock()
378- require.Nil(t, rc.attachmentLease(resumed))
379- require.NotNil(t, rc.attachmentLease(replacement.ac))
380-
381- blocked.finish()
382- awaitTestCompletion(t, handshakeDone, "resumed snatched connection did not continue after replacement")
383-}
384-
385-func TestSnatchedResumePreservesOwnerCapabilityAndCanReclaim(t *testing.T) {
386- pty, release := newBlockingPTY(t)
387- defer release()
388- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
389-
390- oldTransport := &closeTrackingTransport{}
391- sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
392- require.NoError(t, err)
393- token := waiting.resumeToken
394- waiting.setClientTheme(themeui.Theme{
395- Foreground: renderer.RGB{R: 90, G: 10, B: 10},
396- Background: renderer.RGB{R: 20, G: 1, B: 1},
397- HasFG: true, HasBG: true, Known: true,
398- })
399- d.clientGone(sess, waiting, oldTransport, false)
400-
401- activeTransport := &closeTrackingTransport{}
402- _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport)
403- require.NoError(t, err)
404- rc := sess.renderCoordinator()
405- activeGeneration := active.roleGeneration.Load()
406- activeLease := rc.attachmentLease(active)
407- require.NotNil(t, activeLease)
408- activeTheme := themeui.Theme{
409- Foreground: renderer.RGB{R: 10, G: 80, B: 20},
410- Background: renderer.RGB{R: 1, G: 15, B: 3},
411- HasFG: true, HasBG: true, Known: true,
412- }
413- active.setClientTheme(activeTheme)
414- require.True(t, d.applyHostTheme(sess, active, activeTheme, false))
415- assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
416-
417- resumedTransport := &closeTrackingTransport{}
418- resumedSess, resumed, ok, err := d.resumeParked(
419- helloResumeCapable(ports.IntentResume, "work", token),
420- resumedTransport,
421- domain.Size{Cols: 80, Rows: 24},
422- )
423- require.NoError(t, err)
424- require.True(t, ok)
425- require.Same(t, sess, resumedSess)
426- require.Same(t, waiting, resumed)
427- require.Same(t, active, sess.client)
428- require.Equal(t, activeGeneration, active.roleGeneration.Load())
429- require.Same(t, activeLease, rc.attachmentLease(active))
430- require.Nil(t, rc.attachmentLease(waiting), "snatched resume must not acquire a render lease")
431- require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting))
432- assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
433-
434- resumedGeneration := waiting.roleGeneration.Load()
435- require.False(t, d.handleSnatchedClientFrame(sess.attachmentToken(waiting, resumedTransport), ports.Frame{
436- Type: ports.MsgInput,
437- Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
438- }))
439- d.attachmentCleanupWg.Wait()
440-
441- require.Same(t, waiting, sess.client)
442- require.Equal(t, attachmentActive, sess.attachmentRole(waiting))
443- require.Greater(t, waiting.roleGeneration.Load(), resumedGeneration)
444- require.NotNil(t, rc.attachmentLease(waiting))
445- require.Equal(t, attachmentSnatched, sess.attachmentRole(active))
446-}
447-
448-func TestOrdinaryAttachDemotesParkedActiveAndAllowsSnatchedResume(t *testing.T) {
449- pty, release := newBlockingPTY(t)
450- defer release()
451- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
452-
453- oldTransport := &closeTrackingTransport{}
454- sess, old, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
455- require.NoError(t, err)
456- token := old.resumeToken
457- d.clientGone(sess, old, oldTransport, false)
458-
459- activeTransport := &closeTrackingTransport{}
460- _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport)
461- require.NoError(t, err)
462-
463- d.mu.Lock()
464- parked := d.parked[token]
465- d.mu.Unlock()
466- require.NotNil(t, parked, "ordinary attach must preserve the predecessor's resume token")
467- require.Equal(t, attachmentSnatched, parked.role)
468- require.Equal(t, token, old.resumeToken)
469-
470- resumedTransport := &closeTrackingTransport{}
471- resumedSess, resumed, ok, err := d.resumeParked(
472- helloResumeCapable(ports.IntentResume, "work", token),
473- resumedTransport,
474- domain.Size{Cols: 80, Rows: 24},
475- )
476- require.NoError(t, err)
477- require.True(t, ok)
478- require.Same(t, sess, resumedSess)
479- require.Same(t, old, resumed)
480- require.Equal(t, attachmentSnatched, sess.attachmentRole(old))
481- require.Same(t, active, sess.client)
482-}
D internal/usecase/daemon/role_effect_gate.go +0 -307
@@ -1,307 +0,0 @@
1-package daemon
2-
3-import (
4- "sync"
5- "sync/atomic"
6-
7- "github.com/bnema/vev/internal/ports"
8-)
9-
10-type roleEffectPhase uint8
11-
12-const (
13- roleEffectsStable roleEffectPhase = iota
14- roleEffectsFrozen
15-)
16-
17-// roleCapability is the single attachment-local publication consumed by role
18-// effect admission. It deliberately contains no independently mutable state:
19-// a ticket is admitted only when every field matches the frame's token.
20-type roleCapability struct {
21- sess attachmentSession
22- role attachmentRole
23- generation uint64
24- transport transportSnapshot
25- lease *attachmentLease
26-}
27-
28-func capabilityFromToken(token attachmentRoleToken) roleCapability {
29- return roleCapability{
30- sess: token.sess,
31- role: token.role,
32- generation: token.generation,
33- transport: token.transport,
34- lease: token.lease,
35- }
36-}
37-
38-func (c roleCapability) matches(token attachmentRoleToken) bool {
39- return token.ac != nil && c.sess == token.sess && c.role == token.role &&
40- c.generation == token.generation && c.transport.transport == token.transport.transport &&
41- c.transport.incarnation == token.transport.incarnation && c.lease == token.lease
42-}
43-
44-type roleEffectGate struct {
45- mu sync.Mutex
46- cond *sync.Cond
47- changed chan struct{}
48- phase roleEffectPhase
49- capability roleCapability
50- inFlight uint64
51- transportEffects map[*roleEffectTicket]transportSnapshot
52- failedTransport transportSnapshot
53- order atomic.Uint64
54-}
55-
56-var nextRoleEffectGateOrder atomic.Uint64
57-
58-func (g *roleEffectGate) initLocked() {
59- if g.cond == nil {
60- g.cond = sync.NewCond(&g.mu)
61- }
62- if g.changed == nil {
63- g.changed = make(chan struct{})
64- }
65-}
66-
67-func (g *roleEffectGate) signalLocked() {
68- g.initLocked()
69- close(g.changed)
70- g.changed = make(chan struct{})
71- g.cond.Broadcast()
72-}
73-
74-func (g *roleEffectGate) immutableOrder() uint64 {
75- if order := g.order.Load(); order != 0 {
76- return order
77- }
78- order := nextRoleEffectGateOrder.Add(1)
79- if g.order.CompareAndSwap(0, order) {
80- return order
81- }
82- return g.order.Load()
83-}
84-
85-func (g *roleEffectGate) inFlightCount() uint64 {
86- if g == nil {
87- return 0
88- }
89- g.mu.Lock()
90- defer g.mu.Unlock()
91- return g.inFlight
92-}
93-
94-// roleEffectTicket reserves the exact published capability until End. End is
95-// idempotent so error paths can safely defer it before doing any observable I/O.
96-type roleEffectTicket struct {
97- gate *roleEffectGate
98- token attachmentRoleToken
99- actionDaemon *Daemon
100- action string
101- ended atomic.Bool
102-}
103-
104-func (t *roleEffectTicket) bindActionEnd(d *Daemon, action string) {
105- if t == nil || t.ended.Load() {
106- return
107- }
108- t.actionDaemon = d
109- t.action = action
110-}
111-
112-func (t *roleEffectTicket) roleToken() attachmentRoleToken {
113- if t == nil {
114- return attachmentRoleToken{}
115- }
116- token := t.token
117- token.effect = t
118- return token
119-}
120-
121-func (t *roleEffectTicket) End() {
122- if t == nil || t.gate == nil || !t.ended.CompareAndSwap(false, true) {
123- return
124- }
125- g := t.gate
126- g.mu.Lock()
127- g.initLocked()
128- if g.inFlight == 0 {
129- g.mu.Unlock()
130- panic("daemon: role effect ticket ended without admission")
131- }
132- delete(g.transportEffects, t)
133- g.inFlight--
134- g.signalLocked()
135- g.mu.Unlock()
136- if t.actionDaemon != nil && t.actionDaemon.afterActionRoleEffectEnded != nil {
137- t.actionDaemon.afterActionRoleEffectEnded(t.action)
138- }
139-}
140-
141-func (d *Daemon) endActionRoleEffect(effect *roleEffectTicket, _ string) {
142- if effect != nil {
143- effect.End()
144- }
145-}
146-
147-// beginTransportSend marks only the interval spent in an external transport
148-// send as interruptible. A replacement may close this exact published link to
149-// release the send, but cannot retire an idle healthy snatched connection.
150-func (t *roleEffectTicket) beginTransportSend(expected transportSnapshot) bool {
151- if t == nil || t.gate == nil || t.ended.Load() || expected.transport == nil {
152- return false
153- }
154- g := t.gate
155- g.mu.Lock()
156- g.initLocked()
157- if t.ended.Load() || g.capability.transport.transport != expected.transport ||
158- g.capability.transport.incarnation != expected.incarnation {
159- g.mu.Unlock()
160- return false
161- }
162- if g.transportEffects == nil {
163- g.transportEffects = make(map[*roleEffectTicket]transportSnapshot)
164- }
165- g.transportEffects[t] = expected
166- g.signalLocked()
167- g.mu.Unlock()
168- return true
169-}
170-
171-func (t *roleEffectTicket) reportTransportFailure(expected transportSnapshot) {
172- if t == nil || t.gate == nil || expected.transport == nil {
173- return
174- }
175- g := t.gate
176- g.mu.Lock()
177- if !t.ended.Load() && g.capability.transport.transport == expected.transport &&
178- g.capability.transport.incarnation == expected.incarnation {
179- g.failedTransport = expected
180- g.signalLocked()
181- }
182- g.mu.Unlock()
183-}
184-
185-func (t *roleEffectTicket) endTransportSend() {
186- if t == nil || t.gate == nil {
187- return
188- }
189- g := t.gate
190- g.mu.Lock()
191- if _, exists := g.transportEffects[t]; exists {
192- delete(g.transportEffects, t)
193- g.signalLocked()
194- }
195- g.mu.Unlock()
196-}
197-
198-// beginRoleEffect is the sole role-bound effect admission point. The gate is
199-// held only long enough to reserve the capability; the effect itself must run
200-// without the gate mutex.
201-func (ac *attachedClient) beginRoleEffect(token attachmentRoleToken) (*roleEffectTicket, bool) {
202- if ac == nil || token.ac != ac {
203- return nil, false
204- }
205- g := &ac.roleEffects
206- g.mu.Lock()
207- g.initLocked()
208- if g.phase != roleEffectsStable || !g.capability.matches(token) {
209- g.mu.Unlock()
210- return nil, false
211- }
212- g.inFlight++
213- g.mu.Unlock()
214- captured := token
215- captured.effect = nil
216- return &roleEffectTicket{gate: g, token: captured}, true
217-}
218-
219-// beginCurrentRoleEffect waits out a transition that already froze ac, then
220-// admits the role capability derived from the session registries. Handshakes
221-// use it after Welcome has completed so a replacement blocked behind that send
222-// can publish before readiness, first paint, or the snatched reset panel.
223-func (ac *attachedClient) beginCurrentRoleEffect(sess *session, tr ports.Transport) (attachmentRoleToken, *roleEffectTicket, bool) {
224- if ac == nil || sess == nil || tr == nil {
225- return attachmentRoleToken{}, nil, false
226- }
227- for {
228- token := sess.attachmentToken(ac, tr)
229- if token.role == attachmentDetached {
230- return token, nil, false
231- }
232- if ticket, admitted := ac.beginRoleEffect(token); admitted {
233- return token, ticket, true
234- }
235-
236- g := &ac.roleEffects
237- g.mu.Lock()
238- g.initLocked()
239- if g.phase == roleEffectsFrozen {
240- changed := g.changed
241- g.mu.Unlock()
242- <-changed
243- continue
244- }
245- g.mu.Unlock()
246- // Publication may have completed between token capture and admission.
247- // Re-read the registry and exact generation instead of using stale role
248- // or lease authority.
249- }
250-}
251-
252-// publishRoleCapability is used by direct/headless setup and by transition
253-// publication while the gate is frozen. Production role changes publish only
254-// through publishFrozenRoleCapability.
255-func (ac *attachedClient) publishRoleCapability(token attachmentRoleToken) {
256- if ac == nil {
257- return
258- }
259- g := &ac.roleEffects
260- g.mu.Lock()
261- g.initLocked()
262- g.capability = capabilityFromToken(token)
263- g.failedTransport = transportSnapshot{}
264- g.mu.Unlock()
265-}
266-
267-// bootstrapRoleCapability supports direct/headless session construction. It
268-// never changes an existing or frozen production publication.
269-func (ac *attachedClient) bootstrapRoleCapability(token attachmentRoleToken) {
270- if ac == nil || token.sess == nil || token.role == attachmentDetached {
271- return
272- }
273- g := &ac.roleEffects
274- g.mu.Lock()
275- g.initLocked()
276- if g.phase == roleEffectsStable && g.capability.sess == nil {
277- g.capability = capabilityFromToken(token)
278- g.failedTransport = transportSnapshot{}
279- }
280- g.mu.Unlock()
281-}
282-
283-func (ac *attachedClient) publishFrozenRoleCapability(token attachmentRoleToken) {
284- g := &ac.roleEffects
285- g.mu.Lock()
286- g.initLocked()
287- if g.phase != roleEffectsFrozen {
288- g.mu.Unlock()
289- panic("daemon: publishing role capability without transition freeze")
290- }
291- g.capability = capabilityFromToken(token)
292- g.failedTransport = transportSnapshot{}
293- g.mu.Unlock()
294-}
295-
296-func (ac *attachedClient) invalidateFrozenRoleCapability() {
297- g := &ac.roleEffects
298- g.mu.Lock()
299- g.initLocked()
300- if g.phase != roleEffectsFrozen {
301- g.mu.Unlock()
302- panic("daemon: invalidating role capability without transition freeze")
303- }
304- g.capability = roleCapability{}
305- g.failedTransport = transportSnapshot{}
306- g.mu.Unlock()
307-}
D internal/usecase/daemon/role_effect_gate_test.go +0 -1505
@@ -1,1505 +0,0 @@
1-package daemon
2-
3-import (
4- "errors"
5- "io"
6- "sync"
7- "testing"
8- "time"
9-
10- "github.com/stretchr/testify/require"
11-
12- "github.com/bnema/vev/internal/domain"
13- "github.com/bnema/vev/internal/ports"
14- "github.com/bnema/vev/internal/usecase/keys"
15- "github.com/bnema/vev/internal/usecase/picker"
16- "github.com/bnema/vev/pkg/renderer"
17-)
18-
19-func releaseTestGate(t *testing.T, ch chan struct{}) func() {
20- t.Helper()
21- var once sync.Once
22- release := func() { once.Do(func() { close(ch) }) }
23- t.Cleanup(release)
24- return release
25-}
26-
27-type blockedRenderReplacementTransport struct {
28- sendEntered chan struct{}
29- closed chan struct{}
30- sendOnce sync.Once
31- closeOnce sync.Once
32- locksFree chan bool
33- d *Daemon
34- sess *session
35-}
36-
37-func newBlockedRenderReplacementTransport(d *Daemon, sess *session) *blockedRenderReplacementTransport {
38- return &blockedRenderReplacementTransport{
39- sendEntered: make(chan struct{}),
40- closed: make(chan struct{}),
41- locksFree: make(chan bool, 1),
42- d: d,
43- sess: sess,
44- }
45-}
46-
47-func (t *blockedRenderReplacementTransport) Send(ports.Frame) error {
48- t.sendOnce.Do(func() { close(t.sendEntered) })
49- <-t.closed
50- return errors.New("transport closed")
51-}
52-
53-func (*blockedRenderReplacementTransport) Recv() (ports.Frame, error) {
54- return ports.Frame{}, io.EOF
55-}
56-
57-func (t *blockedRenderReplacementTransport) Close() error {
58- locksFree := t.d.mu.TryLock()
59- if locksFree {
60- t.d.mu.Unlock()
61- }
62- if locksFree {
63- locksFree = t.d.notices.routingMu.TryLock()
64- if locksFree {
65- t.d.notices.routingMu.Unlock()
66- }
67- }
68- if locksFree {
69- locksFree = t.sess.mu.TryLock()
70- if locksFree {
71- t.sess.mu.Unlock()
72- }
73- }
74- if locksFree {
75- if rc := t.sess.renderCoordinator(); rc != nil {
76- locksFree = rc.mu.TryLock()
77- if locksFree {
78- rc.mu.Unlock()
79- }
80- }
81- }
82- t.closeOnce.Do(func() {
83- t.locksFree <- locksFree
84- close(t.closed)
85- })
86- return nil
87-}
88-
89-type teardownBlockingTransport struct {
90- sendEntered chan struct{}
91- closed chan struct{}
92- sendOnce sync.Once
93- closeOnce sync.Once
94-}
95-
96-func newTeardownBlockingTransport() *teardownBlockingTransport {
97- return &teardownBlockingTransport{sendEntered: make(chan struct{}), closed: make(chan struct{})}
98-}
99-
100-func (t *teardownBlockingTransport) Send(ports.Frame) error {
101- t.sendOnce.Do(func() { close(t.sendEntered) })
102- <-t.closed
103- return errors.New("transport closed")
104-}
105-
106-func (*teardownBlockingTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
107-
108-func (t *teardownBlockingTransport) Close() error {
109- t.closeOnce.Do(func() { close(t.closed) })
110- return nil
111-}
112-
113-func (t *teardownBlockingTransport) Closed() bool {
114- select {
115- case <-t.closed:
116- return true
117- default:
118- return false
119- }
120-}
121-
122-func startBlockedRoleSend(t *testing.T, token attachmentRoleToken) <-chan struct{} {
123- t.Helper()
124- ticket, admitted := token.ac.beginRoleEffect(token)
125- require.True(t, admitted)
126- done := make(chan struct{})
127- go func() {
128- defer close(done)
129- defer ticket.End()
130- _ = token.ac.sendExpectedTransportForRole(token.transport, framePong(), ticket)
131- }()
132- return done
133-}
134-
135-func requireRoleGateRetired(t *testing.T, ac *attachedClient) {
136- t.Helper()
137- ac.roleEffects.mu.Lock()
138- defer ac.roleEffects.mu.Unlock()
139- require.Equal(t, roleEffectsStable, ac.roleEffects.phase)
140- require.Zero(t, ac.roleEffects.inFlight)
141- require.Empty(t, ac.roleEffects.transportEffects)
142-}
143-
144-func roleGatePublicationSnapshot(ac *attachedClient) (roleEffectPhase, roleCapability) {
145- ac.roleEffects.mu.Lock()
146- defer ac.roleEffects.mu.Unlock()
147- return ac.roleEffects.phase, ac.roleEffects.capability
148-}
149-
150-func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testing.T) {
151- d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
152- firstTransport := &closeTrackingTransport{}
153- first.replaceTransport(firstTransport)
154-
155- blockedTransport := &closeTrackingTransport{}
156- blocked := &attachedClient{tr: blockedTransport, output: newOutputStateStream(), size: first.size}
157- blocked.initOverlays()
158- blocked.setSession(sess)
159- laterTransport := &closeTrackingTransport{}
160- later := &attachedClient{tr: laterTransport, output: newOutputStateStream(), size: first.size}
161- later.initOverlays()
162- later.setSession(sess)
163- sess.mu.Lock()
164- sess.addSnatchedLocked(blocked)
165- sess.addSnatchedLocked(later)
166- sess.mu.Unlock()
167-
168- rc := d.attachCoordinator(sess, nil, first, true)
169- firstToken := sess.attachmentToken(first, firstTransport)
170- firstToken.lease = rc.attachmentLease(first)
171- first.publishRoleCapability(firstToken)
172- blockedToken := sess.attachmentToken(blocked, blockedTransport)
173- blocked.publishRoleCapability(blockedToken)
174- laterToken := sess.attachmentToken(later, laterTransport)
175- later.publishRoleCapability(laterToken)
176-
177- // Fix the canonical order so teardown acquires first, waits behind the gate
178- // owned by this test, and never reaches the still-stable later participant.
179- first.roleEffects.immutableOrder()
180- blocked.roleEffects.immutableOrder()
181- later.roleEffects.immutableOrder()
182- require.Less(t, first.roleEffects.order.Load(), blocked.roleEffects.order.Load())
183- require.Less(t, blocked.roleEffects.order.Load(), later.roleEffects.order.Load())
184-
185- blockedOwner := freezeRoleEffectGates(blocked)
186- blockedOwnerHeld := true
187- t.Cleanup(func() {
188- if blockedOwnerHeld {
189- blockedOwner.unfreeze()
190- }
191- })
192-
193- firstPhase, firstCapability := roleGatePublicationSnapshot(first)
194- blockedPhase, blockedCapability := roleGatePublicationSnapshot(blocked)
195- laterPhase, laterCapability := roleGatePublicationSnapshot(later)
196- require.Equal(t, roleEffectsStable, firstPhase)
197- require.Equal(t, roleEffectsFrozen, blockedPhase)
198- require.Equal(t, roleEffectsStable, laterPhase)
199- firstGeneration := first.roleGeneration.Load()
200- blockedGeneration := blocked.roleGeneration.Load()
201- laterGeneration := later.roleGeneration.Load()
202- firstSnapshot := first.transportSnapshot()
203- blockedSnapshot := blocked.transportSnapshot()
204- laterSnapshot := later.transportSnapshot()
205-
206- clock := &signalClock{timers: make(chan *signalTimer, 1)}
207- d.clock = clock
208- frozenByKill := make(chan *attachedClient, 3)
209- d.afterRoleEffectGateFrozen = func(_ string, ac *attachedClient) { frozenByKill <- ac }
210- killDone := make(chan error, 1)
211- go func() { killDone <- d.killSession(sess, ports.ReasonSessionKilled, false) }()
212-
213- require.Same(t, first, awaitTestValue(t, frozenByKill, "teardown did not acquire the first role gate"))
214- deadline := awaitTestValue(t, clock.timers, "teardown did not arm the gate acquisition deadline")
215- require.Equal(t, detachNotifyTimeout, deadline.duration)
216- deadline.ch <- time.Now()
217- select {
218- case err := <-killDone:
219- require.NoError(t, err)
220- case <-time.After(time.Second):
221- t.Fatal("teardown did not abort after partial gate acquisition timed out")
222- }
223- require.Empty(t, frozenByKill, "teardown acquired a participant after its deadline expired")
224-
225- d.mu.Lock()
226- require.Same(t, sess, d.sessions[sess.id], "partial gate acquisition removed the session registry owner")
227- d.mu.Unlock()
228- sess.mu.Lock()
229- require.Same(t, first, sess.client)
230- require.Contains(t, sess.snatched, blocked)
231- require.Contains(t, sess.snatched, later)
232- sess.mu.Unlock()
233- for _, ac := range []*attachedClient{first, blocked, later} {
234- require.Same(t, sess, ac.currentSession(), "partial gate acquisition cleared attachment ownership")
235- }
236- require.Equal(t, firstGeneration, first.roleGeneration.Load())
237- require.Equal(t, blockedGeneration, blocked.roleGeneration.Load())
238- require.Equal(t, laterGeneration, later.roleGeneration.Load())
239- require.True(t, first.transportSnapshotCurrent(firstSnapshot))
240- require.True(t, blocked.transportSnapshotCurrent(blockedSnapshot))
241- require.True(t, later.transportSnapshotCurrent(laterSnapshot))
242- require.False(t, firstTransport.Closed())
243- require.False(t, blockedTransport.Closed())
244- require.False(t, laterTransport.Closed())
245-
246- firstPhase, gotFirstCapability := roleGatePublicationSnapshot(first)
247- blockedPhase, gotBlockedCapability := roleGatePublicationSnapshot(blocked)
248- laterPhase, gotLaterCapability := roleGatePublicationSnapshot(later)
249- require.Equal(t, roleEffectsStable, firstPhase, "partially acquired gate was not rolled back")
250- require.Equal(t, roleEffectsFrozen, blockedPhase, "teardown released a gate owned by another transition")
251- require.Equal(t, roleEffectsStable, laterPhase, "unacquired later gate was frozen or invalidated")
252- require.Equal(t, firstCapability, gotFirstCapability)
253- require.Equal(t, blockedCapability, gotBlockedCapability)
254- require.Equal(t, laterCapability, gotLaterCapability)
255-
256- blockedOwner.unfreeze()
257- blockedOwnerHeld = false
258- complete := freezeRoleEffectGates(first, blocked, later)
259- require.True(t, complete.acquired, "rolled-back gate set could not be acquired again")
260- require.True(t, complete.drained)
261- complete.unfreeze()
262- for _, token := range []attachmentRoleToken{firstToken, blockedToken, laterToken} {
263- ticket, admitted := token.ac.beginRoleEffect(token)
264- require.True(t, admitted, "pre-publication abort changed a role capability")
265- ticket.End()
266- requireRoleGateRetired(t, token.ac)
267- }
268-}
269-
270-func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
271- d, sess, snatched, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
272- snatchedTransport := newTeardownBlockingTransport()
273- snatched.replaceTransport(snatchedTransport)
274- d.attachCoordinator(sess, nil, snatched, true)
275- snatched.publishRoleCapability(sess.attachmentToken(snatched, snatchedTransport))
276-
277- activeTransport := newTeardownBlockingTransport()
278- active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: snatched.size}
279- active.initOverlays()
280- transition, err := d.transitionAttachment(attachmentTransitionRequest{
281- target: sess, next: active, expectedRole: attachmentDetached, targetRole: attachmentActive,
282- expectedTransport: active.transportSnapshot(), ready: true,
283- })
284- require.NoError(t, err)
285- defer d.deferAttachmentTransitionCleanups(transition)
286-
287- unrelatedTransport := &closeTrackingTransport{}
288- unrelatedClient := &attachedClient{tr: unrelatedTransport, output: newOutputStateStream(), size: active.size}
289- unrelatedClient.initOverlays()
290- unrelated := &session{sessionCore: sessionCore{id: "unrelated", name: "unrelated", client: unrelatedClient}, ctx: sess.ctx, cancel: func() {}}
291- unrelatedClient.setSession(unrelated)
292- d.mu.Lock()
293- d.sessions[unrelated.id] = unrelated
294- d.mu.Unlock()
295- d.attachCoordinator(unrelated, nil, unrelatedClient, true)
296-
297- snatchedToken := sess.attachmentToken(snatched, snatchedTransport)
298- activeToken := sess.attachmentToken(active, activeTransport)
299- snatchedDone := startBlockedRoleSend(t, snatchedToken)
300- activeDone := startBlockedRoleSend(t, activeToken)
301- awaitTestCompletion(t, snatchedTransport.sendEntered, "snatched send did not block")
302- awaitTestCompletion(t, activeTransport.sendEntered, "active send did not block")
303-
304- killDone := make(chan error, 1)
305- go func() { killDone <- d.killSession(sess, ports.ReasonSessionKilled, false) }()
306- select {
307- case err := <-killDone:
308- require.NoError(t, err)
309- case <-time.After(500 * time.Millisecond):
310- _ = snatchedTransport.Close()
311- _ = activeTransport.Close()
312- t.Fatal("session kill remained pinned behind admitted participant sends")
313- }
314- awaitTestCompletion(t, snatchedDone, "snatched role ticket did not retire")
315- awaitTestCompletion(t, activeDone, "active role ticket did not retire")
316- d.waitNotifies()
317-
318- require.True(t, snatchedTransport.Closed())
319- require.True(t, activeTransport.Closed())
320- require.False(t, unrelatedTransport.Closed(), "unrelated session transport was interrupted")
321- require.Same(t, unrelatedClient, unrelated.client)
322- require.Same(t, unrelated, unrelatedClient.currentSession())
323- requireRoleGateRetired(t, snatched)
324- requireRoleGateRetired(t, active)
325-}
326-
327-func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *testing.T) {
328- d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
329- stale := newTeardownBlockingTransport()
330- ac.replaceTransport(stale)
331- d.attachCoordinator(sess, nil, ac, true)
332- ac.publishRoleCapability(sess.attachmentToken(ac, stale))
333-
334- token := sess.attachmentToken(ac, stale)
335- sendDone := startBlockedRoleSend(t, token)
336- awaitTestCompletion(t, stale.sendEntered, "captured incarnation send did not block")
337- fresh := &closeTrackingTransport{}
338- d.afterRoleEffectParticipantsSnapshotted = func(string, []*attachedClient) {
339- ac.replaceTransport(fresh)
340- }
341-
342- require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
343- awaitTestCompletion(t, sendDone, "captured incarnation role ticket did not retire")
344- require.True(t, stale.Closed(), "exact captured in-flight transport was not interrupted")
345- require.False(t, fresh.Closed(), "newer transport incarnation was interrupted")
346- require.Same(t, fresh, ac.transport())
347- d.mu.Lock()
348- require.Same(t, sess, d.sessions[sess.id], "transport incarnation change should abort stale teardown publication")
349- d.mu.Unlock()
350- sess.mu.Lock()
351- require.Same(t, ac, sess.client)
352- sess.mu.Unlock()
353- requireRoleGateRetired(t, ac)
354-}
355-
356-func TestShutdownSignalsServeWhenParticipantGateAcquisitionTimesOut(t *testing.T) {
357- d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
358- transport := &closeTrackingTransport{}
359- active.replaceTransport(transport)
360- d.attachCoordinator(sess, nil, active, true)
361- active.publishRoleCapability(sess.attachmentToken(active, transport))
362-
363- owner := freezeRoleEffectGates(active)
364- require.True(t, owner.acquired)
365- defer owner.unfreeze()
366-
367- clock := &signalClock{timers: make(chan *signalTimer, 1)}
368- d.clock = clock
369- shutdownDone := make(chan bool, 1)
370- go func() { shutdownDone <- d.shutdownAll(ports.ReasonServerShutdown) }()
371-
372- deadline := awaitTestValue(t, clock.timers, "shutdown did not arm the gate acquisition deadline")
373- deadline.ch <- clock.Now()
374- require.False(t, awaitTestValue(t, shutdownDone, "shutdown did not return after its gate acquisition deadline"))
375-
376- d.mu.Lock()
377- registered := d.sessions[sess.id]
378- d.mu.Unlock()
379- require.Same(t, sess, registered, "timed-out initial teardown should leave the session for Serve's shutdown pass")
380- select {
381- case <-d.done:
382- case <-time.After(time.Second):
383- t.Fatal("shutdown request did not signal Serve after the initial teardown pass timed out")
384- }
385-}
386-
387-func TestShutdownInterruptsBlockedParticipantSend(t *testing.T) {
388- d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
389- blocked := newTeardownBlockingTransport()
390- active.replaceTransport(blocked)
391- d.attachCoordinator(sess, nil, active, true)
392- active.publishRoleCapability(sess.attachmentToken(active, blocked))
393-
394- token := sess.attachmentToken(active, blocked)
395- sendDone := startBlockedRoleSend(t, token)
396- awaitTestCompletion(t, blocked.sendEntered, "active send did not block")
397-
398- shutdownDone := make(chan bool, 1)
399- go func() { shutdownDone <- d.shutdownAll(ports.ReasonServerShutdown) }()
400- select {
401- case incomplete := <-shutdownDone:
402- require.False(t, incomplete)
403- case <-time.After(500 * time.Millisecond):
404- _ = blocked.Close()
405- t.Fatal("shutdown remained pinned behind admitted participant send")
406- }
407- awaitTestCompletion(t, sendDone, "shutdown-interrupted role ticket did not retire")
408- d.waitNotifies()
409- require.True(t, blocked.Closed())
410- requireRoleGateRetired(t, active)
411-}
412-
413-type renderFailureTransport struct {
414- mu sync.Mutex
415- closed bool
416-}
417-
418-func (*renderFailureTransport) Send(ports.Frame) error { return errors.New("render send failed") }
419-func (*renderFailureTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
420-func (t *renderFailureTransport) Close() error {
421- t.mu.Lock()
422- t.closed = true
423- t.mu.Unlock()
424- return nil
425-}
426-func (t *renderFailureTransport) Closed() bool {
427- t.mu.Lock()
428- defer t.mu.Unlock()
429- return t.closed
430-}
431-
432-func TestRenderSendFailureCleanupRejectsReplacedAndResumedIncarnation(t *testing.T) {
433- d, sess, original, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
434- failedTransport := &renderFailureTransport{}
435- original.replaceTransport(failedTransport)
436- rc := d.attachCoordinator(sess, nil, original, true)
437- originalToken := sess.attachmentToken(original, failedTransport)
438- originalToken.lease = rc.attachmentLease(original)
439- original.publishRoleCapability(originalToken)
440-
441- cleanupStarted := make(chan struct{})
442- releaseCleanup := make(chan struct{})
443- cleanupDone := make(chan struct{})
444- d.beforeRoleSendErrorCleanup = func(token attachmentRoleToken) {
445- require.Equal(t, originalToken.generation, token.generation)
446- require.Equal(t, originalToken.transport, token.transport)
447- require.Same(t, originalToken.lease, token.lease)
448- close(cleanupStarted)
449- <-releaseCleanup
450- }
451- d.afterRoleSendErrorCleanup = func() { close(cleanupDone) }
452-
453- d.paint(sess, original, true, originalToken.lease)
454- awaitTestCompletion(t, cleanupStarted, "render failure cleanup did not retain the captured role token")
455- cleanupTracked := waitGroupDone(&d.attachmentCleanupWg)
456- select {
457- case <-cleanupTracked:
458- t.Fatal("paused render failure cleanup was not tracked by attachmentCleanupWg")
459- default:
460- }
461-
462- replacementTransport := &closeTrackingTransport{}
463- replacement := &attachedClient{tr: replacementTransport, output: newOutputStateStream(), size: original.size}
464- replacement.initOverlays()
465- displaced, err := d.transitionAttachment(attachmentTransitionRequest{
466- target: sess, next: replacement, expectedRole: attachmentDetached, targetRole: attachmentActive,
467- expectedTransport: replacement.transportSnapshot(), ready: true,
468- })
469- require.NoError(t, err)
470-
471- resumed, err := d.transitionAttachment(attachmentTransitionRequest{
472- target: sess, next: original, expectedRole: attachmentSnatched, targetRole: attachmentActive,
473- expectedTransport: original.transportSnapshot(), ready: true,
474- })
475- require.NoError(t, err)
476- resumedGeneration := original.roleGeneration.Load()
477- resumedLease := resumed.published.lease
478- require.NotSame(t, originalToken.lease, resumedLease)
479-
480- close(releaseCleanup)
481- awaitTestCompletion(t, cleanupDone, "render failure cleanup did not finish")
482- awaitTestCompletion(t, cleanupTracked, "tracked render failure cleanup did not retire")
483-
484- sess.mu.Lock()
485- require.Same(t, original, sess.client, "stale render cleanup detached the resumed incarnation")
486- sess.mu.Unlock()
487- require.Equal(t, resumedGeneration, original.roleGeneration.Load())
488- require.True(t, resumed.published.activeCurrent())
489- require.False(t, failedTransport.Closed(), "stale cleanup closed the resumed incarnation's transport")
490-
491- d.deferAttachmentTransitionCleanups(displaced)
492- d.deferAttachmentTransitionCleanups(resumed)
493- d.attachmentCleanupWg.Wait()
494-}
495-
496-func TestRenderSendFailureCleanupIsDeadlineBoundedWhileRoleGateIsBusy(t *testing.T) {
497- d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
498- transport := &closeTrackingTransport{}
499- ac.replaceTransport(transport)
500- rc := d.attachCoordinator(sess, nil, ac, true)
501- token := sess.attachmentToken(ac, transport)
502- token.lease = rc.attachmentLease(ac)
503- ac.publishRoleCapability(token)
504-
505- clock := &signalClock{timers: make(chan *signalTimer, 1)}
506- d.clock = clock
507- frozen := freezeRoleEffectGates(ac)
508- defer frozen.unfreeze()
509- cleanupDone := make(chan struct{})
510- d.afterRoleSendErrorCleanup = func() { close(cleanupDone) }
511-
512- launchCleanup := d.reserveRoleSendErrorCleanup(token, transport)
513- launchCleanup()
514- deadline := awaitTestValue(t, clock.timers, "render cleanup did not arm its deadline")
515- require.Equal(t, detachNotifyTimeout, deadline.duration)
516- deadline.ch <- time.Now()
517- awaitTestCompletion(t, cleanupDone, "deadline did not release blocked render cleanup")
518- awaitTestCompletion(t, waitGroupDone(&d.attachmentCleanupWg), "bounded render cleanup leaked its tracked goroutine")
519-
520- sess.mu.Lock()
521- require.Same(t, ac, sess.client)
522- sess.mu.Unlock()
523- require.False(t, transport.Closed())
524-}
525-
526-func TestRoleEffectGateReplacementInterruptsBlockedOldRenderBeforePublication(t *testing.T) {
527- d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
528- oldTransport := newBlockedRenderReplacementTransport(d, sess)
529- old.replaceTransport(oldTransport)
530- rc := d.attachCoordinator(sess, nil, old, true)
531- lease := rc.attachmentLease(old)
532- token := sess.attachmentToken(old, oldTransport)
533- token.lease = lease
534- old.publishRoleCapability(token)
535-
536- paintDone := make(chan struct{})
537- go func() {
538- d.paint(sess, old, true, lease)
539- close(paintDone)
540- }()
541- <-oldTransport.sendEntered
542-
543- newTransport := &closeTrackingTransport{}
544- next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size}
545- next.initOverlays()
546- transitionDone := make(chan attachmentTransitionResult, 1)
547- transitionErr := make(chan error, 1)
548- go func() {
549- result, err := d.transitionAttachment(attachmentTransitionRequest{
550- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
551- expectedTransport: next.transportSnapshot(), ready: true,
552- })
553- transitionDone <- result
554- transitionErr <- err
555- }()
556-
557- var result attachmentTransitionResult
558- select {
559- case result = <-transitionDone:
560- case <-time.After(time.Second):
561- t.Fatal("replacement publication remained blocked behind the old render send")
562- }
563- require.NoError(t, <-transitionErr)
564- require.True(t, <-oldTransport.locksFree, "transport close ran while an architecture lock was held")
565- select {
566- case <-paintDone:
567- case <-time.After(time.Second):
568- t.Fatal("closing the exact old transport did not unblock its render")
569- }
570- require.Same(t, next, sess.client)
571- require.False(t, newTransport.Closed(), "replacement transport was affected by old-link interruption")
572-
573- d.deferAttachmentTransitionCleanups(result)
574- d.attachmentCleanupWg.Wait()
575- require.Nil(t, old.transport(), "the unhealthy old link was not retired")
576- require.Same(t, next, sess.client)
577- require.False(t, newTransport.Closed())
578-}
579-
580-func TestDisplacedBlockedRenderCleanupWaitsForStaleDetachFreeze(t *testing.T) {
581- d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
582- oldTransport := newBlockedRenderReplacementTransport(d, sess)
583- old.replaceTransport(oldTransport)
584- rc := d.attachCoordinator(sess, nil, old, true)
585- lease := rc.attachmentLease(old)
586- token := sess.attachmentToken(old, oldTransport)
587- token.lease = lease
588- old.publishRoleCapability(token)
589- old.sendMu.Lock()
590- old.captureFrames = map[*pane]capturedPaneRenderState{sess.activeTab().focusedPane(): {}}
591- old.sendMu.Unlock()
592-
593- staleDetachFrozen := make(chan struct{})
594- releaseStaleDetach := make(chan struct{})
595- var releaseOnce sync.Once
596- t.Cleanup(func() { releaseOnce.Do(func() { close(releaseStaleDetach) }) })
597- d.afterDetachRoleEffectsFrozen = func() {
598- close(staleDetachFrozen)
599- <-releaseStaleDetach
600- }
601- cleanupStarted := make(chan struct{})
602- d.afterDisplacedCleanupStarted = func() { close(cleanupStarted) }
603-
604- paintDone := make(chan struct{})
605- go func() {
606- d.paint(sess, old, true, lease)
607- close(paintDone)
608- }()
609- awaitTestCompletion(t, oldTransport.sendEntered, "old render did not block in transport send")
610-
611- newTransport := &closeTrackingTransport{}
612- next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size}
613- next.initOverlays()
614- transitionDone := make(chan attachmentTransitionResult, 1)
615- transitionErr := make(chan error, 1)
616- go func() {
617- result, err := d.transitionAttachment(attachmentTransitionRequest{
618- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
619- expectedTransport: next.transportSnapshot(), ready: true,
620- })
621- transitionDone <- result
622- transitionErr <- err
623- }()
624-
625- var result attachmentTransitionResult
626- select {
627- case result = <-transitionDone:
628- require.NoError(t, <-transitionErr)
629- case <-time.After(time.Second):
630- t.Fatal("replacement did not interrupt the blocked render")
631- }
632- awaitTestCompletion(t, paintDone, "interrupted render did not retire")
633- awaitTestCompletion(t, staleDetachFrozen, "stale send-error detach did not freeze the role gate")
634-
635- d.deferAttachmentTransitionCleanups(result)
636- awaitTestCompletion(t, cleanupStarted, "displaced cleanup did not start during stale detach freeze")
637- releaseOnce.Do(func() { close(releaseStaleDetach) })
638- select {
639- case <-waitGroupDone(&d.attachmentCleanupWg):
640- case <-time.After(time.Second):
641- t.Fatal("displaced cleanup deadlocked behind stale detach")
642- }
643-
644- sess.mu.Lock()
645- require.Same(t, next, sess.client)
646- require.NotContains(t, sess.snatched, old, "closed displaced incarnation remained registered")
647- sess.mu.Unlock()
648- require.Equal(t, attachmentActive, sess.attachmentRole(next))
649- require.Nil(t, old.transport(), "closed displaced transport remained attached")
650- require.Nil(t, old.currentSession(), "terminal displaced cleanup retained session ownership")
651- old.sendMu.Lock()
652- require.Empty(t, old.captureFrames, "terminal displaced cleanup retained captures")
653- old.sendMu.Unlock()
654- require.False(t, newTransport.Closed(), "stale cleanup affected the new active owner")
655-}
656-
657-func TestInterruptedDisplacedCleanupRejectsNewerReclaimedIncarnation(t *testing.T) {
658- d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
659- staleTransport := &closeTrackingTransport{}
660- old.replaceTransport(staleTransport)
661- d.attachCoordinator(sess, nil, old, true)
662-
663- nextTransport := &closeTrackingTransport{}
664- next := &attachedClient{tr: nextTransport, output: newOutputStateStream(), size: old.size}
665- next.initOverlays()
666- displaced, err := d.transitionAttachment(attachmentTransitionRequest{
667- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
668- expectedTransport: next.transportSnapshot(), ready: true,
669- })
670- require.NoError(t, err)
671- stale := displaced.displaced
672-
673- _, err = d.transitionAttachment(attachmentTransitionRequest{
674- target: sess, next: old, expectedRole: attachmentSnatched, targetRole: attachmentActive,
675- expectedTransport: old.transportSnapshot(), ready: true,
676- })
677- require.NoError(t, err)
678- freshTransport := &closeTrackingTransport{}
679- old.replaceTransport(freshTransport)
680- _, err = d.transitionAttachment(attachmentTransitionRequest{
681- target: sess, next: old, expectedRole: attachmentActive, targetRole: attachmentActive,
682- expectedTransport: old.transportSnapshot(), ready: true,
683- })
684- require.NoError(t, err)
685- require.NoError(t, staleTransport.Close())
686-
687- require.False(t, d.cleanupInterruptedSnatchedAttachment(stale))
688- sess.mu.Lock()
689- require.Same(t, old, sess.client)
690- require.Contains(t, sess.snatched, next)
691- sess.mu.Unlock()
692- require.Equal(t, attachmentActive, sess.attachmentRole(old))
693- require.Same(t, freshTransport, old.transport())
694- require.False(t, freshTransport.Closed(), "stale terminal cleanup closed the newer transport")
695- require.False(t, nextTransport.Closed(), "stale terminal cleanup affected the newer displaced owner")
696-}
697-
698-func TestRoleEffectGateAdmittedEffectCompletesBeforeConflictingTransition(t *testing.T) {
699- d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
700- oldTransport := newDatagramTestTransport()
701- old.replaceTransport(oldTransport)
702- rc := d.attachCoordinator(sess, nil, old, true)
703- token := sess.attachmentToken(old, oldTransport)
704- token.lease = rc.attachmentLease(old)
705- old.publishRoleCapability(token)
706-
707- ticket, ok := old.beginRoleEffect(token)
708- require.True(t, ok)
709-
710- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
711- next.initOverlays()
712- done := make(chan error, 1)
713- go func() {
714- _, err := d.transitionAttachment(attachmentTransitionRequest{
715- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
716- expectedTransport: next.transportSnapshot(), ready: true,
717- })
718- done <- err
719- }()
720-
721- select {
722- case err := <-done:
723- t.Fatalf("transition committed before admitted effect ended: %v", err)
724- case <-time.After(20 * time.Millisecond):
725- }
726- ticket.End()
727- require.NoError(t, <-done)
728-}
729-
730-func TestRoleEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *testing.T) {
731- tests := []struct {
732- name string
733- setup func(*Daemon, *session, *attachedClient, *transactionalResizePTY)
734- frame ports.Frame
735- assert func(*testing.T, *attachedClient, *transactionalResizePTY)
736- }{
737- {
738- name: "PTY input",
739- frame: frameInput([]byte("reserved")),
740- assert: func(t *testing.T, _ *attachedClient, pty *transactionalResizePTY) {
741- require.Equal(t, [][]byte{[]byte("reserved")}, pty.writes())
742- },
743- },
744- {
745- name: "mouse input",
746- setup: func(_ *Daemon, sess *session, _ *attachedClient, _ *transactionalResizePTY) {
747- p := sess.activeTab().focusedPane()
748- p.mu.Lock()
749- p.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
750- p.mu.Unlock()
751- },
752- frame: frameInput([]byte("\x1b[<0;1;2M")),
753- assert: func(t *testing.T, _ *attachedClient, pty *transactionalResizePTY) {
754- require.NotEmpty(t, pty.writes())
755- },
756- },
757- {
758- name: "key action",
759- frame: frameInput([]byte("\x1b ")),
760- assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) {
761- require.True(t, ac.overlays.paletteActive())
762- },
763- },
764- {
765- name: "overlay mutation",
766- setup: func(d *Daemon, sess *session, ac *attachedClient, _ *transactionalResizePTY) {
767- d.enterPrompt(sess, ac, "Rename", "", func(string) error { return nil })
768- },
769- frame: frameInput([]byte("x")),
770- assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) {
771- ac.overlays.promptMu.Lock()
772- defer ac.overlays.promptMu.Unlock()
773- require.Equal(t, "x", ac.overlays.prompt.Value())
774- },
775- },
776- {
777- name: "theme",
778- frame: ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(ports.Theme{
779- HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
780- HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
781- })},
782- assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) {
783- require.True(t, ac.getClientTheme().HasFG)
784- },
785- },
786- {
787- name: "ACK",
788- setup: func(_ *Daemon, _ *session, ac *attachedClient, _ *transactionalResizePTY) {
789- ac.output.next = 3
790- },
791- frame: ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: 1})},
792- assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) {
793- ac.sendMu.Lock()
794- defer ac.sendMu.Unlock()
795- require.Equal(t, uint64(1), ac.output.acked)
796- },
797- },
798- }
799-
800- for _, tt := range tests {
801- t.Run(tt.name, func(t *testing.T) {
802- pty := &transactionalResizePTY{}
803- d, sess, old, _ := newManualSessionWithPTYs(t, pty)
804- oldTransport := newDatagramTestTransport()
805- old.replaceTransport(oldTransport)
806- old.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: old}, &d.bindings)
807- rc := d.attachCoordinator(sess, nil, old, true)
808- token := sess.attachmentToken(old, oldTransport)
809- token.lease = rc.attachmentLease(old)
810- if tt.setup != nil {
811- tt.setup(d, sess, old, pty)
812- }
813-
814- admitted := make(chan struct{})
815- release := make(chan struct{})
816- d.afterRoleEffectAdmitted = func(attachmentRoleToken) {
817- close(admitted)
818- <-release
819- }
820- effectDone := make(chan struct{})
821- go func() {
822- d.handleActiveClientFrame(token, tt.frame)
823- close(effectDone)
824- }()
825- <-admitted
826-
827- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
828- next.initOverlays()
829- transitionDone := make(chan error, 1)
830- go func() {
831- _, err := d.transitionAttachment(attachmentTransitionRequest{
832- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
833- expectedTransport: next.transportSnapshot(), ready: true,
834- })
835- transitionDone <- err
836- }()
837- select {
838- case err := <-transitionDone:
839- t.Fatalf("transition overtook admitted effect: %v", err)
840- case <-time.After(20 * time.Millisecond):
841- }
842- close(release)
843- <-effectDone
844- require.NoError(t, <-transitionDone)
845- tt.assert(t, old, pty)
846- })
847- }
848-}
849-
850-func TestJumpAttentionDoesNotMutateSourceAfterInitiatorReplacement(t *testing.T) {
851- d, sess, old, _, releases := newManualTabSession(t, 2)
852- defer releaseAll(releases)
853- sess.mu.Lock()
854- sess.tabs[1].attention = true
855- sess.tabs[1].attentionAt = time.Unix(1, 0)
856- sess.mu.Unlock()
857-
858- oldTransport := old.transport()
859- rc := d.attachCoordinator(sess, nil, old, true)
860- token := sess.attachmentToken(old, oldTransport)
861- token.lease = rc.attachmentLease(old)
862- old.publishRoleCapability(token)
863-
864- admissionEnded := make(chan struct{})
865- releaseAction := make(chan struct{})
866- release := releaseTestGate(t, releaseAction)
867- var admissionEndedOnce sync.Once
868- d.afterActionRoleEffectEnded = func(action string) {
869- if action != "jump-attention" {
870- return
871- }
872- admissionEndedOnce.Do(func() { close(admissionEnded) })
873- <-releaseAction
874- }
875- actionDone := make(chan struct{})
876- go func() {
877- d.handleActiveClientFrame(token, frameInput([]byte("\x1ba")))
878- close(actionDone)
879- }()
880- <-admissionEnded
881-
882- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
883- next.initOverlays()
884- result, err := d.transitionAttachment(attachmentTransitionRequest{
885- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
886- expectedTransport: next.transportSnapshot(), ready: true,
887- })
888- require.NoError(t, err)
889- sess.mu.Lock()
890- sess.active = 0
891- sess.mu.Unlock()
892- release()
893- <-actionDone
894-
895- require.Equal(t, 0, activeTabIndex(sess), "the replaced initiator mutated source focus after losing its role")
896- d.deferAttachmentTransitionCleanups(result)
897-}
898-
899-func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {
900- p1, release1 := newBlockingPTY(t)
901- p2, release2 := newBlockingPTY(t)
902- p3, release3 := newBlockingPTY(t)
903- defer release1()
904- defer release2()
905- defer release3()
906- d, source, ac, _ := newManualSessionWithPTYs(t, p1)
907- target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{
908- newTab(p2, domain.Size{Cols: 80, Rows: 23}),
909- newTab(p3, domain.Size{Cols: 80, Rows: 23}),
910- }}
911- target.tabs[1].attention = true
912- target.tabs[1].attentionAt = time.Unix(1, 0)
913- d.sessions[target.id] = target
914-
915- rc := d.attachCoordinator(source, nil, ac, true)
916- token := source.attachmentToken(ac, ac.transport())
917- token.lease = rc.attachmentLease(ac)
918- ac.publishRoleCapability(token)
919-
920- d.handleActiveClientFrame(token, frameInput([]byte("\x1ba")))
921-
922- require.Same(t, target, ac.currentSession())
923- require.Equal(t, 1, activeTabIndex(target))
924-}
925-
926-func TestJumpAttentionHandoffRevalidatesInitiatorAfterAdmissionEnds(t *testing.T) {
927- p1, release1 := newBlockingPTY(t)
928- p2, release2 := newBlockingPTY(t)
929- p3, release3 := newBlockingPTY(t)
930- defer release1()
931- defer release2()
932- defer release3()
933- d, source, old, _ := newManualSessionWithPTYs(t, p1)
934- target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{
935- newTab(p2, domain.Size{Cols: 80, Rows: 23}),
936- newTab(p3, domain.Size{Cols: 80, Rows: 23}),
937- }}
938- target.tabs[1].attention = true
939- target.tabs[1].attentionAt = time.Unix(1, 0)
940- d.sessions[target.id] = target
941-
942- oldTransport := old.transport()
943- rc := d.attachCoordinator(source, nil, old, true)
944- token := source.attachmentToken(old, oldTransport)
945- token.lease = rc.attachmentLease(old)
946- old.publishRoleCapability(token)
947-
948- admissionEnded := make(chan struct{})
949- releaseAction := make(chan struct{})
950- release := releaseTestGate(t, releaseAction)
951- var admissionEndedOnce sync.Once
952- d.afterActionRoleEffectEnded = func(action string) {
953- if action == "jump-attention" {
954- admissionEndedOnce.Do(func() { close(admissionEnded) })
955- <-releaseAction
956- }
957- }
958- actionDone := make(chan struct{})
959- go func() {
960- d.handleActiveClientFrame(token, frameInput([]byte("\x1ba")))
961- close(actionDone)
962- }()
963- <-admissionEnded
964-
965- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
966- next.initOverlays()
967- result, err := d.transitionAttachment(attachmentTransitionRequest{
968- target: source, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
969- expectedTransport: next.transportSnapshot(), ready: true,
970- })
971- require.NoError(t, err)
972- release()
973- <-actionDone
974-
975- require.Equal(t, 0, activeTabIndex(target), "stale handoff changed target focus")
976- target.mu.Lock()
977- targetClient := target.client
978- target.mu.Unlock()
979- require.Nil(t, targetClient, "stale handoff attached the replaced initiator")
980- d.deferAttachmentTransitionCleanups(result)
981-}
982-
983-func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T) {
984- p, releasePTY := newBlockingPTY(t)
985- defer releasePTY()
986- d, sess, old, _ := newManualSessionWithPTYs(t, p)
987- d.enterPicker(sess, old)
988-
989- oldTransport := old.transport()
990- rc := d.attachCoordinator(sess, nil, old, true)
991- token := sess.attachmentToken(old, oldTransport)
992- token.lease = rc.attachmentLease(old)
993- old.publishRoleCapability(token)
994-
995- admissionEnded := make(chan struct{})
996- releaseAction := make(chan struct{})
997- release := releaseTestGate(t, releaseAction)
998- var admissionEndedOnce sync.Once
999- d.afterActionRoleEffectEnded = func(action string) {
1000- if action != "picker-delete" {
1001- return
1002- }
1003- admissionEndedOnce.Do(func() { close(admissionEnded) })
1004- <-releaseAction
1005- }
1006- actionDone := make(chan struct{})
1007- go func() {
1008- d.handleActiveClientFrame(token, frameInput([]byte("x")))
1009- close(actionDone)
1010- }()
1011- <-admissionEnded
1012-
1013- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
1014- next.initOverlays()
1015- result, err := d.transitionAttachment(attachmentTransitionRequest{
1016- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
1017- expectedTransport: next.transportSnapshot(), ready: true,
1018- })
1019- require.NoError(t, err)
1020- release()
1021- <-actionDone
1022-
1023- d.mu.Lock()
1024- registered := d.sessions[sess.id]
1025- d.mu.Unlock()
1026- require.Same(t, sess, registered, "the replaced initiator deleted its former source session")
1027- d.deferAttachmentTransitionCleanups(result)
1028-}
1029-
1030-func TestPickerDeleteOtherSessionRevalidatesInitiatorAfterAdmissionEnds(t *testing.T) {
1031- p1, release1 := newBlockingPTY(t)
1032- p2, release2 := newBlockingPTY(t)
1033- defer release1()
1034- defer release2()
1035- d, source, old, _ := newManualSessionWithPTYs(t, p1)
1036- source.name = "source"
1037- target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}}
1038- d.sessions[target.id] = target
1039- d.enterPicker(source, old)
1040- old.overlays.pickerMu.Lock()
1041- for {
1042- selected, ok := old.overlays.picker.Selected()
1043- if ok && selected.Session == target.id {
1044- break
1045- }
1046- old.overlays.picker.Down()
1047- }
1048- old.overlays.pickerMu.Unlock()
1049-
1050- oldTransport := old.transport()
1051- rc := d.attachCoordinator(source, nil, old, true)
1052- token := source.attachmentToken(old, oldTransport)
1053- token.lease = rc.attachmentLease(old)
1054- old.publishRoleCapability(token)
1055-
1056- admissionEnded := make(chan struct{})
1057- releaseAction := make(chan struct{})
1058- release := releaseTestGate(t, releaseAction)
1059- var admissionEndedOnce sync.Once
1060- d.afterActionRoleEffectEnded = func(action string) {
1061- if action == "picker-delete" {
1062- admissionEndedOnce.Do(func() { close(admissionEnded) })
1063- <-releaseAction
1064- }
1065- }
1066- actionDone := make(chan struct{})
1067- go func() {
1068- d.handleActiveClientFrame(token, frameInput([]byte("x")))
1069- close(actionDone)
1070- }()
1071- <-admissionEnded
1072-
1073- d.mu.Lock()
1074- _, targetStillRegistered := d.sessions[target.id]
1075- d.mu.Unlock()
1076- require.True(t, targetStillRegistered, "picker deletion mutated the target before source-token preflight")
1077-
1078- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
1079- next.initOverlays()
1080- result, err := d.transitionAttachment(attachmentTransitionRequest{
1081- target: source, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
1082- expectedTransport: next.transportSnapshot(), ready: true,
1083- })
1084- require.NoError(t, err)
1085- release()
1086- <-actionDone
1087- d.mu.Lock()
1088- _, targetStillRegistered = d.sessions[target.id]
1089- d.mu.Unlock()
1090- require.True(t, targetStillRegistered, "stale picker deletion mutated the target session")
1091- d.deferAttachmentTransitionCleanups(result)
1092-}
1093-
1094-func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) {
1095- p, release := newBlockingPTY(t)
1096- defer release()
1097- d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1098- d.enterPicker(sess, ac)
1099- rc := d.attachCoordinator(sess, nil, ac, true)
1100- token := sess.attachmentToken(ac, ac.transport())
1101- token.lease = rc.attachmentLease(ac)
1102- ac.publishRoleCapability(token)
1103-
1104- d.handleActiveClientFrame(token, frameInput([]byte("x")))
1105-
1106- d.mu.Lock()
1107- _, registered := d.sessions[sess.id]
1108- d.mu.Unlock()
1109- require.False(t, registered)
1110-}
1111-
1112-func TestDelayedKeyCallbackAdmittedBeforeReplacementCompletesFirst(t *testing.T) {
1113- writes := make(chan []byte, 1)
1114- pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }}
1115- d, sess, old, _ := newManualSessionWithPTYs(t, pty)
1116- clock := &signalClock{timers: make(chan *signalTimer, 1)}
1117- d.clock = clock
1118- oldTransport := newDatagramTestTransport()
1119- old.replaceTransport(oldTransport)
1120- old.keys = keys.NewRouter(clock, daemonKeyHandler{d: d, ac: old}, &d.bindings)
1121- rc := d.attachCoordinator(sess, nil, old, true)
1122- token := sess.attachmentToken(old, oldTransport)
1123- token.lease = rc.attachmentLease(old)
1124- old.publishRoleCapability(token)
1125-
1126- d.handleActiveClientFrame(token, frameInput([]byte{keys.ESC}))
1127- timer := <-clock.timers
1128- admitted := make(chan struct{})
1129- release := make(chan struct{})
1130- d.afterRoleEffectAdmitted = func(attachmentRoleToken) {
1131- close(admitted)
1132- <-release
1133- }
1134- timer.ch <- time.Time{}
1135- select {
1136- case <-admitted:
1137- case <-time.After(time.Second):
1138- t.Fatal("delayed key callback did not acquire a fresh role-effect ticket")
1139- }
1140-
1141- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
1142- next.initOverlays()
1143- transitionDone := make(chan error, 1)
1144- go func() {
1145- _, err := d.transitionAttachment(attachmentTransitionRequest{
1146- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
1147- expectedTransport: next.transportSnapshot(), ready: true,
1148- })
1149- transitionDone <- err
1150- }()
1151- select {
1152- case err := <-transitionDone:
1153- t.Fatalf("replacement overtook admitted delayed key callback: %v", err)
1154- case <-time.After(20 * time.Millisecond):
1155- }
1156- close(release)
1157- require.Equal(t, []byte{keys.ESC}, <-writes)
1158- require.NoError(t, <-transitionDone)
1159-}
1160-
1161-func TestDelayedKeyCallbackAfterReplacementDropsStaleWork(t *testing.T) {
1162- writes := make(chan []byte, 1)
1163- pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }}
1164- d, sess, old, _ := newManualSessionWithPTYs(t, pty)
1165- clock := &signalClock{timers: make(chan *signalTimer, 1)}
1166- d.clock = clock
1167- oldTransport := newDatagramTestTransport()
1168- old.replaceTransport(oldTransport)
1169- old.keys = keys.NewRouter(clock, daemonKeyHandler{d: d, ac: old}, &d.bindings)
1170- rc := d.attachCoordinator(sess, nil, old, true)
1171- token := sess.attachmentToken(old, oldTransport)
1172- token.lease = rc.attachmentLease(old)
1173- old.publishRoleCapability(token)
1174-
1175- d.handleActiveClientFrame(token, frameInput([]byte{keys.ESC}))
1176- timer := <-clock.timers
1177- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
1178- next.initOverlays()
1179- _, err := d.transitionAttachment(attachmentTransitionRequest{
1180- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
1181- expectedTransport: next.transportSnapshot(), ready: true,
1182- })
1183- require.NoError(t, err)
1184-
1185- attempted := make(chan bool, 1)
1186- d.afterDelayedKeyEffectAttempt = func(admitted bool) { attempted <- admitted }
1187- timer.ch <- time.Time{}
1188- require.False(t, <-attempted, "stale delayed callback acquired authority after replacement")
1189- select {
1190- case got := <-writes:
1191- t.Fatalf("stale delayed key callback reached the PTY: %q", got)
1192- default:
1193- }
1194-}
1195-
1196-func TestRoleEffectGateAdmittedSnatchedPanelFinishesBeforePromotion(t *testing.T) {
1197- d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
1198- oldTransport := newDatagramTestTransport()
1199- old.replaceTransport(oldTransport)
1200- d.attachCoordinator(sess, nil, old, true)
1201-
1202- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
1203- next.initOverlays()
1204- replaced, err := d.transitionAttachment(attachmentTransitionRequest{
1205- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
1206- expectedTransport: next.transportSnapshot(), ready: true,
1207- })
1208- require.NoError(t, err)
1209- token := replaced.displaced
1210-
1211- admitted := make(chan struct{})
1212- releaseGate := make(chan struct{})
1213- release := releaseTestGate(t, releaseGate)
1214- afterFrozen := make(chan struct{})
1215- d.afterRoleEffectAdmitted = func(attachmentRoleToken) {
1216- close(admitted)
1217- <-releaseGate
1218- }
1219- d.afterRoleEffectGateFrozen = func(action string, ac *attachedClient) {
1220- if action == "" && ac == old {
1221- close(afterFrozen)
1222- }
1223- }
1224- effectDone := make(chan struct{})
1225- wantSize := domain.Size{Cols: 100, Rows: 30}
1226- go func() {
1227- d.handleSnatchedClientFrame(token, ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: wantSize})})
1228- close(effectDone)
1229- }()
1230- <-admitted
1231-
1232- transitionDone := make(chan error, 1)
1233- go func() {
1234- _, err := d.transitionAttachment(attachmentTransitionRequest{
1235- target: sess, next: old, expectedRole: attachmentSnatched, targetRole: attachmentActive,
1236- expectedTransport: old.transportSnapshot(), ready: true,
1237- })
1238- transitionDone <- err
1239- }()
1240- <-afterFrozen
1241- select {
1242- case err := <-transitionDone:
1243- t.Fatalf("promotion overtook admitted snatched panel: %v", err)
1244- default:
1245- }
1246- release()
1247- <-effectDone
1248- require.NoError(t, <-transitionDone)
1249- old.sendMu.Lock()
1250- gotSize := old.size
1251- old.sendMu.Unlock()
1252- require.Equal(t, wantSize, gotSize)
1253-}
1254-
1255-func TestRoleEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *testing.T) {
1256- d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
1257- oldTransport := newDatagramTestTransport()
1258- old.replaceTransport(oldTransport)
1259- rc := d.attachCoordinator(sess, nil, old, true)
1260- token := sess.attachmentToken(old, oldTransport)
1261- token.lease = rc.attachmentLease(old)
1262-
1263- admitted := make(chan struct{})
1264- release := make(chan struct{})
1265- d.afterRoleEffectAdmitted = func(attachmentRoleToken) {
1266- close(admitted)
1267- <-release
1268- }
1269- paintDone := make(chan bool, 1)
1270- go func() { paintDone <- d.firstPaintForTransition(token) }()
1271- <-admitted
1272-
1273- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
1274- next.initOverlays()
1275- transitionDone := make(chan error, 1)
1276- go func() {
1277- _, err := d.transitionAttachment(attachmentTransitionRequest{
1278- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
1279- expectedTransport: next.transportSnapshot(), ready: true,
1280- })
1281- transitionDone <- err
1282- }()
1283- select {
1284- case err := <-transitionDone:
1285- t.Fatalf("replacement overtook admitted first paint: %v", err)
1286- case <-time.After(20 * time.Millisecond):
1287- }
1288- close(release)
1289- require.True(t, <-paintDone)
1290- require.NoError(t, <-transitionDone)
1291-}
1292-
1293-func TestRoleEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testing.T) {
1294- d, first, a, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
1295- aTransport := newDatagramTestTransport()
1296- a.replaceTransport(aTransport)
1297- d.attachCoordinator(first, nil, a, true)
1298- first.attachmentToken(a, aTransport)
1299-
1300- bTransport := newDatagramTestTransport()
1301- b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size}
1302- b.initOverlays()
1303- second := &session{sessionCore: sessionCore{id: "second", name: "second", client: b, snatched: make(map[*attachedClient]struct{})}}
1304- b.setSession(second)
1305- d.mu.Lock()
1306- d.sessions[second.id] = second
1307- d.mu.Unlock()
1308- d.attachCoordinator(second, nil, b, true)
1309- second.attachmentToken(b, bTransport)
1310-
1311- start := make(chan struct{})
1312- results := make(chan error, 2)
1313- go func() {
1314- <-start
1315- _, err := d.transitionAttachment(attachmentTransitionRequest{
1316- source: first, target: second, next: a,
1317- expectedRole: attachmentActive, targetRole: attachmentActive,
1318- expectedTransport: a.transportSnapshot(), ready: true,
1319- })
1320- results <- err
1321- }()
1322- go func() {
1323- <-start
1324- _, err := d.transitionAttachment(attachmentTransitionRequest{
1325- source: second, target: first, next: b,
1326- expectedRole: attachmentActive, targetRole: attachmentActive,
1327- expectedTransport: b.transportSnapshot(), ready: true,
1328- })
1329- results <- err
1330- }()
1331- close(start)
1332-
1333- errs := make([]error, 0, 2)
1334- for range 2 {
1335- select {
1336- case err := <-results:
1337- errs = append(errs, err)
1338- case <-time.After(time.Second):
1339- t.Fatal("reversed transitions deadlocked")
1340- }
1341- }
1342- successes := 0
1343- for _, err := range errs {
1344- if err == nil {
1345- successes++
1346- }
1347- }
1348- require.Equal(t, 1, successes)
1349-}
1350-
1351-func TestPickerDeleteReversedWithTargetTransitionDoesNotDeadlock(t *testing.T) {
1352- d, source, a, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
1353- aTransport := newDatagramTestTransport()
1354- a.replaceTransport(aTransport)
1355- aCoordinator := d.attachCoordinator(source, nil, a, true)
1356-
1357- bTransport := newDatagramTestTransport()
1358- b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size}
1359- b.initOverlays()
1360- target := &session{sessionCore: sessionCore{id: "picker-delete-target", name: "picker-delete-target",
1361- client: b, snatched: make(map[*attachedClient]struct{})}, ctx: source.ctx, cancel: func() {},
1362- }
1363- b.setSession(target)
1364- d.mu.Lock()
1365- d.sessions[target.id] = target
1366- d.mu.Unlock()
1367- d.attachCoordinator(target, nil, b, true)
1368-
1369- // Give B the earlier immutable identity. The reverse transition therefore
1370- // freezes B then A, while the old picker-delete path froze A separately
1371- // before discovering and freezing B.
1372- b.roleEffects.immutableOrder()
1373- a.roleEffects.immutableOrder()
1374- require.Less(t, b.roleEffects.order.Load(), a.roleEffects.order.Load())
1375-
1376- token := source.attachmentToken(a, aTransport)
1377- token.lease = aCoordinator.attachmentLease(a)
1378- a.publishRoleCapability(token)
1379- effect, admitted := a.beginRoleEffect(token)
1380- require.True(t, admitted)
1381- token = effect.roleToken()
1382-
1383- deleteDiscovered := make(chan []*attachedClient, 1)
1384- transitionDiscovered := make(chan []*attachedClient, 1)
1385- releaseDelete := make(chan struct{})
1386- releaseTransition := make(chan struct{})
1387- transitionBFrozen := make(chan struct{})
1388- deleteFrozen := make(chan *attachedClient, 2)
1389- d.afterRoleEffectParticipantsSnapshotted = func(action string, participants []*attachedClient) {
1390- snapshot := append([]*attachedClient(nil), participants...)
1391- switch action {
1392- case "picker-delete":
1393- deleteDiscovered <- snapshot
1394- <-releaseDelete
1395- case "reverse-delete-transition":
1396- transitionDiscovered <- snapshot
1397- <-releaseTransition
1398- }
1399- }
1400- d.afterRoleEffectGateFrozen = func(action string, ac *attachedClient) {
1401- switch action {
1402- case "reverse-delete-transition":
1403- if ac == b {
1404- close(transitionBFrozen)
1405- }
1406- case "picker-delete":
1407- deleteFrozen <- ac
1408- }
1409- }
1410-
1411- deleteDone := make(chan error, 1)
1412- go func() {
1413- deleteDone <- d.killPickerTargetForRole(picker.Target{Session: target.id}, token)
1414- }()
1415- deleteParticipants := <-deleteDiscovered
1416- require.ElementsMatch(t, []*attachedClient{a, b}, deleteParticipants)
1417-
1418- transitionDone := make(chan error, 1)
1419- go func() {
1420- result, err := d.transitionAttachment(attachmentTransitionRequest{
1421- source: target, target: source, next: b,
1422- expectedRole: attachmentActive, targetRole: attachmentActive,
1423- expectedTransport: b.transportSnapshot(), action: "reverse-delete-transition", ready: true,
1424- })
1425- if err == nil {
1426- d.deferAttachmentTransitionCleanups(result)
1427- }
1428- transitionDone <- err
1429- }()
1430- transitionParticipants := <-transitionDiscovered
1431- require.ElementsMatch(t, []*attachedClient{a, b}, transitionParticipants)
1432-
1433- close(releaseTransition)
1434- select {
1435- case <-transitionBFrozen:
1436- case <-time.After(time.Second):
1437- t.Fatal("reverse transition did not freeze B first")
1438- }
1439- close(releaseDelete)
1440-
1441- select {
1442- case err := <-transitionDone:
1443- require.NoError(t, err)
1444- case <-time.After(time.Second):
1445- t.Fatal("reverse transition deadlocked with picker deletion")
1446- }
1447- select {
1448- case err := <-deleteDone:
1449- require.NoError(t, err)
1450- case <-time.After(time.Second):
1451- t.Fatal("picker deletion deadlocked with reverse transition")
1452- }
1453- frozenByDelete := make([]*attachedClient, 0, 2)
1454- for range 2 {
1455- select {
1456- case ac := <-deleteFrozen:
1457- frozenByDelete = append(frozenByDelete, ac)
1458- case <-time.After(time.Second):
1459- t.Fatal("picker deletion did not freeze every participant")
1460- }
1461- }
1462- require.ElementsMatch(t, []*attachedClient{a, b}, frozenByDelete, "picker deletion must freeze each deduplicated participant exactly once")
1463- require.Empty(t, deleteFrozen)
1464-
1465- d.mu.Lock()
1466- registered := d.sessions[target.id]
1467- d.mu.Unlock()
1468- require.Same(t, target, registered, "stale picker deletion mutated its target lifecycle")
1469- require.Equal(t, attachmentSnatched, source.attachmentRole(a))
1470- require.Equal(t, attachmentActive, source.attachmentRole(b))
1471-}
1472-
1473-func TestRoleEffectGateFrozenTransitionRejectsLateEffect(t *testing.T) {
1474- d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
1475- oldTransport := newDatagramTestTransport()
1476- old.replaceTransport(oldTransport)
1477- rc := d.attachCoordinator(sess, nil, old, true)
1478- token := sess.attachmentToken(old, oldTransport)
1479- token.lease = rc.attachmentLease(old)
1480- old.publishRoleCapability(token)
1481-
1482- frozen := make(chan struct{})
1483- release := make(chan struct{})
1484- d.afterRoleEffectsFrozen = func() {
1485- close(frozen)
1486- <-release
1487- }
1488-
1489- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
1490- next.initOverlays()
1491- done := make(chan error, 1)
1492- go func() {
1493- _, err := d.transitionAttachment(attachmentTransitionRequest{
1494- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
1495- expectedTransport: next.transportSnapshot(), ready: true,
1496- })
1497- done <- err
1498- }()
1499- <-frozen
1500-
1501- _, ok := old.beginRoleEffect(token)
1502- require.False(t, ok, "a frozen capability must admit no new effect")
1503- close(release)
1504- require.NoError(t, <-done)
1505-}
M internal/usecase/daemon/screen_output_stream.go +1 -0
@@ -184,6 +184,7 @@ func (p *preparedStructuredOutput) send(sender func(ports.Frame) error) error {
184184 }
185185 p.commit()
186186 p.stream.state.next = p.next
187+ p.stream.state.publishOutstanding()
187188 return nil
188189 }
189190
M internal/usecase/daemon/session.go +171 -166
@@ -51,14 +51,13 @@ type session struct {
5151 teardownWaiters uint
5252 teardownChanged chan struct{}
5353 lifecycleStopOnce sync.Once
54- // sessionCore.mu guards tabs, active, attachment membership, restoreDone,
55- // clipFiles, and clipboard queue state.
54+ // sessionCore.mu guards tabs, attachment membership, restoreDone, clipFiles,
55+ // and clipboard queue state.
5656 // restoreDone remains attached to a restored session after it is published in
5757 // the live registry, so racing attaches cannot bypass restoration completion.
5858 restoreDone chan struct{}
5959 themeMu sync.Mutex
6060 tabs []*tab
61- active int
6261 clipboardQueue []clipboardForward
6362 clipboardWorkerRunning bool
6463 cwd string
@@ -242,6 +241,9 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st
242241 }
243242 }
244243
244+ if d.ptys == nil {
245+ return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("daemon: PTY factory is unavailable"))
246+ }
245247 tbSize := contentSize(sz, proxied)
246248 var names []string
247249 if len(restoredTabNames) > 0 {
@@ -416,27 +418,26 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
416418 cwd := from.cwd
417419 term := from.terminal
418420 env := copyEnvironment(from.env)
419- if from.client != ac {
420- from.mu.Unlock()
421+ _, attached := from.attachments[ac]
422+ from.mu.Unlock()
423+ if !attached {
421424 d.mu.Unlock()
422425 return errors.New("client detached")
423426 }
424- from.mu.Unlock()
425427
426428 newSess, err := d.createSessionLockedWithMode(name, false, cwd, sz, term, env, ac.proxied)
427429 d.mu.Unlock()
428430 if err != nil {
429431 return err
430432 }
431- // The newly created target is registered before the role transition, but
433+ // The newly created target is registered before the attachment transition, but
432434 // remains headless. Freeze/drain must occur with no daemon/routing/session
433435 // lock held; the transition revalidates both lifecycles before publication.
434436 transition, err := d.transitionAttachment(attachmentTransitionRequest{
435- source: from,
436- target: newSess,
437- next: ac,
438- expectedRole: attachmentActive,
439- targetRole: attachmentActive,
437+ source: from,
438+ target: newSess,
439+ next: ac,
440+
440441 expectedTransport: ac.transportSnapshot(),
441442 ready: true,
442443 })
@@ -453,16 +454,16 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
453454 return nil
454455 }
455456
456-// createSessionAndSwitchForRole creates no target until the initiating role
457+// createSessionAndSwitchForAttachment creates no target until the initiating role
457458 // ticket has ended and the exact source capability is frozen and revalidated.
458-func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name string) error {
459+func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionToken, name string) error {
459460 if name == "" {
460461 return errSessionNameRequired
461462 }
462463 if err := domain.ValidateSessionName(name); err != nil {
463464 return err
464465 }
465- if token.sess == nil || token.ac == nil || token.effect == nil || token.role != attachmentActive {
466+ if token.sess == nil || token.ac == nil || token.effect == nil {
466467 return errAttachmentTransition
467468 }
468469 token.effect.bindActionEnd(d, "create-session")
@@ -471,7 +472,7 @@ func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name s
471472 var created *session
472473 transition, err := d.transitionAttachment(attachmentTransitionRequest{
473474 source: token.sess, next: token.ac,
474- expectedRole: attachmentActive, targetRole: attachmentActive,
475+
475476 expectedTransport: token.transport, sourceToken: &token, action: "create-session",
476477 copySourceEnvironment: true, ready: true,
477478 createTargetLocked: func() (*session, error) {
@@ -511,14 +512,26 @@ func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name s
511512 }
512513
513514 func (d *Daemon) createTab(sess *session, sz domain.Size) error {
515+ return d.createTabForAttachment(sess, nil, sz)
516+}
517+
518+func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, sz domain.Size) error {
514519 sess.mu.Lock()
515520 name := sess.name
516521 cwd := sess.cwd
517- client := sess.client
518522 term := sess.terminal
519523 env := copyEnvironment(sess.env)
524+ attachments := sess.snapshotAttachmentsLocked()
520525 sess.mu.Unlock()
521- tbSize := contentSize(sz, client != nil && client.proxied)
526+ // A client-requested tab must use that client's negotiated viewport and
527+ // theme. Only the explicitly headless helper path falls back to the stable
528+ // first attachment snapshot.
529+ themeClient := ac
530+ if themeClient == nil && len(attachments) != 0 {
531+ themeClient = attachments[0]
532+ }
533+ proxied := themeClient != nil && themeClient.proxied
534+ tbSize := contentSize(sz, proxied)
522535 tabStableID, paneStableID, err := d.newTabPaneStableIDs()
523536 if err != nil {
524537 return err
@@ -535,8 +548,8 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
535548 return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err)
536549 }
537550 tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
538- if client != nil {
539- t := d.effectiveTheme(client.getClientTheme())
551+ if themeClient != nil {
552+ t := d.effectiveTheme(themeClient.getClientTheme())
540553 tb.mu.Lock()
541554 p := tb.focusedPane()
542555 tb.mu.Unlock()
@@ -566,11 +579,13 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
566579 // Publish ownership before appending the tab to the visible session.
567580 publishPaneOwner(p, sess, tb, 0)
568581 sess.tabs = append(sess.tabs, tb)
569- sess.active = len(sess.tabs) - 1
570- tabIndex := sess.active
582+ tabIndex := len(sess.tabs) - 1
571583 if !sess.ephemeral {
572584 d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
573585 }
586+ if ac != nil {
587+ sess.activateAttachmentViewLocked(ac, tabIndex)
588+ }
574589 sess.mu.Unlock()
575590 d.mu.Unlock()
576591 d.log.Info("tab created", "session", name, "tab", tabIndex)
@@ -661,8 +676,8 @@ func contentSize(clientSize domain.Size, proxied bool) domain.Size {
661676 func (s *session) fullViewportSize() domain.Size {
662677 s.mu.Lock()
663678 var tb *tab
664- if s.active >= 0 && s.active < len(s.tabs) {
665- tb = s.tabs[s.active]
679+ if len(s.tabs) != 0 {
680+ tb = s.tabs[0]
666681 }
667682 s.mu.Unlock()
668683
@@ -698,7 +713,8 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
698713 return
699714 }
700715 if owner.session != sess || owner.tab != tb {
701- panic("daemon: starting pane reader for a different published owner")
716+ d.log.Warn("pane owner mismatch", "pane", p.id)
717+ return
702718 }
703719 sess.mu.Lock()
704720 name := sess.name
@@ -709,21 +725,18 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
709725 go d.readPanePTY(p)
710726 }
711727
712-// attachClient makes ac the session's current client, displacing any prior one
713-// (which is notified with ReasonDetach and disconnected — its own conn handler
714-
715728 func (s *session) detachIfCurrent(ac *attachedClient) bool {
716729 s.mu.Lock()
717730 defer s.mu.Unlock()
718- if s.client == ac {
719- s.client = nil
720- ac.setSession(nil)
721- return true
731+ if !attachmentRegisteredLocked(s, ac) || ac.currentAttachmentSession() != s {
732+ return false
722733 }
723- return false
734+ s.unregisterAttachmentLocked(ac)
735+ ac.setSession(nil)
736+ return true
724737 }
725738
726-// detachIfCurrent publishes terminal role invalidation through the attachment
739+// detachIfCurrent publishes terminal attachment invalidation through the attachment
727740 // gate. Freeze/drain happens before routing or session locks are acquired.
728741 func (d *Daemon) detachIfCurrent(sess *session, ac *attachedClient) bool {
729742 return d.detachIfCurrentTransport(sess, ac, transportSnapshot{})
@@ -736,22 +749,22 @@ func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, exp
736749 if sess == nil || ac == nil {
737750 return false
738751 }
739- frozen := freezeRoleEffectGates(ac)
752+ frozen := freezeAttachmentEffectGates(ac)
740753 defer frozen.unfreeze()
741- if d.afterDetachRoleEffectsFrozen != nil {
742- d.afterDetachRoleEffectsFrozen()
754+ if d.afterDetachAttachmentEffectsFrozen != nil {
755+ d.afterDetachAttachmentEffectsFrozen()
743756 }
744757 d.notices.routingMu.Lock()
745758 sess.mu.Lock()
746- current := sess.client == ac && ac.currentSession() == sess
759+ current := attachmentRegisteredLocked(sess, ac) && ac.currentAttachmentSession() == sess
747760 if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) {
748761 current = false
749762 }
750763 if current {
751- sess.client = nil
752- ac.roleGeneration.Add(1)
764+ sess.unregisterAttachmentLocked(ac)
765+ ac.connectionGeneration.Add(1)
753766 ac.setSession(nil)
754- ac.invalidateFrozenRoleCapability()
767+ ac.invalidateFrozenAttachmentCapability()
755768 }
756769 sess.mu.Unlock()
757770 d.notices.routingMu.Unlock()
@@ -766,22 +779,22 @@ func (d *Daemon) detachProxyIfCurrentTransport(p *proxySession, ac *attachedClie
766779 if p == nil || ac == nil {
767780 return false
768781 }
769- frozen := freezeRoleEffectGates(ac)
782+ frozen := freezeAttachmentEffectGates(ac)
770783 defer frozen.unfreeze()
771- if d.afterDetachRoleEffectsFrozen != nil {
772- d.afterDetachRoleEffectsFrozen()
784+ if d.afterDetachAttachmentEffectsFrozen != nil {
785+ d.afterDetachAttachmentEffectsFrozen()
773786 }
774787 d.notices.routingMu.Lock()
775788 p.sessionCore.mu.Lock()
776- current := p.client == ac && ac.currentAttachmentSession() == attachmentSession(p)
789+ current := attachmentRegisteredLocked(attachmentSession(p), ac) && ac.currentAttachmentSession() == attachmentSession(p)
777790 if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) {
778791 current = false
779792 }
780793 if current {
781- p.client = nil
782- ac.roleGeneration.Add(1)
794+ unregisterAttachmentSessionLocked(attachmentSession(p), ac)
795+ ac.connectionGeneration.Add(1)
783796 ac.setSession(nil)
784- ac.invalidateFrozenRoleCapability()
797+ ac.invalidateFrozenAttachmentCapability()
785798 }
786799 p.sessionCore.mu.Unlock()
787800 d.notices.routingMu.Unlock()
@@ -791,24 +804,36 @@ func (d *Daemon) detachProxyIfCurrentTransport(p *proxySession, ac *attachedClie
791804 return current
792805 }
793806
794-// detachIfRoleCurrent invalidates only the exact role generation and transport
795-// whose admitted send failed. A queued stale error cannot detach a later role
796-// publication or a rebound transport on the same attachment object.
797-func (d *Daemon) detachIfRoleCurrent(token attachmentRoleToken) bool {
798- return d.detachIfRoleCurrentUntil(token, nil)
807+// detachIfAttachmentCurrent invalidates only the exact attachment generation and transport
808+// whose admitted send failed. A queued stale error cannot detach a later
809+// attachment publication or a rebound transport on the same attachment object.
810+func (d *Daemon) detachIfAttachmentCurrent(token attachmentConnectionToken) bool {
811+ return d.detachIfAttachmentCurrentUntil(token, nil)
799812 }
800813
801-func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func() <-chan struct{}) bool {
802- if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.transport.transport == nil {
814+func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken, done func() <-chan struct{}) bool {
815+ if token.sess == nil || token.ac == nil || token.transport.transport == nil {
803816 return false
804817 }
805- frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{done: done}, token.ac)
818+ // Attachments without the session's optional primary render lease still
819+ // own an independent connection lifecycle and detach through the same exact
820+ // transport/generation fence.
821+ if token.lease == nil {
822+ if sess, ok := localSession(token.sess); ok {
823+ return d.detachIfCurrentTransport(sess, token.ac, token.transport)
824+ }
825+ if proxy, ok := token.sess.(*proxySession); ok {
826+ return d.detachProxyIfCurrentTransport(proxy, token.ac, token.transport)
827+ }
828+ return false
829+ }
830+ frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac)
806831 defer frozen.unfreeze()
807832 if !frozen.acquired || !frozen.drained {
808833 return false
809834 }
810- if d.afterDetachRoleEffectsFrozen != nil {
811- d.afterDetachRoleEffectsFrozen()
835+ if d.afterDetachAttachmentEffectsFrozen != nil {
836+ d.afterDetachAttachmentEffectsFrozen()
812837 }
813838 d.mu.Lock()
814839 core := token.sess.core()
@@ -823,15 +848,14 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func()
823848 coordinator.mu.Lock()
824849 }
825850 req := attachmentTransitionRequest{
826- source: token.sess, next: token.ac, expectedRole: token.role,
827- expectedTransport: token.transport,
851+ source: token.sess, next: token.ac, expectedTransport: token.transport,
828852 }
829853 current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
830854 if current {
831- core.client = nil
832- token.ac.roleGeneration.Add(1)
855+ unregisterAttachmentSessionLocked(token.sess, token.ac)
856+ token.ac.connectionGeneration.Add(1)
833857 token.ac.setSession(nil)
834- token.ac.invalidateFrozenRoleCapability()
858+ token.ac.invalidateFrozenAttachmentCapability()
835859 }
836860 if coordinator != nil {
837861 coordinator.mu.Unlock()
@@ -842,39 +866,6 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func()
842866 return current
843867 }
844868
845-func (s *session) activeTab() *tab {
846- s.mu.Lock()
847- defer s.mu.Unlock()
848- if s.active < 0 || s.active >= len(s.tabs) {
849- return nil
850- }
851- return s.tabs[s.active]
852-}
853-
854-func (s *session) switchTab(idx int) bool {
855- s.mu.Lock()
856- if idx < 0 || idx >= len(s.tabs) || idx == s.active {
857- s.mu.Unlock()
858- return false
859- }
860- s.active = idx
861- s.mu.Unlock()
862- markSnapshotDirty(s)
863- return true
864-}
865-
866-func (s *session) switchRelative(delta int) bool {
867- s.mu.Lock()
868- if len(s.tabs) < 2 {
869- s.mu.Unlock()
870- return false
871- }
872- s.active = (s.active + delta + len(s.tabs)) % len(s.tabs)
873- s.mu.Unlock()
874- markSnapshotDirty(s)
875- return true
876-}
877-
878869 func (d *Daemon) renameSession(sess *session, name string) error {
879870 if name == "" {
880871 return errSessionNameRequired
@@ -1007,7 +998,24 @@ func (s *session) persistRecordLocked(updatedAt int64) domain.CatalogueRecord {
1007998 return domain.CatalogueRecord{Name: s.name, IncarnationID: s.incarnation, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames}
1008999 }
10091000
1001+// closeTab is the public close boundary for callers that are not already
1002+// inside session.runMutation. Mutation dispatchers use closeTabLocked.
10101003 func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
1004+ if sess == nil {
1005+ return layout.ErrNotFound
1006+ }
1007+ sess.dispatchMu.Lock()
1008+ defer sess.dispatchMu.Unlock()
1009+ return d.closeTabLocked(sess, tb, repaint)
1010+}
1011+
1012+// closeTabLocked requires sess.dispatchMu before entering daemon or session
1013+// locks. Existing runMutation callers use it to avoid nested dispatch locking.
1014+func (d *Daemon) closeTabLocked(sess *session, tb *tab, repaint bool) error {
1015+ return d.closeTabLockedWithEffect(sess, tb, repaint, nil)
1016+}
1017+
1018+func (d *Daemon) closeTabLockedWithEffect(sess *session, tb *tab, repaint bool, effect *attachmentEffectTicket) error {
10111019 if sess == nil || tb == nil {
10121020 return layout.ErrNotFound
10131021 }
@@ -1033,7 +1041,14 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
10331041 name := sess.name
10341042 sess.mu.Unlock()
10351043 d.mu.Unlock()
1036- if err := d.killSession(sess, ports.ReasonSessionKilled, false); err != nil {
1044+ var err error
1045+ if effect != nil {
1046+ effect.bindActionEnd(d, "close-tab")
1047+ err = d.killSessionForAttachment(sess, ports.ReasonSessionKilled, false, effect.connectionToken(), "close-tab")
1048+ } else {
1049+ err = d.killSession(sess, ports.ReasonSessionKilled, false)
1050+ }
1051+ if err != nil {
10371052 d.log.Warn("closing last tab failed", "session", name, "err", err)
10381053 d.reportError(sess, domain.UserErr(domain.NoticeSnapshotSaturated,
10391054 "couldn't close tab: session state not yet saved; try again", err))
@@ -1043,19 +1058,14 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
10431058 return nil
10441059 }
10451060 ringing := tb.attention
1046- wasActive := idx == sess.active
1061+ sess.prepareAttachmentViewsForRemovedTabLocked(tb, idx)
10471062 sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)
1048- if sess.active >= len(sess.tabs) {
1049- sess.active = len(sess.tabs) - 1
1050- } else if idx < sess.active {
1051- sess.active--
1052- }
1053- destination := sess.tabs[sess.active]
1054- ac := sess.client
1063+ attachments := sess.snapshotAttachmentsLocked()
10551064 name := sess.name
10561065 if !sess.ephemeral {
10571066 d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
10581067 }
1068+ sess.invalidateViewsLocked()
10591069 sess.mu.Unlock()
10601070 d.mu.Unlock()
10611071 d.log.Info("tab closed", "session", name)
@@ -1065,24 +1075,25 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
10651075 tb.mu.Lock()
10661076 panes := tb.panesSnapshot()
10671077 tb.mu.Unlock()
1068- if ac != nil {
1078+ for _, ac := range attachments {
10691079 ac.pruneCaptureFrames(panes...)
10701080 }
10711081 if tb.cancel != nil {
10721082 tb.cancel()
10731083 }
1074- d.teardownFloating(tb, ac)
1084+ for _, ac := range attachments {
1085+ d.teardownFloating(tb, ac)
1086+ }
10751087 if rc := sess.renderCoordinator(); rc != nil {
10761088 for _, p := range panes {
10771089 rc.noteSyncPaneRemoved(p)
10781090 }
10791091 }
10801092 tb.closeAllPanes()
1081- if wasActive {
1082- d.activateTab(sess, destination)
1083- }
1084- if repaint && ac != nil {
1085- d.invalidateRender(sess, ac, true, "session.go")
1093+ if repaint {
1094+ for _, ac := range attachments {
1095+ d.invalidateRender(sess, ac, true, "session.go")
1096+ }
10861097 }
10871098 if ringing {
10881099 d.repaintAllAttachedClients()
@@ -1160,34 +1171,33 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
11601171 }
11611172
11621173 type sessionKillAdmission struct {
1163- token attachmentRoleToken
1174+ token attachmentConnectionToken
11641175 action string
11651176 }
11661177
11671178 type sessionKillParticipants struct {
1168- active *attachedClient
1169- snatched map[*attachedClient]struct{}
1179+ attachments map[*attachedClient]struct{}
11701180 transports map[*attachedClient]transportSnapshot
1171- roleGates []*attachedClient
1172- interrupts []roleTransportInterrupt
1181+ effectGates []*attachedClient
1182+ interrupts []attachmentTransportInterrupt
11731183 source *session
1174- sourceToken *attachmentRoleToken
1184+ sourceToken *attachmentConnectionToken
11751185 target *session
11761186 }
11771187
1178-func (d *Daemon) killSessionForRole(sess *session, reason uint8, purge bool, token attachmentRoleToken, action string) error {
1188+func (d *Daemon) killSessionForAttachment(sess *session, reason uint8, purge bool, token attachmentConnectionToken, action string) error {
11791189 return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil, &sessionKillAdmission{token: token, action: action})
11801190 }
11811191
1182-// snapshotSessionKillParticipants captures role membership under the canonical
1183-// architecture locks, but releases every lock before any role gate is frozen or
1192+// snapshotSessionKillParticipants captures attachment membership under the canonical
1193+// architecture locks, but releases every lock before any attachment gate is frozen or
11841194 // drained. The initiator is part of the same deduplicated global gate set even
11851195 // when it belongs to a different source session.
11861196 func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *sessionKillAdmission) (sessionKillParticipants, bool) {
11871197 snapshot := sessionKillParticipants{target: target}
11881198 if admission != nil {
11891199 token := admission.token
1190- if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.transport.transport == nil {
1200+ if token.sess == nil || token.ac == nil || token.transport.transport == nil {
11911201 return sessionKillParticipants{}, false
11921202 }
11931203 var ok bool
@@ -1209,21 +1219,18 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
12091219 source = target
12101220 }
12111221 unlockSessions := lockAttachmentSessions(source, target)
1212- snapshot.active = target.client
1213- snapshot.snatched = make(map[*attachedClient]struct{}, len(target.snatched))
1214- snapshot.transports = make(map[*attachedClient]transportSnapshot, 1+len(target.snatched))
1215- if snapshot.active != nil {
1216- snapshot.transports[snapshot.active] = snapshot.active.transportSnapshot()
1217- }
1218- for ac := range target.snatched {
1219- snapshot.snatched[ac] = struct{}{}
1222+ snapshot.attachments = make(map[*attachedClient]struct{}, len(target.attachments))
1223+ snapshot.transports = make(map[*attachedClient]transportSnapshot, len(target.attachments))
1224+ for ac := range target.attachments {
1225+ snapshot.attachments[ac] = struct{}{}
12201226 snapshot.transports[ac] = ac.transportSnapshot()
12211227 }
1228+
12221229 unlockSessions()
12231230 d.notices.routingMu.Unlock()
12241231 d.mu.Unlock()
12251232
1226- seen := make(map[*attachedClient]struct{}, 2+len(snapshot.snatched))
1233+ seen := make(map[*attachedClient]struct{}, 1+len(snapshot.attachments))
12271234 add := func(ac *attachedClient) {
12281235 if ac == nil {
12291236 return
@@ -1232,25 +1239,24 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
12321239 return
12331240 }
12341241 seen[ac] = struct{}{}
1235- snapshot.roleGates = append(snapshot.roleGates, ac)
1242+ snapshot.effectGates = append(snapshot.effectGates, ac)
12361243 }
12371244 if admission != nil {
12381245 add(admission.token.ac)
12391246 }
1240- add(snapshot.active)
1241- for ac := range snapshot.snatched {
1247+ for ac := range snapshot.attachments {
12421248 add(ac)
12431249 }
12441250 for ac, transport := range snapshot.transports {
12451251 if transport.transport != nil {
1246- snapshot.interrupts = append(snapshot.interrupts, roleTransportInterrupt{ac: ac, transport: transport})
1252+ snapshot.interrupts = append(snapshot.interrupts, attachmentTransportInterrupt{ac: ac, transport: transport})
12471253 }
12481254 }
12491255 return snapshot, true
12501256 }
12511257
12521258 // sessionKillParticipantsCurrent repeats lifecycle, source-token, and exact
1253-// target-membership validation while every discovered role gate is frozen.
1259+// target-membership validation while every discovered attachment gate is frozen.
12541260 func (d *Daemon) sessionKillParticipantsCurrent(snapshot sessionKillParticipants) bool {
12551261 d.mu.Lock()
12561262 defer d.mu.Unlock()
@@ -1277,14 +1283,15 @@ func (d *Daemon) sessionKillParticipantsCurrent(snapshot sessionKillParticipants
12771283 // session and coordinator locks.
12781284 func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sourceCoordinator *renderCoordinator) bool {
12791285 target := snapshot.target
1280- if target.client != snapshot.active || len(target.snatched) != len(snapshot.snatched) {
1286+ if len(target.attachments) != len(snapshot.attachments) {
12811287 return false
12821288 }
1283- for ac := range snapshot.snatched {
1284- if _, current := target.snatched[ac]; !current {
1289+ for ac := range snapshot.attachments {
1290+ if _, current := target.attachments[ac]; !current {
12851291 return false
12861292 }
12871293 }
1294+
12881295 for ac, transport := range snapshot.transports {
12891296 if !ac.transportSnapshotCurrent(transport) {
12901297 return false
@@ -1295,8 +1302,7 @@ func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sour
12951302 }
12961303 token := *snapshot.sourceToken
12971304 req := attachmentTransitionRequest{
1298- source: token.sess, next: token.ac, expectedRole: token.role,
1299- expectedTransport: token.transport,
1305+ source: token.sess, next: token.ac, expectedTransport: token.transport,
13001306 }
13011307 return transitionSourceTokenCurrentLocked(token, token.sess, sourceCoordinator, req)
13021308 }
@@ -1406,7 +1412,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
14061412 if sess == nil {
14071413 return nil
14081414 }
1409- // Discover every role whose membership can be invalidated before freezing
1415+ // Discover every attachment whose membership can be invalidated before freezing
14101416 // any one of them. The common gate helper deduplicates and freezes the whole
14111417 // immutable identity set in the same global order used by transitions.
14121418 participants, ok := d.snapshotSessionKillParticipants(sess, admission)
@@ -1414,29 +1420,29 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
14141420 return nil
14151421 }
14161422 if admission != nil {
1417- d.endActionRoleEffect(admission.token.effect, admission.action)
1423+ d.endActionAttachmentEffect(admission.token.effect, admission.action)
14181424 }
14191425 action := ""
14201426 if admission != nil {
14211427 action = admission.action
14221428 }
1423- if d.afterRoleEffectParticipantsSnapshotted != nil {
1424- d.afterRoleEffectParticipantsSnapshotted(action, participants.roleGates)
1429+ if d.afterAttachmentEffectParticipantsSnapshotted != nil {
1430+ d.afterAttachmentEffectParticipantsSnapshotted(action, participants.effectGates)
14251431 }
14261432 var drainDone func() <-chan struct{}
1427- var drainDeadline *roleEffectDrainDeadline
1433+ var drainDeadline *attachmentEffectDrainDeadline
14281434 if deadline != nil {
14291435 drainDone = deadline.Done
14301436 } else {
1431- drainDeadline = newRoleEffectDrainDeadline(d.clock)
1437+ drainDeadline = newAttachmentEffectDrainDeadline(d.clock)
14321438 drainDone = drainDeadline.Done
14331439 defer drainDeadline.stop()
14341440 }
1435- frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, done: drainDone, afterFrozen: func(ac *attachedClient) {
1436- if d.afterRoleEffectGateFrozen != nil {
1437- d.afterRoleEffectGateFrozen(action, ac)
1441+ frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: participants.interrupts, done: drainDone, afterFrozen: func(ac *attachedClient) {
1442+ if d.afterAttachmentEffectGateFrozen != nil {
1443+ d.afterAttachmentEffectGateFrozen(action, ac)
14381444 }
1439- }}, participants.roleGates...)
1445+ }}, participants.effectGates...)
14401446 defer func() { frozen.unfreeze() }()
14411447 if !frozen.acquired {
14421448 // The acquisition helper has already rolled back exactly the gates it
@@ -1540,8 +1546,8 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
15401546 sess.snapshotMu.Unlock()
15411547 }
15421548 }
1543- // Revalidate the exact source capability, target lifecycle, and active plus
1544- // snatched memberships under canonical architecture locks immediately before
1549+ // Revalidate the exact source capability, target lifecycle, and attachment
1550+ // memberships under canonical architecture locks immediately before
15451551 // publication. A changed preflight aborts without mutating either lifecycle.
15461552 d.mu.Lock()
15471553 if d.sessions[sess.id] != sess || participants.source != nil && d.sessions[participants.source.id] != participants.source {
@@ -1566,7 +1572,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
15661572 }
15671573
15681574 // d.mu -> routingMu -> ordered sessions -> ordered coordinators are held
1569- // here. Registry removal and every role invalidation are one publication
1575+ // here. Registry removal and every attachment invalidation are one publication
15701576 // while the complete immutable gate set remains frozen.
15711577 if !d.unregisterSessionLocked(sess) {
15721578 unlockCoordinators()
@@ -1578,23 +1584,20 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
15781584 d.clearBarScriptsForSession(sess.id)
15791585 d.purgeParkingForSessionLocked(sess)
15801586 parkedRetirements := d.purgeParkedForSessionLocked(sess)
1581- detachedActive := sess.client
1582- attachments := make([]detachedAttachmentSnapshot, 0, len(participants.roleGates))
1587+ attachments := make([]detachedAttachmentSnapshot, 0, len(participants.effectGates))
15831588 capture := func(attached *attachedClient) {
15841589 if attached == nil {
15851590 return
15861591 }
15871592 attachments = append(attachments, detachedAttachmentSnapshot{ac: attached, transport: participants.transports[attached]})
1588- attached.roleGeneration.Add(1)
1593+ attached.connectionGeneration.Add(1)
15891594 attached.setSession(nil)
1590- attached.invalidateFrozenRoleCapability()
1595+ attached.invalidateFrozenAttachmentCapability()
15911596 }
1592- capture(sess.client)
1593- for snatched := range sess.snatched {
1594- capture(snatched)
1597+ for attached := range sess.attachments {
1598+ capture(attached)
15951599 }
1596- sess.client = nil
1597- clear(sess.snatched)
1600+ clear(sess.attachments)
15981601 stoppedName := sess.name
15991602 stoppedCwd := sess.cwd
16001603 createdAt := sess.createdAt
@@ -1617,7 +1620,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
16171620 d.mu.Unlock()
16181621 d.finishParkedAttachmentRetirements(parkedRetirements)
16191622 frozen.unfreeze()
1620- frozen = frozenRoleEffectGates{}
1623+ frozen = frozenAttachmentEffectGates{}
16211624 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)
16221625
16231626 var purgeErr error
@@ -1645,7 +1648,9 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
16451648 sess.mu.Unlock()
16461649 for _, tb := range tabs {
16471650 d.clearDestroyedTabPreview(tb)
1648- d.teardownFloating(tb, detachedActive)
1651+ for _, attachment := range attachments {
1652+ d.teardownFloating(tb, attachment.ac)
1653+ }
16491654 }
16501655 for _, path := range clipFiles {
16511656 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
@@ -1758,7 +1763,7 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
17581763 if d.procCwd == nil {
17591764 return
17601765 }
1761- tb := sess.activeTab()
1766+ tb := sess.firstTab()
17621767 if tb == nil {
17631768 return
17641769 }
M internal/usecase/daemon/session_back.go +4 -4
@@ -7,7 +7,7 @@ import (
77
88 // backSession toggles this attachment between its current session and the
99 // immediately preceding successfully activated session.
10-func (d *Daemon) backSessionForRole(token attachmentRoleToken) error {
10+func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error {
1111 if d == nil || token.sess == nil || token.ac == nil {
1212 return nil
1313 }
@@ -21,7 +21,7 @@ func (d *Daemon) backSessionForRole(token attachmentRoleToken) error {
2121 key := proxy.key
2222 pickerTarget.RemoteKey = &key
2323 }
24- return d.switchToTargetForRole(token, pickerTarget, sessionHandoffGuard{}, "back-session")
24+ return d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{}, "back-session")
2525 }
2626
2727 func (d *Daemon) backSession(current *session, ac *attachedClient) {
@@ -35,14 +35,14 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
3535 return
3636 }
3737 token := attachmentToken(current, ac, ac.transport())
38- effect, admitted := ac.beginRoleEffect(token)
38+ effect, admitted := ac.beginAttachmentEffect(token)
3939 if !admitted {
4040 d.reportError(current, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", errAttachmentTransition))
4141 return
4242 }
4343 token.effect = effect
4444 defer effect.End()
45- if err := d.backSessionForRole(token); err != nil {
45+ if err := d.backSessionForAttachment(token); err != nil {
4646 d.reportError(current, err)
4747 }
4848 }
M internal/usecase/daemon/session_core.go +23 -21
@@ -13,8 +13,8 @@ type attachmentSession interface {
1313 core() *sessionCore
1414 snapshotView(viewOptions) sessionView
1515 statusSegments(includeTerminalTitle bool) statusSnapshot
16- capturePrimary(*attachedClient, primaryCaptureRequest) (*capturedRenderState, bool)
17- activateTargetLocked(tabIndex int) bool
16+ captureRenderState(*attachedClient, renderCaptureRequest) (*capturedRenderState, bool)
17+ validTargetTabLocked(tabIndex int) bool
1818 isProxy() bool
1919 }
2020
@@ -24,16 +24,20 @@ type attachmentSession interface {
2424 type sessionCore struct {
2525 mu sync.Mutex
2626
27- id domain.SessionID
28- name string
29- ephemeral bool
30- caps sessionCapabilities
31- client *attachedClient
32- snatched map[*attachedClient]struct{}
33- createdAt int64
34- incarnation domain.IncarnationID
35- mruAt atomic.Uint64
36- coordinator atomic.Pointer[renderCoordinator]
27+ id domain.SessionID
28+ name string
29+ ephemeral bool
30+ caps sessionCapabilities
31+ // attachments is the session-owned membership registry. Connection
32+ // generation and effect state remain attachment-local; admission and view
33+ // code use this collection as their membership source.
34+ attachments map[*attachedClient]struct{}
35+ attachmentOrder map[*attachedClient]uint64
36+ nextAttachmentID uint64
37+ createdAt int64
38+ incarnation domain.IncarnationID
39+ mruAt atomic.Uint64
40+ coordinator atomic.Pointer[renderCoordinator]
3741 }
3842
3943 func (s *session) core() *sessionCore {
@@ -45,20 +49,18 @@ func (s *session) core() *sessionCore {
4549
4650 func (s *session) isProxy() bool { return false }
4751
48-func (s *session) capturePrimary(ac *attachedClient, req primaryCaptureRequest) (*capturedRenderState, bool) {
49- return captureLocalPrimaryRenderState(s, ac, req)
52+func (s *session) captureRenderState(ac *attachedClient, req renderCaptureRequest) (*capturedRenderState, bool) {
53+ return captureLocalRenderState(s, ac, req)
5054 }
5155
52-// activateTargetLocked validates and selects a local tab. Caller holds s.mu.
53-func (s *session) activateTargetLocked(tabIndex int) bool {
56+// validTargetTabLocked validates a target tab index without changing any
57+// attachment view. Client-facing navigation updates attachment views through
58+// selectAttachmentTab.
59+func (s *session) validTargetTabLocked(tabIndex int) bool {
5460 if tabIndex < 0 {
5561 return true
5662 }
57- if tabIndex >= len(s.tabs) {
58- return false
59- }
60- s.active = tabIndex
61- return true
63+ return tabIndex < len(s.tabs)
6264 }
6365
6466 // localSession narrows an attachment entry for operations that own local PTYs,
M internal/usecase/daemon/session_core_test.go +21 -47
@@ -9,6 +9,18 @@ import (
99 "github.com/stretchr/testify/require"
1010 )
1111
12+func clearAttachmentsForTest(sess *session) {
13+ for _, ac := range sess.snapshotAttachments() {
14+ sess.unregisterAttachment(ac)
15+ }
16+}
17+
18+func clearAttachmentsForTestLocked(sess *session) {
19+ for _, ac := range sess.snapshotAttachmentsLocked() {
20+ sess.unregisterAttachmentLocked(ac)
21+ }
22+}
23+
1224 func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) {
1325 sess := &session{}
1426 sess.id = domain.SessionID("local")
@@ -24,8 +36,8 @@ func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) {
2436
2537 core.mu.Lock()
2638 require.False(t, sess.mu.TryLock(), "sessionCore.mu must be the mutex promoted as session.mu")
27- core.client = ac
28- require.Equal(t, attachmentActive, sess.attachmentRoleLocked(ac))
39+ core.registerAttachmentLocked(ac)
40+ require.Equal(t, true, sess.attachmentRegisteredLocked(ac))
2941 sess.tabs = append(sess.tabs, &tab{name: "logs"})
3042 require.Len(t, sess.tabs, 2)
3143 core.mu.Unlock()
@@ -37,7 +49,7 @@ func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) {
3749 func TestSessionSnapshotViewUsesPromotedMutexForLocalTabAndRoleState(t *testing.T) {
3850 sess := &session{
3951 sessionCore: sessionCore{id: domain.SessionID("local"), name: "work"},
40- tabs: []*tab{{name: "shell"}},
52+ tabs: []*tab{{stableID: "tab-shell", name: "shell"}},
4153 }
4254 ac := &attachedClient{}
4355
@@ -45,9 +57,9 @@ func TestSessionSnapshotViewUsesPromotedMutexForLocalTabAndRoleState(t *testing.
4557 // promoted core mutex. snapshotView must observe the same mutex before it
4658 // can publish either field.
4759 sess.core().mu.Lock()
48- sess.tabs = append(sess.tabs, &tab{name: "logs"})
49- sess.active = 1
50- sess.client = ac
60+ sess.tabs = append(sess.tabs, &tab{stableID: "tab-logs", name: "logs"})
61+ sess.registerAttachmentLocked(ac)
62+ sess.activateAttachmentViewLocked(ac, 1)
5163
5264 preCall := make(chan struct{})
5365 allowCall := make(chan struct{})
@@ -71,13 +83,14 @@ func TestSessionSnapshotViewUsesPromotedMutexForLocalTabAndRoleState(t *testing.
7183 t.Fatal("snapshotView completed while sessionCore.mu guarded local session state")
7284 case <-timer.C:
7385 }
74- require.Equal(t, attachmentActive, sess.attachmentRoleLocked(ac))
86+ require.Equal(t, true, sess.attachmentRegisteredLocked(ac))
7587 sess.core().mu.Unlock()
7688
7789 view := awaitTestValue(t, snapshotted, "snapshotView did not complete after sessionCore.mu was released")
7890 require.True(t, view.attached)
7991 require.Equal(t, 2, view.tabCount)
80- require.Equal(t, 1, view.active)
92+ require.Equal(t, 0, view.defaultTab)
93+ require.Equal(t, 1, testAttachmentTabIndex(sess))
8194 }
8295
8396 func TestLocalCreateThenKillRemovesLiveRegistryEntry(t *testing.T) {
@@ -134,45 +147,6 @@ func TestSessionCoreLockOrderUsesImmutableIDs(t *testing.T) {
134147 second.core().mu.Unlock()
135148 }
136149
137-func TestInitialMetadataSkipsInvalidSnapshot(t *testing.T) {
138- d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
139- hello := ports.Hello{
140- Version: ports.ProtocolVersion,
141- Intent: ports.IntentNew,
142- Name: "remote-work",
143- Size: defaultSize,
144- }
145- tr := newWelcomeBlockingTransport(t)
146- done := make(chan struct{})
147- go func() {
148- d.handleHello(tr.tr, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
149- close(done)
150- }()
151-
152- awaitTestCompletion(t, tr.welcomeEntered, "handshake did not send Welcome")
153- sess := firstSession(d)
154- require.NotNil(t, sess)
155- sess.mu.Lock()
156- sess.active = len(sess.tabs)
157- sess.mu.Unlock()
158- // finish (not just release) also closes recvDone, so runConnLoop's Recv
159- // fails immediately after the first paint and the handshake goroutine
160- // returns instead of blocking on further input.
161- tr.finish()
162- awaitTestCompletion(t, done, "invalid metadata snapshot did not end the handshake")
163-
164- welcome := <-tr.sends
165- require.Equal(t, ports.MsgWelcome, welcome.Type)
166- select {
167- case frame := <-tr.sends:
168- t.Fatalf("invalid metadata snapshot was sent as frame type %d", frame.Type)
169- default:
170- }
171-
172- require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
173- d.sessWg.Wait()
174-}
175-
176150 func mustLocalSession(t *testing.T, entry attachmentSession) *session {
177151 t.Helper()
178152 sess, ok := localSession(entry)
M internal/usecase/daemon/session_meta.go +20 -5
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "errors"
55
6+ "github.com/bnema/vev/internal/domain"
67 "github.com/bnema/vev/internal/ports"
78 )
89
@@ -11,17 +12,31 @@ var errSessionMetaUnavailable = errors.New("session metadata is unavailable")
1112 // sessionMetaSnapshot captures the authoritative tab order while session.mu
1213 // owns every field represented on the wire.
1314 func (s *session) sessionMetaSnapshot() (ports.SessionMeta, bool) {
15+ return s.sessionMetaSnapshotFor(nil)
16+}
17+
18+func (s *session) sessionMetaSnapshotFor(ac *attachedClient) (ports.SessionMeta, bool) {
1419 if s == nil {
1520 return ports.SessionMeta{}, false
1621 }
1722 s.mu.Lock()
1823 defer s.mu.Unlock()
19- if len(s.tabs) == 0 || s.active < 0 || s.active >= len(s.tabs) {
24+ if len(s.tabs) == 0 {
2025 return ports.SessionMeta{}, false
2126 }
27+ active := 0
28+ if ac != nil {
29+ view := ac.viewSnapshot()
30+ for i, tb := range s.tabs {
31+ if domain.TabStableID(tb.stableID) == view.tabID {
32+ active = i
33+ break
34+ }
35+ }
36+ }
2237 meta := ports.SessionMeta{
2338 SessionName: s.name,
24- Active: uint16(s.active),
39+ Active: uint16(active),
2540 Tabs: make([]ports.SessionTabMeta, len(s.tabs)),
2641 }
2742 for i, tb := range s.tabs {
@@ -63,11 +78,11 @@ func cloneSessionMeta(meta ports.SessionMeta) ports.SessionMeta {
6378 // caller holds ac.sendMu, which serializes this snapshot shadow with every
6479 // output write. The shadow is committed only after the transport accepts the
6580 // metadata frame.
66-func (ac *attachedClient) sendSessionMetaIfChanged(sess *session, expected transportSnapshot, ticket *roleEffectTicket) error {
81+func (ac *attachedClient) sendSessionMetaIfChanged(sess *session, expected transportSnapshot, ticket *attachmentEffectTicket) error {
6782 if ac == nil || sess == nil || !ac.proxied {
6883 return nil
6984 }
70- meta, ok := sess.sessionMetaSnapshot()
85+ meta, ok := sess.sessionMetaSnapshotFor(ac)
7186 if !ok {
7287 return errSessionMetaUnavailable
7388 }
@@ -76,7 +91,7 @@ func (ac *attachedClient) sendSessionMetaIfChanged(sess *session, expected trans
7691
7792 // sendSessionMetaSnapshot sends an already captured metadata snapshot while
7893 // the caller holds ac.sendMu.
79-func (ac *attachedClient) sendSessionMetaSnapshot(meta ports.SessionMeta, expected transportSnapshot, ticket *roleEffectTicket) error {
94+func (ac *attachedClient) sendSessionMetaSnapshot(meta ports.SessionMeta, expected transportSnapshot, ticket *attachmentEffectTicket) error {
8095 if ac == nil || !ac.proxied {
8196 return nil
8297 }
M internal/usecase/daemon/session_meta_test.go +21 -2
@@ -116,6 +116,25 @@ func TestSessionMetaShadowUpdatesOnlyAfterSuccessfulSend(t *testing.T) {
116116 require.False(t, ac.sessionMetaSent)
117117 }
118118
119+func TestProxiedAttachedCommandMutationFailureDoesNotReportExecution(t *testing.T) {
120+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
121+ ac.proxied = true
122+ d.attachCoordinator(sess, ac, true)
123+ d.ptys = failingPTYFactory{err: errors.New("tab open failed")}
124+ token := sess.attachmentToken(ac, ac.transport())
125+
126+ result := d.executeAttachedCommand(token, ports.CommandRequest{
127+ Version: ports.ProtocolVersion, RequestID: 41, Attached: true, Slug: "new-tab",
128+ })
129+
130+ require.False(t, result.OK)
131+ require.Equal(t, ports.ErrInternal, result.Code)
132+ require.Contains(t, result.Text, "tab open failed")
133+ sess.mu.Lock()
134+ require.Len(t, sess.tabs, 1, "failed attached command must not publish a new tab")
135+ sess.mu.Unlock()
136+}
137+
119138 func TestProxiedAttachedCommandValidationPreservesRequestID(t *testing.T) {
120139 tests := []struct {
121140 name string
@@ -192,12 +211,12 @@ func TestProxiedAttachedCommandValidationPreservesRequestID(t *testing.T) {
192211 t.Run(tt.name, func(t *testing.T) {
193212 d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
194213 ac.proxied = true
195- d.attachCoordinator(sess, nil, ac, true)
214+ d.attachCoordinator(sess, ac, true)
196215 payload, err := ports.MarshalCommandRequest(tt.request)
197216 require.NoError(t, err)
198217
199218 token := sess.attachmentToken(ac, ac.transport())
200- require.False(t, d.handleActiveClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload}))
219+ require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload}))
201220
202221 reply := awaitFrame(t, sends, ports.MsgCommandResult)
203222 result, err := ports.UnmarshalCommandResult(reply.Payload)
M internal/usecase/daemon/session_size_test.go +3 -3
@@ -14,12 +14,12 @@ func TestFullViewportSize(t *testing.T) {
1414 }{
1515 {
1616 name: "derives from active tab content size",
17- sess: &session{tabs: []*tab{{size: domain.Size{Cols: 120, Rows: 38}}}, active: 0},
17+ sess: &session{tabs: []*tab{{size: domain.Size{Cols: 120, Rows: 38}}}},
1818 want: domain.Size{Cols: 120, Rows: 40},
1919 },
2020 {
2121 name: "round-trips through tabSize",
22- sess: &session{tabs: []*tab{{size: tabSize(domain.Size{Cols: 80, Rows: 24})}}, active: 0},
22+ sess: &session{tabs: []*tab{{size: tabSize(domain.Size{Cols: 80, Rows: 24})}}},
2323 want: domain.Size{Cols: 80, Rows: 24},
2424 },
2525 {
@@ -29,7 +29,7 @@ func TestFullViewportSize(t *testing.T) {
2929 },
3030 {
3131 name: "falls back to defaultSize with invalid tab size",
32- sess: &session{tabs: []*tab{{}}, active: 0},
32+ sess: &session{tabs: []*tab{{}}},
3333 want: defaultSize,
3434 },
3535 }
M internal/usecase/daemon/session_test.go +56 -124
@@ -20,6 +20,7 @@ import (
2020 "github.com/bnema/vev/internal/ports"
2121 portsmocks "github.com/bnema/vev/internal/ports/mocks"
2222 "github.com/bnema/vev/internal/usecase/picker"
23+ themeui "github.com/bnema/vev/internal/usecase/theme"
2324 )
2425
2526 // --- test doubles -----------------------------------------------------------
@@ -32,55 +33,6 @@ func expectFloatingPrewarmOpen(factory *portsmocks.MockPTYFactory, normalSize do
3233 // stubClock returns timers whose channel never fires, so a scheduler under it
3334 // blocks in its debounce loop until the session context is cancelled. Used by
3435
35-func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
36- sz := domain.Size{Cols: 80, Rows: 24}
37- first, releaseFirst := newBlockingPTY(t)
38- defer releaseFirst()
39- second, releaseSecond := newBlockingPTY(t)
40- defer releaseSecond()
41- f := portsmocks.NewMockPTYFactory(t)
42- var dirs []string
43- normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
44- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
45- func(_ context.Context, _ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) {
46- dirs = append(dirs, dir)
47- if len(dirs) == 1 {
48- return first, nil
49- }
50- return second, nil
51- },
52- ).Twice()
53- floating := newQuietPTY()
54- expectFloatingPrewarmOpen(f, normalSize, floating)
55-
56- d := newTestDaemon(t, f, stubClock{})
57- tr := portsmocks.NewMockTransport(t)
58- tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
59- tr.EXPECT().Close().Return(nil).Maybe()
60-
61- sess, ac, err := d.route(ports.Hello{
62- Version: ports.ProtocolVersion,
63- Intent: ports.IntentNew,
64- Name: "work",
65- Size: sz,
66- TermEnv: "xterm-256color",
67- Cwd: "/tmp/work",
68- }, tr)
69- require.NoError(t, err)
70- require.NotNil(t, ac)
71- require.Equal(t, "/tmp/work", sess.cwd)
72-
73- require.NoError(t, d.createTab(sess, sz))
74- require.Equal(t, []string{"/tmp/work", "/tmp/work"}, dirs)
75- requireFloatingInitialized(t, sess.activeTab())
76- _ = d.killSession(sess, ports.ReasonSessionKilled, false)
77- releaseFirst()
78- releaseSecond()
79- d.sessWg.Wait()
80- // Teardown may cancel a queued prewarm before it enters Open; an opened
81- // prewarm is closed by teardownFloating.
82-}
83-
8436 func TestHandshakeEphemeralHappy(t *testing.T) {
8537 p, releasePTY := newBlockingPTY(t)
8638 d := newTestDaemon(t, newFactory(t, p), stubClock{})
@@ -200,6 +152,53 @@ func TestKillAllEmptyDaemonSignalsShutdown(t *testing.T) {
200152 }
201153 }
202154
155+func TestCreateTabForAttachmentUsesRequestingAttachmentViewportAndTheme(t *testing.T) {
156+ p, release := newBlockingPTY(t)
157+ defer release()
158+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
159+ firstTransport := &closeTrackingTransport{}
160+ sess, first, err := d.route(ports.Hello{
161+ Version: ports.ProtocolVersion,
162+ Intent: ports.IntentNew,
163+ Name: "work",
164+ ClientID: [16]byte{1},
165+ Size: domain.Size{Cols: 80, Rows: 24},
166+ }, firstTransport)
167+ require.NoError(t, err)
168+ secondTransport := &closeTrackingTransport{}
169+ _, second, err := d.route(ports.Hello{
170+ Version: ports.ProtocolVersion,
171+ Intent: ports.IntentAttach,
172+ Name: "work",
173+ ClientID: [16]byte{2},
174+ Proxied: true,
175+ Size: domain.Size{Cols: 80, Rows: 24},
176+ }, secondTransport)
177+ require.NoError(t, err)
178+ require.NotSame(t, first, second)
179+
180+ requesterTheme := themeui.Theme{
181+ Foreground: themeui.BuiltinLight.Foreground,
182+ Background: themeui.BuiltinLight.Background,
183+ HasFG: true,
184+ HasBG: true,
185+ Known: true,
186+ }
187+ second.setClientTheme(requesterTheme)
188+ require.NoError(t, d.createTabForAttachment(sess, second, domain.Size{Cols: 80, Rows: 24}))
189+
190+ tb := sess.tabs[len(sess.tabs)-1]
191+ tb.mu.Lock()
192+ gotSize := tb.size
193+ pane := tb.focusedPane()
194+ tb.mu.Unlock()
195+ require.Equal(t, domain.Size{Cols: 80, Rows: 24}, gotSize, "proxied sizing must come from the requesting attachment")
196+ assertPaneDefaultColors(t, pane, requesterTheme.Foreground, requesterTheme.Background)
197+
198+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
199+ d.sessWg.Wait()
200+}
201+
203202 func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
204203 p1, release1 := newBlockingPTY(t)
205204 defer release1()
@@ -283,53 +282,6 @@ func TestEphemeralNumberingReuse(t *testing.T) {
283282
284283 // --- attach replace ---------------------------------------------------------
285284
286-func TestAttachReplaceKeepsOldClientSnatched(t *testing.T) {
287- p, releasePTY := newBlockingPTY(t)
288- d := newTestDaemon(t, newFactory(t, p), stubClock{})
289-
290- // Client A creates and attaches to ephemeral "0".
291- trA, sendsA, releaseA := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
292- var hg sync.WaitGroup
293- hg.Go(func() { d.handleConn(trA) })
294- awaitFrame(t, sendsA, ports.MsgWelcome)
295- awaitFrame(t, sendsA, ports.MsgOutput)
296- sess := firstSession(d)
297- require.NotNil(t, sess)
298- sess.mu.Lock()
299- acA := sess.client
300- sess.mu.Unlock()
301- require.NotNil(t, acA)
302- // The transport observes Send before the render transaction releases sendMu.
303- // Wait for that contract boundary before replacing A; otherwise the test can
304- // intentionally classify the in-flight first paint as an interrupted render.
305- acA.sendMu.Lock()
306- require.Same(t, trA, acA.transportSnapshot().transport)
307- acA.sendMu.Unlock()
308-
309- // Client B attaches to the same session, displacing A.
310- trB, sendsB, releaseB := newConn(t, mustHello(ports.IntentAttach, "0", domain.Size{Cols: 80, Rows: 24}))
311- hg.Go(func() { d.handleConn(trB) })
312- awaitFrame(t, sendsB, ports.MsgWelcome)
313- d.attachmentCleanupWg.Wait()
314-
315- // B is now the sole active client while A remains a live snatched member.
316- sess.mu.Lock()
317- require.NotNil(t, sess.client)
318- require.NotSame(t, acA, sess.client)
319- _, oldSnatched := sess.snatched[acA]
320- sess.mu.Unlock()
321- require.True(t, oldSnatched)
322- require.Same(t, sess, acA.currentSession())
323-
324- releaseA()
325- releaseB()
326- _ = d.killSession(sess, ports.ReasonServerShutdown, false)
327- releasePTY()
328- hg.Wait()
329- d.sessWg.Wait()
330- d.waitNotifies()
331-}
332-
333285 // --- detach semantics -------------------------------------------------------
334286
335287 func TestDetachKeepsEphemeralHeadless(t *testing.T) {
@@ -351,7 +303,7 @@ func TestDetachKeepsEphemeralHeadless(t *testing.T) {
351303 require.NotNil(t, sess)
352304 sess.mu.Lock()
353305 require.True(t, sess.ephemeral)
354- require.Nil(t, sess.client, "ephemeral session is headless after detach")
306+ require.Empty(t, sess.snapshotAttachmentsLocked(), "ephemeral session is headless after detach")
355307 sess.mu.Unlock()
356308
357309 _ = d.killSession(sess, ports.ReasonServerShutdown, false)
@@ -398,7 +350,7 @@ func TestDetachKeepsNamed(t *testing.T) {
398350 require.Equal(t, 1, sessionCount(d), "named session survives detach")
399351 sess := firstSession(d)
400352 sess.mu.Lock()
401- require.Nil(t, sess.client, "named session is headless after detach")
353+ require.Empty(t, sess.snapshotAttachmentsLocked(), "named session is headless after detach")
402354 sess.mu.Unlock()
403355
404356 _ = d.killSession(sess, ports.ReasonServerShutdown, false)
@@ -1035,29 +987,6 @@ func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) {
1035987 }
1036988 }
1037989
1038-func TestCloseActiveTabActivatesDestinationFloatingPane(t *testing.T) {
1039- d, sess, ac, _, releases := newManualTabSession(t, 2)
1040- sess.mu.Lock()
1041- sess.client = ac
1042- sess.mu.Unlock()
1043- d.ptys = newBlockingOpenFactory(t, d)
1044- defer releases[0]()
1045- defer releases[1]()
1046- sess.mu.Lock()
1047- first, closing := sess.tabs[0], sess.tabs[1]
1048- sess.active = 1
1049- sess.mu.Unlock()
1050- first.mu.Lock()
1051- stale := first.takeFloatingLocked()
1052- first.mu.Unlock()
1053- closeFloatingPane(stale)
1054-
1055- require.NoError(t, d.closeTab(sess, closing, false))
1056-
1057- require.Same(t, first, sess.activeTab())
1058- requireFloatingInitialized(t, first)
1059-}
1060-
1061990 func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) {
1062991 sz := domain.Size{Cols: 80, Rows: 24}
1063992 p, release := newBlockingPTY(t)
@@ -1619,13 +1548,16 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
16191548 got := ac.currentSession()
16201549 require.NotNil(t, got)
16211550 sourceCoordinator.mu.Lock()
1622- require.NotNil(t, sourceCoordinator.lease)
1623- require.False(t, sourceCoordinator.lease.active, "source callbacks must be stale after handoff")
1551+ require.Empty(t, sourceCoordinator.attachments, "source callbacks must be stale after handoff")
1552+ require.Empty(t, sourceCoordinator.leases, "source leases must be retired after handoff")
16241553 sourceCoordinator.mu.Unlock()
16251554 destinationCoordinator := got.renderCoordinator()
16261555 require.NotNil(t, destinationCoordinator, "destination coordinator precedes first paint")
16271556 destinationCoordinator.mu.Lock()
1628- require.Same(t, ac, destinationCoordinator.lease.attachment)
1557+ require.Contains(t, destinationCoordinator.attachments, ac)
1558+ lease := destinationCoordinator.leases[ac]
1559+ require.NotNil(t, lease)
1560+ require.Same(t, ac, lease.attachment)
16291561 destinationCoordinator.mu.Unlock()
16301562 ac.sendMu.Lock()
16311563 require.Equal(t, uint64(1), ac.output.next-ac.output.acked, "only the destination first paint may follow the rebase")
M internal/usecase/daemon/session_view.go +8 -5
@@ -34,7 +34,7 @@ type sessionView struct {
3434 name string
3535 ephemeral bool
3636 createdAt int64
37- active int
37+ defaultTab int
3838 mruAt uint64
3939 attached bool
4040 tabCount int
@@ -53,16 +53,18 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
5353 opts.tabDetails = true
5454 }
5555 s.mu.Lock()
56- defer s.mu.Unlock()
56+ // Session-level listings use the first ordered tab as their deterministic
57+ // default. Interactive callers pass an attachment and resolve its stable
58+ // view separately.
5759 view := sessionView{
5860 id: s.id,
5961 incarnation: s.incarnation,
6062 name: s.name,
6163 ephemeral: s.ephemeral,
6264 createdAt: s.createdAt,
63- active: s.active,
65+ defaultTab: 0,
6466 mruAt: s.mruAt.Load(),
65- attached: s.client != nil,
67+ attached: len(s.attachments) != 0,
6668 tabCount: len(s.tabs),
6769 cannotAcceptMoves: s.capabilities().cannotAcceptMoves,
6870 }
@@ -90,6 +92,7 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
9092 }
9193 view.tabs = append(view.tabs, entry)
9294 }
95+ s.mu.Unlock()
9396 return view
9497 }
9598
@@ -101,7 +104,7 @@ func (view sessionView) pickerView() picker.SessionView {
101104 Incarnation: view.incarnation,
102105 Name: view.name,
103106 TargetName: view.name,
104- Active: view.active,
107+ Active: view.defaultTab,
105108 Tabs: make([]picker.TabEntry, 0, len(view.tabs)),
106109 CannotAcceptMoves: view.cannotAcceptMoves,
107110 }
M internal/usecase/daemon/session_view_test.go +1 -2
@@ -22,7 +22,7 @@ func TestSnapshotViewCapturesSessionFields(t *testing.T) {
2222 name: "alpha",
2323 createdAt: 42,
2424
25- client: &attachedClient{}}, active: 1,
25+ attachments: map[*attachedClient]struct{}{&attachedClient{}: {}}},
2626
2727 tabs: []*tab{
2828 {stableID: "t1", name: "build"},
@@ -38,7 +38,6 @@ func TestSnapshotViewCapturesSessionFields(t *testing.T) {
3838 incarnation: domain.IncarnationID{1},
3939 name: "alpha",
4040 createdAt: 42,
41- active: 1,
4241 mruAt: 7,
4342 attached: true,
4443 tabCount: 2,
M internal/usecase/daemon/snapshot.go +1 -1
@@ -42,7 +42,7 @@ type snapshotCapture struct {
4242 name string
4343 incarnation domain.IncarnationID
4444 createdAt uint64
45- active uint16
45+ defaultTab uint16
4646 tabs []snapshotCaptureTab
4747 publicationContext context.Context
4848 sealedRefs map[*vt.HistoryChunk]snapcodec.ObjectRef // set by the single encoder worker
M internal/usecase/daemon/snapshot_capture.go +3 -1
@@ -13,7 +13,9 @@ func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapsh
1313 name: sess.name,
1414 incarnation: sess.incarnation,
1515 createdAt: uint64(sess.createdAt),
16- active: uint16(max(sess.active, 0)),
16+ // Snapshot format retains a deterministic first-tab default; it is not
17+ // an interactive attachment selection.
18+ defaultTab: 0,
1719 }
1820 ephemeral := sess.ephemeral
1921 fallbackCwd := sess.cwd
M internal/usecase/daemon/snapshot_incremental.go +1 -1
@@ -114,7 +114,7 @@ func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.Snapsho
114114 if err := prepareSnapshotChunkCache(capture); err != nil {
115115 return ports.SnapshotPublication{}, err
116116 }
117- manifest := snapcodec.Manifest{Generation: capture.generation, IncarnationID: capture.incarnation, ParentCheckpoint: capture.parentCheckpoint, Name: capture.name, CreatedAt: capture.createdAt, Active: capture.active, Tabs: make([]snapcodec.ManifestTab, 0, len(capture.tabs))}
117+ manifest := snapcodec.Manifest{Generation: capture.generation, IncarnationID: capture.incarnation, ParentCheckpoint: capture.parentCheckpoint, Name: capture.name, CreatedAt: capture.createdAt, Active: capture.defaultTab, Tabs: make([]snapcodec.ManifestTab, 0, len(capture.tabs))}
118118 objects := make([]ports.SnapshotObject, 0)
119119 capture.sealedRefs = make(map[*vt.HistoryChunk]snapcodec.ObjectRef)
120120 for _, tab := range capture.tabs {
M internal/usecase/daemon/snapshot_restore.go +3 -1
@@ -290,7 +290,9 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable
290290 }
291291
292292 func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab, term terminalEnv) *session {
293- sess := &session{sessionCore: sessionCore{name: snap.Name, createdAt: int64(snap.CreatedAt)}, ctx: sctx, cancel: cancel, tabs: tabs, active: int(snap.Active), terminal: term, env: copyEnvironment(d.baseEnv), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)}
293+ // Restored sessions keep ordered tabs; attachment repair deterministically
294+ // selects the first tab instead of restoring an interactive client view.
295+ sess := &session{sessionCore: sessionCore{name: snap.Name, createdAt: int64(snap.CreatedAt)}, ctx: sctx, cancel: cancel, tabs: tabs, terminal: term, env: copyEnvironment(d.baseEnv), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)}
294296 // Restored tabs remain private until persistAndRegisterRestoredSession.
295297 // Initialize owners now so registration and reader startup cannot expose an
296298 // ownerless pane.
M internal/usecase/daemon/snapshot_restore_test.go +1 -1
@@ -233,7 +233,7 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
233233 require.False(t, restored.snapDirty.Load(), "a loaded generation must begin clean")
234234 require.Equal(t, uint64(9), restored.snapshotPublishedGeneration, "the loaded manifest is the repository generation head")
235235 require.True(t, restored.terminal.TrueColor, "restored sessions must retain startup terminal capability for future panes")
236- require.Equal(t, 0, restored.active)
236+ require.Equal(t, 0, testAttachmentTabIndex(restored))
237237 require.Equal(t, "/snapshot/cwd", restored.cwd)
238238 require.Len(t, restored.tabs, 1)
239239 tab := restored.tabs[0]
M internal/usecase/daemon/snapshot_resume_publication_test.go +2 -61
@@ -141,65 +141,6 @@ func cloneCheckpointRef(ref *domain.CheckpointRef) *domain.CheckpointRef {
141141 return &clone
142142 }
143143
144-func TestNamedSessionPublishesNextCheckpointAfterClientSnatch(t *testing.T) {
145- pty, release := newBlockingPTY(t)
146- repository := newSnapshotLifecycleRepository(t)
147- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
148- WithSnapshotRepository(repository)(d)
149- store, _ := newMockStore(t)
150- WithStore(t, store)(d)
151- startSnapshotEncodeWorker(t, d)
152- t.Cleanup(func() {
153- d.snapsEnabled = false
154- release()
155- d.sessWg.Wait()
156- })
157-
158- firstTransport := &closeTrackingTransport{}
159- sess, firstClient, err := d.route(snapshotLifecycleHello(ports.IntentNew, "work"), firstTransport)
160- require.NoError(t, err)
161-
162- sess.activeTab().focusedPane().screen.Write([]byte("first checkpoint"))
163- markSnapshotDirty(sess)
164- require.True(t, d.scheduleSnapshot(sess))
165- awaitSnapshotIdle(t, sess)
166- d.snapshotNoticeMu.Lock()
167- firstFailure := d.snapshotActiveFailureSignature
168- d.snapshotNoticeMu.Unlock()
169- attempts, publicationErrors := repository.results()
170- require.Len(t, attempts, 1)
171- require.NoError(t, publicationErrors[0])
172- require.Empty(t, firstFailure)
173- require.False(t, sess.snapDirty.Load())
174- firstRecord := snapshotLifecycleRecord(t, d, sess.name)
175- require.NotNil(t, firstRecord.Committed)
176- require.Equal(t, uint64(1), firstRecord.Committed.Generation)
177-
178- secondTransport := &closeTrackingTransport{}
179- snatchedSession, secondClient, err := d.route(snapshotLifecycleHello(ports.IntentAttach, "work"), secondTransport)
180- require.NoError(t, err)
181- require.Same(t, sess, snatchedSession, "client snatch must retain the live session")
182- require.NotSame(t, firstClient, secondClient, "client B must displace client A")
183-
184- sess.activeTab().focusedPane().screen.Write([]byte("second checkpoint"))
185- markSnapshotDirty(sess)
186- require.True(t, d.scheduleFinalSnapshot(sess))
187- awaitSnapshotIdle(t, sess)
188- attempts, publicationErrors = repository.results()
189- require.Len(t, attempts, 2)
190- require.NoError(t, publicationErrors[1])
191- require.False(t, sess.snapDirty.Load())
192-
193- secondRecord := snapshotLifecycleRecord(t, d, sess.name)
194- require.NotNil(t, secondRecord.Committed)
195- require.Equal(t, firstRecord.Committed.Generation+1, secondRecord.Committed.Generation,
196- "client snatch must not reset or conflict with the repository generation")
197- generation, err := repository.LoadCheckpoint(context.Background(), sess.incarnation, sess.name, *secondRecord.Committed)
198- require.NoError(t, err)
199- require.Equal(t, firstRecord.Committed, generation.ParentCheckpoint,
200- "the post-snatch checkpoint must retain the committed parent")
201-}
202-
203144 func TestStoppedNamedSessionResumePublishesNextCheckpointInSameDaemon(t *testing.T) {
204145 keeperPTY, releaseKeeper := newBlockingPTY(t)
205146 firstPTY, releaseFirst := newBlockingPTY(t)
@@ -223,7 +164,7 @@ func TestStoppedNamedSessionResumePublishesNextCheckpointInSameDaemon(t *testing
223164 sess, _, err := d.route(snapshotLifecycleHello(ports.IntentNew, "work"), &closeTrackingTransport{})
224165 require.NoError(t, err)
225166
226- sess.activeTab().focusedPane().screen.Write([]byte("first checkpoint"))
167+ testAttachmentTab(sess).focusedPane().screen.Write([]byte("first checkpoint"))
227168 markSnapshotDirty(sess)
228169 require.True(t, d.scheduleSnapshot(sess))
229170 awaitSnapshotClean(t, sess)
@@ -247,7 +188,7 @@ func TestStoppedNamedSessionResumePublishesNextCheckpointInSameDaemon(t *testing
247188 require.NotSame(t, sess, resumed, "stopped-session attach must create a new live runtime")
248189 require.Equal(t, sess.incarnation, resumed.incarnation, "resume must retain durable incarnation authority")
249190
250- resumed.activeTab().focusedPane().screen.Write([]byte("checkpoint after resume"))
191+ testAttachmentTab(resumed).focusedPane().screen.Write([]byte("checkpoint after resume"))
251192 markSnapshotDirty(resumed)
252193 require.True(t, d.scheduleSnapshot(resumed))
253194 awaitSnapshotIdle(t, resumed)
M internal/usecase/daemon/snapshot_test.go +1 -1
@@ -89,7 +89,7 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin
8989 p := tb.panes["pane-1"]
9090 p.screen.Write([]byte("hello"))
9191 appendHistoryRow(t, p.history, []renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})
92- sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, createdAt: 42}, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, active: 0, cwd: cwd}
92+ sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, createdAt: 42}, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, cwd: cwd}
9393 if !ephemeral {
9494 sess.incarnation = domain.IncarnationID{1}
9595 }
D internal/usecase/daemon/snatched_cleanup.go +0 -221
@@ -1,221 +0,0 @@
1-package daemon
2-
3-import "github.com/bnema/vev/internal/ports"
4-
5-// removeSnatchedAttachment removes exactly one role generation and transport
6-// from session routing. It never touches the active attachment or closes a
7-// transport, allowing callers that already interrupted a blocked send to avoid
8-// closing the same link twice.
9-func (d *Daemon) removeSnatchedAttachment(token attachmentRoleToken) bool {
10- return d.unrouteSnatchedAttachment(token, true)
11-}
12-
13-func (d *Daemon) unrouteSnatchedAttachment(token attachmentRoleToken, terminal bool) bool {
14- if token.sess == nil || token.ac == nil || token.transport.transport == nil {
15- return false
16- }
17- frozen := freezeRoleEffectGates(token.ac)
18- defer frozen.unfreeze()
19- d.notices.routingMu.Lock()
20- core := token.sess.core()
21- core.mu.Lock()
22- current := attachmentSessionRoleLocked(token.sess, token.ac) == attachmentSnatched &&
23- token.ac.roleGeneration.Load() == token.generation &&
24- token.ac.transportSnapshotCurrent(token.transport)
25- if current {
26- delete(core.snatched, token.ac)
27- token.ac.roleGeneration.Add(1)
28- token.ac.invalidateFrozenRoleCapability()
29- }
30- core.mu.Unlock()
31- d.notices.routingMu.Unlock()
32- if !current {
33- return false
34- }
35-
36- token.ac.clearSnatchedInput()
37- d.unregisterPreview(token.ac)
38- if terminal {
39- token.ac.clearPreviousSession()
40- }
41- token.ac.setSession(nil)
42- token.ac.clearCaptureFrames()
43- if terminal {
44- // A losing park path may have published a same-attachment marker before
45- // this terminal unroute won. Retire only that attachment's marker so
46- // waiters fail closed instead of hanging; park paths (terminal=false)
47- // keep the marker until parkAttachmentAs or abandonment cleanup.
48- d.clearParkingInFlight(token.ac.resumeToken, token.ac)
49- }
50- return true
51-}
52-
53-// parkSnatchedAttachment removes only the exact lost snatched incarnation and
54-// retains its resume identity for a role-preserving reconnect.
55-func (d *Daemon) parkSnatchedAttachment(token attachmentRoleToken) bool {
56- sess, ok := localSession(token.sess)
57- if !ok || token.ac == nil || !token.ac.resumeCapable {
58- return false
59- }
60- // Advertise parking before unroute so IntentResume never observes both the
61- // snatched seat and parking/parked registries empty for a still-valid token.
62- parkingToken := d.markParkingInFlight(sess, token.ac)
63- if !d.unrouteSnatchedAttachment(token, false) {
64- d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)
65- return false
66- }
67- if d.afterSnatchedUnrouteBeforePark != nil {
68- d.afterSnatchedUnrouteBeforePark()
69- }
70- if d.parkAttachmentAs(sess, token.ac, attachmentSnatched) {
71- captured := token.transport.transport
72- _ = token.ac.revokeTransport(captured)
73- _ = token.ac.closeCapturedTransport(captured)
74- return true
75- }
76- d.clearParkingInFlight(parkingToken, token.ac)
77- token.ac.clearPreviousSession()
78- captured := token.transport.transport
79- _ = token.ac.revokeTransport(captured)
80- _ = token.ac.closeCapturedTransport(captured)
81- return false
82-}
83-
84-func (d *Daemon) parkOrDropSnatchedAttachment(token attachmentRoleToken) bool {
85- if d.parkSnatchedAttachment(token) {
86- return true
87- }
88- return d.dropSnatchedAttachment(token)
89-}
90-
91-// cleanupInterruptedSnatchedAttachment owns terminal cleanup for a displaced
92-// transport that was closed to drain an admitted send. It waits on the gate's
93-// condition rather than ordinary effect admission, then removes only the exact
94-// published snatched incarnation. Attachment-local cleanup runs without routing
95-// or session locks while the terminal capability remains frozen.
96-func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken) bool {
97- sess, ok := localSession(token.sess)
98- if !ok || token.ac == nil || token.transport.transport == nil {
99- return false
100- }
101- frozen := freezeRoleEffectGates(token.ac)
102- defer frozen.unfreeze()
103-
104- d.notices.routingMu.Lock()
105- sess.mu.Lock()
106- current := attachmentSessionRoleLocked(token.sess, token.ac) == attachmentSnatched &&
107- token.ac.currentAttachmentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation &&
108- token.ac.transportSnapshotCurrent(token.transport)
109- sess.mu.Unlock()
110- d.notices.routingMu.Unlock()
111- if !current {
112- return false
113- }
114-
115- // Advertise parking before ownership removal so IntentResume can wait out
116- // the unroute→park gap for a still-valid snatched credential.
117- var parkingToken uint64
118- if token.ac.resumeCapable {
119- parkingToken = d.markParkingInFlight(sess, token.ac)
120- }
121-
122- // The frozen gate prevents a promotion from publishing between overlay
123- // cleanup and terminal registry publication.
124- d.clearForSnatch(token)
125-
126- d.notices.routingMu.Lock()
127- sess.mu.Lock()
128- current = attachmentSessionRoleLocked(token.sess, token.ac) == attachmentSnatched &&
129- token.ac.currentAttachmentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation &&
130- token.ac.transportSnapshotCurrent(token.transport)
131- if current {
132- delete(sess.snatched, token.ac)
133- token.ac.roleGeneration.Add(1)
134- token.ac.invalidateFrozenRoleCapability()
135- }
136- sess.mu.Unlock()
137- d.notices.routingMu.Unlock()
138- if !current {
139- d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)
140- return false
141- }
142-
143- token.ac.setSession(nil)
144- if d.afterSnatchedUnrouteBeforePark != nil {
145- d.afterSnatchedUnrouteBeforePark()
146- }
147- if token.ac.resumeCapable && d.parkAttachmentAs(sess, token.ac, attachmentSnatched) {
148- captured := token.transport.transport
149- _ = token.ac.revokeTransport(captured)
150- _ = token.ac.closeCapturedTransport(captured)
151- token.ac.clearSnatchedInput()
152- d.unregisterPreview(token.ac)
153- return true
154- }
155- d.clearParkingInFlight(parkingToken, token.ac)
156- captured := token.transport.transport
157- _ = token.ac.revokeTransport(captured)
158- _ = token.ac.closeCapturedTransport(captured)
159- token.ac.clearSnatchedInput()
160- d.unregisterPreview(token.ac)
161- token.ac.clearPreviousSession()
162- token.ac.clearCaptureFrames()
163- return true
164-}
165-
166-// dropSnatchedAttachment removes and closes only the captured snatched link.
167-func (d *Daemon) dropSnatchedAttachment(token attachmentRoleToken) bool {
168- if !d.removeSnatchedAttachment(token) {
169- return false
170- }
171- _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
172- return true
173-}
174-
175-// deferAttachmentTransitionCleanups retires coordinator timers and accounting
176-// after ownership publication. Cleanup never gates the attaching handshake. A
177-// displaced render that already owns sendMu is interrupted by closing only its
178-// captured transport; an idle healthy snatched transport remains connected and
179-// receives a dependency-free reset panel.
180-func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionResult) {
181- if terminal := result.terminalDisplaced; terminal.ac != nil && terminal.transport.transport != nil {
182- terminal.ac.clearSnatchedInput()
183- d.unregisterPreview(terminal.ac)
184- terminal.ac.clearPreviousSession()
185- terminal.ac.clearCaptureFrames()
186- d.notifyDetachedSnapshotAsync(terminal, ports.ReasonReplaced)
187- }
188- if token := result.displaced; token.ac != nil && token.transport.transport != nil {
189- blockedRender := result.displacedInterrupted
190- if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) {
191- blockedRender = !token.ac.sendMu.TryLock()
192- if !blockedRender {
193- token.ac.sendMu.Unlock()
194- }
195- }
196- d.attachmentCleanupWg.Go(func() {
197- if d.afterDisplacedCleanupStarted != nil {
198- d.afterDisplacedCleanupStarted()
199- }
200- if blockedRender {
201- // This path owns terminal cleanup independently of ordinary role
202- // admission: a stale send-error detach may transiently freeze the gate.
203- _ = token.ac.closeCapturedTransport(token.transport.transport)
204- d.cleanupInterruptedSnatchedAttachment(token)
205- return
206- }
207- ticket, admitted := token.ac.beginRoleEffect(token)
208- if !admitted {
209- return
210- }
211- defer ticket.End()
212- if err := d.sendInitialSnatchedPanel(token, ticket); err != nil {
213- ticket.End()
214- d.parkOrDropSnatchedAttachment(token)
215- }
216- })
217- }
218- for _, cleanup := range result.cleanups {
219- d.attachmentCleanupWg.Go(cleanup.finish)
220- }
221-}
D internal/usecase/daemon/snatched_client.go +0 -107
@@ -1,107 +0,0 @@
1-package daemon
2-
3-import (
4- "errors"
5-
6- "github.com/bnema/vev/internal/ports"
7-)
8-
9-type initialSnatchedPanelAttempt struct {
10- done chan struct{}
11- err error
12-}
13-
14-func (ac *attachedClient) claimInitialSnatchedPanel(generation uint64) (*initialSnatchedPanelAttempt, bool) {
15- ac.initialSnatchedMu.Lock()
16- defer ac.initialSnatchedMu.Unlock()
17- if generation < ac.initialSnatchedGeneration {
18- return nil, false
19- }
20- if generation == ac.initialSnatchedGeneration && ac.initialSnatchedAttempt != nil {
21- return ac.initialSnatchedAttempt, false
22- }
23- attempt := &initialSnatchedPanelAttempt{done: make(chan struct{})}
24- ac.initialSnatchedGeneration = generation
25- ac.initialSnatchedAttempt = attempt
26- return attempt, true
27-}
28-
29-func (ac *attachedClient) initialSnatchedPanelClaimed(generation uint64) bool {
30- ac.initialSnatchedMu.Lock()
31- defer ac.initialSnatchedMu.Unlock()
32- return ac.initialSnatchedGeneration == generation && ac.initialSnatchedAttempt != nil
33-}
34-
35-func (ac *attachedClient) completeInitialSnatchedPanel(attempt *initialSnatchedPanelAttempt, err error) {
36- ac.initialSnatchedMu.Lock()
37- attempt.err = err
38- close(attempt.done)
39- ac.initialSnatchedMu.Unlock()
40-}
41-
42-func (d *Daemon) sendInitialSnatchedPanel(token attachmentRoleToken, ticket *roleEffectTicket) error {
43- attempt, owner := token.ac.claimInitialSnatchedPanel(token.generation)
44- if attempt == nil {
45- return errSnatchedOutputStale
46- }
47- if !owner {
48- <-attempt.done
49- return attempt.err
50- }
51-
52- if !token.current() || !d.clearForSnatch(token) || !d.clearCaptureFramesForSnatch(token) {
53- token.ac.completeInitialSnatchedPanel(attempt, errSnatchedOutputStale)
54- return errSnatchedOutputStale
55- }
56- err := d.sendSnatchedPanel(token.ac, token.transport, token.generation, "", ticket)
57- token.ac.completeInitialSnatchedPanel(attempt, err)
58- return err
59-}
60-
61-func (d *Daemon) clearCaptureFramesForSnatch(token attachmentRoleToken) bool {
62- ac := token.ac
63- ac.sendMu.Lock()
64- defer ac.sendMu.Unlock()
65- if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) {
66- return false
67- }
68- ac.captureFrames = nil
69- return true
70-}
71-
72-func (d *Daemon) sendSnatchedControl(token attachmentRoleToken, frame ports.Frame) error {
73- ac := token.ac
74- ac.sendMu.Lock()
75- defer ac.sendMu.Unlock()
76- if ac.roleGeneration.Load() != token.generation {
77- return errSnatchedOutputStale
78- }
79- if !ac.transportSnapshotCurrent(token.transport) {
80- return errTransportReplaced
81- }
82- if token.effect == nil || !token.effect.beginTransportSend(token.transport) {
83- return errAttachmentTransition
84- }
85- err := token.transport.transport.Send(frame)
86- if err != nil {
87- token.effect.reportTransportFailure(token.transport)
88- }
89- token.effect.endTransportSend()
90- return err
91-}
92-
93-// boundedSendSnatchedControl keeps a best-effort control acknowledgement bound
94-// to the exact admitted role and link incarnation. On deadline it closes only
95-// that captured link, releasing either the transport Send or a wait on sendMu.
96-func (d *Daemon) boundedSendSnatchedControl(token attachmentRoleToken, frame ports.Frame) error {
97- if token.ac == nil || token.effect == nil || token.transport.transport == nil {
98- return errAttachmentTransition
99- }
100- _, err := d.boundedSendWith(token.transport.transport, func() error {
101- return d.sendSnatchedControl(token, frame)
102- })
103- if errors.Is(err, errSendTimedOut) {
104- _ = token.ac.closeCapturedTransport(token.transport.transport)
105- }
106- return err
107-}
D internal/usecase/daemon/snatched_client_test.go +0 -1145
@@ -1,1145 +0,0 @@
1-package daemon
2-
3-import (
4- "context"
5- "errors"
6- "io"
7- "sync"
8- "testing"
9- "time"
10-
11- "github.com/stretchr/testify/require"
12-
13- "github.com/bnema/vev/internal/domain"
14- "github.com/bnema/vev/internal/ports"
15- "github.com/bnema/vev/internal/usecase/keys"
16- themeui "github.com/bnema/vev/internal/usecase/theme"
17- "github.com/bnema/vev/pkg/renderer"
18-)
19-
20-func TestSessionAttachmentRole(t *testing.T) {
21- active := &attachedClient{}
22- waiting := &attachedClient{}
23- unknown := &attachedClient{}
24- sess := &session{sessionCore: sessionCore{client: active, snatched: map[*attachedClient]struct{}{waiting: {}}}}
25-
26- require.Equal(t, attachmentActive, sess.attachmentRole(active))
27- require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting))
28- require.Equal(t, attachmentDetached, sess.attachmentRole(unknown))
29-}
30-
31-func TestTransitionAttachmentReplacesActiveMembership(t *testing.T) {
32- d := newTestDaemon(t, nil, stubClock{})
33- sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}}
34- oldTransport := &closeTrackingTransport{}
35- old := &attachedClient{tr: oldTransport}
36- old.setSession(sess)
37- sess.client = old
38- d.sessions[sess.id] = sess
39-
40- newTransport := &closeTrackingTransport{}
41- next := &attachedClient{tr: newTransport}
42- expected := next.transportSnapshot()
43- result, err := d.transitionAttachment(attachmentTransitionRequest{
44- target: sess,
45- next: next,
46- expectedRole: attachmentDetached,
47- targetRole: attachmentActive,
48- expectedTransport: expected,
49- })
50- require.NoError(t, err)
51-
52- sess.mu.Lock()
53- require.Same(t, next, sess.client)
54- require.Contains(t, sess.snatched, old)
55- _, nextSnatched := sess.snatched[next]
56- require.False(t, nextSnatched)
57- sess.mu.Unlock()
58- require.Equal(t, attachmentActive, sess.attachmentRole(next))
59- require.Equal(t, attachmentSnatched, sess.attachmentRole(old))
60- require.True(t, result.published.current())
61- require.True(t, result.displaced.current())
62- require.Equal(t, uint64(1), result.published.generation)
63- require.Equal(t, uint64(1), result.displaced.generation)
64- require.Same(t, sess, next.currentSession())
65- require.Same(t, sess, old.currentSession())
66-}
67-
68-func TestDeferredAttachmentCleanupKeepsReplacementOpenAndSendsResetPanel(t *testing.T) {
69- d := newTestDaemon(t, nil, stubClock{})
70- sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}}
71- oldTransport := &closeTrackingTransport{}
72- old := &attachedClient{
73- tr: oldTransport,
74- output: newOutputStateStream(),
75- size: domain.Size{Cols: 80, Rows: 24},
76- }
77- old.initOverlays()
78- old.setThemeForTest(themeui.Theme{})
79- old.setSession(sess)
80- sess.client = old
81- d.sessions[sess.id] = sess
82-
83- next := &attachedClient{tr: &closeTrackingTransport{}}
84- result, err := d.transitionAttachment(attachmentTransitionRequest{
85- target: sess,
86- next: next,
87- expectedRole: attachmentDetached,
88- targetRole: attachmentActive,
89- expectedTransport: next.transportSnapshot(),
90- })
91- require.NoError(t, err)
92-
93- d.deferAttachmentTransitionCleanups(result)
94- d.attachmentCleanupWg.Wait()
95-
96- require.False(t, oldTransport.Closed(), "snatched transport must stay open")
97- frames := oldTransport.Sends()
98- require.Len(t, frames, 1)
99- require.Equal(t, ports.MsgOutput, frames[0].Type)
100- output, err := ports.UnmarshalOutput(frames[0].Payload)
101- require.NoError(t, err)
102- require.Contains(t, string(output.Data), "Session snatched")
103-}
104-
105-func TestActiveFrameRevalidatesExactRoleBeforeEveryEffect(t *testing.T) {
106- tests := []struct {
107- name string
108- setup func(*attachedClient, *session, *transactionalResizePTY)
109- frame func() ports.Frame
110- assert func(*testing.T, *Daemon, *session, *attachedClient, *attachedClient, *transactionalResizePTY, *datagramTestTransport)
111- }{
112- {
113- name: "forwarded input",
114- frame: func() ports.Frame { return frameInput([]byte("stale")) },
115- assert: func(t *testing.T, _ *Daemon, _ *session, _ *attachedClient, _ *attachedClient, pty *transactionalResizePTY, _ *datagramTestTransport) {
116- require.Empty(t, pty.writes(), "stale input reached the PTY")
117- },
118- },
119- {
120- name: "key action",
121- frame: func() ports.Frame { return frameInput([]byte("\x1b ")) },
122- assert: func(t *testing.T, _ *Daemon, _ *session, old, next *attachedClient, _ *transactionalResizePTY, _ *datagramTestTransport) {
123- require.False(t, old.overlays.paletteActive(), "stale key action mutated the displaced overlay")
124- require.False(t, next.overlays.paletteActive(), "stale key action mutated the active overlay")
125- },
126- },
127- {
128- name: "mouse input",
129- setup: func(_ *attachedClient, sess *session, _ *transactionalResizePTY) {
130- pane := sess.activeTab().focusedPane()
131- pane.mu.Lock()
132- pane.screen.Write([]byte("\x1b[?1000h"))
133- pane.mu.Unlock()
134- },
135- frame: func() ports.Frame { return frameInput([]byte("\x1b[<0;1;2M")) },
136- assert: func(t *testing.T, _ *Daemon, _ *session, _ *attachedClient, _ *attachedClient, pty *transactionalResizePTY, _ *datagramTestTransport) {
137- require.Empty(t, pty.writes(), "stale mouse input reached the PTY")
138- },
139- },
140- {
141- name: "image push",
142- frame: func() ports.Frame {
143- return ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{Mime: "image/png", Data: []byte("stale-image")})}
144- },
145- assert: func(t *testing.T, _ *Daemon, sess *session, _ *attachedClient, _ *attachedClient, pty *transactionalResizePTY, _ *datagramTestTransport) {
146- sess.mu.Lock()
147- files := append([]string(nil), sess.clipFiles...)
148- sess.mu.Unlock()
149- require.Empty(t, files, "stale image push created a session clipboard file")
150- require.Empty(t, pty.writes(), "stale image path reached the PTY")
151- },
152- },
153- {
154- name: "resize",
155- frame: func() ports.Frame {
156- return ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 120, Rows: 40}})}
157- },
158- assert: func(t *testing.T, _ *Daemon, sess *session, old, _ *attachedClient, pty *transactionalResizePTY, _ *datagramTestTransport) {
159- require.Empty(t, pty.requested(), "stale resize reached a session PTY")
160- require.Nil(t, sess.renderCoordinator().attachmentLease(old), "stale resize rebound the coordinator to the displaced client")
161- },
162- },
163- {
164- name: "theme",
165- frame: func() ports.Frame {
166- return ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(ports.Theme{
167- HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
168- HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
169- })}
170- },
171- assert: func(t *testing.T, _ *Daemon, _ *session, old, _ *attachedClient, _ *transactionalResizePTY, _ *datagramTestTransport) {
172- require.False(t, old.getClientTheme().HasFG, "stale theme mutated the displaced client")
173- },
174- },
175- {
176- name: "client notice",
177- frame: func() ports.Frame {
178- return ports.Frame{Type: ports.MsgClientNotice, Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: ports.ClientNoticeClipboardFallback})}
179- },
180- assert: func(t *testing.T, d *Daemon, _ *session, old, next *attachedClient, _ *transactionalResizePTY, _ *datagramTestTransport) {
181- require.Empty(t, d.notices.history(), "stale notice mutated daemon history")
182- oldToasts, _ := visibleToasts(old)
183- nextToasts, _ := visibleToasts(next)
184- require.Empty(t, oldToasts)
185- require.Empty(t, nextToasts)
186- },
187- },
188- {
189- name: "ack",
190- setup: func(old *attachedClient, _ *session, _ *transactionalResizePTY) {
191- old.output.next = 2
192- },
193- frame: func() ports.Frame {
194- return ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: 1})}
195- },
196- assert: func(t *testing.T, _ *Daemon, _ *session, old, _ *attachedClient, _ *transactionalResizePTY, _ *datagramTestTransport) {
197- require.Equal(t, uint64(2), old.output.outstanding(), "stale ack retired displaced output state")
198- },
199- },
200- {
201- name: "ping",
202- frame: func() ports.Frame { return ports.Frame{Type: ports.MsgPing} },
203- assert: func(t *testing.T, _ *Daemon, _ *session, _ *attachedClient, _ *attachedClient, _ *transactionalResizePTY, tr *datagramTestTransport) {
204- select {
205- case frame := <-tr.sends:
206- t.Fatalf("stale ping wrote frame type %d", frame.Type)
207- default:
208- }
209- },
210- },
211- }
212-
213- for _, tt := range tests {
214- t.Run(tt.name, func(t *testing.T) {
215- pty := &transactionalResizePTY{}
216- d, sess, old, _ := newManualSessionWithPTYs(t, pty)
217- d.tempDir = t.TempDir()
218- oldTransport := newDatagramTestTransport()
219- old.replaceTransport(oldTransport)
220- old.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: old}, &d.bindings)
221- rc := d.attachCoordinator(sess, nil, old, true)
222- token := sess.attachmentToken(old, oldTransport)
223- token.lease = rc.attachmentLease(old)
224- require.True(t, token.activeCurrent())
225- if tt.setup != nil {
226- tt.setup(old, sess, pty)
227- }
228-
229- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
230- next.initOverlays()
231- var transition attachmentTransitionResult
232- d.afterActiveFrameDispatch = func(attachmentRoleToken) {
233- d.afterActiveFrameDispatch = nil
234- var err error
235- transition, err = d.transitionAttachment(attachmentTransitionRequest{
236- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
237- expectedTransport: next.transportSnapshot(), ready: true,
238- })
239- require.NoError(t, err)
240- }
241-
242- d.handleActiveClientFrame(token, tt.frame())
243- for _, cleanup := range transition.cleanups {
244- cleanup.finish()
245- }
246- tt.assert(t, d, sess, old, next, pty, oldTransport)
247- })
248- }
249-}
250-
251-func TestKillSessionClosesActiveAndAllSnatchedClients(t *testing.T) {
252- pty := &transactionalResizePTY{}
253- d, sess, first, _ := newManualSessionWithPTYs(t, pty)
254- firstTransport := newDatagramTestTransport()
255- first.replaceTransport(firstTransport)
256-
257- secondTransport := newDatagramTestTransport()
258- second := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: first.size}
259- second.initOverlays()
260- firstReplacement, err := d.transitionAttachment(attachmentTransitionRequest{
261- target: sess, next: second, expectedRole: attachmentDetached, targetRole: attachmentActive,
262- expectedTransport: second.transportSnapshot(), ready: true,
263- })
264- require.NoError(t, err)
265- for _, cleanup := range firstReplacement.cleanups {
266- cleanup.finish()
267- }
268-
269- thirdTransport := newDatagramTestTransport()
270- third := &attachedClient{tr: thirdTransport, output: newOutputStateStream(), size: first.size}
271- third.initOverlays()
272- secondReplacement, err := d.transitionAttachment(attachmentTransitionRequest{
273- target: sess, next: third, expectedRole: attachmentDetached, targetRole: attachmentActive,
274- expectedTransport: third.transportSnapshot(), ready: true,
275- })
276- require.NoError(t, err)
277- for _, cleanup := range secondReplacement.cleanups {
278- cleanup.finish()
279- }
280-
281- otherCtx, otherCancel := context.WithCancel(d.serveCtx)
282- defer otherCancel()
283- otherTransport := newDatagramTestTransport()
284- otherClient := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: first.size}
285- otherClient.initOverlays()
286- other := &session{sessionCore: sessionCore{id: "other", name: "other", client: otherClient}, ctx: otherCtx, cancel: otherCancel}
287- otherClient.setSession(other)
288- d.mu.Lock()
289- d.sessions[other.id] = other
290- d.mu.Unlock()
291- d.attachCoordinator(other, nil, otherClient, true)
292-
293- roles := []*attachedClient{first, second, third}
294- generations := make([]uint64, len(roles))
295- for i, ac := range roles {
296- generations[i] = ac.roleGeneration.Load()
297- }
298- var killedLoops sync.WaitGroup
299- for _, ac := range roles {
300- killedLoops.Go(func() { d.runConnLoop(ac) })
301- }
302- otherDone := make(chan struct{})
303- go func() {
304- d.runConnLoop(otherClient)
305- close(otherDone)
306- }()
307- defer func() {
308- _ = firstTransport.Close()
309- _ = secondTransport.Close()
310- _ = thirdTransport.Close()
311- _ = otherTransport.Close()
312- killedLoops.Wait()
313- <-otherDone
314- }()
315-
316- require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
317- d.waitNotifies()
318- select {
319- case <-waitGroupDone(&killedLoops):
320- case <-time.After(2 * time.Second):
321- t.Fatal("killed active and snatched connection loops did not retire")
322- }
323-
324- sess.mu.Lock()
325- require.Nil(t, sess.client)
326- require.Empty(t, sess.snatched)
327- sess.mu.Unlock()
328- for i, ac := range roles {
329- require.Nil(t, ac.currentSession())
330- require.Greater(t, ac.roleGeneration.Load(), generations[i], "session kill did not invalidate role generation")
331- }
332- for _, transport := range []*datagramTestTransport{firstTransport, secondTransport, thirdTransport} {
333- detached := awaitFrame(t, transport.sends, ports.MsgDetached)
334- message, err := ports.UnmarshalDetached(detached.Payload)
335- require.NoError(t, err)
336- require.Equal(t, ports.ReasonSessionKilled, message.Reason)
337- _, recvErr := transport.Recv()
338- require.ErrorIs(t, recvErr, io.EOF)
339- }
340- require.Same(t, otherClient, other.client)
341- require.Same(t, other, otherClient.currentSession())
342- require.Equal(t, attachmentActive, other.attachmentRole(otherClient))
343-
344- otherTransport.recv <- ports.Frame{Type: ports.MsgPing}
345- require.Equal(t, ports.MsgPong, awaitFrame(t, otherTransport.sends, ports.MsgPong).Type)
346- select {
347- case <-otherDone:
348- t.Fatal("killing another session retired the active client's connection")
349- default:
350- }
351-}
352-
353-func TestKillSessionRetiresParkedSnatchedAttachment(t *testing.T) {
354- pty, release := newBlockingPTY(t)
355- defer release()
356- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
357-
358- parkedTransport := &closeTrackingTransport{}
359- sess, parkedClient, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), parkedTransport)
360- require.NoError(t, err)
361- parkedToken := parkedClient.resumeToken
362- d.clientGone(sess, parkedClient, parkedTransport, false)
363-
364- activeTransport := &closeTrackingTransport{}
365- _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport)
366- require.NoError(t, err)
367- d.mu.Lock()
368- parked := d.parked[parkedToken]
369- d.mu.Unlock()
370- require.NotNil(t, parked)
371- require.Equal(t, attachmentSnatched, parked.role)
372- parkedGeneration := parkedClient.roleGeneration.Load()
373-
374- require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
375- d.waitNotifies()
376-
377- require.Nil(t, parkedClient.transport(), "terminal teardown must revoke the parked link ownership")
378- require.Greater(t, parkedClient.roleGeneration.Load(), parkedGeneration, "terminal teardown must invalidate the parked role generation")
379- require.Zero(t, parkedClient.resumeToken)
380- require.False(t, parkedClient.parked)
381- select {
382- case <-parked.done:
383- default:
384- t.Fatal("terminal teardown left the parked expiry goroutine live")
385- }
386- require.True(t, parkedTransport.Closed())
387- require.Nil(t, parkedClient.currentSession())
388- require.Nil(t, active.currentSession())
389- require.True(t, activeTransport.Closed())
390-}
391-
392-func TestDaemonShutdownClosesSnatchedClients(t *testing.T) {
393- pty, release := newBlockingPTY(t)
394- defer release()
395- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
396-
397- parkedTransport := &closeTrackingTransport{}
398- sess, parkedClient, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), parkedTransport)
399- require.NoError(t, err)
400- parkedToken := parkedClient.resumeToken
401- d.clientGone(sess, parkedClient, parkedTransport, false)
402-
403- waitingTransport := &closeTrackingTransport{}
404- _, waiting, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), waitingTransport)
405- require.NoError(t, err)
406- activeTransport := &closeTrackingTransport{}
407- _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport)
408- require.NoError(t, err)
409- d.attachmentCleanupWg.Wait()
410-
411- d.mu.Lock()
412- parked := d.parked[parkedToken]
413- d.mu.Unlock()
414- require.NotNil(t, parked)
415- require.Equal(t, attachmentSnatched, parked.role)
416- parkedGeneration := parkedClient.roleGeneration.Load()
417- require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting))
418- require.Equal(t, attachmentActive, sess.attachmentRole(active))
419-
420- require.False(t, d.shutdownAll(ports.ReasonServerShutdown))
421- d.waitNotifies()
422- d.attachmentCleanupWg.Wait()
423-
424- require.Nil(t, parkedClient.transport(), "shutdown must revoke parked attachment link ownership")
425- require.Greater(t, parkedClient.roleGeneration.Load(), parkedGeneration)
426- select {
427- case <-parked.done:
428- default:
429- t.Fatal("shutdown left the parked expiry goroutine live")
430- }
431- for _, client := range []*attachedClient{parkedClient, waiting, active} {
432- require.Nil(t, client.currentSession())
433- }
434- for _, transport := range []*closeTrackingTransport{parkedTransport, waitingTransport, activeTransport} {
435- require.True(t, transport.Closed())
436- }
437- for _, transport := range []*closeTrackingTransport{waitingTransport, activeTransport} {
438- frames := transport.Sends()
439- require.NotEmpty(t, frames)
440- detached, err := ports.UnmarshalDetached(frames[len(frames)-1].Payload)
441- require.NoError(t, err)
442- require.Equal(t, ports.ReasonServerShutdown, detached.Reason)
443- }
444-}
445-
446-func TestSnatchedQuitDoesNotResetActivePaneThemeOrSize(t *testing.T) {
447- pty := &transactionalResizePTY{}
448- d, sess, waiting, _ := newManualSessionWithPTYs(t, pty)
449- waitingTransport := &closeTrackingTransport{}
450- waiting.replaceTransport(waitingTransport)
451-
452- activeTransport := &closeTrackingTransport{}
453- active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size}
454- active.initOverlays()
455- transition, err := d.transitionAttachment(attachmentTransitionRequest{
456- target: sess, next: active, expectedRole: attachmentDetached, targetRole: attachmentActive,
457- expectedTransport: active.transportSnapshot(), ready: true,
458- })
459- require.NoError(t, err)
460- for _, cleanup := range transition.cleanups {
461- cleanup.finish()
462- }
463-
464- activeTheme := ports.Theme{
465- HasForeground: true, Foreground: renderer.RGB{R: 12, G: 34, B: 56},
466- HasBackground: true, Background: renderer.RGB{R: 65, G: 43, B: 21},
467- TrueColor: true,
468- }
469- d.applyTheme(sess, active, activeTheme)
470- activeSize := domain.Size{Cols: 101, Rows: 37}
471- d.resize(sess, active, activeSize)
472- require.Equal(t, activeSize, active.size)
473- assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
474- requestedBeforeQuit := pty.requested()
475-
476- token := sess.attachmentToken(waiting, waitingTransport)
477- require.True(t, d.handleSnatchedClientFrame(token, ports.Frame{
478- Type: ports.MsgInput,
479- Payload: ports.MarshalInput(ports.Input{
480- Data: []byte{'q'},
481- }),
482- }))
483-
484- require.Equal(t, attachmentDetached, sess.attachmentRole(waiting))
485- require.Same(t, active, sess.client)
486- require.False(t, activeTransport.Closed())
487- require.True(t, waitingTransport.Closed())
488- require.Equal(t, activeSize, active.size)
489- require.Equal(t, requestedBeforeQuit, pty.requested(), "snatched quit must not resize active panes")
490- assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
491-}
492-
493-func TestSnatchedConnectionInputCannotReachPTY(t *testing.T) {
494- writes := make(chan []byte, 1)
495- pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }}
496- d, sess, old, _ := newManualSessionWithPTYs(t, pty)
497- oldTransport := newDatagramTestTransport()
498- old.replaceTransport(oldTransport)
499-
500- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
501- next.initOverlays()
502- result, err := d.transitionAttachment(attachmentTransitionRequest{
503- target: sess,
504- next: next,
505- expectedRole: attachmentDetached,
506- targetRole: attachmentActive,
507- expectedTransport: next.transportSnapshot(),
508- })
509- require.NoError(t, err)
510- d.deferAttachmentTransitionCleanups(result)
511- d.attachmentCleanupWg.Wait()
512- awaitFrame(t, oldTransport.sends, ports.MsgOutput)
513-
514- oldTransport.recv <- frameInput([]byte("restricted"))
515- require.NoError(t, oldTransport.Close())
516- d.runConnLoop(old)
517-
518- select {
519- case got := <-writes:
520- t.Fatalf("snatched input reached PTY: %q", got)
521- default:
522- }
523-}
524-
525-func TestStaleActiveKeyForwardCannotReachPTYAfterSnatch(t *testing.T) {
526- writes := make(chan []byte, 1)
527- pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }}
528- d, sess, old, _ := newManualSessionWithPTYs(t, pty)
529- old.replaceTransport(&closeTrackingTransport{})
530-
531- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
532- next.initOverlays()
533- _, err := d.transitionAttachment(attachmentTransitionRequest{
534- target: sess,
535- next: next,
536- expectedRole: attachmentDetached,
537- targetRole: attachmentActive,
538- expectedTransport: next.transportSnapshot(),
539- })
540- require.NoError(t, err)
541-
542- (daemonKeyHandler{d: d, ac: old}).Forward([]byte("stale"))
543-
544- select {
545- case got := <-writes:
546- t.Fatalf("stale active key forward reached PTY: %q", got)
547- default:
548- }
549-}
550-
551-func TestSnatchedConnectionResizeUpdatesPanelWithoutResizingSession(t *testing.T) {
552- pty := &transactionalResizePTY{}
553- d, sess, old, _ := newManualSessionWithPTYs(t, pty)
554- oldTransport := newDatagramTestTransport()
555- old.replaceTransport(oldTransport)
556-
557- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
558- next.initOverlays()
559- result, err := d.transitionAttachment(attachmentTransitionRequest{
560- target: sess,
561- next: next,
562- expectedRole: attachmentDetached,
563- targetRole: attachmentActive,
564- expectedTransport: next.transportSnapshot(),
565- })
566- require.NoError(t, err)
567- d.deferAttachmentTransitionCleanups(result)
568- d.attachmentCleanupWg.Wait()
569- awaitFrame(t, oldTransport.sends, ports.MsgOutput)
570-
571- want := domain.Size{Cols: 100, Rows: 30}
572- oldTransport.recv <- ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: want})}
573- require.NoError(t, oldTransport.Close())
574- d.runConnLoop(old)
575-
576- require.Equal(t, want, old.size)
577- require.Empty(t, pty.requested(), "snatched resize must not resize session PTYs")
578- panel := awaitFrame(t, oldTransport.sends, ports.MsgOutput)
579- output, err := ports.UnmarshalOutput(panel.Payload)
580- require.NoError(t, err)
581- require.Contains(t, string(output.Data), "Session snatched")
582-}
583-
584-func TestSnatchedConnectionAcknowledgesPanelAndAnswersPing(t *testing.T) {
585- d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
586- oldTransport := newDatagramTestTransport()
587- old.replaceTransport(oldTransport)
588-
589- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
590- next.initOverlays()
591- result, err := d.transitionAttachment(attachmentTransitionRequest{
592- target: sess,
593- next: next,
594- expectedRole: attachmentDetached,
595- targetRole: attachmentActive,
596- expectedTransport: next.transportSnapshot(),
597- })
598- require.NoError(t, err)
599- d.deferAttachmentTransitionCleanups(result)
600- d.attachmentCleanupWg.Wait()
601- panelFrame := awaitFrame(t, oldTransport.sends, ports.MsgOutput)
602- panel, err := ports.UnmarshalOutput(panelFrame.Payload)
603- require.NoError(t, err)
604- require.Equal(t, uint64(1), old.output.outstanding())
605-
606- oldTransport.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: panel.NewStateNum})}
607- oldTransport.recv <- ports.Frame{Type: ports.MsgPing}
608- require.NoError(t, oldTransport.Close())
609- d.runConnLoop(old)
610-
611- require.Zero(t, old.output.outstanding())
612- require.Equal(t, ports.MsgPong, awaitFrame(t, oldTransport.sends, ports.MsgPong).Type)
613-}
614-
615-func TestSnatchedConnectionThemeRepaintsPanelWithoutRecoloringSession(t *testing.T) {
616- d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
617- oldTransport := newDatagramTestTransport()
618- old.replaceTransport(oldTransport)
619-
620- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
621- next.initOverlays()
622- result, err := d.transitionAttachment(attachmentTransitionRequest{
623- target: sess,
624- next: next,
625- expectedRole: attachmentDetached,
626- targetRole: attachmentActive,
627- expectedTransport: next.transportSnapshot(),
628- })
629- require.NoError(t, err)
630- d.deferAttachmentTransitionCleanups(result)
631- d.attachmentCleanupWg.Wait()
632- awaitFrame(t, oldTransport.sends, ports.MsgOutput)
633-
634- activeTheme := ports.Theme{
635- HasForeground: true, Foreground: renderer.RGB{R: 10, G: 20, B: 30},
636- HasBackground: true, Background: renderer.RGB{R: 40, G: 50, B: 60},
637- TrueColor: true,
638- }
639- d.applyTheme(sess, next, activeTheme)
640- snatchedTheme := ports.Theme{
641- HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
642- HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
643- TrueColor: true,
644- }
645- oldTransport.recv <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(snatchedTheme)}
646- require.NoError(t, oldTransport.Close())
647- d.runConnLoop(old)
648-
649- require.Equal(t, snatchedTheme.Foreground, old.getAppliedTheme().Raw.Foreground)
650- assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
651- panel := awaitFrame(t, oldTransport.sends, ports.MsgOutput)
652- output, err := ports.UnmarshalOutput(panel.Payload)
653- require.NoError(t, err)
654- require.Contains(t, string(output.Data), "Session snatched")
655-}
656-
657-func TestSnatchedConnectionDetachRemovesOnlySnatchedAttachment(t *testing.T) {
658- d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
659- oldTransport := newDatagramTestTransport()
660- old.replaceTransport(oldTransport)
661-
662- activeTransport := &closeTrackingTransport{}
663- next := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: old.size}
664- next.initOverlays()
665- result, err := d.transitionAttachment(attachmentTransitionRequest{
666- target: sess,
667- next: next,
668- expectedRole: attachmentDetached,
669- targetRole: attachmentActive,
670- expectedTransport: next.transportSnapshot(),
671- })
672- require.NoError(t, err)
673- d.deferAttachmentTransitionCleanups(result)
674- d.attachmentCleanupWg.Wait()
675- awaitFrame(t, oldTransport.sends, ports.MsgOutput)
676-
677- oldTransport.recv <- ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}
678- d.runConnLoop(old)
679-
680- detached := awaitFrame(t, oldTransport.sends, ports.MsgDetached)
681- message, err := ports.UnmarshalDetached(detached.Payload)
682- require.NoError(t, err)
683- require.Equal(t, ports.ReasonDetach, message.Reason)
684- require.Equal(t, attachmentDetached, sess.attachmentRole(old))
685- require.Equal(t, attachmentActive, sess.attachmentRole(next))
686- require.Same(t, next, sess.client)
687- require.False(t, activeTransport.Closed())
688-}
689-
690-func TestReplacementClearsSnatchedOverlaysPreviewAndCaptures(t *testing.T) {
691- d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
692- oldTransport := &closeTrackingTransport{}
693- old.replaceTransport(oldTransport)
694-
695- d.enterPicker(sess, old)
696- d.enterPalette(sess, old)
697- d.enterPrompt(sess, old, " Rename ", "work", func(string) error { return nil })
698- d.enterCopyMode(sess, old)
699- d.enterNotices(sess, old)
700- old.overlays.resizeMu.Lock()
701- old.overlays.resizeActive = true
702- old.overlays.resizePending = []byte("pending")
703- old.overlays.resizeMu.Unlock()
704- old.sendMu.Lock()
705- old.captureFrames = map[*pane]capturedPaneRenderState{sess.activeTab().focusedPane(): {}}
706- old.sendMu.Unlock()
707- require.True(t, old.overlays.Active())
708-
709- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
710- next.initOverlays()
711- result, err := d.transitionAttachment(attachmentTransitionRequest{
712- target: sess,
713- next: next,
714- expectedRole: attachmentDetached,
715- targetRole: attachmentActive,
716- expectedTransport: next.transportSnapshot(),
717- })
718- require.NoError(t, err)
719- d.deferAttachmentTransitionCleanups(result)
720- d.attachmentCleanupWg.Wait()
721-
722- require.False(t, old.overlays.Active())
723- old.overlays.pickerMu.Lock()
724- require.Nil(t, old.overlays.pickerPreview)
725- require.Nil(t, old.overlays.pickerPreviewSession)
726- old.overlays.pickerMu.Unlock()
727- old.sendMu.Lock()
728- require.Empty(t, old.captureFrames)
729- old.sendMu.Unlock()
730- require.False(t, oldTransport.Closed())
731-}
732-
733-type failingSnatchedTransport struct {
734- mu sync.Mutex
735- closed bool
736-}
737-
738-func (*failingSnatchedTransport) Send(ports.Frame) error { return errors.New("send failed") }
739-func (*failingSnatchedTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
740-func (t *failingSnatchedTransport) Close() error {
741- t.mu.Lock()
742- t.closed = true
743- t.mu.Unlock()
744- return nil
745-}
746-func (t *failingSnatchedTransport) Closed() bool {
747- t.mu.Lock()
748- defer t.mu.Unlock()
749- return t.closed
750-}
751-
752-func TestSnatchedPanelSendFailureRemovesOnlyWaitingClient(t *testing.T) {
753- d := newTestDaemon(t, nil, stubClock{})
754- oldTransport := &failingSnatchedTransport{}
755- old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
756- old.initOverlays()
757- sess := &session{sessionCore: sessionCore{id: "work", client: old}}
758- old.setSession(sess)
759- d.sessions[sess.id] = sess
760-
761- activeTransport := &closeTrackingTransport{}
762- next := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: old.size}
763- next.initOverlays()
764- result, err := d.transitionAttachment(attachmentTransitionRequest{
765- target: sess,
766- next: next,
767- expectedRole: attachmentDetached,
768- targetRole: attachmentActive,
769- expectedTransport: next.transportSnapshot(),
770- })
771- require.NoError(t, err)
772- d.deferAttachmentTransitionCleanups(result)
773- d.attachmentCleanupWg.Wait()
774-
775- require.True(t, oldTransport.Closed())
776- require.Equal(t, attachmentDetached, sess.attachmentRole(old))
777- require.Equal(t, attachmentActive, sess.attachmentRole(next))
778- require.Same(t, next, sess.client)
779- require.False(t, activeTransport.Closed())
780-}
781-
782-func TestSnatchedPanelFailurePreservesActivePaneThemeAndSize(t *testing.T) {
783- pty := &transactionalResizePTY{}
784- d, sess, waiting, _ := newManualSessionWithPTYs(t, pty)
785- failed := &failingSnatchedTransport{}
786- waiting.replaceTransport(failed)
787-
788- activeTransport := &closeTrackingTransport{}
789- active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size}
790- active.initOverlays()
791- transition, err := d.transitionAttachment(attachmentTransitionRequest{
792- target: sess, next: active, expectedRole: attachmentDetached, targetRole: attachmentActive,
793- expectedTransport: active.transportSnapshot(), ready: true,
794- })
795- require.NoError(t, err)
796-
797- activeTheme := ports.Theme{
798- HasForeground: true, Foreground: renderer.RGB{R: 12, G: 34, B: 56},
799- HasBackground: true, Background: renderer.RGB{R: 65, G: 43, B: 21},
800- TrueColor: true,
801- }
802- d.applyTheme(sess, active, activeTheme)
803- activeSize := domain.Size{Cols: 103, Rows: 39}
804- d.resize(sess, active, activeSize)
805- require.Equal(t, activeSize, active.size)
806- assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
807- requestedBeforeFailure := pty.requested()
808-
809- d.deferAttachmentTransitionCleanups(transition)
810- d.attachmentCleanupWg.Wait()
811-
812- require.True(t, failed.Closed())
813- require.Equal(t, attachmentDetached, sess.attachmentRole(waiting))
814- require.Same(t, active, sess.client)
815- require.False(t, activeTransport.Closed())
816- require.Equal(t, activeSize, active.size)
817- require.Equal(t, requestedBeforeFailure, pty.requested(), "snatched panel failure must not resize active panes")
818- assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
819-}
820-
821-func TestSnatchedThemePanelFailureRemovesOnlySnatchedAttachment(t *testing.T) {
822- d := newTestDaemon(t, nil, stubClock{})
823- oldTransport := &failingSnatchedTransport{}
824- old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
825- old.initOverlays()
826- sess := &session{sessionCore: sessionCore{id: "work", client: old}}
827- old.setSession(sess)
828- d.sessions[sess.id] = sess
829-
830- activeTransport := &closeTrackingTransport{}
831- next := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: old.size}
832- next.initOverlays()
833- result, err := d.transitionAttachment(attachmentTransitionRequest{
834- target: sess,
835- next: next,
836- expectedRole: attachmentDetached,
837- targetRole: attachmentActive,
838- expectedTransport: next.transportSnapshot(),
839- })
840- require.NoError(t, err)
841- for _, cleanup := range result.cleanups {
842- cleanup.finish()
843- }
844-
845- theme := ports.Theme{
846- HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
847- HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
848- TrueColor: true,
849- }
850- require.True(t, d.handleSnatchedClientFrame(result.displaced, ports.Frame{
851- Type: ports.MsgTheme, Payload: ports.MarshalTheme(theme),
852- }))
853-
854- require.True(t, oldTransport.Closed())
855- require.Equal(t, attachmentDetached, sess.attachmentRole(old))
856- require.Equal(t, attachmentActive, sess.attachmentRole(next))
857- require.Same(t, next, sess.client)
858- require.False(t, activeTransport.Closed())
859-}
860-
861-func TestClearForSnatchReleasesEachOverlayFamilyBeforeTakingNext(t *testing.T) {
862- d := newTestDaemon(t, nil, stubClock{})
863- tr := &closeTrackingTransport{}
864- ac := &attachedClient{tr: tr, output: newOutputStateStream()}
865- ac.initOverlays()
866- ac.roleGeneration.Store(1)
867- sess := &session{sessionCore: sessionCore{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}}}
868- ac.setSession(sess)
869- token := attachmentRoleToken{
870- sess: sess, ac: ac, role: attachmentSnatched,
871- generation: 1, transport: ac.transportSnapshot(),
872- }
873-
874- pickerCleared := make(chan struct{})
875- d.afterSnatchOverlayFamily = func(family string) {
876- if family == "picker" {
877- close(pickerCleared)
878- }
879- }
880- ac.overlays.resizeMu.Lock()
881- resizeLocked := true
882- defer func() {
883- if resizeLocked {
884- ac.overlays.resizeMu.Unlock()
885- }
886- }()
887- cleared := make(chan bool, 1)
888- go func() { cleared <- d.clearForSnatch(token) }()
889- select {
890- case <-pickerCleared:
891- case <-time.After(2 * time.Second):
892- t.Fatal("picker family did not clear before blocked resize family")
893- }
894- require.True(t, ac.overlays.pickerMu.TryLock(), "clear retained pickerMu while waiting for resizeMu")
895- ac.overlays.pickerMu.Unlock()
896-
897- ac.overlays.resizeMu.Unlock()
898- resizeLocked = false
899- require.True(t, <-cleared)
900-}
901-
902-func TestClearForSnatchRejectsStaleGenerationWithoutClearingNewOverlay(t *testing.T) {
903- d := newTestDaemon(t, nil, stubClock{})
904- tr := &closeTrackingTransport{}
905- ac := &attachedClient{tr: tr, output: newOutputStateStream()}
906- ac.initOverlays()
907- ac.roleGeneration.Store(1)
908- sess := &session{sessionCore: sessionCore{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}}}
909- ac.setSession(sess)
910- token := attachmentRoleToken{
911- sess: sess, ac: ac, role: attachmentSnatched,
912- generation: 1, transport: ac.transportSnapshot(),
913- }
914-
915- ac.overlays.pickerMu.Lock()
916- started := make(chan struct{})
917- cleared := make(chan bool, 1)
918- go func() {
919- close(started)
920- cleared <- d.clearForSnatch(token)
921- }()
922- <-started
923- ac.roleGeneration.Add(1)
924- ac.overlays.resizeMu.Lock()
925- ac.overlays.resizeActive = true
926- ac.overlays.resizeMu.Unlock()
927- ac.overlays.pickerMu.Unlock()
928-
929- require.False(t, <-cleared)
930- require.True(t, ac.overlays.resizeModeActive(), "stale cleanup cleared a newer generation")
931-}
932-
933-func TestTransitionAttachmentRejectsBeforeOwnershipCommit(t *testing.T) {
934- tests := []struct {
935- name string
936- mutate func(*Daemon, *session, *attachedClient)
937- }{
938- {
939- name: "stale transport",
940- mutate: func(_ *Daemon, _ *session, next *attachedClient) {
941- next.replaceTransport(&closeTrackingTransport{})
942- },
943- },
944- {
945- name: "session lifecycle ended",
946- mutate: func(d *Daemon, sess *session, _ *attachedClient) {
947- delete(d.sessions, sess.id)
948- },
949- },
950- {
951- name: "unexpected role",
952- mutate: func(_ *Daemon, sess *session, next *attachedClient) {
953- sess.mu.Lock()
954- sess.addSnatchedLocked(next)
955- sess.mu.Unlock()
956- },
957- },
958- }
959-
960- for _, tt := range tests {
961- t.Run(tt.name, func(t *testing.T) {
962- d := newTestDaemon(t, nil, stubClock{})
963- sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}}
964- old := &attachedClient{tr: &closeTrackingTransport{}}
965- old.setSession(sess)
966- sess.client = old
967- d.sessions[sess.id] = sess
968- next := &attachedClient{tr: &closeTrackingTransport{}}
969- expected := next.transportSnapshot()
970- tt.mutate(d, sess, next)
971-
972- _, err := d.transitionAttachment(attachmentTransitionRequest{
973- target: sess,
974- next: next,
975- expectedRole: attachmentDetached,
976- targetRole: attachmentActive,
977- expectedTransport: expected,
978- })
979- require.ErrorIs(t, err, errAttachmentTransition)
980- require.Same(t, old, sess.client)
981- require.Zero(t, old.roleGeneration.Load())
982- require.Zero(t, next.roleGeneration.Load())
983- })
984- }
985-}
986-
987-func TestCrossSessionTransitionPublishesCoherentOwnershipBeforeBlockedFirstPaint(t *testing.T) {
988- d := newTestDaemon(t, nil, stubClock{})
989- from := &session{sessionCore: sessionCore{id: "z-source"}}
990- target := &session{sessionCore: sessionCore{id: "a-target"}}
991- moving := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
992- displaced := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: moving.size}
993- moving.initOverlays()
994- displaced.initOverlays()
995- moving.setSession(from)
996- displaced.setSession(target)
997- from.client = moving
998- target.client = displaced
999- d.sessions[from.id] = from
1000- d.sessions[target.id] = target
1001- sourceCoordinator := d.attachCoordinator(from, nil, moving, true)
1002- targetCoordinator := d.attachCoordinator(target, nil, displaced, true)
1003-
1004- moving.sendMu.Lock()
1005- locked := true
1006- defer func() {
1007- if locked {
1008- moving.sendMu.Unlock()
1009- }
1010- }()
1011- transitionDone := make(chan attachmentTransitionResult, 1)
1012- transitionErr := make(chan error, 1)
1013- go func() {
1014- result, err := d.transitionAttachment(attachmentTransitionRequest{
1015- source: from, target: target, next: moving,
1016- expectedRole: attachmentActive, targetRole: attachmentActive,
1017- expectedTransport: moving.transportSnapshot(), ready: true,
1018- })
1019- transitionDone <- result
1020- transitionErr <- err
1021- }()
1022-
1023- var result attachmentTransitionResult
1024- select {
1025- case result = <-transitionDone:
1026- require.NoError(t, <-transitionErr)
1027- case <-time.After(2 * time.Second):
1028- t.Fatal("ownership publication waited for blocked sendMu")
1029- }
1030-
1031- from.mu.Lock()
1032- require.Nil(t, from.client)
1033- from.mu.Unlock()
1034- target.mu.Lock()
1035- require.Same(t, moving, target.client)
1036- require.Contains(t, target.snatched, displaced)
1037- target.mu.Unlock()
1038- require.Same(t, target, moving.currentSession())
1039- sourceCoordinator.mu.Lock()
1040- require.NotNil(t, sourceCoordinator.lease)
1041- require.False(t, sourceCoordinator.lease.active)
1042- sourceCoordinator.mu.Unlock()
1043- targetCoordinator.mu.Lock()
1044- require.Same(t, result.published.lease, targetCoordinator.lease)
1045- require.True(t, targetCoordinator.lease.active)
1046- require.Same(t, moving, targetCoordinator.lease.attachment)
1047- targetCoordinator.mu.Unlock()
1048- require.True(t, result.published.activeCurrent())
1049-
1050- beforeSendWait := make(chan struct{})
1051- d.beforeFirstPaintSendWait = func(token attachmentRoleToken) {
1052- if token.ac == moving {
1053- close(beforeSendWait)
1054- }
1055- }
1056- paintDone := make(chan bool, 1)
1057- go func() {
1058- paintDone <- d.firstPaintForTransition(result.published)
1059- }()
1060- <-beforeSendWait
1061- require.True(t, d.mu.TryLock(), "first paint retained daemon lock while waiting for sendMu")
1062- d.mu.Unlock()
1063- require.True(t, d.notices.routingMu.TryLock(), "first paint retained routing lock while waiting for sendMu")
1064- d.notices.routingMu.Unlock()
1065- require.True(t, from.mu.TryLock(), "first paint retained source session lock while waiting for sendMu")
1066- from.mu.Unlock()
1067- require.True(t, target.mu.TryLock(), "first paint retained target session lock while waiting for sendMu")
1068- target.mu.Unlock()
1069- require.True(t, sourceCoordinator.mu.TryLock(), "first paint retained source coordinator lock while waiting for sendMu")
1070- sourceCoordinator.mu.Unlock()
1071- require.True(t, targetCoordinator.mu.TryLock(), "first paint retained target coordinator lock while waiting for sendMu")
1072- targetCoordinator.mu.Unlock()
1073-
1074- moving.sendMu.Unlock()
1075- locked = false
1076- require.True(t, <-paintDone)
1077-}
1078-
1079-func TestFirstPaintForTransitionRejectsStaleRoleTokenBeforeRebaseOrReset(t *testing.T) {
1080- d := newTestDaemon(t, nil, stubClock{})
1081- tr := &closeTrackingTransport{}
1082- ac := &attachedClient{
1083- tr: tr,
1084- output: newOutputStateStream(),
1085- size: domain.Size{Cols: 80, Rows: 24},
1086- }
1087- ac.initOverlays()
1088- ac.roleGeneration.Store(2)
1089- ac.output.next = 4
1090- ac.output.acked = 1
1091- sess := &session{sessionCore: sessionCore{id: "work", client: ac}}
1092- ac.setSession(sess)
1093- d.sessions[sess.id] = sess
1094- rc := d.attachCoordinator(sess, nil, ac, true)
1095- rebased := false
1096- ac.renderStages.handoffRebase = func() { rebased = true }
1097-
1098- stale := attachmentRoleToken{
1099- sess: sess, ac: ac, role: attachmentActive,
1100- generation: 1, transport: ac.transportSnapshot(), lease: rc.attachmentLease(ac), rebase: true,
1101- }
1102- require.False(t, d.firstPaintForTransition(stale))
1103-
1104- require.False(t, rebased)
1105- require.Equal(t, uint64(4), ac.output.next)
1106- require.Equal(t, uint64(1), ac.output.acked)
1107- require.Empty(t, tr.Sends(), "stale first paint emitted a reset")
1108-}
1109-
1110-func TestTransitionAttachmentMovesActiveBetweenSessions(t *testing.T) {
1111- d := newTestDaemon(t, nil, stubClock{})
1112- from := &session{sessionCore: sessionCore{id: domain.SessionID("z-source")}}
1113- target := &session{sessionCore: sessionCore{id: domain.SessionID("a-target")}}
1114- moving := &attachedClient{tr: &closeTrackingTransport{}}
1115- displaced := &attachedClient{tr: &closeTrackingTransport{}}
1116- moving.setSession(from)
1117- displaced.setSession(target)
1118- from.client = moving
1119- target.client = displaced
1120- d.sessions[from.id] = from
1121- d.sessions[target.id] = target
1122-
1123- result, err := d.transitionAttachment(attachmentTransitionRequest{
1124- source: from,
1125- target: target,
1126- next: moving,
1127- expectedRole: attachmentActive,
1128- targetRole: attachmentActive,
1129- expectedTransport: moving.transportSnapshot(),
1130- ready: true,
1131- })
1132- require.NoError(t, err)
1133-
1134- from.mu.Lock()
1135- require.Nil(t, from.client)
1136- require.NotContains(t, from.snatched, moving)
1137- from.mu.Unlock()
1138- target.mu.Lock()
1139- require.Same(t, moving, target.client)
1140- require.Contains(t, target.snatched, displaced)
1141- target.mu.Unlock()
1142- require.True(t, result.published.current())
1143- require.True(t, result.displaced.current())
1144- require.Same(t, target, moving.currentSession())
1145-}
D internal/usecase/daemon/snatched_input.go +0 -261
@@ -1,261 +0,0 @@
1-package daemon
2-
3-import (
4- "errors"
5-
6- "github.com/bnema/vev/internal/ports"
7- "github.com/bnema/vev/internal/usecase/keys"
8-)
9-
10-const snatchedInputSequenceLimit = 64
11-
12-type snatchedInputAction uint8
13-
14-const (
15- snatchedInputNone snatchedInputAction = iota
16- snatchedInputReclaim
17- snatchedInputQuit
18-)
19-
20-// clearSnatchedInput invalidates every parser continuation before stopping its
21-// timer. Callers must not hold daemon, routing, session, coordinator, send, or
22-// transport locks because Timer.Stop is an external call.
23-func (ac *attachedClient) clearSnatchedInput() {
24- if ac == nil {
25- return
26- }
27- ac.snatchedInputMu.Lock()
28- ac.snatchedInputPending = nil
29- ac.snatchedInputDrain = false
30- pendingESC := ac.snatchedInputESC
31- ac.snatchedInputESC = pendingByteTimer{}
32- ac.snatchedInputMu.Unlock()
33- pendingESC.stop()
34-}
35-
36-// handleSnatchedInput accepts actions only as exact one-byte input events.
37-// Terminal escape sequences and arbitrary coalesced input are consumed as
38-// indivisible events so a byte in a paste, sequence final, or frame tail can
39-// never become an action.
40-func (d *Daemon) handleSnatchedInput(token attachmentRoleToken, input ports.Input) bool {
41- action := d.parseSnatchedInput(token, input.Data)
42- switch action {
43- case snatchedInputReclaim:
44- d.reclaimSnatchedAttachment(token)
45- case snatchedInputQuit:
46- return d.quitSnatchedAttachment(token)
47- }
48- return false
49-}
50-
51-func (d *Daemon) parseSnatchedInput(token attachmentRoleToken, data []byte) snatchedInputAction {
52- ac := token.ac
53- if ac == nil || len(data) == 0 {
54- return snatchedInputNone
55- }
56-
57- ac.snatchedInputMu.Lock()
58-
59- if ac.snatchedInputDrain {
60- if snatchedSequenceFinal(data) >= 0 {
61- ac.snatchedInputDrain = false
62- }
63- ac.snatchedInputMu.Unlock()
64- return snatchedInputNone
65- }
66-
67- if len(ac.snatchedInputPending) != 0 {
68- pendingESC := ac.snatchedInputESC
69- ac.snatchedInputESC = pendingByteTimer{}
70- combined := make([]byte, 0, len(ac.snatchedInputPending)+len(data))
71- combined = append(combined, ac.snatchedInputPending...)
72- combined = append(combined, data...)
73- ac.snatchedInputPending = nil
74- d.retainSnatchedSequenceLocked(ac, combined)
75- ac.snatchedInputMu.Unlock()
76- pendingESC.stop()
77- return snatchedInputNone
78- }
79-
80- if len(data) == 1 {
81- var action snatchedInputAction
82- switch data[0] {
83- case 'r', 'R':
84- action = snatchedInputReclaim
85- case 'q', 'Q':
86- action = snatchedInputQuit
87- case keys.ESC:
88- ac.snatchedInputPending = append(ac.snatchedInputPending[:0], keys.ESC)
89- ac.snatchedInputESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
90- d.fireSnatchedEscape(token, timer)
91- })
92- }
93- ac.snatchedInputMu.Unlock()
94- return action
95- }
96-
97- // A complete/coalesced event is always ignored. Only an incomplete CSI or
98- // SS3 prefix is retained to prevent its future final byte from acting.
99- d.retainSnatchedSequenceLocked(ac, data)
100- ac.snatchedInputMu.Unlock()
101- return snatchedInputNone
102-}
103-
104-func (d *Daemon) retainSnatchedSequenceLocked(ac *attachedClient, data []byte) {
105- if !snatchedSequencePrefix(data) || snatchedSequenceFinal(data[2:]) >= 0 {
106- return
107- }
108- if len(data) > snatchedInputSequenceLimit {
109- ac.snatchedInputDrain = true
110- return
111- }
112- ac.snatchedInputPending = append(ac.snatchedInputPending[:0], data...)
113-}
114-
115-func snatchedSequencePrefix(data []byte) bool {
116- return len(data) >= 2 && data[0] == keys.ESC && (data[1] == '[' || data[1] == 'O')
117-}
118-
119-func snatchedSequenceFinal(data []byte) int {
120- for i, b := range data {
121- if b >= 0x40 && b <= 0x7e {
122- return i
123- }
124- }
125- return -1
126-}
127-
128-func (d *Daemon) fireSnatchedEscape(token attachmentRoleToken, timer ports.Timer) {
129- ac := token.ac
130- if ac == nil {
131- return
132- }
133- ac.snatchedInputMu.Lock()
134- if ac.snatchedInputESC.timer != timer || len(ac.snatchedInputPending) != 1 || ac.snatchedInputPending[0] != keys.ESC {
135- ac.snatchedInputMu.Unlock()
136- return
137- }
138- ac.snatchedInputPending = nil
139- ac.snatchedInputESC.timer = nil
140- ac.snatchedInputESC.done = nil
141- ac.snatchedInputMu.Unlock()
142-
143- if d.afterSnatchedEscapeAccepted != nil {
144- d.afterSnatchedEscapeAccepted()
145- }
146- if !token.current() {
147- if d.afterSnatchedEscapeAttempt != nil {
148- d.afterSnatchedEscapeAttempt(false)
149- }
150- return
151- }
152- ticket, admitted := ac.beginRoleEffect(token)
153- if d.afterSnatchedEscapeAttempt != nil {
154- d.afterSnatchedEscapeAttempt(admitted)
155- }
156- if !admitted {
157- return
158- }
159- token.effect = ticket
160- defer token.endRoleEffect()
161- d.quitSnatchedAttachment(token)
162-}
163-
164-// acquireActivationBarrier waits for the requester's serialized output path
165-// without retaining role, daemon, routing, session, coordinator, or pane locks.
166-// The caller has already frozen and drained the requester's role gate, so no new
167-// role-bound sender can race this acquisition. A deadline closes only the exact
168-// captured link, which releases a transport Send currently holding sendMu.
169-func (d *Daemon) acquireActivationBarrier(token attachmentRoleToken) (func(), error) {
170- if token.ac == nil || token.transport.transport == nil || !token.current() {
171- return nil, errAttachmentTransition
172- }
173-
174- acquired := make(chan struct{})
175- cancel := make(chan struct{})
176- go func() {
177- token.ac.sendMu.Lock()
178- select {
179- case acquired <- struct{}{}:
180- // The receiver now owns the matching Unlock obligation.
181- case <-cancel:
182- token.ac.sendMu.Unlock()
183- }
184- }()
185-
186- timer := d.clock.NewTimer(detachNotifyTimeout)
187- select {
188- case <-acquired:
189- timer.Stop()
190- if token.ac.roleGeneration.Load() != token.generation ||
191- !token.ac.transportSnapshotCurrent(token.transport) {
192- token.ac.sendMu.Unlock()
193- return nil, errAttachmentTransition
194- }
195- return token.ac.sendMu.Unlock, nil
196- case <-timer.C():
197- close(cancel)
198- _ = token.ac.closeCapturedTransport(token.transport.transport)
199- return nil, errSendTimedOut
200- }
201-}
202-
203-func (d *Daemon) reclaimSnatchedAttachment(token attachmentRoleToken) bool {
204- if token.role != attachmentSnatched || token.effect == nil {
205- return false
206- }
207- token.effect.bindActionEnd(d, "reclaim-snatched")
208- transition, err := d.transitionAttachment(attachmentTransitionRequest{
209- source: token.sess, target: token.sess, next: token.ac,
210- expectedRole: attachmentSnatched, targetRole: attachmentActive,
211- expectedTransport: token.transport, sourceToken: &token,
212- action: "reclaim-snatched", ready: true, activationBarrier: true,
213- })
214- if err != nil {
215- if errors.Is(err, errSendTimedOut) {
216- d.attachmentCleanupWg.Go(func() { d.parkOrDropSnatchedAttachment(token) })
217- } else {
218- d.showSnatchedUnavailable(token)
219- }
220- return false
221- }
222- d.deferAttachmentTransitionCleanups(transition)
223- if d.beforeReclaimFirstPaint != nil {
224- d.beforeReclaimFirstPaint(transition.published)
225- }
226- d.firstPaintForTransition(transition.published)
227- return true
228-}
229-
230-// showSnatchedUnavailable is a best-effort precommit failure panel. Fresh role
231-// admission and the generation-aware sender make it inert after a concurrent
232-// reclaim, quit, transport replacement, or session teardown.
233-func (d *Daemon) showSnatchedUnavailable(token attachmentRoleToken) {
234- if !token.current() {
235- return
236- }
237- fresh := token
238- fresh.effect = nil
239- ticket, admitted := token.ac.beginRoleEffect(fresh)
240- if !admitted {
241- return
242- }
243- fresh.effect = ticket
244- defer fresh.endRoleEffect()
245- if err := d.sendSnatchedPanel(token.ac, token.transport, token.generation, "Session is no longer available.", ticket); err != nil {
246- fresh.endRoleEffect()
247- d.parkOrDropSnatchedAttachment(token)
248- }
249-}
250-
251-func (d *Daemon) quitSnatchedAttachment(token attachmentRoleToken) bool {
252- if token.role != attachmentSnatched || token.effect == nil {
253- return false
254- }
255- token.effect.bindActionEnd(d, "quit-snatched")
256- _ = d.boundedSendSnatchedControl(token, frameDetached(ports.ReasonDetach))
257- // Terminal cleanup freezes the same role gate, so release this action's
258- // admission before attempting the generation-bound removal.
259- token.endRoleEffect()
260- return d.dropSnatchedAttachment(token)
261-}
D internal/usecase/daemon/snatched_input_test.go +0 -363
@@ -1,363 +0,0 @@
1-package daemon
2-
3-import (
4- "errors"
5- "io"
6- "strings"
7- "sync"
8- "testing"
9- "time"
10-
11- "github.com/stretchr/testify/require"
12-
13- "github.com/bnema/vev/internal/domain"
14- "github.com/bnema/vev/internal/ports"
15- "github.com/bnema/vev/internal/usecase/keys"
16-)
17-
18-type snatchedInputTimer struct {
19- ch chan time.Time
20-}
21-
22-func newSnatchedInputTimer() *snatchedInputTimer {
23- return &snatchedInputTimer{ch: make(chan time.Time, 1)}
24-}
25-
26-func (t *snatchedInputTimer) C() <-chan time.Time { return t.ch }
27-func (*snatchedInputTimer) Reset(time.Duration) bool { return false }
28-func (*snatchedInputTimer) Stop() bool { return true }
29-func (t *snatchedInputTimer) Fire() { t.ch <- time.Time{} }
30-
31-type snatchedInputClock struct {
32- timers chan *snatchedInputTimer
33-}
34-
35-func newSnatchedInputClock() *snatchedInputClock {
36- return &snatchedInputClock{timers: make(chan *snatchedInputTimer, 8)}
37-}
38-
39-func (*snatchedInputClock) Now() time.Time { return time.Time{} }
40-func (c *snatchedInputClock) NewTimer(delay time.Duration) ports.Timer {
41- if delay != keys.ESCDelay {
42- return stubTimer{}
43- }
44- timer := newSnatchedInputTimer()
45- c.timers <- timer
46- return timer
47-}
48-
49-type snatchedCloseSignalTransport struct {
50- mu sync.Mutex
51- closed bool
52- closedCh chan struct{}
53- closeOnce sync.Once
54-}
55-
56-func newSnatchedCloseSignalTransport() *snatchedCloseSignalTransport {
57- return &snatchedCloseSignalTransport{closedCh: make(chan struct{})}
58-}
59-
60-func (*snatchedCloseSignalTransport) Send(ports.Frame) error { return nil }
61-func (*snatchedCloseSignalTransport) Recv() (ports.Frame, error) {
62- return ports.Frame{}, errors.New("closed")
63-}
64-func (t *snatchedCloseSignalTransport) Close() error {
65- t.mu.Lock()
66- t.closed = true
67- t.mu.Unlock()
68- t.closeOnce.Do(func() { close(t.closedCh) })
69- return nil
70-}
71-func (t *snatchedCloseSignalTransport) Closed() bool {
72- t.mu.Lock()
73- defer t.mu.Unlock()
74- return t.closed
75-}
76-
77-type blockedSnatchedQuitTransport struct {
78- recv chan ports.Frame
79- sendStarted chan struct{}
80- sendDone chan struct{}
81- closed chan struct{}
82- closeOnce sync.Once
83- sendOnce sync.Once
84- mu sync.Mutex
85- isClosed bool
86-}
87-
88-func newBlockedSnatchedQuitTransport() *blockedSnatchedQuitTransport {
89- return &blockedSnatchedQuitTransport{
90- recv: make(chan ports.Frame, 1),
91- sendStarted: make(chan struct{}),
92- sendDone: make(chan struct{}),
93- closed: make(chan struct{}),
94- }
95-}
96-
97-func (t *blockedSnatchedQuitTransport) Send(frame ports.Frame) error {
98- if frame.Type != ports.MsgDetached {
99- return nil
100- }
101- t.sendOnce.Do(func() { close(t.sendStarted) })
102- <-t.closed
103- close(t.sendDone)
104- return io.ErrClosedPipe
105-}
106-
107-func (t *blockedSnatchedQuitTransport) Recv() (ports.Frame, error) {
108- select {
109- case frame := <-t.recv:
110- return frame, nil
111- case <-t.closed:
112- return ports.Frame{}, io.ErrClosedPipe
113- }
114-}
115-
116-func (t *blockedSnatchedQuitTransport) Close() error {
117- t.mu.Lock()
118- t.isClosed = true
119- t.mu.Unlock()
120- t.closeOnce.Do(func() { close(t.closed) })
121- return nil
122-}
123-
124-func (t *blockedSnatchedQuitTransport) Closed() bool {
125- t.mu.Lock()
126- defer t.mu.Unlock()
127- return t.isClosed
128-}
129-
130-func newSnatchedInputFixture(t *testing.T, clock ports.Clock) (*Daemon, *session, *attachedClient, *attachedClient, *snatchedCloseSignalTransport) {
131- t.Helper()
132- d := newTestDaemon(t, nil, clock)
133- waitingTransport := newSnatchedCloseSignalTransport()
134- waiting := &attachedClient{
135- tr: waitingTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24},
136- }
137- active := &attachedClient{
138- tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: waiting.size,
139- }
140- waiting.initOverlays()
141- active.initOverlays()
142- sess := &session{sessionCore: sessionCore{id: "strict-input", client: active, snatched: map[*attachedClient]struct{}{waiting: {}}}}
143- waiting.setSession(sess)
144- active.setSession(sess)
145- d.sessions[sess.id] = sess
146- d.attachCoordinator(sess, nil, active, true)
147- return d, sess, waiting, active, waitingTransport
148-}
149-
150-func sendSnatchedInput(d *Daemon, sess *session, ac *attachedClient, data string) bool {
151- tr := ac.transport()
152- token := sess.attachmentToken(ac, tr)
153- return d.handleSnatchedClientFrame(token, ports.Frame{
154- Type: ports.MsgInput,
155- Payload: ports.MarshalInput(ports.Input{Data: []byte(data)}),
156- })
157-}
158-
159-func TestSnatchedInputStrictActions(t *testing.T) {
160- tests := []struct {
161- name string
162- chunks []string
163- fireEscape bool
164- wantRole attachmentRole
165- wantClosed bool
166- }{
167- {name: "lowercase reclaim", chunks: []string{"r"}, wantRole: attachmentActive},
168- {name: "uppercase reclaim", chunks: []string{"R"}, wantRole: attachmentActive},
169- {name: "lowercase quit", chunks: []string{"q"}, wantRole: attachmentDetached, wantClosed: true},
170- {name: "uppercase quit", chunks: []string{"Q"}, wantRole: attachmentDetached, wantClosed: true},
171- {name: "standalone escape quits after delay", chunks: []string{"\x1b"}, fireEscape: true, wantRole: attachmentDetached, wantClosed: true},
172- {name: "coalesced reclaim ignored", chunks: []string{"rr"}, wantRole: attachmentSnatched},
173- {name: "paste containing quit ignored", chunks: []string{"paste q"}, wantRole: attachmentSnatched},
174- {name: "complete CSI ignored", chunks: []string{"\x1b[r"}, wantRole: attachmentSnatched},
175- {name: "complete SS3 ignored", chunks: []string{"\x1bOR"}, wantRole: attachmentSnatched},
176- {name: "complete sequence tail ignored", chunks: []string{"\x1b[Aq"}, wantRole: attachmentSnatched},
177- {name: "split CSI final reclaim ignored", chunks: []string{"\x1b[", "r"}, wantRole: attachmentSnatched},
178- {name: "split SS3 final reclaim ignored", chunks: []string{"\x1bO", "R"}, wantRole: attachmentSnatched},
179- {name: "split sequence frame tail ignored", chunks: []string{"\x1b[", "Aq"}, wantRole: attachmentSnatched},
180- {name: "escape followed by action ignored as alt chord", chunks: []string{"\x1b", "q"}, wantRole: attachmentSnatched},
181- {name: "CSI prefix remains inert", chunks: []string{"\x1b[", "12;"}, wantRole: attachmentSnatched},
182- {name: "overflow drain consumes final and frame tail", chunks: []string{"\x1b[", strings.Repeat(";", 80), "rQ"}, wantRole: attachmentSnatched},
183- }
184-
185- for _, tt := range tests {
186- t.Run(tt.name, func(t *testing.T) {
187- clock := newSnatchedInputClock()
188- d, sess, waiting, active, waitingTransport := newSnatchedInputFixture(t, clock)
189- for _, chunk := range tt.chunks {
190- sendSnatchedInput(d, sess, waiting, chunk)
191- }
192- if tt.fireEscape {
193- require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting), "escape acted before its delay")
194- require.False(t, waitingTransport.Closed(), "escape closed the transport before its delay")
195- timer := awaitTestValue(t, clock.timers, "standalone escape did not create a timer")
196- timer.Fire()
197- awaitTestCompletion(t, waitingTransport.closedCh, "standalone escape did not quit")
198- }
199- d.attachmentCleanupWg.Wait()
200-
201- require.Equal(t, tt.wantRole, sess.attachmentRole(waiting))
202- require.Equal(t, tt.wantClosed, waitingTransport.Closed())
203- if tt.wantRole == attachmentActive {
204- require.Same(t, waiting, sess.client)
205- require.Equal(t, attachmentSnatched, sess.attachmentRole(active))
206- } else {
207- require.Same(t, active, sess.client)
208- }
209- })
210- }
211-}
212-
213-func TestBlockedSnatchedQuitDeadlineRetiresOnlyExactWaiter(t *testing.T) {
214- clock := &signalClock{timers: make(chan *signalTimer, 2)}
215- waitingTransport := newBlockedSnatchedQuitTransport()
216- defer func() { _ = waitingTransport.Close() }()
217- activeTransport := &closeTrackingTransport{}
218- d, sess, waiting, active := newAtomicReclaimFixture(t, clock, waitingTransport, activeTransport)
219-
220- otherTransport := &closeTrackingTransport{}
221- other := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: waiting.size}
222- other.initOverlays()
223- other.setSession(sess)
224- sess.mu.Lock()
225- sess.addSnatchedLocked(other)
226- sess.mu.Unlock()
227- otherToken := sess.attachmentToken(other, otherTransport)
228- require.Equal(t, attachmentSnatched, otherToken.role)
229-
230- ownerLease := sess.renderCoordinator().attachmentLease(active)
231- require.NotNil(t, ownerLease)
232- activeGeneration := active.roleGeneration.Load()
233- otherGeneration := other.roleGeneration.Load()
234-
235- loopDone := make(chan struct{})
236- go func() {
237- d.runConnLoop(waiting)
238- close(loopDone)
239- }()
240- waitingTransport.recv <- ports.Frame{
241- Type: ports.MsgInput,
242- Payload: ports.MarshalInput(ports.Input{Data: []byte{'q'}}),
243- }
244- awaitTestCompletion(t, waitingTransport.sendStarted, "snatched quit did not attempt its detach acknowledgement")
245- select {
246- case <-loopDone:
247- t.Fatal("snatched quit returned before its acknowledgement deadline")
248- default:
249- }
250-
251- timer := awaitTestValue(t, clock.timers, "snatched quit did not install its acknowledgement deadline")
252- require.Equal(t, detachNotifyTimeout, timer.duration)
253- timer.ch <- time.Time{}
254- awaitTestCompletion(t, waitingTransport.sendDone, "closing the captured link did not release its blocked send")
255- awaitTestCompletion(t, loopDone, "snatched connection parser did not retire after the acknowledgement deadline")
256-
257- require.True(t, waitingTransport.Closed())
258- requireRoleGateRetired(t, waiting)
259- require.Equal(t, attachmentDetached, sess.attachmentRole(waiting))
260- require.Nil(t, waiting.currentSession())
261- require.Nil(t, waiting.transport())
262- require.Same(t, active, sess.client)
263- require.Equal(t, attachmentActive, sess.attachmentRole(active))
264- require.Same(t, ownerLease, sess.renderCoordinator().attachmentLease(active))
265- require.Equal(t, activeGeneration, active.roleGeneration.Load())
266- require.False(t, activeTransport.Closed())
267- require.Equal(t, attachmentSnatched, sess.attachmentRole(other))
268- require.Equal(t, otherGeneration, other.roleGeneration.Load())
269- require.Same(t, otherTransport, other.transport())
270- require.False(t, otherTransport.Closed())
271-}
272-
273-func TestBlockedSnatchedQuitCleanupRejectsReclaimedTransportGeneration(t *testing.T) {
274- clock := &signalClock{timers: make(chan *signalTimer, 2)}
275- staleTransport := newBlockedSnatchedQuitTransport()
276- defer func() { _ = staleTransport.Close() }()
277- ownerTransport := &closeTrackingTransport{}
278- d, sess, waiting, owner := newAtomicReclaimFixture(t, clock, staleTransport, ownerTransport)
279-
280- ticketEnded := make(chan struct{})
281- releaseQuit := make(chan struct{})
282- d.afterActionRoleEffectEnded = func(action string) {
283- if action == "quit-snatched" {
284- close(ticketEnded)
285- <-releaseQuit
286- }
287- }
288- loopDone := make(chan struct{})
289- go func() {
290- d.runConnLoop(waiting)
291- close(loopDone)
292- }()
293- staleTransport.recv <- ports.Frame{
294- Type: ports.MsgInput,
295- Payload: ports.MarshalInput(ports.Input{Data: []byte{'Q'}}),
296- }
297- awaitTestCompletion(t, staleTransport.sendStarted, "snatched quit did not block in its captured transport")
298- timer := awaitTestValue(t, clock.timers, "snatched quit did not install its acknowledgement deadline")
299- timer.ch <- time.Time{}
300- awaitTestCompletion(t, staleTransport.sendDone, "deadline close did not retire the captured send")
301- awaitTestCompletion(t, ticketEnded, "snatched quit did not end its role ticket before cleanup")
302-
303- freshTransport := &closeTrackingTransport{}
304- waiting.replaceTransport(freshTransport)
305- reclaimed, err := d.transitionAttachment(attachmentTransitionRequest{
306- target: sess, next: waiting, expectedRole: attachmentSnatched, targetRole: attachmentActive,
307- expectedTransport: waiting.transportSnapshot(), ready: true,
308- })
309- require.NoError(t, err)
310- d.deferAttachmentTransitionCleanups(reclaimed)
311- freshGeneration := reclaimed.published.generation
312- freshLease := reclaimed.published.lease
313-
314- close(releaseQuit)
315- awaitTestCompletion(t, loopDone, "stale quit parser did not retire after reclaim")
316- d.attachmentCleanupWg.Wait()
317-
318- require.True(t, staleTransport.Closed())
319- requireRoleGateRetired(t, waiting)
320- require.Same(t, waiting, sess.client)
321- require.Equal(t, attachmentActive, sess.attachmentRole(waiting))
322- require.Equal(t, freshGeneration, waiting.roleGeneration.Load())
323- require.Same(t, freshTransport, waiting.transport())
324- require.False(t, freshTransport.Closed(), "stale quit cleanup closed the reclaimed transport")
325- require.Same(t, freshLease, sess.renderCoordinator().attachmentLease(waiting))
326- require.Equal(t, attachmentSnatched, sess.attachmentRole(owner))
327- require.False(t, ownerTransport.Closed(), "stale quit cleanup affected the displaced owner")
328-}
329-
330-func TestSnatchedInputTimerCannotAffectNewRoleOrTransport(t *testing.T) {
331- clock := newSnatchedInputClock()
332- d, sess, waiting, _, oldTransport := newSnatchedInputFixture(t, clock)
333-
334- sendSnatchedInput(d, sess, waiting, "\x1b")
335- timer := awaitTestValue(t, clock.timers, "standalone escape did not create a timer")
336- accepted := make(chan struct{})
337- release := make(chan struct{})
338- attempted := make(chan bool, 1)
339- d.afterSnatchedEscapeAccepted = func() {
340- close(accepted)
341- <-release
342- }
343- d.afterSnatchedEscapeAttempt = func(admitted bool) { attempted <- admitted }
344- timer.Fire()
345- awaitTestCompletion(t, accepted, "escape timer callback did not consume parser state")
346-
347- result, err := d.transitionAttachment(attachmentTransitionRequest{
348- target: sess, next: waiting, expectedRole: attachmentSnatched, targetRole: attachmentActive,
349- expectedTransport: waiting.transportSnapshot(), ready: true,
350- })
351- require.NoError(t, err)
352- d.deferAttachmentTransitionCleanups(result)
353- newTransport := newSnatchedCloseSignalTransport()
354- waiting.replaceTransport(newTransport)
355- close(release)
356- require.False(t, awaitTestValue(t, attempted, "escape callback did not revalidate its stale role"))
357- d.attachmentCleanupWg.Wait()
358-
359- require.Equal(t, attachmentActive, sess.attachmentRole(waiting))
360- require.Same(t, newTransport, waiting.transport())
361- require.False(t, newTransport.Closed(), "stale timer closed the replacement transport")
362- require.False(t, oldTransport.Closed(), "stale timer closed the old transport after role activation")
363-}
D internal/usecase/daemon/snatched_reclaim_test.go +0 -444
@@ -1,444 +0,0 @@
1-package daemon
2-
3-import (
4- "errors"
5- "sync"
6- "testing"
7- "time"
8-
9- "github.com/stretchr/testify/require"
10-
11- "github.com/bnema/vev/internal/domain"
12- "github.com/bnema/vev/internal/ports"
13- themeui "github.com/bnema/vev/internal/usecase/theme"
14- "github.com/bnema/vev/pkg/renderer"
15-)
16-
17-type failingReclaimTransport struct {
18- mu sync.Mutex
19- closed bool
20-}
21-
22-func (*failingReclaimTransport) Send(ports.Frame) error { return errors.New("send failed") }
23-func (*failingReclaimTransport) Recv() (ports.Frame, error) {
24- return ports.Frame{}, errors.New("closed")
25-}
26-func (t *failingReclaimTransport) Close() error {
27- t.mu.Lock()
28- t.closed = true
29- t.mu.Unlock()
30- return nil
31-}
32-func (t *failingReclaimTransport) Closed() bool {
33- t.mu.Lock()
34- defer t.mu.Unlock()
35- return t.closed
36-}
37-
38-func newAtomicReclaimFixture(t *testing.T, clock ports.Clock, waitingTransport, activeTransport ports.Transport) (*Daemon, *session, *attachedClient, *attachedClient) {
39- t.Helper()
40- d := newTestDaemon(t, nil, clock)
41- waiting := &attachedClient{tr: waitingTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
42- active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size}
43- waiting.initOverlays()
44- active.initOverlays()
45- sess := &session{sessionCore: sessionCore{id: "atomic-reclaim", client: active,
46- snatched: map[*attachedClient]struct{}{waiting: {}}},
47- }
48- waiting.setSession(sess)
49- active.setSession(sess)
50- d.sessions[sess.id] = sess
51- d.attachCoordinator(sess, nil, active, true)
52- return d, sess, waiting, active
53-}
54-
55-func requireSingleOwnerAndLease(t *testing.T, sess *session, clients ...*attachedClient) {
56- t.Helper()
57- sess.mu.Lock()
58- owner := sess.client
59- snatched := make(map[*attachedClient]struct{}, len(sess.snatched))
60- for ac := range sess.snatched {
61- snatched[ac] = struct{}{}
62- }
63- sess.mu.Unlock()
64- require.NotNil(t, owner)
65-
66- activeCount := 0
67- for _, ac := range clients {
68- if ac == owner {
69- activeCount++
70- continue
71- }
72- _, waiting := snatched[ac]
73- require.True(t, waiting, "every non-owner must remain snatched")
74- }
75- require.Equal(t, 1, activeCount)
76-
77- rc := sess.renderCoordinator()
78- rc.mu.Lock()
79- lease := rc.lease
80- rc.mu.Unlock()
81- require.NotNil(t, lease)
82- require.True(t, lease.active)
83- require.Same(t, owner, lease.attachment)
84-}
85-
86-func TestReclaimSnatchedSwapsOwnerLeaseRebasesAndAppliesTheme(t *testing.T) {
87- d, sess, requester, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
88- requesterTransport := &closeTrackingTransport{}
89- requester.replaceTransport(requesterTransport)
90- d.attachCoordinator(sess, nil, requester, true)
91-
92- active := &attachedClient{
93- tr: &closeTrackingTransport{},
94- output: newOutputStateStream(),
95- size: requester.size,
96- }
97- active.initOverlays()
98- displaced, err := d.transitionAttachment(attachmentTransitionRequest{
99- target: sess, next: active,
100- expectedRole: attachmentDetached, targetRole: attachmentActive,
101- expectedTransport: active.transportSnapshot(), ready: true,
102- })
103- require.NoError(t, err)
104- d.deferAttachmentTransitionCleanups(displaced)
105- d.attachmentCleanupWg.Wait()
106-
107- requester.sendMu.Lock()
108- beforeReclaimState := requester.output.next
109- requester.sendMu.Unlock()
110- requesterTheme := themeui.Theme{
111- Foreground: renderer.RGB{R: 11, G: 22, B: 33},
112- Background: renderer.RGB{R: 44, G: 55, B: 66},
113- HasFG: true, HasBG: true, Known: true, TrueColor: true,
114- }
115- requester.setClientTheme(requesterTheme)
116-
117- token := sess.attachmentToken(requester, requesterTransport)
118- require.Equal(t, attachmentSnatched, token.role)
119- require.False(t, d.handleSnatchedClientFrame(token, ports.Frame{
120- Type: ports.MsgInput,
121- Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
122- }))
123-
124- sess.mu.Lock()
125- require.Same(t, requester, sess.client)
126- _, activeIsSnatched := sess.snatched[active]
127- _, requesterIsSnatched := sess.snatched[requester]
128- sess.mu.Unlock()
129- require.True(t, activeIsSnatched)
130- require.False(t, requesterIsSnatched)
131- rc := sess.renderCoordinator()
132- lease := rc.attachmentLease(requester)
133- require.NotNil(t, lease)
134- require.Nil(t, rc.attachmentLease(active))
135- require.True(t, lease.active)
136-
137- requester.sendMu.Lock()
138- require.Greater(t, requester.output.next, beforeReclaimState, "reclaim must emit a reset first paint")
139- require.GreaterOrEqual(t, requester.output.acked, beforeReclaimState, "reclaim must rebase the old panel output chain")
140- requester.sendMu.Unlock()
141- assertSessionDefaultColors(t, sess, requesterTheme.Foreground, requesterTheme.Background)
142-
143- d.attachmentCleanupWg.Wait()
144- require.Equal(t, attachmentSnatched, sess.attachmentRole(active))
145- activeTransport, ok := active.transport().(*closeTrackingTransport)
146- require.True(t, ok, "active transport has unexpected type")
147- require.False(t, activeTransport.Closed())
148- require.Equal(t, domain.Size{Cols: 80, Rows: 24}, requester.size)
149-}
150-
151-func TestFailedReclaimBeforeCommitPreservesOwnerAndShowsUnavailable(t *testing.T) {
152- waitingTransport := &closeTrackingTransport{}
153- activeTransport := &closeTrackingTransport{}
154- d, sess, waiting, active := newAtomicReclaimFixture(t, stubClock{}, waitingTransport, activeTransport)
155- token := sess.attachmentToken(waiting, waitingTransport)
156-
157- d.mu.Lock()
158- delete(d.sessions, sess.id)
159- d.mu.Unlock()
160- d.handleSnatchedClientFrame(token, ports.Frame{
161- Type: ports.MsgInput,
162- Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
163- })
164-
165- requireSingleOwnerAndLease(t, sess, waiting, active)
166- require.Same(t, active, sess.client)
167- require.False(t, waitingTransport.Closed())
168- frames := waitingTransport.Sends()
169- require.Len(t, frames, 1)
170- output, err := ports.UnmarshalOutput(frames[0].Payload)
171- require.NoError(t, err)
172- require.Contains(t, string(output.Data), "Session is no longer available.")
173-}
174-
175-func TestPostcommitPanelFailureRetiresOnlyDisplacedClient(t *testing.T) {
176- waitingTransport := &closeTrackingTransport{}
177- activeTransport := &failingReclaimTransport{}
178- d, sess, waiting, active := newAtomicReclaimFixture(t, stubClock{}, waitingTransport, activeTransport)
179- token := sess.attachmentToken(waiting, waitingTransport)
180-
181- d.handleSnatchedClientFrame(token, ports.Frame{
182- Type: ports.MsgInput,
183- Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
184- })
185- d.attachmentCleanupWg.Wait()
186-
187- require.Same(t, waiting, sess.client)
188- require.Equal(t, attachmentActive, sess.attachmentRole(waiting))
189- require.Equal(t, attachmentDetached, sess.attachmentRole(active))
190- require.NotNil(t, sess.renderCoordinator().attachmentLease(waiting))
191- require.Nil(t, sess.renderCoordinator().attachmentLease(active))
192- require.False(t, waitingTransport.Closed())
193- require.True(t, activeTransport.Closed())
194-}
195-
196-func TestBlockedRequesterActivationDeadlinePreservesOwnerLease(t *testing.T) {
197- clock := &signalClock{timers: make(chan *signalTimer, 2)}
198- waitingTransport := newSnatchedCloseSignalTransport()
199- activeTransport := &closeTrackingTransport{}
200- d, sess, waiting, active := newAtomicReclaimFixture(t, clock, waitingTransport, activeTransport)
201-
202- waiting.sendMu.Lock()
203- locked := true
204- defer func() {
205- if locked {
206- waiting.sendMu.Unlock()
207- }
208- }()
209- done := make(chan struct{})
210- go func() {
211- token := sess.attachmentToken(waiting, waitingTransport)
212- d.handleSnatchedClientFrame(token, ports.Frame{
213- Type: ports.MsgInput,
214- Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
215- })
216- close(done)
217- }()
218-
219- timer := awaitTestValue(t, clock.timers, "reclaim did not install its activation deadline")
220- require.Equal(t, detachNotifyTimeout, timer.duration)
221- timer.ch <- time.Time{}
222- awaitTestCompletion(t, done, "deadline did not release blocked reclaim")
223- require.Same(t, active, sess.client)
224- require.Same(t, active, sess.renderCoordinator().attachmentLease(active).attachment)
225- require.True(t, waitingTransport.Closed())
226-
227- waiting.sendMu.Unlock()
228- locked = false
229- d.attachmentCleanupWg.Wait()
230- require.Equal(t, attachmentDetached, sess.attachmentRole(waiting))
231- require.Equal(t, attachmentActive, sess.attachmentRole(active))
232-}
233-
234-func TestQuitInvalidatesPausedSnatchedFinalizerWithoutLatePanel(t *testing.T) {
235- waitingTransport := &closeTrackingTransport{}
236- activeTransport := &closeTrackingTransport{}
237- d, sess, waiting, active := newAtomicReclaimFixture(t, stubClock{}, waitingTransport, activeTransport)
238-
239- finalizerStarted := make(chan struct{})
240- releaseFinalizer := make(chan struct{})
241- d.afterDisplacedCleanupStarted = func() {
242- close(finalizerStarted)
243- <-releaseFinalizer
244- }
245- waitingToken := sess.attachmentToken(waiting, waitingTransport)
246- d.handleSnatchedClientFrame(waitingToken, ports.Frame{
247- Type: ports.MsgInput,
248- Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
249- })
250- awaitTestCompletion(t, finalizerStarted, "displaced finalizer did not pause")
251-
252- activeToken := sess.attachmentToken(active, activeTransport)
253- require.Equal(t, attachmentSnatched, activeToken.role)
254- require.True(t, d.handleSnatchedClientFrame(activeToken, ports.Frame{
255- Type: ports.MsgInput,
256- Payload: ports.MarshalInput(ports.Input{Data: []byte{'q'}}),
257- }))
258- close(releaseFinalizer)
259- d.attachmentCleanupWg.Wait()
260-
261- require.Equal(t, attachmentDetached, sess.attachmentRole(active))
262- frames := activeTransport.Sends()
263- require.Len(t, frames, 1, "stale finalizer emitted output after quit")
264- require.Equal(t, ports.MsgDetached, frames[0].Type)
265-}
266-
267-func TestReclaimOrdersDisplacedPanelAfterAdmittedOldPaint(t *testing.T) {
268- d, sess, requester, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
269- requesterTransport := &closeTrackingTransport{}
270- requester.replaceTransport(requesterTransport)
271- d.attachCoordinator(sess, nil, requester, true)
272-
273- activeTransport := &closeTrackingTransport{}
274- active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: requester.size}
275- active.initOverlays()
276- initial, err := d.transitionAttachment(attachmentTransitionRequest{
277- target: sess, next: active,
278- expectedRole: attachmentDetached, targetRole: attachmentActive,
279- expectedTransport: active.transportSnapshot(), ready: true,
280- })
281- require.NoError(t, err)
282- d.deferAttachmentTransitionCleanups(initial)
283- d.attachmentCleanupWg.Wait()
284- baselineActiveFrames := len(activeTransport.Sends())
285-
286- paintAdmitted := make(chan struct{})
287- releasePaint := make(chan struct{})
288- d.afterRoleEffectAdmitted = func(token attachmentRoleToken) {
289- if token.ac == active && token.role == attachmentActive {
290- close(paintAdmitted)
291- <-releasePaint
292- }
293- }
294- activeToken := sess.attachmentToken(active, activeTransport)
295- paintDone := make(chan bool, 1)
296- go func() { paintDone <- d.firstPaintForTransition(activeToken) }()
297- awaitTestCompletion(t, paintAdmitted, "old active paint was not admitted")
298-
299- reclaimFrozen := make(chan struct{})
300- var frozenOnce sync.Once
301- d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) {
302- if action == "reclaim-snatched" {
303- frozenOnce.Do(func() { close(reclaimFrozen) })
304- }
305- }
306- reclaimDone := make(chan struct{})
307- go func() {
308- token := sess.attachmentToken(requester, requesterTransport)
309- d.handleSnatchedClientFrame(token, ports.Frame{
310- Type: ports.MsgInput,
311- Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
312- })
313- close(reclaimDone)
314- }()
315- awaitTestCompletion(t, reclaimFrozen, "reclaim did not freeze role effects behind old paint")
316- close(releasePaint)
317- require.True(t, awaitTestValue(t, paintDone, "old paint did not finish"))
318- awaitTestCompletion(t, reclaimDone, "reclaim did not finish after old paint")
319- d.attachmentCleanupWg.Wait()
320-
321- frames := activeTransport.Sends()[baselineActiveFrames:]
322- require.NotEmpty(t, frames)
323- last := frames[len(frames)-1]
324- require.Equal(t, ports.MsgOutput, last.Type)
325- output, err := ports.UnmarshalOutput(last.Payload)
326- require.NoError(t, err)
327- require.Contains(t, string(output.Data), "Session snatched", "old paint overtook the displaced reset panel")
328- requireSingleOwnerAndLease(t, sess, requester, active)
329-}
330-
331-func TestLaterReclaimMakesPausedFirstPaintStale(t *testing.T) {
332- d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
333- firstTransport := &closeTrackingTransport{}
334- first.replaceTransport(firstTransport)
335- d.attachCoordinator(sess, nil, first, true)
336-
337- secondTransport := &closeTrackingTransport{}
338- second := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: first.size}
339- second.initOverlays()
340- initial, err := d.transitionAttachment(attachmentTransitionRequest{
341- target: sess, next: second,
342- expectedRole: attachmentDetached, targetRole: attachmentActive,
343- expectedTransport: second.transportSnapshot(), ready: true,
344- })
345- require.NoError(t, err)
346- d.deferAttachmentTransitionCleanups(initial)
347- d.attachmentCleanupWg.Wait()
348- baselineFirstFrames := len(firstTransport.Sends())
349-
350- firstPaintPaused := make(chan struct{})
351- releaseFirstPaint := make(chan struct{})
352- d.beforeReclaimFirstPaint = func(token attachmentRoleToken) {
353- if token.ac != first {
354- return
355- }
356- close(firstPaintPaused)
357- <-releaseFirstPaint
358- }
359- firstDone := make(chan struct{})
360- go func() {
361- token := sess.attachmentToken(first, firstTransport)
362- d.handleSnatchedClientFrame(token, ports.Frame{
363- Type: ports.MsgInput,
364- Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
365- })
366- close(firstDone)
367- }()
368- awaitTestCompletion(t, firstPaintPaused, "first reclaim did not pause before first paint")
369- require.Same(t, first, sess.client, "first reclaim was not committed before its paint")
370-
371- secondToken := sess.attachmentToken(second, secondTransport)
372- require.Equal(t, attachmentSnatched, secondToken.role)
373- d.handleSnatchedClientFrame(secondToken, ports.Frame{
374- Type: ports.MsgInput,
375- Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
376- })
377- require.Same(t, second, sess.client)
378- close(releaseFirstPaint)
379- awaitTestCompletion(t, firstDone, "stale first paint did not return")
380- d.attachmentCleanupWg.Wait()
381-
382- newFirstFrames := firstTransport.Sends()[baselineFirstFrames:]
383- require.Len(t, newFirstFrames, 1, "stale first paint emitted after a later reclaim")
384- require.Equal(t, ports.MsgOutput, newFirstFrames[0].Type)
385- output, err := ports.UnmarshalOutput(newFirstFrames[0].Payload)
386- require.NoError(t, err)
387- require.Contains(t, string(output.Data), "Session snatched")
388- requireSingleOwnerAndLease(t, sess, first, second)
389-}
390-
391-func TestAnyWaiterCanReclaimAndConcurrentRequestsKeepSingleOwnerLease(t *testing.T) {
392- d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
393- firstTransport := &closeTrackingTransport{}
394- first.replaceTransport(firstTransport)
395- d.attachCoordinator(sess, nil, first, true)
396-
397- secondTransport := &closeTrackingTransport{}
398- second := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: first.size}
399- second.initOverlays()
400- toSecond, err := d.transitionAttachment(attachmentTransitionRequest{
401- target: sess, next: second,
402- expectedRole: attachmentDetached, targetRole: attachmentActive,
403- expectedTransport: second.transportSnapshot(), ready: true,
404- })
405- require.NoError(t, err)
406- d.deferAttachmentTransitionCleanups(toSecond)
407- d.attachmentCleanupWg.Wait()
408-
409- thirdTransport := &closeTrackingTransport{}
410- third := &attachedClient{tr: thirdTransport, output: newOutputStateStream(), size: first.size}
411- third.initOverlays()
412- toThird, err := d.transitionAttachment(attachmentTransitionRequest{
413- target: sess, next: third,
414- expectedRole: attachmentDetached, targetRole: attachmentActive,
415- expectedTransport: third.transportSnapshot(), ready: true,
416- })
417- require.NoError(t, err)
418- d.deferAttachmentTransitionCleanups(toThird)
419- d.attachmentCleanupWg.Wait()
420- requireSingleOwnerAndLease(t, sess, first, second, third)
421-
422- firstToken := sess.attachmentToken(first, firstTransport)
423- secondToken := sess.attachmentToken(second, secondTransport)
424- require.Equal(t, attachmentSnatched, firstToken.role)
425- require.Equal(t, attachmentSnatched, secondToken.role)
426- start := make(chan struct{})
427- done := make(chan struct{}, 2)
428- for _, token := range []attachmentRoleToken{firstToken, secondToken} {
429- go func() {
430- <-start
431- d.handleSnatchedClientFrame(token, ports.Frame{
432- Type: ports.MsgInput,
433- Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
434- })
435- done <- struct{}{}
436- }()
437- }
438- close(start)
439- awaitTestCompletion(t, done, "first concurrent reclaim did not return")
440- awaitTestCompletion(t, done, "second concurrent reclaim did not return")
441- d.attachmentCleanupWg.Wait()
442-
443- requireSingleOwnerAndLease(t, sess, first, second, third)
444-}
D internal/usecase/daemon/snatched_render.go +0 -169
@@ -1,169 +0,0 @@
1-package daemon
2-
3-import (
4- "errors"
5-
6- "github.com/bnema/vev/internal/domain"
7- themeui "github.com/bnema/vev/internal/usecase/theme"
8- "github.com/bnema/vev/internal/usecase/ui"
9- "github.com/bnema/vev/pkg/renderer"
10-)
11-
12-var snatchedModal = ui.Modal{
13- FixedWidth: 42, FixedHeight: 7,
14- Title: " Session snatched ", Anchor: domain.AnchorCenter,
15-}
16-
17-func snatchedPanelFrame(size domain.Size, styles themeui.Styles, feedback string) renderer.Frame {
18- width, height := max(size.Cols, 1), max(size.Rows, 1)
19- frame := renderer.NewFrame(width, height)
20- ui.FillRect(frame, domain.Rect{Width: width, Height: height}, renderer.Cell{Rune: ' ', Style: styles.PickerBase})
21- if width < snatchedModal.FixedWidth || height < snatchedModal.FixedHeight {
22- drawSnatchedCompact(frame, styles, feedback)
23- return frame
24- }
25-
26- presentation := snatchedModal.Resolve(domain.Size{Cols: width, Rows: height})
27- inner := snatchedModal.CompositePresentation(frame, presentation, styles.BorderMuted, styles.PickerBase)
28- message := "This session is now active elsewhere."
29- if feedback != "" {
30- message = feedback
31- }
32- drawCenteredLine(frame, inner, inner.Y+1, message, styles.PickerBase)
33- drawCenteredSegments(frame, inner, inner.Y+3, []textSegment{
34- {" r ", styles.HintKey},
35- {" Resume here", styles.PickerBase},
36- {" ", styles.PickerBase},
37- {" q ", styles.HintKey},
38- {" / ", styles.PickerDescription},
39- {" Esc ", styles.HintKey},
40- {" Quit", styles.PickerBase},
41- })
42- return frame
43-}
44-
45-func drawSnatchedCompact(frame renderer.Frame, styles themeui.Styles, feedback string) {
46- status := "Session snatched"
47- if feedback != "" {
48- status = "Session unavailable"
49- }
50- bounds := domain.Rect{Width: frame.Width, Height: frame.Height}
51- startY := max((frame.Height-3)/2, 0)
52- drawCenteredLine(frame, bounds, startY, status, styles.PickerBase)
53- drawCenteredSegments(frame, bounds, startY+1, []textSegment{
54- {" r ", styles.HintKey},
55- {" Resume", styles.PickerBase},
56- })
57- drawCenteredSegments(frame, bounds, startY+2, []textSegment{
58- {" q ", styles.HintKey},
59- {" Quit", styles.PickerBase},
60- })
61-}
62-
63-// textSegment is one differently-styled run within a centered hint line.
64-type textSegment struct {
65- text string
66- style renderer.Style
67-}
68-
69-func drawCenteredLine(frame renderer.Frame, bounds domain.Rect, y int, text string, style renderer.Style) {
70- drawCenteredSegments(frame, bounds, y, []textSegment{{text, style}})
71-}
72-
73-// drawCenteredSegments draws consecutive differently-styled segments as one
74-// logical line, centered as a whole within bounds.
75-func drawCenteredSegments(frame renderer.Frame, bounds domain.Rect, y int, segments []textSegment) {
76- total := 0
77- for _, seg := range segments {
78- for _, r := range seg.text {
79- total += renderer.RuneWidth(r)
80- }
81- }
82- x := bounds.X + max((bounds.Width-total)/2, 0)
83- limit := bounds.X + bounds.Width
84- for _, seg := range segments {
85- ui.DrawText(frame, x, y, limit, seg.text, seg.style)
86- for _, r := range seg.text {
87- x += renderer.RuneWidth(r)
88- }
89- }
90-}
91-
92-var errSnatchedOutputStale = errors.New("snatched output role changed")
93-
94-// sendSnatchedPanel serializes a dependency-free panel reset onto one exact
95-// attachment role and transport lifetime. It does not inspect session state or
96-// capture pane content.
97-func (d *Daemon) sendSnatchedPanel(ac *attachedClient, expected transportSnapshot, generation uint64, feedback string, effects ...*roleEffectTicket) error {
98- if expected.transport == nil {
99- return errors.New("snatched client transport is nil")
100- }
101- var effect *roleEffectTicket
102- if len(effects) != 0 {
103- effect = effects[0]
104- }
105- tr, err := d.boundedSendWith(expected.transport, func() error {
106- ac.sendMu.Lock()
107- defer ac.sendMu.Unlock()
108- if !ac.transportSnapshotCurrent(expected) {
109- return errTransportReplaced
110- }
111- if ac.roleGeneration.Load() != generation {
112- return errSnatchedOutputStale
113- }
114-
115- applied := ac.getAppliedTheme()
116- frame := snatchedPanelFrame(ac.size, applied.Resolved.Styles, feedback)
117- ac.rebaseOutput()
118- var data []byte
119- var cursor cursorCandidate
120- var preparedANSI *preparedOutput
121- var preparedScreen *preparedStructuredOutput
122- var sendErr error
123- if ac.proxied {
124- screenOutput := ac.ensureScreenOutput()
125- if screenOutput == nil {
126- return errors.New("proxied attachment has no structured output stream")
127- }
128- preparedScreen, sendErr = screenOutput.prepare(frame, []renderer.Damage{renderer.FullRedraw()}, cursorOut{hidden: true}, true, ac.echoAck.Load())
129- if sendErr == nil {
130- data = preparedScreen.data
131- }
132- } else {
133- preparedANSI, sendErr = ac.output.prepare(frame, []renderer.Damage{renderer.FullRedraw()}, true)
134- if sendErr == nil {
135- cursor = ac.prepareCursorTail(cursorOut{hidden: true}, true)
136- data = append(append([]byte(nil), preparedANSI.data...), cursor.data...)
137- }
138- }
139- if sendErr != nil {
140- return sendErr
141- }
142- if effect != nil && !effect.beginTransportSend(expected) {
143- return errAttachmentTransition
144- }
145- if ac.proxied {
146- sendErr = preparedScreen.send(expected.transport.Send)
147- } else {
148- sendErr = preparedANSI.send(data, ac.echoAck.Load(), expected.transport.Send)
149- }
150- if effect != nil {
151- if sendErr != nil {
152- effect.reportTransportFailure(expected)
153- }
154- effect.endTransportSend()
155- }
156- if sendErr == nil {
157- if ac.proxied {
158- ac.lastCursor = cursorOut{hidden: true}
159- } else {
160- ac.lastCursor = cursor.next
161- }
162- }
163- return sendErr
164- })
165- if errors.Is(err, errSendTimedOut) {
166- _ = ac.closeCapturedTransport(tr)
167- }
168- return err
169-}
D internal/usecase/daemon/snatched_render_test.go +0 -340
@@ -1,340 +0,0 @@
1-package daemon
2-
3-import (
4- "errors"
5- "strings"
6- "sync"
7- "testing"
8- "time"
9-
10- "github.com/stretchr/testify/require"
11-
12- "github.com/bnema/vev/internal/domain"
13- "github.com/bnema/vev/internal/ports"
14- themeui "github.com/bnema/vev/internal/usecase/theme"
15- "github.com/bnema/vev/pkg/renderer"
16-)
17-
18-func TestSnatchedPanelFrame(t *testing.T) {
19- styles := themeui.Styles{
20- PickerBase: renderer.Style{Foreground: 1},
21- PickerSelection: renderer.Style{Foreground: 2},
22- BorderMuted: renderer.Style{Foreground: 3},
23- HintKey: renderer.Style{Foreground: 4},
24- PickerDescription: renderer.Style{Foreground: 5},
25- }
26- tests := []struct {
27- name string
28- size domain.Size
29- feedback string
30- wantSize domain.Size
31- want []string
32- wantKeys string
33- wantSep string
34- wantBounds domain.Rect
35- compact bool
36- }{
37- {
38- name: "centered bordered panel",
39- size: domain.Size{Cols: 80, Rows: 24},
40- wantSize: domain.Size{Cols: 80, Rows: 24},
41- want: []string{"Session snatched", "This session is now active elsewhere.", "r Resume here", "q / Esc Quit"},
42- wantKeys: " r q Esc ",
43- wantSep: " / ",
44- wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7},
45- },
46- {
47- name: "compact fallback",
48- size: domain.Size{Cols: 24, Rows: 4},
49- wantSize: domain.Size{Cols: 24, Rows: 4},
50- want: []string{"Session snatched", "r Resume", "q Quit"},
51- wantKeys: " r q ",
52- compact: true,
53- },
54- {
55- name: "compact unavailable feedback",
56- size: domain.Size{Cols: 24, Rows: 4},
57- feedback: "Session is no longer available.",
58- wantSize: domain.Size{Cols: 24, Rows: 4},
59- want: []string{"Session unavailable", "r Resume", "q Quit"},
60- wantKeys: " r q ",
61- compact: true,
62- },
63- {
64- name: "bordered unavailable feedback",
65- size: domain.Size{Cols: 80, Rows: 24},
66- feedback: "Session is no longer available.",
67- wantSize: domain.Size{Cols: 80, Rows: 24},
68- want: []string{"Session snatched", "Session is no longer available.", "r Resume here", "q / Esc Quit"},
69- wantKeys: " r q Esc ",
70- wantSep: " / ",
71- wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7},
72- },
73- {
74- name: "zero dimensions clamp",
75- size: domain.Size{},
76- wantSize: domain.Size{Cols: 1, Rows: 1},
77- compact: true,
78- },
79- {
80- name: "negative dimensions clamp",
81- size: domain.Size{Cols: -10, Rows: -20},
82- wantSize: domain.Size{Cols: 1, Rows: 1},
83- compact: true,
84- },
85- }
86-
87- for _, tt := range tests {
88- t.Run(tt.name, func(t *testing.T) {
89- frame := snatchedPanelFrame(tt.size, styles, tt.feedback)
90- require.Equal(t, tt.wantSize.Cols, frame.Width)
91- require.Equal(t, tt.wantSize.Rows, frame.Height)
92- require.NoError(t, frame.Validate())
93-
94- text := snatchedFrameText(frame)
95- for _, want := range tt.want {
96- require.Contains(t, text, want)
97- }
98- require.Equal(t, tt.wantKeys, snatchedStyledText(frame, styles.HintKey), "keycap badges must use the HintKey style")
99- require.Equal(t, tt.wantSep, snatchedStyledText(frame, styles.PickerDescription), "hint separator must use the muted description style")
100-
101- if tt.compact {
102- require.NotContains(t, text, "┌")
103- require.NotContains(t, text, "┘")
104- return
105- }
106-
107- require.Equal(t, tt.wantBounds, snatchedModal.Resolve(tt.wantSize).Bounds)
108- topLeft := frame.At(tt.wantBounds.X, tt.wantBounds.Y)
109- bottomRight := frame.At(tt.wantBounds.X+tt.wantBounds.Width-1, tt.wantBounds.Y+tt.wantBounds.Height-1)
110- require.Equal(t, '┌', topLeft.Rune)
111- require.Equal(t, '┘', bottomRight.Rune)
112- require.True(t, topLeft.Style.Equal(styles.BorderMuted), "panel border must use captured chrome style")
113- require.True(t, bottomRight.Style.Equal(styles.BorderMuted), "panel border must use captured chrome style")
114- requireFreshSnatchedBackground(t, frame, tt.wantBounds, styles.PickerBase)
115- })
116- }
117-}
118-
119-func TestSendSnatchedPanelRebasesOutput(t *testing.T) {
120- d := newTestDaemon(t, nil, stubClock{})
121- tr := &closeTrackingTransport{}
122- ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
123- ac.output.next = 7
124- ac.setAppliedTheme(appliedTheme{Resolved: themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})})
125- ac.roleGeneration.Store(1)
126-
127- expected := ac.transportSnapshot()
128- require.NoError(t, d.sendSnatchedPanel(ac, expected, 1, ""))
129-
130- frames := tr.Sends()
131- require.Len(t, frames, 1)
132- require.Equal(t, ports.MsgOutput, frames[0].Type)
133- out, err := ports.UnmarshalOutput(frames[0].Payload)
134- require.NoError(t, err)
135- require.Zero(t, out.BaseStateNum)
136- require.Equal(t, uint64(8), out.NewStateNum)
137- require.Contains(t, string(out.Data), "Session snatched")
138- require.True(t, strings.HasSuffix(string(out.Data), "\x1b[?25l"), "panel must force the cursor hidden")
139-}
140-
141-func TestSendSnatchedPanelUsesStructuredSnapshotForProxied(t *testing.T) {
142- d := newTestDaemon(t, nil, stubClock{})
143- tr := &closeTrackingTransport{}
144- ac := &attachedClient{tr: tr, output: newOutputStateStream(), proxied: true, size: domain.Size{Cols: 80, Rows: 24}}
145- ac.output.next = 7
146- ac.setAppliedTheme(appliedTheme{Resolved: themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})})
147- ac.roleGeneration.Store(1)
148-
149- require.NoError(t, d.sendSnatchedPanel(ac, ac.transportSnapshot(), 1, ""))
150- frames := tr.Sends()
151- require.Len(t, frames, 1)
152- require.Equal(t, ports.MsgScreenUpdate, frames[0].Type)
153- update, err := ports.UnmarshalScreenUpdate(frames[0].Payload)
154- require.NoError(t, err)
155- require.Equal(t, ports.ScreenUpdateSnapshot, update.Kind)
156- require.Zero(t, update.BaseStateNum)
157- require.Equal(t, uint64(8), update.NewStateNum, "snatched panel must advance the shared output state")
158- require.False(t, update.Cursor.Visible, "structured snatched panel must hide the cursor semantically")
159- require.Len(t, update.Spans, ac.size.Rows, "structured snapshots must carry every full row")
160-}
161-
162-func TestSendSnatchedPanelPublishesCursorOnlyAfterSendSuccess(t *testing.T) {
163- d := newTestDaemon(t, nil, stubClock{})
164- failed := cacheFailTransport{}
165- ac := &attachedClient{
166- tr: failed,
167- output: newOutputStateStream(),
168- size: domain.Size{Cols: 80, Rows: 24},
169- lastCursor: cursorOut{valid: true, row: 2, col: 3, style: 1, hasStyle: true},
170- }
171- ac.output.next = 7
172- ac.setAppliedTheme(appliedTheme{Resolved: themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})})
173- ac.roleGeneration.Store(1)
174- beforeCursor := ac.lastCursor
175-
176- require.Error(t, d.sendSnatchedPanel(ac, ac.transportSnapshot(), 1, ""))
177- require.Equal(t, beforeCursor, ac.lastCursor)
178- require.Equal(t, uint64(7), ac.output.next)
179-
180- healthy := &closeTrackingTransport{}
181- ac.replaceTransport(healthy)
182- require.NoError(t, d.sendSnatchedPanel(ac, ac.transportSnapshot(), 1, ""))
183- require.Equal(t, cursorOut{valid: true, hidden: true}, ac.lastCursor)
184- require.Equal(t, uint64(8), ac.output.next)
185- frames := healthy.Sends()
186- require.Len(t, frames, 1)
187- out, err := ports.UnmarshalOutput(frames[0].Payload)
188- require.NoError(t, err)
189- require.Zero(t, out.BaseStateNum)
190- require.Equal(t, uint64(8), out.NewStateNum)
191-}
192-
193-func TestSendSnatchedPanelTimeoutClosesCapturedTransportOnly(t *testing.T) {
194- clock := &signalClock{timers: make(chan *signalTimer, 1)}
195- d := newTestDaemon(t, nil, clock)
196- old := newBlockingSnatchedTransport()
197- fresh := &closeTrackingTransport{}
198- ac := &attachedClient{tr: old, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
199- ac.roleGeneration.Store(1)
200- expected := ac.transportSnapshot()
201-
202- result := make(chan error, 1)
203- go func() {
204- result <- d.sendSnatchedPanel(ac, expected, 1, "")
205- }()
206-
207- timer := awaitTestValue(t, clock.timers, "snatched send did not install its timeout")
208- awaitTestCompletion(t, old.sendEntered, "snatched send did not block in the captured transport")
209- ac.replaceTransport(fresh)
210- timer.ch <- time.Time{}
211-
212- require.ErrorIs(t, awaitTestValue(t, result, "snatched send did not time out"), errSendTimedOut)
213- defer func() { _ = old.Close() }() // Release the blocked worker if the assertion below fails.
214- require.True(t, old.Closed(), "timed-out captured transport was not closed")
215- require.False(t, fresh.Closed(), "replacement transport must remain open")
216-
217- sendMuReleased := make(chan uint64, 1)
218- go func() {
219- ac.sendMu.Lock()
220- generation := ac.roleGeneration.Load()
221- ac.sendMu.Unlock()
222- sendMuReleased <- generation
223- }()
224- require.Equal(t, uint64(1), awaitTestValue(t, sendMuReleased, "closing the timed-out transport did not release sendMu"))
225-}
226-
227-type blockingSnatchedTransport struct {
228- sendEntered chan struct{}
229- closed chan struct{}
230- sendOnce sync.Once
231- closeOnce sync.Once
232-}
233-
234-func newBlockingSnatchedTransport() *blockingSnatchedTransport {
235- return &blockingSnatchedTransport{
236- sendEntered: make(chan struct{}),
237- closed: make(chan struct{}),
238- }
239-}
240-
241-func (t *blockingSnatchedTransport) Send(ports.Frame) error {
242- t.sendOnce.Do(func() { close(t.sendEntered) })
243- <-t.closed
244- return errors.New("transport closed")
245-}
246-
247-func (*blockingSnatchedTransport) Recv() (ports.Frame, error) {
248- return ports.Frame{}, errors.New("not implemented")
249-}
250-
251-func (t *blockingSnatchedTransport) Close() error {
252- t.closeOnce.Do(func() { close(t.closed) })
253- return nil
254-}
255-
256-func (t *blockingSnatchedTransport) Closed() bool {
257- select {
258- case <-t.closed:
259- return true
260- default:
261- return false
262- }
263-}
264-
265-func TestSendSnatchedPanelRejectsStaleTransport(t *testing.T) {
266- d := newTestDaemon(t, nil, stubClock{})
267- old := &closeTrackingTransport{}
268- fresh := &closeTrackingTransport{}
269- ac := &attachedClient{tr: old, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
270- ac.roleGeneration.Store(1)
271- stale := ac.transportSnapshot()
272- ac.replaceTransport(fresh)
273-
274- require.ErrorIs(t, d.sendSnatchedPanel(ac, stale, 1, ""), errTransportReplaced)
275- require.Empty(t, old.Sends())
276- require.Empty(t, fresh.Sends())
277-}
278-
279-func TestSendSnatchedPanelRejectsStaleRole(t *testing.T) {
280- d := newTestDaemon(t, nil, stubClock{})
281- tr := &closeTrackingTransport{}
282- ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
283- ac.roleGeneration.Store(2)
284- expected := ac.transportSnapshot()
285-
286- require.ErrorIs(t, d.sendSnatchedPanel(ac, expected, 1, ""), errSnatchedOutputStale)
287- require.Empty(t, tr.Sends())
288-}
289-
290-func requireFreshSnatchedBackground(t *testing.T, frame renderer.Frame, panel domain.Rect, style renderer.Style) {
291- t.Helper()
292- for y := range frame.Height {
293- for x := range frame.Width {
294- if x >= panel.X && x < panel.X+panel.Width && y >= panel.Y && y < panel.Y+panel.Height {
295- continue
296- }
297- cell := frame.At(x, y)
298- require.Equal(t, ' ', cell.Rune, "cell (%d,%d) outside panel retained content", x, y)
299- require.True(t, cell.Style.Equal(style), "cell (%d,%d) outside panel did not use captured base style", x, y)
300- }
301- }
302-}
303-
304-// snatchedStyledText concatenates, in reading order, the runes of every cell
305-// drawn with exactly the given style, so tests can assert which text runs a
306-// style covers without depending on layout coordinates.
307-func snatchedStyledText(frame renderer.Frame, style renderer.Style) string {
308- var out strings.Builder
309- for y := 0; y < frame.Height; y++ {
310- for _, cell := range frame.Row(y) {
311- if cell.Continuation || !cell.Style.Equal(style) {
312- continue
313- }
314- r := cell.Rune
315- if r == 0 {
316- r = ' '
317- }
318- out.WriteRune(r)
319- }
320- }
321- return out.String()
322-}
323-
324-func snatchedFrameText(frame renderer.Frame) string {
325- var out strings.Builder
326- for y := 0; y < frame.Height; y++ {
327- for _, cell := range frame.Row(y) {
328- if cell.Continuation {
329- continue
330- }
331- r := cell.Rune
332- if r == 0 {
333- r = ' '
334- }
335- out.WriteRune(r)
336- }
337- out.WriteByte('\n')
338- }
339- return out.String()
340-}
D internal/usecase/daemon/stalled_attachment_test.go +0 -89
@@ -1,89 +0,0 @@
1-package daemon
2-
3-import (
4- "testing"
5- "time"
6-
7- "github.com/stretchr/testify/mock"
8- "github.com/stretchr/testify/require"
9-
10- "github.com/bnema/vev/internal/domain"
11- "github.com/bnema/vev/internal/ports"
12- portsmocks "github.com/bnema/vev/internal/ports/mocks"
13-)
14-
15-// TestFinishAttachWaitsForAdmittedOldRender verifies role-effect
16-// linearization: an old render already admitted and blocked in Send completes
17-// before replacement publication can change its capability.
18-func TestFinishAttachWaitsForAdmittedOldRender(t *testing.T) {
19- pty, releasePTY := newBlockingPTY(t)
20- defer releasePTY()
21- clock := newCoordinatorMockClock(t, 4)
22- d := newTestDaemon(t, newFactory(t, pty), clock.clock)
23-
24- oldTransport := portsmocks.NewMockTransport(t)
25- oldSendEntered := make(chan struct{})
26- releaseOldSend := make(chan struct{})
27- oldTransport.EXPECT().Send(mock.Anything).RunAndReturn(func(ports.Frame) error {
28- close(oldSendEntered)
29- <-releaseOldSend
30- return nil
31- }).Once()
32- oldTransport.EXPECT().Send(mock.Anything).Return(nil).Maybe()
33- oldTransport.EXPECT().Close().Return(nil).Maybe()
34-
35- hello := ports.Hello{
36- Version: ports.ProtocolVersion,
37- Intent: ports.IntentNew,
38- Name: "stalled-attachment",
39- Size: domain.Size{Cols: 80, Rows: 24},
40- TermEnv: "xterm-256color",
41- }
42- sess, old, err := d.route(hello, oldTransport)
43- require.NoError(t, err)
44- rc := sess.renderCoordinator()
45- lease := rc.attachmentLease(old)
46- require.True(t, rc.markAttachmentReady(lease))
47-
48- rc.invalidateForAttachment(old, renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"})
49- awaitCoordinatorScheduledTimer(t, clock).ch <- time.Time{}
50- <-oldSendEntered
51-
52- replacement := portsmocks.NewMockTransport(t)
53- replacement.EXPECT().Send(mock.Anything).Return(nil).Maybe()
54- replacement.EXPECT().Close().Return(nil).Maybe()
55- type attachResult struct {
56- client *attachedClient
57- err error
58- }
59- attached := make(chan attachResult, 1)
60- go func() {
61- d.mu.Lock()
62- ac, err := d.finishAttach(sess, replacement, hello.Size, terminalEnv{}, hello)
63- attached <- attachResult{client: ac, err: err}
64- }()
65-
66- select {
67- case <-attached:
68- t.Fatal("replacement published before the admitted old render completed")
69- case <-time.After(20 * time.Millisecond):
70- }
71- close(releaseOldSend)
72- select {
73- case result := <-attached:
74- require.NoError(t, result.err)
75- require.Same(t, replacement, result.client.transport())
76- case <-time.After(time.Second):
77- t.Fatal("replacement did not publish after the admitted render completed")
78- }
79-
80- d.attachmentCleanupWg.Wait()
81- require.Same(t, oldTransport, old.transport(), "the displaced attachment remains snatched")
82- replacement.AssertNotCalled(t, "Close")
83-
84- // Retire the session before mock cleanup so asynchronous terminal Detached
85- // notification cannot race the clock's C expectation after the test returns.
86- releasePTY()
87- awaitTestCompletion(t, d.done, "session did not retire after its final PTY closed")
88- d.waitNotifies()
89-}
M internal/usecase/daemon/status.go +30 -3
@@ -6,6 +6,7 @@ import (
66 "strconv"
77 "time"
88
9+ "github.com/bnema/vev/internal/domain"
910 "github.com/bnema/vev/internal/usecase/command"
1011 "github.com/bnema/vev/internal/usecase/palette"
1112 themeui "github.com/bnema/vev/internal/usecase/theme"
@@ -181,6 +182,10 @@ type statusTab struct {
181182 }
182183
183184 func (s *session) statusSegments(includeTerminalTitle bool) statusSnapshot {
185+ return s.statusSegmentsFor(nil, includeTerminalTitle)
186+}
187+
188+func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle bool) statusSnapshot {
184189 s.mu.Lock()
185190 defer s.mu.Unlock()
186191 name := s.name
@@ -188,9 +193,19 @@ func (s *session) statusSegments(includeTerminalTitle bool) statusSnapshot {
188193 name += "*"
189194 }
190195 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
196+ activeIndex := 0
197+ if ac != nil {
198+ view := ac.viewSnapshot()
199+ for i, tb := range s.tabs {
200+ if domain.TabStableID(tb.stableID) == view.tabID {
201+ activeIndex = i
202+ break
203+ }
204+ }
205+ }
191206 for i, tb := range s.tabs {
192207 name := tabDisplayName(tb, i)
193- active := i == s.active
208+ active := i == activeIndex
194209 attention := tb.attention && (!active || tb.attentionVisiblePaint)
195210 snap.tabs[i] = statusTab{name: name, paneTitle: tb.focusedPaneTitle(includeTerminalTitle), active: active, attention: attention}
196211 }
@@ -236,6 +251,10 @@ func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hi
236251 // attachment implementation. Bar scripts remain local-session-only because
237252 // their existing execution contract depends on local tabs and PTYs.
238253 func (d *Daemon) barStateForAttachmentPaletteHints(cur attachmentSession, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
254+ return d.barStateForAttachmentPaletteHintsFor(cur, nil, statusFeedback, hints, recent)
255+}
256+
257+func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur attachmentSession, ac *attachedClient, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
239258 if attachmentSessionCore(cur) == nil {
240259 cur = nil
241260 }
@@ -249,7 +268,11 @@ func (d *Daemon) barStateForAttachmentPaletteHints(cur attachmentSession, status
249268 if d != nil {
250269 includeTerminalTitle = d.currentTabsConfig().TerminalTitle
251270 }
252- state.status = cur.statusSegments(includeTerminalTitle)
271+ if local, ok := localSession(cur); ok {
272+ state.status = local.statusSegmentsFor(ac, includeTerminalTitle)
273+ } else {
274+ state.status = cur.statusSegments(includeTerminalTitle)
275+ }
253276 }
254277 if local, ok := localSession(cur); ok && d != nil {
255278 state.topRight, state.bottomRight = d.barScriptSnapshot(local)
@@ -269,6 +292,10 @@ func (d *Daemon) barStateFor(cur *session, statusFeedback string) barState {
269292 }
270293
271294 func (d *Daemon) barStateBase(cur *session, statusFeedback string) barState {
295+ return d.barStateBaseFor(cur, nil, statusFeedback)
296+}
297+
298+func (d *Daemon) barStateBaseFor(cur *session, ac *attachedClient, statusFeedback string) barState {
272299 state := barState{statusFeedback: statusFeedback}
273300 if d != nil {
274301 state.attentionFrame = d.attentionFrame()
@@ -278,7 +305,7 @@ func (d *Daemon) barStateBase(cur *session, statusFeedback string) barState {
278305 if d != nil {
279306 includeTerminalTitle = d.currentTabsConfig().TerminalTitle
280307 }
281- state.status = cur.statusSegments(includeTerminalTitle)
308+ state.status = cur.statusSegmentsFor(ac, includeTerminalTitle)
282309 }
283310 if d != nil {
284311 state.topRight, state.bottomRight = d.barScriptSnapshot(cur)
M internal/usecase/daemon/status_test.go +20 -74
@@ -31,10 +31,10 @@ func (*coreNilAttachment) snapshotView(viewOptions) sessionView {
3131 return sessionView{}
3232 }
3333 func (*coreNilAttachment) statusSegments(bool) statusSnapshot { return statusSnapshot{} }
34-func (*coreNilAttachment) capturePrimary(*attachedClient, primaryCaptureRequest) (*capturedRenderState, bool) {
34+func (*coreNilAttachment) captureRenderState(*attachedClient, renderCaptureRequest) (*capturedRenderState, bool) {
3535 return nil, false
3636 }
37-func (*coreNilAttachment) activateTargetLocked(int) bool { return false }
37+func (*coreNilAttachment) validTargetTabLocked(int) bool { return false }
3838 func (*coreNilAttachment) isProxy() bool { return false }
3939
4040 // stubClock returns timers whose channel never fires, so a scheduler under it
@@ -46,7 +46,7 @@ func TestApplyConfigThemeRepaintInvalidatesComposedFrameCache(t *testing.T) {
4646 defer releasePTY()
4747 d.ApplyConfig(domain.Config{Theme: domain.ThemeLight})
4848
49- win := sess.activeTab()
49+ win := testAttachmentTab(sess)
5050 left := win.focusedPane()
5151 right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4})
5252 win.mu.Lock()
@@ -89,13 +89,13 @@ func TestApplyConfigThemeRepaintInvalidatesComposedFrameCache(t *testing.T) {
8989 func TestStatusCompositionGolden(t *testing.T) {
9090 p1, releasePTY1 := newBlockingPTY(t)
9191 p2, releasePTY2 := newBlockingPTY(t)
92- _, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
92+ _, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2)
9393 defer releasePTY1()
9494 defer releasePTY2()
95- sess.active = 1
95+ selectTestAttachmentTab(sess, 1)
9696 sess.name = "work"
9797
98- win := sess.activeTab()
98+ win := testAttachmentTab(sess)
9999 win.focusedPane().screen.Resize(12, 2)
100100 win.size = domain.Size{Cols: 12, Rows: 2}
101101 win.focusedPane().screen.Write([]byte("hello"))
@@ -106,7 +106,7 @@ func TestStatusCompositionGolden(t *testing.T) {
106106 panes: []capturedPaneRenderState{{
107107 id: win.focusedPane().id, frame: win.focusedPane().screen.Frame.Clone(), placement: layout.Placement{ID: win.focusedPane().id, Content: domain.Rect{Width: 12, Height: 2}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
108108 }},
109- bars: barState{status: sess.statusSegments(true)},
109+ bars: barState{status: sess.statusSegmentsFor(ac, true)},
110110 }
111111 composed := composeFrame(frameState, composeCacheInput{})
112112 frame, damage := composed.frame, composed.damage
@@ -418,7 +418,7 @@ func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {
418418 p, release := newBlockingPTY(t)
419419 _, sess, ac, _ := newManualSessionWithPTYs(t, p)
420420 defer release()
421- win := sess.activeTab()
421+ win := testAttachmentTab(sess)
422422 win.focusedPane().screen.Resize(12, 2)
423423 win.size = domain.Size{Cols: 12, Rows: 2}
424424 ac.setThemeForTest(themeui.Theme{
@@ -462,7 +462,7 @@ func TestApplyThemePropagatesToFloatingPane(t *testing.T) {
462462 p, releasePTY := newBlockingPTY(t)
463463 defer releasePTY()
464464 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
465- tb := sess.activeTab()
465+ tb := testAttachmentTab(sess)
466466 floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5})
467467 installTestFloating(tb, floating, true)
468468 clientTheme := ports.Theme{
@@ -541,7 +541,7 @@ func TestAttachClientAppliesForcedThemeBeforeMsgTheme(t *testing.T) {
541541 }
542542 tr, _ := newCapturingTransport(t)
543543
544- ac, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
544+ ac, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
545545 require.NoError(t, err)
546546
547547 require.Equal(t, tc.want, ac.getAppliedTheme().Raw)
@@ -581,7 +581,7 @@ func TestAttachClientClearsStaleColorSchemeOnReplacement(t *testing.T) {
581581 assertSessionColorScheme(t, sess, true)
582582
583583 tr, _ := newCapturingTransport(t)
584- _, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
584+ _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
585585 require.NoError(t, err)
586586
587587 assertSessionColorSchemeUnknown(t, sess)
@@ -690,11 +690,11 @@ func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) {
690690 })
691691 tr, _ := newCapturingTransport(t)
692692
693- _, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
693+ _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
694694 require.NoError(t, err)
695695
696696 var got []byte
697- tb := sess.activeTab()
697+ tb := testAttachmentTab(sess)
698698 tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
699699 tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
700700 require.Empty(t, got)
@@ -710,7 +710,7 @@ func TestClientGoneResetsScreenDefaultColors(t *testing.T) {
710710 })
711711
712712 // Sanity: the tab answers OSC 10/11 while ac is attached.
713- tb := sess.activeTab()
713+ tb := testAttachmentTab(sess)
714714 var before []byte
715715 tb.focusedPane().screen.OnResponse = func(b []byte) { before = append(before, b...) }
716716 tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
@@ -724,40 +724,6 @@ func TestClientGoneResetsScreenDefaultColors(t *testing.T) {
724724 require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone")
725725 }
726726
727-func TestClientGoneResetDoesNotClobberNewlyAttachedClient(t *testing.T) {
728- p, release := newBlockingPTY(t)
729- d, sess, ac, _ := newManualSessionWithPTYs(t, p)
730- defer release()
731- d.applyTheme(sess, ac, ports.Theme{
732- HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
733- HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
734- })
735-
736- // Simulate the race window inside clientGone/detachOnSendError: the old
737- // client has already been detached (detachIfCurrent succeeded), but
738- // before resetScreenDefaultColors runs a new client attaches and applies
739- // its own theme.
740- require.True(t, sess.detachIfCurrent(ac))
741-
742- tr, _ := newCapturingTransport(t)
743- newAC, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
744- require.NoError(t, err)
745- d.applyTheme(sess, newAC, ports.Theme{
746- HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22},
747- HasBackground: true, Background: renderer.RGB{R: 23, G: 24, B: 25},
748- })
749-
750- // The late reset from the old client's detach path must not wipe the
751- // new client's freshly applied colors.
752- d.resetScreenDefaultColors(sess)
753-
754- tb := sess.activeTab()
755- var got []byte
756- tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
757- tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
758- require.NotEmpty(t, got, "a newer client's screen default colors must survive a stale detach's reset")
759-}
760-
761727 func TestDetachOnSendErrorResetsScreenDefaultColors(t *testing.T) {
762728 p, release := newBlockingPTY(t)
763729 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
@@ -769,7 +735,7 @@ func TestDetachOnSendErrorResetsScreenDefaultColors(t *testing.T) {
769735
770736 d.detachOnSendError(sess, ac, ac.transport())
771737
772- tb := sess.activeTab()
738+ tb := testAttachmentTab(sess)
773739 var got []byte
774740 tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
775741 tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
@@ -788,40 +754,20 @@ func TestDetachOnSendErrorParkPreservesScreenDefaultColors(t *testing.T) {
788754
789755 d.detachOnSendError(sess, ac, ac.transport())
790756
791- tb := sess.activeTab()
757+ tb := testAttachmentTab(sess)
792758 var got []byte
793759 tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
794760 tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
795761 require.NotEmpty(t, got, "parked clients resume the same attachment, so screen default colors must be preserved")
796762 }
797763
798-func TestApplyThemeIgnoresReplacedClient(t *testing.T) {
799- p, release := newBlockingPTY(t)
800- d, sess, old, _ := newManualSessionWithPTYs(t, p)
801- defer release()
802- tr, _ := newCapturingTransport(t)
803- _, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
804- require.NoError(t, err)
805-
806- d.applyTheme(sess, old, ports.Theme{
807- HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
808- HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
809- })
810-
811- var got []byte
812- tb := sess.activeTab()
813- tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
814- tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
815- require.Empty(t, got)
816-}
817-
818764 func TestStatusMarksEphemeralSession(t *testing.T) {
819765 p, release := newBlockingPTY(t)
820766 _, sess, _, _ := newManualSessionWithPTYs(t, p)
821767 defer release()
822768 sess.name = "0"
823769 sess.ephemeral = true
824- win := sess.activeTab()
770+ win := testAttachmentTab(sess)
825771 win.focusedPane().screen.Resize(12, 2)
826772 win.size = domain.Size{Cols: 12, Rows: 2}
827773
@@ -888,7 +834,7 @@ func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
888834 _, sess, _, _ := newManualSessionWithPTYs(t, p)
889835 defer releasePTY()
890836 sess.name = "work"
891- win := sess.activeTab()
837+ win := testAttachmentTab(sess)
892838 win.focusedPane().screen.Resize(30, 2)
893839 win.size = domain.Size{Cols: 30, Rows: 2}
894840
@@ -960,7 +906,7 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
960906 _, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
961907 defer releasePTY1()
962908 defer releasePTY2()
963- win := sess.activeTab()
909+ win := testAttachmentTab(sess)
964910 win.focusedPane().screen.Resize(18, 2)
965911 win.size = domain.Size{Cols: 18, Rows: 2}
966912 sess.mu.Lock()
@@ -1139,7 +1085,7 @@ func TestCapturePrimaryRenderStatePreservesContextualMRUModeThroughScratchReuse(
11391085 _, sess, ac, _ := newManualSessionWithPTYs(t, nil)
11401086 capture := func(bars barState) capturedRenderState {
11411087 t.Helper()
1142- state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{
1088+ state, ok := captureRenderState(sess, ac, renderCaptureRequest{
11431089 bars: bars,
11441090 overlays: capturedOverlayRenderState{},
11451091 preview: picker.Preview{},
M internal/usecase/daemon/structured_diagnostics_test.go +3 -3
@@ -25,7 +25,7 @@ func TestP4StructuredRuntimeDiagnosticsRecordAcceptedSnapshotAndDelta(t *testing
2525 emit := func(reset bool) ports.ScreenUpdate {
2626 t.Helper()
2727 ac.sendMu.Lock()
28- state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: reset})
28+ state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: reset})
2929 require.True(t, ok)
3030 composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
3131 require.True(t, d.emitFrame(proxy, ac, state, composed))
@@ -75,7 +75,7 @@ func TestP4StructuredRuntimeDiagnosticsDoNotRecordFailedSend(t *testing.T) {
7575 require.True(t, applyTestScreenText(proxy, 0, 0, "failed"))
7676
7777 ac.sendMu.Lock()
78- state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: true})
78+ state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: true})
7979 require.True(t, ok)
8080 composed := composeFrame(*state, composeCacheInput{})
8181 require.True(t, d.emitFrame(proxy, ac, state, composed))
@@ -122,7 +122,7 @@ func TestP4StructuredRuntimeDiagnosticsObserverRunsOutsideAttachmentAndProxyLock
122122 done := make(chan struct{})
123123 go func() {
124124 ac.sendMu.Lock()
125- state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: true})
125+ state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: true})
126126 if !ok {
127127 ac.sendMu.Unlock()
128128 close(done)
M internal/usecase/daemon/theme_resolve.go +1 -12
@@ -12,14 +12,6 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme
1212 return d.applyHostThemeLocked(sess, ac, t, clearUnknownScheme)
1313 }
1414
15-// applyHostThemeSendLocked applies a theme when the caller already owns
16-// ac.sendMu, preserving the usual sendMu -> themeMu lock order.
17-func (d *Daemon) applyHostThemeSendLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
18- sess.themeMu.Lock()
19- defer sess.themeMu.Unlock()
20- return d.applyHostThemeLocked(sess, ac, t, clearUnknownScheme)
21-}
22-
2315 func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
2416 // Resolve while applying, never while composing. The theme mutex only
2517 // publishes the completed value and is not held across session/tab/pane
@@ -28,13 +20,10 @@ func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme
2820 t = applied.Raw
2921 sess.mu.Lock()
3022 if ac != nil {
31- if sess.client != ac {
23+ if _, attached := sess.attachments[ac]; !attached {
3224 sess.mu.Unlock()
3325 return false
3426 }
35- } else if sess.client != nil {
36- sess.mu.Unlock()
37- return false
3827 }
3928 tabs := append([]*tab(nil), sess.tabs...)
4029 sess.mu.Unlock()
M internal/usecase/daemon/transactional_resize.go +38 -10
@@ -362,10 +362,7 @@ func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) {
362362 return
363363 }
364364 markSnapshotDirty(sess)
365- sess.mu.Lock()
366- ac := sess.client
367- sess.mu.Unlock()
368- if ac != nil {
365+ for _, ac := range sess.snapshotAttachments() {
369366 d.invalidateRender(sess, ac, true, "transactional_resize.go")
370367 }
371368 }
@@ -558,7 +555,6 @@ func (d *Daemon) applySessionLayoutWithMode(sess *session, size domain.Size, pro
558555 }
559556 sess.mu.Lock()
560557 tabs := append([]*tab(nil), sess.tabs...)
561- active := sess.active
562558 sess.mu.Unlock()
563559 plans := make([]*preparedTabLayout, 0, len(tabs))
564560 for _, tb := range tabs {
@@ -633,14 +629,17 @@ func (d *Daemon) applySessionLayoutWithMode(sess *session, size domain.Size, pro
633629 }
634630 }
635631 }
636- if active >= 0 && active < len(tabs) {
637- floatingFailed, ok := d.applyVisibleFloatingLayout(sess, tabs[active], current)
632+ for _, tb := range tabs {
633+ floatingFailed, ok := d.applyVisibleFloatingLayout(sess, tb, current)
638634 if !ok {
639635 return nil, false
640636 }
641637 failed = append(failed, floatingFailed...)
642638 }
643639 failedCurrent := len(failed) == 0 || resizeMembersOwnerCurrent(failed)
640+ if current != nil && !current() {
641+ return nil, false
642+ }
644643 if len(failed) != 0 && failedCurrent {
645644 d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed,
646645 "pane resize failed; retrying in background", failed[len(failed)-1].err)
@@ -648,6 +647,12 @@ func (d *Daemon) applySessionLayoutWithMode(sess *session, size domain.Size, pro
648647 if len(failed) != 0 && !failedCurrent {
649648 return nil, false
650649 }
650+ if current != nil {
651+ d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostSnapshotDirty)
652+ if !current() {
653+ return nil, false
654+ }
655+ }
651656 markSnapshotDirty(sess)
652657 d.observeRuntime(ports.RuntimeResizeCommitted, 0, true)
653658 return failed, true
@@ -660,7 +665,13 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *
660665 return false
661666 }
662667 snap := rc.resizeSnapshot()
663- current := func() bool { return rc.resizeCurrentForLease(epoch, ac, lease, false) }
668+ connectionGeneration := ac.connectionGeneration.Load()
669+ connection := ac.transportSnapshot()
670+ current := func() bool {
671+ return ac.connectionGeneration.Load() == connectionGeneration &&
672+ ac.transportSnapshotCurrent(connection) &&
673+ rc.resizeCurrentForLease(epoch, ac, lease, false)
674+ }
664675 if !current() {
665676 return false
666677 }
@@ -671,21 +682,38 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *
671682 if !ok {
672683 return false
673684 }
685+ // Revalidate while holding the attachment output boundary. A lease can be
686+ // retired after the layout commit but before this attachment-local size is
687+ // published; stale generations must not publish any remaining effects.
688+ d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication)
674689 ac.sendMu.Lock()
690+ if !current() {
691+ ac.sendMu.Unlock()
692+ return false
693+ }
675694 ac.size = snap.size
676695 ac.sendMu.Unlock()
696+ if !current() {
697+ return false
698+ }
677699 if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) {
678700 rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) })
679701 }
702+ if !current() {
703+ return false
704+ }
680705 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
681706 // A successful transaction publishes exactly one full S2 frame. The
682707 // coordinator is the only emission route and stale epochs never reach it.
683- if !rc.invalidateForLeaseAtResizeEpoch(ac, lease, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
708+ if !current() || !rc.invalidateForLeaseAtResizeEpoch(ac, lease, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
684709 return false
685710 }
686711 // The resize debounce has already elapsed. Consume this sticky reset now;
687712 // fire preserves ACK and synchronized-output gates rather than scheduling a
688713 // second urgent deadline.
714+ if !current() {
715+ return false
716+ }
689717 rc.fireCurrent(false)
690718 return true
691719 }
@@ -824,7 +852,7 @@ func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, s
824852 if ac == nil {
825853 return d.requestTransactionalResizeForLease(sess, nil, nil, size, immediate)
826854 }
827- rc := d.attachCoordinator(sess, nil, ac, true)
855+ rc := d.attachCoordinator(sess, ac, true)
828856 return d.requestTransactionalResizeForLease(sess, ac, rc.attachmentLease(ac), size, immediate)
829857 }
830858
M internal/usecase/daemon/transactional_resize_test.go +60 -21
@@ -131,7 +131,7 @@ func TestReplayResizePendingBuffersSuccessFailureAndBatchOrder(t *testing.T) {
131131 t.Run("success resizes then replays", func(t *testing.T) {
132132 pty := &transactionalResizePTY{}
133133 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
134- tb, p := sess.activeTab(), sess.activeTab().focusedPane()
134+ tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
135135 p.resizeApplying = true
136136 p.resizePending = []byte("A\x1b[1;81HB")
137137 d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 120, Height: 23})
@@ -142,7 +142,7 @@ func TestReplayResizePendingBuffersSuccessFailureAndBatchOrder(t *testing.T) {
142142 t.Run("failure retains old parser width", func(t *testing.T) {
143143 pty := &transactionalResizePTY{}
144144 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
145- tb, p := sess.activeTab(), sess.activeTab().focusedPane()
145+ tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
146146 p.resizeApplying = true
147147 p.resizePending = []byte("\x1b[1;81HB")
148148 d.replayResizePending(sess, tb, p, false, domain.Rect{Width: 120, Height: 23})
@@ -152,7 +152,7 @@ func TestReplayResizePendingBuffersSuccessFailureAndBatchOrder(t *testing.T) {
152152 t.Run("batches retain read order", func(t *testing.T) {
153153 pty := &transactionalResizePTY{}
154154 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
155- tb, p := sess.activeTab(), sess.activeTab().focusedPane()
155+ tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
156156 p.screen.OnResponse = func([]byte) { p.ptyResponses = append(p.ptyResponses, 'r') }
157157 pty.onWrite = func([]byte) {
158158 p.mu.Lock()
@@ -171,7 +171,7 @@ func TestProcessPTYDataRetainsCallbacksDuringResizeReplay(t *testing.T) {
171171 pty := &transactionalResizePTY{onWrite: func(b []byte) { responses <- b }}
172172 d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
173173 publishActiveClipboardCapability(d, sess, ac, ac.transport())
174- tb, p := sess.activeTab(), sess.activeTab().focusedPane()
174+ tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
175175 p.screen.OnResponse = func(b []byte) { p.ptyResponses = append(p.ptyResponses, b...) }
176176 p.screen.OnBell = func() { p.ptyAttention = true }
177177 p.screen.OnClipboard = func(b64 string) { p.ptyClipboards = append(p.ptyClipboards, b64) }
@@ -207,7 +207,7 @@ func TestProcessPTYDataRetainsCallbacksDuringResizeReplay(t *testing.T) {
207207 func TestApplySessionLayoutStopsRetryWhenSessionCanceled(t *testing.T) {
208208 pty := &transactionalResizePTY{}
209209 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
210- tb, p := sess.activeTab(), sess.activeTab().focusedPane()
210+ tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
211211 d.beforeSessionResizePublication = sess.cancel
212212
213213 _, ok := d.applySessionLayout(sess, domain.Size{Cols: 100, Rows: 30}, nil, nil)
@@ -226,7 +226,7 @@ func TestApplySessionLayoutStopsRetryWhenSessionCanceled(t *testing.T) {
226226 func TestTransactionalResizeApplyDoesNotHoldTabOrPaneLocks(t *testing.T) {
227227 pty := &transactionalResizePTY{}
228228 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
229- tb := sess.activeTab()
229+ tb := testAttachmentTab(sess)
230230 p := tb.focusedPane()
231231 var tabUnlocked, paneUnlocked bool
232232 pty.onResize = func() {
@@ -291,7 +291,7 @@ func TestTransactionalResizePartialFailureCommitsOnlySuccessfulPTYState(t *testi
291291 ok := &transactionalResizePTY{}
292292 failed := &transactionalResizePTY{errs: []error{errors.New("scripted resize failure")}}
293293 d, sess, ac, _ := newManualSessionWithPTYs(t, ok)
294- tb := sess.activeTab()
294+ tb := testAttachmentTab(sess)
295295 second := newPane("pane-2", failed, domain.Size{Cols: 80, Rows: 23})
296296 tb.mu.Lock()
297297 tb.panes[second.id] = second
@@ -332,7 +332,7 @@ func TestTransactionalResizeRetryMarksNamedSnapshotDirtyOnlyOnSuccess(t *testing
332332 pty := &transactionalResizePTY{errs: tc.errs}
333333 d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
334334 sess.snapEligible.Store(true)
335- tb, p := sess.activeTab(), sess.activeTab().focusedPane()
335+ tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
336336
337337 require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 100, Rows: 30}, true))
338338 rc := sess.renderCoordinator()
@@ -356,7 +356,7 @@ func TestTransactionalResizeRetryTargetsNewestCommittedEpoch(t *testing.T) {
356356 ok := &transactionalResizePTY{}
357357 failed := &transactionalResizePTY{errs: []error{errors.New("first epoch fails"), nil}}
358358 d, sess, ac, _ := newManualSessionWithPTYs(t, ok)
359- tb := sess.activeTab()
359+ tb := testAttachmentTab(sess)
360360 retry := newPane("retry", failed, domain.Size{Cols: 80, Rows: 23})
361361 tb.mu.Lock()
362362 tb.panes[retry.id] = retry
@@ -386,10 +386,8 @@ func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) {
386386 name string
387387 run func(*renderCoordinator, *attachedClient, *attachedClient)
388388 }{
389- {"replace", func(rc *renderCoordinator, old, next *attachedClient) { rc.noteReplace(old, next) }},
389+ {"detach and attach", func(rc *renderCoordinator, old, next *attachedClient) { rc.noteDetach(old); rc.attach(next) }},
390390 {"detach", func(rc *renderCoordinator, old, _ *attachedClient) { rc.noteDetach(old) }},
391- {"park", func(rc *renderCoordinator, old, _ *attachedClient) { rc.notePark(old) }},
392- {"resume", func(rc *renderCoordinator, old, next *attachedClient) { rc.notePark(old); rc.attach(next) }},
393391 } {
394392 t.Run(transition.name, func(t *testing.T) {
395393 rc := newRenderCoordinator(renderCoordinatorOptions{})
@@ -412,7 +410,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing
412410 d, sess, ac, _ := newManualSessionWithPTYs(t, first, second)
413411 observer := &daemonRuntimeObserver{}
414412 d.runtimeObserver = observer
415- rc := d.attachCoordinator(sess, nil, ac, true)
413+ rc := d.attachCoordinator(sess, ac, true)
416414 lease := rc.attachmentLease(ac)
417415 var newer uint64
418416 d.beforeSessionResizePublication = func() {
@@ -462,10 +460,51 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing
462460 require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}}, second.requested())
463461 }
464462
463+func TestTransactionalResizeRechecksLeaseAtAttachmentPublication(t *testing.T) {
464+ for _, tc := range []struct {
465+ name string
466+ effect resizeOwnerPostEffect
467+ }{
468+ {name: "before snapshot effects", effect: resizeOwnerPostSnapshotDirty},
469+ {name: "before attachment size", effect: resizeOwnerPostCommitPublication},
470+ } {
471+ t.Run(tc.name, func(t *testing.T) {
472+ pty := &transactionalResizePTY{}
473+ d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
474+ rc := d.attachCoordinator(sess, ac, true)
475+ lease := rc.attachmentLease(ac)
476+ epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)
477+ require.NotZero(t, epoch)
478+ initialSize := ac.size
479+
480+ entered := make(chan struct{})
481+ release := make(chan struct{})
482+ d.beforeResizeOwnerPostEffect = func(effect resizeOwnerPostEffect) {
483+ if effect != tc.effect {
484+ return
485+ }
486+ close(entered)
487+ <-release
488+ }
489+ done := make(chan bool, 1)
490+ go func() { done <- d.runResizeTransaction(sess, ac, lease, epoch) }()
491+ <-entered
492+ newer := rc.recordResizeRequestForLease(domain.Size{Cols: 120, Rows: 34}, ac, lease)
493+ require.NotZero(t, newer)
494+ close(release)
495+ require.False(t, <-done, "stale resize generation must be rejected")
496+ require.Equal(t, initialSize, ac.size, "stale resize must not publish attachment size")
497+ if tc.effect == resizeOwnerPostSnapshotDirty {
498+ require.False(t, sess.snapDirty.Load(), "stale resize must not publish snapshot dirtiness")
499+ }
500+ })
501+ }
502+}
503+
465504 func TestStaleRemovedMemberGateIsCanceledOnceByFreshPlan(t *testing.T) {
466505 pty := &transactionalResizePTY{}
467506 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
468- tb := sess.activeTab()
507+ tb := testAttachmentTab(sess)
469508 p := tb.focusedPane()
470509 p.resizeApplying = true
471510 p.resizePending = []byte("x")
@@ -489,7 +528,7 @@ func TestStaleRemovedMemberGateIsCanceledOnceByFreshPlan(t *testing.T) {
489528 func TestHeadlessResizeRetriesInvalidatedPlan(t *testing.T) {
490529 pty := &transactionalResizePTY{}
491530 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
492- tb := sess.activeTab()
531+ tb := testAttachmentTab(sess)
493532 var invalidate sync.Once
494533 pty.onResize = func() {
495534 invalidate.Do(func() {
@@ -509,14 +548,14 @@ func TestHeadlessResizeRetriesInvalidatedPlan(t *testing.T) {
509548 func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) {
510549 popupPTY := &transactionalResizePTY{errs: []error{errors.New("initial popup failure")}}
511550 d, source, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
512- tb := source.activeTab()
551+ tb := testAttachmentTab(source)
513552 popup := newPane("popup", popupPTY, domain.Size{Cols: 80, Rows: 23})
514553 tb.mu.Lock()
515554 tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7}
516555 tb.mu.Unlock()
517556 owner := publishPaneOwner(popup, source, tb, 7)
518557
519- rc := d.attachCoordinator(source, nil, ac, true)
558+ rc := d.attachCoordinator(source, ac, true)
520559 lease := rc.attachmentLease(ac)
521560 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
522561 require.True(t, d.runResizeTransaction(source, ac, lease, epoch))
@@ -561,7 +600,7 @@ func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) {
561600 d.ApplyConfig(domain.Config{Floating: cfg})
562601 require.True(t, d.requestTransactionalResize(sess, nil, tc.from, true))
563602
564- tb := sess.activeTab()
603+ tb := testAttachmentTab(sess)
565604 fromGeometry := calculateContentFloatingGeometry(tabSize(tc.from), cfg)
566605 toGeometry := calculateContentFloatingGeometry(tabSize(tc.to), cfg)
567606 entered, release := make(chan struct{}), make(chan struct{})
@@ -735,13 +774,13 @@ func TestZeroInnerFloatingDrawerRejectsStaleRequestWithoutPhysicalPublication(t
735774 func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) {
736775 popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}}
737776 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
738- tb := sess.activeTab()
777+ tb := testAttachmentTab(sess)
739778 popup := newPane("popup", popupPTY, domain.Size{Cols: 80, Rows: 23})
740779 tb.mu.Lock()
741780 tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7}
742781 tb.mu.Unlock()
743782
744- rc := d.attachCoordinator(sess, nil, ac, true)
783+ rc := d.attachCoordinator(sess, ac, true)
745784 lease := rc.attachmentLease(ac)
746785 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
747786 require.NotZero(t, epoch)
@@ -823,7 +862,7 @@ func TestTransactionalResizeFloatingMouseCopyAndSearchContract(t *testing.T) {
823862 entered, release := make(chan struct{}), make(chan struct{})
824863 pty := &transactionalResizePTY{errs: tc.errs, onResize: func() { close(entered); <-release }}
825864 d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
826- tb := sess.activeTab()
865+ tb := testAttachmentTab(sess)
827866 p := tb.focusedPane()
828867 old := p.rect
829868 d.enterCopyMode(sess, ac)
M internal/usecase/daemon/transport_observability_test.go +3 -3
@@ -20,7 +20,7 @@ func TestTransportObservabilityDaemonBoundaries(t *testing.T) {
2020 // One in-flight state makes the second public resize take the ACK-blocked
2121 // path. The real coordinator owns both renders and the ACK wake.
2222 ac.output = newOutputStateStream(1)
23- rc := d.attachCoordinator(sess, nil, ac, true)
23+ rc := d.attachCoordinator(sess, ac, true)
2424
2525 if !d.resizeForFirstPaint(sess, ac, domain.Size{Cols: 90, Rows: 25}) {
2626 t.Fatal("first resize was not accepted")
@@ -125,7 +125,7 @@ func TestTransportObservabilityBlockedRenderObserverReleasesArchitectureLocks(t
125125 }
126126 ac.ackOutputState(output.NewStateNum)
127127
128- pane := sess.activeTab().focusedPane()
128+ pane := testAttachmentTab(sess).focusedPane()
129129 for _, lock := range []struct {
130130 name string
131131 fn func()
@@ -141,7 +141,7 @@ func TestTransportObservabilityBlockedRenderObserverReleasesArchitectureLocks(t
141141
142142 livePTY := make(chan struct{})
143143 go func() {
144- d.processPTYData(sess, sess.activeTab(), pane, []byte("live"), false)
144+ d.processPTYData(sess, testAttachmentTab(sess), pane, []byte("live"), false)
145145 close(livePTY)
146146 }()
147147 awaitDaemonObserver(t, livePTY, "live PTY processing")

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close