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) {
261 assertChildEnvironment(t, tr2, p2, sz, "second", secondShell, "/run/second", "wayland-second")261 assertChildEnvironment(t, tr2, p2, sz, "second", secondShell, "/run/second", "wayland-second")
262 }262 }
263 263
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+
264 func TestIntegration_AttachFirstOutput(t *testing.T) {305 func TestIntegration_AttachFirstOutput(t *testing.T) {
265 sz := domain.Size{Cols: 80, Rows: 24}306 sz := domain.Size{Cols: 80, Rows: 24}
266 dir, _ := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf HELLO; sleep 30"}))307 dir, _ := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf HELLO; sleep 30"}))
@@ -329,7 +370,7 @@ func TestIntegration_CommandPaletteRenamesEphemeralSession(t *testing.T) {
329 require.Equal(t, ports.MsgSessions, f.Type)370 require.Equal(t, ports.MsgSessions, f.Type)
330 sessions, err := ports.UnmarshalSessions(f.Payload)371 sessions, err := ports.UnmarshalSessions(f.Payload)
331 require.NoError(t, err)372 require.NoError(t, err)
332- require.Len(t, sessions.Sessions, 1)373+ require.NotEmpty(t, sessions.Sessions)
333 require.Equal(t, "work", sessions.Sessions[0].Name)374 require.Equal(t, "work", sessions.Sessions[0].Name)
334 require.False(t, sessions.Sessions[0].Ephemeral)375 require.False(t, sessions.Sessions[0].Ephemeral)
335 }376 }
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 (
8 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
9 )9 )
10 10
11-type roleEffectDrainDeadline struct {11+type attachmentEffectDrainDeadline struct {
12 clock ports.Clock12 clock ports.Clock
13 done chan struct{}13 done chan struct{}
14 stopCh chan struct{}14 stopCh chan struct{}
@@ -18,13 +18,13 @@ type roleEffectDrainDeadline struct {
18 started atomic.Bool18 started atomic.Bool
19 }19 }
20 20
21-func newRoleEffectDrainDeadline(clock ports.Clock) *roleEffectDrainDeadline {21+func newAttachmentEffectDrainDeadline(clock ports.Clock) *attachmentEffectDrainDeadline {
22- return &roleEffectDrainDeadline{22+ return &attachmentEffectDrainDeadline{
23 clock: clock, done: make(chan struct{}), stopCh: make(chan struct{}), finished: make(chan struct{}),23 clock: clock, done: make(chan struct{}), stopCh: make(chan struct{}), finished: make(chan struct{}),
24 }24 }
25 }25 }
26 26
27-func (d *roleEffectDrainDeadline) stop() {27+func (d *attachmentEffectDrainDeadline) stop() {
28 if d == nil {28 if d == nil {
29 return29 return
30 }30 }
@@ -40,7 +40,7 @@ func (d *roleEffectDrainDeadline) stop() {
40 })40 })
41 }41 }
42 42
43-func (d *roleEffectDrainDeadline) Done() <-chan struct{} {43+func (d *attachmentEffectDrainDeadline) Done() <-chan struct{} {
44 if d == nil {44 if d == nil {
45 return nil45 return nil
46 }46 }
@@ -60,71 +60,57 @@ func (d *roleEffectDrainDeadline) Done() <-chan struct{} {
60 return d.done60 return d.done
61 }61 }
62 62
63-type frozenRoleEffectGates struct {63+type frozenAttachmentEffectGates struct {
64 clients []*attachedClient64 clients []*attachedClient
65 interruptions map[*attachedClient]transportSnapshot65 interruptions map[*attachedClient]transportSnapshot
66 acquired bool66 acquired bool
67 drained bool67 drained bool
68 }68 }
69 69
70-func (f frozenRoleEffectGates) interrupted(ac *attachedClient, expected transportSnapshot) bool {70+type attachmentTransportInterrupt struct {
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 {
85 ac *attachedClient71 ac *attachedClient
86 transport transportSnapshot72 transport transportSnapshot
87 }73 }
88 74
89-// roleEffectFreezeOptions controls one role-gate freeze operation. A freeze75+// attachmentEffectFreezeOptions controls one attachment-effect-gate freeze operation. A freeze
90 // must be called without daemon, routing, session, coordinator, send, overlay,76 // must be called without daemon, routing, session, coordinator, send, overlay,
91 // router, or pane locks held.77 // router, or pane locks held.
92-type roleEffectFreezeOptions struct {78+type attachmentEffectFreezeOptions struct {
93- interrupts []roleTransportInterrupt79+ interrupts []attachmentTransportInterrupt
94 done func() <-chan struct{}80 done func() <-chan struct{}
95 afterFrozen func(*attachedClient)81 afterFrozen func(*attachedClient)
96 nonblocking bool82 nonblocking bool
97 }83 }
98 84
99-// freezeRoleEffectGates establishes transition priority in immutable attachment85+// freezeAttachmentEffectGates establishes transition priority in immutable attachment
100 // order, then drains earlier admissions.86 // order, then drains earlier admissions.
101-func freezeRoleEffectGates(clients ...*attachedClient) frozenRoleEffectGates {87+func freezeAttachmentEffectGates(clients ...*attachedClient) frozenAttachmentEffectGates {
102- return freezeRoleEffectGatesWith(roleEffectFreezeOptions{}, clients...)88+ return freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{}, clients...)
103 }89 }
104 90
105-// freezeRoleEffectGatesWith uses done as one overall bound for ordered91+// freezeAttachmentEffectGatesWith uses done as one overall bound for ordered
106 // acquisition and drain. Acquisition is all-or-nothing: if the bound expires92 // acquisition and drain. Acquisition is all-or-nothing: if the bound expires
107 // behind another owner, only gates acquired by this call are rolled back, in93 // behind another owner, only gates acquired by this call are rolled back, in
108 // reverse order. nonblocking is used by move reservations to avoid inverting94 // reverse order. nonblocking is used by move reservations to avoid inverting
109 // the move/teardown protocols.95 // the move/teardown protocols.
110-func freezeRoleEffectGatesWith(options roleEffectFreezeOptions, clients ...*attachedClient) frozenRoleEffectGates {96+func freezeAttachmentEffectGatesWith(options attachmentEffectFreezeOptions, clients ...*attachedClient) frozenAttachmentEffectGates {
111- ordered := orderedRoleEffectClients(clients)97+ ordered := orderedAttachmentEffectClients(clients)
112- frozen, acquired := acquireRoleEffectGates(ordered, options.done, options.afterFrozen, options.nonblocking)98+ frozen, acquired := acquireAttachmentEffectGates(ordered, options.done, options.afterFrozen, options.nonblocking)
113 if !acquired {99 if !acquired {
114- return frozenRoleEffectGates{}100+ return frozenAttachmentEffectGates{}
115 }101 }
116 102
117- interruptByClient := roleTransportInterruptsByClient(options.interrupts)103+ interruptByClient := attachmentTransportInterruptsByClient(options.interrupts)
118 frozen.interruptions = make(map[*attachedClient]transportSnapshot, len(interruptByClient))104 frozen.interruptions = make(map[*attachedClient]transportSnapshot, len(interruptByClient))
119- interruptFrozenRoleTransports(ordered, interruptByClient)105+ interruptFrozenAttachmentTransports(ordered, interruptByClient)
120 frozen.acquired = true106 frozen.acquired = true
121- frozen.drained = drainFrozenRoleEffects(ordered, interruptByClient, frozen.interruptions, options.done)107+ frozen.drained = drainFrozenAttachmentEffects(ordered, interruptByClient, frozen.interruptions, options.done)
122 return frozen108 return frozen
123 }109 }
124 110
125-// orderedRoleEffectClients snapshots unique participants in immutable lock111+// orderedAttachmentEffectClients snapshots unique participants in immutable lock
126 // order. Assigning a previously unused order does not freeze or wait on a gate.112 // 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 {
128 unique := make(map[*attachedClient]struct{}, len(clients))114 unique := make(map[*attachedClient]struct{}, len(clients))
129 ordered := make([]*attachedClient, 0, len(clients))115 ordered := make([]*attachedClient, 0, len(clients))
130 for _, ac := range clients {116 for _, ac := range clients {
@@ -135,18 +121,18 @@ func orderedRoleEffectClients(clients []*attachedClient) []*attachedClient {
135 continue121 continue
136 }122 }
137 unique[ac] = struct{}{}123 unique[ac] = struct{}{}
138- ac.roleEffects.immutableOrder()124+ ac.attachmentEffects.immutableOrder()
139 ordered = append(ordered, ac)125 ordered = append(ordered, ac)
140 }126 }
141 sort.Slice(ordered, func(i, j int) bool {127 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()
143 })129 })
144 return ordered130 return ordered
145 }131 }
146 132
147-// waitForRoleEffectChangeLocked requires g.mu and returns with g.mu held. A133+// waitForAttachmentEffectChangeLocked requires g.mu and returns with g.mu held. A
148 // true result means the overall deadline won and the caller must stop waiting.134 // 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 {
150 if done == nil {136 if done == nil {
151 g.cond.Wait()137 g.cond.Wait()
152 return false138 return false
@@ -167,28 +153,28 @@ func waitForRoleEffectChangeLocked(g *roleEffectGate, done func() <-chan struct{
167 }153 }
168 }154 }
169 155
170-// acquireRoleEffectGates establishes exclusive transition ownership in order.156+// acquireAttachmentEffectGates establishes exclusive transition ownership in order.
171 // It must run without architecture locks. Deadline rollback releases only this157 // It must run without architecture locks. Deadline rollback releases only this
172 // call's partial acquisition and does so in reverse order.158 // 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) {159+func acquireAttachmentEffectGates(ordered []*attachedClient, done func() <-chan struct{}, afterFrozen func(*attachedClient), nonblocking bool) (frozenAttachmentEffectGates, bool) {
174- frozen := frozenRoleEffectGates{clients: make([]*attachedClient, 0, len(ordered))}160+ frozen := frozenAttachmentEffectGates{clients: make([]*attachedClient, 0, len(ordered))}
175 for _, ac := range ordered {161 for _, ac := range ordered {
176- g := &ac.roleEffects162+ g := &ac.attachmentEffects
177 g.mu.Lock()163 g.mu.Lock()
178 g.initLocked()164 g.initLocked()
179- for g.phase == roleEffectsFrozen {165+ for g.phase == attachmentEffectsFrozen {
180 if nonblocking {166 if nonblocking {
181 g.mu.Unlock()167 g.mu.Unlock()
182 frozen.unfreeze()168 frozen.unfreeze()
183- return frozenRoleEffectGates{}, false169+ return frozenAttachmentEffectGates{}, false
184 }170 }
185- if waitForRoleEffectChangeLocked(g, done) {171+ if waitForAttachmentEffectChangeLocked(g, done) {
186 g.mu.Unlock()172 g.mu.Unlock()
187 frozen.unfreeze()173 frozen.unfreeze()
188- return frozenRoleEffectGates{}, false174+ return frozenAttachmentEffectGates{}, false
189 }175 }
190 }176 }
191- g.phase = roleEffectsFrozen177+ g.phase = attachmentEffectsFrozen
192 g.mu.Unlock()178 g.mu.Unlock()
193 frozen.clients = append(frozen.clients, ac)179 frozen.clients = append(frozen.clients, ac)
194 if afterFrozen != nil {180 if afterFrozen != nil {
@@ -198,7 +184,7 @@ func acquireRoleEffectGates(ordered []*attachedClient, done func() <-chan struct
198 return frozen, true184 return frozen, true
199 }185 }
200 186
201-func roleTransportInterruptsByClient(interrupts []roleTransportInterrupt) map[*attachedClient]transportSnapshot {187+func attachmentTransportInterruptsByClient(interrupts []attachmentTransportInterrupt) map[*attachedClient]transportSnapshot {
202 byClient := make(map[*attachedClient]transportSnapshot, len(interrupts))188 byClient := make(map[*attachedClient]transportSnapshot, len(interrupts))
203 for _, interrupt := range interrupts {189 for _, interrupt := range interrupts {
204 if interrupt.ac != nil && interrupt.transport.transport != nil {190 if interrupt.ac != nil && interrupt.transport.transport != nil {
@@ -208,20 +194,20 @@ func roleTransportInterruptsByClient(interrupts []roleTransportInterrupt) map[*a
208 return byClient194 return byClient
209 }195 }
210 196
211-// interruptFrozenRoleTransports snapshots every exact in-flight link while all197+// interruptFrozenAttachmentTransports snapshots every exact in-flight link while all
212 // gates are frozen, then closes the complete set before any drain wait. One198 // gates are frozen, then closes the complete set before any drain wait. One
213 // uncooperative Send cannot prevent another participant from being interrupted.199 // uncooperative Send cannot prevent another participant from being interrupted.
214-func interruptFrozenRoleTransports(ordered []*attachedClient, interruptByClient map[*attachedClient]transportSnapshot) {200+func interruptFrozenAttachmentTransports(ordered []*attachedClient, interruptByClient map[*attachedClient]transportSnapshot) {
215- toClose := make([]roleTransportInterrupt, 0, len(interruptByClient))201+ toClose := make([]attachmentTransportInterrupt, 0, len(interruptByClient))
216 for _, ac := range ordered {202 for _, ac := range ordered {
217 expected, mayInterrupt := interruptByClient[ac]203 expected, mayInterrupt := interruptByClient[ac]
218 if !mayInterrupt {204 if !mayInterrupt {
219 continue205 continue
220 }206 }
221- g := &ac.roleEffects207+ g := &ac.attachmentEffects
222 g.mu.Lock()208 g.mu.Lock()
223 if gateHasTransportEffectLocked(g, expected) {209 if gateHasTransportEffectLocked(g, expected) {
224- toClose = append(toClose, roleTransportInterrupt{ac: ac, transport: expected})210+ toClose = append(toClose, attachmentTransportInterrupt{ac: ac, transport: expected})
225 }211 }
226 g.mu.Unlock()212 g.mu.Unlock()
227 }213 }
@@ -230,15 +216,15 @@ func interruptFrozenRoleTransports(ordered []*attachedClient, interruptByClient
230 }216 }
231 }217 }
232 218
233-// drainFrozenRoleEffects waits for admissions made before the freeze. A closed219+// drainFrozenAttachmentEffects waits for admissions made before the freeze. A closed
234 // deadline permits terminal teardown to continue with the capability frozen;220 // deadline permits terminal teardown to continue with the capability frozen;
235 // late tickets can then retire without pinning the owner indefinitely.221 // 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 {
237 for _, ac := range ordered {223 for _, ac := range ordered {
238- g := &ac.roleEffects224+ g := &ac.attachmentEffects
239 g.mu.Lock()225 g.mu.Lock()
240 for g.inFlight != 0 {226 for g.inFlight != 0 {
241- if waitForRoleEffectChangeLocked(g, done) {227+ if waitForAttachmentEffectChangeLocked(g, done) {
242 g.mu.Unlock()228 g.mu.Unlock()
243 return false229 return false
244 }230 }
@@ -252,7 +238,7 @@ func drainFrozenRoleEffects(ordered []*attachedClient, interruptByClient, interr
252 return true238 return true
253 }239 }
254 240
255-func gateHasTransportEffectLocked(g *roleEffectGate, expected transportSnapshot) bool {241+func gateHasTransportEffectLocked(g *attachmentEffectGate, expected transportSnapshot) bool {
256 for _, active := range g.transportEffects {242 for _, active := range g.transportEffects {
257 if active.transport == expected.transport && active.incarnation == expected.incarnation {243 if active.transport == expected.transport && active.incarnation == expected.incarnation {
258 return true244 return true
@@ -261,12 +247,12 @@ func gateHasTransportEffectLocked(g *roleEffectGate, expected transportSnapshot)
261 return false247 return false
262 }248 }
263 249
264-func (f frozenRoleEffectGates) unfreeze() {250+func (f frozenAttachmentEffectGates) unfreeze() {
265 for i := len(f.clients) - 1; i >= 0; i-- {251 for i := len(f.clients) - 1; i >= 0; i-- {
266- g := &f.clients[i].roleEffects252+ g := &f.clients[i].attachmentEffects
267 g.mu.Lock()253 g.mu.Lock()
268 g.initLocked()254 g.initLocked()
269- g.phase = roleEffectsStable255+ g.phase = attachmentEffectsStable
270 g.signalLocked()256 g.signalLocked()
271 g.mu.Unlock()257 g.mu.Unlock()
272 }258 }
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 {
23 source attachmentSession23 source attachmentSession
24 target attachmentSession24 target attachmentSession
25 next *attachedClient25 next *attachedClient
26- expectedRole attachmentRole
27- targetRole attachmentRole
28 expectedTransport transportSnapshot26 expectedTransport transportSnapshot
29- sourceToken *attachmentRoleToken27+ sourceToken *attachmentConnectionToken
30 action string28 action string
31 activateTargetTab bool29 activateTargetTab bool
32 targetTabIndex int30 targetTabIndex int
33 copySourceEnvironment bool31 copySourceEnvironment bool
34- // preserveRole commits navigation state without replacing attachment32+ // preserveAttachment commits attachment-local navigation state without
35- // ownership. It is used for same-session picker selections after the exact33+ // changing session membership. The initiating connection token remains the
36- // initiating capability has been frozen and revalidated.34+ // exact authority for this mutation.
37- preserveRole bool35+ preserveAttachment bool
38- expectedSourceTab *tab36+ expectedSourceTab *tab
39 // transferExpectedSourceTab permits an installed visible floating pane only37 // transferExpectedSourceTab permits an installed visible floating pane only
40- // when the caller atomically transfers this exact tab under its own tab and38+ // when the caller atomically transfers this exact tab under its fences.
41- // floating-generation fences. Ordinary navigation still rejects visibility.39+ transferExpectedSourceTab bool
42- transferExpectedSourceTab bool40+ createTargetLocked func() (*session, error)
43- createTargetLocked func() (*session, error)41+ ready bool
44- ready bool42+ expectedTargetLifecycle *attachmentLifecycleFence
45- expectedTargetCurrent *attachedClient43+ preflighted bool
46- expectedTargetTransport transportSnapshot44+ attachmentEffectsFrozen bool
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
55 }45 }
56 46
57-func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source attachmentSession, req attachmentTransitionRequest) bool {47+func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source attachmentSession, req attachmentTransitionRequest) bool {
58- return token.sess == source && token.ac == req.next && token.role == req.expectedRole &&48+ return token.sess == source && token.ac == req.next &&
49+ token.generation == req.next.connectionGeneration.Load() &&
59 token.transport.transport == req.expectedTransport.transport &&50 token.transport.transport == req.expectedTransport.transport &&
60 token.transport.incarnation == req.expectedTransport.incarnation51 token.transport.incarnation == req.expectedTransport.incarnation
61 }52 }
62 53
63-// transitionSourceTokenCurrentLocked requires source.mu and, for an active54+// transitionSourceTokenCurrentLocked requires source.core().mu and, when a
64-// source, sourceCoordinator.mu. This is the canonical handoff preflight: every55+// lease exists, sourceCoordinator.mu. It is the canonical exact-connection
65-// client-originated navigation, delete, or create intent is bound to one exact56+// handoff check for client-originated navigation and lifecycle mutations.
66-// role generation, transport incarnation, and coordinator lease.57+func transitionSourceTokenCurrentLocked(token attachmentConnectionToken, source attachmentSession, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {
67-func transitionSourceTokenCurrentLocked(token attachmentRoleToken, source attachmentSession, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {58+ if token.sess != source || token.ac != req.next ||
68- if token.sess != source || token.ac != req.next || token.role != req.expectedRole ||59+ token.generation != req.next.connectionGeneration.Load() ||
69- token.generation != req.next.roleGeneration.Load() || attachmentSessionRoleLocked(source, req.next) != token.role ||60+ token.sess == nil || token.ac.currentAttachmentSession() != source ||
70- req.next.currentAttachmentSession() != source || !req.next.transportSnapshotCurrent(token.transport) {61+ !attachmentRegisteredLocked(source, req.next) ||
62+ !req.next.transportSnapshotCurrent(token.transport) {
71 return false63 return false
72 }64 }
73- if token.role != attachmentActive {65+ if token.lease == nil {
74 return true66 return true
75 }67 }
76- return sourceCoordinator != nil && token.lease != nil && token.lease.attachment == req.next &&68+ return sourceCoordinator != nil && token.lease.attachment == req.next &&
77 sourceCoordinator.leaseCurrentLocked(token.lease, true)69 sourceCoordinator.leaseCurrentLocked(token.lease, true)
78 }70 }
79 71
80 type attachmentTransitionResult struct {72 type attachmentTransitionResult struct {
81- published attachmentRoleToken73+ published attachmentConnectionToken
82- displaced attachmentRoleToken74+ cleanups []renderLifecycleCleanup
83- terminalDisplaced detachedAttachmentSnapshot
84- displacedInterrupted bool
85- cleanups []renderLifecycleCleanup
86 }75 }
87 76
88 type attachmentTransitionParticipants struct {77 type attachmentTransitionParticipants struct {
89 clients []*attachedClient78 clients []*attachedClient
90- interrupts []roleTransportInterrupt79+ interrupts []attachmentTransportInterrupt
91 }80 }
92 81
93-// snapshotAttachmentTransition discovers the exact participants while d.mu is82+// snapshotAttachmentTransition discovers the exact connection participant
94-// held. It releases every architecture lock before any role effect is ended or83+// while d.mu is held, then releases architecture locks before effect gates are
95-// any gate is frozen and drained.84+// frozen and drained. Existing target attachments are never displaced.
96 func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionRequest, attachmentTransitionParticipants, error) {85 func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionRequest, attachmentTransitionParticipants, error) {
97 d.mu.Lock()86 d.mu.Lock()
98 defer d.mu.Unlock()87 defer d.mu.Unlock()
@@ -110,73 +99,43 @@ func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (
110 targetCore = req.target.core()99 targetCore = req.target.core()
111 }100 }
112 if sourceCore == nil || req.target != nil && targetCore == nil || req.next == nil || d.closing ||101 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 ||102+ 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) {
116 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition103 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
117 }104 }
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- }
126 if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) {105 if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) {
127 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition106 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
128 }107 }
129 req.preflighted = true108 req.preflighted = true
130- return req, attachmentTransitionParticipants{109+ return req, attachmentTransitionParticipants{clients: []*attachedClient{req.next}}, nil
131- clients: []*attachedClient{req.next, req.expectedTargetCurrent},
132- interrupts: []roleTransportInterrupt{{
133- ac: req.expectedTargetCurrent, transport: req.expectedTargetTransport,
134- }},
135- }, nil
136 }110 }
137 111
138-// freezeAttachmentTransition drains the snapshotted participants without any112+// freezeAttachmentTransition drains the snapshotted connection without any
139-// architecture lock held. The returned guard remains frozen through atomic113+// architecture lock held. The returned guard remains frozen through publication.
140-// publication and must be released by the caller.114+func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, participants attachmentTransitionParticipants) (frozenAttachmentEffectGates, error) {
141-func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, participants attachmentTransitionParticipants) (frozenRoleEffectGates, bool, error) {115+ if d.afterAttachmentEffectParticipantsSnapshotted != nil {
142- if d.afterRoleEffectParticipantsSnapshotted != nil {116+ d.afterAttachmentEffectParticipantsSnapshotted(req.action, participants.clients)
143- d.afterRoleEffectParticipantsSnapshotted(req.action, participants.clients)
144 }117 }
145- var drainDeadline *roleEffectDrainDeadline118+ frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: participants.interrupts, afterFrozen: func(ac *attachedClient) {
146- var drainDone func() <-chan struct{}119+ if d.afterAttachmentEffectGateFrozen != nil {
147- if req.activationBarrier {120+ d.afterAttachmentEffectGateFrozen(req.action, ac)
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)
154 }121 }
155 }}, participants.clients...)122 }}, participants.clients...)
156- if drainDeadline != nil {
157- drainDeadline.stop()
158- }
159 if !frozen.acquired || !frozen.drained {123 if !frozen.acquired || !frozen.drained {
160- if req.activationBarrier {124+ return frozen, errAttachmentTransition
161- _ = req.next.closeCapturedTransport(req.expectedTransport.transport)
162- return frozen, false, errSendTimedOut
163- }
164- return frozen, false, errAttachmentTransition
165 }125 }
166- interrupted := frozen.interrupted(req.expectedTargetCurrent, req.expectedTargetTransport)126+ if d.afterAttachmentEffectsFrozen != nil {
167- if d.afterRoleEffectsFrozen != nil {127+ d.afterAttachmentEffectsFrozen()
168- d.afterRoleEffectsFrozen()
169 }128 }
170- return frozen, interrupted, nil129+ return frozen, nil
171 }130 }
172 131
173 // publishAttachmentTransition revalidates the frozen source, creates an132 // 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.
175 func (d *Daemon) publishAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionResult, error) {134 func (d *Daemon) publishAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
176 d.mu.Lock()135 d.mu.Lock()
177 defer d.mu.Unlock()136 defer d.mu.Unlock()
178 137
179- req.roleEffectsFrozen = true138+ req.attachmentEffectsFrozen = true
180 if req.target == nil {139 if req.target == nil {
181 if !d.transitionSourcePreflightLocked(req) {140 if !d.transitionSourcePreflightLocked(req) {
182 return attachmentTransitionResult{}, errAttachmentTransition141 return attachmentTransitionResult{}, errAttachmentTransition
@@ -186,71 +145,35 @@ func (d *Daemon) publishAttachmentTransition(req attachmentTransitionRequest) (a
186 if err != nil {145 if err != nil {
187 return attachmentTransitionResult{}, err146 return attachmentTransitionResult{}, err
188 }147 }
189- req.expectedTargetCurrent = nil
190 }148 }
191 return d.transitionAttachmentLocked(req)149 return d.transitionAttachmentLocked(req)
192 }150 }
193 151
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-
208 func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachmentTransitionResult, error) {152 func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
209 req, participants, err := d.snapshotAttachmentTransition(req)153 req, participants, err := d.snapshotAttachmentTransition(req)
210 if err != nil {154 if err != nil {
211 return attachmentTransitionResult{}, err155 return attachmentTransitionResult{}, err
212 }156 }
213 if req.sourceToken != nil {157 if req.sourceToken != nil {
214- d.endActionRoleEffect(req.sourceToken.effect, req.action)158+ d.endActionAttachmentEffect(req.sourceToken.effect, req.action)
215 }159 }
216 160
217- frozen, interrupted, err := d.freezeAttachmentTransition(req, participants)161+ frozen, err := d.freezeAttachmentTransition(req, participants)
218 defer frozen.unfreeze()162 defer frozen.unfreeze()
219 if err != nil {163 if err != nil {
220 return attachmentTransitionResult{}, err164 return attachmentTransitionResult{}, err
221 }165 }
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- }
236 166
237 result, err := d.publishAttachmentTransition(req)167 result, err := d.publishAttachmentTransition(req)
238 if err != nil {168 if err != nil {
239 return result, err169 return result, err
240 }170 }
241- if req.activationBarrier && result.published.role == attachmentActive {171+ d.proxyAttachmentTransitionCommitted(req.source, req.target, req.next, req.preserveAttachment)
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)
255 return result, nil172 return result, nil
256 }173 }
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
2 2
3 import (3 import (
4 "testing"4 "testing"
5- "time"
6 5
7 "github.com/stretchr/testify/require"6 "github.com/stretchr/testify/require"
8 7
9 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
10 )9 )
11 10
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-
119 func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure(t *testing.T) {11 func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure(t *testing.T) {
120 t.Parallel()12 t.Parallel()
121 13
122 d := newTestDaemon(t, nil, stubClock{})14 d := newTestDaemon(t, nil, stubClock{})
123 source := &session{sessionCore: sessionCore{id: domain.SessionID("source")}}15 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")}}
125 next := &attachedClient{tr: &closeTrackingTransport{}}17 next := &attachedClient{tr: &closeTrackingTransport{}}
126 next.setSession(source)18 next.setSession(source)
127- source.addSnatchedLocked(next)19+ source.registerAttachmentLocked(next)
128 old := &attachedClient{tr: &closeTrackingTransport{}}20 old := &attachedClient{tr: &closeTrackingTransport{}}
129 old.setSession(target)21 old.setSession(target)
130- target.client = old22+ target.registerAttachment(old)
131 d.sessions[source.id] = source23 d.sessions[source.id] = source
132 d.sessions[target.id] = target24 d.sessions[target.id] = target
133 25
134- frozen := freezeRoleEffectGates(next, old)26+ frozen := freezeAttachmentEffectGates(next, old)
135 require.True(t, frozen.acquired)27 require.True(t, frozen.acquired)
136 require.True(t, frozen.drained)28 require.True(t, frozen.drained)
137 defer frozen.unfreeze()29 defer frozen.unfreeze()
138 30
139 req := attachmentTransitionRequest{31 req := attachmentTransitionRequest{
140- source: source,32+ source: source,
141- target: target,33+ target: target,
142- next: next,34+ next: next,
143- expectedRole: attachmentSnatched,35+
144- targetRole: attachmentActive,36+ expectedTransport: next.transportSnapshot(),
145- expectedTransport: next.transportSnapshot(),37+
146- expectedTargetCurrent: old,38+ preflighted: true,
147- preflighted: true,39+ attachmentEffectsFrozen: true,
148- roleEffectsFrozen: true,40+ activateTargetTab: true,
149- activateTargetTab: true,41+ targetTabIndex: 1,
150- targetTabIndex: 1,
151 }42 }
152 43
153 d.mu.Lock()44 d.mu.Lock()
@@ -160,11 +51,11 @@ func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure
160 51
161 require.ErrorIs(t, err, errAttachmentTransition)52 require.ErrorIs(t, err, errAttachmentTransition)
162 require.Nil(t, publication)53 require.Nil(t, publication)
163- require.Equal(t, attachmentSnatched, source.attachmentRole(next))54+ require.Equal(t, true, source.attachmentRegistered(next))
164- require.Equal(t, attachmentDetached, target.attachmentRole(next))55+ require.Equal(t, false, target.attachmentRegistered(next))
165- require.Equal(t, attachmentActive, target.attachmentRole(old))56+ require.Equal(t, true, target.attachmentRegistered(old))
166 require.Same(t, source, next.currentSession())57 require.Same(t, source, next.currentSession())
167- require.Equal(t, uint64(0), next.roleGeneration.Load())58+ require.Equal(t, uint64(0), next.connectionGeneration.Load())
168- require.Equal(t, uint64(0), old.roleGeneration.Load())59+ require.Equal(t, uint64(0), old.connectionGeneration.Load())
169- require.Equal(t, 0, target.active)60+ require.Equal(t, 0, testAttachmentTabIndex(target))
170 }61 }
M internal/usecase/daemon/attachment_transition_publish.go +87 -152
@@ -1,10 +1,14 @@
1 package daemon1 package daemon
2 2
3-import "github.com/bnema/vev/internal/domain"3+import (
4+ "slices"
4 5
5-// transitionSourcePreflightLocked validates an initiating role while its gate6+ "github.com/bnema/vev/internal/domain"
6-// is frozen and d.mu is held. No caller may create or delete a target before7+)
7-// this succeeds.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.
8 func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest) bool {12 func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest) bool {
9 if req.sourceToken == nil || req.source == nil || req.source.core() == nil || d.sessions[req.source.core().id] != req.source {13 if req.sourceToken == nil || req.source == nil || req.source.core() == nil || d.sessions[req.source.core().id] != req.source {
10 return false14 return false
@@ -14,19 +18,18 @@ func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest
14 req.source.core().mu.Lock()18 req.source.core().mu.Lock()
15 defer req.source.core().mu.Unlock()19 defer req.source.core().mu.Unlock()
16 coordinator := req.source.core().coordinator.Load()20 coordinator := req.source.core().coordinator.Load()
17- if coordinator == nil {21+ if coordinator != nil {
18- return false22+ coordinator.mu.Lock()
23+ defer coordinator.mu.Unlock()
19 }24 }
20- coordinator.mu.Lock()
21- defer coordinator.mu.Unlock()
22 if !transitionSourceTokenCurrentLocked(*req.sourceToken, req.source, coordinator, req) {25 if !transitionSourceTokenCurrentLocked(*req.sourceToken, req.source, coordinator, req) {
23 return false26 return false
24 }27 }
25 return transitionSourceTabCurrentLocked(req.source, req.expectedSourceTab)28 return transitionSourceTabCurrentLocked(req.source, req.expectedSourceTab)
26 }29 }
27 30
28-func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool {31+func (d *Daemon) sourceAttachmentTokenCurrentFrozen(token attachmentConnectionToken) bool {
29- if token.sess == nil || token.ac == nil || token.role != attachmentActive {32+ if token.sess == nil || token.ac == nil {
30 return false33 return false
31 }34 }
32 d.mu.Lock()35 d.mu.Lock()
@@ -39,15 +42,11 @@ func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool {
39 token.sess.core().mu.Lock()42 token.sess.core().mu.Lock()
40 defer token.sess.core().mu.Unlock()43 defer token.sess.core().mu.Unlock()
41 coordinator := token.sess.core().coordinator.Load()44 coordinator := token.sess.core().coordinator.Load()
42- if coordinator == nil {45+ if coordinator != nil {
43- return false46+ coordinator.mu.Lock()
44- }47+ defer coordinator.mu.Unlock()
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,
50 }48 }
49+ req := attachmentTransitionRequest{source: token.sess, next: token.ac, expectedTransport: token.transport}
51 return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)50 return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
52 }51 }
53 52
@@ -60,7 +59,7 @@ func transitionSourceTabCurrentForRequestLocked(source attachmentSession, expect
60 return true59 return true
61 }60 }
62 local, ok := localSession(source)61 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) {
64 return false63 return false
65 }64 }
66 expected.mu.Lock()65 expected.mu.Lock()
@@ -68,28 +67,25 @@ func transitionSourceTabCurrentForRequestLocked(source attachmentSession, expect
68 return transferExpected || expected.floating.state != floatingVisible67 return transferExpected || expected.floating.state != floatingVisible
69 }68 }
70 69
70+func containsTab(tabs []*tab, want *tab) bool {
71+ return slices.Contains(tabs, want)
72+}
73+
71 // transitionAttachmentLocked is the publication half of transitionAttachment.74 // 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.
73 func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) {76 func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
74 d.notices.routingMu.Lock()77 d.notices.routingMu.Lock()
75 defer d.notices.routingMu.Unlock()78 defer d.notices.routingMu.Unlock()
76 return d.transitionAttachmentRoutedLocked(req)79 return d.transitionAttachmentRoutedLocked(req)
77 }80 }
78 81
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.
82 type attachmentPublication struct {82 type attachmentPublication struct {
83 req attachmentTransitionRequest83 req attachmentTransitionRequest
84 source attachmentSession84 source attachmentSession
85- old *attachedClient
86- sourceCoordinator *renderCoordinator
87 targetCoordinator *renderCoordinator85 targetCoordinator *renderCoordinator
86+ sourceCoordinator *renderCoordinator
88 releaseCoordinators func()87 releaseCoordinators func()
89- displacedTransport transportSnapshot
90- terminalDisplaced bool
91 nextGeneration uint6488 nextGeneration uint64
92- displacedGeneration uint64
93 }89 }
94 90
95 func (p *attachmentPublication) unlockCoordinators() {91 func (p *attachmentPublication) unlockCoordinators() {
@@ -114,11 +110,9 @@ func attachmentLifecycleCurrentLocked(entry attachmentSession, fence *attachment
114 domain.TabStableID(target.tabs[fence.tabIndex].stableID) == fence.tabID110 domain.TabStableID(target.tabs[fence.tabIndex].stableID) == fence.tabID
115 }111 }
116 112
117-// validateAttachmentTransitionPrelocked performs every fallible transition113+// validateAttachmentTransitionPrelocked performs every fallible check before
118-// check without acquiring d.mu, notices.routingMu, or either session lock.114+// the non-failing membership and lease publication. Caller holds d.mu,
119-// The caller holds those locks in that order, with session locks ordered by ID.115+// notices.routingMu, and ordered source/target session locks.
120-// On success the returned validation retains the ordered coordinator locks
121-// through publishAttachmentTransitionPrelocked.
122 func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionRequest) (*attachmentPublication, error) {116 func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionRequest) (*attachmentPublication, error) {
123 source := req.source117 source := req.source
124 if source == nil {118 if source == nil {
@@ -129,56 +123,60 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
129 }123 }
130 sourceCore := source.core()124 sourceCore := source.core()
131 targetCore := req.target.core()125 targetCore := req.target.core()
132- if sourceCore == nil || targetCore == nil ||126+ if sourceCore == nil || targetCore == nil || !req.preflighted || !req.attachmentEffectsFrozen ||
133- !req.preflighted || !req.roleEffectsFrozen ||127+ req.next == nil || d.closing || d.sessions[sourceCore.id] != source || d.sessions[targetCore.id] != req.target ||
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 ||
137 req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) ||128 req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) ||
138- req.preserveRole && req.sourceToken == nil {129+ req.preserveAttachment && req.sourceToken == nil {
139 return nil, errAttachmentTransition130 return nil, errAttachmentTransition
140 }131 }
141 132
142- var targetCoordinator *renderCoordinator
143- if req.targetRole == attachmentActive {
144- targetCoordinator = d.ensureAttachmentRenderCoordinatorPrelocked(req.target)
145- }
146 sourceCoordinator := sourceCore.coordinator.Load()133 sourceCoordinator := sourceCore.coordinator.Load()
147- if attachmentSessionRoleLocked(source, req.next) != req.expectedRole ||134+ targetCoordinator := d.ensureAttachmentRenderCoordinatorPrelocked(req.target)
148- !req.next.transportSnapshotCurrent(req.expectedTransport) {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()
149 return nil, errAttachmentTransition144 return nil, errAttachmentTransition
150 }145 }
151-146+ if source != req.target && !attachmentRegisteredLocked(source, req.next) {
152- old := targetCore.client147+ releaseCoordinators()
153- if old != req.expectedTargetCurrent || source != req.target && old == req.next {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()
154 return nil, errAttachmentTransition156 return nil, errAttachmentTransition
155 }157 }
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+
156 publication := &attachmentPublication{165 publication := &attachmentPublication{
157- req: req, source: source, old: old,166+ req: req, source: source,
158 sourceCoordinator: sourceCoordinator, targetCoordinator: targetCoordinator,167 sourceCoordinator: sourceCoordinator, targetCoordinator: targetCoordinator,
159- releaseCoordinators: lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator),168+ releaseCoordinators: releaseCoordinators,
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
171 }169 }
172 return publication, nil170 return publication, nil
173 }171 }
174 172
175-// applyTargetStateLocked commits navigation metadata before role ownership, as173+// applyTargetStateLocked commits attachment-local navigation metadata and
176-// part of the same locked publication. It reports role-preserving transitions.174+// shared environment state as part of the same publication.
177 func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool {175 func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool {
178 req := publication.req176 req := publication.req
179- if req.targetRole == attachmentActive {177+ if req.activateTargetTab {
180 if target, ok := localSession(req.target); ok {178 if target, ok := localSession(req.target); ok {
181- d.demoteParkedActiveForSessionLocked(target)179+ target.activateAttachmentViewLocked(req.next, req.targetTabIndex)
182 }180 }
183 }181 }
184 if req.copySourceEnvironment {182 if req.copySourceEnvironment {
@@ -189,113 +187,58 @@ func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool
189 target.env = copyEnvironment(source.env)187 target.env = copyEnvironment(source.env)
190 }188 }
191 }189 }
192- return req.preserveRole190+ return req.preserveAttachment
193 }191 }
194 192
195-// publishAttachmentOwnershipLocked atomically changes session membership,193+// publishAttachmentOwnershipLocked adds/removes only the exact attachment.
196-// generations, and currentSession while all role gates remain frozen.194+// Existing target attachments and their independent views/transports remain
195+// untouched.
197 func publishAttachmentOwnershipLocked(publication *attachmentPublication) {196 func publishAttachmentOwnershipLocked(publication *attachmentPublication) {
198 req := publication.req197 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- }
206 if publication.source != req.target {198 if publication.source != req.target {
207- if publication.source.core().client == req.next {199+ unregisterAttachmentSessionLocked(publication.source, 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- }
232 }200 }
201+ registerAttachmentSessionLocked(req.target, req.next)
202+ publication.nextGeneration = req.next.connectionGeneration.Add(1)
233 req.next.setSession(req.target)203 req.next.setSession(req.target)
234 }204 }
235 205
236-// buildAttachmentPostcommitPlanLocked binds coordinator lifecycle cleanup and
237-// constructs the exact published tokens after ownership has committed.
238 func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) attachmentTransitionResult {206 func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) attachmentTransitionResult {
239 req := publication.req207 req := publication.req
240 result := attachmentTransitionResult{}208 result := attachmentTransitionResult{}
241- if publication.source != req.target && req.expectedRole == attachmentActive && publication.sourceCoordinator != nil {209+ if publication.source != req.target && publication.sourceCoordinator != nil {
242 result.cleanups = append(result.cleanups, publication.sourceCoordinator.beginDetachLocked(req.next))210 result.cleanups = append(result.cleanups, publication.sourceCoordinator.beginDetachLocked(req.next))
243 }211 }
244 var lease *attachmentLease212 var lease *attachmentLease
245- if publication.targetCoordinator != nil {213+ if publication.targetCoordinator != nil && !req.preserveAttachment {
246- var cleanup renderLifecycleCleanup214+ if publication.source == req.target {
247- cleanup, lease = publication.targetCoordinator.beginReplaceLocked(publication.old, req.next, req.ready)215+ lease = publication.targetCoordinator.rebindAttachmentWithReadinessLocked(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}
262 } else {216 } else {
263- result.displaced = attachmentRoleToken{217+ lease = publication.targetCoordinator.attachWithReadinessLocked(req.next, req.ready)
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
271 }218 }
272 }219 }
273- // Membership, currentSession, lease, generation, and both exact admission220+ result.published = attachmentConnectionToken{
274- // capabilities become visible as one frozen publication. The gates remain221+ sess: req.target, ac: req.next,
275- // frozen until every architecture lock has been released.222+ generation: publication.nextGeneration,
276- if req.roleEffectsFrozen {223+ transport: req.expectedTransport, lease: lease,
277- req.next.publishFrozenRoleCapability(result.published)224+ rebase: publication.source != req.target,
278- if result.displaced.ac != nil {225+ }
279- result.displaced.ac.publishFrozenRoleCapability(result.displaced)226+ if req.attachmentEffectsFrozen {
280- }227+ req.next.publishFrozenAttachmentCapability(result.published)
281 }228 }
282 return result229 return result
283 }230 }
284 231
285-// publishAttachmentTransitionPrelocked performs only non-failing metadata,
286-// ownership, generation, and coordinator-lease publication. The caller retains
287-// every lock required by validateAttachmentTransitionPrelocked.
288 func (d *Daemon) publishAttachmentTransitionPrelocked(publication *attachmentPublication) attachmentTransitionResult {232 func (d *Daemon) publishAttachmentTransitionPrelocked(publication *attachmentPublication) attachmentTransitionResult {
289- if d.applyTargetStateLocked(publication) {233+ if publication.req.preserveAttachment {
234+ d.applyTargetStateLocked(publication)
290 return attachmentTransitionResult{published: *publication.req.sourceToken}235 return attachmentTransitionResult{published: *publication.req.sourceToken}
291 }236 }
292 publishAttachmentOwnershipLocked(publication)237 publishAttachmentOwnershipLocked(publication)
238+ d.applyTargetStateLocked(publication)
293 return buildAttachmentPostcommitPlanLocked(publication)239 return buildAttachmentPostcommitPlanLocked(publication)
294 }240 }
295 241
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.
299 func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) {242 func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
300 source := req.source243 source := req.source
301 if source == nil {244 if source == nil {
@@ -315,15 +258,7 @@ func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionReques
315 return d.publishAttachmentTransitionPrelocked(publication), nil258 return d.publishAttachmentTransitionPrelocked(publication), nil
316 }259 }
317 260
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-
325 // lockAttachmentSessions gives every two-session transition one stable order.261 // lockAttachmentSessions gives every two-session transition one stable order.
326-// Session IDs are immutable and unique while their lifecycles are registered.
327 func lockAttachmentSessions(a, b attachmentSession) func() {262 func lockAttachmentSessions(a, b attachmentSession) func() {
328 aCore := attachmentSessionCore(a)263 aCore := attachmentSessionCore(a)
329 bCore := attachmentSessionCore(b)264 bCore := attachmentSessionCore(b)
@@ -347,7 +282,7 @@ func lockAttachmentSessions(a, b attachmentSession) func() {
347 }282 }
348 283
349 // lockAttachmentCoordinators follows the same immutable session-ID order as284 // 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.
351 func lockAttachmentCoordinators(a attachmentSession, aCoordinator *renderCoordinator, b attachmentSession, bCoordinator *renderCoordinator) func() {286 func lockAttachmentCoordinators(a attachmentSession, aCoordinator *renderCoordinator, b attachmentSession, bCoordinator *renderCoordinator) func() {
352 if aCoordinator == nil && bCoordinator == nil {287 if aCoordinator == nil && bCoordinator == nil {
353 return func() {}288 return func() {}
M internal/usecase/daemon/attention.go +16 -15
@@ -23,13 +23,16 @@ func (d *Daemon) noteAttention(sess *session, tb *tab) {
23 }23 }
24 24
25 sess.mu.Lock()25 sess.mu.Lock()
26- visible := sess.client != nil && sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
27 if !tb.attention {26 if !tb.attention {
28 tb.attentionAt = now27 tb.attentionAt = now
29 }28 }
30 tb.attention = true29 tb.attention = true
31- if visible {30+ for _, attachment := range sess.snapshotAttachmentsLocked() {
32- tb.attentionVisiblePaint = true31+ view := attachment.viewSnapshot()
32+ if domain.TabStableID(tb.stableID) == view.tabID {
33+ tb.attentionVisiblePaint = true
34+ break
35+ }
33 }36 }
34 sess.mu.Unlock()37 sess.mu.Unlock()
35 38
@@ -47,16 +50,16 @@ func (d *Daemon) noteAttention(sess *session, tb *tab) {
47 // happened — it also covers "no target exists", which is routine and not an50 // happened — it also covers "no target exists", which is routine and not an
48 // error. Only a failure to reach a target that does exist is a genuine error.51 // error. Only a failure to reach a target that does exist is a genuine error.
49 func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error {52 func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error {
50- return d.jumpAttentionForRole(sess, ac, attachmentRoleToken{})53+ return d.jumpAttentionForAttachment(sess, ac, attachmentConnectionToken{})
51 }54 }
52 55
53-func (d *Daemon) jumpAttentionForRole(sess *session, ac *attachedClient, token attachmentRoleToken) error {56+func (d *Daemon) jumpAttentionForAttachment(sess *session, ac *attachedClient, token attachmentConnectionToken) error {
54 if sess == nil || ac == nil {57 if sess == nil || ac == nil {
55 return nil58 return nil
56 }59 }
57 if idx, ok := oldestAttentionTab(sess); ok {60 if idx, ok := oldestAttentionTab(sess); ok {
58- if sess.switchTab(idx) {61+ if sess.switchAttachmentTab(ac, idx) {
59- d.activateTab(sess, sess.activeTab())62+ d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(ac), false, ac, token.lease)
60 d.invalidateRender(sess, ac, true, "attention.go")63 d.invalidateRender(sess, ac, true, "attention.go")
61 }64 }
62 return nil65 return nil
@@ -70,7 +73,7 @@ func (d *Daemon) jumpAttentionForRole(sess *session, ac *attachedClient, token a
70 if token.ac == nil {73 if token.ac == nil {
71 return d.switchToTarget(sess, ac, pickerTarget)74 return d.switchToTarget(sess, ac, pickerTarget)
72 }75 }
73- return d.switchActiveTargetForRole(token, pickerTarget)76+ return d.switchActiveTargetForAttachment(token, pickerTarget)
74 }77 }
75 78
76 func oldestAttentionTab(sess *session) (int, bool) {79 func oldestAttentionTab(sess *session) (int, bool) {
@@ -127,10 +130,7 @@ func oldestAttentionTabWithTime(sess *session) (int, time.Time, bool) {
127 }130 }
128 131
129 func (d *Daemon) repaintAttachedClients(sess *session) {132 func (d *Daemon) repaintAttachedClients(sess *session) {
130- sess.mu.Lock()133+ for _, ac := range sess.snapshotAttachments() {
131- ac := sess.client
132- sess.mu.Unlock()
133- if ac != nil {
134 d.invalidateRender(sess, ac, false, "attention.go")134 d.invalidateRender(sess, ac, false, "attention.go")
135 }135 }
136 }136 }
@@ -145,13 +145,14 @@ func (d *Daemon) repaintAllAttachedClients() {
145 }145 }
146 }146 }
147 147
148-func (s *session) ackAttention(tb *tab, visible bool) bool {148+func (s *session) ackAttention(tb *tab, ac *attachedClient, visible bool) bool {
149- if tb == nil {149+ if tb == nil || ac == nil {
150 return false150 return false
151 }151 }
152 s.mu.Lock()152 s.mu.Lock()
153 defer s.mu.Unlock()153 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 {
155 return false156 return false
156 }157 }
157 if tb.attentionVisiblePaint && !visible {158 if tb.attentionVisiblePaint && !visible {
M internal/usecase/daemon/attention_test.go +27 -27
@@ -23,7 +23,7 @@ func TestNoteAttentionFlagsBackgroundTab(t *testing.T) {
23 d, sess, _, _, releases := newManualTabSession(t, 2)23 d, sess, _, _, releases := newManualTabSession(t, 2)
24 defer releases[0]()24 defer releases[0]()
25 defer releases[1]()25 defer releases[1]()
26- sess.active = 026+ selectTestAttachmentTab(sess, 0)
27 27
28 d.noteAttention(sess, sess.tabs[1])28 d.noteAttention(sess, sess.tabs[1])
29 29
@@ -37,7 +37,7 @@ func TestNoteAttentionFlagsBackgroundTab(t *testing.T) {
37 func TestNoteAttentionFlagsVisibleTabUntilPainted(t *testing.T) {37 func TestNoteAttentionFlagsVisibleTabUntilPainted(t *testing.T) {
38 d, sess, _, _, releases := newManualTabSession(t, 1)38 d, sess, _, _, releases := newManualTabSession(t, 1)
39 defer releases[0]()39 defer releases[0]()
40- sess.active = 040+ selectTestAttachmentTab(sess, 0)
41 41
42 d.noteAttention(sess, sess.tabs[0])42 d.noteAttention(sess, sess.tabs[0])
43 43
@@ -50,7 +50,7 @@ func TestNoteAttentionFlagsVisibleTabUntilPainted(t *testing.T) {
50 func TestPaintDoesNotAckActiveAttentionOnBlankPulseFrame(t *testing.T) {50 func TestPaintDoesNotAckActiveAttentionOnBlankPulseFrame(t *testing.T) {
51 d, sess, ac, sends, releases := newManualTabSession(t, 1)51 d, sess, ac, sends, releases := newManualTabSession(t, 1)
52 defer releases[0]()52 defer releases[0]()
53- sess.active = 053+ selectTestAttachmentTab(sess, 0)
54 d.setAttentionFrame(0)54 d.setAttentionFrame(0)
55 d.noteAttention(sess, sess.tabs[0])55 d.noteAttention(sess, sess.tabs[0])
56 56
@@ -86,7 +86,7 @@ func TestPTYReaderActiveVisibleAgentNotificationRendersBellBeforeAck(t *testing.
86 t.Run(tt.name, func(t *testing.T) {86 t.Run(tt.name, func(t *testing.T) {
87 pty := newScriptPTY([][]byte{tt.data})87 pty := newScriptPTY([][]byte{tt.data})
88 d, sess, ac, sends := newManualSessionWithPTYs(t, pty)88 d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
89- sess.active = 089+ selectTestAttachmentTab(sess, 0)
90 d.setAttentionFrame(1)90 d.setAttentionFrame(1)
91 91
92 d.sessWg.Add(1)92 d.sessWg.Add(1)
@@ -119,7 +119,7 @@ func TestPTYReaderActiveVisibleAgentNotificationRendersBellBeforeAck(t *testing.
119 func TestNoteAttentionFlagsDetachedActiveTab(t *testing.T) {119 func TestNoteAttentionFlagsDetachedActiveTab(t *testing.T) {
120 d, sess, ac, _, releases := newManualTabSession(t, 1)120 d, sess, ac, _, releases := newManualTabSession(t, 1)
121 defer releases[0]()121 defer releases[0]()
122- sess.active = 0122+ selectTestAttachmentTab(sess, 0)
123 require.True(t, sess.detachIfCurrent(ac))123 require.True(t, sess.detachIfCurrent(ac))
124 124
125 d.noteAttention(sess, sess.tabs[0])125 d.noteAttention(sess, sess.tabs[0])
@@ -135,7 +135,7 @@ func TestPTYReaderBellMarksBackgroundTab(t *testing.T) {
135 p2 := newScriptPTY([][]byte{[]byte("\a")})135 p2 := newScriptPTY([][]byte{[]byte("\a")})
136 d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)136 d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
137 defer releasePTY1()137 defer releasePTY1()
138- sess.active = 0138+ selectTestAttachmentTab(sess, 0)
139 139
140 d.sessWg.Add(1)140 d.sessWg.Add(1)
141 go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())141 go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
@@ -161,7 +161,7 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) {
161 d, sess, _, _, releases := newManualTabSession(t, 2)161 d, sess, _, _, releases := newManualTabSession(t, 2)
162 defer releases[0]()162 defer releases[0]()
163 defer releases[1]()163 defer releases[1]()
164- sess.active = 0164+ selectTestAttachmentTab(sess, 0)
165 165
166 trW := portsmocks.NewMockTransport(t)166 trW := portsmocks.NewMockTransport(t)
167 block := make(chan struct{})167 block := make(chan struct{})
@@ -176,7 +176,7 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) {
176 sctxW, cancelW := context.WithCancel(d.serveCtx)176 sctxW, cancelW := context.WithCancel(d.serveCtx)
177 t.Cleanup(cancelW)177 t.Cleanup(cancelW)
178 tabW := newTestTabWithContext(newScriptPTY(nil), sctxW, cancelW)178 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}}
180 acW.setSession(sessW)180 acW.setSession(sessW)
181 acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW}, nil)181 acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW}, nil)
182 d.sessions[sessW.id] = sessW182 d.sessions[sessW.id] = sessW
@@ -235,7 +235,7 @@ func TestAckAttentionClearsOnlyPaintedVisibleTab(t *testing.T) {
235 d, sess, ac, sends, releases := newManualTabSession(t, 2)235 d, sess, ac, sends, releases := newManualTabSession(t, 2)
236 defer releases[0]()236 defer releases[0]()
237 defer releases[1]()237 defer releases[1]()
238- sess.active = 0238+ selectTestAttachmentTab(sess, 0)
239 sess.mu.Lock()239 sess.mu.Lock()
240 sess.tabs[0].attention = true240 sess.tabs[0].attention = true
241 sess.tabs[0].attentionAt = time.Unix(1, 0)241 sess.tabs[0].attentionAt = time.Unix(1, 0)
@@ -266,7 +266,7 @@ func TestPaintPreservesBackgroundAttention(t *testing.T) {
266 d, sess, ac, sends, releases := newManualTabSession(t, 2)266 d, sess, ac, sends, releases := newManualTabSession(t, 2)
267 defer releases[0]()267 defer releases[0]()
268 defer releases[1]()268 defer releases[1]()
269- sess.active = 0269+ selectTestAttachmentTab(sess, 0)
270 sess.mu.Lock()270 sess.mu.Lock()
271 sess.tabs[1].attention = true271 sess.tabs[1].attention = true
272 sess.tabs[1].attentionAt = time.Unix(2, 0)272 sess.tabs[1].attentionAt = time.Unix(2, 0)
@@ -286,13 +286,13 @@ func TestSwitchTabClearsAttentionEndToEnd(t *testing.T) {
286 d, sess, ac, sends, releases := newManualTabSession(t, 2)286 d, sess, ac, sends, releases := newManualTabSession(t, 2)
287 defer releases[0]()287 defer releases[0]()
288 defer releases[1]()288 defer releases[1]()
289- sess.active = 0289+ selectTestAttachmentTab(sess, 0)
290 sess.mu.Lock()290 sess.mu.Lock()
291 sess.tabs[1].attention = true291 sess.tabs[1].attention = true
292 sess.tabs[1].attentionAt = time.Unix(2, 0)292 sess.tabs[1].attentionAt = time.Unix(2, 0)
293 sess.mu.Unlock()293 sess.mu.Unlock()
294 294
295- require.True(t, sess.switchTab(1))295+ require.True(t, selectTestAttachmentTab(sess, 1))
296 d.paint(sess, ac, true, nil)296 d.paint(sess, ac, true, nil)
297 data := mustOutputData(t, sends)297 data := mustOutputData(t, sends)
298 // As above: the deferred all-clients repaint has nothing new to say about298 // As above: the deferred all-clients repaint has nothing new to say about
@@ -314,7 +314,7 @@ func TestCloseRingingTabClearsClientBar(t *testing.T) {
314 d, sess, ac, sends, releases := newManualTabSession(t, 2)314 d, sess, ac, sends, releases := newManualTabSession(t, 2)
315 defer releases[0]()315 defer releases[0]()
316 defer releases[1]()316 defer releases[1]()
317- sess.active = 0317+ selectTestAttachmentTab(sess, 0)
318 sess.mu.Lock()318 sess.mu.Lock()
319 ringing := sess.tabs[1]319 ringing := sess.tabs[1]
320 ringing.attention = true320 ringing.attention = true
@@ -340,7 +340,7 @@ func TestAnimationRepaintConfinedToBarRows(t *testing.T) {
340 d, sess, ac, sends, releases := newManualTabSession(t, 2)340 d, sess, ac, sends, releases := newManualTabSession(t, 2)
341 defer releases[0]()341 defer releases[0]()
342 defer releases[1]()342 defer releases[1]()
343- sess.active = 0343+ selectTestAttachmentTab(sess, 0)
344 sess.tabs[0].focusedPane().screen.Write([]byte("MIDSCREENMARKER"))344 sess.tabs[0].focusedPane().screen.Write([]byte("MIDSCREENMARKER"))
345 sess.mu.Lock()345 sess.mu.Lock()
346 sess.tabs[1].attention = true346 sess.tabs[1].attention = true
@@ -374,7 +374,7 @@ func TestComposeFrameAttentionFrameChangeDamagesOnlyBars(t *testing.T) {
374 d, sess, _, _, releases := newManualTabSession(t, 2)374 d, sess, _, _, releases := newManualTabSession(t, 2)
375 defer releases[0]()375 defer releases[0]()
376 defer releases[1]()376 defer releases[1]()
377- sess.active = 0377+ selectTestAttachmentTab(sess, 0)
378 sess.mu.Lock()378 sess.mu.Lock()
379 sess.tabs[1].attention = true379 sess.tabs[1].attention = true
380 sess.tabs[1].attentionAt = time.Unix(1, 0)380 sess.tabs[1].attentionAt = time.Unix(1, 0)
@@ -446,7 +446,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
446 sctxB, cancelB := context.WithCancel(d.serveCtx)446 sctxB, cancelB := context.WithCancel(d.serveCtx)
447 t.Cleanup(cancelB)447 t.Cleanup(cancelB)
448 tbB := newTestTabWithContext(pB, sctxB, cancelB)448 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}}
450 acB.setSession(sessB)450 acB.setSession(sessB)
451 acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil)451 acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil)
452 d.sessions[sessB.id] = sessB452 d.sessions[sessB.id] = sessB
@@ -465,13 +465,13 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
465 func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) {465 func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) {
466 d, sess, ac, sends, releases := newManualTabSession(t, 3)466 d, sess, ac, sends, releases := newManualTabSession(t, 3)
467 sess.mu.Lock()467 sess.mu.Lock()
468- sess.client = ac468+ sess.registerAttachmentLocked(ac)
469 sess.mu.Unlock()469 sess.mu.Unlock()
470 d.ptys = newBlockingOpenFactory(t, d)470 d.ptys = newBlockingOpenFactory(t, d)
471 defer releases[0]()471 defer releases[0]()
472 defer releases[1]()472 defer releases[1]()
473 defer releases[2]()473 defer releases[2]()
474- sess.active = 0474+ selectTestAttachmentTab(sess, 0)
475 sess.mu.Lock()475 sess.mu.Lock()
476 sess.tabs[1].attention = true476 sess.tabs[1].attention = true
477 sess.tabs[1].attentionAt = time.Unix(20, 0)477 sess.tabs[1].attentionAt = time.Unix(20, 0)
@@ -481,8 +481,8 @@ func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) {
481 481
482 d.handleInput(sess, ac, []byte("\x1ba"))482 d.handleInput(sess, ac, []byte("\x1ba"))
483 483
484- require.Equal(t, 2, activeTabIndex(sess))484+ require.Equal(t, 2, testAttachmentTabIndex(sess))
485- requireFloatingInitialized(t, sess.activeTab())485+ requireFloatingInitialized(t, testAttachmentTab(sess))
486 _ = mustOutputData(t, sends)486 _ = mustOutputData(t, sends)
487 }487 }
488 488
@@ -512,9 +512,9 @@ func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) {
512 require.NoError(t, d.jumpAttention(sess1, ac))512 require.NoError(t, d.jumpAttention(sess1, ac))
513 513
514 require.Same(t, sess2, ac.currentSession())514 require.Same(t, sess2, ac.currentSession())
515- require.Same(t, ac, sess2.client)515+ require.Contains(t, sess2.snapshotAttachments(), ac)
516- require.Nil(t, sess1.client)516+ require.Empty(t, sess1.snapshotAttachments())
517- require.Equal(t, 1, activeTabIndex(sess2))517+ require.Equal(t, 1, testAttachmentTabIndex(sess2))
518 _ = mustOutputData(t, sends)518 _ = mustOutputData(t, sends)
519 }519 }
520 520
@@ -522,12 +522,12 @@ func TestJumpAttentionNoopsWithNoBells(t *testing.T) {
522 d, sess, ac, sends, releases := newManualTabSession(t, 2)522 d, sess, ac, sends, releases := newManualTabSession(t, 2)
523 defer releases[0]()523 defer releases[0]()
524 defer releases[1]()524 defer releases[1]()
525- sess.active = 0525+ selectTestAttachmentTab(sess, 0)
526 526
527 require.NoError(t, d.jumpAttention(sess, ac), "no target to jump to is not a failure")527 require.NoError(t, d.jumpAttention(sess, ac), "no target to jump to is not a failure")
528 528
529 require.Same(t, sess, ac.currentSession())529 require.Same(t, sess, ac.currentSession())
530- require.Equal(t, 0, activeTabIndex(sess))530+ require.Equal(t, 0, testAttachmentTabIndex(sess))
531 select {531 select {
532 case f := <-sends:532 case f := <-sends:
533 t.Fatalf("unexpected frame on no-op jump: %v", f.Type)533 t.Fatalf("unexpected frame on no-op jump: %v", f.Type)
@@ -556,7 +556,7 @@ func TestJumpAttentionSwitchFailureReportsNotice(t *testing.T) {
556 d.sessions[sess2.id] = sess2556 d.sessions[sess2.id] = sess2
557 557
558 sess1.mu.Lock()558 sess1.mu.Lock()
559- sess1.client = nil559+ clearAttachmentsForTestLocked(sess1)
560 sess1.mu.Unlock()560 sess1.mu.Unlock()
561 561
562 err := d.jumpAttention(sess1, ac)562 err := d.jumpAttention(sess1, ac)
@@ -576,7 +576,7 @@ func TestAttentionAnimatorParksAdvancesAndResets(t *testing.T) {
576 defer releases[1]()576 defer releases[1]()
577 clk := newManualAttentionClock()577 clk := newManualAttentionClock()
578 d.clock = clk578 d.clock = clk
579- sess.active = 0579+ selectTestAttachmentTab(sess, 0)
580 580
581 ctx, cancel := context.WithCancel(context.Background())581 ctx, cancel := context.WithCancel(context.Background())
582 done := make(chan struct{})582 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
122 // slice under sess.mu (daemon.go, resume.go), and nothing mutates the122 // slice under sess.mu (daemon.go, resume.go), and nothing mutates the
123 // backing array in place, so capturing the header here is safe.123 // backing array in place, so capturing the header here is safe.
124 env := sess.env124 env := sess.env
125- active := sess.active125+ attachments := sess.snapshotAttachmentsLocked()
126- var tb *tab
127- if active >= 0 && active < len(sess.tabs) {
128- tb = sess.tabs[active]
129- }
130- ac := sess.client
131 ctx.PaneCWD = sess.cwd126 ctx.PaneCWD = sess.cwd
132 sess.mu.Unlock()127 sess.mu.Unlock()
133- if ac == nil || tb == nil {128+ tb := sess.firstTab()
129+ if len(attachments) == 0 || tb == nil {
134 return ctx, env, false130 return ctx, env, false
135 }131 }
136 tb.mu.Lock()132 tb.mu.Lock()
@@ -413,11 +409,17 @@ func (d *Daemon) pokeSessionRender(sess *session) {
413 if sess == nil {409 if sess == nil {
414 return410 return
415 }411 }
416- sess.mu.Lock()
417- ac := sess.client
418- sess.mu.Unlock()
419 // A headless session can still supply a picker preview to an attachment412 // A headless session can still supply a picker preview to an attachment
420 // owned by another session. Its coordinator fans this wake out to those413 // owned by another session. Its coordinator fans this wake out to those
421 // preview subscribers even though no primary attachment is bound.414 // 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+ }
423 }425 }
M internal/usecase/daemon/bar_refresh_test.go +21 -22
@@ -64,7 +64,7 @@ func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T)
64 r := &fakeBarRunner{outs: []string{"top1", "bot1", "top2", "bot2"}}64 r := &fakeBarRunner{outs: []string{"top1", "bot1", "top2", "bot2"}}
65 d := newBarRefreshTestDaemon(r, 100*time.Millisecond)65 d := newBarRefreshTestDaemon(r, 100*time.Millisecond)
66 sess := newBarRefreshTestSession()66 sess := newBarRefreshTestSession()
67- sess.client = &attachedClient{size: domain.Size{Cols: 120, Rows: 24}}67+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 120, Rows: 24}})
68 68
69 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))69 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))
70 waitBarRefreshIdle(t, d)70 waitBarRefreshIdle(t, d)
@@ -85,17 +85,16 @@ func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T)
85 require.Equal(t, "bot2", state.bottomRight)85 require.Equal(t, "bot2", state.bottomRight)
86 }86 }
87 87
88-func TestBarScriptContextUsesActivePaneSessionAndClientCols(t *testing.T) {88+func TestBarScriptContextUsesFirstOrderedPaneAndClientCols(t *testing.T) {
89 r := &fakeBarRunner{outs: []string{"top", "bottom"}}89 r := &fakeBarRunner{outs: []string{"top", "bottom"}}
90 d := newBarRefreshTestDaemon(r, time.Second)90 d := newBarRefreshTestDaemon(r, time.Second)
91 sess := newBarRefreshTestSession()91 sess := newBarRefreshTestSession()
92 sess.name = "work"92 sess.name = "work"
93 sess.cwd = "/repo"93 sess.cwd = "/repo"
94- sess.client = &attachedClient{size: domain.Size{Cols: 132, Rows: 40}}94+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 132, Rows: 40}})
95- sess.active = 195+ sess.tabs[0].size = domain.Size{Cols: 132, Rows: 38}
96- sess.tabs[1].size = domain.Size{Cols: 132, Rows: 38}96+ active := sess.tabs[0].focusedPane()
97- active := sess.tabs[1].focusedPane()97+ active.stableID = "pane-default"
98- active.stableID = "pane-active"
99 active.pty = newScriptPTY(nil)98 active.pty = newScriptPTY(nil)
100 d.procCwd = func(pid int) (string, error) {99 d.procCwd = func(pid int) (string, error) {
101 require.Equal(t, 4242, pid)100 require.Equal(t, 4242, pid)
@@ -107,8 +106,8 @@ func TestBarScriptContextUsesActivePaneSessionAndClientCols(t *testing.T) {
107 require.Len(t, r.calls, 2)106 require.Len(t, r.calls, 2)
108 for _, call := range r.calls {107 for _, call := range r.calls {
109 require.Equal(t, "work", call.Session)108 require.Equal(t, "work", call.Session)
110- require.Equal(t, "tab-active", call.Tab)109+ require.Equal(t, "tab-inactive", call.Tab)
111- require.Equal(t, "pane-active", call.Pane)110+ require.Equal(t, "pane-default", call.Pane)
112 require.Equal(t, "/pane-repo", call.PaneCWD)111 require.Equal(t, "/pane-repo", call.PaneCWD)
113 require.Equal(t, 132, call.Cols)112 require.Equal(t, 132, call.Cols)
114 }113 }
@@ -131,7 +130,7 @@ func TestBarScriptRefreshSkipsRunnerWhenCommandsDisabled(t *testing.T) {
131 d.barScripts.cfg.topRight = ""130 d.barScripts.cfg.topRight = ""
132 d.barScripts.cfg.bottomRight = ""131 d.barScripts.cfg.bottomRight = ""
133 sess := newBarRefreshTestSession()132 sess := newBarRefreshTestSession()
134- sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}133+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
135 d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "old", bottomRight: "old"}134 d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "old", bottomRight: "old"}
136 135
137 require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))136 require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
@@ -146,7 +145,7 @@ func TestDefaultBarConfigDoesNotInvokeRunner(t *testing.T) {
146 d := newBarRefreshTestDaemon(r, time.Second)145 d := newBarRefreshTestDaemon(r, time.Second)
147 d.barScripts.cfg = barConfigFromDomain(domain.Defaults().Bar)146 d.barScripts.cfg = barConfigFromDomain(domain.Defaults().Bar)
148 sess := newBarRefreshTestSession()147 sess := newBarRefreshTestSession()
149- sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}148+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
150 149
151 require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))150 require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
152 require.Empty(t, r.calls)151 require.Empty(t, r.calls)
@@ -156,7 +155,7 @@ func TestBarScriptForcedRefreshRespectsMinimumInterval(t *testing.T) {
156 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}155 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
157 d := newBarRefreshTestDaemon(r, 5*time.Second)156 d := newBarRefreshTestDaemon(r, 5*time.Second)
158 sess := newBarRefreshTestSession()157 sess := newBarRefreshTestSession()
159- sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}158+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
160 159
161 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))160 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
162 waitBarRefreshIdle(t, d)161 waitBarRefreshIdle(t, d)
@@ -173,7 +172,7 @@ func TestBarScriptContextChangeDebouncesUntilMinimumInterval(t *testing.T) {
173 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}172 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
174 d := newBarRefreshTestDaemon(r, 5*time.Second)173 d := newBarRefreshTestDaemon(r, 5*time.Second)
175 sess := newBarRefreshTestSession()174 sess := newBarRefreshTestSession()
176- sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}175+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
177 176
178 require.True(t, d.refreshBarScriptsIfDue(sess, time.Now(), false))177 require.True(t, d.refreshBarScriptsIfDue(sess, time.Now(), false))
179 waitBarRefreshIdle(t, d)178 waitBarRefreshIdle(t, d)
@@ -191,7 +190,7 @@ func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) {
191 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}190 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
192 d := newBarRefreshTestDaemon(r, 5*time.Second)191 d := newBarRefreshTestDaemon(r, 5*time.Second)
193 sess := newBarRefreshTestSession()192 sess := newBarRefreshTestSession()
194- sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}193+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
195 194
196 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))195 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))
197 waitBarRefreshIdle(t, d)196 waitBarRefreshIdle(t, d)
@@ -219,7 +218,7 @@ func TestRefreshBarScriptsAllSessionsForcesRun(t *testing.T) {
219 r := &fakeBarRunner{outs: []string{"top1", "bottom1"}}218 r := &fakeBarRunner{outs: []string{"top1", "bottom1"}}
220 d := newBarRefreshTestDaemon(r, 60*time.Second)219 d := newBarRefreshTestDaemon(r, 60*time.Second)
221 sess := newBarRefreshTestSession()220 sess := newBarRefreshTestSession()
222- sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}221+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
223 d.sessions = map[domain.SessionID]attachmentSession{sess.id: sess}222 d.sessions = map[domain.SessionID]attachmentSession{sess.id: sess}
224 223
225 // Verifies refreshBarScriptsAllSessions iterates every live session and224 // Verifies refreshBarScriptsAllSessions iterates every live session and
@@ -252,7 +251,7 @@ func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) {
252 d := newBarRefreshTestDaemon(r, time.Second)251 d := newBarRefreshTestDaemon(r, time.Second)
253 d.log = slog.New(slog.NewTextHandler(&logs, nil))252 d.log = slog.New(slog.NewTextHandler(&logs, nil))
254 sess := newBarRefreshTestSession()253 sess := newBarRefreshTestSession()
255- sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}254+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
256 255
257 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))256 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
258 waitBarRefreshIdle(t, d)257 waitBarRefreshIdle(t, d)
@@ -287,7 +286,7 @@ func TestBarScriptRunConsumesPendingAfterRunningClears(t *testing.T) {
287 r := newBlockingBarRunner([]string{"top1", "bottom1", "top2", "bottom2"})286 r := newBlockingBarRunner([]string{"top1", "bottom1", "top2", "bottom2"})
288 d := newBarRefreshTestDaemon(r, time.Second)287 d := newBarRefreshTestDaemon(r, time.Second)
289 sess := newBarRefreshTestSession()288 sess := newBarRefreshTestSession()
290- sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}289+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
291 290
292 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))291 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
293 select {292 select {
@@ -337,7 +336,7 @@ func TestBarScriptFailureLogsOnceUntilSignatureChanges(t *testing.T) {
337 d.log = logger336 d.log = logger
338 d.baseEnv = []string{"PATH=/usr/bin"}337 d.baseEnv = []string{"PATH=/usr/bin"}
339 sess := newBarRefreshTestSession()338 sess := newBarRefreshTestSession()
340- sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}339+ sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
341 340
342 readBuf := func() string {341 readBuf := func() string {
343 mu.Lock()342 mu.Lock()
@@ -420,11 +419,11 @@ func TestBarScriptRefreshIsPerSession(t *testing.T) {
420 a := newBarRefreshTestSession()419 a := newBarRefreshTestSession()
421 a.id = "a"420 a.id = "a"
422 a.name = "a"421 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}})
424 b := newBarRefreshTestSession()423 b := newBarRefreshTestSession()
425 b.id = "b"424 b.id = "b"
426 b.name = "b"425 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}})
428 427
429 require.True(t, d.refreshBarScriptsIfDue(a, time.Unix(0, 0), false))428 require.True(t, d.refreshBarScriptsIfDue(a, time.Unix(0, 0), false))
430 waitBarRefreshIdle(t, d)429 waitBarRefreshIdle(t, d)
@@ -587,7 +586,7 @@ func newBarRefreshTestSession() *session {
587 inactive := newTabWithStableID("tab-inactive", "pane-inactive", nil, domain.Size{Cols: 80, Rows: 22})586 inactive := newTabWithStableID("tab-inactive", "pane-inactive", nil, domain.Size{Cols: 80, Rows: 22})
588 active := newTabWithStableID("tab-active", "pane-active", nil, domain.Size{Cols: 80, Rows: 22})587 active := newTabWithStableID("tab-active", "pane-active", nil, domain.Size{Cols: 80, Rows: 22})
589 active.tree.Focus = layout.PaneID("pane-1")588 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()}
591 }590 }
592 591
593 func waitBarRefreshIdle(t *testing.T, d *Daemon) {592 func waitBarRefreshIdle(t *testing.T, d *Daemon) {
@@ -645,7 +644,7 @@ func TestBarScriptsUseSessionEnvNotDaemonBaseEnv(t *testing.T) {
645 d.baseEnv = tc.baseEnv644 d.baseEnv = tc.baseEnv
646 sess := newBarRefreshTestSession()645 sess := newBarRefreshTestSession()
647 sess.env = tc.sessEnv646 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}})
649 648
650 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))649 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
651 waitBarRefreshIdle(t, d)650 waitBarRefreshIdle(t, d)
M internal/usecase/daemon/client.go +109 -98
@@ -42,15 +42,19 @@ type attachedClient struct {
42 overlays *overlayRuntime42 overlays *overlayRuntime
43 overlayOnce sync.Once43 overlayOnce sync.Once
44 clientID [16]byte44 clientID [16]byte
45- // roleGeneration is the wire-facing capability generation. roleEffects is45+ // connectionGeneration is the wire-facing capability generation. attachmentEffects is
46- // the linearization gate for every role-bound observable operation. A role46+ // the linearization gate for every attachment-bound observable operation. An attachment
47 // transition freezes and drains this gate before changing either generation47 // transition freezes and drains this gate before changing either generation
48 // or the session/coordinator registries.48 // or the session/coordinator registries.
49- roleGeneration atomic.Uint6449+ connectionGeneration atomic.Uint64
50- roleEffects roleEffectGate50+ attachmentEffects attachmentEffectGate
51- resumeCapable bool51+ resumeCapable bool
52- resumeToken uint6452+ resumeToken uint64
53- parked bool53+ // 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
54 // proxied is negotiated once by Hello and remains immutable for this58 // proxied is negotiated once by Hello and remains immutable for this
55 // attachment, including across transport resume.59 // attachment, including across transport resume.
56 proxied bool60 proxied bool
@@ -75,33 +79,28 @@ type attachedClient struct {
75 // captureFrames is keyed by pane ownership, not the tab-local PaneID, so79 // captureFrames is keyed by pane ownership, not the tab-local PaneID, so
76 // snapshots cannot leak when an attachment switches tabs or sessions.80 // snapshots cannot leak when an attachment switches tabs or sessions.
77 captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held81 captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held
78- // initialSnatchedMu elects exactly one reset-panel sender per role generation.82+ size domain.Size
79- // A Welcome handshake and displaced-client cleanup may both discover the83+ keys *keys.Router
80- // same post-transition snatched role; the loser waits for the elected send.84+ // view is attachment-local navigation state. It is never inferred from a
81- initialSnatchedMu sync.Mutex85+ // session-wide active tab, so multiple attachments can observe different
82- initialSnatchedGeneration uint6486+ // tabs and panes without changing shared session ownership.
83- initialSnatchedAttempt *initialSnatchedPanelAttempt87+ viewMu sync.Mutex
84- size domain.Size88+ view attachmentView
85- keys *keys.Router89+ sess Guarded[attachmentSession]
86- sess Guarded[attachmentSession]90+ mouseScan mouse.Scanner
87- mouseScan mouse.Scanner91+ themeMu sync.Mutex
88- themeMu sync.Mutex92+ clientTheme themeui.Theme
89- clientTheme themeui.Theme93+ appliedTheme appliedTheme
90- appliedTheme appliedTheme94+ lastCursor cursorOut
91- lastCursor cursorOut95+ renderStages renderStageHooks // optional render and handoff observability hooks
92- renderStages renderStageHooks // optional render and handoff observability hooks
93 // previousSession is guarded independently. It is retained through temporary96 // previousSession is guarded independently. It is retained through temporary
94 // setSession(nil) hand-offs and cleared only on terminal teardown.97 // setSession(nil) hand-offs and cleared only on terminal teardown.
95 previousSession Guarded[attachmentSession]98 previousSession Guarded[attachmentSession]
96- // snatchedInputMu serializes the restricted input parser with its delayed99+ linkMu sync.Mutex
97- // standalone-ESC callback. It is independent of routing and transport locks;100+ sendMu sync.Mutex
98- // role activation and terminal close clear it only after releasing those locks.101+ // beforeAttachmentTokenValidation is a deterministic lifecycle-race seam
99- snatchedInputMu sync.Mutex102+ // used only by package tests.
100- snatchedInputPending []byte103+ beforeAttachmentTokenValidation func()
101- snatchedInputDrain bool
102- snatchedInputESC pendingByteTimer
103- linkMu sync.Mutex
104- sendMu sync.Mutex
105 }104 }
106 105
107 type cursorOut struct {106 type cursorOut struct {
@@ -243,7 +242,7 @@ func (ac *attachedClient) replaceTransport(tr ports.Transport) {
243 242
244 // revokeTransport removes tr only if it is still this attachment's current243 // revokeTransport removes tr only if it is still this attachment's current
245 // link. It returns the revoked transport, so asynchronous retirement can244 // 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.
247 func (ac *attachedClient) revokeTransport(tr ports.Transport) ports.Transport {246 func (ac *attachedClient) revokeTransport(tr ports.Transport) ports.Transport {
248 if tr == nil {247 if tr == nil {
249 return nil248 return nil
@@ -256,7 +255,6 @@ func (ac *attachedClient) revokeTransport(tr ports.Transport) ports.Transport {
256 ac.tr = nil255 ac.tr = nil
257 ac.transportIncarnation++256 ac.transportIncarnation++
258 ac.linkMu.Unlock()257 ac.linkMu.Unlock()
259- ac.clearSnatchedInput()
260 return tr258 return tr
261 }259 }
262 260
@@ -357,7 +355,7 @@ func (ac *attachedClient) sendExpectedTransport(expected transportSnapshot, f po
357 // attachment's current transport incarnation and admits ticket's interruptible355 // attachment's current transport incarnation and admits ticket's interruptible
358 // transport send. The caller must already hold ac.sendMu and, on success, owns356 // transport send. The caller must already hold ac.sendMu and, on success, owns
359 // both the send and its matching ticket.endTransportSend.357 // 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 {
361 if expected.transport == nil || !ac.transportSnapshotCurrent(expected) {359 if expected.transport == nil || !ac.transportSnapshotCurrent(expected) {
362 return errTransportReplaced360 return errTransportReplaced
363 }361 }
@@ -367,10 +365,10 @@ func (ac *attachedClient) beginExpectedTransportSendLocked(expected transportSna
367 return nil365 return nil
368 }366 }
369 367
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 {
371 ac.sendMu.Lock()369 ac.sendMu.Lock()
372 defer ac.sendMu.Unlock()370 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,
374 // including a replaced transport, as a lost attachment transition.372 // including a replaced transport, as a lost attachment transition.
375 if ticket == nil {373 if ticket == nil {
376 return errAttachmentTransition374 return errAttachmentTransition
@@ -528,15 +526,14 @@ type attachClientOptions struct {
528 proxied bool526 proxied bool
529 }527 }
530 528
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) {
532 d.mu.Lock()530 d.mu.Lock()
533 ac := d.prepareAttachedClientLocked(tr, sz, opts)531 ac := d.prepareAttachedClientLocked(tr, sz, opts)
534 d.mu.Unlock()532 d.mu.Unlock()
535 result, err := d.transitionAttachment(attachmentTransitionRequest{533 result, err := d.transitionAttachment(attachmentTransitionRequest{
536- target: sess,534+ target: sess,
537- next: ac,535+ next: ac,
538- expectedRole: attachmentDetached,536+
539- targetRole: attachmentActive,
540 expectedTransport: ac.transportSnapshot(),537 expectedTransport: ac.transportSnapshot(),
541 ready: false,538 ready: false,
542 })539 })
@@ -544,11 +541,11 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
544 if tr != nil {541 if tr != nil {
545 _ = tr.Close()542 _ = tr.Close()
546 }543 }
547- return nil, nil, err544+ return nil, err
548 }545 }
549 d.finishAttachedClient(sess, ac, opts)546 d.finishAttachedClient(sess, ac, opts)
550 d.deferAttachmentTransitionCleanups(result)547 d.deferAttachmentTransitionCleanups(result)
551- return ac, result.displaced.ac, nil548+ return ac, nil
552 }549 }
553 550
554 func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) {551 func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) {
@@ -558,7 +555,7 @@ func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts at
558 }555 }
559 556
560 // prepareAttachedClientLocked allocates one detached attachment. Caller holds557 // prepareAttachedClientLocked allocates one detached attachment. Caller holds
561-// d.mu only to allocate its resume token; role publication happens later after558+// d.mu only to allocate its resume token; attachment publication happens later after
562 // the caller releases every architecture lock.559 // the caller releases every architecture lock.
563 func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size, opts attachClientOptions) *attachedClient {560 func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size, opts attachClientOptions) *attachedClient {
564 resumeToken := uint64(0)561 resumeToken := uint64(0)
@@ -583,24 +580,21 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size,
583 return ac580 return ac
584 }581 }
585 582
586-// attachCoordinator is the sole direct attachment handoff. It creates at583+// attachCoordinator installs the coordinator lease for an attachment. Session
587-// most one coordinator for sess and changes its identity before any caller584+// membership remains independent, so installing one attachment never displaces
588-// can publish resize or render state for the new client.585+// another.
589-func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient, ready bool) *renderCoordinator {586+func (d *Daemon) attachCoordinator(sess *session, current *attachedClient, ready bool) *renderCoordinator {
590- rc, cleanup := d.attachCoordinatorDeferred(sess, old, current, ready)587+ rc, cleanup := d.attachCoordinatorDeferred(sess, current, ready)
591 cleanup.finish()588 cleanup.finish()
592 return rc589 return rc
593 }590 }
594 591
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) {
596 rc := d.ensureRenderCoordinator(sess)593 rc := d.ensureRenderCoordinator(sess)
597- var cleanup renderLifecycleCleanup594+ if current != nil {
598- if old != nil {
599- cleanup = rc.beginReplace(old, current, ready)
600- } else if current != nil {
601 rc.attachWithReadiness(current, ready)595 rc.attachWithReadiness(current, ready)
602 }596 }
603- return rc, cleanup597+ return rc, renderLifecycleCleanup{}
604 }598 }
605 599
606 // ensureRenderCoordinator publishes the session's single coordinator without600 // ensureRenderCoordinator publishes the session's single coordinator without
@@ -630,25 +624,42 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess
630 clock: d.clock,624 clock: d.clock,
631 observer: d.runtimeObserver,625 observer: d.runtimeObserver,
632 wake: func(w renderWake) {626 wake: func(w renderWake) {
633- // The wake retains the exact attachment lease. paint repeats this627+ if !rc.wakeCurrent(w) {
634- // identity check under sendMu before composing any snapshot.628+ return
635- if w.lease != nil && rc.wakeCurrent(w) {
636- d.paint(entry, w.lease.attachment, w.reset, w.lease)
637 }629 }
638- },630+ // The session snapshot is deterministic. Each paint revalidates the
639- ackReady: func() bool {631+ // attachment's membership and transport fence after that snapshot, so
640- entry.core().mu.Lock()632+ // a detached peer cannot stop remaining attachments receiving output.
641- attached := entry.core().client633+ attachments := snapshotAttachmentSession(entry)
642- entry.core().mu.Unlock()634+ for _, attachment := range attachments {
643- if attached == nil {635+ if !rc.wakeCurrent(w) {
644- return false636+ 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+ }
645 }654 }
646- attached.sendMu.Lock()
647- ready := attached.output == nil || !attached.output.atCapacity()
648- attached.sendMu.Unlock()
649- return ready
650 },655 },
651 })656 })
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+ }
652 installAttachmentRenderCoordinator(entry, rc)663 installAttachmentRenderCoordinator(entry, rc)
653 return rc664 return rc
654 }665 }
@@ -660,29 +671,29 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess
660 // next client (with a different terminal theme) may never have reported.671 // next client (with a different terminal theme) may never have reported.
661 // Mirrors attachClient's reset loop: snapshot sess.tabs under sess.mu,672 // Mirrors attachClient's reset loop: snapshot sess.tabs under sess.mu,
662 // release it, then take each tb.mu in turn — never holding sess.mu and673 // 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.client674+// tb.mu together. Guarded against a race with a newer attachment: if the
664-// is non-nil by the time sess.mu is taken, a new client has already attached675+// session has gained an attachment by the time sess.mu is taken, that
665-// (and run its own attach-time reset), so this call must leave the tabs676+// attachment has already run its attach-time reset, so this call must leave
666-// alone rather than clobbering that client's freshly applied colors.677+// the tabs alone rather than clobbering freshly applied colors.
667 func (d *Daemon) resetScreenDefaultColors(sess *session) {678 func (d *Daemon) resetScreenDefaultColors(sess *session) {
668 d.applyHostTheme(sess, nil, themeui.Theme{}, true)679 d.applyHostTheme(sess, nil, themeui.Theme{}, true)
669 }680 }
670 681
671 // firstPaintForTransition is the only active post-transition paint entry682 // firstPaintForTransition is the only active post-transition paint entry
672-// point. It rejects a stale role capability before rebasing output and carries683+// point. It rejects a stale attachment capability before rebasing output and carries
673 // the exact coordinator lease through every resize and render effect.684 // the exact coordinator lease through every resize and render effect.
674-func (d *Daemon) firstPaintForTransition(token attachmentRoleToken) bool {685+func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
675- if !token.activeCurrent() {686+ if !token.attachmentCurrent() {
676 return false687 return false
677 }688 }
678- ticket, admitted := token.ac.beginRoleEffect(token)689+ ticket, admitted := token.ac.beginAttachmentEffect(token)
679 if !admitted {690 if !admitted {
680 return false691 return false
681 }692 }
682 token.effect = ticket693 token.effect = ticket
683- defer token.endRoleEffect()694+ defer token.endAttachmentEffect()
684- if d.afterRoleEffectAdmitted != nil {695+ if d.afterAttachmentEffectAdmitted != nil {
685- d.afterRoleEffectAdmitted(token)696+ d.afterAttachmentEffectAdmitted(token)
686 }697 }
687 if token.rebase {698 if token.rebase {
688 if d.beforeFirstPaintSendWait != nil {699 if d.beforeFirstPaintSendWait != nil {
@@ -713,7 +724,7 @@ func (d *Daemon) firstPaintForTransition(token attachmentRoleToken) bool {
713 // firstPaint guarantees the freshly attached client sees the full screen: if724 // firstPaint guarantees the freshly attached client sees the full screen: if
714 // the tab size differs from the client's it resizes first, then immediately725 // the tab size differs from the client's it resizes first, then immediately
715 // emits a full redraw. It is retained for direct test/headless setup; active726 // 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.
717 func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {728 func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {
718 d.firstPaintWithLease(sess, ac, clientSize, nil)729 d.firstPaintWithLease(sess, ac, clientSize, nil)
719 }730 }
@@ -723,7 +734,7 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi
723 // Drained before the early return below so a session without an active tab734 // Drained before the early return below so a session without an active tab
724 // cannot swallow the queue.735 // cannot swallow the queue.
725 d.drainPendingForFirstPaint(sess, ac)736 d.drainPendingForFirstPaint(sess, ac)
726- tb := sess.activeTab()737+ tb := sess.tabForAttachment(ac)
727 if tb == nil {738 if tb == nil {
728 return739 return
729 }740 }
@@ -748,7 +759,7 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi
748 if lease == nil {759 if lease == nil {
749 d.invalidateRenderNow(sess, ac, true, "client.go")760 d.invalidateRenderNow(sess, ac, true, "client.go")
750 } else if rc := sess.renderCoordinator(); rc != nil && rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"}) {761 } 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)
752 }763 }
753 }764 }
754 }765 }
@@ -780,17 +791,17 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
780 d.invalidateRender(sess, ac, true, "client.go")791 d.invalidateRender(sess, ac, true, "client.go")
781 }792 }
782 793
783-func (d *Daemon) applyThemeForRole(token attachmentRoleToken, msg ports.Theme) bool {794+func (d *Daemon) applyThemeForAttachment(token attachmentConnectionToken, msg ports.Theme) bool {
784- if !token.activeEffect() {795+ if !token.attachmentEffectCurrent() {
785 return false796 return false
786 }797 }
787 clientTheme := themeFromMessage(msg)798 clientTheme := themeFromMessage(msg)
788 token.ac.setClientTheme(clientTheme)799 token.ac.setClientTheme(clientTheme)
789 sess, ok := localSession(token.sess)800 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) {
791 return false802 return false
792 }803 }
793- if !token.activeEffect() {804+ if !token.attachmentEffectCurrent() {
794 return false805 return false
795 }806 }
796 if rc := token.sess.core().coordinator.Load(); rc != nil {807 if rc := token.sess.core().coordinator.Load(); rc != nil {
@@ -805,8 +816,8 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
805 816
806 // handleClientNotice maps the closed client-event enum to daemon-owned notice817 // handleClientNotice maps the closed client-event enum to daemon-owned notice
807 // content. routingMu makes ownership validation and toast mutation one atomic818 // content. routingMu makes ownership validation and toast mutation one atomic
808-// attachment-routing operation: replacement also takes routingMu before819+// attachment-routing operation: attachment publication also takes routingMu
809-// publishing sess.client. Never retain sess.mu while touching notice or overlay820+// before changing membership. Never retain sess.mu while touching notice or overlay
810 // state, and retain the routingMu -> sess.mu order used by attachment paths.821 // state, and retain the routingMu -> sess.mu order used by attachment paths.
811 func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) {822 func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) {
812 if sess == nil || ac == nil {823 if sess == nil || ac == nil {
@@ -816,23 +827,23 @@ func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice po
816 token := sess.attachmentToken(ac, tr)827 token := sess.attachmentToken(ac, tr)
817 if token.lease == nil {828 if token.lease == nil {
818 // Direct test/headless callers retain pointer-based routing. Production829 // 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.
820 d.handleClientNoticeDirect(sess, ac, notice)831 d.handleClientNoticeDirect(sess, ac, notice)
821 return832 return
822 }833 }
823- d.handleClientNoticeForRole(token, notice)834+ d.handleClientNoticeForAttachment(token, notice)
824 }835 }
825 836
826-func (d *Daemon) handleClientNoticeForRole(token attachmentRoleToken, notice ports.ClientNotice) {837+func (d *Daemon) handleClientNoticeForAttachment(token attachmentConnectionToken, notice ports.ClientNotice) {
827 d.notices.routingMu.Lock()838 d.notices.routingMu.Lock()
828- if !token.activeEffect() {839+ if !token.attachmentEffectCurrent() {
829 d.notices.routingMu.Unlock()840 d.notices.routingMu.Unlock()
830 return841 return
831 }842 }
832 if d.notices.beforeClientNoticeMutation != nil {843 if d.notices.beforeClientNoticeMutation != nil {
833 d.notices.beforeClientNoticeMutation()844 d.notices.beforeClientNoticeMutation()
834 }845 }
835- if !token.activeEffect() {846+ if !token.attachmentEffectCurrent() {
836 d.notices.routingMu.Unlock()847 d.notices.routingMu.Unlock()
837 return848 return
838 }849 }
@@ -843,7 +854,7 @@ func (d *Daemon) handleClientNoticeForRole(token attachmentRoleToken, notice por
843 }854 }
844 repaint := d.mutateClientNotice(sess, token.ac, notice)855 repaint := d.mutateClientNotice(sess, token.ac, notice)
845 d.notices.routingMu.Unlock()856 d.notices.routingMu.Unlock()
846- if repaint && token.activeEffect() {857+ if repaint && token.attachmentEffectCurrent() {
847 d.repaintForNotice(token.ac)858 d.repaintForNotice(token.ac)
848 }859 }
849 }860 }
@@ -851,7 +862,7 @@ func (d *Daemon) handleClientNoticeForRole(token attachmentRoleToken, notice por
851 func (d *Daemon) handleClientNoticeDirect(sess *session, ac *attachedClient, notice ports.ClientNotice) {862 func (d *Daemon) handleClientNoticeDirect(sess *session, ac *attachedClient, notice ports.ClientNotice) {
852 d.notices.routingMu.Lock()863 d.notices.routingMu.Lock()
853 sess.mu.Lock()864 sess.mu.Lock()
854- current := sess.client == ac865+ _, current := sess.attachments[ac]
855 sess.mu.Unlock()866 sess.mu.Unlock()
856 if !current {867 if !current {
857 d.notices.routingMu.Unlock()868 d.notices.routingMu.Unlock()
@@ -883,7 +894,7 @@ func (d *Daemon) mutateClientNotice(sess *session, ac *attachedClient, notice po
883 }894 }
884 895
885 // recordClientNotice mutates notice history and the selected attachment while896 // recordClientNotice mutates notice history and the selected attachment while
886-// routingMu keeps replacement from changing that selection. Rendering is left897+// routingMu keeps another connection from changing that selection. Rendering is left
887 // to the caller after it releases routingMu.898 // to the caller after it releases routingMu.
888 func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, message string) bool {899 func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, message string) bool {
889 n := d.notices.record(domain.Notification{900 n := d.notices.record(domain.Notification{
M internal/usecase/daemon/client_frame_routing.go +50 -134
@@ -8,37 +8,36 @@ import (
8 "github.com/bnema/vev/internal/usecase/command"8 "github.com/bnema/vev/internal/usecase/command"
9 )9 )
10 10
11-const connectionRoleAttempts = 811+const connectionSnapshotAttempts = 8
12 12
13-// currentConnectionRole retries the lock-free session-to-role snapshot when a13+// currentAttachmentConnection retries the lock-free session-to-role snapshot when a
14-// handoff lands between those reads. The role gate still performs the final14+// handoff lands between those reads. The attachment gate still performs the final
15 // mutation admission after this function returns.15 // mutation admission after this function returns.
16-func (d *Daemon) currentConnectionRole(ac *attachedClient, tr ports.Transport) (attachmentSession, attachmentRoleToken, bool) {16+func (d *Daemon) currentAttachmentConnection(ac *attachedClient, tr ports.Transport) (attachmentSession, attachmentConnectionToken, bool) {
17- for range connectionRoleAttempts {17+ for range connectionSnapshotAttempts {
18 if !ac.currentTransportIs(tr) {18 if !ac.currentTransportIs(tr) {
19 break19 break
20 }20 }
21 sess := ac.currentAttachmentSession()21 sess := ac.currentAttachmentSession()
22 if sess == nil {22 if sess == nil {
23- return nil, attachmentRoleToken{}, false23+ return nil, attachmentConnectionToken{}, false
24 }24 }
25 if d.afterConnectionSessionSnapshot != nil {25 if d.afterConnectionSessionSnapshot != nil {
26 d.afterConnectionSessionSnapshot(sess)26 d.afterConnectionSessionSnapshot(sess)
27 }27 }
28 token := attachmentToken(sess, ac, tr)28 token := attachmentToken(sess, ac, tr)
29- if token.role == attachmentDetached || ac.currentAttachmentSession() != sess {29+ if token.ac == nil || ac.currentAttachmentSession() != sess {
30 runtime.Gosched()30 runtime.Gosched()
31 continue31 continue
32 }32 }
33 return sess, token, true33 return sess, token, true
34 }34 }
35- return nil, attachmentRoleToken{}, false35+ return nil, attachmentConnectionToken{}, false
36 }36 }
37 37
38 // runConnLoop is the per-connection input router: it pumps client messages38 // 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 so39+// until detach, EOF, or a transport error. The attachment generation is
40-// a transport displaced while blocked in Recv immediately adopts its restricted40+// resolved after every Recv so a stale transport cannot execute a new frame.
41-// snatched routing rather than executing one stale active frame.
42 func (d *Daemon) runConnLoop(ac *attachedClient) {41 func (d *Daemon) runConnLoop(ac *attachedClient) {
43 tr := ac.transport()42 tr := ac.transport()
44 if tr == nil {43 if tr == nil {
@@ -50,14 +49,12 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
50 }49 }
51 f, err := tr.Recv()50 f, err := tr.Recv()
52 if err != nil {51 if err != nil {
53- for range connectionRoleAttempts {52+ for range connectionSnapshotAttempts {
54- sess, token, ok := d.currentConnectionRole(ac, tr)53+ sess, _, ok := d.currentAttachmentConnection(ac, tr)
55 if !ok {54 if !ok {
56 return55 return
57 }56 }
58- if token.role == attachmentSnatched {57+ if proxy, ok := sess.(*proxySession); ok {
59- d.parkOrDropSnatchedAttachment(token)
60- } else if proxy, ok := sess.(*proxySession); ok {
61 d.detachProxyOnSendError(proxy, ac, tr)58 d.detachProxyOnSendError(proxy, ac, tr)
62 } else if local, ok := localSession(sess); ok {59 } else if local, ok := localSession(sess); ok {
63 d.clientGone(local, ac, tr, false)60 d.clientGone(local, ac, tr, false)
@@ -71,48 +68,40 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
71 }68 }
72 return69 return
73 }70 }
74- _, token, ok := d.currentConnectionRole(ac, tr)71+ _, token, ok := d.currentAttachmentConnection(ac, tr)
75 if !ok {72 if !ok {
76 return73 return
77 }74 }
78- switch token.role {75+ if d.handleAttachmentClientFrame(token, f) {
79- case attachmentActive:76+ return
80- if d.handleActiveClientFrame(token, f) {
81- return
82- }
83- case attachmentSnatched:
84- if d.handleSnatchedClientFrame(token, f) {
85- return
86- }
87 }77 }
88 }78 }
89 }79 }
90 80
91-// handleActiveClientFrame owns the ordinary attached-client protocol. Keeping81+// handleAttachmentClientFrame owns the attached-client protocol.
92-// it separate makes the restricted snatched protocol an explicit allow-list.82+func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f ports.Frame) bool {
93-func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Frame) bool {83+ if !token.attachmentEffectCurrent() {
94- if !token.activeEffect() {
95 return false84 return false
96 }85 }
97- if d.afterActiveFrameDispatch != nil {86+ if d.afterAttachmentFrameDispatch != nil {
98- d.afterActiveFrameDispatch(token)87+ d.afterAttachmentFrameDispatch(token)
99 }88 }
100- ticket, admitted := token.ac.beginRoleEffect(token)89+ ticket, admitted := token.ac.beginAttachmentEffect(token)
101 if !admitted {90 if !admitted {
102 return false91 return false
103 }92 }
104 token.effect = ticket93 token.effect = ticket
105- defer token.endRoleEffect()94+ defer token.endAttachmentEffect()
106- if d.afterRoleEffectAdmitted != nil {95+ if d.afterAttachmentEffectAdmitted != nil {
107- d.afterRoleEffectAdmitted(token)96+ d.afterAttachmentEffectAdmitted(token)
108 }97 }
109 switch f.Type {98 switch f.Type {
110 case ports.MsgInput:99 case ports.MsgInput:
111 if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {100 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)
113 }102 }
114 case ports.MsgResize:103 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() {
116 if sess, ok := localSession(token.sess); ok {105 if sess, ok := localSession(token.sess); ok {
117 d.requestTransactionalResizeForLease(sess, token.ac, token.lease, rz.Size, false)106 d.requestTransactionalResizeForLease(sess, token.ac, token.lease, rz.Size, false)
118 } else if proxy, ok := token.sess.(*proxySession); ok {107 } else if proxy, ok := token.sess.(*proxySession); ok {
@@ -121,30 +110,30 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram
121 }110 }
122 case ports.MsgTheme:111 case ports.MsgTheme:
123 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {112 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {
124- d.applyThemeForRole(token, th)113+ d.applyThemeForAttachment(token, th)
125 }114 }
126 case ports.MsgImagePush:115 case ports.MsgImagePush:
127 if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {116 if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {
128- d.handleSequencedImagePushForRole(token, ip.InputSeq, ip)117+ d.handleSequencedImagePushForAttachment(token, ip.InputSeq, ip)
129 }118 }
130 case ports.MsgClientNotice:119 case ports.MsgClientNotice:
131 if notice, derr := ports.UnmarshalClientNotice(f.Payload); derr == nil {120 if notice, derr := ports.UnmarshalClientNotice(f.Payload); derr == nil {
132- d.handleClientNoticeForRole(token, notice)121+ d.handleClientNoticeForAttachment(token, notice)
133 } else {122 } else {
134 d.log.Warn("malformed client notice", "err", derr)123 d.log.Warn("malformed client notice", "err", derr)
135 }124 }
136 case ports.MsgDetach:125 case ports.MsgDetach:
137- if token.activeEffect() {126+ if token.attachmentEffectCurrent() {
138- d.clientGoneForRole(token, true)127+ d.clientGoneForAttachment(token, true)
139 return true128 return true
140 }129 }
141 case ports.MsgAck:130 case ports.MsgAck:
142 if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {131 if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
143- d.ackActiveOutput(token, ack.AckedStateNum)132+ d.ackOutput(token, ack.AckedStateNum)
144 }133 }
145 case ports.MsgOutputResetRequest:134 case ports.MsgOutputResetRequest:
146 if _, derr := ports.UnmarshalOutputResetRequest(f.Payload); derr == nil {135 if _, derr := ports.UnmarshalOutputResetRequest(f.Payload); derr == nil {
147- d.resetActiveOutput(token)136+ d.resetOutput(token)
148 }137 }
149 case ports.MsgCommand:138 case ports.MsgCommand:
150 if token.ac.proxied {139 if token.ac.proxied {
@@ -153,16 +142,16 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram
153 return false142 return false
154 }143 }
155 result := d.executeAttachedCommand(token, request)144 result := d.executeAttachedCommand(token, request)
156- if err := token.sendActiveControl(frameCommandResult(result)); err != nil {145+ if err := token.sendControl(frameCommandResult(result)); err != nil {
157- token.endRoleEffect()146+ token.endAttachmentEffect()
158- d.detachOnRoleSendError(token, token.transport.transport)147+ d.detachOnAttachmentSendError(token, token.transport.transport)
159 return true148 return true
160 }149 }
161 }150 }
162 case ports.MsgPing:151 case ports.MsgPing:
163- if err := token.sendActiveControl(framePong()); err != nil {152+ if err := token.sendControl(framePong()); err != nil {
164- token.endRoleEffect()153+ token.endAttachmentEffect()
165- d.detachOnRoleSendError(token, token.transport.transport)154+ d.detachOnAttachmentSendError(token, token.transport.transport)
166 return true155 return true
167 }156 }
168 default:157 default:
@@ -172,7 +161,7 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram
172 return false161 return false
173 }162 }
174 163
175-func (d *Daemon) executeAttachedCommand(token attachmentRoleToken, request ports.CommandRequest) ports.CommandResult {164+func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request ports.CommandRequest) ports.CommandResult {
176 result := ports.CommandResult{RequestID: request.RequestID}165 result := ports.CommandResult{RequestID: request.RequestID}
177 if request.Version != ports.ProtocolVersion {166 if request.Version != ports.ProtocolVersion {
178 result.Code = ports.ErrInvalidCommandArgs167 result.Code = ports.ErrInvalidCommandArgs
@@ -200,7 +189,7 @@ func (d *Daemon) executeAttachedCommand(token attachmentRoleToken, request ports
200 return result189 return result
201 }190 }
202 sess, ok := localSession(token.sess)191 sess, ok := localSession(token.sess)
203- if !ok || !token.activeEffect() {192+ if !ok || !token.attachmentEffectCurrent() {
204 result.Code = ports.ErrNoSuchTarget193 result.Code = ports.ErrNoSuchTarget
205 result.Text = "attached session is no longer active"194 result.Text = "attached session is no longer active"
206 return result195 return result
@@ -227,11 +216,11 @@ func (d *Daemon) executeAttachedCommand(token attachmentRoleToken, request ports
227 return result216 return result
228 }217 }
229 218
230- // The frame's role token is the target capability. No registry/name lookup is219+ // The frame's attachment token is the target capability. No registry/name lookup is
231 // performed, so the command cannot escape to another remote session.220 // performed, so the command cannot escape to another remote session.
232- sess.dispatchMu.Lock()221+ err := sess.runMutation(func() error {
233- err := cmd.Run(paletteExec{d: d, sess: sess, ac: token.ac, effect: token.effect}, request.Args)222+ return cmd.Run(paletteExec{d: d, sess: sess, ac: token.ac, effect: token.effect}, request.Args)
234- sess.dispatchMu.Unlock()223+ })
235 if err == nil {224 if err == nil {
236 result.OK = true225 result.OK = true
237 return result226 return result
@@ -252,16 +241,16 @@ func proxyAttachedCommandOwnedRemotely(slug string) bool {
252 return proxyPaletteCommandOwnership(slug) == proxyPaletteRemote241 return proxyPaletteCommandOwnership(slug) == proxyPaletteRemote
253 }242 }
254 243
255-// resetActiveOutput rebases only the exact proxied role and transport admitted244+// resetOutput rebases only the exact proxied attachment and transport admitted
256 // by the frame router. sendMu serializes the rebase with every output send;245 // by the frame router. sendMu serializes the rebase with every output send;
257 // transport revalidation under that lock rejects a link replaced while waiting.246 // 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 {
259 ac := token.ac248 ac := token.ac
260 if ac == nil || !ac.proxied || token.effect == nil || token.effect.ended.Load() {249 if ac == nil || !ac.proxied || token.effect == nil || token.effect.ended.Load() {
261 return false250 return false
262 }251 }
263 ac.sendMu.Lock()252 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 {
265 ac.sendMu.Unlock()254 ac.sendMu.Unlock()
266 return false255 return false
267 }256 }
@@ -274,7 +263,7 @@ func (d *Daemon) resetActiveOutput(token attachmentRoleToken) bool {
274 })263 })
275 }264 }
276 265
277-func (d *Daemon) ackActiveOutput(token attachmentRoleToken, state uint64) bool {266+func (d *Daemon) ackOutput(token attachmentConnectionToken, state uint64) bool {
278 ac := token.ac267 ac := token.ac
279 if token.effect == nil || token.effect.ended.Load() {268 if token.effect == nil || token.effect.ended.Load() {
280 return false269 return false
@@ -287,76 +276,3 @@ func (d *Daemon) ackActiveOutput(token attachmentRoleToken, state uint64) bool {
287 }276 }
288 return true277 return true
289 }278 }
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"
5 // clientGone detaches ac if it is still the session's current client. The5 // clientGone detaches ac if it is still the session's current client. The
6 // session remains registered and headless after the client is gone.6 // session remains registered and headless after the client is gone.
7 func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {7 func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
8+ if sess == nil || ac == nil {
9+ return
10+ }
8 expected := transportSnapshot{}11 expected := transportSnapshot{}
9 if failed != nil {12 if failed != nil {
10 expected = ac.transportSnapshot()13 expected = ac.transportSnapshot()
@@ -28,14 +31,14 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
28 d.finishClientGone(sess, ac, failed, explicit)31 d.finishClientGone(sess, ac, failed, explicit)
29 }32 }
30 33
31-func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) bool {34+func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explicit bool) bool {
32 if token.effect == nil {35 if token.effect == nil {
33 return false36 return false
34 }37 }
35 token.effect.bindActionEnd(d, "detach")38 token.effect.bindActionEnd(d, "detach")
36 token.effect.End()39 token.effect.End()
37 if proxy, ok := token.sess.(*proxySession); ok {40 if proxy, ok := token.sess.(*proxySession); ok {
38- if !d.detachIfRoleCurrent(token) {41+ if !d.detachIfAttachmentCurrent(token) {
39 return false42 return false
40 }43 }
41 d.finishProxyClientGone(proxy, token.ac, token.transport.transport, explicit)44 d.finishProxyClientGone(proxy, token.ac, token.transport.transport, explicit)
@@ -50,7 +53,7 @@ func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) boo
50 if !explicit {53 if !explicit {
51 parkingToken = d.markParkingInFlight(sess, token.ac)54 parkingToken = d.markParkingInFlight(sess, token.ac)
52 }55 }
53- if !d.detachIfRoleCurrent(token) {56+ if !d.detachIfAttachmentCurrent(token) {
54 d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)57 d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)
55 return false58 return false
56 }59 }
@@ -59,6 +62,9 @@ func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) boo
59 }62 }
60 63
61 func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {64 func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
65+ if sess == nil || ac == nil {
66+ return
67+ }
62 if d.afterClientGoneDetach != nil {68 if d.afterClientGoneDetach != nil {
63 d.afterClientGoneDetach()69 d.afterClientGoneDetach()
64 }70 }
@@ -143,8 +149,8 @@ func (d *Daemon) detachProxyOnSendError(p *proxySession, ac *attachedClient, fai
143 }149 }
144 150
145 // finishProxyClientGone retires external client ownership after the exact proxy151 // finishProxyClientGone retires external client ownership after the exact proxy
146-// role has been unpublished. Warm-timer publication remains with the caller so152+// attachment has been unpublished. Warm-timer publication remains with the caller so
147-// each detach path can arm once, after p.client is nil.153+// each detach path can arm once, after membership is empty.
148 func (d *Daemon) finishProxyClientGone(p *proxySession, ac *attachedClient, failed ports.Transport, explicit bool) {154 func (d *Daemon) finishProxyClientGone(p *proxySession, ac *attachedClient, failed ports.Transport, explicit bool) {
149 if rc := p.coordinator.Load(); rc != nil {155 if rc := p.coordinator.Load(); rc != nil {
150 rc.noteDetach(ac)156 rc.noteDetach(ac)
@@ -160,18 +166,18 @@ func (d *Daemon) finishProxyClientGone(p *proxySession, ac *attachedClient, fail
160 d.log.Warn("detached proxy client", "host", p.key.Host, "session", p.key.Name, "explicit", explicit)166 d.log.Warn("detached proxy client", "host", p.key.Host, "session", p.key.Name, "explicit", explicit)
161 }167 }
162 168
163-func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.Transport) {169+func (d *Daemon) detachOnAttachmentSendError(token attachmentConnectionToken, failed ports.Transport) {
164- d.detachOnRoleSendErrorUntil(token, failed, nil)170+ d.detachOnAttachmentSendErrorUntil(token, failed, nil)
165 }171 }
166 172
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{}) {
168 // A delayed sender may report after the client has rebound. Only the exact174 // 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.
170 if failed == nil || failed != token.transport.transport {176 if failed == nil || failed != token.transport.transport {
171 return177 return
172 }178 }
173 if proxy, ok := token.sess.(*proxySession); ok {179 if proxy, ok := token.sess.(*proxySession); ok {
174- if d.detachIfRoleCurrentUntil(token, done) {180+ if d.detachIfAttachmentCurrentUntil(token, done) {
175 d.finishProxyClientGone(proxy, token.ac, failed, false)181 d.finishProxyClientGone(proxy, token.ac, failed, false)
176 d.armProxyWarm(proxy)182 d.armProxyWarm(proxy)
177 }183 }
@@ -182,30 +188,30 @@ func (d *Daemon) detachOnRoleSendErrorUntil(token attachmentRoleToken, failed po
182 return188 return
183 }189 }
184 parkingToken := d.markParkingInFlight(sess, token.ac)190 parkingToken := d.markParkingInFlight(sess, token.ac)
185- if d.detachIfRoleCurrentUntil(token, done) {191+ if d.detachIfAttachmentCurrentUntil(token, done) {
186 d.finishSendErrorDetach(sess, token.ac, failed)192 d.finishSendErrorDetach(sess, token.ac, failed)
187 return193 return
188 }194 }
189 d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)195 d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)
190 }196 }
191 197
192-// reserveRoleSendErrorCleanup accounts for cleanup before End releases the198+// reserveAttachmentSendErrorCleanup accounts for cleanup before End releases the
193-// role gate. This closes the WaitGroup Add/Wait race with terminal teardown;199+// attachment gate. This closes the WaitGroup Add/Wait race with terminal teardown;
194 // the returned launch function must be invoked immediately after ticket End.200 // 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() {
196 d.attachmentCleanupWg.Add(1)202 d.attachmentCleanupWg.Add(1)
197 return func() {203 return func() {
198 go func() {204 go func() {
199 defer d.attachmentCleanupWg.Done()205 defer d.attachmentCleanupWg.Done()
200- if d.afterRoleSendErrorCleanup != nil {206+ if d.afterAttachmentSendErrorCleanup != nil {
201- defer d.afterRoleSendErrorCleanup()207+ defer d.afterAttachmentSendErrorCleanup()
202 }208 }
203- if d.beforeRoleSendErrorCleanup != nil {209+ if d.beforeAttachmentSendErrorCleanup != nil {
204- d.beforeRoleSendErrorCleanup(token)210+ d.beforeAttachmentSendErrorCleanup(token)
205 }211 }
206- deadline := newRoleEffectDrainDeadline(d.clock)212+ deadline := newAttachmentEffectDrainDeadline(d.clock)
207 defer deadline.stop()213 defer deadline.stop()
208- d.detachOnRoleSendErrorUntil(token, failed, deadline.Done)214+ d.detachOnAttachmentSendErrorUntil(token, failed, deadline.Done)
209 }()215 }()
210 }216 }
211 }217 }
M internal/usecase/daemon/clipboard.go +39 -40
@@ -26,27 +26,27 @@ var (
26 clipPasteCloseMarker = []byte("\x1b[201~")26 clipPasteCloseMarker = []byte("\x1b[201~")
27 )27 )
28 28
29-func (d *Daemon) handleSequencedImagePushForRole(token attachmentRoleToken, _ uint64, ip ports.ImagePush) {29+func (d *Daemon) handleSequencedImagePushForAttachment(token attachmentConnectionToken, _ uint64, ip ports.ImagePush) {
30- if !token.activeEffect() {30+ if !token.attachmentEffectCurrent() {
31 return31 return
32 }32 }
33- d.handleImagePushForRole(token, ip)33+ d.handleImagePushForAttachment(token, ip)
34 }34 }
35 35
36-func (d *Daemon) handleImagePushForRole(token attachmentRoleToken, ip ports.ImagePush) {36+func (d *Daemon) handleImagePushForAttachment(token attachmentConnectionToken, ip ports.ImagePush) {
37- if len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !token.activeEffect() {37+ if len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !token.attachmentEffectCurrent() {
38 return38 return
39 }39 }
40- path, err := d.writeClipboardImageForRole(token, ip)40+ path, err := d.writeClipboardImageForAttachment(token, ip)
41 if err != nil {41 if err != nil {
42 d.log.Error("writing clipboard image failed", "err", err)42 d.log.Error("writing clipboard image failed", "err", err)
43 return43 return
44 }44 }
45- if !token.activeEffect() {45+ if !token.attachmentEffectCurrent() {
46 _ = os.Remove(path)46 _ = os.Remove(path)
47 return47 return
48 }48 }
49- d.injectClipboardPathForRole(token, path)49+ d.injectClipboardPathForAttachment(token, path)
50 }50 }
51 51
52 // handleImagePush writes an ImagePush's bytes to a temp file and injects the52 // 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,
84 return path, nil84 return path, nil
85 }85 }
86 86
87-func (d *Daemon) writeClipboardImageForRole(token attachmentRoleToken, ip ports.ImagePush) (string, error) {87+func (d *Daemon) writeClipboardImageForAttachment(token attachmentConnectionToken, ip ports.ImagePush) (string, error) {
88 path, err := d.createClipboardImage(ip)88 path, err := d.createClipboardImage(ip)
89 if err != nil {89 if err != nil {
90 return "", err90 return "", err
@@ -95,7 +95,7 @@ func (d *Daemon) writeClipboardImageForRole(token attachmentRoleToken, ip ports.
95 return "", errAttachmentTransition95 return "", errAttachmentTransition
96 }96 }
97 sess.mu.Lock()97 sess.mu.Lock()
98- if !token.activeEffectSessionLocked() {98+ if !token.attachmentEffectCurrentSessionLocked() {
99 sess.mu.Unlock()99 sess.mu.Unlock()
100 _ = os.Remove(path)100 _ = os.Remove(path)
101 return "", errAttachmentTransition101 return "", errAttachmentTransition
@@ -134,16 +134,25 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) {
134 d.injectClipboardPathToTarget(sess, path, nil)134 d.injectClipboardPathToTarget(sess, path, nil)
135 }135 }
136 136
137-func (d *Daemon) injectClipboardPathForRole(token attachmentRoleToken, path string) {137+func (d *Daemon) injectClipboardPathForAttachment(token attachmentConnectionToken, path string) {
138 sess, ok := localSession(token.sess)138 sess, ok := localSession(token.sess)
139- if !ok || !token.activeEffect() {139+ if !ok || !token.attachmentEffectCurrent() {
140 return140 return
141 }141 }
142 d.injectClipboardPathToTarget(sess, path, &token)142 d.injectClipboardPathToTarget(sess, path, &token)
143 }143 }
144 144
145-func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *attachmentRoleToken) {145+func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *attachmentConnectionToken) {
146- tb := sess.activeTab()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+ }
147 if tb == nil {156 if tb == nil {
148 return157 return
149 }158 }
@@ -166,7 +175,7 @@ func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *
166 wrapped = append(wrapped, clipPasteCloseMarker...)175 wrapped = append(wrapped, clipPasteCloseMarker...)
167 data = wrapped176 data = wrapped
168 }177 }
169- if token != nil && !token.activeEffect() {178+ if token != nil && !token.attachmentEffectCurrent() {
170 return179 return
171 }180 }
172 d.writeToPane(sess, p, data)181 d.writeToPane(sess, p, data)
@@ -193,7 +202,7 @@ func clipboardExt(mime string) string {
193 202
194 type clipboardForward struct {203 type clipboardForward struct {
195 owner paneEffectLease204 owner paneEffectLease
196- token attachmentRoleToken205+ token attachmentConnectionToken
197 seq []byte206 seq []byte
198 }207 }
199 208
@@ -222,32 +231,22 @@ func (d *Daemon) forwardClipboardAsync(owner paneEffectLease, b64 string) {
222 default:231 default:
223 }232 }
224 }233 }
225- ac := sess.client234+ attachments := sess.snapshotAttachmentsLocked()
226 sess.mu.Unlock()235 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- }
235 236
236- sess.mu.Lock()237+ items := make([]clipboardForward, 0, len(attachments))
237- if sess.ctx != nil {238+ for _, ac := range attachments {
238- select {239+ transport := ac.transport()
239- case <-sess.ctx.Done():240+ token := sess.attachmentToken(ac, transport)
240- sess.mu.Unlock()241+ if token.attachmentCurrent() {
241- return242+ items = append(items, clipboardForward{owner: owner, token: token, seq: seq})
242- default:
243 }243 }
244 }244 }
245- if sess.client != ac || ac.roleGeneration.Load() != token.generation ||245+ if len(items) == 0 {
246- !ac.transportSnapshotCurrent(token.transport) || ac.currentSession() != sess {
247- sess.mu.Unlock()
248 return246 return
249 }247 }
250- sess.clipboardQueue = append(sess.clipboardQueue, clipboardForward{owner: owner, token: token, seq: seq})248+ sess.mu.Lock()
249+ sess.clipboardQueue = append(sess.clipboardQueue, items...)
251 if !sess.clipboardWorkerRunning {250 if !sess.clipboardWorkerRunning {
252 sess.clipboardWorkerRunning = true251 sess.clipboardWorkerRunning = true
253 go d.clipboardWorker(sess)252 go d.clipboardWorker(sess)
@@ -255,7 +254,7 @@ func (d *Daemon) forwardClipboardAsync(owner paneEffectLease, b64 string) {
255 sess.mu.Unlock()254 sess.mu.Unlock()
256 }255 }
257 256
258-func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *roleEffectTicket) (ports.Transport, error) {257+func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *attachmentEffectTicket) (ports.Transport, error) {
259 token := item.token258 token := item.token
260 if token.ac == nil || ticket == nil || ticket.ended.Load() || token.transport.transport == nil {259 if token.ac == nil || ticket == nil || ticket.ended.Load() || token.transport.transport == nil {
261 return token.transport.transport, errAttachmentTransition260 return token.transport.transport, errAttachmentTransition
@@ -295,7 +294,7 @@ func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *role
295 // admission and marks the transport interval while ownership publication is294 // admission and marks the transport interval while ownership publication is
296 // excluded. Publication therefore orders either before this check (the send is295 // excluded. Publication therefore orders either before this check (the send is
297 // dropped) or after transport admission (the send belongs to the old owner).296 // 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 {
299 if lease.pane == nil || lease.owner == nil || lease.owner.session == nil || lease.owner.tab == nil {298 if lease.pane == nil || lease.owner == nil || lease.owner.session == nil || lease.owner.tab == nil {
300 return false299 return false
301 }300 }
@@ -326,7 +325,7 @@ func (d *Daemon) clipboardWorker(sess *session) {
326 if !item.owner.Current() {325 if !item.owner.Current() {
327 continue326 continue
328 }327 }
329- ticket, admitted := item.token.ac.beginRoleEffect(item.token)328+ ticket, admitted := item.token.ac.beginAttachmentEffect(item.token)
330 if !admitted {329 if !admitted {
331 continue330 continue
332 }331 }
@@ -336,7 +335,7 @@ func (d *Daemon) clipboardWorker(sess *session) {
336 }335 }
337 ticket.End()336 ticket.End()
338 if err != nil && item.owner.Current() {337 if err != nil && item.owner.Current() {
339- d.detachOnRoleSendError(item.token, failed)338+ d.detachOnAttachmentSendError(item.token, failed)
340 }339 }
341 }340 }
342 }341 }
M internal/usecase/daemon/clipboard_test.go +14 -44
@@ -31,7 +31,7 @@ func TestInjectClipboardPathTargetsVisibleFloatingPane(t *testing.T) {
31 d, sess, _, _ := newManualSessionWithPTYs(t, normal)31 d, sess, _, _ := newManualSessionWithPTYs(t, normal)
32 floating := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 5})32 floating := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 5})
33 floating.screen.Write([]byte("\x1b[?2004h"))33 floating.screen.Write([]byte("\x1b[?2004h"))
34- installTestFloating(sess.activeTab(), floating, true)34+ installTestFloating(testAttachmentTab(sess), floating, true)
35 35
36 d.injectClipboardPath(sess, "/tmp/image.png")36 d.injectClipboardPath(sess, "/tmp/image.png")
37 37
@@ -203,15 +203,15 @@ func chunkReadPTY(t *testing.T, chunks ...[]byte) *portsmocks.MockPTY {
203 }203 }
204 204
205 func publishActiveClipboardCapability(d *Daemon, sess *session, ac *attachedClient, tr ports.Transport) {205 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)
207 token := sess.attachmentToken(ac, tr)207 token := sess.attachmentToken(ac, tr)
208 token.lease = rc.attachmentLease(ac)208 token.lease = rc.attachmentLease(ac)
209- ac.publishRoleCapability(token)209+ ac.publishAttachmentCapability(token)
210 }210 }
211 211
212 func clipboardOwnerLease(t *testing.T, sess *session) paneEffectLease {212 func clipboardOwnerLease(t *testing.T, sess *session) paneEffectLease {
213 t.Helper()213 t.Helper()
214- tb := sess.activeTab()214+ tb := testAttachmentTab(sess)
215 tb.mu.Lock()215 tb.mu.Lock()
216 p := tb.terminalTargetLocked()216 p := tb.terminalTargetLocked()
217 tb.mu.Unlock()217 tb.mu.Unlock()
@@ -231,7 +231,7 @@ func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) {
231 win := newTestTabWithContext(p, sctx, cancel)231 win := newTestTabWithContext(p, sctx, cancel)
232 ac := &attachedClient{tr: tr, output: newOutputStateStream()}232 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
233 ac.initOverlays()233 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}
235 publishTiledPaneOwners(sess, win)235 publishTiledPaneOwners(sess, win)
236 ac.setSession(sess)236 ac.setSession(sess)
237 d.sessions[sess.id] = sess237 d.sessions[sess.id] = sess
@@ -265,7 +265,7 @@ func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) {
265 win := newTestTabWithContext(p, sctx, cancel)265 win := newTestTabWithContext(p, sctx, cancel)
266 ac := &attachedClient{tr: tr, output: newOutputStateStream()}266 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
267 ac.initOverlays()267 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}
269 publishTiledPaneOwners(sess, win)269 publishTiledPaneOwners(sess, win)
270 ac.setSession(sess)270 ac.setSession(sess)
271 d.sessions[sess.id] = sess271 d.sessions[sess.id] = sess
@@ -290,7 +290,7 @@ func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) {
290 win := newTestTabWithContext(p, sctx, cancel)290 win := newTestTabWithContext(p, sctx, cancel)
291 ac := &attachedClient{tr: tr, output: newOutputStateStream()}291 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
292 ac.initOverlays()292 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}
294 publishTiledPaneOwners(sess, win)294 publishTiledPaneOwners(sess, win)
295 ac.setSession(sess)295 ac.setSession(sess)
296 d.sessions[sess.id] = sess296 d.sessions[sess.id] = sess
@@ -354,7 +354,7 @@ func TestQueuedClipboardAfterPaneMoveDoesNotSendToFormerOwner(t *testing.T) {
354 sess.mu.Unlock()354 sess.mu.Unlock()
355 d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued")))355 d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued")))
356 356
357- sourceTab := sess.activeTab()357+ sourceTab := testAttachmentTab(sess)
358 p := sourceTab.focusedPane()358 p := sourceTab.focusedPane()
359 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}}359 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}}
360 publishPaneOwner(p, destination, &tab{}, 0)360 publishPaneOwner(p, destination, &tab{}, 0)
@@ -363,7 +363,7 @@ func TestQueuedClipboardAfterPaneMoveDoesNotSendToFormerOwner(t *testing.T) {
363 d.clipboardWorker(sess)363 d.clipboardWorker(sess)
364 364
365 require.Zero(t, oldTransport.sendCount(), "clipboard queued by the former pane owner reached its client")365 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")
367 }367 }
368 368
369 type movingClipboardErrorTransport struct {369 type movingClipboardErrorTransport struct {
@@ -419,10 +419,10 @@ func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing
419 close(workerDone)419 close(workerDone)
420 }()420 }()
421 require.Eventually(t, func() bool {421 require.Eventually(t, func() bool {
422- return ac.roleEffects.inFlightCount() == 1422+ return ac.attachmentEffects.inFlightCount() == 1
423 }, time.Second, time.Millisecond, "clipboard send was not admitted before waiting on sendMu")423 }, time.Second, time.Millisecond, "clipboard send was not admitted before waiting on sendMu")
424 424
425- sourceTab := sess.activeTab()425+ sourceTab := testAttachmentTab(sess)
426 p := sourceTab.focusedPane()426 p := sourceTab.focusedPane()
427 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}}427 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}}
428 publishPaneOwner(p, destination, &tab{}, 0)428 publishPaneOwner(p, destination, &tab{}, 0)
@@ -432,7 +432,7 @@ func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing
432 awaitTestCompletion(t, workerDone, "clipboard worker did not finish")432 awaitTestCompletion(t, workerDone, "clipboard worker did not finish")
433 433
434 require.Zero(t, oldTransport.sendCount(), "clipboard send was not revalidated immediately before transport I/O")434 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)
436 }436 }
437 437
438 func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) {438 func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) {
@@ -453,7 +453,7 @@ func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) {
453 }()453 }()
454 awaitTestCompletion(t, oldTransport.started, "clipboard send did not start")454 awaitTestCompletion(t, oldTransport.started, "clipboard send did not start")
455 455
456- sourceTab := sess.activeTab()456+ sourceTab := testAttachmentTab(sess)
457 p := sourceTab.focusedPane()457 p := sourceTab.focusedPane()
458 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}}458 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}}
459 publishPaneOwner(p, destination, &tab{}, 0)459 publishPaneOwner(p, destination, &tab{}, 0)
@@ -461,7 +461,7 @@ func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) {
461 close(oldTransport.release)461 close(oldTransport.release)
462 awaitTestCompletion(t, workerDone, "clipboard worker did not finish")462 awaitTestCompletion(t, workerDone, "clipboard worker did not finish")
463 463
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")
465 select {465 select {
466 case <-oldTransport.closed:466 case <-oldTransport.closed:
467 t.Fatal("a send error from the pane's retired owner closed its client transport")467 t.Fatal("a send error from the pane's retired owner closed its client transport")
@@ -469,36 +469,6 @@ func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) {
469 }469 }
470 }470 }
471 471
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-
502 func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) {472 func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) {
503 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})473 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
504 tr := newBlockingClipboardTransport()474 tr := newBlockingClipboardTransport()
M internal/usecase/daemon/config.go +9 -9
@@ -345,16 +345,16 @@ func (d *Daemon) reapplyThemeAllSessions() {
345 }345 }
346 346
347 func (d *Daemon) reapplyThemeSession(sess *session) {347 func (d *Daemon) reapplyThemeSession(sess *session) {
348- sess.mu.Lock()348+ attachments := sess.snapshotAttachments()
349- ac := sess.client349+ if len(attachments) == 0 {
350- sess.mu.Unlock()
351- if ac == nil {
352 d.applyHostTheme(sess, nil, theme.Theme{}, true)350 d.applyHostTheme(sess, nil, theme.Theme{}, true)
353 return351 return
354 }352 }
355- ac.sendMu.Lock()353+ for _, ac := range attachments {
356- defer ac.sendMu.Unlock()354+ ac.sendMu.Lock()
357- sess.themeMu.Lock()355+ sess.themeMu.Lock()
358- defer sess.themeMu.Unlock()356+ d.applyHostThemeLocked(sess, ac, ac.getClientTheme(), false)
359- d.applyHostThemeLocked(sess, ac, ac.getClientTheme(), false)357+ sess.themeMu.Unlock()
358+ ac.sendMu.Unlock()
359+ }
360 }360 }
M internal/usecase/daemon/control.go +72 -33
@@ -161,10 +161,7 @@ func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, u
161 if len(locals) != 1 {161 if len(locals) != 1 {
162 return nil, ports.ErrAmbiguousTarget, "several sessions are live; use -s <session> or run from inside a pane"162 return nil, ports.ErrAmbiguousTarget, "several sessions are live; use -s <session> or run from inside a pane"
163 }163 }
164- for _, sess := range locals {164+ return locals[0], 0, ""
165- return sess, 0, ""
166- }
167- panic("unreachable")
168 }165 }
169 166
170 func (s *session) containsStableIDs(tabID, paneID string) bool {167 func (s *session) containsStableIDs(tabID, paneID string) bool {
@@ -186,7 +183,6 @@ func (s *session) containsStableIDs(tabID, paneID string) bool {
186 func resolveControlTarget(sess *session, kind command.TargetKind, request ports.CommandRequest) (*tab, *pane, uint16, string) {183 func resolveControlTarget(sess *session, kind command.TargetKind, request ports.CommandRequest) (*tab, *pane, uint16, string) {
187 sess.mu.Lock()184 sess.mu.Lock()
188 tabs := append([]*tab(nil), sess.tabs...)185 tabs := append([]*tab(nil), sess.tabs...)
189- active := sess.active
190 sess.mu.Unlock()186 sess.mu.Unlock()
191 if request.Self {187 if request.Self {
192 for _, tb := range tabs {188 for _, tb := range tabs {
@@ -204,10 +200,10 @@ func resolveControlTarget(sess *session, kind command.TargetKind, request ports.
204 }200 }
205 return nil, nil, ports.ErrNoSuchTarget, "no such target tab"201 return nil, nil, ports.ErrNoSuchTarget, "no such target tab"
206 }202 }
207- if len(tabs) == 0 || active < 0 || active >= len(tabs) {203+ if len(tabs) == 0 {
208- return nil, nil, ports.ErrNoSuchTarget, "target session has no active tab"204+ return nil, nil, ports.ErrNoSuchTarget, "target session has no tabs"
209 }205 }
210- tb := tabs[active]206+ tb := tabs[0]
211 if kind != command.TargetPane {207 if kind != command.TargetPane {
212 return tb, nil, 0, ""208 return tb, nil, 0, ""
213 }209 }
@@ -249,14 +245,16 @@ const (
249 )245 )
250 246
251 type daemonActionTarget struct {247 type daemonActionTarget struct {
252- session *session248+ session *session
253- tab *tab249+ attachment *attachedClient
254- pane *pane250+ tab *tab
251+ pane *pane
255 }252 }
256 253
257 type daemonActionRequest struct {254 type daemonActionRequest struct {
258 kind daemonActionKind255 kind daemonActionKind
259 target daemonActionTarget256 target daemonActionTarget
257+ effect *attachmentEffectTicket
260 direction layout.Direction258 direction layout.Direction
261 axis layout.Axis259 axis layout.Axis
262 delta int260 delta int
@@ -268,11 +266,18 @@ type daemonActionRunner interface {
268 Run(daemonActionRequest) error266 Run(daemonActionRequest) error
269 }267 }
270 268
271-func resolveDaemonActionTarget(sess *session) daemonActionTarget {269+func resolveDaemonActionTargetForAttachment(sess *session, ac *attachedClient) daemonActionTarget {
272 if sess == nil {270 if sess == nil {
273 return daemonActionTarget{}271 return daemonActionTarget{}
274 }272 }
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()
276 if tb == nil {281 if tb == nil {
277 return daemonActionTarget{session: sess}282 return daemonActionTarget{session: sess}
278 }283 }
@@ -291,9 +296,9 @@ func (a daemonActions) Run(request daemonActionRequest) error {
291 target := request.target296 target := request.target
292 switch request.kind {297 switch request.kind {
293 case daemonActionCreateTab:298 case daemonActionCreateTab:
294- return a.d.createTab(target.session, request.viewport)299+ return a.d.createTabForAttachment(target.session, target.attachment, request.viewport)
295 case daemonActionCloseTab:300 case daemonActionCloseTab:
296- return a.d.closeTab(target.session, target.tab, true)301+ return a.d.closeTabLockedWithEffect(target.session, target.tab, true, request.effect)
297 case daemonActionSplitPane:302 case daemonActionSplitPane:
298 return a.d.splitPaneAt(target.session, target.tab, target.pane, request.direction)303 return a.d.splitPaneAt(target.session, target.tab, target.pane, request.direction)
299 case daemonActionStackPane:304 case daemonActionStackPane:
@@ -307,7 +312,7 @@ func (a daemonActions) Run(request daemonActionRequest) error {
307 if !a.d.hasDaemonActionPaneTarget(target) {312 if !a.d.hasDaemonActionPaneTarget(target) {
308 return layout.ErrNotFound313 return layout.ErrNotFound
309 }314 }
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 {
311 return err316 return err
312 }317 }
313 if a.d.hasDaemonActionPaneTarget(target) {318 if a.d.hasDaemonActionPaneTarget(target) {
@@ -315,12 +320,12 @@ func (a daemonActions) Run(request daemonActionRequest) error {
315 }320 }
316 return nil321 return nil
317 case daemonActionFocusPane:322 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)
319 return err324 return err
320 case daemonActionNextTab:325 case daemonActionNextTab:
321- return a.switchRelative(target.session, 1)326+ return a.switchRelative(target.session, target.attachment, 1)
322 case daemonActionPreviousTab:327 case daemonActionPreviousTab:
323- return a.switchRelative(target.session, -1)328+ return a.switchRelative(target.session, target.attachment, -1)
324 case daemonActionRenameSession:329 case daemonActionRenameSession:
325 return a.d.renameSession(target.session, request.name)330 return a.d.renameSession(target.session, request.name)
326 case daemonActionRenameTab:331 case daemonActionRenameTab:
@@ -359,9 +364,9 @@ func (d *Daemon) hasDaemonActionPaneTarget(target daemonActionTarget) bool {
359 target.tab.tree != nil && layout.ContainsLeaf(target.tab.tree.Root, target.pane.id)364 target.tab.tree != nil && layout.ContainsLeaf(target.tab.tree.Root, target.pane.id)
360 }365 }
361 366
362-func (a daemonActions) switchRelative(sess *session, delta int) error {367+func (a daemonActions) switchRelative(sess *session, ac *attachedClient, delta int) error {
363- if sess.switchRelative(delta) {368+ if sess.switchAttachmentRelativeForDispatch(ac, delta) {
364- a.d.activateTab(sess, sess.activeTab())369+ a.d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(ac), false, ac, nil)
365 }370 }
366 return nil371 return nil
367 }372 }
@@ -394,10 +399,7 @@ func (e controlExec) runAction(request daemonActionRequest) error {
394 return err399 return err
395 }400 }
396 if e.actions == nil {401 if e.actions == nil {
397- e.sess.mu.Lock()402+ finishDaemonActionForClient(e.d, request, request.target.attachment, "control.go")
398- ac := e.sess.client
399- e.sess.mu.Unlock()
400- finishDaemonActionForClient(e.d, request, ac, "control.go")
401 }403 }
402 return nil404 return nil
403 }405 }
@@ -450,7 +452,17 @@ func (e controlExec) MovePane(destinationSession, destinationTabID string) error
450 if e.sess == nil || e.tab == nil || e.target.pane == nil {452 if e.sess == nil || e.tab == nil || e.target.pane == nil {
451 return errMovePaneInvalid453 return errMovePaneInvalid
452 }454 }
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+ }
453 return e.d.movePane(movePaneRequest{463 return e.d.movePane(movePaneRequest{
464+ Attachment: attachment,
465+ AttachmentToken: token,
454 Source: sessionMoveLocator(e.sess),466 Source: sessionMoveLocator(e.sess),
455 SourceTabID: domain.TabStableID(e.tab.stableID),467 SourceTabID: domain.TabStableID(e.tab.stableID),
456 SourcePaneID: domain.PaneStableID(e.target.pane.stableID),468 SourcePaneID: domain.PaneStableID(e.target.pane.stableID),
@@ -467,10 +479,20 @@ func (e controlExec) MoveTab(destinationSession string) error {
467 if e.sess == nil || e.tab == nil {479 if e.sess == nil || e.tab == nil {
468 return errMovePaneInvalid480 return errMovePaneInvalid
469 }481 }
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+ }
470 return e.d.moveTab(moveTabRequest{490 return e.d.moveTab(moveTabRequest{
471- Source: sessionMoveLocator(e.sess),491+ Attachment: attachment,
472- SourceTabID: domain.TabStableID(e.tab.stableID),492+ AttachmentToken: token,
473- Destination: sessionMoveLocator(destination),493+ Source: sessionMoveLocator(e.sess),
494+ SourceTabID: domain.TabStableID(e.tab.stableID),
495+ Destination: sessionMoveLocator(destination),
474 })496 })
475 }497 }
476 498
@@ -657,8 +679,8 @@ func (e controlExec) ListTabs(asJSON bool) (string, error) {
657 }679 }
658 e.sess.mu.Lock()680 e.sess.mu.Lock()
659 tabs := append([]*tab(nil), e.sess.tabs...)681 tabs := append([]*tab(nil), e.sess.tabs...)
660- active := e.sess.active
661 e.sess.mu.Unlock()682 e.sess.mu.Unlock()
683+ active, _ := e.sess.tabIndexForAttachment(e.target.attachment)
662 rows := make([]row, 0, len(tabs))684 rows := make([]row, 0, len(tabs))
663 for i, tb := range tabs {685 for i, tb := range tabs {
664 tb.mu.Lock()686 tb.mu.Lock()
@@ -686,7 +708,11 @@ func (e controlExec) ListPanes(asJSON bool) (string, error) {
686 }708 }
687 tb := e.tab709 tb := e.tab
688 if tb == nil {710 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+ }
690 }716 }
691 if tb == nil {717 if tb == nil {
692 return "", errors.New("target session has no active tab")718 return "", errors.New("target session has no active tab")
@@ -694,8 +720,17 @@ func (e controlExec) ListPanes(asJSON bool) (string, error) {
694 e.sess.mu.Lock()720 e.sess.mu.Lock()
695 fallbackCWD := e.sess.cwd721 fallbackCWD := e.sess.cwd
696 e.sess.mu.Unlock()722 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+ }
697 tb.mu.Lock()728 tb.mu.Lock()
698- focus := tb.tree.Focus729+ if e.target.attachment == nil {
730+ if focused := tb.focusedPane(); focused != nil {
731+ focus = focused.id
732+ }
733+ }
699 panes := tb.panesSnapshot()734 panes := tb.panesSnapshot()
700 tb.mu.Unlock()735 tb.mu.Unlock()
701 rows := make([]row, 0, len(panes))736 rows := make([]row, 0, len(panes))
@@ -713,7 +748,11 @@ func (e controlExec) ListPanes(asJSON bool) (string, error) {
713 cwd = live748 cwd = live
714 }749 }
715 }750 }
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})
717 }756 }
718 sort.Slice(rows, func(i, j int) bool { return rows[i].Pane < rows[j].Pane })757 sort.Slice(rows, func(i, j int) bool { return rows[i].Pane < rows[j].Pane })
719 if asJSON {758 if asJSON {
M internal/usecase/daemon/control_test.go +59 -38
@@ -37,7 +37,7 @@ func TestPaletteAndControlShareExplicitDaemonActionTarget(t *testing.T) {
37 t.Run(tt.name, func(t *testing.T) {37 t.Run(tt.name, func(t *testing.T) {
38 d := newTestDaemon(t, nil, stubClock{})38 d := newTestDaemon(t, nil, stubClock{})
39 sess := addControlSession(d, "work", "t_work", "p_work")39 sess := addControlSession(d, "work", "t_work", "p_work")
40- tb := sess.activeTab()40+ tb := testAttachmentTab(sess)
41 tb.mu.Lock()41 tb.mu.Lock()
42 pane := tb.focusedPane()42 pane := tb.focusedPane()
43 tb.mu.Unlock()43 tb.mu.Unlock()
@@ -78,9 +78,7 @@ func TestConsumeOrExpelControlSelfTargetsNonFocusedPane(t *testing.T) {
78 78
79 require.True(t, result.OK, result.Text)79 require.True(t, result.OK, result.Text)
80 require.Empty(t, result.Output)80 require.Empty(t, result.Output)
81- h.session.mu.Lock()81+ require.Zero(t, testAttachmentTabIndex(h.session), "--self must not change the attachment tab")
82- require.Zero(t, h.session.active, "--self must not change the active tab")
83- h.session.mu.Unlock()
84 h.tab.mu.Lock()82 h.tab.mu.Lock()
85 require.Equal(t, layout.PaneID("pane-3"), h.tab.tree.Focus, "a moved explicit target becomes focused")83 require.Equal(t, layout.PaneID("pane-3"), h.tab.tree.Focus, "a moved explicit target becomes focused")
86 require.Equal(t, beforeGeneration+1, h.tab.layoutGeneration)84 require.Equal(t, beforeGeneration+1, h.tab.layoutGeneration)
@@ -93,7 +91,7 @@ func TestConsumeOrExpelControlEdgeNoopReturnsOKAndPreservesFocus(t *testing.T) {
93 ac := &attachedClient{}91 ac := &attachedClient{}
94 ac.setSession(h.session)92 ac.setSession(h.session)
95 h.session.mu.Lock()93 h.session.mu.Lock()
96- h.session.client = ac94+ h.session.registerAttachmentLocked(ac)
97 h.session.mu.Unlock()95 h.session.mu.Unlock()
98 invalidations := make(chan renderInvalidation, 1)96 invalidations := make(chan renderInvalidation, 1)
99 rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})97 rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
@@ -240,7 +238,7 @@ func TestHandleCommandStableIDsResolveSessionWithoutSelectingTab(t *testing.T) {
240 second.ctx, second.cancel = context.WithCancel(d.serveCtx)238 second.ctx, second.cancel = context.WithCancel(d.serveCtx)
241 sess.mu.Lock()239 sess.mu.Lock()
242 sess.tabs = append(sess.tabs, second)240 sess.tabs = append(sess.tabs, second)
243- sess.active = 0241+ selectTestAttachmentTabLocked(sess, 0)
244 sess.mu.Unlock()242 sess.mu.Unlock()
245 243
246 result := sendCommand(t, d, ports.CommandRequest{244 result := sendCommand(t, d, ports.CommandRequest{
@@ -256,7 +254,7 @@ func TestHandleCommandStableIDsResolveSessionWithoutSelectingTab(t *testing.T) {
256 require.Empty(t, second.name)254 require.Empty(t, second.name)
257 second.mu.Unlock()255 second.mu.Unlock()
258 sess.mu.Lock()256 sess.mu.Lock()
259- require.Zero(t, sess.active)257+ require.Zero(t, testAttachmentTabIndexLocked(sess))
260 sess.mu.Unlock()258 sess.mu.Unlock()
261 }259 }
262 260
@@ -272,7 +270,7 @@ func TestHandleCommandStableIDsDoNotRedirectSplitFromCurrentFocus(t *testing.T)
272 second.ctx, second.cancel = context.WithCancel(d.serveCtx)270 second.ctx, second.cancel = context.WithCancel(d.serveCtx)
273 sess.mu.Lock()271 sess.mu.Lock()
274 sess.tabs = append(sess.tabs, second)272 sess.tabs = append(sess.tabs, second)
275- sess.active = 0273+ selectTestAttachmentTabLocked(sess, 0)
276 sess.mu.Unlock()274 sess.mu.Unlock()
277 275
278 result := sendCommand(t, d, ports.CommandRequest{276 result := sendCommand(t, d, ports.CommandRequest{
@@ -282,7 +280,7 @@ func TestHandleCommandStableIDsDoNotRedirectSplitFromCurrentFocus(t *testing.T)
282 280
283 require.True(t, result.OK, result.Text)281 require.True(t, result.OK, result.Text)
284 sess.mu.Lock()282 sess.mu.Lock()
285- activeIndex := sess.active283+ activeIndex := testAttachmentTabIndexLocked(sess)
286 sess.mu.Unlock()284 sess.mu.Unlock()
287 require.Zero(t, activeIndex)285 require.Zero(t, activeIndex)
288 active.mu.Lock()286 active.mu.Lock()
@@ -309,7 +307,7 @@ func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) {
309 invoking.ctx, invoking.cancel = context.WithCancel(d.serveCtx)307 invoking.ctx, invoking.cancel = context.WithCancel(d.serveCtx)
310 sess.mu.Lock()308 sess.mu.Lock()
311 sess.tabs = append(sess.tabs, invoking)309 sess.tabs = append(sess.tabs, invoking)
312- sess.active = 0310+ selectTestAttachmentTabLocked(sess, 0)
313 sess.mu.Unlock()311 sess.mu.Unlock()
314 312
315 rename := sendCommand(t, d, ports.CommandRequest{313 rename := sendCommand(t, d, ports.CommandRequest{
@@ -383,7 +381,7 @@ func TestResizeControlOneShotsTargetDetachedSessions(t *testing.T) {
383 sess := addControlSession(d, "work", "t_work", "p_work")381 sess := addControlSession(d, "work", "t_work", "p_work")
384 require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)382 require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
385 require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-down", TargetSession: "work"}).OK)383 require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-down", TargetSession: "work"}).OK)
386- tb := sess.activeTab()384+ tb := testAttachmentTab(sess)
387 tb.mu.Lock()385 tb.mu.Lock()
388 generation := tb.layoutGeneration386 generation := tb.layoutGeneration
389 tb.mu.Unlock()387 tb.mu.Unlock()
@@ -403,7 +401,7 @@ func TestResizeControlOneShotsTargetDetachedSessions(t *testing.T) {
403 require.Equal(t, snapshotGeneration+1, sess.snapshotGeneration, "one accepted action has one snapshot dirty boundary")401 require.Equal(t, snapshotGeneration+1, sess.snapshotGeneration, "one accepted action has one snapshot dirty boundary")
404 sess.snapshotMu.Unlock()402 sess.snapshotMu.Unlock()
405 sess.mu.Lock()403 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")
407 sess.mu.Unlock()405 sess.mu.Unlock()
408 })406 })
409 }407 }
@@ -422,7 +420,7 @@ func TestResizeControlNonSelfVEVLocatorUsesActiveTarget(t *testing.T) {
422 locator.ctx, locator.cancel = context.WithCancel(d.serveCtx)420 locator.ctx, locator.cancel = context.WithCancel(d.serveCtx)
423 sess.mu.Lock()421 sess.mu.Lock()
424 sess.tabs = append(sess.tabs, locator)422 sess.tabs = append(sess.tabs, locator)
425- sess.active = 0423+ selectTestAttachmentTabLocked(sess, 0)
426 sess.mu.Unlock()424 sess.mu.Unlock()
427 active.mu.Lock()425 active.mu.Lock()
428 before := active.layoutGeneration426 before := active.layoutGeneration
@@ -439,7 +437,7 @@ func TestResizeControlNonSelfVEVLocatorUsesActiveTarget(t *testing.T) {
439 require.Zero(t, locator.layoutGeneration)437 require.Zero(t, locator.layoutGeneration)
440 locator.mu.Unlock()438 locator.mu.Unlock()
441 sess.mu.Lock()439 sess.mu.Lock()
442- require.Zero(t, sess.active)440+ require.Zero(t, testAttachmentTabIndexLocked(sess))
443 sess.mu.Unlock()441 sess.mu.Unlock()
444 }442 }
445 443
@@ -449,7 +447,7 @@ func TestResizeControlTooSmallUsesStableFailure(t *testing.T) {
449 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })447 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
450 sess := addControlSession(d, "work", "t_work", "p_work")448 sess := addControlSession(d, "work", "t_work", "p_work")
451 require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)449 require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
452- tb := sess.activeTab()450+ tb := testAttachmentTab(sess)
453 tb.mu.Lock()451 tb.mu.Lock()
454 tb.size.Cols = 41452 tb.size.Cols = 41
455 tb.bumpLayoutGenerationLocked()453 tb.bumpLayoutGenerationLocked()
@@ -468,7 +466,7 @@ func TestHandleCommandSelfListPanesUsesInvokingTab(t *testing.T) {
468 invoking.ctx, invoking.cancel = context.WithCancel(d.serveCtx)466 invoking.ctx, invoking.cancel = context.WithCancel(d.serveCtx)
469 sess.mu.Lock()467 sess.mu.Lock()
470 sess.tabs = append(sess.tabs, invoking)468 sess.tabs = append(sess.tabs, invoking)
471- sess.active = 0469+ selectTestAttachmentTabLocked(sess, 0)
472 sess.mu.Unlock()470 sess.mu.Unlock()
473 471
474 result := sendCommand(t, d, ports.CommandRequest{472 result := sendCommand(t, d, ports.CommandRequest{
@@ -539,7 +537,7 @@ func TestCloseCommandsReportMutationOutcome(t *testing.T) {
539 second.ctx, second.cancel = context.WithCancel(d.serveCtx)537 second.ctx, second.cancel = context.WithCancel(d.serveCtx)
540 sess.mu.Lock()538 sess.mu.Lock()
541 sess.tabs = append(sess.tabs, second)539 sess.tabs = append(sess.tabs, second)
542- sess.active = 1540+ selectTestAttachmentTabLocked(sess, 1)
543 sess.mu.Unlock()541 sess.mu.Unlock()
544 542
545 result := sendCommand(t, d, ports.CommandRequest{Slug: "close-tab", TargetSession: "work"})543 result := sendCommand(t, d, ports.CommandRequest{Slug: "close-tab", TargetSession: "work"})
@@ -547,7 +545,7 @@ func TestCloseCommandsReportMutationOutcome(t *testing.T) {
547 require.True(t, result.OK, result.Text)545 require.True(t, result.OK, result.Text)
548 sess.mu.Lock()546 sess.mu.Lock()
549 require.Len(t, sess.tabs, 1)547 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)
551 sess.mu.Unlock()549 sess.mu.Unlock()
552 })550 })
553 }551 }
@@ -560,7 +558,7 @@ func TestHandleCommandHeadlessMutations(t *testing.T) {
560 verify func(*testing.T, *Daemon, *session)558 verify func(*testing.T, *Daemon, *session)
561 }{559 }{
562 {name: "split focused pane", slug: "split-right", verify: func(t *testing.T, _ *Daemon, sess *session) {560 {name: "split focused pane", slug: "split-right", verify: func(t *testing.T, _ *Daemon, sess *session) {
563- tb := sess.activeTab()561+ tb := testAttachmentTab(sess)
564 tb.mu.Lock()562 tb.mu.Lock()
565 defer tb.mu.Unlock()563 defer tb.mu.Unlock()
566 require.Len(t, tb.panes, 2)564 require.Len(t, tb.panes, 2)
@@ -569,10 +567,10 @@ func TestHandleCommandHeadlessMutations(t *testing.T) {
569 sess.mu.Lock()567 sess.mu.Lock()
570 defer sess.mu.Unlock()568 defer sess.mu.Unlock()
571 require.Len(t, sess.tabs, 2)569 require.Len(t, sess.tabs, 2)
572- require.Equal(t, 1, sess.active)570+ require.Equal(t, 0, testAttachmentTabIndexLocked(sess))
573 }},571 }},
574 {name: "rename tab", slug: "rename-tab", args: []string{"logs"}, verify: func(t *testing.T, _ *Daemon, sess *session) {572 {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)
576 tb.mu.Lock()574 tb.mu.Lock()
577 defer tb.mu.Unlock()575 defer tb.mu.Unlock()
578 require.Equal(t, "logs", tb.name)576 require.Equal(t, "logs", tb.name)
@@ -613,7 +611,7 @@ func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T
613 d.mu.Unlock()611 d.mu.Unlock()
614 require.NotNil(t, created)612 require.NotNil(t, created)
615 created.mu.Lock()613 created.mu.Lock()
616- require.Nil(t, created.client)614+ require.Empty(t, created.snapshotAttachmentsLocked())
617 require.Equal(t, "/tmp/work", created.cwd)615 require.Equal(t, "/tmp/work", created.cwd)
618 require.Equal(t, []string{"INHERITED=yes"}, created.env)616 require.Equal(t, []string{"INHERITED=yes"}, created.env)
619 require.True(t, created.terminal.TrueColor)617 require.True(t, created.terminal.TrueColor)
@@ -622,7 +620,7 @@ func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T
622 tb.mu.Lock()620 tb.mu.Lock()
623 require.Equal(t, domain.Size{Cols: 118, Rows: 38}, tb.size)621 require.Equal(t, domain.Size{Cols: 118, Rows: 38}, tb.size)
624 tb.mu.Unlock()622 tb.mu.Unlock()
625- require.Nil(t, source.client)623+ require.Empty(t, source.snapshotAttachments())
626 624
627 taken := sendCommand(t, d, ports.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})625 taken := sendCommand(t, d, ports.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})
628 require.False(t, taken.OK)626 require.False(t, taken.OK)
@@ -674,7 +672,7 @@ func TestRemoteCatalogJSONOutput(t *testing.T) {
674 d := newTestDaemon(t, nil, stubClock{})672 d := newTestDaemon(t, nil, stubClock{})
675 work := addControlSession(d, "work", "t_work", "p_work")673 work := addControlSession(d, "work", "t_work", "p_work")
676 work.ephemeral = false674 work.ephemeral = false
677- work.client = &attachedClient{}675+ work.registerAttachment(&attachedClient{})
678 work.mruAt.Store(2)676 work.mruAt.Store(2)
679 tb := newTabWithStableID("t_work_2", "p_work_2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})677 tb := newTabWithStableID("t_work_2", "p_work_2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
680 tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)678 tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)
@@ -762,7 +760,7 @@ func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
762 target.ctx, target.cancel = context.WithCancel(d.serveCtx)760 target.ctx, target.cancel = context.WithCancel(d.serveCtx)
763 sess.mu.Lock()761 sess.mu.Lock()
764 sess.tabs = append(sess.tabs, target)762 sess.tabs = append(sess.tabs, target)
765- sess.active = 0763+ selectTestAttachmentTabLocked(sess, 0)
766 sess.mu.Unlock()764 sess.mu.Unlock()
767 original := target.tree.Focus765 original := target.tree.Focus
768 766
@@ -808,7 +806,7 @@ func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
808 require.True(t, awaitCommandResult(t, secondSends).OK)806 require.True(t, awaitCommandResult(t, secondSends).OK)
809 807
810 sess.mu.Lock()808 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")
812 sess.mu.Unlock()810 sess.mu.Unlock()
813 target.mu.Lock()811 target.mu.Lock()
814 defer target.mu.Unlock()812 defer target.mu.Unlock()
@@ -816,6 +814,27 @@ func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
816 require.NotEqual(t, original, target.tree.Focus, "second command must execute after the split creates its right neighbor")814 require.NotEqual(t, original, target.tree.Focus, "second command must execute after the split creates its right neighbor")
817 }815 }
818 816
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+
819 func TestHandleCommandMovePaneUsesActiveFocusedSourceWithSessionFlag(t *testing.T) {838 func TestHandleCommandMovePaneUsesActiveFocusedSourceWithSessionFlag(t *testing.T) {
820 d := newTestDaemon(t, nil, stubClock{})839 d := newTestDaemon(t, nil, stubClock{})
821 source := addControlSession(d, "work", "t_active", "p_active")840 source := addControlSession(d, "work", "t_active", "p_active")
@@ -824,7 +843,7 @@ func TestHandleCommandMovePaneUsesActiveFocusedSourceWithSessionFlag(t *testing.
824 publishTiledPaneOwners(source, inactive)843 publishTiledPaneOwners(source, inactive)
825 source.mu.Lock()844 source.mu.Lock()
826 source.tabs = append(source.tabs, inactive)845 source.tabs = append(source.tabs, inactive)
827- source.active = 0846+ selectTestAttachmentTabLocked(source, 0)
828 source.mu.Unlock()847 source.mu.Unlock()
829 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")848 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
830 849
@@ -859,7 +878,7 @@ func TestHandleCommandMovePaneSelfUsesStableSourceIDs(t *testing.T) {
859 publishTiledPaneOwners(source, inactive)878 publishTiledPaneOwners(source, inactive)
860 source.mu.Lock()879 source.mu.Lock()
861 source.tabs = append(source.tabs, inactive)880 source.tabs = append(source.tabs, inactive)
862- source.active = 0881+ selectTestAttachmentTabLocked(source, 0)
863 source.mu.Unlock()882 source.mu.Unlock()
864 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")883 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
865 884
@@ -891,27 +910,29 @@ func TestHandleCommandMovePaneSelfUsesStableSourceIDs(t *testing.T) {
891 func TestHandleCommandMoveTabUsesActiveTabWithoutSelf(t *testing.T) {910 func TestHandleCommandMoveTabUsesActiveTabWithoutSelf(t *testing.T) {
892 d := newTestDaemon(t, nil, stubClock{})911 d := newTestDaemon(t, nil, stubClock{})
893 source := addControlSession(d, "work", "t_first", "p_first")912 source := addControlSession(d, "work", "t_first", "p_first")
913+ publishTiledPaneOwners(source, source.tabs[0])
894 second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})914 second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
895 second.ctx, second.cancel = context.WithCancel(d.serveCtx)915 second.ctx, second.cancel = context.WithCancel(d.serveCtx)
896 publishTiledPaneOwners(source, second)916 publishTiledPaneOwners(source, second)
897 source.mu.Lock()917 source.mu.Lock()
898 source.tabs = append(source.tabs, second)918 source.tabs = append(source.tabs, second)
899- source.active = 1919+ selectTestAttachmentTabLocked(source, 0)
900 source.mu.Unlock()920 source.mu.Unlock()
901 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")921 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
902 922
903 result := sendCommand(t, d, ports.CommandRequest{923 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",
905 })926 })
906 require.True(t, result.OK, result.Text)927 require.True(t, result.OK, result.Text)
907 928
908 source.mu.Lock()929 source.mu.Lock()
909 require.Len(t, source.tabs, 1)930 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)
911 source.mu.Unlock()932 source.mu.Unlock()
912 destination.mu.Lock()933 destination.mu.Lock()
913 require.Len(t, destination.tabs, 2)934 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)
915 destination.mu.Unlock()936 destination.mu.Unlock()
916 }937 }
917 938
@@ -923,7 +944,7 @@ func TestHandleCommandMoveTabSelfUsesStableTab(t *testing.T) {
923 publishTiledPaneOwners(source, second)944 publishTiledPaneOwners(source, second)
924 source.mu.Lock()945 source.mu.Lock()
925 source.tabs = append(source.tabs, second)946 source.tabs = append(source.tabs, second)
926- source.active = 0947+ selectTestAttachmentTabLocked(source, 0)
927 source.mu.Unlock()948 source.mu.Unlock()
928 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")949 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
929 950
@@ -1093,12 +1114,12 @@ func TestHandleCommandMovePaneStableIDsLocateSessionWithoutSelfRedirect(t *testi
1093 publishTiledPaneOwners(source, inactive)1114 publishTiledPaneOwners(source, inactive)
1094 source.mu.Lock()1115 source.mu.Lock()
1095 source.tabs = append(source.tabs, inactive)1116 source.tabs = append(source.tabs, inactive)
1096- source.active = 11117+ selectTestAttachmentTabLocked(source, 1)
1097 source.mu.Unlock()1118 source.mu.Unlock()
1098 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")1119 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1099 1120
1100 result := sendCommand(t, d, ports.CommandRequest{1121 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,
1102 TargetSession: "old-name", TargetTab: "t_inactive", TargetPane: "p_inactive",1123 TargetSession: "old-name", TargetTab: "t_inactive", TargetPane: "p_inactive",
1103 })1124 })
1104 require.True(t, result.OK, result.Text)1125 require.True(t, result.OK, result.Text)
@@ -1126,7 +1147,7 @@ func TestHandleCommandOppositeMoveCommandsDoNotDeadlock(t *testing.T) {
1126 left.mu.Lock()1147 left.mu.Lock()
1127 left.name = "left"1148 left.name = "left"
1128 left.tabs[0].stableID = "left-moved"1149 left.tabs[0].stableID = "left-moved"
1129- left.active = 01150+ selectTestAttachmentTabLocked(left, 0)
1130 left.mu.Unlock()1151 left.mu.Unlock()
1131 1152
1132 right := addMoveTabTestSession(d, "right", "right-stays")1153 right := addMoveTabTestSession(d, "right", "right-stays")
@@ -1135,7 +1156,7 @@ func TestHandleCommandOppositeMoveCommandsDoNotDeadlock(t *testing.T) {
1135 publishTiledPaneOwners(right, rightMoved)1156 publishTiledPaneOwners(right, rightMoved)
1136 right.mu.Lock()1157 right.mu.Lock()
1137 right.tabs = append(right.tabs, rightMoved)1158 right.tabs = append(right.tabs, rightMoved)
1138- right.active = 11159+ selectTestAttachmentTabLocked(right, 1)
1139 right.mu.Unlock()1160 right.mu.Unlock()
1140 1161
1141 leftFrame := commandFrame(t, ports.CommandRequest{1162 leftFrame := commandFrame(t, ports.CommandRequest{
@@ -1179,7 +1200,7 @@ func addNamedMoveDestination(d *Daemon, name, tabID, paneID string) *session {
1179 tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 23})1200 tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
1180 tb.ctx, tb.cancel = context.WithCancel(ctx)1201 tb.ctx, tb.cancel = context.WithCancel(ctx)
1181 sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, incarnation: domain.IncarnationID{5},1202 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},
1183 }1204 }
1184 publishTiledPaneOwners(sess, tb)1205 publishTiledPaneOwners(sess, tb)
1185 d.mu.Lock()1206 d.mu.Lock()
M internal/usecase/daemon/copy_enter_allocation_budget_race_test.go +1 -1
@@ -2,7 +2,7 @@
2 2
3 package daemon3 package daemon
4 4
5-// Race instrumentation allocations are not part of the parent benchmark's5+// Race instrumentation allocations are not part of the non-race benchmark's
6 // allocation baseline. TestCopyEnterAllocationBudget still exercises copy6 // allocation baseline. TestCopyEnterAllocationBudget still exercises copy
7 // entry and validates its result under -race.7 // entry and validates its result under -race.
8 const copyEnterAllocationBudgetEnabled = false8 const copyEnterAllocationBudgetEnabled = false
M internal/usecase/daemon/copy_enter_allocation_budget_test.go +1 -1
@@ -3,5 +3,5 @@
3 package daemon3 package daemon
4 4
5 // copyEnterAllocationBudgetEnabled keeps the allocation gate aligned with the5 // 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.
7 const copyEnterAllocationBudgetEnabled = true7 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
179 if !ok {179 if !ok {
180 return true180 return true
181 }181 }
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) {
183 mode.StartCharacterSelection(pointer.press)183 mode.StartCharacterSelection(pointer.press)
184 mode.ExtendCharacterSelection(mapped.pos)184 mode.ExtendCharacterSelection(mapped.pos)
185 }, func(runtime *overlayRuntime, mode *scopy.Mode) {185 }, 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) {
71 p, release := newBlockingPTY(t)71 p, release := newBlockingPTY(t)
72 defer release()72 defer release()
73 d, sess, _, _ := newManualSessionWithPTYs(t, p)73 d, sess, _, _ := newManualSessionWithPTYs(t, p)
74- tb := sess.activeTab()74+ tb := testAttachmentTab(sess)
75 75
76 tb.mu.Lock()76 tb.mu.Lock()
77 geometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), 0, 1)77 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) {
63 d.invalidateRender(sess, ac, true, "copymode.go")63 d.invalidateRender(sess, ac, true, "copymode.go")
64 }64 }
65 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {65 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
66- tb := sess.activeTab()66+ tb := sess.tabForAttachment(ac)
67 if tb == nil {67 if tb == nil {
68 return68 return
69 }69 }
@@ -114,7 +114,7 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *
114 if d.beforeCopyModeRevalidate != nil {114 if d.beforeCopyModeRevalidate != nil {
115 d.beforeCopyModeRevalidate()115 d.beforeCopyModeRevalidate()
116 }116 }
117- valid := sess.activeTab() == tb117+ valid := sess.tabForAttachment(ac) == tb
118 if valid {118 if valid {
119 tb.mu.Lock()119 tb.mu.Lock()
120 valid = tb.panes[p.id] == p || (tb.floating.state == floatingVisible && tb.floating.pane == p)120 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) {
45 seedCopyInteractionLocked(ac.overlays, ac.overlays.copyPane, ac.overlays.copyDocument)45 seedCopyInteractionLocked(ac.overlays, ac.overlays.copyPane, ac.overlays.copyDocument)
46 ac.overlays.copyMu.Unlock()46 ac.overlays.copyMu.Unlock()
47 47
48- require.True(t, sess.switchTab(1))48+ require.True(t, selectTestAttachmentTab(sess, 1))
49- d.activateTab(sess, sess.activeTab())49+ d.activateTab(sess, testAttachmentTab(sess))
50 50
51 require.False(t, ac.overlays.copyActive())51 require.False(t, ac.overlays.copyActive())
52 ac.overlays.copyMu.Lock()52 ac.overlays.copyMu.Lock()
@@ -80,7 +80,7 @@ func TestActivateTabAfterResizePathAndCopyModeLifecycle(t *testing.T) {
80 80
81 d.enterCopyMode(sess, ac)81 d.enterCopyMode(sess, ac)
82 require.True(t, ac.overlays.copyActive())82 require.True(t, ac.overlays.copyActive())
83- d.activateTabAfterResizeForLease(sess, sess.activeTab(), true, ac, lease)83+ d.activateTabAfterResizeForLease(sess, testAttachmentTab(sess), true, ac, lease)
84 84
85 require.False(t, ac.overlays.copyActive())85 require.False(t, ac.overlays.copyActive())
86 require.Equal(t, tt.leaseBacked, <-path)86 require.Equal(t, tt.leaseBacked, <-path)
@@ -90,7 +90,7 @@ func TestActivateTabAfterResizePathAndCopyModeLifecycle(t *testing.T) {
90 90
91 func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {91 func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
92 d, sess, _, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})92 d, sess, _, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
93- tb := sess.activeTab()93+ tb := testAttachmentTab(sess)
94 closingPTY := portsmocks.NewMockPTY(t)94 closingPTY := portsmocks.NewMockPTY(t)
95 closingPTY.EXPECT().Close().Return(nil).Once()95 closingPTY.EXPECT().Close().Return(nil).Once()
96 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"}96 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) {
98 tb.panes["pane-2"] = closing98 tb.panes["pane-2"] = closing
99 ac := &attachedClient{output: newOutputStateStream()}99 ac := &attachedClient{output: newOutputStateStream()}
100 ac.initOverlays()100 ac.initOverlays()
101- sess.client = ac101+ sess.registerAttachment(ac)
102 ac.setSession(sess)102 ac.setSession(sess)
103 closing.mu.Lock()103 closing.mu.Lock()
104 document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame, closing.screen.LineBounds(), nil), domain.DefaultWordSeparators)104 document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame, closing.screen.LineBounds(), nil), domain.DefaultWordSeparators)
@@ -126,7 +126,7 @@ func TestCopyModeLifecycleCloseOnlyPaneInTabClearsRecoveredClientState(t *testin
126 release()126 release()
127 }127 }
128 }()128 }()
129- tb := sess.activeTab()129+ tb := testAttachmentTab(sess)
130 p := tb.focusedPane()130 p := tb.focusedPane()
131 d.enterCopyMode(sess, ac)131 d.enterCopyMode(sess, ac)
132 require.True(t, ac.overlays.copyActive())132 require.True(t, ac.overlays.copyActive())
@@ -143,7 +143,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
143 release()143 release()
144 }144 }
145 }()145 }()
146- tb := sess.activeTab()146+ tb := testAttachmentTab(sess)
147 p := tb.focusedPane()147 p := tb.focusedPane()
148 p.mu.Lock()148 p.mu.Lock()
149 document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)149 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) {
185 renderSnapshot := ac.overlays.SnapshotForRender()185 renderSnapshot := ac.overlays.SnapshotForRender()
186 candidateWasRenderable := renderSnapshot.copyActive186 candidateWasRenderable := renderSnapshot.copyActive
187 renderSnapshot.Unlock()187 renderSnapshot.Unlock()
188- require.True(t, sess.switchTab(1))188+ require.True(t, selectTestAttachmentTab(sess, 1))
189 close(resume)189 close(resume)
190 resumed = true190 resumed = true
191 191
@@ -225,7 +225,7 @@ func TestCopyModeLifecyclePublicationEpochRejectsReleaseAndPaneClose(t *testing.
225 release()225 release()
226 }226 }
227 }()227 }()
228- tb := sess.activeTab()228+ tb := testAttachmentTab(sess)
229 p := tb.focusedPane()229 p := tb.focusedPane()
230 230
231 // A real press is required: the motion publication must inherit this231 // A real press is required: the motion publication must inherit this
@@ -285,7 +285,7 @@ func TestCopyModeLifecycleFloatingCloseDuringPublicationDoesNotResurrect(t *test
285 release()285 release()
286 }286 }
287 }()287 }()
288- tb := sess.activeTab()288+ tb := testAttachmentTab(sess)
289 floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5})289 floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5})
290 installTestFloating(tb, floating, true)290 installTestFloating(tb, floating, true)
291 tb.mu.Lock()291 tb.mu.Lock()
@@ -356,7 +356,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.
356 {356 {
357 name: "inactive tab",357 name: "inactive tab",
358 invalidate: func(sess *session, _ *tab, _ *pane) {358 invalidate: func(sess *session, _ *tab, _ *pane) {
359- require.True(t, sess.switchTab(1))359+ require.True(t, selectTestAttachmentTab(sess, 1))
360 },360 },
361 },361 },
362 } {362 } {
@@ -367,7 +367,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.
367 release()367 release()
368 }368 }
369 }()369 }()
370- tb := sess.activeTab()370+ tb := testAttachmentTab(sess)
371 p := tb.focusedPane()371 p := tb.focusedPane()
372 p.mu.Lock()372 p.mu.Lock()
373 document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)373 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
26 d, sess, ac, _ := newManualSessionWithPTYs(t, p)26 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
27 clock := &manualMouseClock{now: time.Unix(1, 0)}27 clock := &manualMouseClock{now: time.Unix(1, 0)}
28 d.clock = clock28 d.clock = clock
29- pane := sess.activeTab().focusedPane()29+ pane := testAttachmentTab(sess).focusedPane()
30 for i, row := range rows {30 for i, row := range rows {
31 copy(pane.screen.Frame.Row(i), testRow(row))31 copy(pane.screen.Frame.Row(i), testRow(row))
32 }32 }
@@ -41,7 +41,7 @@ func copyMouseInput(d *Daemon, sess *session, ac *attachedClient, raw string) {
41 func TestCopyDoubleClick(t *testing.T) {41 func TestCopyDoubleClick(t *testing.T) {
42 t.Run("same normalized position within interval selects word", func(t *testing.T) {42 t.Run("same normalized position within interval selects word", func(t *testing.T) {
43 d, sess, ac, clock := mouseCopyHarness(t, "a界 beta")43 d, sess, ac, clock := mouseCopyHarness(t, "a界 beta")
44- pane := sess.activeTab().focusedPane()44+ pane := testAttachmentTab(sess).focusedPane()
45 d.exitCopyMode(ac)45 d.exitCopyMode(ac)
46 pane.mu.Lock()46 pane.mu.Lock()
47 row := pane.screen.Frame.Row(0)47 row := pane.screen.Frame.Row(0)
@@ -324,7 +324,7 @@ func TestCopyWordSelectionYanksExactOSC52(t *testing.T) {
324 d, sess, ac, sends := newManualSessionWithPTYs(t, p)324 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
325 clock := &manualMouseClock{now: time.Unix(1, 0)}325 clock := &manualMouseClock{now: time.Unix(1, 0)}
326 d.clock = clock326 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"))
328 d.enterCopyMode(sess, ac)328 d.enterCopyMode(sess, ac)
329 mustOutputData(t, sends)329 mustOutputData(t, sends)
330 330
@@ -348,11 +348,11 @@ func TestCopyPointerAndClickResetOnCopyExitAndReplacement(t *testing.T) {
348 {name: "escape", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("\x1bx")) }},348 {name: "escape", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("\x1bx")) }},
349 {name: "yank", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("y")) }},349 {name: "yank", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("y")) }},
350 {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) {350 {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) {
351- p := sess.activeTab().focusedPane()351+ p := testAttachmentTab(sess).focusedPane()
352 p.mu.Lock()352 p.mu.Lock()
353 doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)353 doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
354 p.mu.Unlock()354 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))
356 }},356 }},
357 } {357 } {
358 t.Run(tc.name, func(t *testing.T) {358 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
48 ac := &attachedClient{output: newOutputStateStream()}48 ac := &attachedClient{output: newOutputStateStream()}
49 failed := &ownedSwapErrorTransport{ac: ac, replacement: replacement, err: sendErr, sent: make(chan ports.Frame, 1)}49 failed := &ownedSwapErrorTransport{ac: ac, replacement: replacement, err: sendErr, sent: make(chan ports.Frame, 1)}
50 ac.replaceTransport(failed)50 ac.replaceTransport(failed)
51- sess := &session{sessionCore: sessionCore{name: "work", client: ac}}51+ sess := &session{sessionCore: sessionCore{name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}}
52 ac.setSession(sess)52 ac.setSession(sess)
53 53
54 used, err := d.boundedSendOutputErrTransport(ac, []byte("copy"))54 used, err := d.boundedSendOutputErrTransport(ac, []byte("copy"))
@@ -60,7 +60,7 @@ func TestOwnedSynchronousSendReturnsCapturedTransportAcrossReplacement(t *testin
60 require.NoError(t, decodeErr)60 require.NoError(t, decodeErr)
61 require.Equal(t, []byte("copy"), out.Data)61 require.Equal(t, []byte("copy"), out.Data)
62 d.detachOnSendError(sess, ac, used)62 d.detachOnSendError(sess, ac, used)
63- require.Same(t, ac, sess.client)63+ require.Contains(t, sess.snapshotAttachments(), ac)
64 require.False(t, replacement.Closed())64 require.False(t, replacement.Closed())
65 }65 }
66 66
@@ -108,7 +108,7 @@ func TestCopySearchModalGeometry(t *testing.T) {
108 func TestCopyModeDocumentCarriesPaneRowIDs(t *testing.T) {108 func TestCopyModeDocumentCarriesPaneRowIDs(t *testing.T) {
109 p, _ := newBlockingPTY(t)109 p, _ := newBlockingPTY(t)
110 d, sess, ac, sends := newManualSessionWithPTYs(t, p)110 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
111- pane := sess.activeTab().focusedPane()111+ pane := testAttachmentTab(sess).focusedPane()
112 appendHistoryRow(t, pane.history, testRow("history"))112 appendHistoryRow(t, pane.history, testRow("history"))
113 pane.screen.Write([]byte("live"))113 pane.screen.Write([]byte("live"))
114 historyID := pane.history.View().RowID(0)114 historyID := pane.history.View().RowID(0)
@@ -128,7 +128,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
128 p, release := newBlockingPTY(t)128 p, release := newBlockingPTY(t)
129 defer release()129 defer release()
130 _, sess, _, _ := newManualSessionWithPTYs(t, p)130 _, sess, _, _ := newManualSessionWithPTYs(t, p)
131- tb := sess.activeTab()131+ tb := testAttachmentTab(sess)
132 pane := tb.focusedPane()132 pane := tb.focusedPane()
133 pane.mu.Lock()133 pane.mu.Lock()
134 snap := scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds(), nil)134 snap := scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds(), nil)
@@ -162,7 +162,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
162 _, sess, _, _ := newManualSessionWithPTYs(t, p)162 _, sess, _, _ := newManualSessionWithPTYs(t, p)
163 defer release()163 defer release()
164 sess.name = "work"164 sess.name = "work"
165- tb := sess.activeTab()165+ tb := testAttachmentTab(sess)
166 tb.focusedPane().screen = vt.NewScreen(12, 3)166 tb.focusedPane().screen = vt.NewScreen(12, 3)
167 tb.focusedPane().screen.Write([]byte("live"))167 tb.focusedPane().screen.Write([]byte("live"))
168 snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame, tb.focusedPane().screen.LineBounds(), nil)168 snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame, tb.focusedPane().screen.LineBounds(), nil)
@@ -441,7 +441,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
441 if sess == nil {441 if sess == nil {
442 return false442 return false
443 }443 }
444- win := sess.activeTab()444+ win := testAttachmentTab(sess)
445 if win == nil {445 if win == nil {
446 return false446 return false
447 }447 }
@@ -458,7 +458,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
458 458
459 sess := firstSession(d)459 sess := firstSession(d)
460 require.NotNil(t, sess)460 require.NotNil(t, sess)
461- ac := sess.client461+ ac := sess.snapshotAttachments()[0]
462 require.NotNil(t, ac)462 require.NotNil(t, ac)
463 d.handleInput(sess, ac, []byte("\x1b "))463 d.handleInput(sess, ac, []byte("\x1b "))
464 awaitCoordinatorOutput(t, sends, clk.timers, "while advancing render clock", "controllable timers did not produce an output frame")464 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) {
662 p, release := newBlockingPTY(t)662 p, release := newBlockingPTY(t)
663 defer release()663 defer release()
664 d, sess, ac, sends := newManualSessionWithPTYs(t, p)664 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
665- pane := sess.activeTab().focusedPane()665+ pane := testAttachmentTab(sess).focusedPane()
666 pane.screen.Write([]byte("live"))666 pane.screen.Write([]byte("live"))
667 d.enterCopyMode(sess, ac)667 d.enterCopyMode(sess, ac)
668 awaitFrame(t, sends, ports.MsgOutput)668 awaitFrame(t, sends, ports.MsgOutput)
@@ -729,7 +729,7 @@ func installFloatingCopyFixture(t *testing.T, sess *session, size domain.Size) *
729 floatingPTY, releaseFloating := newBlockingPTY(t)729 floatingPTY, releaseFloating := newBlockingPTY(t)
730 t.Cleanup(releaseFloating)730 t.Cleanup(releaseFloating)
731 fp := newPane("floating", floatingPTY, size)731 fp := newPane("floating", floatingPTY, size)
732- installTestFloating(sess.activeTab(), fp, true)732+ installTestFloating(testAttachmentTab(sess), fp, true)
733 return fp733 return fp
734 }734 }
735 735
@@ -748,7 +748,7 @@ func TestCopyModeCapturesSourceAndRetainsItAcrossFocusMove(t *testing.T) {
748 normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites)748 normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites)
749 defer releaseNormal()749 defer releaseNormal()
750 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)750 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
751- tb := sess.activeTab()751+ tb := testAttachmentTab(sess)
752 second := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 5})752 second := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 5})
753 tb.mu.Lock()753 tb.mu.Lock()
754 tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}754 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) {
856 normal, releaseNormal := newBlockingPTY(t)856 normal, releaseNormal := newBlockingPTY(t)
857 defer releaseNormal()857 defer releaseNormal()
858 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)858 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
859- tb := sess.activeTab()859+ tb := testAttachmentTab(sess)
860 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})860 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
861 fp.screen.Write([]byte("flt-live"))861 fp.screen.Write([]byte("flt-live"))
862 862
M internal/usecase/daemon/daemon.go +64 -90
@@ -144,45 +144,41 @@ type Daemon struct {
144 // after publishResizeCommit acquires attachment sendMu and before it acquires144 // after publishResizeCommit acquires attachment sendMu and before it acquires
145 // owner fences. Tests must not perform external work from this callback.145 // owner fences. Tests must not perform external work from this callback.
146 afterResizeCommitSendLocked func()146 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)
156 // afterConnectionSessionSnapshot is a deterministic test seam after the147 // afterConnectionSessionSnapshot is a deterministic test seam after the
157 // connection loop reads its current session and before it captures that role.148 // connection loop reads its current session and before it captures that role.
158 afterConnectionSessionSnapshot func(attachmentSession)149 afterConnectionSessionSnapshot func(attachmentSession)
159- // afterActiveFrameDispatch is a deterministic test seam after the connection150+ // afterAttachmentFrameDispatch is a deterministic test seam after the
160- // loop's first role check and before a decoded active frame takes effect.151+ // connection loop snapshots a token and before a decoded frame takes effect.
161- afterActiveFrameDispatch func(attachmentRoleToken)152+ afterAttachmentFrameDispatch func(attachmentConnectionToken)
162- // afterRoleEffectParticipantsSnapshotted observes immutable role-gate153+ // afterAttachmentEffectParticipantsSnapshotted observes immutable role-gate
163 // participants after architecture preflight locks are released and before154 // participants after architecture preflight locks are released and before
164 // the globally ordered freeze begins.155 // the globally ordered freeze begins.
165- afterRoleEffectParticipantsSnapshotted func(string, []*attachedClient)156+ afterAttachmentEffectParticipantsSnapshotted func(string, []*attachedClient)
166- // afterRoleEffectGateFrozen observes each participant after it is frozen in157+ // afterAttachmentEffectGateFrozen observes each participant after it is frozen in
167 // immutable identity order and before drain/publication continues.158 // immutable identity order and before drain/publication continues.
168- afterRoleEffectGateFrozen func(string, *attachedClient)159+ afterAttachmentEffectGateFrozen func(string, *attachedClient)
169- // afterRoleEffectsFrozen observes the lock-free boundary after all affected160+ // afterAttachmentEffectsFrozen observes the lock-free boundary after all affected
170 // attachment gates are frozen and drained, before architecture publication.161 // attachment gates are frozen and drained, before architecture publication.
171- afterRoleEffectsFrozen func()162+ afterAttachmentEffectsFrozen func()
172- // afterMoveLifecycleReserved and afterMovePaneSourceSnapshot are test-only163+ // afterAttachmentTransitionCoordinatorsLocked is a deterministic lock-order
173- // seams. They run before role/fence work and after the pre-fence source164+ // 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
174 // attachment snapshot respectively. beforeMovePaneCommit runs inside the169 // attachment snapshot respectively. beforeMovePaneCommit runs inside the
175 // non-failing publication section. None is set in production, and none may170 // non-failing publication section. None is set in production, and none may
176 // perform external work while locks are held.171 // perform external work while locks are held.
172+ beforeMoveDispatch func()
177 afterMoveLifecycleReserved func()173 afterMoveLifecycleReserved func()
178 afterMoveLifecycleGateBeforeTeardownLocks func()174 afterMoveLifecycleGateBeforeTeardownLocks func()
179 afterMovePaneSourceSnapshot func()175 afterMovePaneSourceSnapshot func()
180 afterMoveTabSourceSnapshot func()176 afterMoveTabSourceSnapshot func()
181 beforeMovePaneCommit func()177 beforeMovePaneCommit func()
182 beforeMoveTabCommit func()178 beforeMoveTabCommit func()
183- // afterDetachRoleEffectsFrozen observes terminal detach after it wins the179+ // afterDetachAttachmentEffectsFrozen observes terminal detach after it wins the
184 // attachment gate but before it checks session ownership.180 // attachment gate but before it checks session ownership.
185- afterDetachRoleEffectsFrozen func()181+ afterDetachAttachmentEffectsFrozen func()
186 // beforeClientGoneDetach pauses clientGone after the stale-transport182 // beforeClientGoneDetach pauses clientGone after the stale-transport
187 // precheck and before exact transport/incarnation detach validation.183 // precheck and before exact transport/incarnation detach validation.
188 beforeClientGoneDetach func()184 beforeClientGoneDetach func()
@@ -200,49 +196,38 @@ type Daemon struct {
200 // afterParkingWaitArmed observes waitParkingInFlight after it has resolved a196 // afterParkingWaitArmed observes waitParkingInFlight after it has resolved a
201 // matching in-flight entry and before it blocks on done.197 // matching in-flight entry and before it blocks on done.
202 afterParkingWaitArmed func()198 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()
208 // beforeResumeParkedSendMu pauses resumeParked after the initial parked199 // beforeResumeParkedSendMu pauses resumeParked after the initial parked
209 // lookup and before the attachment send lock, so tests can consume or200 // lookup and before the attachment send lock, so tests can consume or
210 // replace the credential while the handshake waits.201 // replace the credential while the handshake waits.
211 beforeResumeParkedSendMu func()202 beforeResumeParkedSendMu func()
212- // afterDisplacedCleanupStarted observes deferred displaced cleanup before it203+ // afterAttachmentEffectAdmitted is a deterministic test seam after a frame/paint
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
219 // reserves its exact capability and before its first observable mutation.204 // reserves its exact capability and before its first observable mutation.
220- afterRoleEffectAdmitted func(attachmentRoleToken)205+ afterAttachmentEffectAdmitted func(attachmentConnectionToken)
221 // beforeFirstPaintSendWait is a test-only seam immediately before a206 // beforeFirstPaintSendWait is a test-only seam immediately before a
222 // transition first paint waits for the attachment send lock.207 // transition first paint waits for the attachment send lock.
223- beforeFirstPaintSendWait func(attachmentRoleToken)208+ beforeFirstPaintSendWait func(attachmentConnectionToken)
224 // afterDelayedKeyEffectAttempt observes whether a timer callback acquired a209 // afterDelayedKeyEffectAttempt observes whether a timer callback acquired a
225 // fresh exact capability before producing PTY, action, or overlay effects.210 // fresh exact capability before producing PTY, action, or overlay effects.
226 afterDelayedKeyEffectAttempt func(bool)211 afterDelayedKeyEffectAttempt func(bool)
227- // afterActionRoleEffectEnded observes action-specific admission release. It212+ // afterActionAttachmentEffectEnded observes action-specific admission release. It
228 // is a deterministic seam for proving no role-bound mutation follows release.213 // is a deterministic seam for proving no role-bound mutation follows release.
229- afterActionRoleEffectEnded func(string)214+ afterActionAttachmentEffectEnded func(string)
230- // beforeRoleSendErrorCleanup pauses asynchronous render-failure retirement215+ // beforeAttachmentSendErrorCleanup pauses asynchronous render-failure retirement
231 // after the render ticket ends and before exact lifecycle validation.216 // after the render ticket ends and before exact lifecycle validation.
232- beforeRoleSendErrorCleanup func(attachmentRoleToken)217+ beforeAttachmentSendErrorCleanup func(attachmentConnectionToken)
233- afterRoleSendErrorCleanup func()218+ afterAttachmentSendErrorCleanup func()
234- ptys ports.PTYFactory219+ ptys ports.PTYFactory
235- clock ports.Clock220+ clock ports.Clock
236- log *slog.Logger221+ log *slog.Logger
237- runtimeObserver ports.RuntimeObserver222+ runtimeObserver ports.RuntimeObserver
238- baseEnv []string223+ baseEnv []string
239- shell string224+ shell string
240- shellArgs []string225+ shellArgs []string
241- shellOverride bool226+ shellOverride bool
242- persistEnabled bool227+ persistEnabled bool
243- catalogue ports.Catalogue228+ catalogue ports.Catalogue
244- catalogueRecords []domain.CatalogueRecord229+ catalogueRecords []domain.CatalogueRecord
245- catalogueRecordsProvided bool230+ catalogueRecordsProvided bool
246 // snapshotRepository is the sole checkpoint storage contract.231 // snapshotRepository is the sole checkpoint storage contract.
247 snapshotRepository ports.SnapshotRepository232 snapshotRepository ports.SnapshotRepository
248 recovery *recoveryusecase.Coordinator233 recovery *recoveryusecase.Coordinator
@@ -339,9 +324,9 @@ type Daemon struct {
339 type parkedAttachment struct {324 type parkedAttachment struct {
340 sess *session325 sess *session
341 ac *attachedClient326 ac *attachedClient
342- role attachmentRole
343 pickerGeneration uint64327 pickerGeneration uint64
344 timer ports.Timer328 timer ports.Timer
329+ claimed bool
345 done chan struct{}330 done chan struct{}
346 doneOnce sync.Once331 doneOnce sync.Once
347 }332 }
@@ -1012,7 +997,7 @@ func (d *Daemon) handleList(tr ports.Transport) {
1012 State: ports.SessionRunning,997 State: ports.SessionRunning,
1013 Ephemeral: s.ephemeral,998 Ephemeral: s.ephemeral,
1014 Tabs: uint16(len(s.tabs)),999 Tabs: uint16(len(s.tabs)),
1015- Attached: s.client != nil,1000+ Attached: len(s.attachments) != 0,
1016 }1001 }
1017 liveNames[s.name] = struct{}{}1002 liveNames[s.name] = struct{}{}
1018 s.mu.Unlock()1003 s.mu.Unlock()
@@ -1119,68 +1104,58 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
1119 expected := ac.transportSnapshot()1104 expected := ac.transportSnapshot()
1120 welcomeToken := sess.attachmentToken(ac, tr)1105 welcomeToken := sess.attachmentToken(ac, tr)
1121 welcomeToken.lease = lease1106 welcomeToken.lease = lease
1122- welcomeTicket, admitted := ac.beginRoleEffect(welcomeToken)1107+ welcomeTicket, admitted := ac.beginAttachmentEffect(welcomeToken)
1123- validRole := welcomeToken.role == attachmentSnatched || welcomeToken.role == attachmentActive && lease != nil1108+ if expected.transport != tr || !admitted || welcomeToken.ac == nil {
1124- if expected.transport != tr || !admitted || !validRole {
1125 if admitted {1109 if admitted {
1126 welcomeTicket.End()1110 welcomeTicket.End()
1127 }1111 }
1128- if welcomeToken.role == attachmentSnatched {1112+ if !d.abortResumeClaim(ac) {
1129- d.parkOrDropSnatchedAttachment(welcomeToken)
1130- } else {
1131 d.clientGone(sess, ac, tr, false)1113 d.clientGone(sess, ac, tr, false)
1132 }1114 }
1133 return1115 return
1134 }1116 }
1135- if err := ac.sendExpectedTransportForRole(expected, frameWelcome(sess, ac), welcomeTicket); err != nil {1117+ if err := ac.sendExpectedTransportForAttachment(expected, frameWelcome(sess, ac), welcomeTicket); err != nil {
1136 welcomeTicket.End()1118 welcomeTicket.End()
1137- if welcomeToken.role == attachmentSnatched {1119+ if !d.abortResumeClaim(ac) {
1138- d.parkOrDropSnatchedAttachment(welcomeToken)1120+ d.clientGone(sess, ac, tr, false)
1139- } else {1121+ }
1122+ return
1123+ }
1124+ if !d.commitResumeClaim(ac) {
1125+ welcomeTicket.End()
1126+ if !d.abortResumeClaim(ac) {
1140 d.clientGone(sess, ac, tr, false)1127 d.clientGone(sess, ac, tr, false)
1141 }1128 }
1142 return1129 return
1143 }1130 }
1144- // Welcome is the only effect allowed to retain the route-time role. Release1131+ // Release Welcome's effect before discovering post-handshake authority so a
1145- // it before discovering post-handshake authority so a replacement blocked1132+ // replacement blocked behind the send can publish its generation and lease.
1146- // behind the send can publish its generation and lease first.
1147 welcomeTicket.End()1133 welcomeTicket.End()
1148- postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentRoleEffect(sess, tr)1134+ postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentAttachmentEffect(sess, tr)
1149 if !admitted {1135 if !admitted {
1150 d.clientGone(sess, ac, tr, false)1136 d.clientGone(sess, ac, tr, false)
1151 return1137 return
1152 }1138 }
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- }
1164 postWelcomeLease := postWelcomeToken.lease1139 postWelcomeLease := postWelcomeToken.lease
1165- if postWelcomeToken.role != attachmentActive || rc == nil || postWelcomeLease == nil {1140+ if postWelcomeToken.ac == nil {
1166 postWelcomeTicket.End()1141 postWelcomeTicket.End()
1167 d.clientGone(sess, ac, tr, false)1142 d.clientGone(sess, ac, tr, false)
1168 return1143 return
1169 }1144 }
1170 if ac.proxied {1145 if ac.proxied {
1171- meta, ok := sess.sessionMetaSnapshot()1146+ meta, ok := sess.sessionMetaSnapshotFor(ac)
1172 metaFrame, metaErr := frameSessionMeta(meta)1147 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 {
1174 postWelcomeTicket.End()1149 postWelcomeTicket.End()
1175 d.clientGone(sess, ac, tr, false)1150 d.clientGone(sess, ac, tr, false)
1176 return1151 return
1177 }1152 }
1178 ac.markSessionMetaSent(meta)1153 ac.markSessionMetaSent(meta)
1179 }1154 }
1180- if !rc.markAttachmentReady(postWelcomeLease) {1155+ if postWelcomeLease != nil && (rc == nil || !rc.markAttachmentReady(postWelcomeLease)) {
1181 postWelcomeTicket.End()1156 postWelcomeTicket.End()
1182- // The attachment was displaced or detached while Welcome was in flight;1157+ // The attachment was detached while Welcome was in flight; never let
1183- // never let this stale handshake emit an Output frame.1158+ // this stale handshake emit an Output frame.
1184 d.clientGone(sess, ac, tr, false)1159 d.clientGone(sess, ac, tr, false)
1185 return1160 return
1186 }1161 }
@@ -1223,10 +1198,9 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
1223 ac := d.prepareAttachedClientLocked(tr, sz, opts)1198 ac := d.prepareAttachedClientLocked(tr, sz, opts)
1224 d.mu.Unlock()1199 d.mu.Unlock()
1225 result, err := d.transitionAttachment(attachmentTransitionRequest{1200 result, err := d.transitionAttachment(attachmentTransitionRequest{
1226- target: sess,1201+ target: sess,
1227- next: ac,1202+ next: ac,
1228- expectedRole: attachmentDetached,1203+
1229- targetRole: attachmentActive,
1230 expectedTransport: ac.transportSnapshot(),1204 expectedTransport: ac.transportSnapshot(),
1231 ready: false,1205 ready: false,
1232 })1206 })
M internal/usecase/daemon/daemon_test.go +95 -4
@@ -413,7 +413,7 @@ func newManualSessionWithPTYsClockCleanup(t testing.TB, clock ports.Clock, regis
413 }413 }
414 tabs = append(tabs, tb)414 tabs = append(tabs, tb)
415 }415 }
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}
417 for _, tb := range tabs {417 for _, tb := range tabs {
418 publishTiledPaneOwners(sess, tb)418 publishTiledPaneOwners(sess, tb)
419 }419 }
@@ -439,10 +439,101 @@ func newManualTabSession(t *testing.T, n int) (*Daemon, *session, *attachedClien
439 return d, sess, ac, sends, releases439 return d, sess, ac, sends, releases
440 }440 }
441 441
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+ }
443 sess.mu.Lock()534 sess.mu.Lock()
444 defer sess.mu.Unlock()535 defer sess.mu.Unlock()
445- return sess.active536+ return testAttachmentTabIndexLocked(sess)
446 }537 }
447 538
448 func testRow(text string) []renderer.Cell {539 func testRow(text string) []renderer.Cell {
@@ -537,7 +628,7 @@ func TestHandleHelloDefersFreshOutputUntilWelcome(t *testing.T) {
537 require.Equal(t, ports.MsgWelcome, welcome.Type)628 require.Equal(t, ports.MsgWelcome, welcome.Type)
538 sess := firstSession(d)629 sess := firstSession(d)
539 sess.mu.Lock()630 sess.mu.Lock()
540- ac := sess.client631+ ac := sess.snapshotAttachmentsLocked()[0]
541 sess.mu.Unlock()632 sess.mu.Unlock()
542 require.NotNil(t, ac, "route must publish the attachment before Welcome returns")633 require.NotNil(t, ac, "route must publish the attachment before Welcome returns")
543 634
M internal/usecase/daemon/floating.go +38 -23
@@ -178,14 +178,8 @@ func (d *Daemon) activateTabAfterResizeForLease(sess *session, tb *tab, outerRes
178 if d == nil || sess == nil || tb == nil {178 if d == nil || sess == nil || tb == nil {
179 return false179 return false
180 }180 }
181- // A headless session has no actual terminal destination. Deferring warmup181+ // A headless session has no attachment-local copy mode to clear. Keep its
182- // until firstPaint keeps restored tabs cold and avoids launching children182+ // geometry work session-wide instead of choosing an arbitrary attachment.
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- }
189 if rc := sess.renderCoordinator(); rc != nil && rc.opts.onActivateTabAfterResize != nil {183 if rc := sess.renderCoordinator(); rc != nil && rc.opts.onActivateTabAfterResize != nil {
190 rc.opts.onActivateTabAfterResize(lease != nil)184 rc.opts.onActivateTabAfterResize(lease != nil)
191 }185 }
@@ -195,11 +189,24 @@ func (d *Daemon) activateTabAfterResizeForLease(sess *session, tb *tab, outerRes
195 return false189 return false
196 }190 }
197 }191 }
198- d.exitCopyMode(ac)192+ d.ensureFloatingWarm(sess, tb)
199 if ac == nil {193 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+ }
200 return false207 return false
201 }208 }
202- d.ensureFloatingWarm(sess, tb)209+ d.exitCopyMode(ac)
203 if outerResizeAccepted {210 if outerResizeAccepted {
204 return false211 return false
205 }212 }
@@ -222,7 +229,10 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error {
222 if d == nil || sess == nil {229 if d == nil || sess == nil {
223 return domain.UserErr(domain.NoticeFloatingSpawn, "couldn't open floating pane: no active session", nil)230 return domain.UserErr(domain.NoticeFloatingSpawn, "couldn't open floating pane: no active session", nil)
224 }231 }
225- tb := sess.activeTab()232+ tb := sess.tabForAttachment(ac)
233+ if ac == nil {
234+ tb = sess.firstTab()
235+ }
226 if tb == nil {236 if tb == nil {
227 return domain.UserErr(domain.NoticeFloatingSpawn, "couldn't open floating pane: no active tab", layout.ErrNotFound)237 return domain.UserErr(domain.NoticeFloatingSpawn, "couldn't open floating pane: no active tab", layout.ErrNotFound)
228 }238 }
@@ -478,13 +488,19 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin
478 d.reapplyThemeSession(sess)488 d.reapplyThemeSession(sess)
479 d.startPaneGoroutines(sess, tb, p)489 d.startPaneGoroutines(sess, tb, p)
480 if visible {490 if visible {
481- sess.mu.Lock()491+ attachments := sess.snapshotAttachments()
482- ac := sess.client492+ if len(attachments) == 0 {
483- sess.mu.Unlock()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+ }
484 tb.mu.Lock()498 tb.mu.Lock()
485 size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}499 size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
486 tb.mu.Unlock()500 tb.mu.Unlock()
487- d.requestTransactionalResize(sess, ac, size, true)501+ for _, ac := range attachments {
502+ d.requestTransactionalResize(sess, ac, size, true)
503+ }
488 }504 }
489 }505 }
490 506
@@ -515,15 +531,14 @@ func (d *Daemon) reapInstalledFloating(p *pane) {
515 return531 return
516 }532 }
517 closeFloatingPane(p)533 closeFloatingPane(p)
518- sess.mu.Lock()534+ attachments := sess.snapshotAttachments()
519- ac := sess.client535+ copyCleared := false
520- sess.mu.Unlock()536+ for _, ac := range attachments {
521- if ac != nil {
522 ac.pruneCaptureFrames(p)537 ac.pruneCaptureFrames(p)
523- }538+ copyCleared = ac.overlays.clearCopyModeForPane(p) || copyCleared
524- copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p)539+ if visible || copyCleared {
525- if ac != nil && (visible || copyCleared) {540+ d.invalidateRender(sess, ac, true, "floating.go")
526- d.invalidateRender(sess, ac, true, "floating.go")541+ }
527 }542 }
528 return543 return
529 }544 }
M internal/usecase/daemon/floating_open_test.go +3 -3
@@ -130,11 +130,11 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {
130 tb.mu.Unlock()130 tb.mu.Unlock()
131 require.NotNil(t, floating)131 require.NotNil(t, floating)
132 sess.mu.Lock()132 sess.mu.Lock()
133- sess.client = &attachedClient{}133+ sess.registerAttachmentLocked(&attachedClient{})
134 sess.mu.Unlock()134 sess.mu.Unlock()
135 require.Equal(t, tt.desiredVisible, d.paneRenderable(sess, tb, floating), "installed popup renderability must follow its retained visibility")135 require.Equal(t, tt.desiredVisible, d.paneRenderable(sess, tb, floating), "installed popup renderability must follow its retained visibility")
136 sess.mu.Lock()136 sess.mu.Lock()
137- sess.client = nil137+ clearAttachmentsForTestLocked(sess)
138 sess.mu.Unlock()138 sess.mu.Unlock()
139 select {139 select {
140 case <-pty.readerStarted:140 case <-pty.readerStarted:
@@ -228,7 +228,7 @@ func TestFloatingOpenErrorReleasesReturnedPTYBeforePublishingFailure(t *testing.
228 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}228 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
229 ac.initOverlays()229 ac.initOverlays()
230 sessCtx, cancelSession := context.WithCancel(t.Context())230 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}
232 ac.setSession(sess)232 ac.setSession(sess)
233 233
234 // Close must be safe to run before failure publication and without234 // 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) {
595 installTestFloating(tb, p, true)595 installTestFloating(tb, p, true)
596 ac := &attachedClient{}596 ac := &attachedClient{}
597 ac.initOverlays()597 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}}
599 base := barState{}599 base := barState{}
600 cfg := domain.FloatingConfig{Width: 100, Height: 100}600 cfg := domain.FloatingConfig{Width: 100, Height: 100}
601 601
@@ -617,7 +617,7 @@ func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
617 cache := composeCacheInput{}617 cache := composeCacheInput{}
618 for range 500 {618 for range 500 {
619 ac.sendMu.Lock()619 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})
621 ac.sendMu.Unlock()621 ac.sendMu.Unlock()
622 require.True(t, ok)622 require.True(t, ok)
623 out := composeFrame(*state, cache)623 out := composeFrame(*state, cache)
M internal/usecase/daemon/floating_test.go +4 -4
@@ -406,8 +406,8 @@ func TestFloatingExitUsesCurrentOwnerAfterTabTransfer(t *testing.T) {
406 sourceClient.initOverlays()406 sourceClient.initOverlays()
407 destinationClient := &attachedClient{captureFrames: map[*pane]capturedPaneRenderState{floating: {}}}407 destinationClient := &attachedClient{captureFrames: map[*pane]capturedPaneRenderState{floating: {}}}
408 destinationClient.initOverlays()408 destinationClient.initOverlays()
409- source.client = sourceClient409+ source.registerAttachment(sourceClient)
410- destination.client = destinationClient410+ destination.registerAttachment(destinationClient)
411 411
412 floating.onExit()412 floating.onExit()
413 d.sessWg.Wait()413 d.sessWg.Wait()
@@ -485,7 +485,7 @@ func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) {
485 t.Helper()485 t.Helper()
486 normalPTY, releaseNormal := newBlockingPTY(t)486 normalPTY, releaseNormal := newBlockingPTY(t)
487 d, sess, ac, sends := newManualSessionWithPTYs(t, normalPTY)487 d, sess, ac, sends := newManualSessionWithPTYs(t, normalPTY)
488- tb := sess.activeTab()488+ tb := testAttachmentTab(sess)
489 require.NotNil(t, tb)489 require.NotNil(t, tb)
490 tb.mu.Lock()490 tb.mu.Lock()
491 normal := tb.focusedPane()491 normal := tb.focusedPane()
@@ -770,7 +770,7 @@ func TestFloatingAsyncSpawnFailureToastsOnlyForUserOpen(t *testing.T) {
770 tr, _ := newCapturingTransport(t)770 tr, _ := newCapturingTransport(t)
771 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}771 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
772 ac.initOverlays()772 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()}
774 ac.setSession(sess)774 ac.setSession(sess)
775 775
776 d.startFloating(sess, tb, tc.userOpen)776 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
20 d.handleInput(sess, ac, data)20 d.handleInput(sess, ac, data)
21 }21 }
22 22
23-func (d *Daemon) handleSequencedInputForRole(token attachmentRoleToken, _ uint64, data []byte) {23+func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionToken, _ uint64, data []byte) {
24- if !token.activeEffect() {24+ if !token.attachmentEffectCurrent() {
25 return25 return
26 }26 }
27- d.handleInputForRole(token, data)27+ d.handleInputForAttachment(token, data)
28 }28 }
29 29
30 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {30 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
@@ -40,19 +40,19 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
40 )40 )
41 }41 }
42 42
43-func (d *Daemon) handleInputForRole(token attachmentRoleToken, data []byte) {43+func (d *Daemon) handleInputForAttachment(token attachmentConnectionToken, data []byte) {
44 ac := token.ac44 ac := token.ac
45 ac.initOverlays()45 ac.initOverlays()
46 if proxy, ok := token.sess.(*proxySession); ok {46 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}
48 ac.mouseScan.Scan(data,48 ac.mouseScan.Scan(data,
49 func(ev mouse.Event) {49 func(ev mouse.Event) {
50- if token.activeEffect() && !proxyMouseOwnedLocally(ac.overlays) {50+ if token.attachmentEffectCurrent() && !proxyMouseOwnedLocally(ac.overlays) {
51 handler.Mouse(ev)51 handler.Mouse(ev)
52 }52 }
53 },53 },
54 func(b []byte) {54 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() {
56 return56 return
57 }57 }
58 ac.keys.RouteWithHandler(b, handler)58 ac.keys.RouteWithHandler(b, handler)
@@ -62,26 +62,30 @@ func (d *Daemon) handleInputForRole(token attachmentRoleToken, data []byte) {
62 }62 }
63 ac.mouseScan.Scan(data,63 ac.mouseScan.Scan(data,
64 func(ev mouse.Event) {64 func(ev mouse.Event) {
65- if token.activeEffect() {65+ if token.attachmentEffectCurrent() {
66 d.handleMouse(ac, ev)66 d.handleMouse(ac, ev)
67 }67 }
68 },68 },
69 func(b []byte) {69 func(b []byte) {
70- if !token.activeEffect() {70+ if !token.attachmentEffectCurrent() {
71 return71 return
72 }72 }
73 if ac.overlays.HandleInput(d, b, token.effect) {73 if ac.overlays.HandleInput(d, b, token.effect) {
74 return74 return
75 }75 }
76- if !token.activeEffect() {76+ if !token.attachmentEffectCurrent() {
77 return77 return
78 }78 }
79- ac.keys.RouteWithHandler(b, daemonKeyHandler{d: d, ac: ac, roleToken: token})79+ ac.keys.RouteWithHandler(b, daemonKeyHandler{d: d, ac: ac, connectionToken: token})
80 },80 },
81 )81 )
82 }82 }
83 83
84 func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {84 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) {
85 frameEvent := ev89 frameEvent := ev
86 ac.initOverlays()90 ac.initOverlays()
87 rt := ac.overlays91 rt := ac.overlays
@@ -93,7 +97,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
93 invalidateRejectedLeftPointer(rt, ev)97 invalidateRejectedLeftPointer(rt, ev)
94 return98 return
95 }99 }
96- tb := sess.activeTab()100+ tb := sess.tabForAttachment(ac)
97 if tb == nil {101 if tb == nil {
98 invalidateRejectedLeftPointer(rt, ev)102 invalidateRejectedLeftPointer(rt, ev)
99 return103 return
@@ -108,97 +112,157 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
108 return112 return
109 }113 }
110 114
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
112 contentRow := ev.Row - clientTopBarRows152 contentRow := ev.Row - clientTopBarRows
153+ tb.mu.Lock()
113 floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())154 floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
114 if floatingVisible {155 if floatingVisible {
115 if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) {156 if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) {
116 tb.mu.Unlock()157 tb.mu.Unlock()
117- invalidateRejectedLeftPointer(rt, ev)158+ route.handled = true
118- return159+ return route
119 }160 }
120 tb.mu.Unlock()161 tb.mu.Unlock()
121- if ev.Button == mouse.Left && ev.Type == mouse.Press && d.handleFreshCopyPress(sess, ac, tb, floating, frameEvent) {162+ route.p = floating
122- return163+ route.event = translateMouseEvent(ev, floatingGeometry.Inner.X, floatingGeometry.Inner.Y)
123- }164+ route.translated = true
124- ev = translateMouseEvent(ev, floatingGeometry.Inner.X, floatingGeometry.Inner.Y)165+ return route
125- d.handleTerminalMouse(sess, ac, floating, ev, true, true)
126- return
127 }166 }
128 pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow)167 pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow)
129 multi := len(tb.panes) > 1168 multi := len(tb.panes) > 1
130- focusedID := layout.PaneID("")169+ focusedID := mouseFocusedPaneIDLocked(ac, tb)
131- if tb.tree != nil {
132- focusedID = tb.tree.Focus
133- }
134 if hit && pointInRect(ev.Col, contentRow, pl.TitleBar) {170 if hit && pointInRect(ev.Col, contentRow, pl.TitleBar) {
135 if !isMouseFocusPress(ev) {171 if !isMouseFocusPress(ev) {
136 tb.mu.Unlock()172 tb.mu.Unlock()
137- invalidateRejectedLeftPointer(rt, ev)173+ route.handled = true
138- return174+ return route
139 }175 }
140- oldFocus := focusedID
141- layoutChanged := focusPlacementLocked(tb, pl.ID)
142 tb.mu.Unlock()176 tb.mu.Unlock()
143- if layoutChanged {177+ _, focused := d.focusMousePane(sess, ac, tb, pl.ID)
144- d.applyTabLayout(sess, tb)178+ if focused == nil {
179+ route.handled = true
180+ return route
145 }181 }
146- // A title bar never routes to terminal content. Clear any pre-existing182+ invalidateRejectedLeftPointer(ac.overlays, ev)
147- // left-button candidate before handling the focus result, including when183+ if pl.ID != focusedID {
148- // this press leaves the same pane focused.
149- invalidateRejectedLeftPointer(rt, ev)
150- if pl.ID != oldFocus {
151 d.exitCopyMode(ac)184 d.exitCopyMode(ac)
152 d.refreshPaneTitleOnFocus(sess, pl.ID)185 d.refreshPaneTitleOnFocus(sess, pl.ID)
153 }186 }
154 d.invalidateRender(sess, ac, true, "input.go")187 d.invalidateRender(sess, ac, true, "input.go")
155- return188+ route.handled = true
189+ return route
156 }190 }
157- var p *pane
158- translated := false
159- hoveredFocused := true
160 if hit && !pl.Collapsed && pointInRect(ev.Col, contentRow, pl.Content) {191 if hit && !pl.Collapsed && pointInRect(ev.Col, contentRow, pl.Content) {
161 oldFocus := focusedID192 oldFocus := focusedID
162- layoutChanged := false193+ shouldFocus := isMouseFocusPress(ev)
163- if isMouseFocusPress(ev) {194+ p := tb.panes[pl.ID]
164- layoutChanged = focusPlacementLocked(tb, pl.ID)195+ hoveredFocused := pl.ID == oldFocus
165- }
166- p = tb.panes[pl.ID]
167- hoveredFocused = pl.ID == oldFocus
168 tb.mu.Unlock()196 tb.mu.Unlock()
169- if layoutChanged {197+ if shouldFocus {
170- d.applyTabLayout(sess, tb)198+ _, p = d.focusMousePane(sess, ac, tb, pl.ID)
171 }199 }
172 if p == nil {200 if p == nil {
173- invalidateRejectedLeftPointer(rt, ev)201+ route.handled = true
174- return202+ return route
175 }203 }
176- if isMouseFocusPress(ev) && pl.ID != oldFocus {204+ if shouldFocus && pl.ID != oldFocus {
177 d.exitCopyMode(ac)205 d.exitCopyMode(ac)
178 d.refreshPaneTitleOnFocus(sess, pl.ID)206 d.refreshPaneTitleOnFocus(sess, pl.ID)
179 d.invalidateRender(sess, ac, true, "input.go")207 d.invalidateRender(sess, ac, true, "input.go")
180 }208 }
209+ route.p = p
210+ route.hoveredFocused = hoveredFocused
181 if multi {211 if multi {
182- ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)212+ route.event = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)
183- translated = true213+ route.translated = true
184- }
185- } else {
186- if multi {
187- tb.mu.Unlock()
188- invalidateRejectedLeftPointer(rt, ev)
189- return
190 }214 }
191- p = tb.focusedPane()215+ return route
216+ }
217+ if multi {
192 tb.mu.Unlock()218 tb.mu.Unlock()
193- if p == nil {219+ route.handled = true
194- invalidateRejectedLeftPointer(rt, ev)220+ return route
195- return221+ }
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+ }
196 }236 }
197 }237 }
198- if ev.Button == mouse.Left && ev.Type == mouse.Press && d.handleFreshCopyPress(sess, ac, tb, p, frameEvent) {238+ if tb == nil || tb.tree == nil {
199- return239+ 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
200 }258 }
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
202 }266 }
203 267
204 func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) {268 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,
215 if !mouseSGR || ev.Row == 0 || ev.Row > childRows {279 if !mouseSGR || ev.Row == 0 || ev.Row > childRows {
216 return280 return
217 }281 }
218- if translated {282+ _ = sess.runMutation(func() error {
219- d.writeToPane(sess, p, ev.Raw)283+ if translated {
220- } else {284+ d.writeToPane(sess, p, ev.Raw)
221- d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -clientTopBarRows))285+ } else {
222- }286+ d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -clientTopBarRows))
287+ }
288+ return nil
289+ })
223 return290 return
224 }291 }
225 292
226 switch ev.Button {293 switch ev.Button {
227 case mouse.WheelUp:294 case mouse.WheelUp:
228 if altScreen {295 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+ })
230 return300 return
231 }301 }
232 if !hoveredFocused {302 if !hoveredFocused {
@@ -236,7 +306,10 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
236 d.copyWheel(sess, ac, -3)306 d.copyWheel(sess, ac, -3)
237 case mouse.WheelDown:307 case mouse.WheelDown:
238 if altScreen {308 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+ })
240 }313 }
241 }314 }
242 }315 }
@@ -315,34 +388,34 @@ func sgrOffset(raw []byte, colDelta, rowDelta int) []byte {
315 }388 }
316 389
317 type daemonKeyHandler struct {390 type daemonKeyHandler struct {
318- d *Daemon391+ d *Daemon
319- ac *attachedClient392+ ac *attachedClient
320- actions daemonActionRunner393+ actions daemonActionRunner
321- roleToken attachmentRoleToken394+ connectionToken attachmentConnectionToken
322 }395 }
323 396
324-// acquireRoleEffect preserves a synchronous frame's existing admission and397+// acquireAttachmentEffect preserves a synchronous frame's existing admission and
325-// gives delayed router callbacks a fresh ticket for the exact captured role.398+// gives delayed router callbacks a fresh ticket for the exact captured attachment.
326-// Direct/headless callers without a role token retain their existing behavior.399+// Direct/headless callers without an attachment token retain their existing behavior.
327-func (h daemonKeyHandler) acquireRoleEffect() (*session, *roleEffectTicket, bool) {400+func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) {
328- if h.roleToken.ac != nil {401+ if h.connectionToken.ac != nil {
329- if effect := h.roleToken.effect; effect != nil && !effect.ended.Load() {402+ if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {
330- sess, _ := localSession(h.roleToken.sess)403+ sess, _ := localSession(h.connectionToken.sess)
331 return sess, effect, false404 return sess, effect, false
332 }405 }
333- effect, admitted := h.roleToken.ac.beginRoleEffect(h.roleToken)406+ effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken)
334 if h.d.afterDelayedKeyEffectAttempt != nil {407 if h.d.afterDelayedKeyEffectAttempt != nil {
335 h.d.afterDelayedKeyEffectAttempt(admitted)408 h.d.afterDelayedKeyEffectAttempt(admitted)
336 }409 }
337 if !admitted {410 if !admitted {
338 return nil, nil, false411 return nil, nil, false
339 }412 }
340- if h.d.afterRoleEffectAdmitted != nil {413+ if h.d.afterAttachmentEffectAdmitted != nil {
341- token := h.roleToken414+ token := h.connectionToken
342 token.effect = effect415 token.effect = effect
343- h.d.afterRoleEffectAdmitted(token)416+ h.d.afterAttachmentEffectAdmitted(token)
344 }417 }
345- sess, ok := localSession(h.roleToken.sess)418+ sess, ok := localSession(h.connectionToken.sess)
346 if !ok {419 if !ok {
347 effect.End()420 effect.End()
348 return nil, nil, false421 return nil, nil, false
@@ -350,32 +423,35 @@ func (h daemonKeyHandler) acquireRoleEffect() (*session, *roleEffectTicket, bool
350 return sess, effect, true423 return sess, effect, true
351 }424 }
352 sess := h.ac.currentSession()425 sess := h.ac.currentSession()
353- if sess == nil || sess.attachmentRole(h.ac) == attachmentSnatched {426+ if sess == nil {
354 return nil, nil, false427 return nil, nil, false
355 }428 }
356 return sess, nil, false429 return sess, nil, false
357 }430 }
358 431
359 func (h daemonKeyHandler) Forward(data []byte) {432 func (h daemonKeyHandler) Forward(data []byte) {
360- sess, effect, owned := h.acquireRoleEffect()433+ sess, effect, owned := h.acquireAttachmentEffect()
361 if sess == nil {434 if sess == nil {
362 return435 return
363 }436 }
364 if owned {437 if owned {
365 defer effect.End()438 defer effect.End()
366 }439 }
367- tb := sess.activeTab()440+ _ = sess.runMutation(func() error {
368- if tb == nil {441+ tb := sess.tabForAttachment(h.ac)
369- return442+ if tb == nil {
370- }443+ return nil
371- tb.mu.Lock()444+ }
372- p := tb.terminalTargetLocked()445+ tb.mu.Lock()
373- tb.mu.Unlock()446+ p := tb.terminalTargetLocked()
374- h.d.writeToPane(sess, p, data)447+ tb.mu.Unlock()
448+ h.d.writeToPane(sess, p, data)
449+ return nil
450+ })
375 }451 }
376 452
377 func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {453 func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
378- sess, effect, owned := h.acquireRoleEffect()454+ sess, effect, owned := h.acquireAttachmentEffect()
379 if sess == nil {455 if sess == nil {
380 return456 return
381 }457 }
@@ -383,12 +459,15 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
383 defer effect.End()459 defer effect.End()
384 }460 }
385 runAction := func(request daemonActionRequest) {461 runAction := func(request daemonActionRequest) {
386- request.target = resolveDaemonActionTarget(sess)462+ request.effect = effect
387 runner := h.actions463 runner := h.actions
388 if runner == nil {464 if runner == nil {
389 runner = daemonActions{d: h.d}465 runner = daemonActions{d: h.d}
390 }466 }
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 {
392 if errors.Is(err, errDaemonActionNoChange) {471 if errors.Is(err, errDaemonActionNoChange) {
393 return472 return
394 }473 }
@@ -408,10 +487,13 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
408 h.d.enterPalette(sess, h.ac)487 h.d.enterPalette(sess, h.ac)
409 case keys.ActionJumpAttention:488 case keys.ActionJumpAttention:
410 if !proxiedJumpSearchesOtherSessions(h.ac.proxied) {489 if !proxiedJumpSearchesOtherSessions(h.ac.proxied) {
411- if idx, ok := oldestAttentionTab(sess); ok && sess.switchTab(idx) {490+ _ = sess.runMutation(func() error {
412- h.d.activateTab(sess, sess.activeTab())491+ if idx, ok := oldestAttentionTab(sess); ok && sess.switchAttachmentTabForDispatch(h.ac, idx) {
413- h.d.invalidateRender(sess, h.ac, true, "input.go")492+ h.d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(h.ac), false, h.ac, nil)
414- }493+ h.d.invalidateRender(sess, h.ac, true, "input.go")
494+ }
495+ return nil
496+ })
415 return497 return
416 }498 }
417 if effect == nil {499 if effect == nil {
@@ -421,13 +503,13 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
421 return503 return
422 }504 }
423 effect.bindActionEnd(h.d, "jump-attention")505 effect.bindActionEnd(h.d, "jump-attention")
424- token := h.roleToken506+ token := h.connectionToken
425 token.effect = effect507 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 {
427 h.d.reportError(sess, err)509 h.d.reportError(sess, err)
428 }510 }
429 case keys.ActionToggleFloatingPane:511 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 {
431 h.d.log.Warn("toggle floating pane failed", "err", err)513 h.d.log.Warn("toggle floating pane failed", "err", err)
432 h.d.reportError(sess, err)514 h.d.reportError(sess, err)
433 }515 }
@@ -457,14 +539,17 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
457 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,539 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
458 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:540 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
459 idx := int(action - keys.ActionSwitchTab1)541 idx := int(action - keys.ActionSwitchTab1)
460- if sess.switchTab(idx) {542+ _ = sess.runMutation(func() error {
461- h.d.activateTab(sess, sess.activeTab())543+ if sess.switchAttachmentTabForDispatch(h.ac, idx) {
462- h.d.invalidateRender(sess, h.ac, true, "input.go")544+ h.d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(h.ac), false, h.ac, nil)
463- }545+ h.d.invalidateRender(sess, h.ac, true, "input.go")
546+ }
547+ return nil
548+ })
464 }549 }
465 }550 }
466 551
467-func (h daemonKeyHandler) focus(sess *session, dir layout.Direction, effect *roleEffectTicket) {552+func (h daemonKeyHandler) focus(sess *session, dir layout.Direction, effect *attachmentEffectTicket) {
468 var err error553 var err error
469 if h.ac.proxied {554 if h.ac.proxied {
470 err = h.d.focusDirProxied(sess, h.ac, dir)555 err = h.d.focusDirProxied(sess, h.ac, dir)
M internal/usecase/daemon/input_test.go +96 -288
@@ -27,6 +27,59 @@ import (
27 27
28 // --- test doubles -----------------------------------------------------------28 // --- test doubles -----------------------------------------------------------
29 29
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+
30 func TestConfiguredConsumeOrExpelActionsRouteThroughDaemonInput(t *testing.T) {83 func TestConfiguredConsumeOrExpelActionsRouteThroughDaemonInput(t *testing.T) {
31 tests := []struct {84 tests := []struct {
32 name string85 name string
@@ -53,7 +106,7 @@ func TestConfiguredConsumeOrExpelActionsRouteThroughDaemonInput(t *testing.T) {
53 ac.setSession(h.session)106 ac.setSession(h.session)
54 ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings)107 ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings)
55 h.session.mu.Lock()108 h.session.mu.Lock()
56- h.session.client = ac109+ h.session.registerAttachmentLocked(ac)
57 h.session.mu.Unlock()110 h.session.mu.Unlock()
58 invalidations := make(chan renderInvalidation, 1)111 invalidations := make(chan renderInvalidation, 1)
59 rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})112 rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
@@ -82,7 +135,7 @@ func TestConfiguredConsumeOrExpelEdgeActionIsSilent(t *testing.T) {
82 ac.setSession(h.session)135 ac.setSession(h.session)
83 ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings)136 ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings)
84 h.session.mu.Lock()137 h.session.mu.Lock()
85- h.session.client = ac138+ h.session.registerAttachmentLocked(ac)
86 h.session.mu.Unlock()139 h.session.mu.Unlock()
87 invalidations := make(chan renderInvalidation, 1)140 invalidations := make(chan renderInvalidation, 1)
88 rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})141 rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
@@ -129,254 +182,9 @@ func TestConsumeOrExpelKeyActionPreservesRearrangementWarning(t *testing.T) {
129 }182 }
130 }183 }
131 184
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-
313 // stubClock returns timers whose channel never fires, so a scheduler under it185 // stubClock returns timers whose channel never fires, so a scheduler under it
314 // blocks in its debounce loop until the session context is cancelled. Used by186 // blocks in its debounce loop until the session context is cancelled. Used by
315 187
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-
380 func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) {188 func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) {
381 d, sess, ac, sends := newManualSessionWithPTYs(t, nil, nil)189 d, sess, ac, sends := newManualSessionWithPTYs(t, nil, nil)
382 source := sess.tabs[0].focusedPane()190 source := sess.tabs[0].focusedPane()
@@ -401,7 +209,7 @@ func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) {
401 // Use the real key action: it requests the mandatory complete repaint for209 // Use the real key action: it requests the mandatory complete repaint for
402 // the first target-tab frame.210 // the first target-tab frame.
403 daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2, nil)211 daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2, nil)
404- require.Equal(t, 1, activeTabIndex(sess))212+ require.Equal(t, 1, testAttachmentTabIndex(sess))
405 213
406 second := awaitFrame(t, sends, ports.MsgOutput)214 second := awaitFrame(t, sends, ports.MsgOutput)
407 secondOutput, err := ports.UnmarshalOutput(second.Payload)215 secondOutput, err := ports.UnmarshalOutput(second.Payload)
@@ -496,7 +304,7 @@ func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) {
496 floating := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 5})304 floating := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 5})
497 floating.ctx = floatingCtx305 floating.ctx = floatingCtx
498 floating.screen.Write([]byte("popup-content"))306 floating.screen.Write([]byte("popup-content"))
499- installTestFloating(sess.activeTab(), floating, false)307+ installTestFloating(testAttachmentTab(sess), floating, false)
500 308
501 // Establish the client shadow while the retained popup is hidden.309 // Establish the client shadow while the retained popup is hidden.
502 d.paint(sess, ac, true, nil)310 d.paint(sess, ac, true, nil)
@@ -518,7 +326,7 @@ func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) {
518 d.handleInput(sess, ac, []byte("\x1bf"))326 d.handleInput(sess, ac, []byte("\x1bf"))
519 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))327 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
520 require.NotContains(t, strings.Join(frameRows(client.Frame), "\n"), "popup-content")328 require.NotContains(t, strings.Join(frameRows(client.Frame), "\n"), "popup-content")
521- tb := sess.activeTab()329+ tb := testAttachmentTab(sess)
522 tb.mu.Lock()330 tb.mu.Lock()
523 require.Equal(t, floatingHidden, tb.floating.state)331 require.Equal(t, floatingHidden, tb.floating.state)
524 require.Same(t, floating, tb.floating.pane)332 require.Same(t, floating, tb.floating.pane)
@@ -586,7 +394,7 @@ func TestFloatingKeyboardRoutesVisibleAndHiddenInput(t *testing.T) {
586 defer releaseNormal()394 defer releaseNormal()
587 defer releaseFloating()395 defer releaseFloating()
588 d, sess, ac, _ := newManualSessionWithPTYs(t, normal)396 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)
590 398
591 daemonKeyHandler{d: d, ac: ac}.Forward(tc.input)399 daemonKeyHandler{d: d, ac: ac}.Forward(tc.input)
592 if tc.visible {400 if tc.visible {
@@ -608,7 +416,7 @@ func TestFloatingBracketedPasteAndGlobalActions(t *testing.T) {
608 defer releaseNormal()416 defer releaseNormal()
609 defer releaseFloating()417 defer releaseFloating()
610 d, sess, ac, _ := newManualSessionWithPTYs(t, normal)418 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)
612 420
613 paste := []byte("\x1b[200~paste\x1bf\x1b[201~")421 paste := []byte("\x1b[200~paste\x1bf\x1b[201~")
614 d.handleInput(sess, ac, paste)422 d.handleInput(sess, ac, paste)
@@ -626,7 +434,7 @@ func TestFloatingStaysTerminalTargetAfterDirectionalFocus(t *testing.T) {
626 defer releaseNormal()434 defer releaseNormal()
627 defer releaseFloating()435 defer releaseFloating()
628 d, sess, ac, _ := newManualSessionWithPTYs(t, normal)436 d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
629- tb := sess.activeTab()437+ tb := testAttachmentTab(sess)
630 second := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 5})438 second := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 5})
631 tb.mu.Lock()439 tb.mu.Lock()
632 tb.size = domain.Size{Cols: 81, Rows: 5}440 tb.size = domain.Size{Cols: 81, Rows: 5}
@@ -658,7 +466,7 @@ func TestActionFocusPaneAtEdgeStaysSilent(t *testing.T) {
658 // the user as a notice instead of being silently discarded.466 // the user as a notice instead of being silently discarded.
659 func TestActionFocusPaneGenuineErrorReportsNoticeInternal(t *testing.T) {467 func TestActionFocusPaneGenuineErrorReportsNoticeInternal(t *testing.T) {
660 d, sess, ac, _ := newManualSessionWithPTYs(t, nil)468 d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
661- tb := sess.activeTab()469+ tb := testAttachmentTab(sess)
662 tb.mu.Lock()470 tb.mu.Lock()
663 tb.tree = nil471 tb.tree = nil
664 tb.mu.Unlock()472 tb.mu.Unlock()
@@ -675,7 +483,7 @@ func TestActionFocusPaneGenuineErrorReportsNoticeInternal(t *testing.T) {
675 483
676 func TestFloatingVisibilityRemainsIndependentAcrossTabSwitches(t *testing.T) {484 func TestFloatingVisibilityRemainsIndependentAcrossTabSwitches(t *testing.T) {
677 d, sess, ac, _ := newManualSessionWithPTYs(t, nil)485 d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
678- first := sess.activeTab()486+ first := testAttachmentTab(sess)
679 second := newTab(nil, first.size)487 second := newTab(nil, first.size)
680 installTestFloating(first, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)488 installTestFloating(first, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
681 installTestFloating(second, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), false)489 installTestFloating(second, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), false)
@@ -699,7 +507,7 @@ func TestFloatingMouseTranslatesSGRToInnerCoordinates(t *testing.T) {
699 floatingPTY := portsmocks.NewMockPTY(t)507 floatingPTY := portsmocks.NewMockPTY(t)
700 floatingPTY.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()508 floatingPTY.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()
701 d, sess, ac, _ := newManualSessionWithPTYs(t, normal)509 d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
702- tb := sess.activeTab()510+ tb := testAttachmentTab(sess)
703 tb.mu.Lock()511 tb.mu.Lock()
704 tb.size = domain.Size{Cols: 100, Rows: 20}512 tb.size = domain.Size{Cols: 100, Rows: 20}
705 tb.mu.Unlock()513 tb.mu.Unlock()
@@ -716,7 +524,7 @@ func TestFloatingMouseIgnoresBorderAndOutside(t *testing.T) {
716 normal := portsmocks.NewMockPTY(t)524 normal := portsmocks.NewMockPTY(t)
717 floatingPTY := portsmocks.NewMockPTY(t)525 floatingPTY := portsmocks.NewMockPTY(t)
718 d, sess, ac, _ := newManualSessionWithPTYs(t, normal)526 d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
719- tb := sess.activeTab()527+ tb := testAttachmentTab(sess)
720 tb.mu.Lock()528 tb.mu.Lock()
721 tb.size = domain.Size{Cols: 100, Rows: 20}529 tb.size = domain.Size{Cols: 100, Rows: 20}
722 tb.mu.Unlock()530 tb.mu.Unlock()
@@ -736,7 +544,7 @@ func TestResponsiveDrawerFloatingMouseRoutesOnlyInnerClientCells(t *testing.T) {
736 floatingPTY := portsmocks.NewMockPTY(t)544 floatingPTY := portsmocks.NewMockPTY(t)
737 floatingPTY.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()545 floatingPTY.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()
738 d, sess, ac, _ := newManualSessionWithPTYs(t, normal)546 d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
739- tb := sess.activeTab()547+ tb := testAttachmentTab(sess)
740 complete := domain.Size{Cols: 79, Rows: 25}548 complete := domain.Size{Cols: 79, Rows: 25}
741 content := tabSize(complete)549 content := tabSize(complete)
742 geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig())550 geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig())
@@ -760,7 +568,7 @@ func TestResponsiveDrawerCopyMouseMapsInnerAndPinsDragAwayFromChrome(t *testing.
760 p, release := newBlockingPTY(t)568 p, release := newBlockingPTY(t)
761 defer release()569 defer release()
762 d, sess, ac, _ := newManualSessionWithPTYs(t, p)570 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
763- tb := sess.activeTab()571+ tb := testAttachmentTab(sess)
764 complete := domain.Size{Cols: 79, Rows: 25}572 complete := domain.Size{Cols: 79, Rows: 25}
765 content := tabSize(complete)573 content := tabSize(complete)
766 geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig())574 geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig())
@@ -960,12 +768,12 @@ func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {
960 release()768 release()
961 }769 }
962 }()770 }()
963- sess.active = tc.start771+ selectTestAttachmentTab(sess, tc.start)
964 772
965 d.handleInput(sess, ac, []byte("\x1b "))773 d.handleInput(sess, ac, []byte("\x1b "))
966 d.handleInput(sess, ac, tc.query)774 d.handleInput(sess, ac, tc.query)
967 775
968- require.Equal(t, tc.wantIndex, activeTabIndex(sess))776+ require.Equal(t, tc.wantIndex, testAttachmentTabIndex(sess))
969 })777 })
970 }778 }
971 }779 }
@@ -1015,7 +823,7 @@ func TestBackSessionReportsStaleHandoffOnce(t *testing.T) {
1015 // A displaced attachment makes the previously valid handoff stale by the823 // A displaced attachment makes the previously valid handoff stale by the
1016 // time switchToTarget commits it.824 // time switchToTarget commits it.
1017 current.mu.Lock()825 current.mu.Lock()
1018- current.client = nil826+ clearAttachmentsForTestLocked(current)
1019 current.mu.Unlock()827 current.mu.Unlock()
1020 828
1021 d.backSession(current, ac)829 d.backSession(current, ac)
@@ -1200,14 +1008,14 @@ func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) {
1200 p2, releasePTY2 := newBlockingPTY(t)1008 p2, releasePTY2 := newBlockingPTY(t)
1201 p3, releasePTY3 := newBlockingPTYWithWrites(t, writes)1009 p3, releasePTY3 := newBlockingPTYWithWrites(t, writes)
1202 d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2, p3)1010 d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2, p3)
1203- sess.active = 11011+ selectTestAttachmentTab(sess, 1)
1204 1012
1205 d.handleInput(sess, ac, []byte("\x1b "))1013 d.handleInput(sess, ac, []byte("\x1b "))
1206 d.handleInput(sess, ac, []byte("CLT\r"))1014 d.handleInput(sess, ac, []byte("CLT\r"))
1207 1015
1208 require.Equal(t, 1, sessionCount(d))1016 require.Equal(t, 1, sessionCount(d))
1209 require.Len(t, sess.tabs, 2)1017 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")
1211 d.handleInput(sess, ac, []byte("Z"))1019 d.handleInput(sess, ac, []byte("Z"))
1212 require.Eventually(t, func() bool { return len(writes) == 1 }, 2*time.Second, 5*time.Millisecond)1020 require.Eventually(t, func() bool { return len(writes) == 1 }, 2*time.Second, 5*time.Millisecond)
1213 requirePTYWrite(t, writes, []byte("Z"))1021 requirePTYWrite(t, writes, []byte("Z"))
@@ -1224,7 +1032,7 @@ func TestAltDDetachesCurrentClient(t *testing.T) {
1224 d.handleInput(sess, ac, []byte("\x1b "))1032 d.handleInput(sess, ac, []byte("\x1b "))
1225 d.handleInput(sess, ac, []byte("DET\r"))1033 d.handleInput(sess, ac, []byte("DET\r"))
1226 1034
1227- require.Nil(t, sess.client)1035+ require.Empty(t, sess.snapshotAttachments())
1228 f := awaitFrame(t, sends, ports.MsgDetached)1036 f := awaitFrame(t, sends, ports.MsgDetached)
1229 det, err := ports.UnmarshalDetached(f.Payload)1037 det, err := ports.UnmarshalDetached(f.Payload)
1230 require.NoError(t, err)1038 require.NoError(t, err)
@@ -1258,7 +1066,7 @@ func TestRNTOpensPromptAndRenamesActiveTab(t *testing.T) {
1258 release()1066 release()
1259 }1067 }
1260 }()1068 }()
1261- sess.active = 11069+ selectTestAttachmentTab(sess, 1)
1262 1070
1263 d.handleInput(sess, ac, []byte("\x1b "))1071 d.handleInput(sess, ac, []byte("\x1b "))
1264 awaitFrame(t, sends, ports.MsgOutput)1072 awaitFrame(t, sends, ports.MsgOutput)
@@ -1639,7 +1447,7 @@ func TestMouseWheelOverUnfocusedPaneDoesNotFocusAndForwardsChildMouse(t *testing
1639 p2.EXPECT().Write([]byte("\x1b[<64;1;1M")).Return(len("\x1b[<64;1;1M"), nil).Once()1447 p2.EXPECT().Write([]byte("\x1b[<64;1;1M")).Return(len("\x1b[<64;1;1M"), nil).Once()
1640 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)1448 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1641 d.procComm = nil1449 d.procComm = nil
1642- tb := sess.activeTab()1450+ tb := testAttachmentTab(sess)
1643 p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})1451 p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})
1644 p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))1452 p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
1645 tb.mu.Lock()1453 tb.mu.Lock()
@@ -1663,7 +1471,7 @@ func TestRejectedLeftReleaseInvalidatesFreshPointerBeforeMotion(t *testing.T) {
1663 {1471 {
1664 name: "split divider",1472 name: "split divider",
1665 setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, _ *[]byte) []byte {1473 setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, _ *[]byte) []byte {
1666- tb := sess.activeTab()1474+ tb := testAttachmentTab(sess)
1667 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})1475 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
1668 tb.mu.Lock()1476 tb.mu.Lock()
1669 tb.size = domain.Size{Cols: 41, Rows: 10}1477 tb.size = domain.Size{Cols: 41, Rows: 10}
@@ -1676,7 +1484,7 @@ func TestRejectedLeftReleaseInvalidatesFreshPointerBeforeMotion(t *testing.T) {
1676 {1484 {
1677 name: "split title bar",1485 name: "split title bar",
1678 setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, _ *[]byte) []byte {1486 setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, _ *[]byte) []byte {
1679- tb := sess.activeTab()1487+ tb := testAttachmentTab(sess)
1680 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})1488 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
1681 tb.mu.Lock()1489 tb.mu.Lock()
1682 tb.size = domain.Size{Cols: 41, Rows: 10}1490 tb.size = domain.Size{Cols: 41, Rows: 10}
@@ -1689,7 +1497,7 @@ func TestRejectedLeftReleaseInvalidatesFreshPointerBeforeMotion(t *testing.T) {
1689 {1497 {
1690 name: "floating exterior",1498 name: "floating exterior",
1691 setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, press *[]byte) []byte {1499 setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, press *[]byte) []byte {
1692- tb := sess.activeTab()1500+ tb := testAttachmentTab(sess)
1693 floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5})1501 floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5})
1694 installTestFloating(tb, floating, true)1502 installTestFloating(tb, floating, true)
1695 tb.mu.Lock()1503 tb.mu.Lock()
@@ -1749,7 +1557,7 @@ func TestPressAtFormerStackTitleRowIsTreatedAsExpandedContent(t *testing.T) {
1749 p, release := newBlockingPTY(t)1557 p, release := newBlockingPTY(t)
1750 defer release()1558 defer release()
1751 d, sess, ac, _ := newManualSessionWithPTYs(t, p)1559 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1752- tb := sess.activeTab()1560+ tb := testAttachmentTab(sess)
1753 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 9})1561 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 9})
1754 p3 := newPane("pane-3", nil, domain.Size{Cols: 20, Rows: 1})1562 p3 := newPane("pane-3", nil, domain.Size{Cols: 20, Rows: 1})
1755 tb.mu.Lock()1563 tb.mu.Lock()
@@ -1821,7 +1629,7 @@ func TestMouseDividerAndTitleBarDoNotForwardBogusCoordinates(t *testing.T) {
1821 p2.EXPECT().Resize(mock.Anything).Return(nil).Maybe()1629 p2.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
1822 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)1630 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1823 d.procComm = nil1631 d.procComm = nil
1824- tb := sess.activeTab()1632+ tb := testAttachmentTab(sess)
1825 tb.focusedPane().screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))1633 tb.focusedPane().screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
1826 tb.mu.Lock()1634 tb.mu.Lock()
1827 tb.size = tc.size1635 tb.size = tc.size
@@ -1844,7 +1652,7 @@ func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) {
1844 p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Maybe()1652 p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Maybe()
1845 d, sess, ac, sends := newManualSessionWithPTYs(t, p1)1653 d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
1846 d.procComm = nil1654 d.procComm = nil
1847- tb := sess.activeTab()1655+ tb := testAttachmentTab(sess)
1848 for i := range 10 {1656 for i := range 10 {
1849 copy(tb.focusedPane().screen.Frame.Row(i), testRow(string(rune('a'+i))))1657 copy(tb.focusedPane().screen.Frame.Row(i), testRow(string(rune('a'+i))))
1850 }1658 }
@@ -1878,7 +1686,7 @@ func TestMouseHitTestFocusesPaneAndTranslatesSGRColumns(t *testing.T) {
1878 p2.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()1686 p2.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()
1879 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)1687 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1880 d.procComm = nil1688 d.procComm = nil
1881- tb := sess.activeTab()1689+ tb := testAttachmentTab(sess)
1882 p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})1690 p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})
1883 p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))1691 p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
1884 tb.mu.Lock()1692 tb.mu.Lock()
@@ -1905,7 +1713,7 @@ func TestMouseGatedWhileNoticesOverlayActive(t *testing.T) {
1905 p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()1713 p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
1906 d, sess, ac, sends := newManualSessionWithPTYs(t, p1)1714 d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
1907 d.procComm = nil1715 d.procComm = nil
1908- tb := sess.activeTab()1716+ tb := testAttachmentTab(sess)
1909 p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})1717 p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})
1910 p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))1718 p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
1911 tb.mu.Lock()1719 tb.mu.Lock()
@@ -1935,7 +1743,7 @@ func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) {
1935 p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 4}).Return(nil).Maybe()1743 p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 4}).Return(nil).Maybe()
1936 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)1744 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1937 d.procComm = nil1745 d.procComm = nil
1938- tb := sess.activeTab()1746+ tb := testAttachmentTab(sess)
1939 p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 3})1747 p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 3})
1940 tb.mu.Lock()1748 tb.mu.Lock()
1941 tb.size = domain.Size{Cols: 20, Rows: 5}1749 tb.size = domain.Size{Cols: 20, Rows: 5}
@@ -1954,7 +1762,7 @@ func TestActiveCopyPressOnOtherPaneFocusesAndExitsBeforeFutureInput(t *testing.T
1954 p1, release := newBlockingPTY(t)1762 p1, release := newBlockingPTY(t)
1955 defer release()1763 defer release()
1956 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)1764 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1957- tb := sess.activeTab()1765+ tb := testAttachmentTab(sess)
1958 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})1766 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
1959 tb.mu.Lock()1767 tb.mu.Lock()
1960 tb.size = domain.Size{Cols: 41, Rows: 10}1768 tb.size = domain.Size{Cols: 41, Rows: 10}
@@ -2053,8 +1861,8 @@ func TestActiveCopyMouseIgnoresStalePointerResets(t *testing.T) {
2053 name: "press-owned release rejects mapping",1861 name: "press-owned release rejects mapping",
2054 prepare: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) mouse.Event {1862 prepare: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) mouse.Event {
2055 t.Helper()1863 t.Helper()
2056- p := sess.activeTab().focusedPane()1864+ p := testAttachmentTab(sess).focusedPane()
2057- tb := sess.activeTab()1865+ tb := testAttachmentTab(sess)
2058 tb.mu.Lock()1866 tb.mu.Lock()
2059 geometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), 1, 2)1867 geometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), 1, 2)
2060 tb.mu.Unlock()1868 tb.mu.Unlock()
@@ -2069,7 +1877,7 @@ func TestActiveCopyMouseIgnoresStalePointerResets(t *testing.T) {
2069 name: "current geometry disappears",1877 name: "current geometry disappears",
2070 prepare: func(t *testing.T, _ *Daemon, sess *session, _ *attachedClient) mouse.Event {1878 prepare: func(t *testing.T, _ *Daemon, sess *session, _ *attachedClient) mouse.Event {
2071 t.Helper()1879 t.Helper()
2072- tb := sess.activeTab()1880+ tb := testAttachmentTab(sess)
2073 tb.mu.Lock()1881 tb.mu.Lock()
2074 tb.tree = nil1882 tb.tree = nil
2075 tb.mu.Unlock()1883 tb.mu.Unlock()
@@ -2080,7 +1888,7 @@ func TestActiveCopyMouseIgnoresStalePointerResets(t *testing.T) {
2080 name: "fresh press rejects document mapping",1888 name: "fresh press rejects document mapping",
2081 prepare: func(t *testing.T, _ *Daemon, sess *session, _ *attachedClient) mouse.Event {1889 prepare: func(t *testing.T, _ *Daemon, sess *session, _ *attachedClient) mouse.Event {
2082 t.Helper()1890 t.Helper()
2083- tb := sess.activeTab()1891+ tb := testAttachmentTab(sess)
2084 tb.mu.Lock()1892 tb.mu.Lock()
2085 tb.size.Rows = 40 // valid layout coordinate, outside the old document.1893 tb.size.Rows = 40 // valid layout coordinate, outside the old document.
2086 tb.mu.Unlock()1894 tb.mu.Unlock()
@@ -2109,7 +1917,7 @@ func TestActiveCopyMouseIgnoresStalePointerResets(t *testing.T) {
2109 }1917 }
2110 defer func() { d.beforeCopyMouseMap = nil }()1918 defer func() { d.beforeCopyMouseMap = nil }()
2111 1919
2112- d.handleActiveCopyMouse(sess, ac, sess.activeTab(), ev)1920+ d.handleActiveCopyMouse(sess, ac, testAttachmentTab(sess), ev)
2113 1921
2114 ac.overlays.copyMu.Lock()1922 ac.overlays.copyMu.Lock()
2115 defer ac.overlays.copyMu.Unlock()1923 defer ac.overlays.copyMu.Unlock()
@@ -2128,7 +1936,7 @@ func TestActiveCopyMouseRejectsViewportChangeAfterMappingSnapshot(t *testing.T)
2128 p, release := newBlockingPTY(t)1936 p, release := newBlockingPTY(t)
2129 defer release()1937 defer release()
2130 d, sess, ac, sends := newManualSessionWithPTYs(t, p)1938 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
2131- pane := sess.activeTab().focusedPane()1939+ pane := testAttachmentTab(sess).focusedPane()
2132 for range 8 {1940 for range 8 {
2133 appendHistoryRow(t, pane.history, testRow("history"))1941 appendHistoryRow(t, pane.history, testRow("history"))
2134 }1942 }
@@ -2162,13 +1970,13 @@ func TestFreshCopyPressUsesFrameAbsoluteHitTestGeometry(t *testing.T) {
2162 {1970 {
2163 name: "mono",1971 name: "mono",
2164 setup: func(_ *testing.T, _ *Daemon, sess *session) (*pane, mouse.Event) {1972 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}
2166 },1974 },
2167 },1975 },
2168 {1976 {
2169 name: "split",1977 name: "split",
2170 setup: func(_ *testing.T, _ *Daemon, sess *session) (*pane, mouse.Event) {1978 setup: func(_ *testing.T, _ *Daemon, sess *session) (*pane, mouse.Event) {
2171- tb := sess.activeTab()1979+ tb := testAttachmentTab(sess)
2172 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})1980 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
2173 tb.mu.Lock()1981 tb.mu.Lock()
2174 tb.size = domain.Size{Cols: 41, Rows: 10}1982 tb.size = domain.Size{Cols: 41, Rows: 10}
@@ -2182,8 +1990,8 @@ func TestFreshCopyPressUsesFrameAbsoluteHitTestGeometry(t *testing.T) {
2182 name: "floating",1990 name: "floating",
2183 setup: func(t *testing.T, d *Daemon, sess *session) (*pane, mouse.Event) {1991 setup: func(t *testing.T, d *Daemon, sess *session) (*pane, mouse.Event) {
2184 p := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5})1992 p := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5})
2185- installTestFloating(sess.activeTab(), p, true)1993+ installTestFloating(testAttachmentTab(sess), p, true)
2186- tb := sess.activeTab()1994+ tb := testAttachmentTab(sess)
2187 tb.mu.Lock()1995 tb.mu.Lock()
2188 _, geometry, visible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())1996 _, geometry, visible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
2189 tb.mu.Unlock()1997 tb.mu.Unlock()
@@ -2197,7 +2005,7 @@ func TestFreshCopyPressUsesFrameAbsoluteHitTestGeometry(t *testing.T) {
2197 defer release()2005 defer release()
2198 d, sess, ac, _ := newManualSessionWithPTYs(t, p)2006 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
2199 wantPane, ev := tc.setup(t, d, sess)2007 wantPane, ev := tc.setup(t, d, sess)
2200- tb := sess.activeTab()2008+ tb := testAttachmentTab(sess)
2201 tb.mu.Lock()2009 tb.mu.Lock()
2202 wantGeometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), ev.Col, ev.Row)2010 wantGeometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), ev.Col, ev.Row)
2203 tb.mu.Unlock()2011 tb.mu.Unlock()
M internal/usecase/daemon/lease_callback_guard_test.go +1 -1
@@ -54,7 +54,7 @@ func TestResizeAndRetryCallbacksRejectSameObjectLeaseReplacement(t *testing.T) {
54 done := tc.done(t, rc)54 done := tc.done(t, rc)
55 require.NotNil(t, done)55 require.NotNil(t, done)
56 56
57- rc.notePark(ac)57+ rc.noteDetach(ac)
58 rc.attach(ac) // resume the exact same object under a new incarnation58 rc.attach(ac) // resume the exact same object under a new incarnation
59 timer.ch <- time.Time{}59 timer.ch <- time.Time{}
60 awaitTestCompletion(t, done, "stale callback did not complete")60 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 (
10 "github.com/bnema/vev/internal/usecase/mouse"10 "github.com/bnema/vev/internal/usecase/mouse"
11 )11 )
12 12
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-
29 func hitTestPlacementLocked(tb *tab, col, row int) (layout.Placement, bool) {13 func hitTestPlacementLocked(tb *tab, col, row int) (layout.Placement, bool) {
30 if tb == nil || tb.tree == nil || tb.tree.Root == nil || !tb.size.Valid() {14 if tb == nil || tb.tree == nil || tb.tree.Root == nil || !tb.size.Valid() {
31 return layout.Placement{}, false15 return layout.Placement{}, false
M internal/usecase/daemon/move.go +45 -112
@@ -10,6 +10,8 @@ import (
10 // immutable identity. Session names are advisory; the ID and incarnation are10 // immutable identity. Session names are advisory; the ID and incarnation are
11 // the commit-time authority.11 // the commit-time authority.
12 type movePaneRequest struct {12 type movePaneRequest struct {
13+ Attachment *attachedClient
14+ AttachmentToken attachmentConnectionToken
13 Source moveSessionLocator15 Source moveSessionLocator
14 SourceTabID domain.TabStableID16 SourceTabID domain.TabStableID
15 SourcePaneID domain.PaneStableID17 SourcePaneID domain.PaneStableID
@@ -18,9 +20,11 @@ type movePaneRequest struct {
18 }20 }
19 21
20 type moveTabRequest struct {22 type moveTabRequest struct {
21- Source moveSessionLocator23+ Attachment *attachedClient
22- SourceTabID domain.TabStableID24+ AttachmentToken attachmentConnectionToken
23- Destination moveSessionLocator25+ Source moveSessionLocator
26+ SourceTabID domain.TabStableID
27+ Destination moveSessionLocator
24 }28 }
25 29
26 // movePane resolves, reserves, validates, and commits one pane relocation. All30 // movePane resolves, reserves, validates, and commits one pane relocation. All
@@ -42,6 +46,20 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
42 return errMoveStaleTarget46 return errMoveStaleTarget
43 }47 }
44 48
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+
45 reservation, err := d.reserveMoveLifecycles(source, destination)63 reservation, err := d.reserveMoveLifecycles(source, destination)
46 if err != nil {64 if err != nil {
47 return errMoveStaleTarget65 return errMoveStaleTarget
@@ -56,14 +74,6 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
56 d.afterMoveLifecycleReserved()74 d.afterMoveLifecycleReserved()
57 }75 }
58 76
59- unlockDispatch := lockMoveDispatch(source, destination)
60- dispatchHeld := true
61- defer func() {
62- if dispatchHeld {
63- unlockDispatch()
64- }
65- }()
66-
67 // Snapshot the exact live objects and generations before waiting on resize77 // Snapshot the exact live objects and generations before waiting on resize
68 // fences. The locked commit repeats every authority check before publication.78 // fences. The locked commit repeats every authority check before publication.
69 admission, err := d.snapshotMovePaneAdmission(req, source, destination)79 admission, err := d.snapshotMovePaneAdmission(req, source, destination)
@@ -73,10 +83,6 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
73 sourceTab := admission.sourceTab83 sourceTab := admission.sourceTab
74 destinationTab := admission.destinationTab84 destinationTab := admission.destinationTab
75 movedPane := admission.movedPane85 movedPane := admission.movedPane
76- sourceClient := admission.sourceClient
77- destinationClient := admission.destinationClient
78- sourceSnatched := admission.sourceSnatched
79- sourceTabWasActive := admission.sourceTabWasActive
80 sourceTabInitialGeneration := admission.sourceGeneration86 sourceTabInitialGeneration := admission.sourceGeneration
81 destinationTabInitialGeneration := admission.destinationGeneration87 destinationTabInitialGeneration := admission.destinationGeneration
82 finalSourceTab := admission.finalSourceTab88 finalSourceTab := admission.finalSourceTab
@@ -84,90 +90,32 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
84 d.afterMovePaneSourceSnapshot()90 d.afterMovePaneSourceSnapshot()
85 }91 }
86 92
87- // A final source tab with an active client needs the centralized attachment93+ // A final source tab is retired without transferring any attachment to the
88- // transition. Freeze that exact role set before entering the fence callback;94+ // destination. Freeze every affected source connection before publication so
89- // the callback then validates the handoff before changing any topology.95+ // teardown invalidates exact generations rather than racing input or render.
90- var handoffReq attachmentTransitionRequest96+ var frozen frozenAttachmentEffectGates
91- var handoffParticipants attachmentTransitionParticipants97+ var sourceEffectsFrozen bool
92- var frozen frozenRoleEffectGates98+ if finalSourceTab && source != destination && len(admission.sourceAttachments) != 0 {
93- var sourceRolesFrozen bool99+ interrupts := make([]attachmentTransportInterrupt, 0, len(admission.sourceAttachments))
94- var handoffFrozen bool100+ for _, ac := range admission.sourceAttachments {
95-101+ if transport := admission.sourceTransports[ac]; transport.transport != nil {
96- if finalSourceTab && source != destination && sourceClient != nil {102+ interrupts = append(interrupts, attachmentTransportInterrupt{ac: ac, transport: transport})
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})
127 }103 }
128 }104 }
129- source.mu.Unlock()105+ frozen = freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) {
130- d.mu.Unlock()106+ if d.afterAttachmentEffectGateFrozen != nil {
131- frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: handoffParticipants.interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) {107+ d.afterAttachmentEffectGateFrozen("move-pane", ac)
132- if d.afterRoleEffectGateFrozen != nil {
133- d.afterRoleEffectGateFrozen("move-pane", ac)
134 }108 }
135- }}, handoffParticipants.clients...)109+ }}, admission.sourceAttachments...)
136 if !frozen.acquired || !frozen.drained {110 if !frozen.acquired || !frozen.drained {
137 frozen.unfreeze()111 frozen.unfreeze()
138 return errMovePaneInvalid112 return errMovePaneInvalid
139 }113 }
140- handoffFrozen = true114+ sourceEffectsFrozen = 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
167 }115 }
168- if sourceRolesFrozen {116+ if sourceEffectsFrozen {
169 defer func() {117 defer func() {
170- if sourceRolesFrozen {118+ if sourceEffectsFrozen {
171 frozen.unfreeze()119 frozen.unfreeze()
172 }120 }
173 }()121 }()
@@ -180,17 +128,14 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
180 sourceTab: sourceTab,128 sourceTab: sourceTab,
181 destinationTab: destinationTab,129 destinationTab: destinationTab,
182 movedPane: movedPane,130 movedPane: movedPane,
183- sourceClient: sourceClient,131+ sourceAttachments: admission.sourceAttachments,
184- sourceSnatched: sourceSnatched,132+ sourceTransports: admission.sourceTransports,
185 sourceGeneration: sourceTabInitialGeneration,133 sourceGeneration: sourceTabInitialGeneration,
186 destinationGeneration: destinationTabInitialGeneration,134 destinationGeneration: destinationTabInitialGeneration,
187- handoffFrozen: handoffFrozen,135+ frozenEffects: frozen,
188- frozenRoles: frozen,
189- handoffReq: handoffReq,
190 }136 }
191 fences := newMovePaneResizeFences(source, destination, sourceTab, destinationTab, movedPane)137 fences := newMovePaneResizeFences(source, destination, sourceTab, destinationTab, movedPane)
192 if !fences.acquire(func() bool { return commit.publishLocked(d) }) {138 if !fences.acquire(func() bool { return commit.publishLocked(d) }) {
193- commit.releasePublication()
194 if commit.err != nil {139 if commit.err != nil {
195 return commit.err140 return commit.err
196 }141 }
@@ -205,15 +150,12 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
205 sourceTab: sourceTab,150 sourceTab: sourceTab,
206 destinationTab: destinationTab,151 destinationTab: destinationTab,
207 movedPane: movedPane,152 movedPane: movedPane,
208- destinationClient: destinationClient,153+ sourceAttachments: admission.sourceAttachments,
209- sourceCleanupToken: commit.sourceCleanupToken,
210- handoffResult: commit.handoffResult,
211 syncCleanup: commit.syncCleanup,154 syncCleanup: commit.syncCleanup,
212- frozenRoles: frozen,155+ frozenEffects: frozen,
213- rolesFrozen: sourceRolesFrozen,156+ effectsFrozen: sourceEffectsFrozen,
214 unlockDispatch: unlockDispatch,157 unlockDispatch: unlockDispatch,
215 reservation: reservation,158 reservation: reservation,
216- sourceTabWasActive: sourceTabWasActive,
217 sourceTabRemoved: commit.sourceTabRemoved,159 sourceTabRemoved: commit.sourceTabRemoved,
218 sourceEmpty: commit.sourceEmpty,160 sourceEmpty: commit.sourceEmpty,
219 retiredParked: commit.retiredParked,161 retiredParked: commit.retiredParked,
@@ -225,7 +167,7 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
225 }167 }
226 // Transfer cleanup ownership away from the admission defers only after the168 // Transfer cleanup ownership away from the admission defers only after the
227 // complete postcommit plan exists.169 // complete postcommit plan exists.
228- sourceRolesFrozen = false170+ sourceEffectsFrozen = false
229 dispatchHeld = false171 dispatchHeld = false
230 reservationHeld = false172 reservationHeld = false
231 postcommit.execute(d)173 postcommit.execute(d)
@@ -261,15 +203,6 @@ func findMoveTabLocked(sess *session, stableID domain.TabStableID) *tab {
261 return nil203 return nil
262 }204 }
263 205
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-
273 func indexMoveTabLocked(sess *session, target *tab) int {206 func indexMoveTabLocked(sess *session, target *tab) int {
274 for i, tb := range sess.tabs {207 for i, tb := range sess.tabs {
275 if tb == target {208 if tb == target {
M internal/usecase/daemon/move_acceptance_test.go +4 -4
@@ -89,7 +89,7 @@ func TestMovePaneAcceptanceRetainsSourceFocusAndDestinationActivity(t *testing.T
89 89
90 destinationActive := newTabWithStableID("destination-active", "active-pane", activePTY, domain.Size{Cols: 80, Rows: 23})90 destinationActive := newTabWithStableID("destination-active", "active-pane", activePTY, domain.Size{Cols: 80, Rows: 23})
91 destinationTab := newTabWithStableID("destination-target", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})91 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}}
93 publishTiledPaneOwners(destination, destinationActive)93 publishTiledPaneOwners(destination, destinationActive)
94 publishTiledPaneOwners(destination, destinationTab)94 publishTiledPaneOwners(destination, destinationTab)
95 d.mu.Lock()95 d.mu.Lock()
@@ -140,7 +140,7 @@ func TestMovePaneAcceptanceRetainsSourceFocusAndDestinationActivity(t *testing.T
140 require.Same(t, remaining, remainingPane)140 require.Same(t, remaining, remainingPane)
141 require.Equal(t, layout.PaneID("pane-2"), sourceFocus, "source focus must use the valid fallback leaf")141 require.Equal(t, layout.PaneID("pane-2"), sourceFocus, "source focus must use the valid fallback leaf")
142 destination.mu.Lock()142 destination.mu.Lock()
143- destinationActiveTab := destination.tabs[destination.active]143+ destinationActiveTab := destination.tabs[testAttachmentTabIndexLocked(destination)]
144 destination.mu.Unlock()144 destination.mu.Unlock()
145 require.Same(t, destinationActive, destinationActiveTab, "destination active tab must remain unchanged")145 require.Same(t, destinationActive, destinationActiveTab, "destination active tab must remain unchanged")
146 destinationTab.mu.Lock()146 destinationTab.mu.Lock()
@@ -182,7 +182,7 @@ func TestMovePaneRejectsStaleIncarnationWithoutMutation(t *testing.T) {
182 sourceTab := source.tabs[0]182 sourceTab := source.tabs[0]
183 sourceTab.stableID = "source-tab"183 sourceTab.stableID = "source-tab"
184 moved := sourceTab.focusedPane()184 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})}}
186 destinationTab := destination.tabs[0]186 destinationTab := destination.tabs[0]
187 publishTiledPaneOwners(destination, destinationTab)187 publishTiledPaneOwners(destination, destinationTab)
188 d.mu.Lock()188 d.mu.Lock()
@@ -231,7 +231,7 @@ func TestMovePaneRejectsStaleLayoutGenerationBeforeMutation(t *testing.T) {
231 sourceTab := source.tabs[0]231 sourceTab := source.tabs[0]
232 sourceTab.stableID = "source-tab"232 sourceTab.stableID = "source-tab"
233 moved := sourceTab.focusedPane()233 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})}}
235 destinationTab := destination.tabs[0]235 destinationTab := destination.tabs[0]
236 publishTiledPaneOwners(destination, destinationTab)236 publishTiledPaneOwners(destination, destinationTab)
237 d.mu.Lock()237 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 @@
1 package daemon1 package daemon
2 2
3-// frozenMoveAttachmentRetirement is proof that every source snatched client3+func moveAttachmentTokenCurrentLocked(token attachmentConnectionToken, sess *session) bool {
4-// retired by a final move has had its exact role-effect gate frozen and drained.4+ return token.sess == sess && token.ac != nil && token.generation == token.ac.connectionGeneration.Load() &&
5-// It is prepared while the source session lock is held, before topology writes.5+ token.ac.currentAttachmentSession() == sess && token.ac.transportSnapshotCurrent(token.transport) &&
6-type frozenMoveAttachmentRetirement struct {6+ attachmentRegisteredLocked(sess, token.ac)
7- clients []*attachedClient
8 }7 }
9 8
10-func snapshotMoveSnatchedLocked(sess *session) []*attachedClient {9+// sameMoveAttachmentsLocked reports whether the source membership is unchanged
11- clients := make([]*attachedClient, 0, len(sess.snatched))10+// since admission. Caller holds the source session lock.
12- for ac := range sess.snatched {11+func sameMoveAttachmentsLocked(sess *session, admitted []*attachedClient) bool {
13- clients = append(clients, ac)12+ if sess == nil || len(sess.attachments) != len(admitted) {
14- }
15- return clients
16-}
17-
18-func sameMoveSnatchedLocked(sess *session, admitted []*attachedClient) bool {
19- if sess == nil || len(sess.snatched) != len(admitted) {
20 return false13 return false
21 }14 }
22 for _, ac := range admitted {15 for _, ac := range admitted {
23- if _, ok := sess.snatched[ac]; !ok {16+ if !attachmentRegisteredLocked(sess, ac) {
24 return false17 return false
25 }18 }
26 }19 }
27 return true20 return true
28 }21 }
29 22
30-func prepareFrozenMoveAttachmentRetirementLocked(sess *session, admitted []*attachedClient, frozen frozenRoleEffectGates) (frozenMoveAttachmentRetirement, bool) {23+// detachMoveAttachmentsLocked invalidates every attachment of a source session
31- if !sameMoveSnatchedLocked(sess, admitted) {24+// that became empty. Moves never transfer attachment ownership to the target;
32- return frozenMoveAttachmentRetirement{}, false25+// each connection is retired independently after the shared content move.
33- }26+// Caller holds the source session lock and the affected attachment gates are
34- if len(admitted) == 0 {27+// frozen.
35- return frozenMoveAttachmentRetirement{}, true28+func detachMoveAttachmentsLocked(sess *session, transports map[*attachedClient]transportSnapshot) []detachedAttachmentSnapshot {
29+ if sess == nil || len(sess.attachments) == 0 {
30+ return nil
36 }31 }
37- if !frozen.acquired || !frozen.drained {32+ attachments := sess.snapshotAttachmentsLocked()
38- return frozenMoveAttachmentRetirement{}, false33+ retired := make([]detachedAttachmentSnapshot, 0, len(attachments))
39- }34+ for _, ac := range attachments {
40- for _, ac := range admitted {35+ retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: transports[ac]})
41- if !frozen.contains(ac) {36+ ac.connectionGeneration.Add(1)
42- return frozenMoveAttachmentRetirement{}, false37+ ac.setSession(nil)
43- }38+ ac.invalidateFrozenAttachmentCapability()
44 }39 }
45- return frozenMoveAttachmentRetirement{clients: append([]*attachedClient(nil), admitted...)}, true40+ clear(sess.attachments)
41+ return retired
46 }42 }
M internal/usecase/daemon/move_cleanup_test.go +32 -19
@@ -9,6 +9,23 @@ import (
9 scopy "github.com/bnema/vev/internal/usecase/copy"9 scopy "github.com/bnema/vev/internal/usecase/copy"
10 )10 )
11 11
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+
12 func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {29 func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {
13 movedPTY, releaseMoved := newBlockingPTY(t)30 movedPTY, releaseMoved := newBlockingPTY(t)
14 remainingPTY, releaseRemaining := newBlockingPTY(t)31 remainingPTY, releaseRemaining := newBlockingPTY(t)
@@ -18,7 +35,7 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {
18 defer releaseDestination()35 defer releaseDestination()
19 36
20 d, source, client, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY)37 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))
22 sourceTab := source.tabs[0]39 sourceTab := source.tabs[0]
23 sourceTab.stableID = "source-tab"40 sourceTab.stableID = "source-tab"
24 moved := sourceTab.focusedPane()41 moved := sourceTab.focusedPane()
@@ -26,9 +43,7 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {
26 client.captureFrames = map[*pane]capturedPaneRenderState{moved: {}}43 client.captureFrames = map[*pane]capturedPaneRenderState{moved: {}}
27 client.sendMu.Unlock()44 client.sendMu.Unlock()
28 45
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})},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})}}
30- active: 0,
31- }
32 destinationTab := destination.tabs[0]47 destinationTab := destination.tabs[0]
33 publishTiledPaneOwners(destination, destinationTab)48 publishTiledPaneOwners(destination, destinationTab)
34 d.mu.Lock()49 d.mu.Lock()
@@ -43,7 +58,7 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {
43 require.NotNil(t, rc)58 require.NotNil(t, rc)
44 lease := rc.attachmentLease(client)59 lease := rc.attachmentLease(client)
45 epoch := rc.recordResizeRequestForLease(client.size, client, lease)60 epoch := rc.recordResizeRequestForLease(client.size, client, lease)
46- ticket, admitted := beginActiveLeaseEffect(source, client, lease)61+ ticket, admitted := beginAttachmentLeaseEffect(source, client, lease)
47 require.True(t, admitted)62 require.True(t, admitted)
48 defer ticket.End()63 defer ticket.End()
49 64
@@ -102,20 +117,18 @@ func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) {
102 sourceTab.stableID = "source-tab"117 sourceTab.stableID = "source-tab"
103 remainingTab.stableID = "remaining-tab"118 remainingTab.stableID = "remaining-tab"
104 moved := sourceTab.focusedPane()119 moved := sourceTab.focusedPane()
105- require.NotNil(t, d.attachCoordinator(source, nil, displaced, true))120+ require.NotNil(t, d.attachCoordinator(source, displaced, true))
106 121
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})},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})}}
108- active: 0,
109- }
110 destinationTab := destination.tabs[0]123 destinationTab := destination.tabs[0]
111 publishTiledPaneOwners(destination, destinationTab)124 publishTiledPaneOwners(destination, destinationTab)
112 d.mu.Lock()125 d.mu.Lock()
113 d.sessions[destination.id] = destination126 d.sessions[destination.id] = destination
114 d.mu.Unlock()127 d.mu.Unlock()
115 128
116- // Both clients carry state tied specifically to the pane. The displaced129+ // Both clients carry state tied specifically to the pane. The existing
117- // client was captured before the fence wait; the replacement is the only130+ // attachment and the concurrently registered attachment remain independent;
118- // attachment that may be cleaned after commit.131+ // moving one exact attachment must not clean the other.
119 displaced.overlays.copyMu.Lock()132 displaced.overlays.copyMu.Lock()
120 displaced.overlays.copyPane = moved133 displaced.overlays.copyPane = moved
121 displaced.overlays.copyMode = &scopy.Mode{}134 displaced.overlays.copyMode = &scopy.Mode{}
@@ -163,27 +176,27 @@ func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) {
163 awaitTestCompletion(t, snapshot, "move did not capture its pre-fence source attachment")176 awaitTestCompletion(t, snapshot, "move did not capture its pre-fence source attachment")
164 177
165 source.mu.Lock()178 source.mu.Lock()
166- source.client = replacement179+ source.registerAttachmentLocked(replacement)
167 source.mu.Unlock()180 source.mu.Unlock()
168 close(releaseSnapshot)181 close(releaseSnapshot)
169 source.layoutApplyMu.Unlock()182 source.layoutApplyMu.Unlock()
170 require.NoError(t, awaitTestValue(t, moveDone, "move did not finish after source fence release"))183 require.NoError(t, awaitTestValue(t, moveDone, "move did not finish after source fence release"))
171 184
172 displaced.overlays.copyMu.Lock()185 displaced.overlays.copyMu.Lock()
173- require.NotNil(t, displaced.overlays.copyMode, "displaced source client was incorrectly cleaned")186+ require.Nil(t, displaced.overlays.copyMode, "moved attachment copy mode was not cleaned")
174- require.Same(t, moved, displaced.overlays.copyPane)187+ require.Nil(t, displaced.overlays.copyPane)
175 displaced.overlays.copyMu.Unlock()188 displaced.overlays.copyMu.Unlock()
176 displaced.sendMu.Lock()189 displaced.sendMu.Lock()
177 _, displacedCaptured := displaced.captureFrames[moved]190 _, displacedCaptured := displaced.captureFrames[moved]
178 displaced.sendMu.Unlock()191 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")
180 193
181 replacement.overlays.copyMu.Lock()194 replacement.overlays.copyMu.Lock()
182- require.Nil(t, replacement.overlays.copyMode, "current source client copy mode was not cleaned")195+ require.NotNil(t, replacement.overlays.copyMode, "independent attachment copy mode was cleaned")
183- require.Nil(t, replacement.overlays.copyPane)196+ require.Same(t, moved, replacement.overlays.copyPane)
184 replacement.overlays.copyMu.Unlock()197 replacement.overlays.copyMu.Unlock()
185 replacement.sendMu.Lock()198 replacement.sendMu.Lock()
186 _, replacementCaptured := replacement.captureFrames[moved]199 _, replacementCaptured := replacement.captureFrames[moved]
187 replacement.sendMu.Unlock()200 replacement.sendMu.Unlock()
188- require.False(t, replacementCaptured, "current source capture was not pruned")201+ require.True(t, replacementCaptured, "independent attachment capture was pruned")
189 }202 }
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
19 sourceTab := source.tabs[0]19 sourceTab := source.tabs[0]
20 sourceTab.stableID = "source-tab"20 sourceTab.stableID = "source-tab"
21 moved := sourceTab.focusedPane()21 moved := sourceTab.focusedPane()
22- require.NotNil(t, d.attachCoordinator(source, nil, client, true))22+ require.NotNil(t, d.attachCoordinator(source, client, true))
23 23
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})},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- }
27 destinationTab := destination.tabs[0]25 destinationTab := destination.tabs[0]
28 publishTiledPaneOwners(destination, destinationTab)26 publishTiledPaneOwners(destination, destinationTab)
29 d.mu.Lock()27 d.mu.Lock()
@@ -76,7 +74,7 @@ func TestMovePaneGateReservationRaceKillWinsWithoutDeadlock(t *testing.T) {
76 killFrozen := make(chan struct{})74 killFrozen := make(chan struct{})
77 releaseKill := make(chan struct{})75 releaseKill := make(chan struct{})
78 var killFrozenOnce sync.Once76 var killFrozenOnce sync.Once
79- d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) {77+ d.afterAttachmentEffectGateFrozen = func(action string, _ *attachedClient) {
80 if action != "" {78 if action != "" {
81 return79 return
82 }80 }
@@ -85,7 +83,7 @@ func TestMovePaneGateReservationRaceKillWinsWithoutDeadlock(t *testing.T) {
85 }83 }
86 defer func() {84 defer func() {
87 d.afterMoveLifecycleReserved = nil85 d.afterMoveLifecycleReserved = nil
88- d.afterRoleEffectGateFrozen = nil86+ d.afterAttachmentEffectGateFrozen = nil
89 }()87 }()
90 88
91 moveDone := make(chan error, 1)89 moveDone := make(chan error, 1)
@@ -113,6 +111,139 @@ func TestMovePaneGateReservationRaceKillWinsWithoutDeadlock(t *testing.T) {
113 require.NotSame(t, moved, destination.tabs[0].panes[moved.id], "kill winner must not publish a partial destination move")111 require.NotSame(t, moved, destination.tabs[0].panes[moved.id], "kill winner must not publish a partial destination move")
114 }112 }
115 113
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+
116 func TestMovePaneGateReservationRaceMoveWinsWithoutDeadlock(t *testing.T) {247 func TestMovePaneGateReservationRaceMoveWinsWithoutDeadlock(t *testing.T) {
117 d, source, sourceTab, moved, destination, destinationTab, releasePTYs := newMoveGateRaceFixture(t)248 d, source, sourceTab, moved, destination, destinationTab, releasePTYs := newMoveGateRaceFixture(t)
118 defer releasePTYs()249 defer releasePTYs()
@@ -120,7 +251,7 @@ func TestMovePaneGateReservationRaceMoveWinsWithoutDeadlock(t *testing.T) {
120 moveGateFrozen := make(chan struct{})251 moveGateFrozen := make(chan struct{})
121 releaseMove := make(chan struct{})252 releaseMove := make(chan struct{})
122 var moveGateFrozenOnce sync.Once253 var moveGateFrozenOnce sync.Once
123- d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) {254+ d.afterAttachmentEffectGateFrozen = func(action string, _ *attachedClient) {
124 if action != "move-pane" {255 if action != "move-pane" {
125 return256 return
126 }257 }
@@ -129,14 +260,14 @@ func TestMovePaneGateReservationRaceMoveWinsWithoutDeadlock(t *testing.T) {
129 }260 }
130 killSnapshotted := make(chan struct{})261 killSnapshotted := make(chan struct{})
131 var killSnapshottedOnce sync.Once262 var killSnapshottedOnce sync.Once
132- d.afterRoleEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) {263+ d.afterAttachmentEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) {
133 if action == "" {264 if action == "" {
134 killSnapshottedOnce.Do(func() { close(killSnapshotted) })265 killSnapshottedOnce.Do(func() { close(killSnapshotted) })
135 }266 }
136 }267 }
137 defer func() {268 defer func() {
138- d.afterRoleEffectGateFrozen = nil269+ d.afterAttachmentEffectGateFrozen = nil
139- d.afterRoleEffectParticipantsSnapshotted = nil270+ d.afterAttachmentEffectParticipantsSnapshotted = nil
140 }()271 }()
141 272
142 moveDone := make(chan error, 1)273 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 @@
1 package daemon1 package daemon
2 2
3-import (3+import "github.com/bnema/vev/internal/usecase/layout"
4- "github.com/bnema/vev/internal/usecase/layout"
5-)
6 4
7 // movePaneAdmission is an immutable snapshot of the exact live objects and5 // movePaneAdmission is an immutable snapshot of the exact live objects and
8 // generations a move may later commit. Resize fences are acquired only after6 // generations a move may later commit. Resize fences are acquired only after
@@ -13,13 +11,11 @@ type movePaneAdmission struct {
13 sourceTab *tab11 sourceTab *tab
14 destinationTab *tab12 destinationTab *tab
15 movedPane *pane13 movedPane *pane
16- sourceClient *attachedClient14+ sourceAttachments []*attachedClient
17- destinationClient *attachedClient15+ sourceTransports map[*attachedClient]transportSnapshot
18- sourceSnatched []*attachedClient
19 sourceGeneration uint6416 sourceGeneration uint64
20 destinationGeneration uint6417 destinationGeneration uint64
21 finalSourceTab bool18 finalSourceTab bool
22- sourceTabWasActive bool
23 }19 }
24 20
25 func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destination *session) (*movePaneAdmission, error) {21 func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destination *session) (*movePaneAdmission, error) {
@@ -33,6 +29,12 @@ func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destinat
33 if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) {29 if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) {
34 return nil, errMoveStaleTarget30 return nil, errMoveStaleTarget
35 }31 }
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+ }
36 38
37 sourceTab := findMoveTabLocked(source, req.SourceTabID)39 sourceTab := findMoveTabLocked(source, req.SourceTabID)
38 destinationTab := findMoveTabLocked(destination, req.DestinationTabID)40 destinationTab := findMoveTabLocked(destination, req.DestinationTabID)
@@ -56,19 +58,16 @@ func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destinat
56 return nil, errMoveStaleTarget58 return nil, errMoveStaleTarget
57 }59 }
58 60
61+ attachments := source.snapshotAttachmentsLocked()
62+ transports := make(map[*attachedClient]transportSnapshot, len(attachments))
63+ for _, ac := range attachments {
64+ transports[ac] = ac.transportSnapshot()
65+ }
59 return &movePaneAdmission{66 return &movePaneAdmission{
60- source: source,67+ source: source, destination: destination, sourceTab: sourceTab, destinationTab: destinationTab,
61- destination: destination,68+ movedPane: movedPane, sourceAttachments: attachments, sourceTransports: transports,
62- sourceTab: sourceTab,69+ sourceGeneration: sourceTab.layoutGeneration, destinationGeneration: destinationTab.layoutGeneration,
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,
70 finalSourceTab: len(source.tabs) == 1 && source.tabs[0] == sourceTab &&70 finalSourceTab: len(source.tabs) == 1 && source.tabs[0] == sourceTab &&
71 sourceTab.tree != nil && sourceTab.tree.Root != nil && len(layout.LeafIDs(sourceTab.tree.Root)) == 1,71 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,
73 }, nil72 }, nil
74 }73 }
M internal/usecase/daemon/move_pane_commit.go +18 -59
@@ -13,19 +13,14 @@ type movePaneCommit struct {
13 source, destination *session13 source, destination *session
14 sourceTab, destinationTab *tab14 sourceTab, destinationTab *tab
15 movedPane *pane15 movedPane *pane
16- sourceClient *attachedClient16+ sourceAttachments []*attachedClient
17- sourceSnatched []*attachedClient17+ sourceTransports map[*attachedClient]transportSnapshot
18 sourceGeneration uint6418 sourceGeneration uint64
19 destinationGeneration uint6419 destinationGeneration uint64
20- handoffFrozen bool20+ frozenEffects frozenAttachmentEffectGates
21- frozenRoles frozenRoleEffectGates
22- handoffReq attachmentTransitionRequest
23- handoffPublication *attachmentPublication
24 err error21 err error
25 22
26- handoffResult attachmentTransitionResult
27 syncCleanup syncTimerCleanup23 syncCleanup syncTimerCleanup
28- sourceCleanupToken attachmentRoleToken
29 sourceEmpty bool24 sourceEmpty bool
30 sourceTabRemoved bool25 sourceTabRemoved bool
31 retiredParked []parkedAttachmentRetirement26 retiredParked []parkedAttachmentRetirement
@@ -36,16 +31,8 @@ type movePaneCommit struct {
36 destinationMetadataValid bool31 destinationMetadataValid bool
37 }32 }
38 33
39-func (c *movePaneCommit) releasePublication() {
40- if c != nil && c.handoffPublication != nil {
41- c.handoffPublication.unlockCoordinators()
42- c.handoffPublication = nil
43- }
44-}
45-
46 func (c *movePaneCommit) publishLocked(d *Daemon) bool {34 func (c *movePaneCommit) publishLocked(d *Daemon) bool {
47 var candidate *movePaneCandidate35 var candidate *movePaneCandidate
48- var retirement frozenMoveAttachmentRetirement
49 var err error36 var err error
50 d.mu.Lock()37 d.mu.Lock()
51 if d.closing || d.sessions[c.source.id] != c.source || d.sessions[c.destination.id] != c.destination {38 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 {
61 if !moveSessionLocatorCurrentLocked(c.source, c.req.Source) || !moveSessionLocatorCurrentLocked(c.destination, c.req.Destination) {48 if !moveSessionLocatorCurrentLocked(c.source, c.req.Source) || !moveSessionLocatorCurrentLocked(c.destination, c.req.Destination) {
62 return false49 return false
63 }50 }
64- if c.handoffFrozen {51+ if c.req.Attachment != nil && !attachmentRegisteredLocked(c.source, c.req.Attachment) {
65- if c.source.client != c.sourceClient || !sameMoveSnatchedLocked(c.source, c.sourceSnatched) ||52+ return false
66- c.handoffReq.targetTabIndex < 0 || c.handoffReq.targetTabIndex >= len(c.destination.tabs) ||53+ }
67- c.destination.tabs[c.handoffReq.targetTabIndex] != c.destinationTab {54+ if c.req.AttachmentToken.ac != nil && (c.req.AttachmentToken.ac != c.req.Attachment || !moveAttachmentTokenCurrentLocked(c.req.AttachmentToken, c.source)) {
68- return false55+ 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()
76 }56 }
77 57
78 unlockTabs := lockMoveTabs(c.sourceTab, c.destinationTab)58 unlockTabs := lockMoveTabs(c.sourceTab, c.destinationTab)
@@ -101,34 +81,22 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool {
101 }81 }
102 82
103 sourceWillEmpty := candidate.removeSourceTab && len(c.source.tabs) == 183 sourceWillEmpty := candidate.removeSourceTab && len(c.source.tabs) == 1
104- if sourceWillEmpty && c.source != c.destination {84+ if sourceWillEmpty && c.source != c.destination && !sameMoveAttachmentsLocked(c.source, c.sourceAttachments) {
105- if c.source.client != c.sourceClient || !sameMoveSnatchedLocked(c.source, c.sourceSnatched) ||85+ return false
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- }
114 }86 }
115 87
116- // Everything above is fallible. Topology, owner, lifecycle registry, and88+ // Everything above is fallible. Shared topology and owner publication are
117- // optional attachment roles now become visible under one lock section.89+ // committed once; attachment views are repaired after these locks release.
118 if candidate.removeSourceTab {90 if candidate.removeSourceTab {
119 idx := indexMoveTabLocked(c.source, c.sourceTab)91 idx := indexMoveTabLocked(c.source, c.sourceTab)
120 if idx < 0 {92 if idx < 0 {
121 return false93 return false
122 }94 }
95+ if len(c.source.tabs) > 1 {
96+ c.source.prepareAttachmentViewsForRemovedTabLocked(c.sourceTab, idx)
97+ }
123 c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...)98 c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...)
124 c.sourceTabRemoved = true99 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- }
132 } else {100 } else {
133 c.sourceTab.tree = candidate.sourceTree101 c.sourceTab.tree = candidate.sourceTree
134 c.sourceTab.bumpLayoutGenerationLocked()102 c.sourceTab.bumpLayoutGenerationLocked()
@@ -143,21 +111,12 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool {
143 c.destinationTab.bumpLayoutGenerationLocked()111 c.destinationTab.bumpLayoutGenerationLocked()
144 c.movedPane.id = candidate.destinationID112 c.movedPane.id = candidate.destinationID
145 oldOwner, newOwner := c.movedPane.publishOwnerLocked(c.destination, c.destinationTab, 0)113 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- }
151 c.syncCleanup.append(d.migratePaneSyncOwnerLocked(c.movedPane, oldOwner, newOwner))114 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- }
157 115
158- if candidate.removeSourceTab && len(c.source.tabs) == 0 {116+ if sourceWillEmpty {
159 d.unregisterSessionLocked(c.source)117 d.unregisterSessionLocked(c.source)
160- c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement)118+ c.retiredAttachments = detachMoveAttachmentsLocked(c.source, c.sourceTransports)
119+ c.source.tabs = nil
161 d.purgeParkingForSessionLocked(c.source)120 d.purgeParkingForSessionLocked(c.source)
162 c.retiredParked = d.purgeParkedForSessionLocked(c.source)121 c.retiredParked = d.purgeParkedForSessionLocked(c.source)
163 }122 }
M internal/usecase/daemon/move_pane_postcommit.go +35 -78
@@ -20,25 +20,22 @@ func firstMovePane(panes []*pane) *pane {
20 }20 }
21 21
22 type movePanePostcommitPlan struct {22 type movePanePostcommitPlan struct {
23- source *session23+ source *session
24- destination *session24+ destination *session
25- sourceTab *tab25+ sourceTab *tab
26- destinationTab *tab26+ destinationTab *tab
27- movedPane *pane27+ movedPane *pane
28- movedPanes []*pane28+ movedPanes []*pane
29- destinationClient *attachedClient29+ sourceAttachments []*attachedClient
30- sourceCleanupToken attachmentRoleToken30+ syncCleanup syncTimerCleanup
31- handoffResult attachmentTransitionResult31+ frozenEffects frozenAttachmentEffectGates
32- syncCleanup syncTimerCleanup32+ effectsFrozen bool
33- frozenRoles frozenRoleEffectGates33+ unlockDispatch func()
34- rolesFrozen bool34+ reservation *moveLifecycleReservation
35- unlockDispatch func()35+ oldTabCancel context.CancelFunc
36- reservation *moveLifecycleReservation
37- oldTabCancel context.CancelFunc
38 36
39- sourceTabWasActive bool37+ sourceTabRemoved bool
40- sourceTabRemoved bool38+ sourceEmpty bool
41- sourceEmpty bool
42 39
43 retiredParked []parkedAttachmentRetirement40 retiredParked []parkedAttachmentRetirement
44 retiredAttachments []detachedAttachmentSnapshot41 retiredAttachments []detachedAttachmentSnapshot
@@ -57,12 +54,11 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
57 } else if p.sourceTabRemoved && p.sourceTab != nil && p.sourceTab.cancel != nil {54 } else if p.sourceTabRemoved && p.sourceTab != nil && p.sourceTab.cancel != nil {
58 p.sourceTab.cancel()55 p.sourceTab.cancel()
59 }56 }
60- if p.sourceCleanupToken.current() {57+ panes := p.movedPanes
61- cleanupClient := p.sourceCleanupToken.ac58+ if len(panes) == 0 && p.movedPane != nil {
62- panes := p.movedPanes59+ panes = []*pane{p.movedPane}
63- if len(panes) == 0 && p.movedPane != nil {60+ }
64- panes = []*pane{p.movedPane}61+ for _, cleanupClient := range p.sourceAttachments {
65- }
66 for _, movedPane := range panes {62 for _, movedPane := range panes {
67 if cleanupClient.overlays != nil {63 if cleanupClient.overlays != nil {
68 cleanupClient.overlays.clearCopyModeForPane(movedPane)64 cleanupClient.overlays.clearCopyModeForPane(movedPane)
@@ -78,19 +74,23 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
78 markSnapshotDirty(p.source)74 markSnapshotDirty(p.source)
79 }75 }
80 p.syncCleanup.finish()76 p.syncCleanup.finish()
81- if p.rolesFrozen {77+ if p.effectsFrozen {
82- p.frozenRoles.unfreeze()78+ p.frozenEffects.unfreeze()
83 }79 }
84 p.unlockDispatch()80 p.unlockDispatch()
85 p.reservation.Release()81 p.reservation.Release()
86 82
87- if p.sourceTabWasActive && !moveSessionRetired(d, p.source) {83+ // Repair attachment-local stable targets after shared topology publication
88- d.activateTab(p.source, p.source.activeTab())84+ // and after the move dispatch locks have been released.
85+ p.source.repairAttachmentViews()
86+ if p.destination != p.source {
87+ p.destination.repairAttachmentViews()
89 }88 }
89+
90 if !moveSessionRetired(d, p.source) {90 if !moveSessionRetired(d, p.source) {
91 sourceLayoutTab := p.sourceTab91 sourceLayoutTab := p.sourceTab
92 if p.sourceTabRemoved {92 if p.sourceTabRemoved {
93- sourceLayoutTab = p.source.activeTab()93+ sourceLayoutTab = p.source.firstTab()
94 }94 }
95 if sourceLayoutTab != nil && sourceLayoutTab != p.destinationTab {95 if sourceLayoutTab != nil && sourceLayoutTab != p.destinationTab {
96 d.applyTabLayout(p.source, sourceLayoutTab)96 d.applyTabLayout(p.source, sourceLayoutTab)
@@ -103,18 +103,13 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
103 if p.destinationMetadataValid {103 if p.destinationMetadataValid {
104 d.markCatalogueDirty(p.destinationMetadata)104 d.markCatalogueDirty(p.destinationMetadata)
105 }105 }
106- if p.destinationClient != nil && sessionClientIs(p.destination, p.destinationClient) && destinationTabIsActive(p.destination, p.destinationTab) {106+ for _, ac := range p.destination.snapshotAttachments() {
107- d.invalidateRender(p.destination, p.destinationClient, true, "move.go")107+ d.invalidateRender(p.destination, ac, true, "move.go")
108 }108 }
109- if p.sourceCleanupToken.ac != nil && !p.sourceEmpty && p.sourceTabWasActive && sessionClientIs(p.source, p.sourceCleanupToken.ac) {109+ if !p.sourceEmpty && p.source != p.destination {
110- d.invalidateRender(p.source, p.sourceCleanupToken.ac, true, "move.go")110+ for _, ac := range p.source.snapshotAttachments() {
111- }111+ d.invalidateRender(p.source, ac, true, "move.go")
112- if p.handoffResult.published.ac != nil {112+ }
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)
118 }113 }
119 if p.sourceEmpty {114 if p.sourceEmpty {
120 retireEmptySessionAfterMove(d, p.source)115 retireEmptySessionAfterMove(d, p.source)
@@ -142,26 +137,6 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
142 }137 }
143 }138 }
144 139
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-
165 func moveSessionRetired(d *Daemon, sess *session) bool {140 func moveSessionRetired(d *Daemon, sess *session) bool {
166 if d == nil || sess == nil {141 if d == nil || sess == nil {
167 return true142 return true
@@ -172,24 +147,6 @@ func moveSessionRetired(d *Daemon, sess *session) bool {
172 return !live147 return !live
173 }148 }
174 149
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-
193 // retireEmptySessionAfterMove completes only source-owned lifecycle work. The150 // retireEmptySessionAfterMove completes only source-owned lifecycle work. The
194 // moved pane has already been removed from source.tabs, so stopInMemoryLifecycle151 // moved pane has already been removed from source.tabs, so stopInMemoryLifecycle
195 // cannot cancel or close the transferred PTY.152 // 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) {
177 }177 }
178 d, source, client, _ := newManualSessionWithPTYs(t, ptys...)178 d, source, client, _ := newManualSessionWithPTYs(t, ptys...)
179 if tt.finalSource {179 if tt.finalSource {
180- source.client = nil180+ clearAttachmentsForTest(source)
181 client.setSession(nil)181 client.setSession(nil)
182 }182 }
183 source.incarnation = domain.IncarnationID{1}183 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 {
16 }16 }
17 sess.mu.Lock()17 sess.mu.Lock()
18 defer sess.mu.Unlock()18 defer sess.mu.Unlock()
19- if source.Client != nil && sess.client != source.Client {19+ if source.AttachmentToken.ac != nil && !moveAttachmentTokenCurrentLocked(source.AttachmentToken, sess) {
20- return domain.UserWarn(domain.NoticeSessionUnavailable, "Source client is no longer active.", errMovePaneInvalid)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)
21 }24 }
22 tb := findMoveTabLocked(sess, source.TabID)25 tb := findMoveTabLocked(sess, source.TabID)
23 if tb == nil {26 if tb == nil {
@@ -49,10 +52,11 @@ type moveSessionLocator struct {
49 }52 }
50 53
51 type moveSourceLocator struct {54 type moveSourceLocator struct {
52- Session moveSessionLocator55+ Session moveSessionLocator
53- TabID domain.TabStableID56+ TabID domain.TabStableID
54- PaneID domain.PaneStableID57+ PaneID domain.PaneStableID
55- Client *attachedClient58+ Attachment *attachedClient
59+ AttachmentToken attachmentConnectionToken
56 }60 }
57 61
58 // enterPickerForIntent returns errNoMoveDestination only for move intents.62 // enterPickerForIntent returns errNoMoveDestination only for move intents.
@@ -60,7 +64,10 @@ func (d *Daemon) enterPickerForIntent(sess *session, ac *attachedClient, intent
60 if intent != pickerNavigate && !sess.capabilities().yieldsMoves() {64 if intent != pickerNavigate && !sess.capabilities().yieldsMoves() {
61 return errSessionCannotYieldMoves65 return errSessionCannotYieldMoves
62 }66 }
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{})
64 if intent != pickerNavigate {71 if intent != pickerNavigate {
65 if _, ok := model.Selected(); !ok {72 if _, ok := model.Selected(); !ok {
66 return errNoMoveDestination73 return errNoMoveDestination
@@ -90,6 +97,8 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc
90 return errMovePaneInvalid97 return errMovePaneInvalid
91 }98 }
92 return d.movePane(movePaneRequest{99 return d.movePane(movePaneRequest{
100+ Attachment: source.Attachment,
101+ AttachmentToken: source.AttachmentToken,
93 Source: source.Session,102 Source: source.Session,
94 SourceTabID: source.TabID,103 SourceTabID: source.TabID,
95 SourcePaneID: source.PaneID,104 SourcePaneID: source.PaneID,
@@ -98,9 +107,11 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc
98 })107 })
99 case pickerMoveTab:108 case pickerMoveTab:
100 return d.moveTab(moveTabRequest{109 return d.moveTab(moveTabRequest{
101- Source: source.Session,110+ Attachment: source.Attachment,
102- SourceTabID: source.TabID,111+ AttachmentToken: source.AttachmentToken,
103- Destination: destination,112+ Source: source.Session,
113+ SourceTabID: source.TabID,
114+ Destination: destination,
104 })115 })
105 default:116 default:
106 return errMovePaneInvalid117 return errMovePaneInvalid
@@ -121,10 +132,10 @@ func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*sess
121 return nil, nil132 return nil, nil
122 }133 }
123 if intent == pickerMoveTab {134 if intent == pickerMoveTab {
124- if sess.active < 0 || sess.active >= len(sess.tabs) {135+ if len(sess.tabs) == 0 {
125 return nil, nil136 return nil, nil
126 }137 }
127- return sess, sess.tabs[sess.active]138+ return sess, sess.tabs[0]
128 }139 }
129 if target.TabID != "" {140 if target.TabID != "" {
130 for _, tb := range sess.tabs {141 for _, tb := range sess.tabs {
@@ -170,7 +181,7 @@ func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions
170 }181 }
171 }182 }
172 rt.pickerMu.Unlock()183 rt.pickerMu.Unlock()
173- model := d.newPickerModel(sess, intent, source, current)184+ model := d.newPickerModel(sess, ac, intent, source, current)
174 if rt.afterPickerRefreshBuild != nil {185 if rt.afterPickerRefreshBuild != nil {
175 rt.afterPickerRefreshBuild(model)186 rt.afterPickerRefreshBuild(model)
176 }187 }
M internal/usecase/daemon/move_picker_test.go +7 -66
@@ -1,7 +1,6 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4- "sync/atomic"
5 "testing"4 "testing"
6 "time"5 "time"
7 6
@@ -36,7 +35,7 @@ func setupMovePickerSessionsWithClock(t *testing.T, clock ports.Clock, extraDest
36 releases = append(releases, releaseDest)35 releases = append(releases, releaseDest)
37 destinationTab := newTabWithStableID("destination-tab", "destination-pane", destPTY, domain.Size{Cols: 80, Rows: 23})36 destinationTab := newTabWithStableID("destination-tab", "destination-pane", destPTY, domain.Size{Cols: 80, Rows: 23})
38 publishTiledPaneOwners(source, sourceTab)37 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}}
40 publishTiledPaneOwners(destination, destinationTab)39 publishTiledPaneOwners(destination, destinationTab)
41 for range extraDestinationTabs {40 for range extraDestinationTabs {
42 extraPTY, releaseExtra := newBlockingPTY(t)41 extraPTY, releaseExtra := newBlockingPTY(t)
@@ -68,7 +67,7 @@ func TestPaletteMovePaneCapturesSourceAndOpensPicker(t *testing.T) {
68 require.Equal(t, moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, captured.Session)67 require.Equal(t, moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, captured.Session)
69 require.Equal(t, domain.TabStableID("source-tab"), captured.TabID)68 require.Equal(t, domain.TabStableID("source-tab"), captured.TabID)
70 require.Equal(t, domain.PaneStableID("source-pane"), captured.PaneID)69 require.Equal(t, domain.PaneStableID("source-pane"), captured.PaneID)
71- require.Same(t, ac, captured.Client)70+ require.Same(t, ac, captured.Attachment)
72 }71 }
73 72
74 func TestPaletteMoveTabCapturesActiveTabAndOpensPicker(t *testing.T) {73 func TestPaletteMoveTabCapturesActiveTabAndOpensPicker(t *testing.T) {
@@ -87,7 +86,7 @@ func TestPaletteMoveTabCapturesActiveTabAndOpensPicker(t *testing.T) {
87 require.Equal(t, pickerMoveTab, intent)86 require.Equal(t, pickerMoveTab, intent)
88 require.Equal(t, domain.TabStableID("active-tab"), captured.TabID)87 require.Equal(t, domain.TabStableID("active-tab"), captured.TabID)
89 require.Equal(t, domain.PaneStableID(""), captured.PaneID)88 require.Equal(t, domain.PaneStableID(""), captured.PaneID)
90- require.Same(t, ac, captured.Client)89+ require.Same(t, ac, captured.Attachment)
91 }90 }
92 91
93 func TestPaletteMoveWithoutDestinationShowsToastAndNoPicker(t *testing.T) {92 func TestPaletteMoveWithoutDestinationShowsToastAndNoPicker(t *testing.T) {
@@ -110,7 +109,7 @@ func TestMovePickerEnterCommitsMovePane(t *testing.T) {
110 d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0)109 d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0)
111 defer releaseAll(releases)110 defer releaseAll(releases)
112 source.mu.Lock()111 source.mu.Lock()
113- source.client = nil112+ clearAttachmentsForTestLocked(source)
114 source.mu.Unlock()113 source.mu.Unlock()
115 sourceTab := source.tabs[0]114 sourceTab := source.tabs[0]
116 moved := sourceTab.focusedPane()115 moved := sourceTab.focusedPane()
@@ -131,7 +130,7 @@ func TestMovePickerEnterCommitsMovePaneViaSharedAPI(t *testing.T) {
131 d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0)130 d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0)
132 defer releaseAll(releases)131 defer releaseAll(releases)
133 source.mu.Lock()132 source.mu.Lock()
134- source.client = nil133+ clearAttachmentsForTestLocked(source)
135 source.mu.Unlock()134 source.mu.Unlock()
136 sourceTab := source.tabs[0]135 sourceTab := source.tabs[0]
137 moved := sourceTab.focusedPane()136 moved := sourceTab.focusedPane()
@@ -158,7 +157,7 @@ func TestMovePickerEnterCommitsMoveTab(t *testing.T) {
158 d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)157 d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
159 defer releaseAll(releases)158 defer releaseAll(releases)
160 source.mu.Lock()159 source.mu.Lock()
161- source.client = nil160+ clearAttachmentsForTestLocked(source)
162 source.mu.Unlock()161 source.mu.Unlock()
163 movedTab := source.tabs[0]162 movedTab := source.tabs[0]
164 movedTab.stableID = "moved-tab"163 movedTab.stableID = "moved-tab"
@@ -257,64 +256,6 @@ func TestMovePickerStaleSourceTabReportsPreciseFeedback(t *testing.T) {
257 require.Equal(t, "Tab no longer exists.", history[0].Message)256 require.Equal(t, "Tab no longer exists.", history[0].Message)
258 }257 }
259 258
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-
318 func TestMovePickerRefreshCloseKeepsReplacementPicker(t *testing.T) {259 func TestMovePickerRefreshCloseKeepsReplacementPicker(t *testing.T) {
319 d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)260 d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
320 defer releaseAll(releases)261 defer releaseAll(releases)
@@ -343,7 +284,7 @@ func TestMovePickerRefreshCloseKeepsReplacementPicker(t *testing.T) {
343 t.Fatal("timed out waiting for move-picker refresh rebuild")284 t.Fatal("timed out waiting for move-picker refresh rebuild")
344 }285 }
345 286
346- replacement := d.newPickerModel(source, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})287+ replacement := d.newPickerModel(source, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
347 d.publishPicker(source, ac, replacement, pickerNavigate, moveSourceLocator{})288 d.publishPicker(source, ac, replacement, pickerNavigate, moveSourceLocator{})
348 close(allowClose)289 close(allowClose)
349 select {290 select {
M internal/usecase/daemon/move_stale_destination_test.go +1 -1
@@ -18,7 +18,7 @@ func TestMovePaneRejectsStaleDestinationIncarnationWithoutMutation(t *testing.T)
18 sourceTab := source.tabs[0]18 sourceTab := source.tabs[0]
19 sourceTab.stableID = "source-tab"19 sourceTab.stableID = "source-tab"
20 moved := sourceTab.focusedPane()20 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})}}
22 destinationTab := destination.tabs[0]22 destinationTab := destination.tabs[0]
23 publishTiledPaneOwners(destination, destinationTab)23 publishTiledPaneOwners(destination, destinationTab)
24 d.mu.Lock()24 d.mu.Lock()
M internal/usecase/daemon/move_tab.go +65 -145
@@ -10,8 +10,8 @@ import (
10 type moveTabAdmission struct {10 type moveTabAdmission struct {
11 tab *tab11 tab *tab
12 sourceIndex int12 sourceIndex int
13- sourceClient *attachedClient13+ sourceAttachments []*attachedClient
14- sourceSnatched []*attachedClient14+ sourceTransports map[*attachedClient]transportSnapshot
15 layoutGeneration uint6415 layoutGeneration uint64
16 floatingState floatingState16 floatingState floatingState
17 floatingPane *pane17 floatingPane *pane
@@ -27,14 +27,9 @@ type moveTabCommit struct {
27 source *session27 source *session
28 destination *session28 destination *session
29 admission moveTabAdmission29 admission moveTabAdmission
30- handoffFrozen bool30+ sourceEffectsFrozen bool
31- sourceRolesFrozen bool31+ frozenEffects frozenAttachmentEffectGates
32- frozenRoles frozenRoleEffectGates
33- handoffReq attachmentTransitionRequest
34- handoffPublication *attachmentPublication
35- handoffResult attachmentTransitionResult
36 syncCleanup syncTimerCleanup32 syncCleanup syncTimerCleanup
37- sourceCleanupToken attachmentRoleToken
38 oldTabCancel context.CancelFunc33 oldTabCancel context.CancelFunc
39 sourceEmpty bool34 sourceEmpty bool
40 retiredParked []parkedAttachmentRetirement35 retiredParked []parkedAttachmentRetirement
@@ -46,27 +41,6 @@ type moveTabCommit struct {
46 err error41 err error
47 }42 }
48 43
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-
70 func (d *Daemon) moveTab(req moveTabRequest) (result error) {44 func (d *Daemon) moveTab(req moveTabRequest) (result error) {
71 defer func() { result = normalizeMoveRejection(result) }()45 defer func() { result = normalizeMoveRejection(result) }()
72 if d == nil || req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID {46 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) {
82 if source == destination {56 if source == destination {
83 return errMovePaneInvalid57 return errMovePaneInvalid
84 }58 }
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+
85 reservation, err := d.reserveMoveLifecycles(source, destination)73 reservation, err := d.reserveMoveLifecycles(source, destination)
86 if err != nil {74 if err != nil {
87 return errMoveStaleTarget75 return errMoveStaleTarget
@@ -95,13 +83,6 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
95 if d.afterMoveLifecycleReserved != nil {83 if d.afterMoveLifecycleReserved != nil {
96 d.afterMoveLifecycleReserved()84 d.afterMoveLifecycleReserved()
97 }85 }
98- unlockDispatch := lockMoveDispatch(source, destination)
99- dispatchHeld := true
100- defer func() {
101- if dispatchHeld {
102- unlockDispatch()
103- }
104- }()
105 86
106 admission, err := d.snapshotMoveTabAdmission(req, source, destination)87 admission, err := d.snapshotMoveTabAdmission(req, source, destination)
107 if err != nil {88 if err != nil {
@@ -111,59 +92,35 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
111 d.afterMoveTabSourceSnapshot()92 d.afterMoveTabSourceSnapshot()
112 }93 }
113 commit := moveTabCommit{req: req, source: source, destination: destination, admission: *admission}94 commit := moveTabCommit{req: req, source: source, destination: destination, admission: *admission}
114- var frozen frozenRoleEffectGates95+ var frozen frozenAttachmentEffectGates
115- if admission.finalSource && admission.sourceClient != nil {96+ if admission.finalSource && len(admission.sourceAttachments) != 0 {
116- transport := admission.sourceClient.transportSnapshot()97+ interrupts := make([]attachmentTransportInterrupt, 0, len(admission.sourceAttachments))
117- token := source.attachmentToken(admission.sourceClient, transport.transport)98+ for _, ac := range admission.sourceAttachments {
118- if token.role != attachmentActive || token.transport.transport == nil {99+ if transport := admission.sourceTransports[ac]; transport.transport != nil {
119- return errMovePaneInvalid100+ interrupts = append(interrupts, attachmentTransportInterrupt{ac: ac, transport: transport})
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)
138 }101 }
139- }}, participants.clients...)
140- if !frozen.acquired || !frozen.drained {
141- frozen.unfreeze()
142- return errMovePaneInvalid
143 }102 }
144- commit.handoffFrozen = true103+ frozen = freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) {
145- commit.sourceRolesFrozen = true104+ if d.afterAttachmentEffectGateFrozen != nil {
146- commit.handoffReq.roleEffectsFrozen = true105+ d.afterAttachmentEffectGateFrozen("move-tab", ac)
147- } else if admission.finalSource && len(admission.sourceSnatched) > 0 {106+ }
148- clients, interrupts := d.snapshotSnatchedRoleParticipants(source, admission.sourceSnatched)107+ }}, admission.sourceAttachments...)
149- frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: interrupts, nonblocking: true}, clients...)
150 if !frozen.acquired || !frozen.drained {108 if !frozen.acquired || !frozen.drained {
151 frozen.unfreeze()109 frozen.unfreeze()
152 return errMovePaneInvalid110 return errMovePaneInvalid
153 }111 }
154- commit.sourceRolesFrozen = true112+ commit.sourceEffectsFrozen = true
155 }113 }
156- commit.frozenRoles = frozen114+ commit.frozenEffects = frozen
157- rolesFrozen := commit.sourceRolesFrozen115+ effectsFrozen := commit.sourceEffectsFrozen
158 defer func() {116 defer func() {
159- if rolesFrozen {117+ if effectsFrozen {
160 frozen.unfreeze()118 frozen.unfreeze()
161 }119 }
162 }()120 }()
163 121
164 fences := newMoveTabResizeFences(source, destination, admission.tab)122 fences := newMoveTabResizeFences(source, destination, admission.tab)
165 if !fences.acquire(func() bool { return commit.publishLocked(d, fences.panes) }) {123 if !fences.acquire(func() bool { return commit.publishLocked(d, fences.panes) }) {
166- commit.releasePublication()
167 if commit.err != nil {124 if commit.err != nil {
168 return commit.err125 return commit.err
169 }126 }
@@ -177,15 +134,13 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
177 destinationTab: admission.tab,134 destinationTab: admission.tab,
178 movedPane: firstMovePane(admission.panes),135 movedPane: firstMovePane(admission.panes),
179 movedPanes: admission.panes,136 movedPanes: admission.panes,
180- sourceCleanupToken: commit.sourceCleanupToken,137+ sourceAttachments: admission.sourceAttachments,
181- handoffResult: commit.handoffResult,
182 syncCleanup: commit.syncCleanup,138 syncCleanup: commit.syncCleanup,
183- frozenRoles: frozen,139+ frozenEffects: frozen,
184- rolesFrozen: rolesFrozen,140+ effectsFrozen: effectsFrozen,
185 unlockDispatch: unlockDispatch,141 unlockDispatch: unlockDispatch,
186 reservation: reservation,142 reservation: reservation,
187 oldTabCancel: commit.oldTabCancel,143 oldTabCancel: commit.oldTabCancel,
188- sourceTabWasActive: !commit.sourceEmpty,
189 sourceTabRemoved: true,144 sourceTabRemoved: true,
190 sourceEmpty: commit.sourceEmpty,145 sourceEmpty: commit.sourceEmpty,
191 retiredParked: commit.retiredParked,146 retiredParked: commit.retiredParked,
@@ -195,7 +150,7 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
195 destinationMetadata: commit.destMetadata,150 destinationMetadata: commit.destMetadata,
196 destinationMetadataValid: commit.destMetadataValid,151 destinationMetadataValid: commit.destMetadataValid,
197 }152 }
198- rolesFrozen = false153+ effectsFrozen = false
199 dispatchHeld = false154 dispatchHeld = false
200 reservationHeld = false155 reservationHeld = false
201 postcommit.execute(d)156 postcommit.execute(d)
@@ -213,11 +168,17 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio
213 if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) {168 if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) {
214 return nil, errMoveStaleTarget169 return nil, errMoveStaleTarget
215 }170 }
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+ }
216 moved := findMoveTabLocked(source, req.SourceTabID)177 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 {
218 return nil, errMoveStaleTarget179 return nil, errMoveStaleTarget
219 }180 }
220- destinationActive := destination.tabs[destination.active]181+ destinationActive := destination.tabs[0]
221 destinationActive.mu.Lock()182 destinationActive.mu.Lock()
222 destinationSize := destinationActive.size183 destinationSize := destinationActive.size
223 destinationActive.mu.Unlock()184 destinationActive.mu.Unlock()
@@ -240,9 +201,13 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio
240 panes = append(panes, moved.floating.pane)201 panes = append(panes, moved.floating.pane)
241 }202 }
242 idx := indexMoveTabLocked(source, moved)203 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+ }
243 return &moveTabAdmission{209 return &moveTabAdmission{
244- tab: moved, sourceIndex: idx,210+ tab: moved, sourceIndex: idx, sourceAttachments: attachments, sourceTransports: transports,
245- sourceClient: source.client, sourceSnatched: snapshotMoveSnatchedLocked(source),
246 layoutGeneration: moved.layoutGeneration, floatingState: moved.floating.state,211 layoutGeneration: moved.layoutGeneration, floatingState: moved.floating.state,
247 floatingPane: moved.floating.pane, floatingGeneration: moved.floating.generation,212 floatingPane: moved.floating.pane, floatingGeneration: moved.floating.generation,
248 panes: panes, destinationActive: destinationActive, destinationSize: destinationSize,213 panes: panes, destinationActive: destinationActive, destinationSize: destinationSize,
@@ -250,13 +215,6 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio
250 }, nil215 }, nil
251 }216 }
252 217
253-func (c *moveTabCommit) releasePublication() {
254- if c.handoffPublication != nil {
255- c.handoffPublication.unlockCoordinators()
256- c.handoffPublication = nil
257- }
258-}
259-
260 func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {218 func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
261 d.mu.Lock()219 d.mu.Lock()
262 if d.closing || d.sessions[c.source.id] != c.source || d.sessions[c.destination.id] != c.destination {220 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 {
271 if !moveSessionLocatorCurrentLocked(c.source, c.req.Source) || !moveSessionLocatorCurrentLocked(c.destination, c.req.Destination) {229 if !moveSessionLocatorCurrentLocked(c.source, c.req.Source) || !moveSessionLocatorCurrentLocked(c.destination, c.req.Destination) {
272 return false230 return false
273 }231 }
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+ }
274 moved := c.admission.tab238 moved := c.admission.tab
275 if indexMoveTabLocked(c.source, moved) != c.admission.sourceIndex || findMoveTabLocked(c.destination, c.req.SourceTabID) != nil ||239 if indexMoveTabLocked(c.source, moved) != c.admission.sourceIndex || findMoveTabLocked(c.destination, c.req.SourceTabID) != nil ||
276 len(c.source.tabs) == 1 != c.admission.finalSource {240 len(c.source.tabs) == 1 != c.admission.finalSource {
277 return false241 return false
278 }242 }
279- // Attachment validation inspects the expected source tab and therefore must243+ if c.admission.finalSource && !sameMoveAttachmentsLocked(c.source, c.admission.sourceAttachments) {
280- // precede moved.mu. The resize fences and ordered session locks keep topology244+ return false
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- }
305 }245 }
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 {
307 return false247 return false
308 }248 }
309 c.admission.destinationActive.mu.Lock()249 c.admission.destinationActive.mu.Lock()
@@ -329,17 +269,10 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
329 }269 }
330 270
331 idx := c.admission.sourceIndex271 idx := c.admission.sourceIndex
332- c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...)272+ if len(c.source.tabs) > 1 {
333- if len(c.source.tabs) == 0 {273+ c.source.prepareAttachmentViewsForRemovedTabLocked(moved, idx)
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
342 }274 }
275+ c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...)
343 c.destination.tabs = append(c.destination.tabs, moved)276 c.destination.tabs = append(c.destination.tabs, moved)
344 moved.size = c.admission.destinationSize277 moved.size = c.admission.destinationSize
345 moved.bumpLayoutGenerationLocked()278 moved.bumpLayoutGenerationLocked()
@@ -366,26 +299,13 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
366 if d.beforeMoveTabCommit != nil {299 if d.beforeMoveTabCommit != nil {
367 d.beforeMoveTabCommit()300 d.beforeMoveTabCommit()
368 }301 }
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.
378 for _, change := range ownerChanges {302 for _, change := range ownerChanges {
379 c.syncCleanup.append(d.migratePaneSyncOwnerLocked(change.pane, change.oldOwner, change.newOwner))303 c.syncCleanup.append(d.migratePaneSyncOwnerLocked(change.pane, change.oldOwner, change.newOwner))
380 }304 }
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- }
386 if len(c.source.tabs) == 0 {305 if len(c.source.tabs) == 0 {
387 d.unregisterSessionLocked(c.source)306 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
389 d.purgeParkingForSessionLocked(c.source)309 d.purgeParkingForSessionLocked(c.source)
390 c.retiredParked = d.purgeParkedForSessionLocked(c.source)310 c.retiredParked = d.purgeParkedForSessionLocked(c.source)
391 }311 }
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) {
27 moved.name = "preserved-name"27 moved.name = "preserved-name"
28 remaining := source.tabs[1]28 remaining := source.tabs[1]
29 remaining.stableID = "remaining-tab"29 remaining.stableID = "remaining-tab"
30- source.active = 030+ selectTestAttachmentTab(source, 0)
31 31
32 destinationCtx, destinationCancel := context.WithCancel(d.serveCtx)32 destinationCtx, destinationCancel := context.WithCancel(d.serveCtx)
33 t.Cleanup(destinationCancel)33 t.Cleanup(destinationCancel)
34 destinationTab := newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 100, Rows: 30})34 destinationTab := newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 100, Rows: 30})
35 destinationTab.ctx, destinationTab.cancel = context.WithCancel(destinationCtx)35 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}}
37 publishTiledPaneOwners(destination, destinationTab)37 publishTiledPaneOwners(destination, destinationTab)
38 38
39 floating := newPaneWithStableID("floating", "floating-stable", floatingPTY, domain.Size{Cols: 20, Rows: 8})39 floating := newPaneWithStableID("floating", "floating-stable", floatingPTY, domain.Size{Cols: 20, Rows: 8})
@@ -53,7 +53,7 @@ func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) {
53 oldWorkerCtx := moved.ctx53 oldWorkerCtx := moved.ctx
54 oldTree := moved.tree54 oldTree := moved.tree
55 oldPanes := moved.panes55 oldPanes := moved.panes
56- beforeDestinationActive := destination.tabs[destination.active]56+ beforeDestinationActive := destination.tabs[testAttachmentTabIndex(destination)]
57 57
58 err := d.moveTab(moveTabRequest{58 err := d.moveTab(moveTabRequest{
59 Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},59 Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
@@ -63,10 +63,10 @@ func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) {
63 require.NoError(t, err)63 require.NoError(t, err)
64 64
65 require.Equal(t, []*tab{remaining}, source.tabs)65 require.Equal(t, []*tab{remaining}, source.tabs)
66- require.Zero(t, source.active)66+ require.Zero(t, testAttachmentTabIndex(source))
67- require.Same(t, remaining, source.tabs[source.active])67+ require.Same(t, remaining, source.tabs[testAttachmentTabIndex(source)])
68 require.Equal(t, []*tab{destinationTab, moved}, destination.tabs)68 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")
70 require.Equal(t, destinationTab.size, moved.size, "destination geometry becomes authoritative")70 require.Equal(t, destinationTab.size, moved.size, "destination geometry becomes authoritative")
71 require.Equal(t, "preserved-name", moved.name)71 require.Equal(t, "preserved-name", moved.name)
72 require.Same(t, oldTree, moved.tree)72 require.Same(t, oldTree, moved.tree)
@@ -87,85 +87,6 @@ func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) {
87 }87 }
88 }88 }
89 89
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-
169 func TestMoveTabRejectsSameSessionStaleMembershipAndWarming(t *testing.T) {90 func TestMoveTabRejectsSameSessionStaleMembershipAndWarming(t *testing.T) {
170 tests := []struct {91 tests := []struct {
171 name string92 name string
@@ -231,7 +152,7 @@ func TestMoveTabFinalSourceVisibleFloatingRejectsStaleSlotTransitions(t *testing
231 d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())152 d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
232 moved := source.tabs[0]153 moved := source.tabs[0]
233 moved.stableID = "moved-tab"154 moved.stableID = "moved-tab"
234- require.NotNil(t, d.attachCoordinator(source, nil, client, true))155+ require.NotNil(t, d.attachCoordinator(source, client, true))
235 destination := addMoveTabTestSession(d, "destination", "destination-tab")156 destination := addMoveTabTestSession(d, "destination", "destination-tab")
236 floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8})157 floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8})
237 moved.mu.Lock()158 moved.mu.Lock()
@@ -241,7 +162,7 @@ func TestMoveTabFinalSourceVisibleFloatingRejectsStaleSlotTransitions(t *testing
241 162
242 source.layoutApplyMu.Lock()163 source.layoutApplyMu.Lock()
243 admitted := make(chan struct{})164 admitted := make(chan struct{})
244- d.afterRoleEffectGateFrozen = func(action string, ac *attachedClient) {165+ d.afterAttachmentEffectGateFrozen = func(action string, ac *attachedClient) {
245 if action == "move-tab" && ac == client {166 if action == "move-tab" && ac == client {
246 close(admitted)167 close(admitted)
247 }168 }
@@ -262,7 +183,7 @@ func TestMoveTabFinalSourceVisibleFloatingRejectsStaleSlotTransitions(t *testing
262 require.Error(t, awaitTestValue(t, moveDone, "stale floating transfer did not return"))183 require.Error(t, awaitTestValue(t, moveDone, "stale floating transfer did not return"))
263 require.NotContains(t, destination.tabs, moved)184 require.NotContains(t, destination.tabs, moved)
264 require.Same(t, source, floating.ownerSnapshot().session)185 require.Same(t, source, floating.ownerSnapshot().session)
265- d.afterRoleEffectGateFrozen = nil186+ d.afterAttachmentEffectGateFrozen = nil
266 })187 })
267 }188 }
268 }189 }
@@ -487,12 +408,12 @@ func (r *moveTabPurgeRepository) DeleteIncarnation(_ context.Context, id domain.
487 sourceFenceUnlocked := r.source.layoutApplyMu.TryLock()408 sourceFenceUnlocked := r.source.layoutApplyMu.TryLock()
488 destinationFenceUnlocked := r.destination.layoutApplyMu.TryLock()409 destinationFenceUnlocked := r.destination.layoutApplyMu.TryLock()
489 tabFenceUnlocked := r.moved.layoutApplyMu.TryLock()410 tabFenceUnlocked := r.moved.layoutApplyMu.TryLock()
490- roleUnlocked := r.client.roleEffects.mu.TryLock()411+ roleUnlocked := r.client.attachmentEffects.mu.TryLock()
491- roleStable := roleUnlocked && r.client.roleEffects.phase == roleEffectsStable412+ roleStable := roleUnlocked && r.client.attachmentEffects.phase == attachmentEffectsStable
492 r.outside = daemonUnlocked && routingUnlocked && sourceUnlocked && destinationUnlocked && tabUnlocked && paneUnlocked &&413 r.outside = daemonUnlocked && routingUnlocked && sourceUnlocked && destinationUnlocked && tabUnlocked && paneUnlocked &&
493 sourceFenceUnlocked && destinationFenceUnlocked && tabFenceUnlocked && roleStable414 sourceFenceUnlocked && destinationFenceUnlocked && tabFenceUnlocked && roleStable
494 if roleUnlocked {415 if roleUnlocked {
495- r.client.roleEffects.mu.Unlock()416+ r.client.attachmentEffects.mu.Unlock()
496 }417 }
497 if tabFenceUnlocked {418 if tabFenceUnlocked {
498 r.moved.layoutApplyMu.Unlock()419 r.moved.layoutApplyMu.Unlock()
@@ -532,7 +453,7 @@ func (r *moveTabPurgeRepository) DeleteIncarnation(_ context.Context, id domain.
532 453
533 func TestMoveTabFinalNamedSourcePurgesAfterDestinationAdmissionAndFailureDoesNotRollback(t *testing.T) {454 func TestMoveTabFinalNamedSourcePurgesAfterDestinationAdmissionAndFailureDoesNotRollback(t *testing.T) {
534 d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())455 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))
536 source.incarnation = domain.IncarnationID{4}457 source.incarnation = domain.IncarnationID{4}
537 moved := source.tabs[0]458 moved := source.tabs[0]
538 moved.stableID = "moved-tab"459 moved.stableID = "moved-tab"
@@ -572,7 +493,7 @@ func addMoveTabTestSession(d *Daemon, id domain.SessionID, tabID string) *sessio
572 ctx, cancel := context.WithCancel(d.serveCtx)493 ctx, cancel := context.WithCancel(d.serveCtx)
573 tb := newTabWithStableID(tabID, tabID+"-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})494 tb := newTabWithStableID(tabID, tabID+"-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
574 tb.ctx, tb.cancel = context.WithCancel(ctx)495 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}}
576 publishTiledPaneOwners(sess, tb)497 publishTiledPaneOwners(sess, tb)
577 d.mu.Lock()498 d.mu.Lock()
578 d.sessions[sess.id] = sess499 d.sessions[sess.id] = sess
M internal/usecase/daemon/move_test.go +6 -52
@@ -3,7 +3,6 @@ package daemon
3 import (3 import (
4 "context"4 "context"
5 "os"5 "os"
6- "sync/atomic"
7 "testing"6 "testing"
8 7
9 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
@@ -49,7 +48,7 @@ func TestMovePaneMutationCrossSession(t *testing.T) {
49 defer release2()48 defer release2()
50 d, source, _, _ := newManualSessionWithPTYs(t, p1)49 d, source, _, _ := newManualSessionWithPTYs(t, p1)
51 source.mu.Lock()50 source.mu.Lock()
52- source.client = nil51+ clearAttachmentsForTestLocked(source)
53 source.mu.Unlock()52 source.mu.Unlock()
54 sourceTab := source.tabs[0]53 sourceTab := source.tabs[0]
55 sourceTab.stableID = "source-tab"54 sourceTab.stableID = "source-tab"
@@ -57,7 +56,7 @@ func TestMovePaneMutationCrossSession(t *testing.T) {
57 movedCtx, movedCancel := context.WithCancel(d.paneProcessCtx)56 movedCtx, movedCancel := context.WithCancel(d.paneProcessCtx)
58 moved.ctx, moved.cancel = movedCtx, movedCancel57 moved.ctx, moved.cancel = movedCtx, movedCancel
59 58
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})}}
61 destinationTab := destination.tabs[0]60 destinationTab := destination.tabs[0]
62 destinationTab.stableID = "destination-tab"61 destinationTab.stableID = "destination-tab"
63 publishTiledPaneOwners(destination, destinationTab)62 publishTiledPaneOwners(destination, destinationTab)
@@ -81,47 +80,6 @@ func TestMovePaneMutationCrossSession(t *testing.T) {
81 require.NoError(t, moved.ctx.Err(), "retiring the source must not cancel the transferred pane process")80 require.NoError(t, moved.ctx.Err(), "retiring the source must not cancel the transferred pane process")
82 }81 }
83 82
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-
125 func TestMovePaneRetiresEmptySourceWithoutClosingTransferredResources(t *testing.T) {83 func TestMovePaneRetiresEmptySourceWithoutClosingTransferredResources(t *testing.T) {
126 movedPTY := newQuietPTY()84 movedPTY := newQuietPTY()
127 d, source, active, _ := newManualSessionWithPTYs(t, movedPTY)85 d, source, active, _ := newManualSessionWithPTYs(t, movedPTY)
@@ -129,7 +87,7 @@ func TestMovePaneRetiresEmptySourceWithoutClosingTransferredResources(t *testing
129 require.NoError(t, err)87 require.NoError(t, err)
130 require.NoError(t, clip.Close())88 require.NoError(t, clip.Close())
131 source.mu.Lock()89 source.mu.Lock()
132- source.client = nil90+ clearAttachmentsForTestLocked(source)
133 source.clipFiles = []string{clip.Name()}91 source.clipFiles = []string{clip.Name()}
134 source.mu.Unlock()92 source.mu.Unlock()
135 active.setSession(nil)93 active.setSession(nil)
@@ -140,9 +98,7 @@ func TestMovePaneRetiresEmptySourceWithoutClosingTransferredResources(t *testing
140 movedTab.stableID = "source-tab"98 movedTab.stableID = "source-tab"
141 99
142 destinationPTY := newQuietPTY()100 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})},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})}}
144- active: 0,
145- }
146 publishTiledPaneOwners(destination, destination.tabs[0])102 publishTiledPaneOwners(destination, destination.tabs[0])
147 d.mu.Lock()103 d.mu.Lock()
148 d.sessions[destination.id] = destination104 d.sessions[destination.id] = destination
@@ -179,7 +135,7 @@ func TestMovePaneRetiresSourceParkedClients(t *testing.T) {
179 movedPTY := newQuietPTY()135 movedPTY := newQuietPTY()
180 d, source, _, _ := newManualSessionWithPTYs(t, movedPTY)136 d, source, _, _ := newManualSessionWithPTYs(t, movedPTY)
181 source.mu.Lock()137 source.mu.Lock()
182- source.client = nil138+ clearAttachmentsForTestLocked(source)
183 source.mu.Unlock()139 source.mu.Unlock()
184 moved := source.tabs[0].focusedPane()140 moved := source.tabs[0].focusedPane()
185 movedTab := source.tabs[0]141 movedTab := source.tabs[0]
@@ -190,9 +146,7 @@ func TestMovePaneRetiresSourceParkedClients(t *testing.T) {
190 parked.initOverlays()146 parked.initOverlays()
191 require.True(t, d.parkAttachment(source, parked))147 require.True(t, d.parkAttachment(source, parked))
192 destinationPTY := newQuietPTY()148 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})},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})}}
194- active: 0,
195- }
196 publishTiledPaneOwners(destination, destination.tabs[0])150 publishTiledPaneOwners(destination, destination.tabs[0])
197 d.mu.Lock()151 d.mu.Lock()
198 d.sessions[destination.id] = destination152 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) {
16 tests := []struct {16 tests := []struct {
17 name string17 name string
18 action string18 action string
19- run func(*Daemon, *session, *session, *attachedClient, *roleEffectTicket) error19+ run func(*Daemon, *session, *session, *attachedClient, *attachmentEffectTicket) error
20 check func(*testing.T, *Daemon, *session, *session)20 check func(*testing.T, *Daemon, *session, *session)
21 }{21 }{
22 {22 {
23 name: "picker selection", action: "picker-select",23 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 {
25 d.enterPicker(source, ac)25 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")
27 },27 },
28 },28 },
29 {29 {
30 name: "palette session", action: "palette-session",30 name: "palette session", action: "palette-session",
31- run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *roleEffectTicket) error {31+ run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffectTicket) error {
32- return d.switchToTargetForRole(effect.roleToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session")32+ return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session")
33 },33 },
34 },34 },
35 {35 {
36 name: "palette recent session", action: "palette-recent-session",36 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 {
38 return paletteExec{d: d, sess: source, ac: ac, recent: []recentSession{{id: target.id}}, effect: effect}.JumpRecentSession(1)38 return paletteExec{d: d, sess: source, ac: ac, recent: []recentSession{{id: target.id}}, effect: effect}.JumpRecentSession(1)
39 },39 },
40 },40 },
41 {41 {
42 name: "session overflow", action: "overflow-session",42 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 {
44 source.mu.Lock()44 source.mu.Lock()
45 source.name = "alpha"45 source.name = "alpha"
46 source.mu.Unlock()46 source.mu.Unlock()
@@ -53,13 +53,13 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
53 },53 },
54 {54 {
55 name: "stopped session resume", action: "stopped-session",55 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 {
57 d.mu.Lock()57 d.mu.Lock()
58 delete(d.sessions, target.id)58 delete(d.sessions, target.id)
59 d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 9, state: ports.SessionStopped}59 d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 9, state: ports.SessionStopped}
60 d.mu.Unlock()60 d.mu.Unlock()
61 expectedCreatedAt := int64(9)61 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")
63 },63 },
64 check: func(t *testing.T, d *Daemon, _, _ *session) {64 check: func(t *testing.T, d *Daemon, _, _ *session) {
65 d.mu.Lock()65 d.mu.Lock()
@@ -72,8 +72,8 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
72 },72 },
73 {73 {
74 name: "transition prompt creation", action: "create-session",74 name: "transition prompt creation", action: "create-session",
75- run: func(d *Daemon, _ *session, _ *session, _ *attachedClient, effect *roleEffectTicket) error {75+ run: func(d *Daemon, _ *session, _ *session, _ *attachedClient, effect *attachmentEffectTicket) error {
76- return d.createSessionAndSwitchForRole(effect.roleToken(), "created")76+ return d.createSessionAndSwitchForAttachment(effect.connectionToken(), "created")
77 },77 },
78 check: func(t *testing.T, d *Daemon, _, _ *session) {78 check: func(t *testing.T, d *Daemon, _, _ *session) {
79 d.mu.Lock()79 d.mu.Lock()
@@ -97,17 +97,17 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
97 d.sessions[target.id] = target97 d.sessions[target.id] = target
98 d.mu.Unlock()98 d.mu.Unlock()
99 99
100- rc := d.attachCoordinator(source, nil, old, true)100+ rc := d.attachCoordinator(source, old, true)
101 token := source.attachmentToken(old, old.transport())101 token := source.attachmentToken(old, old.transport())
102 token.lease = rc.attachmentLease(old)102 token.lease = rc.attachmentLease(old)
103- old.publishRoleCapability(token)103+ old.publishAttachmentCapability(token)
104- effect, admitted := old.beginRoleEffect(token)104+ effect, admitted := old.beginAttachmentEffect(token)
105 require.True(t, admitted)105 require.True(t, admitted)
106 106
107 admissionEnded := make(chan struct{})107 admissionEnded := make(chan struct{})
108 releaseAction := make(chan struct{})108 releaseAction := make(chan struct{})
109 var admissionEndedOnce sync.Once109 var admissionEndedOnce sync.Once
110- d.afterActionRoleEffectEnded = func(action string) {110+ d.afterActionAttachmentEffectEnded = func(action string) {
111 if action == tt.action {111 if action == tt.action {
112 admissionEndedOnce.Do(func() { close(admissionEnded) })112 admissionEndedOnce.Do(func() { close(admissionEnded) })
113 <-releaseAction113 <-releaseAction
@@ -123,7 +123,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
123 123
124 replacement, err := d.transitionAttachment(attachmentTransitionRequest{124 replacement, err := d.transitionAttachment(attachmentTransitionRequest{
125 source: source, target: source, next: old,125 source: source, target: source, next: old,
126- expectedRole: attachmentActive, targetRole: attachmentActive,126+
127 expectedTransport: old.transportSnapshot(), ready: true,127 expectedTransport: old.transportSnapshot(), ready: true,
128 })128 })
129 require.NoError(t, err)129 require.NoError(t, err)
@@ -132,11 +132,11 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
132 132
133 require.Same(t, source, old.currentSession())133 require.Same(t, source, old.currentSession())
134 source.mu.Lock()134 source.mu.Lock()
135- require.Same(t, old, source.client)135+ require.Contains(t, source.snapshotAttachmentsLocked(), old)
136 source.mu.Unlock()136 source.mu.Unlock()
137 target.mu.Lock()137 target.mu.Lock()
138- require.Nil(t, target.client, "stale handoff attached to the target")138+ require.Empty(t, target.snapshotAttachmentsLocked(), "stale handoff attached to the target")
139- require.Zero(t, target.active, "stale handoff changed target tab focus")139+ require.Zero(t, testAttachmentTabIndexLocked(target), "stale handoff changed target tab focus")
140 target.mu.Unlock()140 target.mu.Unlock()
141 if tt.check != nil {141 if tt.check != nil {
142 tt.check(t, d, source, target)142 tt.check(t, d, source, target)
@@ -152,17 +152,17 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
152 d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 7, state: ports.SessionStopped}152 d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 7, state: ports.SessionStopped}
153 d.mu.Unlock()153 d.mu.Unlock()
154 154
155- rc := d.attachCoordinator(source, nil, old, true)155+ rc := d.attachCoordinator(source, old, true)
156 token := source.attachmentToken(old, old.transport())156 token := source.attachmentToken(old, old.transport())
157 token.lease = rc.attachmentLease(old)157 token.lease = rc.attachmentLease(old)
158- old.publishRoleCapability(token)158+ old.publishAttachmentCapability(token)
159- effect, admitted := old.beginRoleEffect(token)159+ effect, admitted := old.beginAttachmentEffect(token)
160 require.True(t, admitted)160 require.True(t, admitted)
161 161
162 ended := make(chan struct{})162 ended := make(chan struct{})
163 release := make(chan struct{})163 release := make(chan struct{})
164 var endedOnce sync.Once164 var endedOnce sync.Once
165- d.afterActionRoleEffectEnded = func(action string) {165+ d.afterActionAttachmentEffectEnded = func(action string) {
166 if action == "picker-stopped" {166 if action == "picker-stopped" {
167 endedOnce.Do(func() { close(ended) })167 endedOnce.Do(func() { close(ended) })
168 <-release168 <-release
@@ -171,7 +171,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
171 expectedCreatedAt := int64(7)171 expectedCreatedAt := int64(7)
172 done := make(chan error, 1)172 done := make(chan error, 1)
173 go func() {173 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")
175 }()175 }()
176 select {176 select {
177 case <-ended:177 case <-ended:
@@ -182,8 +182,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
182 next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}182 next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
183 next.initOverlays()183 next.initOverlays()
184 replacement, err := d.transitionAttachment(attachmentTransitionRequest{184 replacement, err := d.transitionAttachment(attachmentTransitionRequest{
185- target: source, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,185+ target: source, next: next, expectedTransport: next.transportSnapshot(), ready: true,
186- expectedTransport: next.transportSnapshot(), ready: true,
187 })186 })
188 require.NoError(t, err)187 require.NoError(t, err)
189 close(release)188 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
18 rebind: func(t *testing.T, d *Daemon, source *session, ac *attachedClient) {18 rebind: func(t *testing.T, d *Daemon, source *session, ac *attachedClient) {
19 result, err := d.transitionAttachment(attachmentTransitionRequest{19 result, err := d.transitionAttachment(attachmentTransitionRequest{
20 source: source, target: source, next: ac,20 source: source, target: source, next: ac,
21- expectedRole: attachmentActive, targetRole: attachmentActive,21+
22 expectedTransport: ac.transportSnapshot(), ready: true,22 expectedTransport: ac.transportSnapshot(), ready: true,
23 })23 })
24 require.NoError(t, err)24 require.NoError(t, err)
@@ -31,7 +31,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
31 ac.replaceTransport(&closeTrackingTransport{})31 ac.replaceTransport(&closeTrackingTransport{})
32 current := source.attachmentToken(ac, ac.transport())32 current := source.attachmentToken(ac, ac.transport())
33 current.lease = source.renderCoordinator().attachmentLease(ac)33 current.lease = source.renderCoordinator().attachmentLease(ac)
34- ac.publishRoleCapability(current)34+ ac.publishAttachmentCapability(current)
35 },35 },
36 },36 },
37 }37 }
@@ -47,16 +47,16 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
47 d.sessions[target.id] = target47 d.sessions[target.id] = target
48 d.mu.Unlock()48 d.mu.Unlock()
49 49
50- rc := d.attachCoordinator(source, nil, ac, true)50+ rc := d.attachCoordinator(source, ac, true)
51 token := source.attachmentToken(ac, ac.transport())51 token := source.attachmentToken(ac, ac.transport())
52 token.lease = rc.attachmentLease(ac)52 token.lease = rc.attachmentLease(ac)
53- ac.publishRoleCapability(token)53+ ac.publishAttachmentCapability(token)
54- effect, admitted := ac.beginRoleEffect(token)54+ effect, admitted := ac.beginAttachmentEffect(token)
55 require.True(t, admitted)55 require.True(t, admitted)
56 56
57 ended := make(chan struct{})57 ended := make(chan struct{})
58 release := make(chan struct{})58 release := make(chan struct{})
59- d.afterActionRoleEffectEnded = func(action string) {59+ d.afterActionAttachmentEffectEnded = func(action string) {
60 if action == "test-handoff" {60 if action == "test-handoff" {
61 close(ended)61 close(ended)
62 <-release62 <-release
@@ -64,7 +64,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
64 }64 }
65 done := make(chan error, 1)65 done := make(chan error, 1)
66 go func() {66 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")
68 }()68 }()
69 awaitTestCompletion(t, ended, "test handoff did not release its role ticket")69 awaitTestCompletion(t, ended, "test handoff did not release its role ticket")
70 tt.rebind(t, d, source, ac)70 tt.rebind(t, d, source, ac)
@@ -73,8 +73,8 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
73 73
74 require.Same(t, source, ac.currentSession())74 require.Same(t, source, ac.currentSession())
75 target.mu.Lock()75 target.mu.Lock()
76- require.Nil(t, target.client)76+ require.Empty(t, target.snapshotAttachmentsLocked())
77- require.Zero(t, target.active)77+ require.Zero(t, testAttachmentTabIndexLocked(target))
78 target.mu.Unlock()78 target.mu.Unlock()
79 })79 })
80 }80 }
M internal/usecase/daemon/navigation_overflow.go +19 -14
@@ -53,13 +53,13 @@ func resolveOverflow(dir layout.Direction, cfg domain.NavConfig, position, count
53 // overflowSourceEligible validates that directional overflow still originates53 // overflowSourceEligible validates that directional overflow still originates
54 // from the active tiled tab. The session-to-tab lock order matches tab overflow54 // from the active tiled tab. The session-to-tab lock order matches tab overflow
55 // commit and leaves no lock held across a session handoff.55 // 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 {
57 if sess == nil || expectedSource == nil {57 if sess == nil || expectedSource == nil {
58 return false58 return false
59 }59 }
60 sess.mu.Lock()60 sess.mu.Lock()
61 defer sess.mu.Unlock()61 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 {
63 return false63 return false
64 }64 }
65 expectedSource.mu.Lock()65 expectedSource.mu.Lock()
@@ -134,18 +134,26 @@ type tabOverflowCandidate struct {
134 targetOldFocus layout.PaneID134 targetOldFocus layout.PaneID
135 }135 }
136 136
137-// prepareTabOverflow captures tab identities under the session lock and uses137+func (d *Daemon) prepareTabOverflowForAttachment(sess *session, ac *attachedClient, expectedSource *tab, dir layout.Direction, span domain.Rect, delta int) (tabOverflowCandidate, bool) {
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) {
140 if sess == nil || expectedSource == nil || (delta != -1 && delta != 1) {138 if sess == nil || expectedSource == nil || (delta != -1 && delta != 1) {
141 return tabOverflowCandidate{}, false139 return tabOverflowCandidate{}, false
142 }140 }
143 sess.mu.Lock()141 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 {
145 sess.mu.Unlock()154 sess.mu.Unlock()
146 return tabOverflowCandidate{}, false155 return tabOverflowCandidate{}, false
147 }156 }
148- sourceIndex := sess.active
149 targetIndex := sourceIndex + delta157 targetIndex := sourceIndex + delta
150 if targetIndex < 0 || targetIndex >= len(sess.tabs) {158 if targetIndex < 0 || targetIndex >= len(sess.tabs) {
151 sess.mu.Unlock()159 sess.mu.Unlock()
@@ -189,17 +197,14 @@ func (d *Daemon) prepareTabOverflow(sess *session, expectedSource *tab, dir layo
189 }, true197 }, true
190 }198 }
191 199
192-// commitTabOverflow atomically revalidates the source and target tab entries.200+func (d *Daemon) commitTabOverflowForAttachment(sess *session, ac *attachedClient, candidate tabOverflowCandidate) bool {
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 {
196 if sess == nil || candidate.source == nil || candidate.target == nil {201 if sess == nil || candidate.source == nil || candidate.target == nil {
197 return false202 return false
198 }203 }
199 sess.mu.Lock()204 sess.mu.Lock()
200 targetIndex := candidate.sourceIndex + candidate.delta205 targetIndex := candidate.sourceIndex + candidate.delta
201- if candidate.sourceIndex < 0 || candidate.sourceIndex >= len(sess.tabs) || targetIndex < 0 || targetIndex >= len(sess.tabs) ||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) ||
202- sess.active != candidate.sourceIndex || sess.tabs[candidate.sourceIndex] != candidate.source || sess.tabs[targetIndex] != candidate.target {207+ sess.tabs[candidate.sourceIndex] != candidate.source || sess.tabs[targetIndex] != candidate.target {
203 sess.mu.Unlock()208 sess.mu.Unlock()
204 return false209 return false
205 }210 }
@@ -234,10 +239,10 @@ func (d *Daemon) commitTabOverflow(sess *session, candidate tabOverflowCandidate
234 }239 }
235 candidate.target.tree = targetTree240 candidate.target.tree = targetTree
236 candidate.target.bumpLayoutGenerationLocked()241 candidate.target.bumpLayoutGenerationLocked()
237- sess.active = targetIndex
238 candidate.target.mu.Unlock()242 candidate.target.mu.Unlock()
239 candidate.source.mu.Unlock()243 candidate.source.mu.Unlock()
240 sess.mu.Unlock()244 sess.mu.Unlock()
245+ sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID))
241 d.applyTabLayout(sess, candidate.target)246 d.applyTabLayout(sess, candidate.target)
242 return true247 return true
243 }248 }
M internal/usecase/daemon/navigation_overflow_test.go +33 -53
@@ -42,6 +42,20 @@ func TestResolveOverflowObeysAxisConfigurationAndWalls(t *testing.T) {
42 }42 }
43 }43 }
44 44
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+
45 func TestKeyboardHorizontalOverflowLandsOnFacingEdge(t *testing.T) {59 func TestKeyboardHorizontalOverflowLandsOnFacingEdge(t *testing.T) {
46 tests := []struct {60 tests := []struct {
47 name string61 name string
@@ -58,7 +72,7 @@ func TestKeyboardHorizontalOverflowLandsOnFacingEdge(t *testing.T) {
58 t.Run(tt.name, func(t *testing.T) {72 t.Run(tt.name, func(t *testing.T) {
59 d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil)73 d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil)
60 sess.mu.Lock()74 sess.mu.Lock()
61- sess.active = tt.start75+ selectTestAttachmentTabLocked(sess, tt.start)
62 target := sess.tabs[tt.wantActive]76 target := sess.tabs[tt.wantActive]
63 source := sess.tabs[tt.start]77 source := sess.tabs[tt.start]
64 sess.mu.Unlock()78 sess.mu.Unlock()
@@ -71,11 +85,11 @@ func TestKeyboardHorizontalOverflowLandsOnFacingEdge(t *testing.T) {
71 85
72 daemonKeyHandler{d: d, ac: ac}.Action(tt.action, nil)86 daemonKeyHandler{d: d, ac: ac}.Action(tt.action, nil)
73 87
74- require.Equal(t, tt.wantActive, activeTabIndex(sess))88+ require.Equal(t, tt.wantActive, testAttachmentTabIndex(sess))
75 target.mu.Lock()89 target.mu.Lock()
76 require.Equal(t, tt.wantFocus, target.tree.Focus)90 require.Equal(t, tt.wantFocus, target.tree.Focus)
77 target.mu.Unlock()91 target.mu.Unlock()
78- require.NotSame(t, source, sess.activeTab())92+ require.NotSame(t, source, testAttachmentTab(sess))
79 })93 })
80 }94 }
81 }95 }
@@ -93,7 +107,7 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t
93 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}107 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}
94 target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})108 target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
95 target.mu.Unlock()109 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}
97 }111 }
98 charlie := newSession("live-charlie", "charlie", 1, "pane-2")112 charlie := newSession("live-charlie", "charlie", 1, "pane-2")
99 echo := newSession("live-echo", "echo", 1, "pane-2")113 echo := newSession("live-echo", "echo", 1, "pane-2")
@@ -130,7 +144,7 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t
130 }144 }
131 145
132 for _, target := range []*session{charlie, echo} {146 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")
134 target.tabs[1].mu.Lock()148 target.tabs[1].mu.Lock()
135 require.Equal(t, layout.PaneID("pane-2"), target.tabs[1].tree.Focus, "switch preserves the target pane focus")149 require.Equal(t, layout.PaneID("pane-2"), target.tabs[1].tree.Focus, "switch preserves the target pane focus")
136 target.tabs[1].mu.Unlock()150 target.tabs[1].mu.Unlock()
@@ -149,11 +163,11 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t
149 }163 }
150 d.mu.Unlock()164 d.mu.Unlock()
151 alpha.mu.Lock()165 alpha.mu.Lock()
152- attached := alpha.client166+ attached := alpha.snapshotAttachmentsLocked()
153 alpha.mu.Unlock()167 alpha.mu.Unlock()
154 require.True(t, stopped, "vertical overflow leaves the stopped session stopped")168 require.True(t, stopped, "vertical overflow leaves the stopped session stopped")
155 require.False(t, bravoLive, "the stopped session is absent from the live registry")169 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")
157 }171 }
158 172
159 func TestKeyboardVerticalOverflowRefusesVisibleFloatingSource(t *testing.T) {173 func TestKeyboardVerticalOverflowRefusesVisibleFloatingSource(t *testing.T) {
@@ -172,10 +186,10 @@ func TestKeyboardVerticalOverflowRefusesVisibleFloatingSource(t *testing.T) {
172 186
173 require.Same(t, alpha, ac.currentSession())187 require.Same(t, alpha, ac.currentSession())
174 alpha.mu.Lock()188 alpha.mu.Lock()
175- require.Same(t, ac, alpha.client)189+ require.Contains(t, alpha.snapshotAttachmentsLocked(), ac)
176 alpha.mu.Unlock()190 alpha.mu.Unlock()
177 charlie.mu.Lock()191 charlie.mu.Lock()
178- require.Nil(t, charlie.client)192+ require.Empty(t, charlie.snapshotAttachmentsLocked())
179 charlie.mu.Unlock()193 charlie.mu.Unlock()
180 }194 }
181 195
@@ -196,10 +210,10 @@ func TestVerticalOverflowRevalidatesFloatingSourceBeforeHandoff(t *testing.T) {
196 require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor)210 require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor)
197 require.Same(t, alpha, ac.currentSession())211 require.Same(t, alpha, ac.currentSession())
198 alpha.mu.Lock()212 alpha.mu.Lock()
199- require.Same(t, ac, alpha.client)213+ require.Contains(t, alpha.snapshotAttachmentsLocked(), ac)
200 alpha.mu.Unlock()214 alpha.mu.Unlock()
201 charlie.mu.Lock()215 charlie.mu.Lock()
202- require.Nil(t, charlie.client)216+ require.Empty(t, charlie.snapshotAttachmentsLocked())
203 charlie.mu.Unlock()217 charlie.mu.Unlock()
204 }218 }
205 219
@@ -208,7 +222,7 @@ func TestVerticalOverflowRejectsStaleSourceTab(t *testing.T) {
208 alpha.mu.Lock()222 alpha.mu.Lock()
209 alpha.name = "alpha"223 alpha.name = "alpha"
210 expectedSource := alpha.tabs[0]224 expectedSource := alpha.tabs[0]
211- alpha.active = 1225+ selectTestAttachmentTabLocked(alpha, 1)
212 alpha.mu.Unlock()226 alpha.mu.Unlock()
213 charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}227 charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
214 d.mu.Lock()228 d.mu.Lock()
@@ -218,10 +232,10 @@ func TestVerticalOverflowRejectsStaleSourceTab(t *testing.T) {
218 require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, expectedSource, picker.Target{Session: charlie.id, TabIndex: -1}), errNoNeighbor)232 require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, expectedSource, picker.Target{Session: charlie.id, TabIndex: -1}), errNoNeighbor)
219 require.Same(t, alpha, ac.currentSession())233 require.Same(t, alpha, ac.currentSession())
220 alpha.mu.Lock()234 alpha.mu.Lock()
221- require.Same(t, ac, alpha.client)235+ require.Contains(t, alpha.snapshotAttachmentsLocked(), ac)
222 alpha.mu.Unlock()236 alpha.mu.Unlock()
223 charlie.mu.Lock()237 charlie.mu.Lock()
224- require.Nil(t, charlie.client)238+ require.Empty(t, charlie.snapshotAttachmentsLocked())
225 charlie.mu.Unlock()239 charlie.mu.Unlock()
226 }240 }
227 241
@@ -251,16 +265,16 @@ func TestVerticalOverflowTreatsDisplacedSourceClientAsNoNeighbor(t *testing.T) {
251 require.True(t, ok)265 require.True(t, ok)
252 replacement := &attachedClient{}266 replacement := &attachedClient{}
253 alpha.mu.Lock()267 alpha.mu.Lock()
254- alpha.client = replacement268+ alpha.registerAttachmentLocked(replacement)
255 alpha.mu.Unlock()269 alpha.mu.Unlock()
256 270
257 require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor)271 require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor)
258 require.Same(t, alpha, ac.currentSession())272 require.Same(t, alpha, ac.currentSession())
259 alpha.mu.Lock()273 alpha.mu.Lock()
260- require.Same(t, replacement, alpha.client)274+ require.Contains(t, alpha.snapshotAttachmentsLocked(), replacement)
261 alpha.mu.Unlock()275 alpha.mu.Unlock()
262 charlie.mu.Lock()276 charlie.mu.Lock()
263- require.Nil(t, charlie.client)277+ require.Empty(t, charlie.snapshotAttachmentsLocked())
264 charlie.mu.Unlock()278 charlie.mu.Unlock()
265 }279 }
266 280
@@ -314,7 +328,7 @@ func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) {
314 continue328 continue
315 }329 }
316 candidate.mu.Lock()330 candidate.mu.Lock()
317- if candidate.client == ac {331+ if attachmentRegisteredLocked(candidate, ac) {
318 owners = append(owners, candidate)332 owners = append(owners, candidate)
319 }333 }
320 candidate.mu.Unlock()334 candidate.mu.Unlock()
@@ -351,41 +365,7 @@ func TestKeyboardHorizontalOverflowRespectsDefaultsWallsFailedEntryAndFloating(t
351 365
352 daemonKeyHandler{d: d, ac: ac}.Action(tt.dir, nil)366 daemonKeyHandler{d: d, ac: ac}.Action(tt.dir, nil)
353 367
354- require.Equal(t, 0, activeTabIndex(sess))368+ require.Equal(t, 0, testAttachmentTabIndex(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))
389 })369 })
390 }370 }
391 }371 }
M internal/usecase/daemon/notices_overlay_test.go +2 -2
@@ -434,7 +434,7 @@ func TestYankLastNotificationWithEmptyHistoryShowsWarnToast(t *testing.T) {
434 func TestProxyYankLastNotificationEmptyHistoryUsesAttachmentScope(t *testing.T) {434 func TestProxyYankLastNotificationEmptyHistoryUsesAttachmentScope(t *testing.T) {
435 d, proxy, ac, _, handler := newProxyInputHarness(t)435 d, proxy, ac, _, handler := newProxyInputHarness(t)
436 handler.enterPalette()436 handler.enterPalette()
437- d.handlePaletteInput(ac, []byte("YLN\r"), handler.roleToken.effect)437+ d.handlePaletteInput(ac, []byte("YLN\r"), handler.connectionToken.effect)
438 438
439 history := d.notices.history()439 history := d.notices.history()
440 require.Len(t, history, 1)440 require.Len(t, history, 1)
@@ -444,7 +444,7 @@ func TestProxyYankLastNotificationEmptyHistoryUsesAttachmentScope(t *testing.T)
444 444
445 func TestProxyNoticeYankSendFailureDetachesAndArmsWarmExpiry(t *testing.T) {445 func TestProxyNoticeYankSendFailureDetachesAndArmsWarmExpiry(t *testing.T) {
446 d, proxy, ac, _, handler := newProxyInputHarness(t)446 d, proxy, ac, _, handler := newProxyInputHarness(t)
447- handler.roleToken.effect.End()447+ handler.connectionToken.effect.End()
448 client, ok := ac.transport().(*proxyTestTransport)448 client, ok := ac.transport().(*proxyTestTransport)
449 require.True(t, ok)449 require.True(t, ok)
450 client.sendFails.Store(true)450 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
267 if sess != nil {267 if sess != nil {
268 d.notices.routingMu.Lock()268 d.notices.routingMu.Lock()
269 sess.mu.Lock()269 sess.mu.Lock()
270- ac := sess.client270+ attachments := sess.snapshotAttachmentsLocked()
271 sess.mu.Unlock()271 sess.mu.Unlock()
272- if ac == nil {272+ if len(attachments) == 0 {
273 d.notices.queueGlobal(n)273 d.notices.queueGlobal(n)
274 d.notices.routingMu.Unlock()274 d.notices.routingMu.Unlock()
275 return275 return
@@ -277,11 +277,14 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No
277 if d.notices.beforeSessionDelivery != nil {277 if d.notices.beforeSessionDelivery != nil {
278 d.notices.beforeSessionDelivery()278 d.notices.beforeSessionDelivery()
279 }279 }
280- // Publish the toast while detach is excluded, then release routing before280+ published := make([]*attachedClient, 0, len(attachments))
281- // repainting. Rendering can report another notice and re-enter routing.281+ for _, ac := range attachments {
282- published := d.publishToast(ac, n)282+ if d.publishToast(ac, n) {
283+ published = append(published, ac)
284+ }
285+ }
283 d.notices.routingMu.Unlock()286 d.notices.routingMu.Unlock()
284- if published {287+ for _, ac := range published {
285 d.repaintForNotice(ac)288 d.repaintForNotice(ac)
286 }289 }
287 return290 return
@@ -308,14 +311,12 @@ func (d *Daemon) deliverGlobal(n domain.Notification) {
308 }311 }
309 targets := make([]*attachedClient, 0, len(sessions))312 targets := make([]*attachedClient, 0, len(sessions))
310 for _, s := range sessions {313 for _, s := range sessions {
311- core := s.core()314+ for _, ac := range snapshotAttachmentSession(s) {
312- core.mu.Lock()315+ if !d.publishToast(ac, n) {
313- ac := core.client316+ continue
314- core.mu.Unlock()317+ }
315- if ac == nil || !d.publishToast(ac, n) {318+ targets = append(targets, ac)
316- continue
317 }319 }
318- targets = append(targets, ac)
319 }320 }
320 if len(targets) == 0 {321 if len(targets) == 0 {
321 if d.notices.beforeQueueGlobal != nil {322 if d.notices.beforeQueueGlobal != nil {
@@ -338,7 +339,7 @@ func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) {
338 d.notices.routingMu.Lock()339 d.notices.routingMu.Lock()
339 340
340 sess.mu.Lock()341 sess.mu.Lock()
341- current := sess.client == ac342+ current := attachmentRegisteredLocked(sess, ac)
342 sess.mu.Unlock()343 sess.mu.Unlock()
343 if !current {344 if !current {
344 d.notices.routingMu.Unlock()345 d.notices.routingMu.Unlock()
@@ -367,14 +368,6 @@ func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode,
367 d.notify(nil, sev, code, msg, cause)368 d.notify(nil, sev, code, msg, cause)
368 }369 }
369 370
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-
378 // publishToast durably mutates one client's toast state without repainting.371 // publishToast durably mutates one client's toast state without repainting.
379 // Routing paths call it while routingMu excludes detach, then release routingMu372 // Routing paths call it while routingMu excludes detach, then release routingMu
380 // before rendering so a render failure can safely route another notice.373 // 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.
55 }55 }
56 }56 }
57 57
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-
151 func TestMalformedClientNoticeIsIgnored(t *testing.T) {58 func TestMalformedClientNoticeIsIgnored(t *testing.T) {
152 d, sess, ac, _ := newNoticeFixture(t, &noticeClock{})59 d, sess, ac, _ := newNoticeFixture(t, &noticeClock{})
153- d.attachCoordinator(sess, nil, ac, true)60+ d.attachCoordinator(sess, ac, true)
154 tr, ok := ac.tr.(*portsmocks.MockTransport)61 tr, ok := ac.tr.(*portsmocks.MockTransport)
155 require.True(t, ok, "attached client transport must be a MockTransport")62 require.True(t, ok, "attached client transport must be a MockTransport")
156 frames := []ports.Frame{63 frames := []ports.Frame{
@@ -379,7 +286,7 @@ func newNoticeFixture(t *testing.T, clk ports.Clock) (*Daemon, *session, *attach
379 for _, pane := range tb.panes {286 for _, pane := range tb.panes {
380 pane.ctx, pane.cancel = wctx, wcancel287 pane.ctx, pane.cancel = wctx, wcancel
381 }288 }
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}}
383 ac.setSession(sess)290 ac.setSession(sess)
384 d.sessions[sess.id] = sess291 d.sessions[sess.id] = sess
385 t.Cleanup(cancel)292 t.Cleanup(cancel)
@@ -521,7 +428,7 @@ func TestNotifyRoutesToSessionClientOnly(t *testing.T) {
521 428
522 other := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}429 other := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
523 other.initOverlays()430 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() {}}
525 other.setSession(sess2)432 other.setSession(sess2)
526 d.mu.Lock()433 d.mu.Lock()
527 d.sessions[sess2.id] = sess2434 d.sessions[sess2.id] = sess2
@@ -606,7 +513,7 @@ func TestNotifySessionScopedReleasesRoutingBeforeRepaint(t *testing.T) {
606 func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) {513 func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) {
607 d, sessA, oldA, _ := newNoticeFixture(t, newNoticeClock())514 d, sessA, oldA, _ := newNoticeFixture(t, newNoticeClock())
608 sessA.mu.Lock()515 sessA.mu.Lock()
609- sessA.client = nil516+ clearAttachmentsForTestLocked(sessA)
610 sessA.mu.Unlock()517 sessA.mu.Unlock()
611 518
612 clientB := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}519 clientB := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
@@ -628,7 +535,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) {
628 require.Empty(t, oldToasts, "a detached session must not paint a toast")535 require.Empty(t, oldToasts, "a detached session must not paint a toast")
629 536
630 sessB.mu.Lock()537 sessB.mu.Lock()
631- sessB.client = clientB538+ sessB.registerAttachmentLocked(clientB)
632 sessB.mu.Unlock()539 sessB.mu.Unlock()
633 d.drainPendingForFirstPaint(sessB, clientB)540 d.drainPendingForFirstPaint(sessB, clientB)
634 bToasts, _ := visibleToasts(clientB)541 bToasts, _ := visibleToasts(clientB)
@@ -638,7 +545,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) {
638 require.Equal(t, "global announcement", bToasts[0].Message)545 require.Equal(t, "global announcement", bToasts[0].Message)
639 546
640 sessA.mu.Lock()547 sessA.mu.Lock()
641- sessA.client = oldA548+ sessA.registerAttachmentLocked(oldA)
642 sessA.mu.Unlock()549 sessA.mu.Unlock()
643 d.drainPendingForFirstPaint(sessA, oldA)550 d.drainPendingForFirstPaint(sessA, oldA)
644 aToasts, _ := visibleToasts(oldA)551 aToasts, _ := visibleToasts(oldA)
@@ -652,7 +559,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) {
652 secondA.initOverlays()559 secondA.initOverlays()
653 secondA.setSession(sessA)560 secondA.setSession(sessA)
654 sessA.mu.Lock()561 sessA.mu.Lock()
655- sessA.client = secondA562+ sessA.registerAttachmentLocked(secondA)
656 sessA.mu.Unlock()563 sessA.mu.Unlock()
657 d.drainPendingForFirstPaint(sessA, secondA)564 d.drainPendingForFirstPaint(sessA, secondA)
658 secondToasts, _ := visibleToasts(secondA)565 secondToasts, _ := visibleToasts(secondA)
@@ -664,7 +571,7 @@ func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) {
664 571
665 second := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}572 second := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
666 second.initOverlays()573 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() {}}
668 second.setSession(sess2)575 second.setSession(sess2)
669 d.mu.Lock()576 d.mu.Lock()
670 d.sessions[sess2.id] = sess2577 d.sessions[sess2.id] = sess2
@@ -682,7 +589,7 @@ func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) {
682 func TestNotifyGlobalDoesNotStrandNoticeWhenFirstPaintRacesQueue(t *testing.T) {589 func TestNotifyGlobalDoesNotStrandNoticeWhenFirstPaintRacesQueue(t *testing.T) {
683 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())590 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
684 sess.mu.Lock()591 sess.mu.Lock()
685- sess.client = nil592+ clearAttachmentsForTestLocked(sess)
686 sess.mu.Unlock()593 sess.mu.Unlock()
687 594
688 routeObserved := make(chan struct{})595 routeObserved := make(chan struct{})
@@ -701,7 +608,7 @@ func TestNotifyGlobalDoesNotStrandNoticeWhenFirstPaintRacesQueue(t *testing.T) {
701 // Publish the attachment and start firstPaint while global routing still608 // Publish the attachment and start firstPaint while global routing still
702 // owns its gate. firstPaint must wait for the queue, then drain it.609 // owns its gate. firstPaint must wait for the queue, then drain it.
703 sess.mu.Lock()610 sess.mu.Lock()
704- sess.client = ac611+ sess.registerAttachmentLocked(ac)
705 sess.mu.Unlock()612 sess.mu.Unlock()
706 paintDone := make(chan struct{})613 paintDone := make(chan struct{})
707 go func() {614 go func() {
@@ -744,52 +651,14 @@ func TestNotifyGlobalSerializesDetachWithDelivery(t *testing.T) {
744 toasts, _ := visibleToasts(ac)651 toasts, _ := visibleToasts(ac)
745 require.Len(t, toasts, 1)652 require.Len(t, toasts, 1)
746 sess.mu.Lock()653 sess.mu.Lock()
747- require.Nil(t, sess.client)654+ require.Empty(t, sess.snapshotAttachmentsLocked())
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)
786 sess.mu.Unlock()655 sess.mu.Unlock()
787 }656 }
788 657
789 func TestDetachedNoticeCoalescingPreservesDistinctContentAndSeverity(t *testing.T) {658 func TestDetachedNoticeCoalescingPreservesDistinctContentAndSeverity(t *testing.T) {
790 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())659 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
791 sess.mu.Lock()660 sess.mu.Lock()
792- sess.client = nil661+ clearAttachmentsForTestLocked(sess)
793 sess.mu.Unlock()662 sess.mu.Unlock()
794 663
795 d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "build started", nil)664 d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "build started", nil)
@@ -797,7 +666,7 @@ func TestDetachedNoticeCoalescingPreservesDistinctContentAndSeverity(t *testing.
797 d.notify(sess, domain.NoticeWarn, domain.NoticeUser, "build delayed", nil)666 d.notify(sess, domain.NoticeWarn, domain.NoticeUser, "build delayed", nil)
798 667
799 sess.mu.Lock()668 sess.mu.Lock()
800- sess.client = ac669+ sess.registerAttachmentLocked(ac)
801 sess.mu.Unlock()670 sess.mu.Unlock()
802 d.drainPendingForFirstPaint(sess, ac)671 d.drainPendingForFirstPaint(sess, ac)
803 672
@@ -813,7 +682,7 @@ func TestDetachedNoticeCoalescingPreservesDistinctContentAndSeverity(t *testing.
813 func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) {682 func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) {
814 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())683 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
815 sess.mu.Lock()684 sess.mu.Lock()
816- sess.client = nil685+ clearAttachmentsForTestLocked(sess)
817 sess.mu.Unlock()686 sess.mu.Unlock()
818 687
819 d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotWrite, "snapshot write failed", nil)688 d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotWrite, "snapshot write failed", nil)
@@ -824,7 +693,7 @@ func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) {
824 693
825 // Re-attach and let firstPaint drain the pending queue.694 // Re-attach and let firstPaint drain the pending queue.
826 sess.mu.Lock()695 sess.mu.Lock()
827- sess.client = ac696+ sess.registerAttachmentLocked(ac)
828 sess.mu.Unlock()697 sess.mu.Unlock()
829 d.firstPaint(sess, ac, domain.Size{})698 d.firstPaint(sess, ac, domain.Size{})
830 699
@@ -841,7 +710,7 @@ func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) {
841 func TestNotifyGlobalPersistDisabledDrainsAtFirstAttach(t *testing.T) {710 func TestNotifyGlobalPersistDisabledDrainsAtFirstAttach(t *testing.T) {
842 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())711 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
843 sess.mu.Lock()712 sess.mu.Lock()
844- sess.client = nil713+ clearAttachmentsForTestLocked(sess)
845 sess.mu.Unlock()714 sess.mu.Unlock()
846 715
847 cause := errors.New("open store.db: permission denied")716 cause := errors.New("open store.db: permission denied")
@@ -854,7 +723,7 @@ func TestNotifyGlobalPersistDisabledDrainsAtFirstAttach(t *testing.T) {
854 723
855 // Re-attach and let firstPaint drain the pending queue.724 // Re-attach and let firstPaint drain the pending queue.
856 sess.mu.Lock()725 sess.mu.Lock()
857- sess.client = ac726+ sess.registerAttachmentLocked(ac)
858 sess.mu.Unlock()727 sess.mu.Unlock()
859 d.firstPaint(sess, ac, domain.Size{})728 d.firstPaint(sess, ac, domain.Size{})
860 729
M internal/usecase/daemon/output_state_stream.go +38 -7
@@ -1,6 +1,8 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "sync/atomic"
5+
4 "github.com/bnema/vev/internal/ports"6 "github.com/bnema/vev/internal/ports"
5 "github.com/bnema/vev/pkg/renderer"7 "github.com/bnema/vev/pkg/renderer"
6 )8 )
@@ -15,11 +17,13 @@ import (
15 // Callers serialize access with attachedClient.sendMu. A prepared transaction17 // Callers serialize access with attachedClient.sendMu. A prepared transaction
16 // must be sent or committed as a no-op before the next transaction is prepared.18 // must be sent or committed as a no-op before the next transaction is prepared.
17 type outputStateStream struct {19 type outputStateStream struct {
18- renderer *renderer.Renderer20+ renderer *renderer.Renderer
19- next uint6421+ next uint64
20- acked uint6422+ acked uint64
21- maxOutstanding uint6423+ maxOutstanding uint64
22- forceSnapshot bool24+ outstandingAtomic atomic.Uint64
25+ maxOutstandingAtomic atomic.Uint64
26+ forceSnapshot bool
23 }27 }
24 28
25 func newOutputStateStream(windowSize ...uint8) *outputStateStream {29 func newOutputStateStream(windowSize ...uint8) *outputStateStream {
@@ -27,10 +31,13 @@ func newOutputStateStream(windowSize ...uint8) *outputStateStream {
27 if len(windowSize) > 0 {31 if len(windowSize) > 0 {
28 window = normalizeOutputWindow(windowSize[0])32 window = normalizeOutputWindow(windowSize[0])
29 }33 }
30- return &outputStateStream{34+ stream := &outputStateStream{
31 renderer: renderer.New(renderer.Capabilities{}),35 renderer: renderer.New(renderer.Capabilities{}),
32 maxOutstanding: uint64(window),36 maxOutstanding: uint64(window),
33 }37 }
38+ stream.maxOutstandingAtomic.Store(uint64(window))
39+ stream.publishOutstanding()
40+ return stream
34 }41 }
35 42
36 type preparedOutput struct {43 type preparedOutput struct {
@@ -73,6 +80,7 @@ func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame
73 }80 }
74 p.commit()81 p.commit()
75 p.stream.next = p.next82 p.stream.next = p.next
83+ p.stream.publishOutstanding()
76 return nil84 return nil
77 }85 }
78 86
@@ -98,16 +106,39 @@ func (s *outputStateStream) ack(state uint64) {
98 return106 return
99 }107 }
100 s.acked = state108 s.acked = state
109+ s.publishOutstanding()
101 }110 }
102 111
103 func (s *outputStateStream) rebase() {112 func (s *outputStateStream) rebase() {
104 s.acked = s.next113 s.acked = s.next
114+ s.publishOutstanding()
115+ s.maxOutstandingAtomic.Store(s.maxOutstanding)
105 s.renderer.Reset()116 s.renderer.Reset()
106 s.forceSnapshot = true117 s.forceSnapshot = true
107 }118 }
108 119
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+
109 func (s *outputStateStream) atCapacity() bool {137 func (s *outputStateStream) atCapacity() bool {
110- return s.outstanding() >= s.maxOutstanding138+ if s == nil {
139+ return false
140+ }
141+ return s.outstandingAtomic.Load() >= s.maxOutstandingAtomic.Load()
111 }142 }
112 143
113 func (s *outputStateStream) outstanding() uint64 { return s.next - s.acked }144 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
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "runtime"
5 "testing"6 "testing"
6 7
7 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
@@ -35,6 +36,44 @@ func TestOutputStateStreamBuildsPipelinedDependencyChain(t *testing.T) {
35 require.Equal(t, uint64(2), stream.outstanding())36 require.Equal(t, uint64(2), stream.outstanding())
36 }37 }
37 38
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+
38 func TestOutputStateStreamFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T) {77 func TestOutputStateStreamFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T) {
39 stream := newOutputStateStream()78 stream := newOutputStateStream()
40 initial := renderer.NewFrame(3, 1)79 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) {
285 p := newResizeReaderPTY([]byte("\x1b[1;81H" + strings.Repeat("B", 40)))285 p := newResizeReaderPTY([]byte("\x1b[1;81H" + strings.Repeat("B", 40)))
286 d, sess, ac, sends := newManualSessionWithPTYs(t, p)286 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
287 ac.output.maxOutstanding = tc.window287 ac.output.maxOutstanding = tc.window
288- pane := sess.activeTab().focusedPane()288+ pane := testAttachmentTab(sess).focusedPane()
289 p.applying = func() bool {289 p.applying = func() bool {
290 pane.mu.Lock()290 pane.mu.Lock()
291 defer pane.mu.Unlock()291 defer pane.mu.Unlock()
@@ -299,7 +299,7 @@ func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) {
299 299
300 d.sessWg.Add(1)300 d.sessWg.Add(1)
301 pane.onExit = func() {}301 pane.onExit = func() {}
302- go d.ptyReader(sess, sess.activeTab(), pane)302+ go d.ptyReader(sess, testAttachmentTab(sess), pane)
303 defer func() { p.close(); d.sessWg.Wait() }()303 defer func() { p.close(); d.sessWg.Wait() }()
304 304
305 // The resize deadline drives coordinator prepare/apply/commit. No test305 // The resize deadline drives coordinator prepare/apply/commit. No test
@@ -387,59 +387,6 @@ func TestResizeBurstFlushesOnlyLatestGeometry(t *testing.T) {
387 awaitTestCompletion(t, latestDone, "latest resize callback did not complete")387 awaitTestCompletion(t, latestDone, "latest resize callback did not complete")
388 awaitFrame(t, sends, ports.MsgOutput)388 awaitFrame(t, sends, ports.MsgOutput)
389 require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size)389 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)
391 requireNoOutputFrame(t, sends)391 requireNoOutputFrame(t, sends)
392 }392 }
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 {
48 promptMu sync.Mutex48 promptMu sync.Mutex
49 prompt *promptui.Model49 prompt *promptui.Model
50 promptSubmit func(string) error50 promptSubmit func(string) error
51- promptTransitionSubmit func(string, attachmentRoleToken) error51+ promptTransitionSubmit func(string, attachmentConnectionToken) error
52 promptPending []byte52 promptPending []byte
53 53
54 resizeMu sync.Mutex54 resizeMu sync.Mutex
@@ -242,118 +242,12 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
242 return active || prePublication242 return active || prePublication
243 }243 }
244 244
245-// clearForSnatch clears each overlay family independently. Every family245+func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*attachmentEffectTicket) bool {
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 {
352 if rt == nil || rt.ac == nil {246 if rt == nil || rt.ac == nil {
353 return false247 return false
354 }248 }
355 ac := rt.ac249 ac := rt.ac
356- var effect *roleEffectTicket250+ var effect *attachmentEffectTicket
357 if len(effects) != 0 {251 if len(effects) != 0 {
358 effect = effects[0]252 effect = effects[0]
359 }253 }
M internal/usecase/daemon/palette.go +38 -27
@@ -174,7 +174,7 @@ func (d *Daemon) recordPaletteUse(code string) {
174 d.paletteRecent = recent174 d.paletteRecent = recent
175 }175 }
176 176
177-func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {177+func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
178 entry := ac.currentAttachmentSession()178 entry := ac.currentAttachmentSession()
179 if entry == nil {179 if entry == nil {
180 return180 return
@@ -189,7 +189,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
189 var generation uint64189 var generation uint64
190 var rawQuery string190 var rawQuery string
191 changed, cancel, execute := false, false, false191 changed, cancel, execute := false, false, false
192- var effect *roleEffectTicket192+ var effect *attachmentEffectTicket
193 if len(effects) != 0 {193 if len(effects) != 0 {
194 effect = effects[0]194 effect = effects[0]
195 }195 }
@@ -305,7 +305,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
305 }305 }
306 var err error306 var err error
307 if effect != nil {307 if effect != nil {
308- err = d.switchToTargetForRole(effect.roleToken(), target, sessionHandoffGuard{}, "palette-session")308+ err = d.switchToTargetForAttachment(effect.connectionToken(), target, sessionHandoffGuard{}, "palette-session")
309 } else if local {309 } else if local {
310 err = d.switchToTarget(sess, ac, target)310 err = d.switchToTarget(sess, ac, target)
311 } else {311 } else {
@@ -389,10 +389,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
389 }389 }
390 case "back-session":390 case "back-session":
391 if effect != nil {391 if effect != nil {
392- err = d.backSessionForRole(effect.roleToken())392+ err = d.backSessionForAttachment(effect.connectionToken())
393 }393 }
394 case "detach":394 case "detach":
395- if effect != nil && !d.clientGoneForRole(effect.roleToken(), true) {395+ if effect != nil && !d.clientGoneForAttachment(effect.connectionToken(), true) {
396 err = errAttachmentTransition396 err = errAttachmentTransition
397 }397 }
398 case "session-picker":398 case "session-picker":
@@ -413,17 +413,17 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
413 }413 }
414 return414 return
415 }415 }
416- roleHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach"416+ attachmentHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach"
417- if !roleHandoff && !ac.closeExecutedPalette(generation, rawQuery) {417+ if !attachmentHandoff && !ac.closeExecutedPalette(generation, rawQuery) {
418 return418 return
419 }419 }
420 sess.dispatchMu.Lock()420 sess.dispatchMu.Lock()
421 err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, effect: effect, redrawClosedPalette: true}, args)421 err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, effect: effect, redrawClosedPalette: true}, args)
422 sess.dispatchMu.Unlock()422 sess.dispatchMu.Unlock()
423- if roleHandoff {423+ if attachmentHandoff {
424 if current := ac.currentSession(); current != nil {424 if current := ac.currentSession(); current != nil {
425 currentToken := current.attachmentToken(ac, ac.transport())425 currentToken := current.attachmentToken(ac, ac.transport())
426- fresh, admitted := ac.beginRoleEffect(currentToken)426+ fresh, admitted := ac.beginAttachmentEffect(currentToken)
427 if ac.closeExecutedPalette(generation, rawQuery) {427 if ac.closeExecutedPalette(generation, rawQuery) {
428 d.invalidateRender(current, ac, true, "palette.go")428 d.invalidateRender(current, ac, true, "palette.go")
429 }429 }
@@ -495,13 +495,14 @@ type paletteExec struct {
495 ac *attachedClient495 ac *attachedClient
496 recent []recentSession496 recent []recentSession
497 actions daemonActionRunner497 actions daemonActionRunner
498- effect *roleEffectTicket498+ effect *attachmentEffectTicket
499 redrawClosedPalette bool499 redrawClosedPalette bool
500 }500 }
501 501
502 func (e paletteExec) runAction(request daemonActionRequest) error {502 func (e paletteExec) runAction(request daemonActionRequest) error {
503+ request.effect = e.effect
503 if request.target.session == nil {504 if request.target.session == nil {
504- request.target = resolveDaemonActionTarget(e.sess)505+ request.target = resolveDaemonActionTargetForAttachment(e.sess, e.ac)
505 }506 }
506 runner := e.actions507 runner := e.actions
507 if runner == nil {508 if runner == nil {
@@ -532,7 +533,7 @@ func (e paletteExec) CreateSession() error {
532 if entry == nil {533 if entry == nil {
533 entry = e.sess534 entry = e.sess
534 }535 }
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 {
536 if token.ac == nil {537 if token.ac == nil {
537 sess, ok := e.localSession()538 sess, ok := e.localSession()
538 if !ok {539 if !ok {
@@ -540,7 +541,7 @@ func (e paletteExec) CreateSession() error {
540 }541 }
541 return e.d.createSessionAndSwitch(sess, e.ac, name)542 return e.d.createSessionAndSwitch(sess, e.ac, name)
542 }543 }
543- return e.d.createSessionAndSwitchForRole(token, name)544+ return e.d.createSessionAndSwitchForAttachment(token, name)
544 })545 })
545 return nil546 return nil
546 }547 }
@@ -580,27 +581,27 @@ func (e paletteExec) ClosePane() error {
580 }581 }
581 582
582 func (e paletteExec) OpenMovePanePicker() error {583 func (e paletteExec) OpenMovePanePicker() error {
583- target := resolveDaemonActionTarget(e.sess)584+ target := resolveDaemonActionTargetForAttachment(e.sess, e.ac)
584 if target.tab == nil || target.pane == nil {585 if target.tab == nil || target.pane == nil {
585 return errMovePaneInvalid586 return errMovePaneInvalid
586 }587 }
587 return e.d.enterPickerForIntent(e.sess, e.ac, pickerMovePane, moveSourceLocator{588 return e.d.enterPickerForIntent(e.sess, e.ac, pickerMovePane, moveSourceLocator{
588- Session: sessionMoveLocator(e.sess),589+ Session: sessionMoveLocator(e.sess),
589- TabID: domain.TabStableID(target.tab.stableID),590+ TabID: domain.TabStableID(target.tab.stableID),
590- PaneID: domain.PaneStableID(target.pane.stableID),591+ PaneID: domain.PaneStableID(target.pane.stableID),
591- Client: e.ac,592+ Attachment: e.ac,
592 })593 })
593 }594 }
594 595
595 func (e paletteExec) OpenMoveTabPicker() error {596 func (e paletteExec) OpenMoveTabPicker() error {
596- target := resolveDaemonActionTarget(e.sess)597+ target := resolveDaemonActionTargetForAttachment(e.sess, e.ac)
597 if target.tab == nil {598 if target.tab == nil {
598 return errMovePaneInvalid599 return errMovePaneInvalid
599 }600 }
600 return e.d.enterPickerForIntent(e.sess, e.ac, pickerMoveTab, moveSourceLocator{601 return e.d.enterPickerForIntent(e.sess, e.ac, pickerMoveTab, moveSourceLocator{
601- Session: sessionMoveLocator(e.sess),602+ Session: sessionMoveLocator(e.sess),
602- TabID: domain.TabStableID(target.tab.stableID),603+ TabID: domain.TabStableID(target.tab.stableID),
603- Client: e.ac,604+ Attachment: e.ac,
604 })605 })
605 }606 }
606 607
@@ -639,7 +640,7 @@ func (e paletteExec) ToggleFloatingPane() error {
639 640
640 func (e paletteExec) BackSession() error {641 func (e paletteExec) BackSession() error {
641 if e.effect != nil {642 if e.effect != nil {
642- return e.d.backSessionForRole(e.effect.roleToken())643+ return e.d.backSessionForAttachment(e.effect.connectionToken())
643 }644 }
644 e.d.backSession(e.sess, e.ac)645 e.d.backSession(e.sess, e.ac)
645 return nil646 return nil
@@ -647,7 +648,7 @@ func (e paletteExec) BackSession() error {
647 648
648 func (e paletteExec) Detach() error {649 func (e paletteExec) Detach() error {
649 if e.effect != nil {650 if e.effect != nil {
650- if !e.d.clientGoneForRole(e.effect.roleToken(), true) {651+ if !e.d.clientGoneForAttachment(e.effect.connectionToken(), true) {
651 return errAttachmentTransition652 return errAttachmentTransition
652 }653 }
653 return nil654 return nil
@@ -672,12 +673,22 @@ func (e paletteExec) RenameSession() error {
672 }673 }
673 674
674 func (e paletteExec) RenameTab() error {675 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+ }
676 if tb == nil {680 if tb == nil {
677 return nil681 return nil
678 }682 }
679 e.sess.mu.Lock()683 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)
681 e.sess.mu.Unlock()692 e.sess.mu.Unlock()
682 e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error {693 e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error {
683 return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, target: daemonActionTarget{session: e.sess, tab: tb}, name: name})694 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 {
733 }744 }
734 var err error745 var err error
735 if e.effect != nil {746 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")
737 } else {748 } else {
738 sess, ok := e.localSession()749 sess, ok := e.localSession()
739 if !ok {750 if !ok {
M internal/usecase/daemon/palette_test.go +24 -24
@@ -94,7 +94,7 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
94 p2, release2 := newBlockingPTY(t)94 p2, release2 := newBlockingPTY(t)
95 d, sess, ac, sends := newManualSessionWithPTYs(t, p1, p2)95 d, sess, ac, sends := newManualSessionWithPTYs(t, p1, p2)
96 sess.mu.Lock()96 sess.mu.Lock()
97- sess.client = ac97+ sess.registerAttachmentLocked(ac)
98 sess.mu.Unlock()98 sess.mu.Unlock()
99 d.ptys = newBlockingOpenFactory(t, d)99 d.ptys = newBlockingOpenFactory(t, d)
100 defer release1()100 defer release1()
@@ -109,8 +109,8 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
109 109
110 d.handleInput(sess, ac, []byte("NXT\r"))110 d.handleInput(sess, ac, []byte("NXT\r"))
111 require.False(t, ac.overlays.paletteActive())111 require.False(t, ac.overlays.paletteActive())
112- require.Equal(t, 1, activeTabIndex(sess))112+ require.Equal(t, 1, testAttachmentTabIndex(sess))
113- requireFloatingInitialized(t, sess.activeTab())113+ requireFloatingInitialized(t, testAttachmentTab(sess))
114 awaitFrame(t, sends, ports.MsgOutput)114 awaitFrame(t, sends, ports.MsgOutput)
115 115
116 d.handleInput(sess, ac, []byte("\x1b "))116 d.handleInput(sess, ac, []byte("\x1b "))
@@ -138,7 +138,7 @@ func TestRemotePaletteClosesBeforeOpeningLocalOverlay(t *testing.T) {
138 t.Run(test.name, func(t *testing.T) {138 t.Run(test.name, func(t *testing.T) {
139 d, _, ac, link, handler := newProxyInputHarness(t)139 d, _, ac, link, handler := newProxyInputHarness(t)
140 handler.enterPalette()140 handler.enterPalette()
141- d.handlePaletteInput(ac, []byte(test.code+"\r"), handler.roleToken.effect)141+ d.handlePaletteInput(ac, []byte(test.code+"\r"), handler.connectionToken.effect)
142 142
143 require.False(t, ac.overlays.paletteActive(), "stale remote palette remained active")143 require.False(t, ac.overlays.paletteActive(), "stale remote palette remained active")
144 require.True(t, test.active(ac.overlays), "replacement local overlay was not opened")144 require.True(t, test.active(ac.overlays), "replacement local overlay was not opened")
@@ -317,7 +317,7 @@ func TestPaletteSelectedActiveSessionSwitchesWithoutRecordingCommandRecency(t *t
317 d.handleInput(current, ac, []byte("recent\r"))317 d.handleInput(current, ac, []byte("recent\r"))
318 318
319 require.Same(t, target, ac.currentSession())319 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")
321 require.False(t, ac.overlays.paletteActive())321 require.False(t, ac.overlays.paletteActive())
322 require.Empty(t, d.paletteRecent, "session selections are not command recency")322 require.Empty(t, d.paletteRecent, "session selections are not command recency")
323 awaitFrame(t, sends, ports.MsgOutput)323 awaitFrame(t, sends, ports.MsgOutput)
@@ -340,7 +340,7 @@ func TestPaletteStoppedSessionResumeFailureKeepsPaletteAndSourceAttachment(t *te
340 340
341 require.Same(t, current, ac.currentSession(), "failed resume must retain the source attachment")341 require.Same(t, current, ac.currentSession(), "failed resume must retain the source attachment")
342 current.mu.Lock()342 current.mu.Lock()
343- require.Same(t, ac, current.client)343+ require.Contains(t, current.snapshotAttachmentsLocked(), ac)
344 current.mu.Unlock()344 current.mu.Unlock()
345 require.True(t, ac.overlays.paletteActive())345 require.True(t, ac.overlays.paletteActive())
346 ac.overlays.paletteMu.Lock()346 ac.overlays.paletteMu.Lock()
@@ -361,14 +361,14 @@ func TestPaletteSelectedStoppedSessionResumesAndSwitches(t *testing.T) {
361 361
362 d.handleInput(current, ac, []byte("\x1b "))362 d.handleInput(current, ac, []byte("\x1b "))
363 awaitFrame(t, sends, ports.MsgOutput)363 awaitFrame(t, sends, ports.MsgOutput)
364- generation := ac.roleGeneration.Load()364+ generation := ac.connectionGeneration.Load()
365 d.handleInput(current, ac, []byte("stopped\r"))365 d.handleInput(current, ac, []byte("stopped\r"))
366 366
367 resumed := ac.currentSession()367 resumed := ac.currentSession()
368 require.Equal(t, "stopped", resumed.name)368 require.Equal(t, "stopped", resumed.name)
369 require.Equal(t, int64(42), resumed.createdAt)369 require.Equal(t, int64(42), resumed.createdAt)
370- require.Equal(t, attachmentActive, resumed.attachmentRole(ac))370+ require.Equal(t, true, resumed.attachmentRegistered(ac))
371- require.Greater(t, ac.roleGeneration.Load(), generation, "stopped-session handoff must publish through the attachment transition")371+ require.Greater(t, ac.connectionGeneration.Load(), generation, "stopped-session handoff must publish through the attachment transition")
372 require.False(t, ac.overlays.paletteActive())372 require.False(t, ac.overlays.paletteActive())
373 firstPaint := awaitFrame(t, sends, ports.MsgOutput)373 firstPaint := awaitFrame(t, sends, ports.MsgOutput)
374 firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload)374 firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload)
@@ -440,7 +440,7 @@ func TestPaletteFuzzySelectedStaticCommandExecutes(t *testing.T) {
440 d.handleInput(sess, ac, []byte("next\r"))440 d.handleInput(sess, ac, []byte("next\r"))
441 441
442 require.False(t, ac.overlays.paletteActive())442 require.False(t, ac.overlays.paletteActive())
443- require.Equal(t, 1, activeTabIndex(sess))443+ require.Equal(t, 1, testAttachmentTabIndex(sess))
444 awaitFrame(t, sends, ports.MsgOutput)444 awaitFrame(t, sends, ports.MsgOutput)
445 }445 }
446 446
@@ -486,7 +486,7 @@ func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) {
486 invalidations := installPaletteInvalidationObserver(current)486 invalidations := installPaletteInvalidationObserver(current)
487 d.enterPalette(current, ac)487 d.enterPalette(current, ac)
488 d.handlePaletteInput(ac, []byte("BSK"))488 d.handlePaletteInput(ac, []byte("BSK"))
489- d.afterActionRoleEffectEnded = func(action string) {489+ d.afterActionAttachmentEffectEnded = func(action string) {
490 if action == "back-session" {490 if action == "back-session" {
491 d.mu.Lock()491 d.mu.Lock()
492 delete(d.sessions, target.core().id)492 delete(d.sessions, target.core().id)
@@ -495,7 +495,7 @@ func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) {
495 }495 }
496 496
497 token := current.attachmentToken(ac, ac.transport())497 token := current.attachmentToken(ac, ac.transport())
498- effect, admitted := ac.beginRoleEffect(token)498+ effect, admitted := ac.beginAttachmentEffect(token)
499 require.True(t, admitted)499 require.True(t, admitted)
500 d.handlePaletteInput(ac, []byte("\r"), effect)500 d.handlePaletteInput(ac, []byte("\r"), effect)
501 501
@@ -505,7 +505,7 @@ func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) {
505 require.True(t, invalidation.reset)505 require.True(t, invalidation.reset)
506 }506 }
507 507
508-func TestPaletteDeniedPostHandoffRoleEffectClosesAndInvalidates(t *testing.T) {508+func TestPaletteDeniedPostHandoffAttachmentEffectClosesAndInvalidates(t *testing.T) {
509 p, release := newBlockingPTY(t)509 p, release := newBlockingPTY(t)
510 defer release()510 defer release()
511 d, sess, ac, _ := newManualSessionWithPTYs(t, p)511 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
@@ -514,10 +514,10 @@ func TestPaletteDeniedPostHandoffRoleEffectClosesAndInvalidates(t *testing.T) {
514 d.enterPalette(sess, ac)514 d.enterPalette(sess, ac)
515 d.handlePaletteInput(ac, []byte("BSK"))515 d.handlePaletteInput(ac, []byte("BSK"))
516 // Make the attachment token detached while retaining currentSession. The516 // Make the attachment token detached while retaining currentSession. The
517- // no-op BSK command succeeds, but its post-execution beginRoleEffect is517+ // no-op BSK command succeeds, but its post-execution beginAttachmentEffect is
518 // deterministically denied.518 // deterministically denied.
519 sess.mu.Lock()519 sess.mu.Lock()
520- sess.client = nil520+ clearAttachmentsForTestLocked(sess)
521 sess.mu.Unlock()521 sess.mu.Unlock()
522 522
523 d.handlePaletteInput(ac, []byte("\r"))523 d.handlePaletteInput(ac, []byte("\r"))
@@ -639,7 +639,7 @@ func TestPaletteFLTExecutesFloatingToggle(t *testing.T) {
639 p, release := newBlockingPTY(t)639 p, release := newBlockingPTY(t)
640 defer release()640 defer release()
641 d, sess, ac, sends := newManualSessionWithPTYs(t, p)641 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
642- tb := sess.activeTab()642+ tb := testAttachmentTab(sess)
643 installTestFloating(tb, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), false)643 installTestFloating(tb, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), false)
644 644
645 d.handleInput(sess, ac, []byte("\x1b "))645 d.handleInput(sess, ac, []byte("\x1b "))
@@ -671,7 +671,7 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
671 require.True(t, ac.overlays.promptActive())671 require.True(t, ac.overlays.promptActive())
672 require.Contains(t, string(promptOutput.Data), "Create session")672 require.Contains(t, string(promptOutput.Data), "Create session")
673 673
674- generation := ac.roleGeneration.Load()674+ generation := ac.connectionGeneration.Load()
675 d.handleInput(sess, ac, []byte("scratch\r"))675 d.handleInput(sess, ac, []byte("scratch\r"))
676 // The submit first paints the newly attached session while the prompt is676 // The submit first paints the newly attached session while the prompt is
677 // still open, then handlePromptInput closes the prompt and repaints the677 // still open, then handlePromptInput closes the prompt and repaints the
@@ -685,15 +685,15 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
685 require.NoError(t, err)685 require.NoError(t, err)
686 require.False(t, ac.overlays.promptActive())686 require.False(t, ac.overlays.promptActive())
687 require.Equal(t, 2, sessionCount(d))687 require.Equal(t, 2, sessionCount(d))
688- require.Nil(t, sess.client)688+ require.Empty(t, sess.snapshotAttachments())
689 newSess := ac.currentSession()689 newSess := ac.currentSession()
690 require.NotNil(t, newSess)690 require.NotNil(t, newSess)
691 require.NotSame(t, sess, newSess)691 require.NotSame(t, sess, newSess)
692 require.Equal(t, "scratch", newSess.name)692 require.Equal(t, "scratch", newSess.name)
693 require.False(t, newSess.ephemeral)693 require.False(t, newSess.ephemeral)
694- require.Same(t, ac, newSess.client)694+ require.Contains(t, newSess.snapshotAttachments(), ac)
695- require.Equal(t, attachmentActive, newSess.attachmentRole(ac))695+ require.Equal(t, true, newSess.attachmentRegistered(ac))
696- require.Greater(t, ac.roleGeneration.Load(), generation, "new-session handoff must publish through the attachment transition")696+ require.Greater(t, ac.connectionGeneration.Load(), generation, "new-session handoff must publish through the attachment transition")
697 require.Contains(t, string(finalOutput.Data), "scratch")697 require.Contains(t, string(finalOutput.Data), "scratch")
698 require.NotContains(t, string(finalOutput.Data), "Create session")698 require.NotContains(t, string(finalOutput.Data), "Create session")
699 }699 }
@@ -701,7 +701,7 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
701 func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) {701 func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) {
702 d, sess, ac, sends, releases := newManualTabSession(t, 2)702 d, sess, ac, sends, releases := newManualTabSession(t, 2)
703 sess.mu.Lock()703 sess.mu.Lock()
704- sess.client = ac704+ sess.registerAttachmentLocked(ac)
705 sess.mu.Unlock()705 sess.mu.Unlock()
706 defer releases[0]()706 defer releases[0]()
707 defer releases[1]()707 defer releases[1]()
@@ -1125,9 +1125,9 @@ func TestPaletteExecMethods(t *testing.T) {
1125 exec := paletteExec{d: d, sess: sess, ac: ac}1125 exec := paletteExec{d: d, sess: sess, ac: ac}
1126 1126
1127 require.NoError(t, exec.NextTab())1127 require.NoError(t, exec.NextTab())
1128- require.Equal(t, 1, activeTabIndex(sess))1128+ require.Equal(t, 1, testAttachmentTabIndex(sess))
1129 require.NoError(t, exec.PrevTab())1129 require.NoError(t, exec.PrevTab())
1130- require.Equal(t, 0, activeTabIndex(sess))1130+ require.Equal(t, 0, testAttachmentTabIndex(sess))
1131 sess.ephemeral = true1131 sess.ephemeral = true
1132 require.NoError(t, exec.RenameSession())1132 require.NoError(t, exec.RenameSession())
1133 require.True(t, ac.overlays.promptActive())1133 require.True(t, ac.overlays.promptActive())
M internal/usecase/daemon/pane_actions.go +145 -45
@@ -10,7 +10,7 @@ import (
10 )10 )
11 11
12 func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Direction) error {12 func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Direction) error {
13- target := resolveDaemonActionTarget(sess)13+ target := resolveDaemonActionTargetForAttachment(sess, ac)
14 err := d.splitPaneAt(sess, target.tab, target.pane, dir)14 err := d.splitPaneAt(sess, target.tab, target.pane, dir)
15 if err == nil && ac != nil {15 if err == nil && ac != nil {
16 d.invalidateRender(sess, ac, true, "pane_actions.go")16 d.invalidateRender(sess, ac, true, "pane_actions.go")
@@ -131,7 +131,7 @@ func placementContent(placements []layout.Placement, id layout.PaneID) domain.Re
131 func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Rows: r.Height} }131 func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Rows: r.Height} }
132 132
133 func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {133 func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {
134- target := resolveDaemonActionTarget(sess)134+ target := resolveDaemonActionTargetForAttachment(sess, ac)
135 err := d.stackPaneAt(sess, target.tab, target.pane)135 err := d.stackPaneAt(sess, target.tab, target.pane)
136 if err == nil && ac != nil {136 if err == nil && ac != nil {
137 d.invalidateRender(sess, ac, true, "pane_actions.go")137 d.invalidateRender(sess, ac, true, "pane_actions.go")
@@ -146,7 +146,7 @@ func (d *Daemon) stackPaneAt(sess *session, tb *tab, target *pane) error {
146 }146 }
147 147
148 func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {148 func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
149- target := resolveDaemonActionTarget(sess)149+ target := resolveDaemonActionTargetForAttachment(sess, ac)
150 err := d.toggleStackAt(sess, target.tab, target.pane)150 err := d.toggleStackAt(sess, target.tab, target.pane)
151 if err == nil && ac != nil {151 if err == nil && ac != nil {
152 d.invalidateRender(sess, ac, true, "pane_actions.go")152 d.invalidateRender(sess, ac, true, "pane_actions.go")
@@ -190,18 +190,15 @@ func (d *Daemon) closeFocusedPane(sess *session, ac *attachedClient) error {
190 }190 }
191 return nil191 return nil
192 }192 }
193- tb := sess.activeTab()193+ target := resolveDaemonActionTargetForAttachment(sess, ac)
194+ tb := target.tab
194 if tb == nil {195 if tb == nil {
195 return layout.ErrNotFound196 return layout.ErrNotFound
196 }197 }
197- tb.mu.Lock()198+ if target.pane == nil {
198- if tb.tree == nil {
199- tb.mu.Unlock()
200 return layout.ErrNotFound199 return layout.ErrNotFound
201 }200 }
202- id := tb.tree.Focus201+ return d.closePane(sess, tb, target.pane.id, ac, true)
203- tb.mu.Unlock()
204- return d.closePane(sess, tb, id, ac, true)
205 }202 }
206 203
207 // reapPane retains the explicit-owner entry point for focused close tests. PTY204 // reapPane retains the explicit-owner entry point for focused close tests. PTY
@@ -244,6 +241,11 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
244 return true241 return true
245 }242 }
246 sess, tb := owner.session, owner.tab243 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()
247 sess.mu.Lock()249 sess.mu.Lock()
248 ownedTab := slices.Contains(sess.tabs, tb)250 ownedTab := slices.Contains(sess.tabs, tb)
249 if p.owner.Load() != owner {251 if p.owner.Load() != owner {
@@ -254,7 +256,7 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
254 sess.mu.Unlock()256 sess.mu.Unlock()
255 return true257 return true
256 }258 }
257- ac := sess.client259+ attachments := sess.snapshotAttachmentsLocked()
258 sessionName := sess.name260 sessionName := sess.name
259 tb.mu.Lock()261 tb.mu.Lock()
260 if p.owner.Load() != owner {262 if p.owner.Load() != owner {
@@ -288,15 +290,23 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
288 p.clearOwnerLocked()290 p.clearOwnerLocked()
289 p.mu.Unlock()291 p.mu.Unlock()
290 tb.mu.Unlock()292 tb.mu.Unlock()
293+ sess.invalidateViewsLocked()
291 sess.mu.Unlock()294 sess.mu.Unlock()
292 295
293 if finalPane {296 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)
295 return true303 return true
296 }304 }
297 d.applyTabLayout(sess, tb)305 d.applyTabLayout(sess, tb)
298- if ac != nil {306+ for _, ac := range attachments {
299- ac.overlays.clearCopyModeForPane(p)307+ if ac.overlays != nil {
308+ ac.overlays.clearCopyModeForPane(p)
309+ }
300 ac.pruneCaptureFrames(p)310 ac.pruneCaptureFrames(p)
301 }311 }
302 if rc := sess.renderCoordinator(); rc != nil {312 if rc := sess.renderCoordinator(); rc != nil {
@@ -304,21 +314,36 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
304 }314 }
305 closePaneProcess(p)315 closePaneProcess(p)
306 d.log.Info("pane closed", "session", sessionName, "pane", p.id)316 d.log.Info("pane closed", "session", sessionName, "pane", p.id)
307- if ac != nil {317+ for _, ac := range attachments {
308 d.invalidateRender(sess, ac, true, "pane_actions.go")318 d.invalidateRender(sess, ac, true, "pane_actions.go")
309 }319 }
310 return true320 return true
311 }321 }
312 322
323+// closePane is the public close boundary for callers that are not already
324+// inside session.runMutation. Mutation dispatchers use closePaneLocked.
313 func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool) error {325 func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool) error {
314- if tb == nil {326+ if sess == nil {
315 return layout.ErrNotFound327 return layout.ErrNotFound
316 }328 }
317- if ac == nil {329+ sess.dispatchMu.Lock()
318- sess.mu.Lock()330+ defer sess.dispatchMu.Unlock()
319- ac = sess.client331+ return d.closePaneLocked(sess, tb, id, ac, repaint)
320- sess.mu.Unlock()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
321 }344 }
345+ // A nil attachment denotes a session-wide close with an explicit pane target.
346+ attachments := sess.snapshotAttachments()
322 tb.mu.Lock()347 tb.mu.Lock()
323 p := tb.panes[id]348 p := tb.panes[id]
324 if p == nil || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, id) {349 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
327 }352 }
328 if len(layout.LeafIDs(tb.tree.Root)) <= 1 {353 if len(layout.LeafIDs(tb.tree.Root)) <= 1 {
329 tb.mu.Unlock()354 tb.mu.Unlock()
330- if ac != nil {355+ for _, viewer := range attachments {
331- ac.overlays.clearCopyModeForPane(p)356+ if viewer.overlays != nil {
357+ viewer.overlays.clearCopyModeForPane(p)
358+ }
359+ viewer.pruneCaptureFrames(p)
332 }360 }
333- return d.closeTab(sess, tb, repaint)361+ return d.closeTabLockedWithEffect(sess, tb, repaint, effect)
334 }362 }
335 if err := tb.tree.Close(id); err != nil {363 if err := tb.tree.Close(id); err != nil {
336 tb.mu.Unlock()364 tb.mu.Unlock()
@@ -339,11 +367,16 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
339 delete(tb.panes, id)367 delete(tb.panes, id)
340 tb.bumpLayoutGenerationLocked()368 tb.bumpLayoutGenerationLocked()
341 tb.mu.Unlock()369 tb.mu.Unlock()
370+ sess.mu.Lock()
371+ sess.invalidateViewsLocked()
372+ sess.mu.Unlock()
342 d.applyTabLayout(sess, tb)373 d.applyTabLayout(sess, tb)
343 374
344- if ac != nil {375+ for _, viewer := range attachments {
345- ac.overlays.clearCopyModeForPane(p)376+ if viewer.overlays != nil {
346- ac.pruneCaptureFrames(p)377+ viewer.overlays.clearCopyModeForPane(p)
378+ }
379+ viewer.pruneCaptureFrames(p)
347 }380 }
348 381
349 if rc := sess.renderCoordinator(); rc != nil {382 if rc := sess.renderCoordinator(); rc != nil {
@@ -354,18 +387,22 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
354 if repaint {387 if repaint {
355 if ac != nil {388 if ac != nil {
356 d.invalidateRender(sess, ac, true, "pane_actions.go")389 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+ }
357 }394 }
358 }395 }
359 return nil396 return nil
360 }397 }
361 398
362-func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction, effect *roleEffectTicket) error {399+func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction, effect *attachmentEffectTicket) error {
363- target := resolveDaemonActionTarget(sess)400+ target := resolveDaemonActionTargetForAttachment(sess, ac)
364 oldFocus := layout.PaneID("")401 oldFocus := layout.PaneID("")
365 if target.pane != nil {402 if target.pane != nil {
366 oldFocus = target.pane.id403 oldFocus = target.pane.id
367 }404 }
368- span, err := d.focusDirAt(sess, target.tab, target.pane, dir)405+ span, err := d.focusDirAt(sess, target.tab, target.pane, dir, ac)
369 if err == nil {406 if err == nil {
370 if ac != nil {407 if ac != nil {
371 d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "pane_actions.go")408 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
375 if !errors.Is(err, errNoNeighbor) || target.tab == nil {412 if !errors.Is(err, errNoNeighbor) || target.tab == nil {
376 return err413 return err
377 }414 }
378- if !overflowSourceEligible(sess, target.tab) {415+ if !overflowSourceEligible(sess, ac, target.tab) {
379 return errNoNeighbor416 return errNoNeighbor
380 }417 }
381 418
@@ -385,24 +422,22 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
385 return errNoNeighbor422 return errNoNeighbor
386 }423 }
387 if effect != nil {424 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")
389 }426 }
390 return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)427 return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)
391 }428 }
392 429
393- sess.mu.Lock()430+ position, count := sess.tabIndexForAttachment(ac)
394- position, count := sess.active, len(sess.tabs)
395- sess.mu.Unlock()
396 step := resolveOverflow(dir, cfg, position, count)431 step := resolveOverflow(dir, cfg, position, count)
397 if step.kind != overflowTabs {432 if step.kind != overflowTabs {
398 return err433 return err
399 }434 }
400- candidate, ok := d.prepareTabOverflow(sess, target.tab, dir, span, step.delta)435+ candidate, ok := d.prepareTabOverflowForAttachment(sess, ac, target.tab, dir, span, step.delta)
401- if !ok || !d.commitTabOverflow(sess, candidate) {436+ if !ok || !d.commitTabOverflowForAttachment(sess, ac, candidate) {
402 return errNoNeighbor437 return errNoNeighbor
403 }438 }
404- d.activateTab(sess, candidate.target)
405 if ac != nil {439 if ac != nil {
440+ sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID))
406 d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "pane_actions.go")441 d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "pane_actions.go")
407 }442 }
408 return nil443 return nil
@@ -412,23 +447,77 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
412 // successful directional focus move. Keep copy-mode exit before the optional447 // successful directional focus move. Keep copy-mode exit before the optional
413 // stack title refresh, and both before render invalidation.448 // stack title refresh, and both before render invalidation.
414 func (d *Daemon) finishPaneFocusForClient(sess *session, ac *attachedClient, tb *tab, oldFocus layout.PaneID, producer string) {449 func (d *Daemon) finishPaneFocusForClient(sess *session, ac *attachedClient, tb *tab, oldFocus layout.PaneID, producer string) {
415- tb.mu.Lock()450+ var newFocus layout.PaneID
416- newFocus := tb.tree.Focus451+ var newPane *pane
417- pl, hasPlacement := focusedPlacementLocked(tb)452+ var pl layout.Placement
418- tb.mu.Unlock()453+ var hasPlacement bool
419- if newFocus != oldFocus {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 {
420 d.exitCopyMode(ac)492 d.exitCopyMode(ac)
421 if hasPlacement && pl.InStack {493 if hasPlacement && pl.InStack {
422- d.refreshPaneTitleOnFocus(sess, newFocus)494+ d.refreshPaneTitleOnFocus(sess, newFocus, tb)
423 }495 }
424 }496 }
425 d.invalidateRender(sess, ac, true, producer)497 d.invalidateRender(sess, ac, true, producer)
426 }498 }
427 499
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) {
429 if tb == nil || target == nil {514 if tb == nil || target == nil {
430 return domain.Rect{}, layout.ErrNotFound515 return domain.Rect{}, layout.ErrNotFound
431 }516 }
517+ var ac *attachedClient
518+ if len(initiating) > 0 {
519+ ac = initiating[0]
520+ }
432 tb.mu.Lock()521 tb.mu.Lock()
433 if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) {522 if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) {
434 tb.mu.Unlock()523 tb.mu.Unlock()
@@ -439,13 +528,24 @@ func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Dir
439 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}528 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
440 span, _ := candidate.FocusSpan(area)529 span, _ := candidate.FocusSpan(area)
441 err := candidate.FocusDir(dir, area)530 err := candidate.FocusDir(dir, area)
442- committed := err == nil && candidate.Focus != tb.tree.Focus531+ currentFocus := tb.focusedPane()
532+ committed := err == nil && currentFocus != nil && candidate.Focus != currentFocus.id
533+ var focusedPane *pane
443 if committed {534 if committed {
535+ focusedPane = tb.panes[candidate.Focus]
444 tb.tree = candidate536 tb.tree = candidate
445 tb.bumpLayoutGenerationLocked()537 tb.bumpLayoutGenerationLocked()
446 }538 }
447 tb.mu.Unlock()539 tb.mu.Unlock()
448 if committed {540 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+ }
449 d.applyTabLayout(sess, tb)549 d.applyTabLayout(sess, tb)
450 }550 }
451 if errors.Is(err, layout.ErrNoPane) {551 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) {
78 d, sess, _, _ := newManualSessionWithPTYs(t, pty)78 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
79 clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 16)}79 clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 16)}
80 d.clock = clock80 d.clock = clock
81- tb := sess.activeTab()81+ tb := testAttachmentTab(sess)
82 tb.mu.Lock()82 tb.mu.Lock()
83 tb.size = domain.Size{Cols: 100, Rows: 20}83 tb.size = domain.Size{Cols: 100, Rows: 20}
84 tb.bumpLayoutGenerationLocked()84 tb.bumpLayoutGenerationLocked()
@@ -118,7 +118,7 @@ func TestLayoutRetryIsBoundedDeduplicatedAndCanceled(t *testing.T) {
118 d, sess, _, _ := newManualSessionWithPTYs(t, pty)118 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
119 clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 16)}119 clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 16)}
120 d.clock = clock120 d.clock = clock
121- tb := sess.activeTab()121+ tb := testAttachmentTab(sess)
122 tb.mu.Lock()122 tb.mu.Lock()
123 tb.size = domain.Size{Cols: 100, Rows: 20}123 tb.size = domain.Size{Cols: 100, Rows: 20}
124 tb.bumpLayoutGenerationLocked()124 tb.bumpLayoutGenerationLocked()
@@ -145,7 +145,7 @@ func TestLayoutRetryIsBoundedDeduplicatedAndCanceled(t *testing.T) {
145 func TestFocusDirAtDoesNotApplyUncommittedCandidate(t *testing.T) {145 func TestFocusDirAtDoesNotApplyUncommittedCandidate(t *testing.T) {
146 pty := &transactionalResizePTY{}146 pty := &transactionalResizePTY{}
147 d, sess, _, _ := newManualSessionWithPTYs(t, pty)147 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
148- tb := sess.activeTab()148+ tb := testAttachmentTab(sess)
149 second := newPane("pane-2", nil, domain.Size{Cols: 80, Rows: 23})149 second := newPane("pane-2", nil, domain.Size{Cols: 80, Rows: 23})
150 tb.mu.Lock()150 tb.mu.Lock()
151 tb.panes[second.id] = second151 tb.panes[second.id] = second
@@ -164,7 +164,7 @@ func TestLayoutApplicationRetriesOnlyAcceptedFailedGeometry(t *testing.T) {
164 d, sess, _, _ := newManualSessionWithPTYs(t, pty)164 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
165 clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 16)}165 clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 16)}
166 d.clock = clock166 d.clock = clock
167- tb, p := sess.activeTab(), sess.activeTab().focusedPane()167+ tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
168 tb.mu.Lock()168 tb.mu.Lock()
169 tb.size = domain.Size{Cols: 100, Rows: 20}169 tb.size = domain.Size{Cols: 100, Rows: 20}
170 tb.bumpLayoutGenerationLocked()170 tb.bumpLayoutGenerationLocked()
@@ -206,7 +206,7 @@ func TestDelayedRetryUsesFreshLayoutAfterPaneResize(t *testing.T) {
206 failed := &transactionalResizePTY{errs: []error{errors.New("first resize fails")}}206 failed := &transactionalResizePTY{errs: []error{errors.New("first resize fails")}}
207 d, sess, ac, _ := newManualSessionWithPTYs(t, failed)207 d, sess, ac, _ := newManualSessionWithPTYs(t, failed)
208 d.clock = clock.clock208 d.clock = clock.clock
209- tb := sess.activeTab()209+ tb := testAttachmentTab(sess)
210 first := tb.focusedPane()210 first := tb.focusedPane()
211 second := newPane("pane-2", nil, domain.Size{Cols: 80, Rows: 23})211 second := newPane("pane-2", nil, domain.Size{Cols: 80, Rows: 23})
212 tb.mu.Lock()212 tb.mu.Lock()
@@ -260,7 +260,7 @@ func TestDelayedRetryUsesFreshLayoutAfterPaneResize(t *testing.T) {
260 260
261 func TestLayoutApplicationDoesNotHoldTabLock(t *testing.T) {261 func TestLayoutApplicationDoesNotHoldTabLock(t *testing.T) {
262 d, sess, pty, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})262 d, sess, pty, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
263- tb := sess.activeTab()263+ tb := testAttachmentTab(sess)
264 tb.focusedPane().rect = domain.Rect{Width: 20, Height: 10}264 tb.focusedPane().rect = domain.Rect{Width: 20, Height: 10}
265 entered := make(chan struct{})265 entered := make(chan struct{})
266 release := make(chan struct{})266 release := make(chan struct{})
@@ -290,7 +290,7 @@ func TestLayoutApplicationDoesNotHoldTabLock(t *testing.T) {
290 290
291 func TestLayoutApplicationRejectsStalePaneIdentity(t *testing.T) {291 func TestLayoutApplicationRejectsStalePaneIdentity(t *testing.T) {
292 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})292 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
293- tb := sess.activeTab()293+ tb := testAttachmentTab(sess)
294 oldPane := tb.focusedPane()294 oldPane := tb.focusedPane()
295 oldPane.rect = domain.Rect{Width: 20, Height: 10}295 oldPane.rect = domain.Rect{Width: 20, Height: 10}
296 oldPane.screen.Resize(20, 10)296 oldPane.screen.Resize(20, 10)
@@ -341,7 +341,7 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) {
341 first, entered, release := newBlockedPTY()341 first, entered, release := newBlockedPTY()
342 second := &transactionalResizePTY{}342 second := &transactionalResizePTY{}
343 d, sess, _, _ := newManualSessionWithPTYs(t, first)343 d, sess, _, _ := newManualSessionWithPTYs(t, first)
344- tb := sess.activeTab()344+ tb := testAttachmentTab(sess)
345 p1 := tb.focusedPane()345 p1 := tb.focusedPane()
346 p2 := newPane("pane-2", second, domain.Size{Cols: 80, Rows: 23})346 p2 := newPane("pane-2", second, domain.Size{Cols: 80, Rows: 23})
347 tb.mu.Lock()347 tb.mu.Lock()
@@ -383,7 +383,7 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) {
383 first, entered, release := newBlockedPTY()383 first, entered, release := newBlockedPTY()
384 second := &transactionalResizePTY{}384 second := &transactionalResizePTY{}
385 d, sess, _, _ := newManualSessionWithPTYs(t, first)385 d, sess, _, _ := newManualSessionWithPTYs(t, first)
386- tb := sess.activeTab()386+ tb := testAttachmentTab(sess)
387 p1 := tb.focusedPane()387 p1 := tb.focusedPane()
388 p2 := newPane("pane-2", second, domain.Size{Cols: 80, Rows: 23})388 p2 := newPane("pane-2", second, domain.Size{Cols: 80, Rows: 23})
389 tb.mu.Lock()389 tb.mu.Lock()
@@ -417,7 +417,7 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) {
417 t.Run("client size rejects and retries a blocked plan", func(t *testing.T) {417 t.Run("client size rejects and retries a blocked plan", func(t *testing.T) {
418 pty, entered, release := newBlockedPTY()418 pty, entered, release := newBlockedPTY()
419 d, sess, _, _ := newManualSessionWithPTYs(t, pty)419 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
420- tb := sess.activeTab()420+ tb := testAttachmentTab(sess)
421 p := tb.focusedPane()421 p := tb.focusedPane()
422 p.rect = domain.Rect{Width: 20, Height: 10}422 p.rect = domain.Rect{Width: 20, Height: 10}
423 423
@@ -476,7 +476,7 @@ func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) {
476 476
477 require.NoError(t, d.splitPane(sess, nil, tt.dir))477 require.NoError(t, d.splitPane(sess, nil, tt.dir))
478 478
479- tb := sess.activeTab()479+ tb := testAttachmentTab(sess)
480 tb.mu.Lock()480 tb.mu.Lock()
481 defer tb.mu.Unlock()481 defer tb.mu.Unlock()
482 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)482 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
@@ -492,7 +492,7 @@ func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) {
492 492
493 func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) {493 func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) {
494 d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 4})494 d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 4})
495- tb := sess.activeTab()495+ tb := testAttachmentTab(sess)
496 stackPTY := portsmocks.NewMockPTY(t)496 stackPTY := portsmocks.NewMockPTY(t)
497 stackPane := newPane("pane-2", stackPTY, domain.Size{Cols: 41, Rows: 2})497 stackPane := newPane("pane-2", stackPTY, domain.Size{Cols: 41, Rows: 2})
498 tb.panes[stackPane.id] = stackPane498 tb.panes[stackPane.id] = stackPane
@@ -520,7 +520,7 @@ func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) {
520 cause := errors.New("open failed")520 cause := errors.New("open failed")
521 factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(nil, cause).Once()521 factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(nil, cause).Once()
522 522
523- tb := sess.activeTab()523+ tb := testAttachmentTab(sess)
524 tb.mu.Lock()524 tb.mu.Lock()
525 beforeRoot, beforeFocus, beforeNext, beforePaneCount := tb.tree.Root.Leaf, tb.tree.Focus, tb.nextPaneID, len(tb.panes)525 beforeRoot, beforeFocus, beforeNext, beforePaneCount := tb.tree.Root.Leaf, tb.tree.Focus, tb.nextPaneID, len(tb.panes)
526 tb.mu.Unlock()526 tb.mu.Unlock()
@@ -547,7 +547,7 @@ func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) {
547 547
548 func TestApplyLayoutResizesPTYsAndScreens(t *testing.T) {548 func TestApplyLayoutResizesPTYsAndScreens(t *testing.T) {
549 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})549 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
550- tb := sess.activeTab()550+ tb := testAttachmentTab(sess)
551 newPTY := portsmocks.NewMockPTY(t)551 newPTY := portsmocks.NewMockPTY(t)
552 newPane := newPane("pane-2", newPTY, domain.Size{Cols: 41, Rows: 10})552 newPane := newPane("pane-2", newPTY, domain.Size{Cols: 41, Rows: 10})
553 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"}553 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) {
564 564
565 func TestResizeReflowsAllPanes(t *testing.T) {565 func TestResizeReflowsAllPanes(t *testing.T) {
566 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})566 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
567- tb := sess.activeTab()567+ tb := testAttachmentTab(sess)
568 newPTY := portsmocks.NewMockPTY(t)568 newPTY := portsmocks.NewMockPTY(t)
569 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"}569 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"}
570 tb.panes["pane-2"] = newPane("pane-2", newPTY, domain.Size{Cols: 20, Rows: 10})570 tb.panes["pane-2"] = newPane("pane-2", newPTY, domain.Size{Cols: 20, Rows: 10})
@@ -596,7 +596,7 @@ func TestFocusDirAtReturnsDepartingSpanAndMapsNoNeighbor(t *testing.T) {
596 for _, tt := range tests {596 for _, tt := range tests {
597 t.Run(tt.name, func(t *testing.T) {597 t.Run(tt.name, func(t *testing.T) {
598 d, sess, _, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})598 d, sess, _, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
599- tb := sess.activeTab()599+ tb := testAttachmentTab(sess)
600 target := tb.panes["pane-1"]600 target := tb.panes["pane-1"]
601 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"}601 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"}
602 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}602 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
@@ -614,7 +614,7 @@ func TestFocusDirAtReturnsDepartingSpanAndMapsNoNeighbor(t *testing.T) {
614 614
615 func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) {615 func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) {
616 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})616 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
617- tb := sess.activeTab()617+ tb := testAttachmentTab(sess)
618 newPTY := portsmocks.NewMockPTY(t)618 newPTY := portsmocks.NewMockPTY(t)
619 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"}619 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"}
620 tb.panes["pane-2"] = newPane("pane-2", newPTY, domain.Size{Cols: 20, Rows: 10})620 tb.panes["pane-2"] = newPane("pane-2", newPTY, domain.Size{Cols: 20, Rows: 10})
@@ -623,6 +623,8 @@ func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) {
623 tr, _ := newCapturingTransport(t)623 tr, _ := newCapturingTransport(t)
624 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 41, Rows: 12}}624 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 41, Rows: 12}}
625 ac.initOverlays()625 ac.initOverlays()
626+ ac.setSession(sess)
627+ require.True(t, sess.registerAttachment(ac))
626 ac.overlays.copyMode = &scopy.Mode{}628 ac.overlays.copyMode = &scopy.Mode{}
627 629
628 require.NoError(t, d.focusDir(sess, ac, layout.Right, nil))630 require.NoError(t, d.focusDir(sess, ac, layout.Right, nil))
@@ -633,9 +635,119 @@ func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) {
633 newPTY.AssertNotCalled(t, "Resize", mock.Anything)635 newPTY.AssertNotCalled(t, "Resize", mock.Anything)
634 }636 }
635 637
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+
636 func TestCloseFocusedPaneRemovesPaneReflowsAndIsIdempotent(t *testing.T) {748 func TestCloseFocusedPaneRemovesPaneReflowsAndIsIdempotent(t *testing.T) {
637 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})749 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
638- tb := sess.activeTab()750+ tb := testAttachmentTab(sess)
639 closingPTY := portsmocks.NewMockPTY(t)751 closingPTY := portsmocks.NewMockPTY(t)
640 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"}752 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"}
641 tb.panes["pane-2"] = newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10})753 tb.panes["pane-2"] = newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10})
@@ -654,7 +766,7 @@ func TestCloseFocusedPaneRemovesPaneReflowsAndIsIdempotent(t *testing.T) {
654 766
655 func TestReapPaneSharesClosePathAndIsIdempotent(t *testing.T) {767 func TestReapPaneSharesClosePathAndIsIdempotent(t *testing.T) {
656 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})768 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
657- tb := sess.activeTab()769+ tb := testAttachmentTab(sess)
658 reapedPTY := portsmocks.NewMockPTY(t)770 reapedPTY := portsmocks.NewMockPTY(t)
659 reaped := newPane("pane-2", reapedPTY, domain.Size{Cols: 20, Rows: 10})771 reaped := newPane("pane-2", reapedPTY, domain.Size{Cols: 20, Rows: 10})
660 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"}772 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) {
673 785
674 func TestClosePaneKeepsTabOpenWhenPendingSpawnLeafExists(t *testing.T) {786 func TestClosePaneKeepsTabOpenWhenPendingSpawnLeafExists(t *testing.T) {
675 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})787 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
676- tb := sess.activeTab()788+ tb := testAttachmentTab(sess)
677 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"}789 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"}
678 oldPTY.EXPECT().Close().Return(nil).Once()790 oldPTY.EXPECT().Close().Return(nil).Once()
679 791
@@ -705,7 +817,7 @@ func TestStackPaneCreatesStackAndToggleRestoresSplit(t *testing.T) {
705 factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once()817 factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once()
706 818
707 require.NoError(t, d.stackPane(sess, nil))819 require.NoError(t, d.stackPane(sess, nil))
708- tb := sess.activeTab()820+ tb := testAttachmentTab(sess)
709 require.Equal(t, layout.Stack, tb.tree.Root.Kind)821 require.Equal(t, layout.Stack, tb.tree.Root.Kind)
710 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)822 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
711 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded)823 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded)
@@ -728,7 +840,7 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) {
728 840
729 require.NoError(t, d.focusDir(sess, nil, layout.Up, nil))841 require.NoError(t, d.focusDir(sess, nil, layout.Up, nil))
730 842
731- tb := sess.activeTab()843+ tb := testAttachmentTab(sess)
732 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)844 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
733 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Root.Expanded)845 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Root.Expanded)
734 thirdPTY := portsmocks.NewMockPTY(t)846 thirdPTY := portsmocks.NewMockPTY(t)
@@ -773,7 +885,7 @@ func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) {
773 factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()885 factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()
774 886
775 require.NoError(t, d.splitPane(sess, nil, layout.Right))887 require.NoError(t, d.splitPane(sess, nil, layout.Right))
776- tb := sess.activeTab()888+ tb := testAttachmentTab(sess)
777 tb.mu.Lock()889 tb.mu.Lock()
778 tb.tree.Focus = "pane-1"890 tb.tree.Focus = "pane-1"
779 tb.mu.Unlock()891 tb.mu.Unlock()
@@ -785,6 +897,7 @@ func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) {
785 ac := &attachedClient{}897 ac := &attachedClient{}
786 ac.initOverlays()898 ac.initOverlays()
787 ac.setSession(sess)899 ac.setSession(sess)
900+ require.True(t, sess.registerAttachment(ac))
788 daemonKeyHandler{d: d, ac: ac}.Forward([]byte("Z"))901 daemonKeyHandler{d: d, ac: ac}.Forward([]byte("Z"))
789 require.Equal(t, []byte("Z"), <-writes)902 require.Equal(t, []byte("Z"), <-writes)
790 tb.focusedPane().screen.Write([]byte("survivor"))903 tb.focusedPane().screen.Write([]byte("survivor"))
@@ -844,7 +957,7 @@ func TestSplitPaneUsesExactAuthoritativeEnvironment(t *testing.T) {
844 ).Once()957 ).Once()
845 958
846 require.NoError(t, d.splitPane(sess, nil, layout.Right))959 require.NoError(t, d.splitPane(sess, nil, layout.Right))
847- tb := sess.activeTab()960+ tb := testAttachmentTab(sess)
848 tb.mu.Lock()961 tb.mu.Lock()
849 paneID := tb.panes["pane-2"].stableID962 paneID := tb.panes["pane-2"].stableID
850 tabID := tb.stableID963 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, _ [
64 return p, nil64 return p, nil
65 }65 }
66 66
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-
73 func TestPaneOpenErrorsReleaseReturnedPTYWithoutPublication(t *testing.T) {67 func TestPaneOpenErrorsReleaseReturnedPTYWithoutPublication(t *testing.T) {
74 type openCase struct {68 type openCase struct {
75 name string69 name string
@@ -211,125 +205,3 @@ func (f *paneErrorFactory) Open(ctx context.Context, _ string, _ []string, _ []s
211 }205 }
212 return f.pty, f.err206 return f.pty, f.err
213 }207 }
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) {
51 return 0, nil51 return 0, nil
52 }52 }
53 53
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-
68 func (p *pane) clearOwnerForTest() {54 func (p *pane) clearOwnerForTest() {
69 p.mu.Lock()55 p.mu.Lock()
70 p.clearOwnerLocked()56 p.clearOwnerLocked()
71 p.mu.Unlock()57 p.mu.Unlock()
72 }58 }
73 59
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-
125 func TestPTYReaderExitReapsExactlyCurrentOwnerAfterPublication(t *testing.T) {60 func TestPTYReaderExitReapsExactlyCurrentOwnerAfterPublication(t *testing.T) {
126 pty := newOwnerRoutingPTY()61 pty := newOwnerRoutingPTY()
127 d, source, _, _ := newManualSessionWithPTYs(t, pty)62 d, source, _, _ := newManualSessionWithPTYs(t, pty)
128- sourceTab := source.activeTab()63+ sourceTab := testAttachmentTab(source)
129 moved := sourceTab.focusedPane()64 moved := sourceTab.focusedPane()
130 65
131 destinationTab := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 23})66 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
2 2
3 import (3 import (
4 "context"4 "context"
5- "errors"
6 "io"5 "io"
7 "maps"6 "maps"
8 "sync"7 "sync"
@@ -185,7 +184,7 @@ func TestPaneRearrangeEdgeNoopHasExactSilentInvariants(t *testing.T) {
185 ac := &attachedClient{}184 ac := &attachedClient{}
186 ac.setSession(h.session)185 ac.setSession(h.session)
187 h.session.mu.Lock()186 h.session.mu.Lock()
188- h.session.client = ac187+ h.session.registerAttachmentLocked(ac)
189 h.session.mu.Unlock()188 h.session.mu.Unlock()
190 invalidations := make(chan renderInvalidation, 2)189 invalidations := make(chan renderInvalidation, 2)
191 rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})190 rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
@@ -265,113 +264,6 @@ func TestPaneRearrangeMapsUnsupportedAndTooSmallToUserNoticesAtomically(t *testi
265 }264 }
266 }265 }
267 266
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-
375 func TestPaneRearrangeNoChangeAdapterSideEffects(t *testing.T) {267 func TestPaneRearrangeNoChangeAdapterSideEffects(t *testing.T) {
376 tests := []struct {268 tests := []struct {
377 name string269 name string
@@ -406,7 +298,7 @@ func TestPaneRearrangeNoChangeAdapterSideEffects(t *testing.T) {
406 ac := &attachedClient{}298 ac := &attachedClient{}
407 ac.setSession(h.session)299 ac.setSession(h.session)
408 h.session.mu.Lock()300 h.session.mu.Lock()
409- h.session.client = ac301+ h.session.registerAttachmentLocked(ac)
410 h.session.mu.Unlock()302 h.session.mu.Unlock()
411 invalidations := make(chan renderInvalidation, 2)303 invalidations := make(chan renderInvalidation, 2)
412 rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})304 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 {
137 return fallback137 return fallback
138 }138 }
139 139
140-func (d *Daemon) refreshPaneTitle(sess *session, id layout.PaneID) string {140+func (d *Daemon) refreshPaneTitle(sess *session, id layout.PaneID, owningTab ...*tab) string {
141- return d.refreshPaneTitleCached(sess, id, false)141+ return d.refreshPaneTitleCached(sess, id, false, owningTab...)
142 }142 }
143 143
144-func (d *Daemon) refreshPaneTitleOnFocus(sess *session, id layout.PaneID) string {144+func (d *Daemon) refreshPaneTitleOnFocus(sess *session, id layout.PaneID, owningTab ...*tab) string {
145- return d.refreshPaneTitleCached(sess, id, true)145+ return d.refreshPaneTitleCached(sess, id, true, owningTab...)
146 }146 }
147 147
148-// refreshPaneTitleCached retains the ID lookup used by normal layout panes.148+// refreshPaneTitleCached resolves the pane in its owning tab. A caller with a
149-func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force bool) string {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 {
150 fallback := d.paneTitleFallback()152 fallback := d.paneTitleFallback()
151 if sess == nil {153 if sess == nil {
152 return fallback154 return fallback
153 }155 }
154- tb := sess.activeTab()156+ tabs := owningTab
155- if tb == nil {157+ if len(tabs) == 0 || tabs[0] == nil {
156- return fallback158+ sess.mu.Lock()
159+ tabs = append([]*tab(nil), sess.tabs...)
160+ sess.mu.Unlock()
157 }161 }
158- tb.mu.Lock()162+ title := fallback
159- p := tb.panes[id]163+ for _, tb := range tabs {
160- tb.mu.Unlock()164+ if tb == nil {
161- if p == nil {165+ continue
162- return fallback166+ }
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)
163 }175 }
164- p.setDisplayFallback(fallback)176+ return title
165- return d.refreshPaneDisplayTitle(sess, p, force)
166 }177 }
167 178
168 // refreshPaneDisplayTitle refreshes only the process-name portion of p's179 // 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) {
78 title := d.refreshPaneTitle(sess, "pane-1")78 title := d.refreshPaneTitle(sess, "pane-1")
79 require.Equal(t, "vim", title)79 require.Equal(t, "vim", title)
80 80
81- p := sess.activeTab().focusedPane()81+ p := testAttachmentTab(sess).focusedPane()
82 p.mu.Lock()82 p.mu.Lock()
83 p.screen.Write([]byte("\x1b]2;project/main.go\a"))83 p.screen.Write([]byte("\x1b]2;project/main.go\a"))
84 p.refreshTerminalTitleLocked()84 p.refreshTerminalTitleLocked()
@@ -89,6 +89,27 @@ func TestRefreshPaneTitleUsesForegroundProcessComm(t *testing.T) {
89 require.Equal(t, "vim", p.title.processName)89 require.Equal(t, "vim", p.title.processName)
90 }90 }
91 91
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+
92 func TestRefreshPaneTitleCachesByTTLAndRefreshesOnFocus(t *testing.T) {113 func TestRefreshPaneTitleCachesByTTLAndRefreshesOnFocus(t *testing.T) {
93 pty := portsmocks.NewMockPTY(t)114 pty := portsmocks.NewMockPTY(t)
94 pty.EXPECT().ForegroundPgid().Return(1234, nil).Twice()115 pty.EXPECT().ForegroundPgid().Return(1234, nil).Twice()
@@ -142,7 +163,7 @@ func TestRefreshFloatingPaneTitleUsesShellFallbackForEmptyCommand(t *testing.T)
142 163
143 func TestRefreshPaneTitleUpdatesNormalPaneShellFallback(t *testing.T) {164 func TestRefreshPaneTitleUpdatesNormalPaneShellFallback(t *testing.T) {
144 d, sess, _, _ := newManualSessionWithPTYs(t, nil)165 d, sess, _, _ := newManualSessionWithPTYs(t, nil)
145- p := sess.activeTab().focusedPane()166+ p := testAttachmentTab(sess).focusedPane()
146 167
147 d.shell = "/usr/bin/fish"168 d.shell = "/usr/bin/fish"
148 require.Equal(t, "fish", d.refreshPaneTitle(sess, "pane-1"))169 require.Equal(t, "fish", d.refreshPaneTitle(sess, "pane-1"))
@@ -170,7 +191,7 @@ func TestRefreshPaneTitleLookupFailureKeepsProcessNameEmpty(t *testing.T) {
170 d := newTestDaemon(t, nil, clk)191 d := newTestDaemon(t, nil, clk)
171 d.shell = "/usr/bin/fish"192 d.shell = "/usr/bin/fish"
172 d.procComm = func(int) (string, error) { return "", errors.New("unused") }193 d.procComm = func(int) (string, error) { return "", errors.New("unused") }
173- p := sess.activeTab().focusedPane()194+ p := testAttachmentTab(sess).focusedPane()
174 195
175 p.mu.Lock()196 p.mu.Lock()
176 p.screen.Write([]byte("\x1b]2;terminal\a"))197 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) {
1247 }1247 }
1248 }1248 }
1249 1249
1250-// TestCopyEnterAllocationBudget protects the parent baseline plus 10%. Copy1250+// TestCopyEnterAllocationBudget protects the attachment-aware baseline plus
1251-// rendering borrows sealed VT history rows; allocating a copy per viewport row1251+// 10%. Copy rendering borrows sealed VT history rows; allocating a copy per
1252-// is a production regression even though the capture itself remains immutable.1252+// viewport row is a production regression even though capture is immutable.
1253 func TestCopyEnterAllocationBudget(t *testing.T) {1253 func TestCopyEnterAllocationBudget(t *testing.T) {
1254 for _, tt := range []struct {1254 for _, tt := range []struct {
1255 name string1255 name string
1256 tabs, panes, max int1256 tabs, panes, max int
1257 }{1257 }{
1258- {name: "1tab-1pane", tabs: 1, panes: 1, max: 38},1258+ {name: "1tab-1pane", tabs: 1, panes: 1, max: 55},
1259- {name: "1tab-4panes", tabs: 1, panes: 4, max: 43},1259+ {name: "1tab-4panes", tabs: 1, panes: 4, max: 55},
1260- {name: "4tabs-1pane", tabs: 4, panes: 1, max: 42},1260+ {name: "4tabs-1pane", tabs: 4, panes: 1, max: 60},
1261- {name: "4tabs-4panes", tabs: 4, panes: 4, max: 48},1261+ {name: "4tabs-4panes", tabs: 4, panes: 4, max: 60},
1262- {name: "8tabs-1pane", tabs: 8, panes: 1, max: 50},1262+ {name: "8tabs-1pane", tabs: 8, panes: 1, max: 68},
1263 } {1263 } {
1264 t.Run(tt.name, func(t *testing.T) {1264 t.Run(tt.name, func(t *testing.T) {
1265 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: 10_000})1265 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) {
1278 }1278 }
1279 1279
1280 allocs := testing.AllocsPerRun(20, run)1280 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")
1282 })1282 })
1283 }1283 }
1284 }1284 }
@@ -1488,7 +1488,7 @@ func newPerformanceFixtureWithCleanup(t testing.TB, config performanceConfig, re
1488 compose: func() { fixture.renderCompositions++ },1488 compose: func() { fixture.renderCompositions++ },
1489 emit: func() { fixture.renderEmissions++ },1489 emit: func() { fixture.renderEmissions++ },
1490 }1490 }
1491- d.attachCoordinator(sess, nil, ac, true)1491+ d.attachCoordinator(sess, ac, true)
1492 1492
1493 // Prime the real renderer shadow before measurements. Subsequent paints use1493 // Prime the real renderer shadow before measurements. Subsequent paints use
1494 // actual production diffs rather than the initial full frame.1494 // actual production diffs rather than the initial full frame.
@@ -1565,7 +1565,7 @@ func performanceFullWidthRow(width, tabIndex, row int) string {
1565 1565
1566 func (f *performanceFixture) findActivePane() *pane {1566 func (f *performanceFixture) findActivePane() *pane {
1567 f.t.Helper()1567 f.t.Helper()
1568- tb := f.sess.activeTab()1568+ tb := testAttachmentTab(f.sess)
1569 require.NotNil(f.t, tb)1569 require.NotNil(f.t, tb)
1570 tb.mu.Lock()1570 tb.mu.Lock()
1571 defer tb.mu.Unlock()1571 defer tb.mu.Unlock()
M internal/usecase/daemon/picker.go +86 -71
@@ -52,7 +52,7 @@ func (d *Daemon) togglePickerSort() {
52 // publishes its model, including an empty one; only move entry can fail for a52 // publishes its model, including an empty one; only move entry can fail for a
53 // missing destination.53 // missing destination.
54 func (d *Daemon) enterPicker(sess attachmentSession, ac *attachedClient) {54 func (d *Daemon) enterPicker(sess attachmentSession, ac *attachedClient) {
55- model := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})55+ model := d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
56 d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})56 d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
57 }57 }
58 58
@@ -83,7 +83,7 @@ func (d *Daemon) publishPicker(sess attachmentSession, ac *attachedClient, model
83 83
84 // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally84 // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally
85 // knows nothing about picker intent; picker.New owns all destination policy.85 // knows nothing about picker intent; picker.New owns all destination policy.
86-func (d *Daemon) pickerViews(cur attachmentSession) ([]picker.SessionView, picker.SourceFilter) {86+func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picker.SessionView, picker.SourceFilter) {
87 d.mu.Lock()87 d.mu.Lock()
88 sessions := make([]attachmentSession, 0, len(d.sessions))88 sessions := make([]attachmentSession, 0, len(d.sessions))
89 proxies := make(map[domain.SessionID]*proxySession)89 proxies := make(map[domain.SessionID]*proxySession)
@@ -119,13 +119,23 @@ func (d *Daemon) pickerViews(cur attachmentSession) ([]picker.SessionView, picke
119 live := make([]liveSnapshot, 0, len(sessions))119 live := make([]liveSnapshot, 0, len(sessions))
120 var current picker.SourceFilter120 var current picker.SourceFilter
121 for _, entry := range sessions {121 for _, entry := range sessions {
122+ if entry == cur && ac != nil {
123+ if local, ok := localSession(entry); ok {
124+ local.repairAttachmentView(ac)
125+ }
126+ }
122 snap := entry.snapshotView(opts)127 snap := entry.snapshotView(opts)
123 if entry == cur {128 if entry == cur {
124 if proxy, ok := entry.(*proxySession); ok {129 if proxy, ok := entry.(*proxySession); ok {
125 key := proxy.key130 key := proxy.key
126 current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, RemoteKey: &key}131 current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, RemoteKey: &key}
127- } else if snap.active >= 0 && snap.active < len(snap.tabs) {132+ } else if _, ok := localSession(entry); ok && ac != nil {
128- current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.active].id}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}
129 }139 }
130 }140 }
131 live = append(live, liveSnapshot{entry: entry, view: snap})141 live = append(live, liveSnapshot{entry: entry, view: snap})
@@ -201,8 +211,8 @@ func (d *Daemon) pickerViews(cur attachmentSession) ([]picker.SessionView, picke
201 return views, current211 return views, current
202 }212 }
203 213
204-func (d *Daemon) newPickerModel(cur attachmentSession, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model {214+func (d *Daemon) newPickerModel(cur attachmentSession, ac *attachedClient, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model {
205- views, attachedCurrent := d.pickerViews(cur)215+ views, attachedCurrent := d.pickerViews(cur, ac)
206 if intent == pickerNavigate && current == (picker.SourceFilter{}) {216 if intent == pickerNavigate && current == (picker.SourceFilter{}) {
207 current = attachedCurrent217 current = attachedCurrent
208 }218 }
@@ -248,7 +258,7 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
248 }258 }
249 }259 }
250 260
251-func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {261+func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
252 sess := ac.currentAttachmentSession()262 sess := ac.currentAttachmentSession()
253 if sess == nil {263 if sess == nil {
254 return264 return
@@ -293,7 +303,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
293 return303 return
294 }304 }
295 if result.action == 'x' {305 if result.action == 'x' {
296- var effect *roleEffectTicket306+ var effect *attachmentEffectTicket
297 if len(effects) != 0 {307 if len(effects) != 0 {
298 effect = effects[0]308 effect = effects[0]
299 }309 }
@@ -301,7 +311,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
301 var err error311 var err error
302 if effect != nil {312 if effect != nil {
303 effect.bindActionEnd(d, "picker-delete")313 effect.bindActionEnd(d, "picker-delete")
304- err = d.killPickerTargetForRole(target, effect.roleToken())314+ err = d.killPickerTargetForAttachment(target, effect.connectionToken())
305 } else {315 } else {
306 err = d.killPickerTarget(target)316 err = d.killPickerTarget(target)
307 }317 }
@@ -310,7 +320,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
310 }320 }
311 }321 }
312 if effect != nil && effect.ended.Load() {322 if effect != nil && effect.ended.Load() {
313- fresh, admitted := ac.beginRoleEffect(effect.token)323+ fresh, admitted := ac.beginAttachmentEffect(effect.token)
314 if !admitted {324 if !admitted {
315 return325 return
316 }326 }
@@ -349,7 +359,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
349 }359 }
350 var err error360 var err error
351 if len(effects) != 0 && effects[0] != nil {361 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")
353 } else if local, ok := localSession(sess); ok {363 } else if local, ok := localSession(sess); ok {
354 d.closePicker(ac)364 d.closePicker(ac)
355 err = d.switchToTarget(local, ac, target)365 err = d.switchToTarget(local, ac, target)
@@ -413,7 +423,7 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
413 if targetSess == ac.currentAttachmentSession() {423 if targetSess == ac.currentAttachmentSession() {
414 return424 return
415 }425 }
416- rc := d.attachCoordinator(targetSess, nil, nil, false)426+ rc := d.attachCoordinator(targetSess, nil, false)
417 rc.subscribePreviewFor(ac, generation, func(renderWake) {427 rc.subscribePreviewFor(ac, generation, func(renderWake) {
418 if pickerPreviewCurrent(ac, targetSess, next, generation) {428 if pickerPreviewCurrent(ac, targetSess, next, generation) {
419 if owner := ac.currentAttachmentSession(); owner != nil {429 if owner := ac.currentAttachmentSession(); owner != nil {
@@ -489,9 +499,7 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
489 continue499 continue
490 }500 }
491 sess.mu.Lock()501 sess.mu.Lock()
492- if sess.client != nil {502+ clients = append(clients, sess.snapshotAttachmentsLocked()...)
493- clients = append(clients, sess.client)
494- }
495 sess.mu.Unlock()503 sess.mu.Unlock()
496 }504 }
497 d.mu.Unlock()505 d.mu.Unlock()
@@ -519,6 +527,9 @@ func (d *Daemon) closePicker(ac *attachedClient) {
519 // generation guards terminal parked cleanup even when a refresh replaced the527 // generation guards terminal parked cleanup even when a refresh replaced the
520 // model within that generation.528 // model within that generation.
521 func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, generation uint64) bool {529 func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, generation uint64) bool {
530+ if ac == nil || ac.overlays == nil {
531+ return false
532+ }
522 ac.overlays.pickerMu.Lock()533 ac.overlays.pickerMu.Lock()
523 if model != nil && (ac.overlays.picker != model || ac.overlays.pickerGeneration != generation) || model == nil && generation != 0 && ac.overlays.pickerGeneration != generation {534 if model != nil && (ac.overlays.picker != model || ac.overlays.pickerGeneration != generation) || model == nil && generation != 0 && ac.overlays.pickerGeneration != generation {
524 ac.overlays.pickerMu.Unlock()535 ac.overlays.pickerMu.Unlock()
@@ -573,12 +584,12 @@ type sessionHandoffGuard struct {
573 closePicker bool584 closePicker bool
574 }585 }
575 586
576-// switchActiveTargetForRole hands a frame-bound navigation request to the587+// switchActiveTargetForAttachment hands a frame-bound navigation request to the
577 // centralized transition. The transition releases admission at its freeze seam588 // centralized transition. The transition releases admission at its freeze seam
578 // and revalidates the exact initiating token before changing target focus or589 // and revalidates the exact initiating token before changing target focus or
579 // attachment ownership.590 // attachment ownership.
580-func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target picker.Target) error {591+func (d *Daemon) switchActiveTargetForAttachment(token attachmentConnectionToken, target picker.Target) error {
581- return d.switchActiveTargetForRoleGuarded(token, target, sessionHandoffGuard{}, "jump-attention")592+ return d.switchActiveTargetForAttachmentGuarded(token, target, sessionHandoffGuard{}, "jump-attention")
582 }593 }
583 594
584 func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence {595 func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence {
@@ -603,15 +614,15 @@ func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence
603 return fence614 return fence
604 }615 }
605 616
606-func (d *Daemon) switchActiveTargetForRoleGuarded(token attachmentRoleToken, target picker.Target, guard sessionHandoffGuard, action string) error {617+func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
607- if token.sess == nil || token.ac == nil || token.role != attachmentActive {618+ if token.sess == nil || token.ac == nil {
608 return nil619 return nil
609 }620 }
610 d.mu.Lock()621 d.mu.Lock()
611 targetSess, ok := localSession(d.sessions[target.Session])622 targetSess, ok := localSession(d.sessions[target.Session])
612 d.mu.Unlock()623 d.mu.Unlock()
613 if !ok {624 if !ok {
614- if !token.activeCurrent() {625+ if !token.attachmentCurrent() {
615 return nil626 return nil
616 }627 }
617 d.invalidateRender(token.sess, token.ac, true, "picker.go")628 d.invalidateRender(token.sess, token.ac, true, "picker.go")
@@ -623,21 +634,21 @@ func (d *Daemon) switchActiveTargetForRoleGuarded(token attachmentRoleToken, tar
623 634
624 transition, err := d.transitionAttachment(attachmentTransitionRequest{635 transition, err := d.transitionAttachment(attachmentTransitionRequest{
625 source: token.sess, target: targetSess, next: token.ac,636 source: token.sess, target: targetSess, next: token.ac,
626- expectedRole: attachmentActive, targetRole: attachmentActive,637+
627 expectedTransport: token.transport, sourceToken: &token, action: action,638 expectedTransport: token.transport, sourceToken: &token, action: action,
628 expectedTargetLifecycle: pickerTargetLifecycleFence(target),639 expectedTargetLifecycle: pickerTargetLifecycleFence(target),
629 activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,640 activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,
630 })641 })
631 if err != nil {642 if err != nil {
632- // Losing the exact source role is a benign stale action, not a notice for643+ // Losing the exact source attachment is a benign stale action, not a notice for
633 // whichever attachment replaced the initiator.644 // whichever attachment replaced the initiator.
634- if !token.activeCurrent() {645+ if !token.attachmentCurrent() {
635 return nil646 return nil
636 }647 }
637 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)648 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
638 }649 }
639 if guard.closePicker {650 if guard.closePicker {
640- fresh, admitted := token.ac.beginRoleEffect(transition.published)651+ fresh, admitted := token.ac.beginAttachmentEffect(transition.published)
641 if admitted {652 if admitted {
642 d.closePicker(token.ac)653 d.closePicker(token.ac)
643 fresh.End()654 fresh.End()
@@ -657,34 +668,34 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
657 return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})668 return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})
658 }669 }
659 670
660-// switchToTargetForRole is the sole client-originated navigation entry. The671+// switchToTargetForAttachment is the sole client-originated navigation entry. The
661 // intent captures the exact initiating capability before admission is released;672 // intent captures the exact initiating capability before admission is released;
662 // every active, stopped, and same-session target then uses transitionAttachment673 // every active, stopped, and same-session target then uses transitionAttachment
663 // for frozen, atomic source-token preflight.674 // for frozen, atomic source-token preflight.
664-func (d *Daemon) switchToTargetForRole(token attachmentRoleToken, target picker.Target, guard sessionHandoffGuard, action string) error {675+func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
665- if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.effect == nil {676+ if token.sess == nil || token.ac == nil || token.effect == nil {
666 return nil677 return nil
667 }678 }
668 token.effect.bindActionEnd(d, action)679 token.effect.bindActionEnd(d, action)
669 token.effect.End()680 token.effect.End()
670 if target.RemoteKey != nil {681 if target.RemoteKey != nil {
671- return d.switchToRemoteTargetForRole(token, target, *target.RemoteKey, guard, action)682+ return d.switchToRemoteTargetForAttachment(token, target, *target.RemoteKey, guard, action)
672 }683 }
673 if token.sess.isProxy() && !target.Stopped {684 if token.sess.isProxy() && !target.Stopped {
674- return d.switchActiveTargetForRoleGuarded(token, target, guard, action)685+ return d.switchActiveTargetForAttachmentGuarded(token, target, guard, action)
675 }686 }
676 sess, ok := localSession(token.sess)687 sess, ok := localSession(token.sess)
677 if !ok {688 if !ok {
678 return errAttachmentTransition689 return errAttachmentTransition
679 }690 }
680- return d.switchToTargetGuardedForRole(sess, token.ac, target, guard, &token, action)691+ return d.switchToTargetGuardedForAttachment(sess, token.ac, target, guard, &token, action)
681 }692 }
682 693
683-// switchToRemoteTargetForRole performs dialing after the initiating effect has694+// switchToRemoteTargetForAttachment performs dialing after the initiating effect has
684-// ended and without role or architecture locks. transitionAttachment then695+// ended and without attachment-effect or architecture locks. transitionAttachment then
685-// freezes and revalidates the exact source role and exact structured-key proxy696+// freezes and revalidates the exact source attachment and exact structured-key proxy
686 // before publishing ownership.697 // 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 {
688 if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" {699 if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" {
689 return errAttachmentTransition700 return errAttachmentTransition
690 }701 }
@@ -702,7 +713,7 @@ func (d *Daemon) switchToRemoteTargetForRole(token attachmentRoleToken, target p
702 ownsCandidate := !alreadyPublished && !constructionInFlight713 ownsCandidate := !alreadyPublished && !constructionInFlight
703 proxy, err := d.openProxySession(ctx, key, token.ac.size)714 proxy, err := d.openProxySession(ctx, key, token.ac.size)
704 if err != nil {715 if err != nil {
705- if !token.activeCurrent() {716+ if !token.attachmentCurrent() {
706 return errAttachmentTransition717 return errAttachmentTransition
707 }718 }
708 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't connect to that remote session", err)719 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
716 727
717 transition, err := d.transitionAttachment(attachmentTransitionRequest{728 transition, err := d.transitionAttachment(attachmentTransitionRequest{
718 source: token.sess, target: proxy, next: token.ac,729 source: token.sess, target: proxy, next: token.ac,
719- expectedRole: attachmentActive, targetRole: attachmentActive,730+
720 expectedTransport: token.transport, sourceToken: &token, action: action,731 expectedTransport: token.transport, sourceToken: &token, action: action,
721- preserveRole: proxy == token.sess, ready: true,732+ preserveAttachment: proxy == token.sess, ready: true,
722 })733 })
723 if err != nil {734 if err != nil {
724 if ownsCandidate {735 if ownsCandidate {
@@ -727,7 +738,7 @@ func (d *Daemon) switchToRemoteTargetForRole(token attachmentRoleToken, target p
727 return errAttachmentTransition738 return errAttachmentTransition
728 }739 }
729 if guard.closePicker {740 if guard.closePicker {
730- fresh, admitted := token.ac.beginRoleEffect(transition.published)741+ fresh, admitted := token.ac.beginAttachmentEffect(transition.published)
731 if admitted {742 if admitted {
732 d.closePicker(token.ac)743 d.closePicker(token.ac)
733 fresh.End()744 fresh.End()
@@ -759,7 +770,7 @@ func (d *Daemon) discardUnattachedProxyGeneration(proxy *proxySession, generatio
759 return false770 return false
760 }771 }
761 proxy.sessionCore.mu.Lock()772 proxy.sessionCore.mu.Lock()
762- if proxy.client != nil {773+ if len(proxy.attachments) != 0 {
763 proxy.sessionCore.mu.Unlock()774 proxy.sessionCore.mu.Unlock()
764 d.mu.Unlock()775 d.mu.Unlock()
765 return false776 return false
@@ -810,17 +821,17 @@ func (d *Daemon) discardUnattachedProxyGeneration(proxy *proxySession, generatio
810 821
811 // switchToTargetGuarded is retained for daemon-internal and headless callers.822 // switchToTargetGuarded is retained for daemon-internal and headless callers.
812 func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error {823 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, "")
814 }825 }
815 826
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 {
817 if target.Name != "" {828 if target.Name != "" {
818 ctx := d.serveCtx829 ctx := d.serveCtx
819 if ctx == nil {830 if ctx == nil {
820 ctx = context.Background()831 ctx = context.Background()
821 }832 }
822 if err := d.waitForTargetRestore(ctx, target.Name); err != nil {833 if err := d.waitForTargetRestore(ctx, target.Name); err != nil {
823- if sourceToken != nil && !sourceToken.activeCurrent() {834+ if sourceToken != nil && !sourceToken.attachmentCurrent() {
824 return errAttachmentTransition835 return errAttachmentTransition
825 }836 }
826 d.invalidateRender(from, ac, true, "picker.go")837 d.invalidateRender(from, ac, true, "picker.go")
@@ -854,7 +865,7 @@ func (d *Daemon) switchToTargetGuardedForRole(from *session, ac *attachedClient,
854 d.mu.Unlock()865 d.mu.Unlock()
855 866
856 if !switched {867 if !switched {
857- if sourceToken != nil && !sourceToken.activeCurrent() {868+ if sourceToken != nil && !sourceToken.attachmentCurrent() {
858 return errAttachmentTransition869 return errAttachmentTransition
859 }870 }
860 if guard.expectedSource != nil {871 if guard.expectedSource != nil {
@@ -865,7 +876,7 @@ func (d *Daemon) switchToTargetGuardedForRole(from *session, ac *attachedClient,
865 }876 }
866 if guard.closePicker {877 if guard.closePicker {
867 if sourceToken != nil {878 if sourceToken != nil {
868- fresh, admitted := ac.beginRoleEffect(transition.published)879+ fresh, admitted := ac.beginAttachmentEffect(transition.published)
869 if admitted {880 if admitted {
870 d.closePicker(ac)881 d.closePicker(ac)
871 fresh.End()882 fresh.End()
@@ -877,14 +888,14 @@ func (d *Daemon) switchToTargetGuardedForRole(from *session, ac *attachedClient,
877 d.deferAttachmentTransitionCleanups(transition)888 d.deferAttachmentTransitionCleanups(transition)
878 if targetSess == from {889 if targetSess == from {
879 if sourceToken != nil {890 if sourceToken != nil {
880- fresh, admitted := ac.beginRoleEffect(transition.published)891+ fresh, admitted := ac.beginAttachmentEffect(transition.published)
881 if admitted {892 if admitted {
882- d.activateTab(from, from.activeTab())893+ d.activateTabAfterResizeForLease(from, from.tabForAttachment(ac), false, ac, transition.published.lease)
883 d.invalidateRender(from, ac, true, "picker.go")894 d.invalidateRender(from, ac, true, "picker.go")
884 fresh.End()895 fresh.End()
885 }896 }
886 } else {897 } else {
887- d.activateTab(from, from.activeTab())898+ d.activateTabAfterResizeForLease(from, from.tabForAttachment(ac), false, ac, nil)
888 d.invalidateRender(from, ac, true, "picker.go")899 d.invalidateRender(from, ac, true, "picker.go")
889 }900 }
890 } else {901 } else {
@@ -915,7 +926,7 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi
915 926
916 // switchToActiveTargetLocked commits an active target handoff through the927 // switchToActiveTargetLocked commits an active target handoff through the
917 // centralized attachment transition. Caller holds d.mu.928 // 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) {
919 if d.sessions[target.Session] != targetSess {930 if d.sessions[target.Session] != targetSess {
920 return nil, attachmentTransitionResult{}, false931 return nil, attachmentTransitionResult{}, false
921 }932 }
@@ -923,30 +934,35 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
923 if sourceToken == nil {934 if sourceToken == nil {
924 targetSess.mu.Lock()935 targetSess.mu.Lock()
925 defer targetSess.mu.Unlock()936 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) {
927 return nil, attachmentTransitionResult{}, false942 return nil, attachmentTransitionResult{}, false
928 }943 }
929- targetSess.active = target.TabIndex
930 return targetSess, attachmentTransitionResult{}, true944 return targetSess, attachmentTransitionResult{}, true
931 }945 }
932 d.mu.Unlock()946 d.mu.Unlock()
933 transition, err := d.transitionAttachment(attachmentTransitionRequest{947 transition, err := d.transitionAttachment(attachmentTransitionRequest{
934 source: from, target: targetSess, next: ac,948 source: from, target: targetSess, next: ac,
935- expectedRole: attachmentActive, targetRole: attachmentActive,949+
936 expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,950 expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,
937- activateTargetTab: true, targetTabIndex: target.TabIndex, preserveRole: true,951+ activateTargetTab: true, targetTabIndex: target.TabIndex, preserveAttachment: true,
938 })952 })
939 d.mu.Lock()953 d.mu.Lock()
940 return targetSess, transition, err == nil954 return targetSess, transition, err == nil
941 }955 }
942 956
943 unlock := lockAttachmentSessions(from, targetSess)957 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) {
945 unlock()960 unlock()
946 return nil, attachmentTransitionResult{}, false961 return nil, attachmentTransitionResult{}, false
947 }962 }
948 if guard.expectedSource != nil {963 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 {
950 unlock()966 unlock()
951 return nil, attachmentTransitionResult{}, false967 return nil, attachmentTransitionResult{}, false
952 }968 }
@@ -968,11 +984,10 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
968 expectedTransport = sourceToken.transport984 expectedTransport = sourceToken.transport
969 }985 }
970 transition, err := d.transitionAttachment(attachmentTransitionRequest{986 transition, err := d.transitionAttachment(attachmentTransitionRequest{
971- source: from,987+ source: from,
972- target: targetSess,988+ target: targetSess,
973- next: ac,989+ next: ac,
974- expectedRole: attachmentActive,990+
975- targetRole: attachmentActive,
976 expectedTransport: expectedTransport,991 expectedTransport: expectedTransport,
977 sourceToken: sourceToken,992 sourceToken: sourceToken,
978 action: action,993 action: action,
@@ -994,7 +1009,7 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
994 // resumeStoppedAndSwitchLocked creates the stopped representation and commits1009 // resumeStoppedAndSwitchLocked creates the stopped representation and commits
995 // the handoff while d.mu is held. Creation failure leaves the source client1010 // the handoff while d.mu is held. Creation failure leaves the source client
996 // and stopped record untouched.1011 // 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) {
998 if stopped.record.Name != "" && stopped.state == ports.SessionBroken {1013 if stopped.record.Name != "" && stopped.state == ports.SessionBroken {
999 return nil, attachmentTransitionResult{}, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name}1014 return nil, attachmentTransitionResult{}, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name}
1000 }1015 }
@@ -1003,7 +1018,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
1003 var targetSess *session1018 var targetSess *session
1004 d.mu.Unlock()1019 d.mu.Unlock()
1005 transition, err := d.transitionAttachment(attachmentTransitionRequest{1020 transition, err := d.transitionAttachment(attachmentTransitionRequest{
1006- source: from, next: ac, expectedRole: attachmentActive, targetRole: attachmentActive,1021+ source: from, next: ac,
1007 expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,1022 expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,
1008 expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true,1023 expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true,
1009 createTargetLocked: func() (*session, error) {1024 createTargetLocked: func() (*session, error) {
@@ -1034,7 +1049,8 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
1034 }1049 }
1035 1050
1036 from.mu.Lock()1051 from.mu.Lock()
1037- if from.client != ac {1052+ _, attached := from.attachments[ac]
1053+ if !attached {
1038 from.mu.Unlock()1054 from.mu.Unlock()
1039 return nil, attachmentTransitionResult{}, false, nil1055 return nil, attachmentTransitionResult{}, false, nil
1040 }1056 }
@@ -1051,7 +1067,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
1051 d.mu.Unlock()1067 d.mu.Unlock()
1052 transition, err := d.transitionAttachment(attachmentTransitionRequest{1068 transition, err := d.transitionAttachment(attachmentTransitionRequest{
1053 source: from, target: targetSess, next: ac,1069 source: from, target: targetSess, next: ac,
1054- expectedRole: attachmentActive, targetRole: attachmentActive,1070+
1055 expectedTransport: ac.transportSnapshot(), ready: true,1071 expectedTransport: ac.transportSnapshot(), ready: true,
1056 })1072 })
1057 d.mu.Lock()1073 d.mu.Lock()
@@ -1069,15 +1085,14 @@ func targetMatchesLifecycle(target picker.Target, name string, createdAt int64)
1069 1085
1070 // stealClientForTarget is retained for direct-ID callers and tests. Named1086 // stealClientForTarget is retained for direct-ID callers and tests. Named
1071 // targets must use switchToTarget so resolution and commit share d.mu.1087 // 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) {
1073 d.mu.Lock()1089 d.mu.Lock()
1074 _, transition, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{}, nil, "")1090 _, transition, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{}, nil, "")
1075 d.mu.Unlock()1091 d.mu.Unlock()
1076 if !switched {1092 if !switched {
1077- return nil1093+ return
1078 }1094 }
1079 d.deferAttachmentTransitionCleanups(transition)1095 d.deferAttachmentTransitionCleanups(transition)
1080- return transition.displaced.ac
1081 }1096 }
1082 1097
1083 // resumeStoppedAndSwitch is retained for direct callers and tests. It resolves1098 // resumeStoppedAndSwitch is retained for direct callers and tests. It resolves
@@ -1102,7 +1117,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
1102 return true1117 return true
1103 }1118 }
1104 1119
1105-func (d *Daemon) killPickerTargetForRole(target picker.Target, token attachmentRoleToken) error {1120+func (d *Daemon) killPickerTargetForAttachment(target picker.Target, token attachmentConnectionToken) error {
1106 if token.ac == nil {1121 if token.ac == nil {
1107 return d.killPickerTarget(target)1122 return d.killPickerTarget(target)
1108 }1123 }
@@ -1113,9 +1128,9 @@ func (d *Daemon) killPickerTargetForRole(target picker.Target, token attachmentR
1113 if target.Stopped {1128 if target.Stopped {
1114 token.effect.bindActionEnd(d, "picker-delete")1129 token.effect.bindActionEnd(d, "picker-delete")
1115 token.effect.End()1130 token.effect.End()
1116- frozen := freezeRoleEffectGates(token.ac)1131+ frozen := freezeAttachmentEffectGates(token.ac)
1117 defer frozen.unfreeze()1132 defer frozen.unfreeze()
1118- if !d.sourceRoleTokenCurrentFrozen(token) {1133+ if !d.sourceAttachmentTokenCurrentFrozen(token) {
1119 return nil1134 return nil
1120 }1135 }
1121 return d.killPickerTarget(target)1136 return d.killPickerTarget(target)
@@ -1126,7 +1141,7 @@ func (d *Daemon) killPickerTargetForRole(target picker.Target, token attachmentR
1126 if !ok {1141 if !ok {
1127 return nil1142 return nil
1128 }1143 }
1129- return d.killSessionForRole(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete")1144+ return d.killSessionForAttachment(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete")
1130 }1145 }
1131 1146
1132 func (d *Daemon) killPickerTarget(target picker.Target) error {1147 func (d *Daemon) killPickerTarget(target picker.Target) error {
M internal/usecase/daemon/picker_test.go +38 -191
@@ -14,7 +14,6 @@ import (
14 "github.com/bnema/vev/internal/persist"14 "github.com/bnema/vev/internal/persist"
15 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
16 portsmocks "github.com/bnema/vev/internal/ports/mocks"16 portsmocks "github.com/bnema/vev/internal/ports/mocks"
17- "github.com/bnema/vev/internal/usecase/keys"
18 "github.com/bnema/vev/internal/usecase/layout"17 "github.com/bnema/vev/internal/usecase/layout"
19 "github.com/bnema/vev/internal/usecase/picker"18 "github.com/bnema/vev/internal/usecase/picker"
20 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"19 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
@@ -79,7 +78,7 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
79 d.sessions[current.id] = current78 d.sessions[current.id] = current
80 d.sessions[ringing.id] = ringing79 d.sessions[ringing.id] = ringing
81 80
82- views, currentSelection := d.pickerViews(current)81+ views, currentSelection := d.pickerViews(current, nil)
83 82
84 require.Equal(t, picker.SourceFilter{Session: current.id}, currentSelection)83 require.Equal(t, picker.SourceFilter{Session: current.id}, currentSelection)
85 require.Len(t, views, 2)84 require.Len(t, views, 2)
@@ -104,7 +103,7 @@ func TestPickerViewsIncludesEphemeralSessions(t *testing.T) {
104 d.sessions[current.id] = current103 d.sessions[current.id] = current
105 d.sessions[ephemeral.id] = ephemeral104 d.sessions[ephemeral.id] = ephemeral
106 105
107- views, _ := d.pickerViews(current)106+ views, _ := d.pickerViews(current, nil)
108 107
109 require.Len(t, views, 2)108 require.Len(t, views, 2)
110 var ephemeralView picker.SessionView109 var ephemeralView picker.SessionView
@@ -132,7 +131,7 @@ func TestPickerViewsOrdersByMRUWithEphemeralInterleaved(t *testing.T) {
132 d.stopped["halted-old"] = stoppedSession{name: "halted-old", createdAt: 10, lastUsedSeq: 1}131 d.stopped["halted-old"] = stoppedSession{name: "halted-old", createdAt: 10, lastUsedSeq: 1}
133 d.stopped["halted-new"] = stoppedSession{name: "halted-new", createdAt: 11, lastUsedSeq: 2}132 d.stopped["halted-new"] = stoppedSession{name: "halted-new", createdAt: 11, lastUsedSeq: 2}
134 133
135- views, _ := d.pickerViews(recent)134+ views, _ := d.pickerViews(recent, nil)
136 135
137 names := make([]string, 0, len(views))136 names := make([]string, 0, len(views))
138 for _, v := range views {137 for _, v := range views {
@@ -152,7 +151,7 @@ func TestPickerViewsMRUTieBreaksAlphabetically(t *testing.T) {
152 d.sessions[b.id] = b151 d.sessions[b.id] = b
153 d.sessions[a.id] = a152 d.sessions[a.id] = a
154 153
155- views, _ := d.pickerViews(a)154+ views, _ := d.pickerViews(a, nil)
156 155
157 require.Equal(t, "alpha", views[0].Name)156 require.Equal(t, "alpha", views[0].Name)
158 require.Equal(t, "bravo", views[1].Name)157 require.Equal(t, "bravo", views[1].Name)
@@ -172,7 +171,7 @@ func TestPickerViewsGroupedModePutsNamedBeforeEphemeral(t *testing.T) {
172 d.sessions[named2.id] = named2171 d.sessions[named2.id] = named2
173 d.stopped["halted"] = stoppedSession{name: "halted", createdAt: 9, lastUsedSeq: 9}172 d.stopped["halted"] = stoppedSession{name: "halted", createdAt: 9, lastUsedSeq: 9}
174 173
175- views, _ := d.pickerViews(named)174+ views, _ := d.pickerViews(named, nil)
176 175
177 names := make([]string, 0, len(views))176 names := make([]string, 0, len(views))
178 for _, v := range views {177 for _, v := range views {
@@ -200,7 +199,7 @@ func TestPickerSortToggleFlipsModeAndKeepsSelection(t *testing.T) {
200 d, sess, ac, sends, releases := newManualTabSession(t, 2)199 d, sess, ac, sends, releases := newManualTabSession(t, 2)
201 defer releaseAll(releases)200 defer releaseAll(releases)
202 sess.mu.Lock()201 sess.mu.Lock()
203- sess.client = ac202+ sess.registerAttachmentLocked(ac)
204 secondTabID := domain.TabStableID(sess.tabs[1].stableID)203 secondTabID := domain.TabStableID(sess.tabs[1].stableID)
205 sess.mu.Unlock()204 sess.mu.Unlock()
206 sess.mruAt.Store(1)205 sess.mruAt.Store(1)
@@ -283,7 +282,7 @@ func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) {
283 d.sessions[active.id] = active282 d.sessions[active.id] = active
284 d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 24}283 d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 24}
285 284
286- views, _ := d.pickerViews(active)285+ views, _ := d.pickerViews(active, nil)
287 286
288 require.Len(t, views, 2)287 require.Len(t, views, 2)
289 require.NotNil(t, views[0].ExpectedCreatedAt)288 require.NotNil(t, views[0].ExpectedCreatedAt)
@@ -294,7 +293,7 @@ func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) {
294 293
295 func TestPickerCanonicalViewsLeaveMoveFilteringToModel(t *testing.T) {294 func TestPickerCanonicalViewsLeaveMoveFilteringToModel(t *testing.T) {
296 d := newTestDaemon(t, nil, stubClock{})295 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}},
298 tabs: []*tab{297 tabs: []*tab{
299 {stableID: "source-tab", panes: map[layout.PaneID]*pane{}},298 {stableID: "source-tab", panes: map[layout.PaneID]*pane{}},
300 {stableID: "sibling-tab", panes: map[layout.PaneID]*pane{}},299 {stableID: "sibling-tab", panes: map[layout.PaneID]*pane{}},
@@ -310,7 +309,7 @@ func TestPickerCanonicalViewsLeaveMoveFilteringToModel(t *testing.T) {
310 PaneID: "source-pane",309 PaneID: "source-pane",
311 }310 }
312 311
313- views, current := d.pickerViews(source)312+ views, current := d.pickerViews(source, nil)
314 require.Len(t, views, 3, "canonical capture includes active and stopped lifecycles")313 require.Len(t, views, 3, "canonical capture includes active and stopped lifecycles")
315 require.Equal(t, picker.SourceFilter{Session: source.id, Incarnation: source.incarnation, TabID: "source-tab"}, current)314 require.Equal(t, picker.SourceFilter{Session: source.id, Incarnation: source.incarnation, TabID: "source-tab"}, current)
316 var sourceView picker.SessionView315 var sourceView picker.SessionView
@@ -322,14 +321,14 @@ func TestPickerCanonicalViewsLeaveMoveFilteringToModel(t *testing.T) {
322 require.Equal(t, []picker.TabEntry{{TabID: "source-tab", Name: "1"}, {TabID: "sibling-tab", Name: "2"}}, sourceView.Tabs,321 require.Equal(t, []picker.TabEntry{{TabID: "source-tab", Name: "1"}, {TabID: "sibling-tab", Name: "2"}}, sourceView.Tabs,
323 "daemon capture does not remove the move source")322 "daemon capture does not remove the move source")
324 323
325- paneModel := d.newPickerModel(source, pickerMovePane, sourceLocator, picker.SourceFilter{324+ paneModel := d.newPickerModel(source, nil, pickerMovePane, sourceLocator, picker.SourceFilter{
326 Session: source.id, Incarnation: source.incarnation, TabID: "sibling-tab",325 Session: source.id, Incarnation: source.incarnation, TabID: "sibling-tab",
327 })326 })
328 paneTarget, ok := paneModel.Selected()327 paneTarget, ok := paneModel.Selected()
329 require.True(t, ok)328 require.True(t, ok)
330 require.Equal(t, domain.TabStableID("sibling-tab"), paneTarget.TabID)329 require.Equal(t, domain.TabStableID("sibling-tab"), paneTarget.TabID)
331 330
332- tabModel := d.newPickerModel(source, pickerMoveTab, sourceLocator, picker.SourceFilter{})331+ tabModel := d.newPickerModel(source, nil, pickerMoveTab, sourceLocator, picker.SourceFilter{})
333 tabTarget, ok := tabModel.Selected()332 tabTarget, ok := tabModel.Selected()
334 require.True(t, ok)333 require.True(t, ok)
335 require.Equal(t, destination.id, tabTarget.Session)334 require.Equal(t, destination.id, tabTarget.Session)
@@ -445,7 +444,7 @@ func TestPickerMoveTabPreviewsDestinationActiveTabWithoutActivatingIt(t *testing
445 sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1}444 sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1}
446 delete(d.sessions, domain.SessionID("manual"))445 delete(d.sessions, domain.SessionID("manual"))
447 d.sessions[sourceSession.id] = sourceSession446 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},
449 ctx: sourceSession.ctx, cancel: func() {},448 ctx: sourceSession.ctx, cancel: func() {},
450 tabs: []*tab{449 tabs: []*tab{
451 newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel),450 newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel),
@@ -463,7 +462,7 @@ func TestPickerMoveTabPreviewsDestinationActiveTabWithoutActivatingIt(t *testing
463 462
464 ac.overlays.pickerMu.Lock()463 ac.overlays.pickerMu.Lock()
465 selected, ok := ac.overlays.picker.Selected()464 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)
467 ac.overlays.pickerMu.Unlock()466 ac.overlays.pickerMu.Unlock()
468 require.True(t, ok)467 require.True(t, ok)
469 require.Equal(t, target.id, selected.Session)468 require.Equal(t, target.id, selected.Session)
@@ -471,7 +470,7 @@ func TestPickerMoveTabPreviewsDestinationActiveTabWithoutActivatingIt(t *testing
471 require.Equal(t, target.name, selected.Name)470 require.Equal(t, target.name, selected.Name)
472 require.Equal(t, -1, selected.TabIndex)471 require.Equal(t, -1, selected.TabIndex)
473 require.Nil(t, selected.ExpectedCreatedAt)472 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")
475 d.closePicker(ac)474 d.closePicker(ac)
476 }475 }
477 476
@@ -575,7 +574,7 @@ func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) {
575 original := &session{sessionCore: sessionCore{id: "ephemeral-1", name: "2", ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}574 original := &session{sessionCore: sessionCore{id: "ephemeral-1", name: "2", ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
576 d.sessions[original.id] = original575 d.sessions[original.id] = original
577 576
578- views, _ := d.pickerViews(current)577+ views, _ := d.pickerViews(current, nil)
579 var originalView picker.SessionView578 var originalView picker.SessionView
580 for _, view := range views {579 for _, view := range views {
581 if view.ID == original.id {580 if view.ID == original.id {
@@ -595,18 +594,6 @@ func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) {
595 require.Same(t, current, ac.currentSession())594 require.Same(t, current, ac.currentSession())
596 }595 }
597 596
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-
610 func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {597 func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {
611 p1, releasePTY1 := newBlockingPTY(t)598 p1, releasePTY1 := newBlockingPTY(t)
612 p2, releasePTY2 := newBlockingPTY(t)599 p2, releasePTY2 := newBlockingPTY(t)
@@ -626,7 +613,7 @@ func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {
626 sess.tabs[1].attention = true613 sess.tabs[1].attention = true
627 sess.mu.Unlock()614 sess.mu.Unlock()
628 615
629- views, _ := d.pickerViews(sess)616+ views, _ := d.pickerViews(sess, nil)
630 617
631 require.Len(t, views, 1)618 require.Len(t, views, 1)
632 require.Equal(t, []picker.TabEntry{619 require.Equal(t, []picker.TabEntry{
@@ -653,7 +640,7 @@ func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) {
653 640
654 d.ApplyConfig(domain.Config{Tabs: domain.TabsConfig{TerminalTitle: false}})641 d.ApplyConfig(domain.Config{Tabs: domain.TabsConfig{TerminalTitle: false}})
655 642
656- views, _ := d.pickerViews(sess)643+ views, _ := d.pickerViews(sess, nil)
657 644
658 require.Len(t, views, 1)645 require.Len(t, views, 1)
659 require.Equal(t, []picker.TabEntry{646 require.Equal(t, []picker.TabEntry{
@@ -829,7 +816,7 @@ func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
829 func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {816 func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
830 d, sess, ac, sends, releases := newManualTabSession(t, 2)817 d, sess, ac, sends, releases := newManualTabSession(t, 2)
831 sess.mu.Lock()818 sess.mu.Lock()
832- sess.client = ac819+ sess.registerAttachmentLocked(ac)
833 sess.mu.Unlock()820 sess.mu.Unlock()
834 d.ptys = newBlockingOpenFactory(t, d)821 d.ptys = newBlockingOpenFactory(t, d)
835 defer func() {822 defer func() {
@@ -844,8 +831,8 @@ func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
844 awaitFrame(t, sends, ports.MsgOutput)831 awaitFrame(t, sends, ports.MsgOutput)
845 d.handleInput(sess, ac, []byte("\r"))832 d.handleInput(sess, ac, []byte("\r"))
846 833
847- require.Equal(t, 1, activeTabIndex(sess))834+ require.Equal(t, 1, testAttachmentTabIndex(sess))
848- requireFloatingInitialized(t, sess.activeTab())835+ requireFloatingInitialized(t, testAttachmentTab(sess))
849 awaitFrame(t, sends, ports.MsgOutput)836 awaitFrame(t, sends, ports.MsgOutput)
850 d.enterPicker(sess, ac)837 d.enterPicker(sess, ac)
851 awaitFrame(t, sends, ports.MsgOutput)838 awaitFrame(t, sends, ports.MsgOutput)
@@ -886,7 +873,7 @@ func TestPickerSplitArrowNavigatesWithoutExiting(t *testing.T) {
886 }873 }
887 require.True(t, ac.overlays.pickerActive())874 require.True(t, ac.overlays.pickerActive())
888 d.handleInput(sess, ac, []byte("\r"))875 d.handleInput(sess, ac, []byte("\r"))
889- require.Equal(t, tc.wantActive, activeTabIndex(sess))876+ require.Equal(t, tc.wantActive, testAttachmentTabIndex(sess))
890 })877 })
891 }878 }
892 }879 }
@@ -946,95 +933,6 @@ func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) {
946 require.Contains(t, frame, "TARGET", "first target reset must immediately show clean target VT state")933 require.Contains(t, frame, "TARGET", "first target reset must immediately show clean target VT state")
947 }934 }
948 935
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-
1038 func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {936 func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
1039 p1, releasePTY1 := newBlockingPTY(t)937 p1, releasePTY1 := newBlockingPTY(t)
1040 p2, releasePTY2 := newBlockingPTY(t)938 p2, releasePTY2 := newBlockingPTY(t)
@@ -1051,8 +949,8 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
1051 sctx2, cancel2 := context.WithCancel(d.serveCtx)949 sctx2, cancel2 := context.WithCancel(d.serveCtx)
1052 defer cancel1()950 defer cancel1()
1053 defer cancel2()951 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)}}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)}}
1055- sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}953+ sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", attachments: map[*attachedClient]struct{}{ac2: {}}}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}
1056 ac1.setSession(sess1)954 ac1.setSession(sess1)
1057 ac2.setSession(sess2)955 ac2.setSession(sess2)
1058 d.sessions[sess1.id] = sess1956 d.sessions[sess1.id] = sess1
@@ -1102,7 +1000,7 @@ func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing
1102 sctx2, cancel2 := context.WithCancel(d.serveCtx)1000 sctx2, cancel2 := context.WithCancel(d.serveCtx)
1103 defer cancel1()1001 defer cancel1()
1104 defer cancel2()1002 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)}}
1106 sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta"}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}1004 sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta"}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}
1107 ac.setSession(sess1)1005 ac.setSession(sess1)
1108 d.sessions[sess1.id] = sess11006 d.sessions[sess1.id] = sess1
@@ -1120,11 +1018,10 @@ func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing
1120 switchErr := make(chan error, 1)1018 switchErr := make(chan error, 1)
1121 go func() {1019 go func() {
1122 result, err := d.transitionAttachment(attachmentTransitionRequest{1020 result, err := d.transitionAttachment(attachmentTransitionRequest{
1123- source: sess1,1021+ source: sess1,
1124- target: sess2,1022+ target: sess2,
1125- next: ac,1023+ next: ac,
1126- expectedRole: attachmentActive,1024+
1127- targetRole: attachmentActive,
1128 expectedTransport: ac.transportSnapshot(),1025 expectedTransport: ac.transportSnapshot(),
1129 ready: true,1026 ready: true,
1130 })1027 })
@@ -1139,7 +1036,7 @@ func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing
1139 case <-time.After(2 * time.Second):1036 case <-time.After(2 * time.Second):
1140 t.Fatal("session switch waited for in-flight transport send")1037 t.Fatal("session switch waited for in-flight transport send")
1141 }1038 }
1142- require.True(t, result.published.activeCurrent())1039+ require.True(t, result.published.attachmentCurrent())
1143 select {1040 select {
1144 case <-handoffAtSendMu:1041 case <-handoffAtSendMu:
1145 t.Fatal("output rebase ran during architecture publication")1042 t.Fatal("output rebase ran during architecture publication")
@@ -1153,56 +1050,6 @@ func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing
1153 }1050 }
1154 }1051 }
1155 1052
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-
1206 func TestPickerPreviewGenerationRejectsStaleSubscriptionReplacement(t *testing.T) {1053 func TestPickerPreviewGenerationRejectsStaleSubscriptionReplacement(t *testing.T) {
1207 rc := newRenderCoordinator(renderCoordinatorOptions{})1054 rc := newRenderCoordinator(renderCoordinatorOptions{})
1208 viewer := &attachedClient{}1055 viewer := &attachedClient{}
@@ -1549,7 +1396,7 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
1549 require.Contains(t, opens[1], "COLORTERM=truecolor")1396 require.Contains(t, opens[1], "COLORTERM=truecolor")
1550 require.Contains(t, opens[1], "TERM_PROGRAM=vev")1397 require.Contains(t, opens[1], "TERM_PROGRAM=vev")
1551 require.Eventually(t, func() bool {1398 require.Eventually(t, func() bool {
1552- tb := got.activeTab()1399+ tb := testAttachmentTab(got)
1553 if tb == nil {1400 if tb == nil {
1554 return false1401 return false
1555 }1402 }
@@ -1640,8 +1487,9 @@ func TestPickerNavigationRefreshAfterDeleteSelectsReplacingRow(t *testing.T) {
1640 t.Run(tt.name, func(t *testing.T) {1487 t.Run(tt.name, func(t *testing.T) {
1641 d, current, ac, _, currentReleases := newManualTabSession(t, 2)1488 d, current, ac, _, currentReleases := newManualTabSession(t, 2)
1642 defer releaseAll(currentReleases)1489 defer releaseAll(currentReleases)
1643- current.id, current.name, current.active = "current", tt.currentName, 11490+ current.id, current.name = "current", tt.currentName
1644 current.tabs[0].stableID, current.tabs[1].stableID = "current-first", "current-active"1491 current.tabs[0].stableID, current.tabs[1].stableID = "current-first", "current-active"
1492+ selectTestAttachmentTab(current, 1)
1645 delete(d.sessions, domain.SessionID("manual"))1493 delete(d.sessions, domain.SessionID("manual"))
1646 d.sessions[current.id] = current1494 d.sessions[current.id] = current
1647 1495
@@ -1687,7 +1535,7 @@ func TestPickerNavigationRefreshAfterDeleteSelectsReplacingRow(t *testing.T) {
1687 }1535 }
1688 }1536 }
1689 1537
1690-func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T) {1538+func TestPickerAttachmentEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T) {
1691 d, current, ac, _, currentReleases := newManualTabSession(t, 1)1539 d, current, ac, _, currentReleases := newManualTabSession(t, 1)
1692 defer releaseAll(currentReleases)1540 defer releaseAll(currentReleases)
1693 current.id, current.name = "current", "z-current"1541 current.id, current.name = "current", "z-current"
@@ -1700,9 +1548,9 @@ func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T)
1700 target := &session{sessionCore: sessionCore{id: "target", name: "a-target", ephemeral: true}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}}1548 target := &session{sessionCore: sessionCore{id: "target", name: "a-target", ephemeral: true}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}}
1701 d.sessions[target.id] = target1549 d.sessions[target.id] = target
1702 current.mu.Lock()1550 current.mu.Lock()
1703- current.client = ac1551+ current.registerAttachmentLocked(ac)
1704 current.mu.Unlock()1552 current.mu.Unlock()
1705- require.NotNil(t, d.attachCoordinator(current, nil, ac, true))1553+ require.NotNil(t, d.attachCoordinator(current, ac, true))
1706 1554
1707 d.enterPicker(current, ac)1555 d.enterPicker(current, ac)
1708 d.handlePickerInput(ac, []byte("k"))1556 d.handlePickerInput(ac, []byte("k"))
@@ -1714,8 +1562,8 @@ func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T)
1714 1562
1715 token := current.attachmentToken(ac, ac.transport())1563 token := current.attachmentToken(ac, ac.transport())
1716 token.lease = current.renderCoordinator().attachmentLease(ac)1564 token.lease = current.renderCoordinator().attachmentLease(ac)
1717- ac.publishRoleCapability(token)1565+ ac.publishAttachmentCapability(token)
1718- effect, admitted := ac.beginRoleEffect(token)1566+ effect, admitted := ac.beginAttachmentEffect(token)
1719 require.True(t, admitted)1567 require.True(t, admitted)
1720 d.handlePickerInput(ac, []byte("x"), effect)1568 d.handlePickerInput(ac, []byte("x"), effect)
1721 1569
@@ -1811,8 +1659,7 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry
1811 require.True(t, stopped.purging, "an uncertain deletion stays fenced from restore")1659 require.True(t, stopped.purging, "an uncertain deletion stays fenced from restore")
1812 1660
1813 from.mu.Lock()1661 from.mu.Lock()
1814- activeTab := from.tabs[from.active]1662+ activeTabID := testAttachmentTabLocked(from).stableID
1815- activeTabID := activeTab.stableID
1816 from.mu.Unlock()1663 from.mu.Unlock()
1817 ac.overlays.pickerMu.Lock()1664 ac.overlays.pickerMu.Lock()
1818 selected, selectedOK := ac.overlays.picker.Selected()1665 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,
23 d.invalidateRender(sess, ac, true, "prompt.go")23 d.invalidateRender(sess, ac, true, "prompt.go")
24 }24 }
25 25
26-func (d *Daemon) enterTransitionPrompt(sess attachmentSession, ac *attachedClient, title, initial string, submit func(string, attachmentRoleToken) error) {26+func (d *Daemon) enterTransitionPrompt(sess attachmentSession, ac *attachedClient, title, initial string, submit func(string, attachmentConnectionToken) error) {
27 d.closePrompt(ac)27 d.closePrompt(ac)
28 ac.overlays.promptMu.Lock()28 ac.overlays.promptMu.Lock()
29 ac.overlays.prompt = promptui.New(title, initial)29 ac.overlays.prompt = promptui.New(title, initial)
@@ -51,7 +51,7 @@ func promptValidationError(err error) bool {
51 errors.Is(err, errProxyKillConfirmation)51 errors.Is(err, errProxyKillConfirmation)
52 }52 }
53 53
54-func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {54+func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
55 sess := ac.currentAttachmentSession()55 sess := ac.currentAttachmentSession()
56 if sess == nil {56 if sess == nil {
57 return57 return
@@ -67,7 +67,7 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*
67 changed := false67 changed := false
68 exit := false68 exit := false
69 var submit func(string) error69 var submit func(string) error
70- var transitionSubmit func(string, attachmentRoleToken) error70+ var transitionSubmit func(string, attachmentConnectionToken) error
71 var submittedPrompt *promptui.Model71 var submittedPrompt *promptui.Model
72 var value string72 var value string
73 73
@@ -95,9 +95,9 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*
95 if submit != nil || transitionSubmit != nil {95 if submit != nil || transitionSubmit != nil {
96 var err error96 var err error
97 if transitionSubmit != nil {97 if transitionSubmit != nil {
98- var token attachmentRoleToken98+ var token attachmentConnectionToken
99 if len(effects) != 0 && effects[0] != nil {99 if len(effects) != 0 && effects[0] != nil {
100- token = effects[0].roleToken()100+ token = effects[0].connectionToken()
101 }101 }
102 err = transitionSubmit(value, token)102 err = transitionSubmit(value, token)
103 } else {103 } else {
M internal/usecase/daemon/proxied_contract_test.go +20 -18
@@ -57,9 +57,10 @@ func TestProxiedHandshakeOrdersWelcomeMetadataAndBaseZeroContent(t *testing.T) {
57 57
58 sess := firstSession(d)58 sess := firstSession(d)
59 require.NotNil(t, sess)59 require.NotNil(t, sess)
60- sess.activeTab().mu.Lock()60+ tb := sess.tabs[0]
61- actualContentSize := sess.activeTab().size61+ tb.mu.Lock()
62- sess.activeTab().mu.Unlock()62+ actualContentSize := tb.size
63+ tb.mu.Unlock()
63 require.Equal(t, contentSize, actualContentSize, "proxied geometry must not subtract chrome a second time")64 require.Equal(t, contentSize, actualContentSize, "proxied geometry must not subtract chrome a second time")
64 65
65 release()66 release()
@@ -94,9 +95,10 @@ func TestOrdinaryHandshakeRetainsChromeAndNoMetadata(t *testing.T) {
94 95
95 sess := firstSession(d)96 sess := firstSession(d)
96 require.NotNil(t, sess)97 require.NotNil(t, sess)
97- sess.activeTab().mu.Lock()98+ tb := sess.tabs[0]
98- actualContentSize := sess.activeTab().size99+ tb.mu.Lock()
99- sess.activeTab().mu.Unlock()100+ actualContentSize := tb.size
101+ tb.mu.Unlock()
100 require.Equal(t, tabSize(viewport), actualContentSize)102 require.Equal(t, tabSize(viewport), actualContentSize)
101 requireNoOutputFrame(t, sends)103 requireNoOutputFrame(t, sends)
102 104
@@ -127,7 +129,7 @@ func TestProxiedRemotePaintSendsMetadataBeforeScreen(t *testing.T) {
127 require.True(t, applyTestScreenText(proxy, 0, 0, "remote"))129 require.True(t, applyTestScreenText(proxy, 0, 0, "remote"))
128 130
129 ac.sendMu.Lock()131 ac.sendMu.Lock()
130- state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: true})132+ state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: true})
131 require.True(t, ok)133 require.True(t, ok)
132 composed := composeFrame(*state, composeCacheInput{})134 composed := composeFrame(*state, composeCacheInput{})
133 require.True(t, d.emitFrame(proxy, ac, state, composed))135 require.True(t, d.emitFrame(proxy, ac, state, composed))
@@ -143,7 +145,7 @@ func TestProxiedRemotePaintSendsMetadataBeforeScreen(t *testing.T) {
143 func TestProxiedPaintPublishesAbsoluteVisibleCursorStyle(t *testing.T) {145 func TestProxiedPaintPublishesAbsoluteVisibleCursorStyle(t *testing.T) {
144 d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())146 d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
145 ac.proxied = true147 ac.proxied = true
146- pane := sess.activeTab().focusedPane()148+ pane := testAttachmentTab(sess).focusedPane()
147 pane.mu.Lock()149 pane.mu.Lock()
148 pane.screen.Write([]byte("\x1b[3;6H\x1b[0 q"))150 pane.screen.Write([]byte("\x1b[3;6H\x1b[0 q"))
149 pane.mu.Unlock()151 pane.mu.Unlock()
@@ -203,13 +205,13 @@ func TestProxyResizeUpdatesAttachmentViewport(t *testing.T) {
203 func TestProxiedTransactionalResizeUsesReceivedContentGeometry(t *testing.T) {205 func TestProxiedTransactionalResizeUsesReceivedContentGeometry(t *testing.T) {
204 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())206 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
205 ac.proxied = true207 ac.proxied = true
206- rc := d.attachCoordinator(sess, nil, ac, true)208+ rc := d.attachCoordinator(sess, ac, true)
207 lease := rc.attachmentLease(ac)209 lease := rc.attachmentLease(ac)
208 require.NotNil(t, lease)210 require.NotNil(t, lease)
209 211
210 want := domain.Size{Cols: 100, Rows: 12}212 want := domain.Size{Cols: 100, Rows: 12}
211 require.True(t, d.requestTransactionalResizeForLease(sess, ac, lease, want, true))213 require.True(t, d.requestTransactionalResizeForLease(sess, ac, lease, want, true))
212- tb := sess.activeTab()214+ tb := testAttachmentTab(sess)
213 tb.mu.Lock()215 tb.mu.Lock()
214 require.Equal(t, want, tb.size)216 require.Equal(t, want, tb.size)
215 tb.mu.Unlock()217 tb.mu.Unlock()
@@ -260,7 +262,7 @@ func TestProxiedModeCannotChangeAcrossResume(t *testing.T) {
260 func TestOutputResetRebasesFullWindowAndSchedulesBaseZeroPaint(t *testing.T) {262 func TestOutputResetRebasesFullWindowAndSchedulesBaseZeroPaint(t *testing.T) {
261 d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())263 d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
262 ac.proxied = true264 ac.proxied = true
263- rc := d.attachCoordinator(sess, nil, ac, true)265+ rc := d.attachCoordinator(sess, ac, true)
264 lease := rc.attachmentLease(ac)266 lease := rc.attachmentLease(ac)
265 require.NotNil(t, lease)267 require.NotNil(t, lease)
266 268
@@ -270,7 +272,7 @@ func TestOutputResetRebasesFullWindowAndSchedulesBaseZeroPaint(t *testing.T) {
270 ac.sendMu.Unlock()272 ac.sendMu.Unlock()
271 273
272 token := sess.attachmentToken(ac, ac.transport())274 token := sess.attachmentToken(ac, ac.transport())
273- require.False(t, d.handleActiveClientFrame(token, ports.Frame{275+ require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{
274 Type: ports.MsgOutputResetRequest,276 Type: ports.MsgOutputResetRequest,
275 Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),277 Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
276 }))278 }))
@@ -302,16 +304,16 @@ func TestOutputResetRequiresProxiedActiveRoleAndStrictPayload(t *testing.T) {
302 t.Run(tt.name, func(t *testing.T) {304 t.Run(tt.name, func(t *testing.T) {
303 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())305 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
304 ac.proxied = tt.proxied306 ac.proxied = tt.proxied
305- d.attachCoordinator(sess, nil, ac, true)307+ d.attachCoordinator(sess, ac, true)
306 ac.sendMu.Lock()308 ac.sendMu.Lock()
307 ac.output.next, ac.output.acked = 4, 0309 ac.output.next, ac.output.acked = 4, 0
308 ac.sendMu.Unlock()310 ac.sendMu.Unlock()
309 311
310 token := sess.attachmentToken(ac, ac.transport())312 token := sess.attachmentToken(ac, ac.transport())
311 if tt.staleRole {313 if tt.staleRole {
312- token.role = attachmentSnatched314+ token.generation++
313 }315 }
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}))
315 ac.sendMu.Lock()317 ac.sendMu.Lock()
316 acked := ac.output.acked318 acked := ac.output.acked
317 ac.sendMu.Unlock()319 ac.sendMu.Unlock()
@@ -323,7 +325,7 @@ func TestOutputResetRequiresProxiedActiveRoleAndStrictPayload(t *testing.T) {
323 func TestOutputResetRevalidatesTransportUnderSendLock(t *testing.T) {325 func TestOutputResetRevalidatesTransportUnderSendLock(t *testing.T) {
324 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())326 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
325 ac.proxied = true327 ac.proxied = true
326- d.attachCoordinator(sess, nil, ac, true)328+ d.attachCoordinator(sess, ac, true)
327 ac.sendMu.Lock()329 ac.sendMu.Lock()
328 ac.output.next, ac.output.acked = 4, 0330 ac.output.next, ac.output.acked = 4, 0
329 ac.sendMu.Unlock()331 ac.sendMu.Unlock()
@@ -331,11 +333,11 @@ func TestOutputResetRevalidatesTransportUnderSendLock(t *testing.T) {
331 333
332 admitted := make(chan struct{})334 admitted := make(chan struct{})
333 var once sync.Once335 var once sync.Once
334- d.afterRoleEffectAdmitted = func(attachmentRoleToken) { once.Do(func() { close(admitted) }) }336+ d.afterAttachmentEffectAdmitted = func(attachmentConnectionToken) { once.Do(func() { close(admitted) }) }
335 ac.sendMu.Lock()337 ac.sendMu.Lock()
336 done := make(chan bool, 1)338 done := make(chan bool, 1)
337 go func() {339 go func() {
338- done <- d.handleActiveClientFrame(token, ports.Frame{340+ done <- d.handleAttachmentClientFrame(token, ports.Frame{
339 Type: ports.MsgOutputResetRequest,341 Type: ports.MsgOutputResetRequest,
340 Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),342 Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
341 })343 })
M internal/usecase/daemon/proxy_input.go +25 -27
@@ -42,36 +42,36 @@ func (p *proxySession) sendInput(raw []byte) error {
42 // proxy. Local UI actions never reach the link. Every remote-owned action is42 // proxy. Local UI actions never reach the link. Every remote-owned action is
43 // sent using the exact bytes supplied by keys.Router.43 // sent using the exact bytes supplied by keys.Router.
44 type proxyKeyHandler struct {44 type proxyKeyHandler struct {
45- d *Daemon45+ d *Daemon
46- proxy *proxySession46+ proxy *proxySession
47- ac *attachedClient47+ ac *attachedClient
48- roleToken attachmentRoleToken48+ connectionToken attachmentConnectionToken
49 }49 }
50 50
51-func (h proxyKeyHandler) acquireRoleEffect() (*roleEffectTicket, bool, bool) {51+func (h proxyKeyHandler) acquireAttachmentEffect() (*attachmentEffectTicket, bool, bool) {
52 if h.proxy == nil || h.ac == nil {52 if h.proxy == nil || h.ac == nil {
53 return nil, false, false53 return nil, false, false
54 }54 }
55- if h.roleToken.ac == nil {55+ if h.connectionToken.ac == nil {
56 return nil, false, false56 return nil, false, false
57 }57 }
58- if h.roleToken.sess != h.proxy || h.roleToken.ac != h.ac {58+ if h.connectionToken.sess != h.proxy || h.connectionToken.ac != h.ac {
59 return nil, false, false59 return nil, false, false
60 }60 }
61- if effect := h.roleToken.effect; effect != nil && !effect.ended.Load() {61+ if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {
62 return effect, false, true62 return effect, false, true
63 }63 }
64- effect, admitted := h.roleToken.ac.beginRoleEffect(h.roleToken)64+ effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken)
65 if h.d != nil && h.d.afterDelayedKeyEffectAttempt != nil {65 if h.d != nil && h.d.afterDelayedKeyEffectAttempt != nil {
66 h.d.afterDelayedKeyEffectAttempt(admitted)66 h.d.afterDelayedKeyEffectAttempt(admitted)
67 }67 }
68 if !admitted {68 if !admitted {
69 return nil, false, false69 return nil, false, false
70 }70 }
71- if h.d != nil && h.d.afterRoleEffectAdmitted != nil {71+ if h.d != nil && h.d.afterAttachmentEffectAdmitted != nil {
72- token := h.roleToken72+ token := h.connectionToken
73 token.effect = effect73 token.effect = effect
74- h.d.afterRoleEffectAdmitted(token)74+ h.d.afterAttachmentEffectAdmitted(token)
75 }75 }
76 return effect, true, true76 return effect, true, true
77 }77 }
@@ -86,7 +86,7 @@ func (h proxyKeyHandler) sendOwned(raw []byte) {
86 }86 }
87 87
88 func (h proxyKeyHandler) send(raw []byte) {88 func (h proxyKeyHandler) send(raw []byte) {
89- effect, owned, ok := h.acquireRoleEffect()89+ effect, owned, ok := h.acquireAttachmentEffect()
90 if !ok {90 if !ok {
91 return91 return
92 }92 }
@@ -101,14 +101,14 @@ func (h proxyKeyHandler) Forward(raw []byte) { h.send(raw) }
101 func (h proxyKeyHandler) Mouse(event mouse.Event) { h.send(event.Raw) }101 func (h proxyKeyHandler) Mouse(event mouse.Event) { h.send(event.Raw) }
102 102
103 func (h proxyKeyHandler) Action(action keys.Action, raw []byte) {103 func (h proxyKeyHandler) Action(action keys.Action, raw []byte) {
104- effect, owned, ok := h.acquireRoleEffect()104+ effect, owned, ok := h.acquireAttachmentEffect()
105 if !ok {105 if !ok {
106 return106 return
107 }107 }
108 if owned {108 if owned {
109 defer effect.End()109 defer effect.End()
110 }110 }
111- h.roleToken.effect = effect111+ h.connectionToken.effect = effect
112 switch action {112 switch action {
113 case keys.ActionOpenPalette:113 case keys.ActionOpenPalette:
114 h.enterPalette()114 h.enterPalette()
@@ -176,7 +176,7 @@ func (h proxyKeyHandler) enterPalette() {
176 // navigation. A pending tab on the proxy remains remote-owned; without one,176 // navigation. A pending tab on the proxy remains remote-owned; without one,
177 // the local daemon may move the client to another local session.177 // the local daemon may move the client to another local session.
178 func (h proxyKeyHandler) jumpLocalAttention() {178 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 {
180 return180 return
181 }181 }
182 target, ok := h.d.oldestOtherSessionAttention(nil)182 target, ok := h.d.oldestOtherSessionAttention(nil)
@@ -190,12 +190,12 @@ func (h proxyKeyHandler) jumpLocalAttention() {
190 return190 return
191 }191 }
192 192
193- token := h.roleToken193+ token := h.connectionToken
194 token.effect.bindActionEnd(h.d, "proxy-jump-attention")194 token.effect.bindActionEnd(h.d, "proxy-jump-attention")
195 token.effect.End()195 token.effect.End()
196 transition, err := h.d.transitionAttachment(attachmentTransitionRequest{196 transition, err := h.d.transitionAttachment(attachmentTransitionRequest{
197 source: h.proxy, target: targetSession, next: h.ac,197 source: h.proxy, target: targetSession, next: h.ac,
198- expectedRole: attachmentActive, targetRole: attachmentActive,198+
199 expectedTransport: token.transport, sourceToken: &token,199 expectedTransport: token.transport, sourceToken: &token,
200 action: "proxy-jump-attention", activateTargetTab: true,200 action: "proxy-jump-attention", activateTargetTab: true,
201 targetTabIndex: target.tabIndex, ready: true,201 targetTabIndex: target.tabIndex, ready: true,
@@ -227,34 +227,32 @@ func proxiedJumpSearchesOtherSessions(proxied bool) bool { return !proxied }
227 // but never follows OverflowSessions. The local proxy daemon is the only owner227 // but never follows OverflowSessions. The local proxy daemon is the only owner
228 // allowed to change the selected session.228 // allowed to change the selected session.
229 func (d *Daemon) focusDirProxied(sess *session, ac *attachedClient, dir layout.Direction) error {229 func (d *Daemon) focusDirProxied(sess *session, ac *attachedClient, dir layout.Direction) error {
230- target := resolveDaemonActionTarget(sess)230+ target := resolveDaemonActionTargetForAttachment(sess, ac)
231 oldFocus := layout.PaneID("")231 oldFocus := layout.PaneID("")
232 if target.pane != nil {232 if target.pane != nil {
233 oldFocus = target.pane.id233 oldFocus = target.pane.id
234 }234 }
235- span, err := d.focusDirAt(sess, target.tab, target.pane, dir)235+ span, err := d.focusDirAt(sess, target.tab, target.pane, dir, ac)
236 if err == nil {236 if err == nil {
237 if ac != nil {237 if ac != nil {
238 d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "proxy_input.go")238 d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "proxy_input.go")
239 }239 }
240 return nil240 return nil
241 }241 }
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) {
243 return err243 return err
244 }244 }
245 245
246- sess.mu.Lock()246+ position, count := sess.tabIndexForAttachment(ac)
247- position, count := sess.active, len(sess.tabs)
248- sess.mu.Unlock()
249 step := resolveOverflow(dir, proxiedNavConfig(d.currentNavConfig()), position, count)247 step := resolveOverflow(dir, proxiedNavConfig(d.currentNavConfig()), position, count)
250 if step.kind != overflowTabs {248 if step.kind != overflowTabs {
251 return errNoNeighbor249 return errNoNeighbor
252 }250 }
253- candidate, ok := d.prepareTabOverflow(sess, target.tab, dir, span, step.delta)251+ candidate, ok := d.prepareTabOverflowForAttachment(sess, ac, target.tab, dir, span, step.delta)
254- if !ok || !d.commitTabOverflow(sess, candidate) {252+ if !ok || !d.commitTabOverflowForAttachment(sess, ac, candidate) {
255 return errNoNeighbor253 return errNoNeighbor
256 }254 }
257- d.activateTab(sess, candidate.target)255+ sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID))
258 if ac != nil {256 if ac != nil {
259 d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "proxy_input.go")257 d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "proxy_input.go")
260 }258 }
M internal/usecase/daemon/proxy_input_test.go +75 -75
@@ -35,11 +35,11 @@ func newProxyInputHarness(t *testing.T) (*Daemon, *proxySession, *attachedClient
35 proxy.mu.Unlock()35 proxy.mu.Unlock()
36 ac := &attachedClient{size: domain.Size{Cols: 80, Rows: 24}, output: newOutputStateStream()}36 ac := &attachedClient{size: domain.Size{Cols: 80, Rows: 24}, output: newOutputStateStream()}
37 ac.initOverlays()37 ac.initOverlays()
38- token := roleEffectForTest(t, attachProxyInputRole(t, d, proxy, ac))38+ token := attachmentEffectForTest(t, attachProxyInputRole(t, d, proxy, ac))
39- return d, proxy, ac, link, proxyKeyHandler{d: d, proxy: proxy, ac: ac, roleToken: token}39+ return d, proxy, ac, link, proxyKeyHandler{d: d, proxy: proxy, ac: ac, connectionToken: token}
40 }40 }
41 41
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 {
43 t.Helper()43 t.Helper()
44 clientTransport := newProxyTestTransport()44 clientTransport := newProxyTestTransport()
45 ac.replaceTransport(clientTransport)45 ac.replaceTransport(clientTransport)
@@ -49,7 +49,7 @@ func attachProxyInputRole(t *testing.T, d *Daemon, proxy *proxySession, ac *atta
49 d.mu.Unlock()49 d.mu.Unlock()
50 transition, err := d.transitionAttachment(attachmentTransitionRequest{50 transition, err := d.transitionAttachment(attachmentTransitionRequest{
51 target: proxy, next: ac,51 target: proxy, next: ac,
52- expectedRole: attachmentDetached, targetRole: attachmentActive,52+
53 expectedTransport: ac.transportSnapshot(), ready: true,53 expectedTransport: ac.transportSnapshot(), ready: true,
54 })54 })
55 require.NoError(t, err)55 require.NoError(t, err)
@@ -57,9 +57,9 @@ func attachProxyInputRole(t *testing.T, d *Daemon, proxy *proxySession, ac *atta
57 return transition.published57 return transition.published
58 }58 }
59 59
60-func roleEffectForTest(t *testing.T, token attachmentRoleToken) attachmentRoleToken {60+func attachmentEffectForTest(t *testing.T, token attachmentConnectionToken) attachmentConnectionToken {
61 t.Helper()61 t.Helper()
62- effect, admitted := token.ac.beginRoleEffect(token)62+ effect, admitted := token.ac.beginAttachmentEffect(token)
63 require.True(t, admitted)63 require.True(t, admitted)
64 token.effect = effect64 token.effect = effect
65 t.Cleanup(effect.End)65 t.Cleanup(effect.End)
@@ -151,22 +151,22 @@ func TestProxyInputRouterPreservesCustomAndDelayedActionBytes(t *testing.T) {
151 151
152 t.Run("retained escape reacquires its original role", func(t *testing.T) {152 t.Run("retained escape reacquires its original role", func(t *testing.T) {
153 d, proxy, ac, link, initial := newProxyInputHarness(t)153 d, proxy, ac, link, initial := newProxyInputHarness(t)
154- base := initial.roleToken154+ base := initial.connectionToken
155 base.effect.End()155 base.effect.End()
156 base.effect = nil156 base.effect = nil
157 157
158- firstEffect, admitted := ac.beginRoleEffect(base)158+ firstEffect, admitted := ac.beginAttachmentEffect(base)
159 require.True(t, admitted)159 require.True(t, admitted)
160 first := base160 first := base
161 first.effect = firstEffect161 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})
163 firstEffect.End()163 firstEffect.End()
164 164
165- secondEffect, admitted := ac.beginRoleEffect(base)165+ secondEffect, admitted := ac.beginAttachmentEffect(base)
166 require.True(t, admitted)166 require.True(t, admitted)
167 second := base167 second := base
168 second.effect = secondEffect168 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})
170 secondEffect.End()170 secondEffect.End()
171 171
172 requireProxyInputFrame(t, link, 1, []byte{keys.ESC, 'j'})172 requireProxyInputFrame(t, link, 1, []byte{keys.ESC, 'j'})
@@ -175,15 +175,15 @@ func TestProxyInputRouterPreservesCustomAndDelayedActionBytes(t *testing.T) {
175 175
176 func TestProxyInputLocalPaletteNeverForwards(t *testing.T) {176 func TestProxyInputLocalPaletteNeverForwards(t *testing.T) {
177 d, _, ac, link, handler := newProxyInputHarness(t)177 d, _, ac, link, handler := newProxyInputHarness(t)
178- token := handler.roleToken178+ token := handler.connectionToken
179 handler.Action(keys.ActionOpenPalette, []byte{keys.ESC, ' '})179 handler.Action(keys.ActionOpenPalette, []byte{keys.ESC, ' '})
180 require.True(t, ac.overlays.paletteActive())180 require.True(t, ac.overlays.paletteActive())
181 requireNoProxyFrame(t, link)181 requireNoProxyFrame(t, link)
182 182
183 // Keyboard and mouse navigation are consumed by the local overlay before183 // Keyboard and mouse navigation are consumed by the local overlay before
184 // either the proxy router or proxy mouse sender can observe them.184 // either the proxy router or proxy mouse sender can observe them.
185- d.handleInputForRole(token, []byte("x"))185+ d.handleInputForAttachment(token, []byte("x"))
186- d.handleInputForRole(token, []byte("\x1b[<0;12;8M"))186+ d.handleInputForAttachment(token, []byte("\x1b[<0;12;8M"))
187 requireNoProxyFrame(t, link)187 requireNoProxyFrame(t, link)
188 }188 }
189 189
@@ -259,7 +259,7 @@ func TestProxyPaletteCanonicalLocalCommandsNeverReachRemote(t *testing.T) {
259 t.Run("new session opens local transition prompt", func(t *testing.T) {259 t.Run("new session opens local transition prompt", func(t *testing.T) {
260 d, proxy, ac, link, handler := newProxyInputHarness(t)260 d, proxy, ac, link, handler := newProxyInputHarness(t)
261 handler.enterPalette()261 handler.enterPalette()
262- d.handlePaletteInput(ac, []byte("CNS\r"), handler.roleToken.effect)262+ d.handlePaletteInput(ac, []byte("CNS\r"), handler.connectionToken.effect)
263 263
264 require.False(t, ac.overlays.paletteActive())264 require.False(t, ac.overlays.paletteActive())
265 require.True(t, ac.overlays.promptActive())265 require.True(t, ac.overlays.promptActive())
@@ -276,7 +276,7 @@ func TestProxyPaletteCanonicalLocalCommandsNeverReachRemote(t *testing.T) {
276 for len(client.sent) > 0 {276 for len(client.sent) > 0 {
277 <-client.sent277 <-client.sent
278 }278 }
279- d.handlePaletteInput(ac, []byte("YLN\r"), handler.roleToken.effect)279+ d.handlePaletteInput(ac, []byte("YLN\r"), handler.connectionToken.effect)
280 280
281 require.False(t, ac.overlays.paletteActive())281 require.False(t, ac.overlays.paletteActive())
282 require.Same(t, proxy, ac.currentAttachmentSession())282 require.Same(t, proxy, ac.currentAttachmentSession())
@@ -514,15 +514,15 @@ func TestProxyAttachedCommandRejectsTargetsAndExecutesExactActiveSession(t *test
514 publishTiledPaneOwners(sess, second)514 publishTiledPaneOwners(sess, second)
515 sess.mu.Lock()515 sess.mu.Lock()
516 sess.tabs = append(sess.tabs, second)516 sess.tabs = append(sess.tabs, second)
517- sess.active = 0517+ selectTestAttachmentTabLocked(sess, 0)
518 sess.mu.Unlock()518 sess.mu.Unlock()
519- d.attachCoordinator(sess, nil, ac, true)519+ d.attachCoordinator(sess, ac, true)
520 520
521 send := func(request ports.CommandRequest) ports.CommandResult {521 send := func(request ports.CommandRequest) ports.CommandResult {
522 payload, err := ports.MarshalCommandRequest(request)522 payload, err := ports.MarshalCommandRequest(request)
523 require.NoError(t, err)523 require.NoError(t, err)
524 token := sess.attachmentToken(ac, ac.transport())524 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}))
526 frame := awaitFrame(t, sends, ports.MsgCommandResult)526 frame := awaitFrame(t, sends, ports.MsgCommandResult)
527 result, err := ports.UnmarshalCommandResult(frame.Payload)527 result, err := ports.UnmarshalCommandResult(frame.Payload)
528 require.NoError(t, err)528 require.NoError(t, err)
@@ -532,13 +532,13 @@ func TestProxyAttachedCommandRejectsTargetsAndExecutesExactActiveSession(t *test
532 result := send(ports.CommandRequest{Version: ports.ProtocolVersion, RequestID: 1, Attached: true, Slug: "next-tab"})532 result := send(ports.CommandRequest{Version: ports.ProtocolVersion, RequestID: 1, Attached: true, Slug: "next-tab"})
533 require.True(t, result.OK)533 require.True(t, result.OK)
534 sess.mu.Lock()534 sess.mu.Lock()
535- require.Equal(t, 1, sess.active)535+ require.Equal(t, 1, testAttachmentTabIndexLocked(sess))
536 sess.mu.Unlock()536 sess.mu.Unlock()
537 537
538 result = send(ports.CommandRequest{Version: ports.ProtocolVersion, RequestID: 2, Attached: true, Slug: "previous-tab", TargetSession: "other"})538 result = send(ports.CommandRequest{Version: ports.ProtocolVersion, RequestID: 2, Attached: true, Slug: "previous-tab", TargetSession: "other"})
539 require.False(t, result.OK)539 require.False(t, result.OK)
540 sess.mu.Lock()540 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")
542 sess.mu.Unlock()542 sess.mu.Unlock()
543 543
544 result = send(ports.CommandRequest{Version: ports.ProtocolVersion, RequestID: 3, Attached: true, Slug: "rename-tab"})544 result = send(ports.CommandRequest{Version: ports.ProtocolVersion, RequestID: 3, Attached: true, Slug: "rename-tab"})
@@ -553,7 +553,7 @@ func TestProxyAttachedCommandRemoteErrorCreatesLocalNotice(t *testing.T) {
553 handler.enterPalette()553 handler.enterPalette()
554 done := make(chan struct{})554 done := make(chan struct{})
555 go func() {555 go func() {
556- d.handlePaletteInput(ac, []byte("NXT\r"), handler.roleToken.effect)556+ d.handlePaletteInput(ac, []byte("NXT\r"), handler.connectionToken.effect)
557 close(done)557 close(done)
558 }()558 }()
559 request := requireProxyCommandRequest(t, link)559 request := requireProxyCommandRequest(t, link)
@@ -620,13 +620,13 @@ func TestProxyPickerSelectionDialsOutsideLocksAndRevalidatesExactRoleAndKey(t *t
620 dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once()620 dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once()
621 d.remoteDialerFactory = factory621 d.remoteDialerFactory = factory
622 d.remoteTransportMode = ports.RemoteTransportUDP622 d.remoteTransportMode = ports.RemoteTransportUDP
623- d.attachCoordinator(source, nil, ac, true)623+ d.attachCoordinator(source, ac, true)
624 624
625 token := source.attachmentToken(ac, ac.transport())625 token := source.attachmentToken(ac, ac.transport())
626- effect, admitted := ac.beginRoleEffect(token)626+ effect, admitted := ac.beginAttachmentEffect(token)
627 require.True(t, admitted)627 require.True(t, admitted)
628 token.effect = effect628 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")
630 require.NoError(t, err)630 require.NoError(t, err)
631 require.True(t, daemonLockAvailable)631 require.True(t, daemonLockAvailable)
632 require.True(t, sourceLockAvailable)632 require.True(t, sourceLockAvailable)
@@ -637,26 +637,26 @@ func TestProxyPickerSelectionDialsOutsideLocksAndRevalidatesExactRoleAndKey(t *t
637 637
638 wrong := domain.RemoteSessionKey{Host: "arch", Name: "other"}638 wrong := domain.RemoteSessionKey{Host: "arch", Name: "other"}
639 currentToken := attachmentToken(proxy, ac, ac.transport())639 currentToken := attachmentToken(proxy, ac, ac.transport())
640- currentEffect, admitted := ac.beginRoleEffect(currentToken)640+ currentEffect, admitted := ac.beginAttachmentEffect(currentToken)
641 require.True(t, admitted)641 require.True(t, admitted)
642 currentToken.effect = currentEffect642 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")
644 require.ErrorIs(t, err, errAttachmentTransition)644 require.ErrorIs(t, err, errAttachmentTransition)
645 require.Same(t, proxy, ac.currentAttachmentSession(), "mismatched structured key must not redirect attachment")645 require.Same(t, proxy, ac.currentAttachmentSession(), "mismatched structured key must not redirect attachment")
646 646
647 staleToken := attachmentToken(proxy, ac, ac.transport())647 staleToken := attachmentToken(proxy, ac, ac.transport())
648- staleEffect, admitted := ac.beginRoleEffect(staleToken)648+ staleEffect, admitted := ac.beginAttachmentEffect(staleToken)
649 require.True(t, admitted)649 require.True(t, admitted)
650 staleToken.effect = staleEffect650 staleToken.effect = staleEffect
651- ac.roleGeneration.Add(1) // model a concurrent role replacement after selection651+ ac.connectionGeneration.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")652+ err = d.switchToTargetForAttachment(staleToken, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
653 require.ErrorIs(t, err, errAttachmentTransition)653 require.ErrorIs(t, err, errAttachmentTransition)
654 require.Same(t, proxy, ac.currentAttachmentSession(), "stale initiating role must not republish attachment")654 require.Same(t, proxy, ac.currentAttachmentSession(), "stale initiating role must not republish attachment")
655 }655 }
656 656
657 func TestRemotePickerEnterRoutesStructuredKeyThroughProxyOwnership(t *testing.T) {657 func TestRemotePickerEnterRoutesStructuredKeyThroughProxyOwnership(t *testing.T) {
658 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())658 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
659- d.attachCoordinator(local, nil, ac, true)659+ d.attachCoordinator(local, ac, true)
660 key := domain.RemoteSessionKey{Host: "arch", Name: "enter"}660 key := domain.RemoteSessionKey{Host: "arch", Name: "enter"}
661 transport := newProxyTestTransport()661 transport := newProxyTestTransport()
662 transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}662 transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
@@ -669,14 +669,14 @@ func TestRemotePickerEnterRoutesStructuredKeyThroughProxyOwnership(t *testing.T)
669 Sessions: []ports.RemoteCatalogSession{{Name: key.Name, State: "running"}},669 Sessions: []ports.RemoteCatalogSession{{Name: key.Name, State: "running"}},
670 }})670 }})
671 671
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})
673 d.publishPicker(local, ac, model, pickerNavigate, moveSourceLocator{})673 d.publishPicker(local, ac, model, pickerNavigate, moveSourceLocator{})
674 target, selectable := model.Selected()674 target, selectable := model.Selected()
675 require.True(t, selectable)675 require.True(t, selectable)
676 require.Equal(t, key, *target.RemoteKey)676 require.Equal(t, key, *target.RemoteKey)
677 677
678 token := local.attachmentToken(ac, ac.transport())678 token := local.attachmentToken(ac, ac.transport())
679- effect, admitted := ac.beginRoleEffect(token)679+ effect, admitted := ac.beginAttachmentEffect(token)
680 require.True(t, admitted)680 require.True(t, admitted)
681 d.handlePickerInput(ac, []byte("\r"), effect)681 d.handlePickerInput(ac, []byte("\r"), effect)
682 proxy, ok := ac.currentAttachmentSession().(*proxySession)682 proxy, ok := ac.currentAttachmentSession().(*proxySession)
@@ -694,7 +694,7 @@ func TestRemotePickerSwitchesFromProxyBackToLocalSession(t *testing.T) {
694 require.True(t, d.registerSessionLocked(local))694 require.True(t, d.registerSessionLocked(local))
695 d.mu.Unlock()695 d.mu.Unlock()
696 696
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)})
698 d.publishPicker(proxy, ac, model, pickerNavigate, moveSourceLocator{})698 d.publishPicker(proxy, ac, model, pickerNavigate, moveSourceLocator{})
699 target, selectable := model.Selected()699 target, selectable := model.Selected()
700 require.True(t, selectable)700 require.True(t, selectable)
@@ -702,17 +702,17 @@ func TestRemotePickerSwitchesFromProxyBackToLocalSession(t *testing.T) {
702 require.Equal(t, local.name, target.Name)702 require.Equal(t, local.name, target.Name)
703 require.False(t, target.Stopped)703 require.False(t, target.Stopped)
704 704
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"))
706 require.Same(t, local, ac.currentSession())706 require.Same(t, local, ac.currentSession())
707 require.False(t, ac.overlays.pickerActive(), "successful handoff must close the picker")707 require.False(t, ac.overlays.pickerActive(), "successful handoff must close the picker")
708 708
709 d.remoteDialerFactory = portsmocks.NewMockRemoteDialerFactory(t)709 d.remoteDialerFactory = portsmocks.NewMockRemoteDialerFactory(t)
710- localToken := roleEffectForTest(t, local.attachmentToken(ac, ac.transport()))710+ localToken := attachmentEffectForTest(t, local.attachmentToken(ac, ac.transport()))
711- require.NoError(t, d.backSessionForRole(localToken))711+ require.NoError(t, d.backSessionForAttachment(localToken))
712 require.Same(t, proxy, ac.currentAttachmentSession(), "back-session must return to the previous warm proxy")712 require.Same(t, proxy, ac.currentAttachmentSession(), "back-session must return to the previous warm proxy")
713 713
714- proxyToken := roleEffectForTest(t, attachmentToken(proxy, ac, ac.transport()))714+ proxyToken := attachmentEffectForTest(t, attachmentToken(proxy, ac, ac.transport()))
715- require.NoError(t, d.switchToTargetForRole(proxyToken, target, sessionHandoffGuard{}, "picker-select"))715+ require.NoError(t, d.switchToTargetForAttachment(proxyToken, target, sessionHandoffGuard{}, "picker-select"))
716 require.Same(t, local, ac.currentSession())716 require.Same(t, local, ac.currentSession())
717 d.backSession(local, ac)717 d.backSession(local, ac)
718 require.Same(t, proxy, ac.currentAttachmentSession(), "legacy back-session must preserve the previous proxy identity")718 require.Same(t, proxy, ac.currentAttachmentSession(), "legacy back-session must preserve the previous proxy identity")
@@ -720,7 +720,7 @@ func TestRemotePickerSwitchesFromProxyBackToLocalSession(t *testing.T) {
720 720
721 func TestRemotePickerReportsUnavailableLocalTarget(t *testing.T) {721 func TestRemotePickerReportsUnavailableLocalTarget(t *testing.T) {
722 d, proxy, ac, _, handler := newProxyInputHarness(t)722 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")
724 require.Error(t, err)724 require.Error(t, err)
725 require.Same(t, proxy, ac.currentAttachmentSession())725 require.Same(t, proxy, ac.currentAttachmentSession())
726 }726 }
@@ -734,18 +734,18 @@ func TestRemotePickerRejectsReplacedLocalLifecycle(t *testing.T) {
734 require.True(t, d.registerSessionLocked(local))734 require.True(t, d.registerSessionLocked(local))
735 d.mu.Unlock()735 d.mu.Unlock()
736 736
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)})
738 target, selectable := model.Selected()738 target, selectable := model.Selected()
739 require.True(t, selectable)739 require.True(t, selectable)
740- d.afterRoleEffectsFrozen = func() {740+ d.afterAttachmentEffectsFrozen = func() {
741- d.afterRoleEffectsFrozen = nil741+ d.afterAttachmentEffectsFrozen = nil
742 local.mu.Lock()742 local.mu.Lock()
743 local.name = "replacement"743 local.name = "replacement"
744 local.createdAt++744 local.createdAt++
745 local.mu.Unlock()745 local.mu.Unlock()
746 }746 }
747 747
748- require.Error(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{}, "picker-select"))748+ require.Error(t, d.switchToTargetForAttachment(handler.connectionToken, target, sessionHandoffGuard{}, "picker-select"))
749 require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker lifecycle must not redirect the attachment")749 require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker lifecycle must not redirect the attachment")
750 }750 }
751 751
@@ -759,17 +759,17 @@ func TestRemotePickerRejectsReplacedLocalIncarnation(t *testing.T) {
759 require.True(t, d.registerSessionLocked(local))759 require.True(t, d.registerSessionLocked(local))
760 d.mu.Unlock()760 d.mu.Unlock()
761 761
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)})
763 target, selectable := model.Selected()763 target, selectable := model.Selected()
764 require.True(t, selectable)764 require.True(t, selectable)
765- d.afterRoleEffectsFrozen = func() {765+ d.afterAttachmentEffectsFrozen = func() {
766- d.afterRoleEffectsFrozen = nil766+ d.afterAttachmentEffectsFrozen = nil
767 local.mu.Lock()767 local.mu.Lock()
768 local.incarnation = domain.IncarnationID{2}768 local.incarnation = domain.IncarnationID{2}
769 local.mu.Unlock()769 local.mu.Unlock()
770 }770 }
771 771
772- require.Error(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{}, "picker-select"))772+ require.Error(t, d.switchToTargetForAttachment(handler.connectionToken, target, sessionHandoffGuard{}, "picker-select"))
773 require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker incarnation must not redirect the attachment")773 require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker incarnation must not redirect the attachment")
774 }774 }
775 775
@@ -786,24 +786,24 @@ func TestRemotePickerRejectsReplacedLocalTab(t *testing.T) {
786 require.True(t, d.registerSessionLocked(local))786 require.True(t, d.registerSessionLocked(local))
787 d.mu.Unlock()787 d.mu.Unlock()
788 788
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)})
790 target, selectable := model.Selected()790 target, selectable := model.Selected()
791 require.True(t, selectable)791 require.True(t, selectable)
792 require.Equal(t, domain.TabStableID(second.stableID), target.TabID)792 require.Equal(t, domain.TabStableID(second.stableID), target.TabID)
793- d.afterRoleEffectsFrozen = func() {793+ d.afterAttachmentEffectsFrozen = func() {
794- d.afterRoleEffectsFrozen = nil794+ d.afterAttachmentEffectsFrozen = nil
795 local.mu.Lock()795 local.mu.Lock()
796 local.tabs[0], local.tabs[1] = local.tabs[1], local.tabs[0]796 local.tabs[0], local.tabs[1] = local.tabs[1], local.tabs[0]
797 local.mu.Unlock()797 local.mu.Unlock()
798 }798 }
799 799
800- require.Error(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{}, "picker-select"))800+ require.Error(t, d.switchToTargetForAttachment(handler.connectionToken, target, sessionHandoffGuard{}, "picker-select"))
801 require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker tab must not redirect the attachment")801 require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker tab must not redirect the attachment")
802 }802 }
803 803
804 func TestRemotePickerCanReselectWarmProxyAfterReturningLocal(t *testing.T) {804 func TestRemotePickerCanReselectWarmProxyAfterReturningLocal(t *testing.T) {
805 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())805 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
806- d.attachCoordinator(local, nil, ac, true)806+ d.attachCoordinator(local, ac, true)
807 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}807 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
808 remote := newProxyTestTransport()808 remote := newProxyTestTransport()
809 remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}809 remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
@@ -812,23 +812,23 @@ func TestRemotePickerCanReselectWarmProxyAfterReturningLocal(t *testing.T) {
812 d.remoteDialerFactory = newProxyConstructionFactory(remote)812 d.remoteDialerFactory = newProxyConstructionFactory(remote)
813 d.remoteTransportMode = ports.RemoteTransportUDP813 d.remoteTransportMode = ports.RemoteTransportUDP
814 814
815- localToken := roleEffectForTest(t, local.attachmentToken(ac, ac.transport()))815+ localToken := attachmentEffectForTest(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"))816+ require.NoError(t, d.switchToTargetForAttachment(localToken, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
817 proxy, ok := ac.currentAttachmentSession().(*proxySession)817 proxy, ok := ac.currentAttachmentSession().(*proxySession)
818 require.True(t, ok)818 require.True(t, ok)
819 t.Cleanup(func() { stopProxy(t, proxy) })819 t.Cleanup(func() { stopProxy(t, proxy) })
820 820
821- proxyToken := roleEffectForTest(t, attachmentToken(proxy, ac, ac.transport()))821+ proxyToken := attachmentEffectForTest(t, attachmentToken(proxy, ac, ac.transport()))
822- require.NoError(t, d.switchToTargetForRole(proxyToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select"))822+ require.NoError(t, d.switchToTargetForAttachment(proxyToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select"))
823 require.Same(t, local, ac.currentSession())823 require.Same(t, local, ac.currentSession())
824 824
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})
826 d.publishPicker(local, ac, model, pickerNavigate, moveSourceLocator{})826 d.publishPicker(local, ac, model, pickerNavigate, moveSourceLocator{})
827 target, selectable := model.Selected()827 target, selectable := model.Selected()
828 require.True(t, selectable)828 require.True(t, selectable)
829 require.Empty(t, target.Name, "live proxy rows must route only by their structured remote key")829 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()))830+ localToken = attachmentEffectForTest(t, local.attachmentToken(ac, ac.transport()))
831- require.NoError(t, d.switchToTargetForRole(localToken, target, sessionHandoffGuard{closePicker: true}, "picker-select"))831+ require.NoError(t, d.switchToTargetForAttachment(localToken, target, sessionHandoffGuard{closePicker: true}, "picker-select"))
832 require.Same(t, proxy, ac.currentAttachmentSession())832 require.Same(t, proxy, ac.currentAttachmentSession())
833 require.False(t, ac.overlays.pickerActive())833 require.False(t, ac.overlays.pickerActive())
834 }834 }
@@ -838,8 +838,8 @@ func TestConnectionRoleResolutionStopsAfterUnstableSnapshots(t *testing.T) {
838 client := newProxyTestTransport()838 client := newProxyTestTransport()
839 ac := &attachedClient{}839 ac := &attachedClient{}
840 ac.replaceTransport(client)840 ac.replaceTransport(client)
841- first := &session{sessionCore: sessionCore{client: ac}}841+ first := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{ac: {}}}}
842- second := &session{sessionCore: sessionCore{client: ac}}842+ second := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{ac: {}}}}
843 ac.setSession(first)843 ac.setSession(first)
844 844
845 const unstableSnapshots = 100845 const unstableSnapshots = 100
@@ -856,7 +856,7 @@ func TestConnectionRoleResolutionStopsAfterUnstableSnapshots(t *testing.T) {
856 }856 }
857 }857 }
858 858
859- _, _, ok := d.currentConnectionRole(ac, client)859+ _, _, ok := d.currentAttachmentConnection(ac, client)
860 require.False(t, ok)860 require.False(t, ok)
861 require.Less(t, attempts, unstableSnapshots)861 require.Less(t, attempts, unstableSnapshots)
862 }862 }
@@ -866,14 +866,14 @@ func TestConnectionLoopStopsWhenReceiveErrorCleanupMakesNoProgress(t *testing.T)
866 client := newProxyTestTransport()866 client := newProxyTestTransport()
867 ac := &attachedClient{}867 ac := &attachedClient{}
868 ac.replaceTransport(client)868 ac.replaceTransport(client)
869- local := &session{sessionCore: sessionCore{client: ac}}869+ local := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{ac: {}}}}
870 ac.setSession(local)870 ac.setSession(local)
871 871
872 cleanupAttempts := 0872 cleanupAttempts := 0
873 d.beforeClientGoneDetach = func() {873 d.beforeClientGoneDetach = func() {
874 cleanupAttempts++874 cleanupAttempts++
875 local.mu.Lock()875 local.mu.Lock()
876- local.client = &attachedClient{}876+ local.registerAttachmentLocked(&attachedClient{})
877 local.mu.Unlock()877 local.mu.Unlock()
878 }878 }
879 snapshots := 0879 snapshots := 0
@@ -895,7 +895,7 @@ func TestConnectionLoopRetriesRoleResolutionAcrossHandoff(t *testing.T) {
895 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())895 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
896 client := newProxyTestTransport()896 client := newProxyTestTransport()
897 ac.replaceTransport(client)897 ac.replaceTransport(client)
898- d.attachCoordinator(local, nil, ac, true)898+ d.attachCoordinator(local, ac, true)
899 899
900 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}900 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
901 remote := newProxyTestTransport()901 remote := newProxyTestTransport()
@@ -904,13 +904,13 @@ func TestConnectionLoopRetriesRoleResolutionAcrossHandoff(t *testing.T) {
904 remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}904 remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
905 d.remoteDialerFactory = newProxyConstructionFactory(remote)905 d.remoteDialerFactory = newProxyConstructionFactory(remote)
906 d.remoteTransportMode = ports.RemoteTransportUDP906 d.remoteTransportMode = ports.RemoteTransportUDP
907- token := roleEffectForTest(t, local.attachmentToken(ac, client))907+ token := attachmentEffectForTest(t, local.attachmentToken(ac, client))
908 snapshots := make(chan attachmentSession, 1)908 snapshots := make(chan attachmentSession, 1)
909 transitionErr := make(chan error, 1)909 transitionErr := make(chan error, 1)
910 d.afterConnectionSessionSnapshot = func(snapshot attachmentSession) {910 d.afterConnectionSessionSnapshot = func(snapshot attachmentSession) {
911 d.afterConnectionSessionSnapshot = nil911 d.afterConnectionSessionSnapshot = nil
912 snapshots <- snapshot912 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")
914 }914 }
915 915
916 loopDone := make(chan struct{})916 loopDone := make(chan struct{})
@@ -934,7 +934,7 @@ func TestConnectionLoopCleansCurrentProxyAfterHandoffReceiveError(t *testing.T)
934 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())934 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
935 client := newProxyTestTransport()935 client := newProxyTestTransport()
936 ac.replaceTransport(client)936 ac.replaceTransport(client)
937- d.attachCoordinator(local, nil, ac, true)937+ d.attachCoordinator(local, ac, true)
938 938
939 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}939 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
940 remote := newProxyTestTransport()940 remote := newProxyTestTransport()
@@ -951,8 +951,8 @@ func TestConnectionLoopCleansCurrentProxyAfterHandoffReceiveError(t *testing.T)
951 }()951 }()
952 require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)952 require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
953 953
954- token := roleEffectForTest(t, local.attachmentToken(ac, client))954+ token := attachmentEffectForTest(t, local.attachmentToken(ac, client))
955- require.NoError(t, d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))955+ require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
956 proxy, ok := ac.currentAttachmentSession().(*proxySession)956 proxy, ok := ac.currentAttachmentSession().(*proxySession)
957 require.True(t, ok)957 require.True(t, ok)
958 t.Cleanup(func() { stopProxy(t, proxy) })958 t.Cleanup(func() { stopProxy(t, proxy) })
@@ -980,7 +980,7 @@ func TestConnectionLoopFollowsClientFromProxyBackToLocal(t *testing.T) {
980 }()980 }()
981 require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)981 require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
982 982
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"))
984 require.Same(t, local, ac.currentSession())984 require.Same(t, local, ac.currentSession())
985 client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}}985 client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}}
986 awaitTestCompletion(t, loopDone, "connection loop did not follow the client back to its local session")986 awaitTestCompletion(t, loopDone, "connection loop did not follow the client back to its local session")
@@ -991,7 +991,7 @@ func TestConnectionLoopFollowsClientFromLocalToRemoteProxy(t *testing.T) {
991 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())991 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
992 client := newProxyTestTransport()992 client := newProxyTestTransport()
993 ac.replaceTransport(client)993 ac.replaceTransport(client)
994- d.attachCoordinator(local, nil, ac, true)994+ d.attachCoordinator(local, ac, true)
995 995
996 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}996 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
997 remote := newProxyTestTransport()997 remote := newProxyTestTransport()
@@ -1009,10 +1009,10 @@ func TestConnectionLoopFollowsClientFromLocalToRemoteProxy(t *testing.T) {
1009 require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)1009 require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
1010 1010
1011 token := local.attachmentToken(ac, client)1011 token := local.attachmentToken(ac, client)
1012- effect, admitted := ac.beginRoleEffect(token)1012+ effect, admitted := ac.beginAttachmentEffect(token)
1013 require.True(t, admitted)1013 require.True(t, admitted)
1014 token.effect = effect1014 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"))
1016 proxy, ok := ac.currentAttachmentSession().(*proxySession)1016 proxy, ok := ac.currentAttachmentSession().(*proxySession)
1017 require.True(t, ok)1017 require.True(t, ok)
1018 t.Cleanup(func() { stopProxy(t, proxy) })1018 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
47 if unreachable {47 if unreachable {
48 title = " Type " + key.Display() + " to kill (remote unreachable) "48 title = " Type " + key.Display() + " to kill (remote unreachable) "
49 }49 }
50- d.enterTransitionPrompt(owner, ac, title, "", func(value string, token attachmentRoleToken) error {50+ d.enterTransitionPrompt(owner, ac, title, "", func(value string, token attachmentConnectionToken) error {
51 if value != key.Display() {51 if value != key.Display() {
52 return errProxyKillConfirmation52 return errProxyKillConfirmation
53 }53 }
54 if token.ac != nil {54 if token.ac != nil {
55- if !token.activeCurrent() {55+ if !token.attachmentCurrent() {
56 return errAttachmentTransition56 return errAttachmentTransition
57 }57 }
58 token.effect.bindActionEnd(d, "remote-picker-delete")58 token.effect.bindActionEnd(d, "remote-picker-delete")
M internal/usecase/daemon/proxy_lifecycle.go +12 -10
@@ -39,8 +39,8 @@ func (t *proxyWarmTimer) stop() {
39 // proxyAttachmentTransitionCommitted applies lifecycle policy only after the39 // proxyAttachmentTransitionCommitted applies lifecycle policy only after the
40 // attachment publication has released every architecture lock. Each helper40 // attachment publication has released every architecture lock. Each helper
41 // performs its own exact pointer/client revalidation before changing a timer.41 // performs its own exact pointer/client revalidation before changing a timer.
42-func (d *Daemon) proxyAttachmentTransitionCommitted(source, target attachmentSession, ac *attachedClient, preserveRole bool) {42+func (d *Daemon) proxyAttachmentTransitionCommitted(source, target attachmentSession, ac *attachedClient, preserveAttachment bool) {
43- if d == nil || preserveRole || source == target {43+ if d == nil || preserveAttachment || source == target {
44 return44 return
45 }45 }
46 if proxy, ok := source.(*proxySession); ok {46 if proxy, ok := source.(*proxySession); ok {
@@ -66,7 +66,7 @@ func (d *Daemon) cancelProxyWarmForAttachment(p *proxySession, ac *attachedClien
66 return false66 return false
67 }67 }
68 p.sessionCore.mu.Lock()68 p.sessionCore.mu.Lock()
69- if p.client != ac {69+ if !attachmentRegisteredLocked(attachmentSession(p), ac) {
70 p.sessionCore.mu.Unlock()70 p.sessionCore.mu.Unlock()
71 d.mu.Unlock()71 d.mu.Unlock()
72 return false72 return false
@@ -100,7 +100,7 @@ func (d *Daemon) armProxyWarm(p *proxySession) bool {
100 return false100 return false
101 }101 }
102 p.sessionCore.mu.Lock()102 p.sessionCore.mu.Lock()
103- if p.client != nil {103+ if len(p.attachments) != 0 {
104 p.sessionCore.mu.Unlock()104 p.sessionCore.mu.Unlock()
105 d.mu.Unlock()105 d.mu.Unlock()
106 return false106 return false
@@ -126,7 +126,7 @@ func (d *Daemon) armProxyWarm(p *proxySession) bool {
126 valid := d.sessions[p.id] == p && !d.closing126 valid := d.sessions[p.id] == p && !d.closing
127 if valid {127 if valid {
128 p.sessionCore.mu.Lock()128 p.sessionCore.mu.Lock()
129- valid = p.client == nil129+ valid = len(p.attachments) == 0
130 if valid {130 if valid {
131 p.mu.Lock()131 p.mu.Lock()
132 valid = p.generation == generation && p.warm == old132 valid = p.generation == generation && p.warm == old
@@ -174,7 +174,7 @@ func (d *Daemon) expireWarmProxy(p *proxySession, token *proxyWarmTimer) bool {
174 return false174 return false
175 }175 }
176 p.sessionCore.mu.Lock()176 p.sessionCore.mu.Lock()
177- if p.client != nil {177+ if len(p.attachments) != 0 {
178 p.sessionCore.mu.Unlock()178 p.sessionCore.mu.Unlock()
179 d.mu.Unlock()179 d.mu.Unlock()
180 return false180 return false
@@ -245,12 +245,14 @@ func (d *Daemon) withProxyLocked(p *proxySession, update func() bool) bool {
245 p.sessionCore.mu.Lock()245 p.sessionCore.mu.Lock()
246 p.mu.Lock()246 p.mu.Lock()
247 current := update()247 current := update()
248- ac := p.client248+ attachments := append([]*attachedClient(nil), p.snapshotAttachmentsLocked()...)
249 p.mu.Unlock()249 p.mu.Unlock()
250 p.sessionCore.mu.Unlock()250 p.sessionCore.mu.Unlock()
251 d.mu.Unlock()251 d.mu.Unlock()
252- if current && ac != nil {252+ if current {
253- d.invalidateRender(p, ac, false, "proxy_lifecycle.go")253+ for _, ac := range attachments {
254+ d.invalidateRender(p, ac, false, "proxy_lifecycle.go")
255+ }
254 }256 }
255 return current257 return current
256 }258 }
@@ -317,7 +319,7 @@ func (d *Daemon) markProxyReplaced(p *proxySession, generation uint64, transport
317 p.warm.generation = p.generation319 p.warm.generation = p.generation
318 }320 }
319 }321 }
320- needsWarm = current && p.client == nil && p.warm == nil322+ needsWarm = current && len(p.attachments) == 0 && p.warm == nil
321 return current323 return current
322 })324 })
323 if needsWarm {325 if needsWarm {
M internal/usecase/daemon/proxy_lifecycle_test.go +66 -67
@@ -174,7 +174,7 @@ func TestProxyWarmLifecycleFollowsAttachmentTransitions(t *testing.T) {
174 clock := newProxyLifecycleClock()174 clock := newProxyLifecycleClock()
175 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())175 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
176 d.clock = clock176 d.clock = clock
177- localCoordinator := d.attachCoordinator(local, nil, ac, true)177+ localCoordinator := d.attachCoordinator(local, ac, true)
178 localToken := attachmentToken(local, ac, ac.transport())178 localToken := attachmentToken(local, ac, ac.transport())
179 localToken.lease = localCoordinator.attachmentLease(ac)179 localToken.lease = localCoordinator.attachmentLease(ac)
180 180
@@ -186,7 +186,7 @@ func TestProxyWarmLifecycleFollowsAttachmentTransitions(t *testing.T) {
186 186
187 toProxy, err := d.transitionAttachment(attachmentTransitionRequest{187 toProxy, err := d.transitionAttachment(attachmentTransitionRequest{
188 source: local, target: proxy, next: ac,188 source: local, target: proxy, next: ac,
189- expectedRole: attachmentActive, targetRole: attachmentActive,189+
190 expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,190 expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
191 action: "lifecycle test", ready: true,191 action: "lifecycle test", ready: true,
192 })192 })
@@ -198,7 +198,7 @@ func TestProxyWarmLifecycleFollowsAttachmentTransitions(t *testing.T) {
198 198
199 _, err = d.transitionAttachment(attachmentTransitionRequest{199 _, err = d.transitionAttachment(attachmentTransitionRequest{
200 source: proxy, target: local, next: ac,200 source: proxy, target: local, next: ac,
201- expectedRole: attachmentActive, targetRole: attachmentActive,201+
202 expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published,202 expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published,
203 action: "lifecycle test", ready: true,203 action: "lifecycle test", ready: true,
204 })204 })
@@ -255,7 +255,7 @@ func TestProxyWarmReattachCancelsAndStaleTimerCannotRemoveProxy(t *testing.T) {
255 255
256 ac := &attachedClient{}256 ac := &attachedClient{}
257 proxy.sessionCore.mu.Lock()257 proxy.sessionCore.mu.Lock()
258- proxy.client = ac258+ proxy.sessionCore.registerAttachmentLocked(ac)
259 proxy.sessionCore.mu.Unlock()259 proxy.sessionCore.mu.Unlock()
260 d.cancelProxyWarmForAttachment(proxy, ac)260 d.cancelProxyWarmForAttachment(proxy, ac)
261 awaitTestCompletion(t, firstToken.done, "first proxy warm lifecycle did not complete")261 awaitTestCompletion(t, firstToken.done, "first proxy warm lifecycle did not complete")
@@ -266,7 +266,9 @@ func TestProxyWarmReattachCancelsAndStaleTimerCannotRemoveProxy(t *testing.T) {
266 d.mu.Unlock()266 d.mu.Unlock()
267 267
268 proxy.sessionCore.mu.Lock()268 proxy.sessionCore.mu.Lock()
269- proxy.client = nil269+ for _, attachment := range proxy.sessionCore.snapshotAttachmentsLocked() {
270+ proxy.sessionCore.unregisterAttachmentLocked(attachment)
271+ }
270 proxy.sessionCore.mu.Unlock()272 proxy.sessionCore.mu.Unlock()
271 d.armProxyWarm(proxy)273 d.armProxyWarm(proxy)
272 secondTimer := awaitTestValue(t, clock.timers, "second proxy timer was not registered")274 secondTimer := awaitTestValue(t, clock.timers, "second proxy timer was not registered")
@@ -464,42 +466,43 @@ func TestProxyLinkStateDisplayAndExactGeneration(t *testing.T) {
464 require.Equal(t, proxy.name+" [dead]", proxy.statusSegments(false).session)466 require.Equal(t, proxy.name+" [dead]", proxy.statusSegments(false).session)
465 }467 }
466 468
467-func TestIncomingDirectAttachTerminatesProxiedRemoteAttachment(t *testing.T) {469+func TestIncomingDirectAttachPreservesProxiedRemoteAttachment(t *testing.T) {
468 d := newTestDaemon(t, nil, newProxyLifecycleClock())470 d := newTestDaemon(t, nil, newProxyLifecycleClock())
469 sess := &session{sessionCore: sessionCore{id: "work", name: "work"}}471 sess := &session{sessionCore: sessionCore{id: "work", name: "work"}}
470 oldTransport := newProxyTestTransport()472 oldTransport := newProxyTestTransport()
471 old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), proxied: true}473 old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), proxied: true}
472 old.initOverlays()474 old.initOverlays()
473 old.setSession(sess)475 old.setSession(sess)
474- sess.client = old476+ sess.registerAttachment(old)
475 d.mu.Lock()477 d.mu.Lock()
476 require.True(t, d.registerSessionLocked(sess))478 require.True(t, d.registerSessionLocked(sess))
477 d.mu.Unlock()479 d.mu.Unlock()
478- d.attachCoordinator(sess, nil, old, true)480+ d.attachCoordinator(sess, old, true)
479 481
480 newTransport := newProxyTestTransport()482 newTransport := newProxyTestTransport()
481 next := &attachedClient{tr: newTransport, output: newOutputStateStream()}483 next := &attachedClient{tr: newTransport, output: newOutputStateStream()}
482 next.initOverlays()484 next.initOverlays()
483 result, err := d.transitionAttachment(attachmentTransitionRequest{485 result, err := d.transitionAttachment(attachmentTransitionRequest{
484 target: sess, next: next,486 target: sess, next: next,
485- expectedRole: attachmentDetached, targetRole: attachmentActive,487+
486 expectedTransport: next.transportSnapshot(), ready: true,488 expectedTransport: next.transportSnapshot(), ready: true,
487 })489 })
488 require.NoError(t, err)490 require.NoError(t, err)
489- require.Nil(t, result.displaced.ac, "a proxied attachment must never enter the snatched role")491+ require.NotNil(t, result.published.ac)
490- require.Same(t, next, sess.client)492+ require.Contains(t, sess.snapshotAttachments(), next)
491- require.Nil(t, old.currentAttachmentSession())493+ require.Same(t, sess, old.currentAttachmentSession())
492 494
493 d.deferAttachmentTransitionCleanups(result)495 d.deferAttachmentTransitionCleanups(result)
494 d.waitNotifies()496 d.waitNotifies()
495- frame := awaitTestValue(t, oldTransport.sent, "replacement did not notify old transport")497+ select {
496- detached, err := ports.UnmarshalDetached(frame.Payload)498+ case <-oldTransport.sent:
497- require.NoError(t, err)499+ t.Fatal("publishing an attachment displaced the existing connection")
498- require.Equal(t, ports.ReasonReplaced, detached.Reason)500+ default:
501+ }
499 select {502 select {
500 case <-oldTransport.closed:503 case <-oldTransport.closed:
504+ t.Fatal("publishing an attachment closed the existing connection")
501 default:505 default:
502- t.Fatal("terminal proxied replacement did not close its transport")
503 }506 }
504 }507 }
505 508
@@ -532,7 +535,7 @@ func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T)
532 clock := newProxyLifecycleClock()535 clock := newProxyLifecycleClock()
533 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())536 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
534 d.clock = clock537 d.clock = clock
535- coordinator := d.attachCoordinator(local, nil, ac, true)538+ coordinator := d.attachCoordinator(local, ac, true)
536 localToken := attachmentToken(local, ac, ac.transport())539 localToken := attachmentToken(local, ac, ac.transport())
537 localToken.lease = coordinator.attachmentLease(ac)540 localToken.lease = coordinator.attachmentLease(ac)
538 541
@@ -550,7 +553,7 @@ func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T)
550 553
551 toProxy, err := d.transitionAttachment(attachmentTransitionRequest{554 toProxy, err := d.transitionAttachment(attachmentTransitionRequest{
552 source: local, target: proxy, next: ac,555 source: local, target: proxy, next: ac,
553- expectedRole: attachmentActive, targetRole: attachmentActive,556+
554 expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,557 expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
555 action: "replaced picker regression", ready: true,558 action: "replaced picker regression", ready: true,
556 })559 })
@@ -565,7 +568,7 @@ func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T)
565 568
566 _, err = d.transitionAttachment(attachmentTransitionRequest{569 _, err = d.transitionAttachment(attachmentTransitionRequest{
567 source: proxy, target: local, next: ac,570 source: proxy, target: local, next: ac,
568- expectedRole: attachmentActive, targetRole: attachmentActive,571+
569 expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published,572 expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published,
570 action: "replaced picker regression", ready: true,573 action: "replaced picker regression", ready: true,
571 })574 })
@@ -579,7 +582,7 @@ func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T)
579 require.True(t, ok)582 require.True(t, ok)
580 require.Equal(t, proxyWarmDuration, warmTimer.delay)583 require.Equal(t, proxyWarmDuration, warmTimer.delay)
581 584
582- views, _ := d.pickerViews(local)585+ views, _ := d.pickerViews(local, nil)
583 var expiredView picker.SessionView586 var expiredView picker.SessionView
584 for _, view := range views {587 for _, view := range views {
585 if view.RemoteKey != nil && *view.RemoteKey == key {588 if view.RemoteKey != nil && *view.RemoteKey == key {
@@ -595,7 +598,7 @@ func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T)
595 require.Equal(t, key.Display()+" [expired]", proxy.lifecycleDisplayName())598 require.Equal(t, key.Display()+" [expired]", proxy.lifecycleDisplayName())
596 599
597 d.enterPicker(local, ac)600 d.enterPicker(local, ac)
598- forced := d.newPickerModel(local, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{601+ forced := d.newPickerModel(local, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{
599 Session: key.ID(), RemoteKey: &key,602 Session: key.ID(), RemoteKey: &key,
600 })603 })
601 ac.overlays.pickerMu.Lock()604 ac.overlays.pickerMu.Lock()
@@ -611,10 +614,10 @@ func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T)
611 require.Same(t, local, ac.currentAttachmentSession(), "picker activation reattached the terminal proxy")614 require.Same(t, local, ac.currentAttachmentSession(), "picker activation reattached the terminal proxy")
612 615
613 token := attachmentToken(local, ac, ac.transport())616 token := attachmentToken(local, ac, ac.transport())
614- effect, admitted := ac.beginRoleEffect(token)617+ effect, admitted := ac.beginAttachmentEffect(token)
615 require.True(t, admitted)618 require.True(t, admitted)
616 token.effect = effect619 token.effect = effect
617- err = d.switchToTargetForRole(token, cursor, sessionHandoffGuard{}, "replaced picker regression")620+ err = d.switchToTargetForAttachment(token, cursor, sessionHandoffGuard{}, "replaced picker regression")
618 require.Error(t, err)621 require.Error(t, err)
619 require.Same(t, local, ac.currentAttachmentSession(), "defensive activation reused the terminal proxy")622 require.Same(t, local, ac.currentAttachmentSession(), "defensive activation reused the terminal proxy")
620 623
@@ -1085,7 +1088,7 @@ func TestProxyKillConfirmationRequiresExactRemoteDisplay(t *testing.T) {
1085 require.Equal(t, " Type work@arch to kill ", ac.overlays.prompt.Title())1088 require.Equal(t, " Type work@arch to kill ", ac.overlays.prompt.Title())
1086 submit := ac.overlays.promptTransitionSubmit1089 submit := ac.overlays.promptTransitionSubmit
1087 ac.overlays.promptMu.Unlock()1090 ac.overlays.promptMu.Unlock()
1088- require.Error(t, submit("work", attachmentRoleToken{}))1091+ require.Error(t, submit("work", attachmentConnectionToken{}))
1089 d.mu.Lock()1092 d.mu.Lock()
1090 require.Same(t, proxy, d.sessions[proxy.id])1093 require.Same(t, proxy, d.sessions[proxy.id])
1091 d.mu.Unlock()1094 d.mu.Unlock()
@@ -1199,7 +1202,7 @@ func TestOpenProxySessionSerializesSameKeyAttachConstruction(t *testing.T) {
1199 require.Equal(t, 1, awaitTestValue(t, factory.calls, "leader did not dial"))1202 require.Equal(t, 1, awaitTestValue(t, factory.calls, "leader did not dial"))
1200 _ = requireProxyHello(t, first)1203 _ = requireProxyHello(t, first)
1201 1204
1202- waitCtx, cancelWait := context.WithCancel(context.Background())1205+ waitCtx, cancelWait := context.WithCancel(t.Context())
1203 defer cancelWait()1206 defer cancelWait()
1204 observed := &proxyConstructionWaitContext{Context: waitCtx, observed: make(chan struct{})}1207 observed := &proxyConstructionWaitContext{Context: waitCtx, observed: make(chan struct{})}
1205 waiter := openProxyForTest(d, observed, key)1208 waiter := openProxyForTest(d, observed, key)
@@ -1322,7 +1325,7 @@ func proxyWarmToken(p *proxySession) (*proxyWarmTimer, uint64) {
1322 1325
1323 func setProxyLifecycleClient(p *proxySession, ac *attachedClient) {1326 func setProxyLifecycleClient(p *proxySession, ac *attachedClient) {
1324 p.sessionCore.mu.Lock()1327 p.sessionCore.mu.Lock()
1325- p.client = ac1328+ p.sessionCore.registerAttachmentLocked(ac)
1326 p.sessionCore.mu.Unlock()1329 p.sessionCore.mu.Unlock()
1327 }1330 }
1328 1331
@@ -1354,37 +1357,33 @@ func armWarmTimer(t *testing.T, d *Daemon, p *proxySession, clock *warmHookClock
1354 func TestArmProxyWarmKeepsExpiryPathWhenPublicationFails(t *testing.T) {1357 func TestArmProxyWarmKeepsExpiryPathWhenPublicationFails(t *testing.T) {
1355 tests := []struct {1358 tests := []struct {
1356 name string1359 name string
1357- race func(d *Daemon, p *proxySession)1360+ race func(*Daemon, *proxySession) func()
1358- undo func(d *Daemon, p *proxySession)
1359 }{1361 }{
1360 {1362 {
1361- name: "client attaches during arm",1363+ name: "fresh attachment publishes during arm",
1362- race: func(_ *Daemon, p *proxySession) { setProxyLifecycleClient(p, &attachedClient{}) },1364+ race: func(_ *Daemon, p *proxySession) func() {
1363- undo: func(_ *Daemon, p *proxySession) { setProxyLifecycleClient(p, nil) },1365+ ac := &attachedClient{}
1364- },1366+ p.sessionCore.mu.Lock()
1365- {1367+ p.sessionCore.registerAttachmentLocked(ac)
1366- name: "replacement rekeys the lifecycle generation",1368+ p.sessionCore.mu.Unlock()
1367- race: func(_ *Daemon, p *proxySession) {1369+ return func() {
1368- p.mu.Lock()1370+ p.sessionCore.mu.Lock()
1369- p.expired = true1371+ p.sessionCore.unregisterAttachmentLocked(ac)
1370- p.generation++1372+ p.sessionCore.mu.Unlock()
1371- if p.warm != nil {
1372- p.warm.generation = p.generation
1373 }1373 }
1374- p.mu.Unlock()
1375 },1374 },
1376 },1375 },
1377 {1376 {
1378- name: "daemon starts closing during arm",1377+ name: "daemon shutdown publishes during arm",
1379- race: func(d *Daemon, _ *proxySession) {1378+ race: func(d *Daemon, _ *proxySession) func() {
1380 d.mu.Lock()1379 d.mu.Lock()
1381 d.closing = true1380 d.closing = true
1382 d.mu.Unlock()1381 d.mu.Unlock()
1383- },1382+ return func() {
1384- undo: func(d *Daemon, _ *proxySession) {1383+ d.mu.Lock()
1385- d.mu.Lock()1384+ d.closing = false
1386- d.closing = false1385+ d.mu.Unlock()
1387- d.mu.Unlock()1386+ }
1388 },1387 },
1389 },1388 },
1390 }1389 }
@@ -1393,7 +1392,8 @@ func TestArmProxyWarmKeepsExpiryPathWhenPublicationFails(t *testing.T) {
1393 d, p, clock := newProxyLifecycleFixture(t)1392 d, p, clock := newProxyLifecycleFixture(t)
1394 token, timer := armWarmTimer(t, d, p, clock)1393 token, timer := armWarmTimer(t, d, p, clock)
1395 1394
1396- clock.setHook(func() { tt.race(d, p) })1395+ var undo func()
1396+ clock.setHook(func() { undo = tt.race(d, p) })
1397 require.False(t, d.armProxyWarm(p), "raced re-arm must not publish a replacement")1397 require.False(t, d.armProxyWarm(p), "raced re-arm must not publish a replacement")
1398 awaitTestValue(t, clock.timers, "raced re-arm never reached its clock")1398 awaitTestValue(t, clock.timers, "raced re-arm never reached its clock")
1399 1399
@@ -1403,9 +1403,8 @@ func TestArmProxyWarmKeepsExpiryPathWhenPublicationFails(t *testing.T) {
1403 require.Equal(t, generation, retained.generation, "retained timer no longer owns the lifecycle generation")1403 require.Equal(t, generation, retained.generation, "retained timer no longer owns the lifecycle generation")
1404 require.True(t, proxyRegistered(d, p), "a failed re-arm must not unregister the proxy")1404 require.True(t, proxyRegistered(d, p), "a failed re-arm must not unregister the proxy")
1405 1405
1406- if tt.undo != nil {1406+ require.NotNil(t, undo, "publication race did not run")
1407- tt.undo(d, p)1407+ undo()
1408- }
1409 timer.ch <- time.Time{}1408 timer.ch <- time.Time{}
1410 awaitTestCompletion(t, token.done, "retained warm timer never ran to completion")1409 awaitTestCompletion(t, token.done, "retained warm timer never ran to completion")
1411 require.False(t, proxyRegistered(d, p), "retained warm timer did not expire the dormant proxy")1410 require.False(t, proxyRegistered(d, p), "retained warm timer did not expire the dormant proxy")
@@ -1544,20 +1543,20 @@ func TestExpireWarmProxyIgnoresSupersededToken(t *testing.T) {
1544 require.False(t, proxyRegistered(d, p))1543 require.False(t, proxyRegistered(d, p))
1545 }1544 }
1546 1545
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) {
1548 t.Helper()1547 t.Helper()
1549 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())1548 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
1550 clock := &warmHookClock{timers: make(chan *signalTimer, 4)}1549 clock := &warmHookClock{timers: make(chan *signalTimer, 4)}
1551 d.clock = clock1550 d.clock = clock
1552 clientTransport := &closeTrackingTransport{}1551 clientTransport := &closeTrackingTransport{}
1553 ac.replaceTransport(clientTransport)1552 ac.replaceTransport(clientTransport)
1554- localCoordinator := d.attachCoordinator(local, nil, ac, true)1553+ localCoordinator := d.attachCoordinator(local, ac, true)
1555 localToken := attachmentToken(local, ac, clientTransport)1554 localToken := attachmentToken(local, ac, clientTransport)
1556 localToken.lease = localCoordinator.attachmentLease(ac)1555 localToken.lease = localCoordinator.attachmentLease(ac)
1557 proxy := registerLifecycleProxy(t, d, domain.RemoteSessionKey{Host: "arch", Name: "detach"})1556 proxy := registerLifecycleProxy(t, d, domain.RemoteSessionKey{Host: "arch", Name: "detach"})
1558 transition, err := d.transitionAttachment(attachmentTransitionRequest{1557 transition, err := d.transitionAttachment(attachmentTransitionRequest{
1559 source: local, target: proxy, next: ac,1558 source: local, target: proxy, next: ac,
1560- expectedRole: attachmentActive, targetRole: attachmentActive,1559+
1561 expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,1560 expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
1562 action: "proxy detach fixture", ready: true,1561 action: "proxy detach fixture", ready: true,
1563 })1562 })
@@ -1568,21 +1567,21 @@ func newProxyRoleDetachFixture(t *testing.T) (*Daemon, *proxySession, *attachedC
1568 func TestProxyRoleDetachRetiresExactClientAndOwnsOneWarmTimer(t *testing.T) {1567 func TestProxyRoleDetachRetiresExactClientAndOwnsOneWarmTimer(t *testing.T) {
1569 tests := []struct {1568 tests := []struct {
1570 name string1569 name string
1571- detach func(*Daemon, attachmentRoleToken, ports.Transport) bool1570+ detach func(*Daemon, attachmentConnectionToken, ports.Transport) bool
1572 }{1571 }{
1573 {1572 {
1574 name: "explicit",1573 name: "explicit",
1575- detach: func(d *Daemon, token attachmentRoleToken, _ ports.Transport) bool {1574+ detach: func(d *Daemon, token attachmentConnectionToken, _ ports.Transport) bool {
1576- effect, admitted := token.ac.beginRoleEffect(token)1575+ effect, admitted := token.ac.beginAttachmentEffect(token)
1577 require.True(t, admitted)1576 require.True(t, admitted)
1578 token.effect = effect1577 token.effect = effect
1579- return d.clientGoneForRole(token, true)1578+ return d.clientGoneForAttachment(token, true)
1580 },1579 },
1581 },1580 },
1582 {1581 {
1583 name: "send error",1582 name: "send error",
1584- detach: func(d *Daemon, token attachmentRoleToken, failed ports.Transport) bool {1583+ detach: func(d *Daemon, token attachmentConnectionToken, failed ports.Transport) bool {
1585- d.detachOnRoleSendError(token, failed)1584+ d.detachOnAttachmentSendError(token, failed)
1586 return token.ac.currentAttachmentSession() == nil1585 return token.ac.currentAttachmentSession() == nil
1587 },1586 },
1588 },1587 },
@@ -1620,11 +1619,11 @@ func TestProxyRoleDetachRetiresExactClientAndOwnsOneWarmTimer(t *testing.T) {
1620 func TestProxyRoleDetachRejectsStaleTransportAndRole(t *testing.T) {1619 func TestProxyRoleDetachRejectsStaleTransportAndRole(t *testing.T) {
1621 tests := []struct {1620 tests := []struct {
1622 name string1621 name string
1623- stale func(*attachedClient, *attachmentRoleToken) *closeTrackingTransport1622+ stale func(*attachedClient, *attachmentConnectionToken) *closeTrackingTransport
1624 }{1623 }{
1625 {1624 {
1626 name: "transport",1625 name: "transport",
1627- stale: func(ac *attachedClient, _ *attachmentRoleToken) *closeTrackingTransport {1626+ stale: func(ac *attachedClient, _ *attachmentConnectionToken) *closeTrackingTransport {
1628 current := &closeTrackingTransport{}1627 current := &closeTrackingTransport{}
1629 ac.replaceTransport(current)1628 ac.replaceTransport(current)
1630 return current1629 return current
@@ -1632,8 +1631,8 @@ func TestProxyRoleDetachRejectsStaleTransportAndRole(t *testing.T) {
1632 },1631 },
1633 {1632 {
1634 name: "role generation",1633 name: "role generation",
1635- stale: func(ac *attachedClient, _ *attachmentRoleToken) *closeTrackingTransport {1634+ stale: func(ac *attachedClient, _ *attachmentConnectionToken) *closeTrackingTransport {
1636- ac.roleGeneration.Add(1)1635+ ac.connectionGeneration.Add(1)
1637 return nil1636 return nil
1638 },1637 },
1639 },1638 },
@@ -1642,7 +1641,7 @@ func TestProxyRoleDetachRejectsStaleTransportAndRole(t *testing.T) {
1642 t.Run(tt.name, func(t *testing.T) {1641 t.Run(tt.name, func(t *testing.T) {
1643 d, proxy, ac, failed, clock, token, _ := newProxyRoleDetachFixture(t)1642 d, proxy, ac, failed, clock, token, _ := newProxyRoleDetachFixture(t)
1644 current := tt.stale(ac, &token)1643 current := tt.stale(ac, &token)
1645- d.detachOnRoleSendError(token, failed)1644+ d.detachOnAttachmentSendError(token, failed)
1646 require.Same(t, proxy, ac.currentAttachmentSession())1645 require.Same(t, proxy, ac.currentAttachmentSession())
1647 if current != nil {1646 if current != nil {
1648 require.False(t, current.Closed(), "stale transport error closed the rebound client")1647 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
651 }651 }
652 }652 }
653 653
654-// handleProxySideEffect forwards a stateless remote effect to the exact local654+// handleProxySideEffect fans out a stateless remote effect to every attachment
655-// attachment that currently owns this proxy. Role admission and transport655+// currently owning this proxy. Each attachment gets an independent capability,
656-// incarnation checks make a stale handoff a harmless drop rather than a send656+// effect ticket, output snapshot, and exact transport-incarnation fence.
657-// to the client's next session.
658 func (d *Daemon) handleProxySideEffect(p *proxySession, generation uint64, out ports.Output) error {657 func (d *Daemon) handleProxySideEffect(p *proxySession, generation uint64, out ports.Output) error {
659 if p == nil || !p.currentLinkGeneration(generation) {658 if p == nil || !p.currentLinkGeneration(generation) {
660 return nil659 return nil
661 }660 }
662- p.sessionCore.mu.Lock()661+ var sendErr error
663- ac := p.client662+ for _, ac := range snapshotAttachmentSession(p) {
664- p.sessionCore.mu.Unlock()663+ if !p.currentLinkGeneration(generation) {
665- if ac == nil || ac.output == nil {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) {
666 return nil675 return nil
667 }676 }
668 expected := ac.transportSnapshot()677 expected := ac.transportSnapshot()
@@ -670,19 +679,22 @@ func (d *Daemon) handleProxySideEffect(p *proxySession, generation uint64, out p
670 return nil679 return nil
671 }680 }
672 token := attachmentToken(p, ac, expected.transport)681 token := attachmentToken(p, ac, expected.transport)
673- if token.role != attachmentActive || token.sess != p {682+ if token.sess != p || !token.current() {
674 return nil683 return nil
675 }684 }
676- ticket, admitted := ac.beginRoleEffect(token)685+ ticket, admitted := ac.beginAttachmentEffect(token)
677 if !admitted {686 if !admitted {
678 return nil687 return nil
679 }688 }
680 defer ticket.End()689 defer ticket.End()
681- if !p.currentLinkGeneration(generation) {690+ if !p.currentLinkGeneration(generation) || !token.current() {
682 return nil691 return nil
683 }692 }
684 frame := ac.output.sideEffect(out.Data, ac.echoAck.Load())693 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 {
686 if errors.Is(err, errAttachmentTransition) {698 if errors.Is(err, errAttachmentTransition) {
687 return nil699 return nil
688 }700 }
M internal/usecase/daemon/proxy_output.go +3 -4
@@ -91,10 +91,9 @@ func (d *Daemon) handleProxyScreenUpdate(p *proxySession, generation uint64, upd
91 return nil91 return nil
92 }92 }
93 93
94- p.sessionCore.mu.Lock()94+ for _, ac := range snapshotAttachmentSession(p) {
95- ac := p.client95+ d.invalidateRender(p, ac, false, "proxy_output.go")
96- p.sessionCore.mu.Unlock()96+ }
97- d.invalidateRender(p, ac, false, "proxy_output.go")
98 return nil97 return nil
99 }98 }
100 99
M internal/usecase/daemon/proxy_output_test.go +65 -2
@@ -987,6 +987,69 @@ func TestProxySideEffectForwardsOSC52ToCurrentThinClient(t *testing.T) {
987 }987 }
988 }988 }
989 989
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+
990 func TestProxySideEffectRejectsStateBearingAndMalformedMsgOutput(t *testing.T) {1053 func TestProxySideEffectRejectsStateBearingAndMalformedMsgOutput(t *testing.T) {
991 tests := []struct {1054 tests := []struct {
992 name string1055 name string
@@ -1031,10 +1094,10 @@ func TestProxySideEffectDropsStaleGenerationAndRole(t *testing.T) {
1031 }1094 }
1032 1095
1033 token := attachmentToken(proxy, ac, ac.transport())1096 token := attachmentToken(proxy, ac, ac.transport())
1034- ticket, admitted := ac.beginRoleEffect(token)1097+ ticket, admitted := ac.beginAttachmentEffect(token)
1035 require.True(t, admitted)1098 require.True(t, admitted)
1036 ticket.End()1099 ticket.End()
1037- ac.roleGeneration.Add(1)1100+ ac.connectionGeneration.Add(1)
1038 result, err = d.handleLinkFrame(proxy, 1, frame)1101 result, err = d.handleLinkFrame(proxy, 1, frame)
1039 require.Equal(t, proxyLinkResume, result)1102 require.Equal(t, proxyLinkResume, result)
1040 require.NoError(t, err)1103 require.NoError(t, err)
M internal/usecase/daemon/proxy_render.go +4 -4
@@ -17,11 +17,11 @@ type proxyCapture struct {
17 cursor capturedCursorInputs17 cursor capturedCursorInputs
18 }18 }
19 19
20-// capturePrimary snapshots the remote VT while holding only proxy.mu. The20+// captureRenderState snapshots the remote VT while holding only proxy.mu. The
21 // snapshot is then composed by the ordinary local frame pipeline, which owns21 // snapshot is then composed by the ordinary local frame pipeline, which owns
22 // the bars, overlays, cursor, and renderer shadow. Remote ANSI is therefore22 // the bars, overlays, cursor, and renderer shadow. Remote ANSI is therefore
23 // never handed to a renderer.23 // 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) {
25 if p == nil || ac == nil {25 if p == nil || ac == nil {
26 return nil, false26 return nil, false
27 }27 }
@@ -35,7 +35,7 @@ func (p *proxySession) capturePrimary(ac *attachedClient, request primaryCapture
35 capture := &ac.proxyCapture35 capture := &ac.proxyCapture
36 damage := screen.CaptureDamage()36 damage := screen.CaptureDamage()
37 if capture.screen != screen {37 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.
39 capture.frame = renderer.Frame{}39 capture.frame = renderer.Frame{}
40 }40 }
41 if capture.screen != screen || capture.generation != damage.Generation || capture.frame.Validate() != nil {41 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) {
152 p.screen.ResizePlaceholder(content)152 p.screen.ResizePlaceholder(content)
153 p.contentSize = content153 p.contentSize = content
154 // A resize invalidates the local replay chain, but the last applied state154 // 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.
156 p.screenReady = false156 p.screenReady = false
157 p.resetRequested = false157 p.resetRequested = false
158 p.screen.stateNum = 0158 p.screen.stateNum = 0
M internal/usecase/daemon/proxy_render_test.go +13 -13
@@ -25,7 +25,7 @@ func TestProxyCaptureRetainsOwnedFrameAcrossSafeDamage(t *testing.T) {
25 proxy.mu.Unlock()25 proxy.mu.Unlock()
26 26
27 ac := &attachedClient{}27 ac := &attachedClient{}
28- state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{})28+ state, ok := proxy.captureRenderState(ac, renderCaptureRequest{})
29 require.True(t, ok)29 require.True(t, ok)
30 require.Equal(t, "abcdefghijkl", proxyFrameText(state.panes[0].frame))30 require.Equal(t, "abcdefghijkl", proxyFrameText(state.panes[0].frame))
31 commitDamageReceipts(state.receipts)31 commitDamageReceipts(state.receipts)
@@ -43,7 +43,7 @@ func TestProxyCaptureRetainsOwnedFrameAcrossSafeDamage(t *testing.T) {
43 proxy.mu.Unlock()43 proxy.mu.Unlock()
44 44
45 cellsBefore := &ac.proxyCapture.frame.Cells[0]45 cellsBefore := &ac.proxyCapture.frame.Cells[0]
46- state, ok = proxy.capturePrimary(ac, primaryCaptureRequest{})46+ state, ok = proxy.captureRenderState(ac, renderCaptureRequest{})
47 require.True(t, ok)47 require.True(t, ok)
48 require.Equal(t, "abcdeZghijkl", proxyFrameText(state.panes[0].frame))48 require.Equal(t, "abcdeZghijkl", proxyFrameText(state.panes[0].frame))
49 require.Equal(t, cellsBefore, &ac.proxyCapture.frame.Cells[0], "safe span damage must update the retained frame")49 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) {
59 changed := applyTestScreenText(proxy, 1, 2, "remote")59 changed := applyTestScreenText(proxy, 1, 2, "remote")
60 require.True(t, changed)60 require.True(t, changed)
61 61
62- state, ok := proxy.capturePrimary(&attachedClient{}, primaryCaptureRequest{62+ state, ok := proxy.captureRenderState(&attachedClient{}, renderCaptureRequest{
63 bars: barState{status: proxy.statusSegments(false)},63 bars: barState{status: proxy.statusSegments(false)},
64 reset: true,64 reset: true,
65 })65 })
@@ -91,7 +91,7 @@ func TestProxyRenderPaintsAfterLocalToRemoteHandoff(t *testing.T) {
91 ac := &attachedClient{tr: transport, output: newOutputStateStream(), size: domain.Size{Cols: 16, Rows: 6}}91 ac := &attachedClient{tr: transport, output: newOutputStateStream(), size: domain.Size{Cols: 16, Rows: 6}}
92 ac.setSession(proxy)92 ac.setSession(proxy)
93 proxy.sessionCore.mu.Lock()93 proxy.sessionCore.mu.Lock()
94- proxy.client = ac94+ proxy.sessionCore.registerAttachmentLocked(ac)
95 proxy.sessionCore.mu.Unlock()95 proxy.sessionCore.mu.Unlock()
96 96
97 d.paint(proxy, ac, true, nil)97 d.paint(proxy, ac, true, nil)
@@ -101,7 +101,7 @@ func TestProxyRenderPaintsAfterLocalToRemoteHandoff(t *testing.T) {
101 101
102 func TestProxyRenderTransitionsPaintBothLocalAndRemoteSessions(t *testing.T) {102 func TestProxyRenderTransitionsPaintBothLocalAndRemoteSessions(t *testing.T) {
103 d, local, ac, sent := newManualSessionWithPTYs(t, newQuietPTY())103 d, local, ac, sent := newManualSessionWithPTYs(t, newQuietPTY())
104- localCoordinator := d.attachCoordinator(local, nil, ac, true)104+ localCoordinator := d.attachCoordinator(local, ac, true)
105 localToken := attachmentToken(local, ac, ac.transport())105 localToken := attachmentToken(local, ac, ac.transport())
106 localToken.lease = localCoordinator.attachmentLease(ac)106 localToken.lease = localCoordinator.attachmentLease(ac)
107 107
@@ -118,7 +118,7 @@ func TestProxyRenderTransitionsPaintBothLocalAndRemoteSessions(t *testing.T) {
118 118
119 toProxy, err := d.transitionAttachment(attachmentTransitionRequest{119 toProxy, err := d.transitionAttachment(attachmentTransitionRequest{
120 source: local, target: proxy, next: ac,120 source: local, target: proxy, next: ac,
121- expectedRole: attachmentActive, targetRole: attachmentActive,121+
122 expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,122 expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
123 action: "test transition", ready: true,123 action: "test transition", ready: true,
124 })124 })
@@ -129,7 +129,7 @@ func TestProxyRenderTransitionsPaintBothLocalAndRemoteSessions(t *testing.T) {
129 129
130 toLocal, err := d.transitionAttachment(attachmentTransitionRequest{130 toLocal, err := d.transitionAttachment(attachmentTransitionRequest{
131 source: proxy, target: local, next: ac,131 source: proxy, target: local, next: ac,
132- expectedRole: attachmentActive, targetRole: attachmentActive,132+
133 expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published,133 expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published,
134 action: "test transition", ready: true,134 action: "test transition", ready: true,
135 })135 })
@@ -269,7 +269,7 @@ func TestProxyResizeUsesReducedRemoteGeometryOnce(t *testing.T) {
269 default:269 default:
270 }270 }
271 271
272- state, ok := proxy.capturePrimary(&attachedClient{}, primaryCaptureRequest{reset: true})272+ state, ok := proxy.captureRenderState(&attachedClient{}, renderCaptureRequest{reset: true})
273 require.True(t, ok)273 require.True(t, ok)
274 composed := composeFrame(*state, composeCacheInput{})274 composed := composeFrame(*state, composeCacheInput{})
275 require.Equal(t, 10, composed.frame.Height, "local viewport remains full-sized")275 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,
296 ac := &attachedClient{tr: clientTr, output: newOutputStateStream(), size: domain.Size{Cols: 16, Rows: 6}}296 ac := &attachedClient{tr: clientTr, output: newOutputStateStream(), size: domain.Size{Cols: 16, Rows: 6}}
297 ac.setSession(proxy)297 ac.setSession(proxy)
298 proxy.sessionCore.mu.Lock()298 proxy.sessionCore.mu.Lock()
299- proxy.client = ac299+ proxy.sessionCore.registerAttachmentLocked(ac)
300 proxy.sessionCore.mu.Unlock()300 proxy.sessionCore.mu.Unlock()
301 return proxy, ac301 return proxy, ac
302 }302 }
@@ -318,7 +318,7 @@ func TestProxyStructuredSendFailureRetainsProxyDamage(t *testing.T) {
318 require.True(t, applyTestScreenText(proxy, 0, 0, "pending"))318 require.True(t, applyTestScreenText(proxy, 0, 0, "pending"))
319 319
320 ac.sendMu.Lock()320 ac.sendMu.Lock()
321- state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: true})321+ state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: true})
322 require.True(t, ok)322 require.True(t, ok)
323 composed := composeFrame(*state, composeCacheInput{})323 composed := composeFrame(*state, composeCacheInput{})
324 require.True(t, d.emitFrame(proxy, ac, state, composed))324 require.True(t, d.emitFrame(proxy, ac, state, composed))
@@ -373,7 +373,7 @@ func TestProxyPrepareFailureRepaintsWithoutLocalSession(t *testing.T) {
373 proxy, ac := newAttachedProxyFixture(t, d, clientTr, newProxyTestTransport())373 proxy, ac := newAttachedProxyFixture(t, d, clientTr, newProxyTestTransport())
374 374
375 ac.sendMu.Lock() // emitFrame releases the transaction lock.375 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})
377 require.True(t, ok)377 require.True(t, ok)
378 composed := composeFrame(*state, composeCacheInput{})378 composed := composeFrame(*state, composeCacheInput{})
379 // A malformed frame is what makes outputStateStream.prepare fail.379 // A malformed frame is what makes outputStateStream.prepare fail.
@@ -394,9 +394,9 @@ func TestProxySendErrorDetachesProxyAttachment(t *testing.T) {
394 d.paint(proxy, ac, true, nil)394 d.paint(proxy, ac, true, nil)
395 395
396 proxy.sessionCore.mu.Lock()396 proxy.sessionCore.mu.Lock()
397- client := proxy.client397+ attachments := proxy.sessionCore.snapshotAttachmentsLocked()
398 proxy.sessionCore.mu.Unlock()398 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")
400 require.Nil(t, ac.currentAttachmentSession(), "the client must no longer point at the proxy")400 require.Nil(t, ac.currentAttachmentSession(), "the client must no longer point at the proxy")
401 }401 }
402 402
M internal/usecase/daemon/proxy_session.go +4 -7
@@ -122,10 +122,7 @@ func (d *Daemon) applyProxySessionMeta(p *proxySession, generation uint64, meta
122 p.mu.Unlock()122 p.mu.Unlock()
123 123
124 d.pokeAttentionTicker()124 d.pokeAttentionTicker()
125- p.sessionCore.mu.Lock()125+ for _, ac := range snapshotAttachmentSession(p) {
126- ac := p.client
127- p.sessionCore.mu.Unlock()
128- if ac != nil {
129 d.invalidateRender(p, ac, false, "proxy_session.go")126 d.invalidateRender(p, ac, false, "proxy_session.go")
130 }127 }
131 return true, nil128 return true, nil
@@ -151,12 +148,12 @@ func (p *proxySession) snapshotView(opts viewOptions) sessionView {
151 expired: expired,148 expired: expired,
152 createdAt: p.createdAt,149 createdAt: p.createdAt,
153 mruAt: p.mruAt.Load(),150 mruAt: p.mruAt.Load(),
154- active: int(meta.Active),151+ defaultTab: 0,
155 tabCount: len(meta.Tabs),152 tabCount: len(meta.Tabs),
156 cannotAcceptMoves: true,153 cannotAcceptMoves: true,
157 }154 }
158 p.sessionCore.mu.Lock()155 p.sessionCore.mu.Lock()
159- view.attached = p.client != nil156+ view.attached = len(p.attachments) != 0
160 p.sessionCore.mu.Unlock()157 p.sessionCore.mu.Unlock()
161 if opts.tabDetails {158 if opts.tabDetails {
162 view.tabs = make([]tabView, len(meta.Tabs))159 view.tabs = make([]tabView, len(meta.Tabs))
@@ -194,7 +191,7 @@ func (p *proxySession) statusSegments(_ bool) statusSnapshot {
194 return snapshot191 return snapshot
195 }192 }
196 193
197-func (p *proxySession) activateTargetLocked(tabIndex int) bool {194+func (p *proxySession) validTargetTabLocked(tabIndex int) bool {
198 if tabIndex < 0 {195 if tabIndex < 0 {
199 return true196 return true
200 }197 }
M internal/usecase/daemon/remote_picker_test.go +22 -56
@@ -83,7 +83,7 @@ func TestRemotePickerMerge(t *testing.T) {
83 d.remoteCatalog.status["learned"] = remoteHostFresh83 d.remoteCatalog.status["learned"] = remoteHostFresh
84 d.remoteCatalog.mu.Unlock()84 d.remoteCatalog.mu.Unlock()
85 85
86- views, _ := d.pickerViews(local)86+ views, _ := d.pickerViews(local, nil)
87 87
88 require.Equal(t, []string{"local", "work@pinned", "alpha@pinned", "alpha@learned", "stopped"}, pickerViewNames(views))88 require.Equal(t, []string{"local", "work@pinned", "alpha@pinned", "alpha@learned", "stopped"}, pickerViewNames(views))
89 require.False(t, views[0].Stopped)89 require.False(t, views[0].Stopped)
@@ -113,7 +113,7 @@ func TestRemotePickerDedupeUsesExactKeyAndKeepsLiveID(t *testing.T) {
113 },113 },
114 }})114 }})
115 115
116- views, _ := d.pickerViews(nil)116+ views, _ := d.pickerViews(nil, nil)
117 117
118 require.Len(t, views, 1)118 require.Len(t, views, 1)
119 require.Equal(t, key.ID(), views[0].ID)119 require.Equal(t, key.ID(), views[0].ID)
@@ -132,7 +132,7 @@ func TestRemotePickerSnapshotsDoNotRetainProxyLock(t *testing.T) {
132 }132 }
133 d.sessions[key.ID()] = proxy133 d.sessions[key.ID()] = proxy
134 134
135- views, _ := d.pickerViews(nil)135+ views, _ := d.pickerViews(nil, nil)
136 136
137 require.Len(t, views, 1)137 require.Len(t, views, 1)
138 require.True(t, proxy.mu.TryLock(), "picker construction must release the proxy leaf lock")138 require.True(t, proxy.mu.TryLock(), "picker construction must release the proxy leaf lock")
@@ -162,7 +162,7 @@ func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
162 d.remoteCatalog.status["arch"] = test.status162 d.remoteCatalog.status["arch"] = test.status
163 d.remoteCatalog.mu.Unlock()163 d.remoteCatalog.mu.Unlock()
164 164
165- views, _ := d.pickerViews(nil)165+ views, _ := d.pickerViews(nil, nil)
166 166
167 require.Len(t, views, 1)167 require.Len(t, views, 1)
168 require.Equal(t, test.want, views[0].RemoteAvailability)168 require.Equal(t, test.want, views[0].RemoteAvailability)
@@ -182,7 +182,7 @@ func TestRemotePickerStaleDetailUsesLastSuccessfulFetch(t *testing.T) {
182 d.remoteCatalog.failure["arch"] = failedAt182 d.remoteCatalog.failure["arch"] = failedAt
183 d.remoteCatalog.mu.Unlock()183 d.remoteCatalog.mu.Unlock()
184 184
185- views, _ := d.pickerViews(nil)185+ views, _ := d.pickerViews(nil, nil)
186 186
187 require.Len(t, views, 1)187 require.Len(t, views, 1)
188 require.Equal(t, "stale since "+fetchedAt.Format(time.RFC3339), views[0].RemoteDetail)188 require.Equal(t, "stale since "+fetchedAt.Format(time.RFC3339), views[0].RemoteDetail)
@@ -206,7 +206,7 @@ func TestRemotePickerNoCacheHostFailuresRemainVisible(t *testing.T) {
206 d.remoteCatalog.failure["arch"] = time.Unix(50, 0)206 d.remoteCatalog.failure["arch"] = time.Unix(50, 0)
207 d.remoteCatalog.mu.Unlock()207 d.remoteCatalog.mu.Unlock()
208 208
209- views, _ := d.pickerViews(nil)209+ views, _ := d.pickerViews(nil, nil)
210 210
211 require.Len(t, views, 1)211 require.Len(t, views, 1)
212 require.Equal(t, "arch", views[0].Name)212 require.Equal(t, "arch", views[0].Name)
@@ -233,7 +233,7 @@ func TestRemotePickerGroupedSortTreatsProxiesAsLiveSessions(t *testing.T) {
233 d.sessions[proxy.id] = proxy233 d.sessions[proxy.id] = proxy
234 d.sessions[named.id] = named234 d.sessions[named.id] = named
235 235
236- views, _ := d.pickerViews(nil)236+ views, _ := d.pickerViews(nil, nil)
237 237
238 require.Equal(t, []string{"named", "work@arch", "1"}, pickerViewNames(views))238 require.Equal(t, []string{"named", "work@arch", "1"}, pickerViewNames(views))
239 }239 }
@@ -539,7 +539,7 @@ func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID) (
539 ac.initOverlays()539 ac.initOverlays()
540 tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})540 tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
541 tb.stableID = string(id) + "-tab"541 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}}
543 publishTiledPaneOwners(sess, tb)543 publishTiledPaneOwners(sess, tb)
544 ac.setSession(sess)544 ac.setSession(sess)
545 d.sessions[id] = sess545 d.sessions[id] = sess
@@ -555,7 +555,7 @@ func TestRemoteRefreshUpdatesAllOpenPickersPreservingSelection(t *testing.T) {
555 555
556 for _, owner := range []*attachedClient{first, second} {556 for _, owner := range []*attachedClient{first, second} {
557 owner.overlays.pickerMu.Lock()557 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"})
559 owner.overlays.pickerIntent = pickerNavigate559 owner.overlays.pickerIntent = pickerNavigate
560 owner.overlays.pickerGeneration++560 owner.overlays.pickerGeneration++
561 instance := remotePickerInstance{ac: owner, generation: owner.overlays.pickerGeneration, model: owner.overlays.picker}561 instance := remotePickerInstance{ac: owner, generation: owner.overlays.pickerGeneration, model: owner.overlays.picker}
@@ -600,7 +600,7 @@ func TestRemotePickerTeardownLifecycle(t *testing.T) {
600 d := newRemotePickerDaemon(nil)600 d := newRemotePickerDaemon(nil)
601 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")601 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
602 ac.resumeCapable = test.resumeCapable602 ac.resumeCapable = test.resumeCapable
603- model := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})603+ model := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
604 d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})604 d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
605 ac.overlays.pickerMu.Lock()605 ac.overlays.pickerMu.Lock()
606 generation := ac.overlays.pickerGeneration606 generation := ac.overlays.pickerGeneration
@@ -639,7 +639,7 @@ func TestParkedPickerExpiryClosesCapturedGenerationAndCancelsRefresh(t *testing.
639 d.clock = clock639 d.clock = clock
640 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")640 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
641 ac.resumeCapable = true641 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{})
643 request := receiveRemotePicker(t, catalog.requests, "catalog request")643 request := receiveRemotePicker(t, catalog.requests, "catalog request")
644 644
645 require.True(t, d.parkAttachment(sess, ac))645 require.True(t, d.parkAttachment(sess, ac))
@@ -659,7 +659,7 @@ func TestParkedPickerRetirementClosesCapturedGenerationOnly(t *testing.T) {
659 d := newRemotePickerDaemon(nil)659 d := newRemotePickerDaemon(nil)
660 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")660 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
661 ac.resumeCapable = true661 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{})
663 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})663 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
664 ac.overlays.pickerMu.Lock()664 ac.overlays.pickerMu.Lock()
665 refreshed := ac.overlays.picker665 refreshed := ac.overlays.picker
@@ -686,10 +686,10 @@ func TestParkedPickerTokenReplacementRetiresPreviousGeneration(t *testing.T) {
686 firstSession, first, _ := addRemoteRefreshPickerOwner(t, d, "first")686 firstSession, first, _ := addRemoteRefreshPickerOwner(t, d, "first")
687 secondSession, second, _ := addRemoteRefreshPickerOwner(t, d, "second")687 secondSession, second, _ := addRemoteRefreshPickerOwner(t, d, "second")
688 first.resumeCapable, second.resumeCapable = true, true688 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{})
690 require.True(t, d.parkAttachment(firstSession, first))690 require.True(t, d.parkAttachment(firstSession, first))
691 token := first.resumeToken691 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{})
693 second.resumeToken = token693 second.resumeToken = token
694 require.True(t, d.parkAttachment(secondSession, second))694 require.True(t, d.parkAttachment(secondSession, second))
695 695
@@ -706,7 +706,7 @@ func TestParkedPickerResumePreservesGeneration(t *testing.T) {
706 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")706 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
707 ac.resumeCapable = true707 ac.resumeCapable = true
708 ac.clientID = [16]byte{1, 2, 3, 4}708 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{})
710 ac.overlays.pickerMu.Lock()710 ac.overlays.pickerMu.Lock()
711 model, generation := ac.overlays.picker, ac.overlays.pickerGeneration711 model, generation := ac.overlays.picker, ac.overlays.pickerGeneration
712 ac.overlays.pickerMu.Unlock()712 ac.overlays.pickerMu.Unlock()
@@ -728,7 +728,7 @@ func TestStaleParkedPickerExpiryPreservesNewGeneration(t *testing.T) {
728 d := newRemotePickerDaemon(nil)728 d := newRemotePickerDaemon(nil)
729 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")729 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
730 ac.resumeCapable = true730 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{})
732 require.True(t, d.parkAttachment(sess, ac))732 require.True(t, d.parkAttachment(sess, ac))
733 token := ac.resumeToken733 token := ac.resumeToken
734 d.mu.Lock()734 d.mu.Lock()
@@ -736,7 +736,7 @@ func TestStaleParkedPickerExpiryPreservesNewGeneration(t *testing.T) {
736 d.mu.Unlock()736 d.mu.Unlock()
737 require.NotNil(t, parked)737 require.NotNil(t, parked)
738 738
739- newer := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})739+ newer := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
740 d.publishPicker(sess, ac, newer, pickerNavigate, moveSourceLocator{})740 d.publishPicker(sess, ac, newer, pickerNavigate, moveSourceLocator{})
741 d.expireParked(token, parked)741 d.expireParked(token, parked)
742 ac.overlays.pickerMu.Lock()742 ac.overlays.pickerMu.Lock()
@@ -752,7 +752,7 @@ func TestRemoteRefreshPreservesRemoteCursorAcrossCacheToProxyUpgrade(t *testing.
752 Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: key.Name, State: "running"}},752 Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: key.Name, State: "running"}},
753 }})753 }})
754 owner.overlays.pickerMu.Lock()754 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})
756 owner.overlays.pickerIntent = pickerNavigate756 owner.overlays.pickerIntent = pickerNavigate
757 before, ok := owner.overlays.picker.Cursor()757 before, ok := owner.overlays.picker.Cursor()
758 _, beforeSelectable := owner.overlays.picker.Selected()758 _, beforeSelectable := owner.overlays.picker.Selected()
@@ -861,44 +861,10 @@ func TestRemoteRefreshCannotStartAfterLastPickerCloseWins(t *testing.T) {
861 }861 }
862 }862 }
863 863
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-
898 func TestRemotePickerStaleAfterCloseCannotRemoveReopenedPicker(t *testing.T) {864 func TestRemotePickerStaleAfterCloseCannotRemoveReopenedPicker(t *testing.T) {
899 d := newRemotePickerDaemon(nil)865 d := newRemotePickerDaemon(nil)
900 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")866 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{})
902 d.publishPicker(sess, ac, first, pickerNavigate, moveSourceLocator{})868 d.publishPicker(sess, ac, first, pickerNavigate, moveSourceLocator{})
903 869
904 staleClose := d.pickerListInputState(ac)870 staleClose := d.pickerListInputState(ac)
@@ -906,7 +872,7 @@ func TestRemotePickerStaleAfterCloseCannotRemoveReopenedPicker(t *testing.T) {
906 staleClose.closeLocked()872 staleClose.closeLocked()
907 ac.overlays.pickerMu.Unlock()873 ac.overlays.pickerMu.Unlock()
908 874
909- reopened := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})875+ reopened := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
910 d.publishPicker(sess, ac, reopened, pickerNavigate, moveSourceLocator{})876 d.publishPicker(sess, ac, reopened, pickerNavigate, moveSourceLocator{})
911 staleClose.afterClose()877 staleClose.afterClose()
912 878
@@ -922,7 +888,7 @@ func TestRemotePickerStaleAfterCloseCannotRemoveReopenedPicker(t *testing.T) {
922 func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {888 func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {
923 d := newRemotePickerDaemon(nil)889 d := newRemotePickerDaemon(nil)
924 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")890 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{})
926 d.publishPicker(sess, ac, first, pickerNavigate, moveSourceLocator{})892 d.publishPicker(sess, ac, first, pickerNavigate, moveSourceLocator{})
927 893
928 rebuildReached := make(chan struct{})894 rebuildReached := make(chan struct{})
@@ -940,7 +906,7 @@ func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {
940 receiveRemotePickerClose(t, rebuildReached, "picker rebuild")906 receiveRemotePickerClose(t, rebuildReached, "picker rebuild")
941 907
942 d.closePicker(ac)908 d.closePicker(ac)
943- reopened := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})909+ reopened := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
944 d.publishPicker(sess, ac, reopened, pickerNavigate, moveSourceLocator{})910 d.publishPicker(sess, ac, reopened, pickerNavigate, moveSourceLocator{})
945 close(allowPublication)911 close(allowPublication)
946 require.NoError(t, receiveRemotePicker(t, publicationWait, "picker publication release"))912 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 {
26 return false26 return false
27 }27 }
28 sess.mu.Lock()28 sess.mu.Lock()
29- active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb29+ active := sess.attachmentViewsTabLocked(tb)
30- attached := sess.client != nil30+ attached := len(sess.attachments) != 0
31 sess.mu.Unlock()31 sess.mu.Unlock()
32 32
33 // The normal attached render path needs no cross-session picker lookup.33 // The normal attached render path needs no cross-session picker lookup.
@@ -63,17 +63,16 @@ func (d *Daemon) tabIsPickerPreview(tb *tab) bool {
63 sessions := localSessionsSnapshot(d.sessions)63 sessions := localSessionsSnapshot(d.sessions)
64 d.mu.Unlock()64 d.mu.Unlock()
65 for _, sess := range sessions {65 for _, sess := range sessions {
66- sess.mu.Lock()66+ for _, ac := range sess.snapshotAttachments() {
67- ac := sess.client67+ if ac == nil || ac.overlays == nil {
68- sess.mu.Unlock()68+ continue
69- if ac == nil || ac.overlays == nil {69+ }
70- continue70+ ac.overlays.pickerMu.Lock()
71- }71+ preview := ac.overlays.pickerPreview == tb
72- ac.overlays.pickerMu.Lock()72+ ac.overlays.pickerMu.Unlock()
73- preview := ac.overlays.pickerPreview == tb73+ if preview {
74- ac.overlays.pickerMu.Unlock()74+ return true
75- if preview {75+ }
76- return true
77 }76 }
78 }77 }
79 return false78 return false
@@ -259,9 +258,9 @@ func (d *Daemon) observeRuntime(kind ports.RuntimeMarkKind, bytes uint64, valid
259 // paint owns attachment sendMu while it captures, composes, and emits; JSONL258 // paint owns attachment sendMu while it captures, composes, and emits; JSONL
260 // observer I/O must therefore happen only after that ownership is released.259 // observer I/O must therefore happen only after that ownership is released.
261 type runtimeMarkBatch struct {260 type runtimeMarkBatch struct {
262- observer ports.RuntimeObserver261+ observer ports.RuntimeObserver
263- marks []ports.RuntimeMark262+ marks []ports.RuntimeMark
264- roleEffect *roleEffectTicket263+ attachmentEffect *attachmentEffectTicket
265 }264 }
266 265
267 func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch {266 func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch {
@@ -305,22 +304,22 @@ func (b *runtimeMarkBatch) flush() {
305 func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, lease *attachmentLease) {304 func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, lease *attachmentLease) {
306 // Local sessions retain their tab/PTY preparation below. Proxy sessions305 // Local sessions retain their tab/PTY preparation below. Proxy sessions
307 // enter the same attachment pipeline but supply their VT snapshot through306 // enter the same attachment pipeline but supply their VT snapshot through
308- // attachmentSession.capturePrimary.307+ // attachmentSession.captureRenderState.
309 sess, local := localSession(entry)308 sess, local := localSession(entry)
310 marks := d.newRuntimeMarkBatch()309 marks := d.newRuntimeMarkBatch()
311 if lease != nil {310 if lease != nil {
312 token := attachmentToken(entry, ac, ac.transport())311 token := attachmentToken(entry, ac, ac.transport())
313 token.lease = lease312 token.lease = lease
314- ticket, admitted := ac.beginRoleEffect(token)313+ ticket, admitted := ac.beginAttachmentEffect(token)
315 if !admitted {314 if !admitted {
316 return315 return
317 }316 }
318- marks.roleEffect = ticket317+ marks.attachmentEffect = ticket
319 defer ticket.End()318 defer ticket.End()
320 }319 }
321 var tb *tab320 var tb *tab
322 if local {321 if local {
323- tb = sess.activeTab()322+ tb = sess.tabForAttachment(ac)
324 if tb == nil {323 if tb == nil {
325 return324 return
326 }325 }
@@ -331,7 +330,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
331 // published identity while holding it so a deadline captured before an330 // published identity while holding it so a deadline captured before an
332 // attach/replace cannot emit on either the old or new output chain.331 // attach/replace cannot emit on either the old or new output chain.
333 entry.core().mu.Lock()332 entry.core().mu.Lock()
334- owned := entry.core().client == ac333+ _, owned := entry.core().attachments[ac]
335 entry.core().mu.Unlock()334 entry.core().mu.Unlock()
336 if !owned || ac.currentAttachmentSession() != entry {335 if !owned || ac.currentAttachmentSession() != entry {
337 ac.sendMu.Unlock()336 ac.sendMu.Unlock()
@@ -382,12 +381,12 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
382 if statusFeedback == "" && overlays.resizeActive {381 if statusFeedback == "" && overlays.resizeActive {
383 statusFeedback = "resize: h/j/k/l or arrows · = equalize · q/esc/enter exit"382 statusFeedback = "resize: h/j/k/l or arrows · = equalize · q/esc/enter exit"
384 }383 }
385- bars := d.barStateForAttachmentPaletteHints(entry, statusFeedback, overlays.paletteHints, overlays.paletteRecent)384+ bars := d.barStateForAttachmentPaletteHintsFor(entry, ac, statusFeedback, overlays.paletteHints, overlays.paletteRecent)
386 applied := ac.getAppliedTheme()385 applied := ac.getAppliedTheme()
387 bars.theme = applied.Raw386 bars.theme = applied.Raw
388 if local {387 if local {
389 attentionVisible := pulseVisible(bars.attentionFrame)388 attentionVisible := pulseVisible(bars.attentionFrame)
390- repaintAttachedClients = sess.ackAttention(tb, attentionVisible)389+ repaintAttachedClients = sess.ackAttention(tb, ac, attentionVisible)
391 }390 }
392 391
393 paletteCfg := d.currentPaletteConfig()392 paletteCfg := d.currentPaletteConfig()
@@ -405,7 +404,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
405 hasFloating := tb.floating.state == floatingVisible && floating != nil404 hasFloating := tb.floating.state == floatingVisible && floating != nil
406 tb.mu.Unlock()405 tb.mu.Unlock()
407 for _, id := range titleIDs {406 for _, id := range titleIDs {
408- d.refreshPaneTitle(sess, id)407+ d.refreshPaneTitle(sess, id, tb)
409 }408 }
410 if hasFloating {409 if hasFloating {
411 d.refreshPaneDisplayTitle(sess, floating, false)410 d.refreshPaneDisplayTitle(sess, floating, false)
@@ -418,7 +417,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
418 resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,417 resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,
419 }418 }
420 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)419 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
421- state, ok := entry.capturePrimary(ac, primaryCaptureRequest{420+ state, ok := entry.captureRenderState(ac, renderCaptureRequest{
422 bars: bars,421 bars: bars,
423 overlays: capturedOverlays,422 overlays: capturedOverlays,
424 preview: preview,423 preview: preview,
M internal/usecase/daemon/render_capture.go +21 -14
@@ -182,7 +182,7 @@ func uncertainDamage(damage []renderer.Damage, width, height int) bool {
182 return false182 return false
183 }183 }
184 184
185-type primaryCaptureRequest struct {185+type renderCaptureRequest struct {
186 bars barState186 bars barState
187 overlays capturedOverlayRenderState187 overlays capturedOverlayRenderState
188 preview picker.Preview188 preview picker.Preview
@@ -193,14 +193,14 @@ type primaryCaptureRequest struct {
193 lease *attachmentLease193 lease *attachmentLease
194 }194 }
195 195
196-// captureLocalPrimaryRenderState is the ownership boundary for a local primary196+// captureLocalRenderState is the ownership boundary for a local attachment
197 // render transaction. Callers hold attachment sendMu; this function then follows197 // render transaction. Callers hold attachment sendMu; this function then follows
198 // session -> tab -> pane lock order. ACK-blocked capture returns before touching198 // session -> tab -> pane lock order. ACK-blocked capture returns before touching
199 // VT damage, and every captured pane records a receipt for successful emission.199 // VT damage, and every captured pane records a receipt for successful emission.
200-func captureLocalPrimaryRenderState(200+func captureLocalRenderState(
201 sess *session,201 sess *session,
202 ac *attachedClient,202 ac *attachedClient,
203- request primaryCaptureRequest,203+ request renderCaptureRequest,
204 ) (*capturedRenderState, bool) {204 ) (*capturedRenderState, bool) {
205 bars := request.bars205 bars := request.bars
206 overlays := request.overlays206 overlays := request.overlays
@@ -212,12 +212,18 @@ func captureLocalPrimaryRenderState(
212 return nil, false212 return nil, false
213 }213 }
214 sess.mu.Lock()214 sess.mu.Lock()
215- if sess.client != ac || sess.active < 0 || sess.active >= len(sess.tabs) {215+ _, owned := sess.attachments[ac]
216- sess.mu.Unlock()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 {
217 return nil, false225 return nil, false
218 }226 }
219- tb := sess.tabs[sess.active]
220- sess.mu.Unlock()
221 227
222 scratch := &ac.renderScratch228 scratch := &ac.renderScratch
223 scratch.statusTabs = append(scratch.statusTabs[:0], bars.status.tabs...)229 scratch.statusTabs = append(scratch.statusTabs[:0], bars.status.tabs...)
@@ -238,8 +244,8 @@ func captureLocalPrimaryRenderState(
238 defer tb.mu.Unlock()244 defer tb.mu.Unlock()
239 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}245 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
240 var focus layout.PaneID246 var focus layout.PaneID
241- if tb.tree != nil {247+ if focusedPane != nil && tb.panes[focusedPane.id] == focusedPane {
242- focus = tb.tree.Focus248+ focus = focusedPane.id
243 }249 }
244 layoutSnap := tabLayoutSnapshot{250 layoutSnap := tabLayoutSnapshot{
245 area: area, focus: focus, placements: scratch.placements,251 area: area, focus: focus, placements: scratch.placements,
@@ -247,6 +253,7 @@ func captureLocalPrimaryRenderState(
247 }253 }
248 if scratch.layoutTab != tb || scratch.layoutGeneration != tb.layoutGeneration || scratch.layoutArea != area || scratch.layoutFocus != focus {254 if scratch.layoutTab != tb || scratch.layoutGeneration != tb.layoutGeneration || scratch.layoutArea != area || scratch.layoutFocus != focus {
249 layoutSnap = solveTabLayoutLocked(tb)255 layoutSnap = solveTabLayoutLocked(tb)
256+ layoutSnap.focus = focus
250 scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)257 scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)
251 scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...)258 scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...)
252 scratch.layoutTab = tb259 scratch.layoutTab = tb
@@ -324,10 +331,10 @@ func captureLocalPrimaryRenderState(
324 return state, true331 return state, true
325 }332 }
326 333
327-// capturePrimaryRenderState preserves the focused local test seam. Production334+// captureRenderState preserves the focused local test seam. Production
328-// rendering delegates through attachmentSession.capturePrimary.335+// rendering delegates through attachmentSession.captureRenderState.
329-func capturePrimaryRenderState(sess *session, ac *attachedClient, request primaryCaptureRequest) (*capturedRenderState, bool) {336+func captureRenderState(sess *session, ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) {
330- return captureLocalPrimaryRenderState(sess, ac, request)337+ return captureLocalRenderState(sess, ac, request)
331 }338 }
332 339
333 // copyRankedRecentInto preserves the non-nil empty slice that selects340 // 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 {
21 // enqueue until fire consumes the coalesced work.21 // enqueue until fire consumes the coalesced work.
22 queueMarked bool22 queueMarked bool
23 queueCorrelation ports.RuntimeCorrelation23 queueCorrelation ports.RuntimeCorrelation
24- // pendingPreview is accounted separately from the target primary wake:24+ // pendingPreview is accounted separately from the attachment wake:
25- // viewers may consume a coalesced target snapshot while that target is25+ // viewers may consume a coalesced target snapshot while output is
26 // still ACK-blocked.26 // still ACK-blocked.
27 pendingPreview bool27 pendingPreview bool
28- // ackDeferred records that an expired deadline or watchdog was blocked by28+ // ackDeferred records that at least one attachment's render wake was
29- // output-window capacity. ACK notifications may flush only this state.29+ // blocked by output-window capacity. ACK notifications use ackDeferredFor
30- ackDeferred bool30+ // 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
31 // ackBlocked retains the first rejected ACK-capacity probe until the45 // ackBlocked retains the first rejected ACK-capacity probe until the
32 // matching consume or lifecycle closure releases that exact interval.46 // matching consume or lifecycle closure releases that exact interval.
33 ackBlocked *ackBlockedSpan47 ackBlocked *ackBlockedSpan
@@ -40,17 +54,16 @@ type renderCoordinator struct {
40 // batch perpetually reset its own timer.54 // batch perpetually reset its own timer.
41 outputPressure int55 outputPressure int
42 56
43- // previewWake receives the same coalesced wakes for the legacy single57+ // previewWakes tracks picker subscriptions by viewer, so one inactive session
44- // subscriber. previewWakes tracks picker subscriptions by viewer, so one58+ // cannot replace another viewer's live preview.
45- // inactive session cannot replace another viewer's live preview.
46- previewWake func(renderWake)
47 previewWakes map[*attachedClient]previewSubscription59 previewWakes map[*attachedClient]previewSubscription
48 60
49- // lease is the sole primary attachment lifecycle state. A revoked lease is61+ // attachments and leases are coordinator-owned lifecycle snapshots. Shared
50- // retained after detach so headless preview delivery remains distinguishable62+ // render work is not tied to any one attachment; leases only fence callbacks
51- // from an unbound test coordinator.63+ // for the attachment that captured them.
52- lease *attachmentLease64+ attachments map[*attachedClient]struct{}
53- torndown bool65+ leases map[*attachedClient]*attachmentLease
66+ torndown bool
54 67
55 resize resizeRequestMetadata68 resize resizeRequestMetadata
56 // Each lane owns one complete immutable worker token at a time.69 // Each lane owns one complete immutable worker token at a time.
@@ -107,13 +120,13 @@ func (c *renderCoordinator) detachSyncTimersLocked() []*timerToken {
107 120
108 // invalidate publishes a coordinator-owned producer transition. Callers that121 // invalidate publishes a coordinator-owned producer transition. Callers that
109 // carry an attachment identity must use invalidateForAttachment instead.122 // carry an attachment identity must use invalidateForAttachment instead.
110-func (c *renderCoordinator) invalidate(inv renderInvalidation) {123+func (c *renderCoordinator) invalidate(inv renderInvalidation) bool {
111- c.invalidateForAttachment(nil, inv)124+ return c.invalidateForAttachment(nil, inv)
112 }125 }
113 126
114-// invalidateForAttachment publishes only while source is still the bound127+// invalidateForAttachment publishes only while source is still registered.
115-// attachment. This check shares c.mu with replacement, making a stale128+// This check shares c.mu with attachment lifecycle changes, making a stale
116-// attachment's timer callback unable to enqueue work for its replacement.129+// timer callback unable to enqueue work for a newer attachment incarnation.
117 // A nil source represents an internal pane/session invalidation.130 // A nil source represents an internal pane/session invalidation.
118 func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv renderInvalidation) bool {131 func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv renderInvalidation) bool {
119 return c.invalidateForAttachmentAtResizeEpoch(source, 0, inv)132 return c.invalidateForAttachmentAtResizeEpoch(source, 0, inv)
@@ -155,17 +168,21 @@ type renderInvalidationReservation struct {
155 func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) (*renderInvalidationReservation, bool) {168 func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) (*renderInvalidationReservation, bool) {
156 c.mu.Lock()169 c.mu.Lock()
157 defer c.mu.Unlock()170 defer c.mu.Unlock()
158- detachedPreviewOnly := c.primaryDetachedLocked() && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0)
159 if c.torndown || (lease != nil && (!c.leaseCurrentLocked(lease, true) || lease.attachment != source)) ||171 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)) ||
161 (epoch != 0 && (c.resize.epoch != epoch || c.resize.source != source || (lease != nil && c.resize.lease != lease))) {173 (epoch != 0 && (c.resize.epoch != epoch || c.resize.source != source || (lease != nil && c.resize.lease != lease))) {
162 return nil, false174 return nil, false
163 }175 }
164 reservation := &renderInvalidationReservation{coordinator: c, invalidation: inv, onInvalidate: c.opts.onInvalidate}176 reservation := &renderInvalidationReservation{coordinator: c, invalidation: inv, onInvalidate: c.opts.onInvalidate}
165 c.metrics.invalidations.Add(1)177 c.metrics.invalidations.Add(1)
178+ c.pendingGeneration++
179+ if c.pendingGeneration == 0 {
180+ c.pendingGeneration = 1
181+ }
166 wasPending, wasUrgent, wasPreviewPending := c.pending, c.pendingUrgent, c.pendingPreview182 wasPending, wasUrgent, wasPreviewPending := c.pending, c.pendingUrgent, c.pendingPreview
167 if !wasPending {183 if !wasPending {
168 c.ackDeferred = false184 c.ackDeferred = false
185+ c.ackDeferredFor = nil
169 c.deadlineDue = false186 c.deadlineDue = false
170 if c.opts.observer != nil {187 if c.opts.observer != nil {
171 c.queueMarked = true188 c.queueMarked = true
@@ -175,11 +192,26 @@ func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpoch(source *att
175 }192 }
176 }193 }
177 c.pending = true194 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+ }
178 c.pendingReset = c.pendingReset || inv.reset210 c.pendingReset = c.pendingReset || inv.reset
179 c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent211 c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent
180- c.pendingPreview = c.pendingPreview || c.previewWake != nil || len(c.previewWakes) != 0212+ c.pendingPreview = c.pendingPreview || len(c.previewWakes) != 0
181 c.coalesced++213 c.coalesced++
182- reservation.arm = !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue214+ reservation.arm = !wasPending || !c.armed || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue
183 if reservation.arm {215 if reservation.arm {
184 _, reservation.old = c.normalLane.replaceLocked()216 _, reservation.old = c.normalLane.replaceLocked()
185 }217 }
@@ -240,8 +272,8 @@ func (r *renderInvalidationReservation) finish() {
240 }272 }
241 273
242 // invalidateForLeaseAtResizeEpoch is the attachment-callback variant: it274 // invalidateForLeaseAtResizeEpoch is the attachment-callback variant: it
243-// rejects a same-object replacement whose source pointer still matches but275+// rejects a callback whose source pointer still matches but whose lifecycle
244-// whose lifecycle lease has been revoked.276+// lease has been revoked.
245 func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) bool {277 func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) bool {
246 reservation, ok := c.reserveInvalidationForLeaseAtResizeEpoch(source, lease, epoch, inv)278 reservation, ok := c.reserveInvalidationForLeaseAtResizeEpoch(source, lease, epoch, inv)
247 if !ok {279 if !ok {
@@ -252,20 +284,25 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie
252 }284 }
253 285
254 // notifyAck reports that the client acknowledged an output state, releasing286 // notifyAck reports that the client acknowledged an output state, releasing
255-// at most one deadline/watchdog-deferred wake. It never bypasses an unexpired287+// at most one attachment's blocked wake. It never bypasses an unexpired normal
256-// normal or urgent deadline.288+// or urgent deadline.
257 func (c *renderCoordinator) notifyAck() { c.notifyAckForLease(nil) }289 func (c *renderCoordinator) notifyAck() { c.notifyAckForLease(nil) }
258 290
259 // notifyAckForLease prevents an acknowledgement captured by an obsolete active291 // notifyAckForLease prevents an acknowledgement captured by an obsolete active
260-// frame from flushing the replacement attachment's render queue. A nil lease is292+// frame from flushing another attachment's render queue. A nil lease is
261 // reserved for coordinator-owned/direct test callers.293 // reserved for coordinator-owned/direct test callers.
262 func (c *renderCoordinator) notifyAckForLease(lease *attachmentLease) {294 func (c *renderCoordinator) notifyAckForLease(lease *attachmentLease) {
263 c.mu.Lock()295 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 {
265 c.mu.Unlock()302 c.mu.Unlock()
266 return303 return
267 }304 }
268- if (!c.ackDeferred && !c.deadlineDue) || !c.pending {305+ if !c.pending {
269 c.mu.Unlock()306 c.mu.Unlock()
270 return307 return
271 }308 }
@@ -482,16 +519,6 @@ func (c *renderCoordinator) noteSyncPaneRemoved(p *pane) {
482 stopDetachedTimer(worker)519 stopDetachedTimer(worker)
483 }520 }
484 521
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-
495 type previewSubscription struct {522 type previewSubscription struct {
496 generation uint64523 generation uint64
497 fn func(renderWake)524 fn func(renderWake)
@@ -519,7 +546,7 @@ func (c *renderCoordinator) subscribePreviewFor(viewer *attachedClient, generati
519 }546 }
520 547
521 // teardownPreviewFor removes viewer's observer only when it still belongs to548 // 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.
523 func (c *renderCoordinator) teardownPreviewFor(viewer *attachedClient, generation uint64) {550 func (c *renderCoordinator) teardownPreviewFor(viewer *attachedClient, generation uint64) {
524 c.mu.Lock()551 c.mu.Lock()
525 if current, ok := c.previewWakes[viewer]; ok && current.generation == generation {552 if current, ok := c.previewWakes[viewer]; ok && current.generation == generation {
@@ -528,29 +555,54 @@ func (c *renderCoordinator) teardownPreviewFor(viewer *attachedClient, generatio
528 c.mu.Unlock()555 c.mu.Unlock()
529 }556 }
530 557
531-// primaryDetachedLocked distinguishes an explicitly detached primary from an558+func (c *renderCoordinator) attachmentRegisteredLocked(ac *attachedClient) bool {
532-// unbound coordinator used by headless tests. Preview subscribers remain live559+ if ac == nil {
533-// for the former, but it can never regain a primary transport without a lease.560+ return false
534-func (c *renderCoordinator) primaryDetachedLocked() bool { return c.lease != nil && !c.lease.active }561+ }
562+ _, ok := c.attachments[ac]
563+ return ok
564+}
535 565
536 func (c *renderCoordinator) installLeaseLocked(ac *attachedClient, ready bool) *attachmentLease {566 func (c *renderCoordinator) installLeaseLocked(ac *attachedClient, ready bool) *attachmentLease {
537- if c.lease != nil {567+ if ac == nil {
538- c.lease.active = false568+ return nil
539 }569 }
540- c.lease = &attachmentLease{attachment: ac, ready: ready, active: ac != nil}570+ if c.attachments == nil {
541- return c.lease571+ 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
542 }590 }
543 591
544 // attachmentLease returns the exact currently published lease for ac. Welcome592 // attachmentLease returns the exact currently published lease for ac. Welcome
545-// continuations capture this before their transport write and cannot later593+// continuations capture this before their transport write and revalidate it at
546-// bless a same-object park/resume replacement.594+// every side-effect boundary.
547 func (c *renderCoordinator) attachmentLease(ac *attachedClient) *attachmentLease {595 func (c *renderCoordinator) attachmentLease(ac *attachedClient) *attachmentLease {
548 c.mu.Lock()596 c.mu.Lock()
549 defer c.mu.Unlock()597 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 {
551 return nil603 return nil
552 }604 }
553- return c.lease605+ return lease
554 }606 }
555 607
556 func (c *renderCoordinator) leaseCurrent(lease *attachmentLease, ready bool) bool {608 func (c *renderCoordinator) leaseCurrent(lease *attachmentLease, ready bool) bool {
@@ -563,23 +615,45 @@ func (c *renderCoordinator) leaseCurrent(lease *attachmentLease, ready bool) boo
563 // must not be used to retain c.mu across input routing, transport operations,615 // must not be used to retain c.mu across input routing, transport operations,
564 // or other arbitrary handler work.616 // or other arbitrary handler work.
565 func (c *renderCoordinator) leaseCurrentLocked(lease *attachmentLease, ready bool) bool {617 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)
567 }620 }
568 621
569 // attach binds an already-handshaken internal attachment. Route/resume use622 // attach binds an already-handshaken internal attachment. Route/resume use
570 // attachWithReadiness(..., false) until their Welcome frame is accepted.623 // attachWithReadiness(..., false) until their Welcome frame is accepted.
571 func (c *renderCoordinator) attach(ac *attachedClient) { c.attachWithReadiness(ac, true) }624 func (c *renderCoordinator) attach(ac *attachedClient) { c.attachWithReadiness(ac, true) }
572 625
573-func (c *renderCoordinator) attachWithReadiness(ac *attachedClient, ready bool) {626+func (c *renderCoordinator) attachWithReadiness(ac *attachedClient, ready bool) *attachmentLease {
574 c.mu.Lock()627 c.mu.Lock()
575- if !c.torndown && (c.lease == nil || !c.lease.active || c.lease.attachment != ac) {628+ defer c.mu.Unlock()
576- c.installLeaseLocked(ac, ready)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
577 }637 }
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)
579 }654 }
580 655
581 // markAttachmentReady completes only the captured attachment incarnation.656 // markAttachmentReady completes only the captured attachment incarnation.
582-// A stale Welcome must never bless a replacement that reused its client object.
583 func (c *renderCoordinator) markAttachmentReady(lease *attachmentLease) bool {657 func (c *renderCoordinator) markAttachmentReady(lease *attachmentLease) bool {
584 c.mu.Lock()658 c.mu.Lock()
585 defer c.mu.Unlock()659 defer c.mu.Unlock()
@@ -590,12 +664,13 @@ func (c *renderCoordinator) markAttachmentReady(lease *attachmentLease) bool {
590 return true664 return true
591 }665 }
592 666
593-// wakeCurrent validates a dispatched wake. paint repeats this check after667+// wakeCurrent validates an attachment-specific wake. Shared wakes have no
594-// sendMu is acquired, so a reused attachedClient cannot revive an old wake.668+// lease and remain valid while the coordinator is live; their paint callback
669+// rechecks each attachment's connection fence.
595 func (c *renderCoordinator) wakeCurrent(w renderWake) bool {670 func (c *renderCoordinator) wakeCurrent(w renderWake) bool {
596 c.mu.Lock()671 c.mu.Lock()
597 defer c.mu.Unlock()672 defer c.mu.Unlock()
598- return c.leaseCurrentLocked(w.lease, true)673+ return !c.torndown && (w.lease == nil || c.leaseCurrentLocked(w.lease, true))
599 }674 }
600 675
601 type renderLifecycleCleanup struct {676 type renderLifecycleCleanup struct {
@@ -629,12 +704,28 @@ func (c *renderCoordinator) beginDetach(ac *attachedClient) renderLifecycleClean
629 // beginDetachLocked is the prelocked lifecycle seam used when registry,704 // beginDetachLocked is the prelocked lifecycle seam used when registry,
630 // current-session, and lease publication must share one commit boundary.705 // current-session, and lease publication must share one commit boundary.
631 func (c *renderCoordinator) beginDetachLocked(ac *attachedClient) renderLifecycleCleanup {706 func (c *renderCoordinator) beginDetachLocked(ac *attachedClient) renderLifecycleCleanup {
632- var cleanup renderLifecycleCleanup707+ if ac == nil || !c.attachmentRegisteredLocked(ac) {
633- if c.lease != nil && c.lease.active && c.lease.attachment == ac {708+ return renderLifecycleCleanup{}
634- c.lease.active = false
635- cleanup = c.resetAttachmentLifecycleLocked()
636 }709 }
637- return cleanup710+ 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()
638 }729 }
639 730
640 func (c *renderCoordinator) resetAttachmentLifecycleLocked() renderLifecycleCleanup {731 func (c *renderCoordinator) resetAttachmentLifecycleLocked() renderLifecycleCleanup {
@@ -642,7 +733,11 @@ func (c *renderCoordinator) resetAttachmentLifecycleLocked() renderLifecycleClea
642 c.pendingReset = false733 c.pendingReset = false
643 c.pendingUrgent = false734 c.pendingUrgent = false
644 c.ackDeferred = false735 c.ackDeferred = false
736+ c.ackDeferredFor = nil
737+ c.delivered = nil
645 c.pendingPreview = false738 c.pendingPreview = false
739+ c.pendingShared = false
740+ c.pendingTargets = nil
646 c.coalesced = 0741 c.coalesced = 0
647 _, timer := c.normalLane.replaceLocked()742 _, timer := c.normalLane.replaceLocked()
648 c.armed = false743 c.armed = false
@@ -664,45 +759,6 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) {
664 c.beginDetach(ac).finish()759 c.beginDetach(ac).finish()
665 }760 }
666 761
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-
706 // beginSessionTeardown is terminal phase one. It rejects future worker762 // beginSessionTeardown is terminal phase one. It rejects future worker
707 // registration and atomically detaches every complete lane token.763 // registration and atomically detaches every complete lane token.
708 func (c *renderCoordinator) beginSessionTeardown() renderLifecycleCleanup {764 func (c *renderCoordinator) beginSessionTeardown() renderLifecycleCleanup {
@@ -712,16 +768,22 @@ func (c *renderCoordinator) beginSessionTeardown() renderLifecycleCleanup {
712 return renderLifecycleCleanup{}768 return renderLifecycleCleanup{}
713 }769 }
714 c.torndown = true770 c.torndown = true
715- if c.lease != nil {771+ for ac, lease := range c.leases {
716- c.lease.active = false772+ lease.active = false
773+ delete(c.attachments, ac)
717 }774 }
718- c.previewWake = nil775+ c.leases = nil
776+ c.attachments = nil
719 c.previewWakes = nil777 c.previewWakes = nil
720 c.pending = false778 c.pending = false
721 _, resizeTimer := c.resizeLane.replaceLocked()779 _, resizeTimer := c.resizeLane.replaceLocked()
722 _, retryTimer := c.retryLane.replaceLocked()780 _, retryTimer := c.retryLane.replaceLocked()
723 c.ackDeferred = false781 c.ackDeferred = false
782+ c.ackDeferredFor = nil
783+ c.delivered = nil
724 c.pendingPreview = false784 c.pendingPreview = false
785+ c.pendingShared = false
786+ c.pendingTargets = nil
725 _, timer := c.normalLane.replaceLocked()787 _, timer := c.normalLane.replaceLocked()
726 c.armed = false788 c.armed = false
727 workers := c.detachSyncTimersLocked()789 workers := c.detachSyncTimersLocked()
@@ -747,7 +809,19 @@ func (c *renderCoordinator) waitForTimerWorkers() { c.supervisor.wait() }
747 // compositor; attached sessions always schedule through their coordinator.809 // compositor; attached sessions always schedule through their coordinator.
748 func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, reset bool, producer string) {810 func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, reset bool, producer string) {
749 if rc := attachmentRenderCoordinator(entry); rc != nil {811 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+ }
751 return825 return
752 }826 }
753 if ac != nil {827 if ac != nil {
@@ -760,8 +834,13 @@ func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, r
760 // the required first full frame never depends on a debounce timer.834 // the required first full frame never depends on a debounce timer.
761 func (d *Daemon) invalidateRenderNow(entry attachmentSession, ac *attachedClient, reset bool, producer string) {835 func (d *Daemon) invalidateRenderNow(entry attachmentSession, ac *attachedClient, reset bool, producer string) {
762 if rc := attachmentRenderCoordinator(entry); rc != nil {836 if rc := attachmentRenderCoordinator(entry); rc != nil {
763- rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})837+ if rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}) {
764- rc.fireCurrent(false)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+ }
765 return844 return
766 }845 }
767 if ac != nil {846 if ac != nil {
@@ -780,27 +859,73 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
780 c.fireWithTimerToken(token, gen, watchdog, deadline)859 c.fireWithTimerToken(token, gen, watchdog, deadline)
781 }860 }
782 861
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+
783 // fireFromTimer consumes a normal deadline using its exact immutable token.873 // fireFromTimer consumes a normal deadline using its exact immutable token.
784 func (c *renderCoordinator) fireFromTimer(token *timerToken, watchdog, deadline bool) {874 func (c *renderCoordinator) fireFromTimer(token *timerToken, watchdog, deadline bool) {
785 c.fireWithTimerToken(token, token.generation, watchdog, deadline)875 c.fireWithTimerToken(token, token.generation, watchdog, deadline)
786 }876 }
787 877
788 func (c *renderCoordinator) fireWithTimerToken(token *timerToken, gen uint64, watchdog, deadline bool) {878 func (c *renderCoordinator) fireWithTimerToken(token *timerToken, gen uint64, watchdog, deadline bool) {
789- // Readiness probes enter the attachment send path, which may in turn read879+ c.fireWithTimerTokenAndLease(token, gen, watchdog, deadline, nil)
790- // coordinator metadata. Never hold c.mu across that external callback.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.
791 c.mu.Lock()895 c.mu.Lock()
792 if !c.fireValidLocked(token, gen, watchdog) {896 if !c.fireValidLocked(token, gen, watchdog) {
793 c.mu.Unlock()897 c.mu.Unlock()
794 return898 return
795 }899 }
796- // Welcome holds attachment.sendMu while its transport Send is in flight.900+ if c.pendingGeneration == 0 {
797- // Do not probe ACK capacity through that lock before this incarnation is901+ c.pendingGeneration = 1
798- // ready: a deadline that expires during the handshake must complete and902+ }
799- // leave work pending, rather than wedging behind Welcome.903+ leases := make(map[*attachedClient]*attachmentLease, len(c.leases))
800- handshakePending := c.lease != nil && c.lease.active && !c.lease.ready904+ for ac, lease := range c.leases {
801- ackReady := c.opts.ackReady905+ 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
802 c.mu.Unlock()911 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+ }
804 929
805 // The readiness callback can detach, replace, or publish a sync batch.930 // The readiness callback can detach, replace, or publish a sync batch.
806 // syncGateOpenLocked retries predicate evaluation on every registry change931 // syncGateOpenLocked retries predicate evaluation on every registry change
@@ -818,67 +943,171 @@ func (c *renderCoordinator) fireWithTimerToken(token *timerToken, gen uint64, wa
818 c.mu.Unlock()943 c.mu.Unlock()
819 return944 return
820 }945 }
821- // A detached target has no primary transport or ACK window. Its pending946+
822- // state belongs exclusively to picker previews and must be consumed after947+ // Re-snapshot after every external readiness probe. A replacement may have
823- // notification rather than accumulating ackDeferred forever. Do not pass948+ // retired one of the leases we probed while the callback was unlocked.
824- // its revoked primary lease to preview consumers.949+ leases = make(map[*attachedClient]*attachmentLease, len(c.leases))
825- headlessPreviewOnly := c.primaryDetachedLocked()950+ for ac, lease := range c.leases {
826- lease := c.lease951+ if lease != nil && lease.active {
827- if headlessPreviewOnly {952+ leases[ac] = lease
828- lease = nil953+ }
829- }954+ }
830- w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog, lease: lease}955+ var attachmentLeases map[*attachedClient]*attachmentLease
831- if !headlessPreviewOnly && (!ready || (c.lease != nil && c.lease.active && !c.lease.ready)) {956+ if len(leases) != 0 {
832- var ackStart *ackBlockedSpan957+ attachmentLeases = make(map[*attachedClient]*attachmentLease, len(leases))
833- if !ready && deadline {958+ }
834- c.ackDeferred = true959+ blockedByAck := false
835- if c.ackBlocked == nil && c.opts.observer != nil {960+ allDelivered := true
836- // Install the span while coordinator state is owned. Its Start961+ if len(leases) != 0 {
837- // publication runs unlocked, and its own handoff defers End until962+ for ac, lease := range leases {
838- // that call has returned.963+ if !c.pendingShared && len(c.pendingTargets) != 0 {
839- ackStart = newACKBlockedSpan(c.opts.observer)964+ targetLease, targeted := c.pendingTargets[ac]
840- c.ackBlocked = ackStart965+ 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
841 }986 }
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
842 }1020 }
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 {
844 c.mu.Unlock()1036 c.mu.Unlock()
1037+ stopDetachedTimer(worker)
845 if ackStart != nil {1038 if ackStart != nil {
846 ackStart.publishStart()1039 ackStart.publishStart()
847 }1040 }
848- c.notifyPreviews(w, preview, previews)1041+ c.notifyPreviews(w, previews)
849 return1042 return
850 }1043 }
851- preview, previews := c.takePendingPreviewsLocked()1044+
852- c.metrics.wakes.Add(1)1045+ if outputReady {
853- c.metrics.coalesced.Add(uint64(w.coalesced))1046+ for ac := range attachmentLeases {
854- if !w.urgent {1047+ if c.delivered == nil {
855- if w.coalesced > 1 {1048+ c.delivered = make(map[*attachedClient]uint64)
856- c.outputPressure += w.coalesced - 11049+ }
857- if c.outputPressure > int((maxOutputRenderDeadline-minOutputRenderDeadline)/time.Millisecond) {1050+ c.delivered[ac] = c.pendingGeneration
858- c.outputPressure = int((maxOutputRenderDeadline - minOutputRenderDeadline) / time.Millisecond)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--
859 }1087 }
860- } else if c.outputPressure > 0 {
861- c.outputPressure--
862 }1088 }
863 }1089 }
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
866 observer, queueCorrelation, queueMarked := c.opts.observer, c.queueCorrelation, c.queueMarked1090 observer, queueCorrelation, queueMarked := c.opts.observer, c.queueCorrelation, c.queueMarked
867 ackBlocked := c.ackBlocked1091 ackBlocked := c.ackBlocked
868- c.queueMarked, c.ackBlocked = false, nil1092+ if complete {
1093+ c.queueMarked, c.ackBlocked = false, nil
1094+ }
869 wake := c.opts.wake1095 wake := c.opts.wake
870 c.mu.Unlock()1096 c.mu.Unlock()
871 stopDetachedTimer(worker)1097 stopDetachedTimer(worker)
872- if queueMarked && observer != nil {1098+ if ackStart != nil {
1099+ ackStart.publishStart()
1100+ }
1101+ if complete && queueMarked && observer != nil {
873 observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueDequeued, 0, true))1102 observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueDequeued, 0, true))
874 }1103 }
875- if ackBlocked != nil {1104+ if complete && ackBlocked != nil {
876 ackBlocked.finish(true)1105 ackBlocked.finish(true)
877 }1106 }
878- if wake != nil && !headlessPreviewOnly {1107+ if outputReady && wake != nil {
879 wake(w)1108 wake(w)
880 }1109 }
881- c.notifyPreviews(w, preview, previews)1110+ c.notifyPreviews(w, previews)
882 }1111 }
883 1112
884 func (c *renderCoordinator) fireValidLocked(token *timerToken, gen uint64, watchdog bool) bool {1113 func (c *renderCoordinator) fireValidLocked(token *timerToken, gen uint64, watchdog bool) bool {
@@ -920,24 +1149,21 @@ func (c *renderCoordinator) syncGateOpenLocked() bool {
920 }1149 }
921 }1150 }
922 1151
923-// takePendingPreviewsLocked snapshots and consumes the preview delivery for1152+// takePendingPreviewsLocked snapshots and consumes picker preview delivery
924-// this target generation. Unlike the target primary, this is never ACK-gated.1153+// for this target generation. Preview callbacks are never ACK-gated.
925-func (c *renderCoordinator) takePendingPreviewsLocked() (func(renderWake), []func(renderWake)) {1154+func (c *renderCoordinator) takePendingPreviewsLocked() []func(renderWake) {
926 if !c.pendingPreview {1155 if !c.pendingPreview {
927- return nil, nil1156+ return nil
928 }1157 }
929 c.pendingPreview = false1158 c.pendingPreview = false
930 previews := make([]func(renderWake), 0, len(c.previewWakes))1159 previews := make([]func(renderWake), 0, len(c.previewWakes))
931 for _, subscription := range c.previewWakes {1160 for _, subscription := range c.previewWakes {
932 previews = append(previews, subscription.fn)1161 previews = append(previews, subscription.fn)
933 }1162 }
934- return c.previewWake, previews1163+ return previews
935 }1164 }
936 1165
937-func (c *renderCoordinator) notifyPreviews(w renderWake, preview func(renderWake), previews []func(renderWake)) {1166+func (c *renderCoordinator) notifyPreviews(w renderWake, previews []func(renderWake)) {
938- if preview != nil {
939- preview(w)
940- }
941 for _, fn := range previews {1167 for _, fn := range previews {
942 fn(w)1168 fn(w)
943 }1169 }
M internal/usecase/daemon/render_coordinator_handshake_test.go +132 -2
@@ -1,12 +1,55 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "io"
5+ "sync"
4 "testing"6 "testing"
5 "time"7 "time"
6 8
7 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
8 )13 )
9 14
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+
10 // Fresh producer state may arrive after route has published an attachment but53 // Fresh producer state may arrive after route has published an attachment but
11 // before the transport has accepted Welcome. It must remain pending until the54 // before the transport has accepted Welcome. It must remain pending until the
12 // exact attachment incarnation completes that handshake.55 // exact attachment incarnation completes that handshake.
@@ -32,10 +75,97 @@ func TestRenderCoordinatorFreshWakeWaitsForWelcome(t *testing.T) {
32 wake := awaitWake(t, h.wakes)75 wake := awaitWake(t, h.wakes)
33 require.True(t, wake.reset)76 require.True(t, wake.reset)
34 require.Equal(t, 1, wake.coalesced)77 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")
36 requireNoWake(t, h.wakes)79 requireNoWake(t, h.wakes)
37 }80 }
38 81
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+
39 // Parking and resuming the same client object is a new incarnation: a failed169 // Parking and resuming the same client object is a new incarnation: a failed
40 // Welcome on the replacement transport must not revive readiness from before170 // Welcome on the replacement transport must not revive readiness from before
41 // the park.171 // the park.
@@ -45,7 +175,7 @@ func TestRenderCoordinatorParkedResumeRequiresNewWelcome(t *testing.T) {
45 h.rc.attachWithReadiness(ac, false)175 h.rc.attachWithReadiness(ac, false)
46 firstLease := h.rc.attachmentLease(ac)176 firstLease := h.rc.attachmentLease(ac)
47 require.True(t, h.rc.markAttachmentReady(firstLease))177 require.True(t, h.rc.markAttachmentReady(firstLease))
48- h.rc.notePark(ac)178+ h.rc.noteDetach(ac)
49 h.rc.attachWithReadiness(ac, false)179 h.rc.attachWithReadiness(ac, false)
50 resumedLease := h.rc.attachmentLease(ac)180 resumedLease := h.rc.attachmentLease(ac)
51 require.NotNil(t, resumedLease)181 require.NotNil(t, resumedLease)
M internal/usecase/daemon/render_coordinator_publication_test.go +2 -1
@@ -14,7 +14,8 @@ func TestCoordinatorRejectsStaleAttachmentInvalidationAfterReplacement(t *testin
14 old := &attachedClient{}14 old := &attachedClient{}
15 replacement := &attachedClient{}15 replacement := &attachedClient{}
16 rc.attach(old)16 rc.attach(old)
17- rc.noteReplace(old, replacement)17+ rc.noteDetach(old)
18+ rc.attach(replacement)
18 19
19 // This models a stale resize callback that passed its sendMu generation20 // This models a stale resize callback that passed its sendMu generation
20 // check immediately before a replacement was published.21 // 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 {
181 // deterministic contract failure, not a slow behavior to poll for.181 // deterministic contract failure, not a slow behavior to poll for.
182 func awaitWake(t *testing.T, ch chan renderWake) renderWake {182 func awaitWake(t *testing.T, ch chan renderWake) renderWake {
183 t.Helper()183 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
185 }187 }
186 188
187 func requireNoWake(t *testing.T, ch chan renderWake) {189 func requireNoWake(t *testing.T, ch chan renderWake) {
@@ -376,6 +378,37 @@ func TestRenderCoordinatorUrgentDeadlineCannotBeExtended(t *testing.T) {
376 378
377 // --- ACK gating ----------------------------------------------------------------379 // --- ACK gating ----------------------------------------------------------------
378 380
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+
379 func TestRenderCoordinatorAckReadinessReentersWithoutBlockingResize(t *testing.T) {412 func TestRenderCoordinatorAckReadinessReentersWithoutBlockingResize(t *testing.T) {
380 owner := &attachedClient{}413 owner := &attachedClient{}
381 wakes := make(chan renderWake, 1)414 wakes := make(chan renderWake, 1)
@@ -407,8 +440,7 @@ func TestRenderCoordinatorAckReadinessReentersWithoutBlockingResize(t *testing.T
407 epoch := awaitTestValue(t, resizeDone, "ack readiness re-entry blocked resize metadata")440 epoch := awaitTestValue(t, resizeDone, "ack readiness re-entry blocked resize metadata")
408 require.Equal(t, uint64(1), epoch)441 require.Equal(t, uint64(1), epoch)
409 wake := awaitWake(t, wakes)442 wake := awaitWake(t, wakes)
410- require.Same(t, owner, wake.lease.attachment)443+ require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
411- wake.lease = nil
412 require.Equal(t, renderWake{coalesced: 1}, wake)444 require.Equal(t, renderWake{coalesced: 1}, wake)
413 awaitTestCompletion(t, fireDone, "fire did not return after wake")445 awaitTestCompletion(t, fireDone, "fire did not return after wake")
414 }446 }
@@ -749,7 +781,8 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) {
749 781
750 func TestRenderCoordinatorPreviewSubscription(t *testing.T) {782 func TestRenderCoordinatorPreviewSubscription(t *testing.T) {
751 h := newCoordinatorHarness(t)783 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 })
753 786
754 h.rc.invalidate(renderInvalidation{class: invalidateUrgent})787 h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
755 timers := h.armedTimers(t)788 timers := h.armedTimers(t)
@@ -760,7 +793,7 @@ func TestRenderCoordinatorPreviewSubscription(t *testing.T) {
760 require.Equal(t, owner, preview, "a subscribed preview observes the same coalesced wake")793 require.Equal(t, owner, preview, "a subscribed preview observes the same coalesced wake")
761 requireNoWake(t, h.previews)794 requireNoWake(t, h.previews)
762 795
763- h.rc.teardownPreview()796+ h.rc.teardownPreviewFor(viewer, 1)
764 h.rc.invalidate(renderInvalidation{class: invalidateUrgent})797 h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
765 timers = h.armedTimers(t)798 timers = h.armedTimers(t)
766 require.NotEmpty(t, timers, "invalidate must arm a deadline timer")799 require.NotEmpty(t, timers, "invalidate must arm a deadline timer")
@@ -795,24 +828,24 @@ func TestRenderCoordinatorPreviewWakesDoNotWaitForTargetAck(t *testing.T) {
795 timers[0].ch <- time.Time{}828 timers[0].ch <- time.Time{}
796 829
797 preview := awaitWake(t, h.previews)830 preview := awaitWake(t, h.previews)
798- if tc.attach {
799- require.NotNil(t, preview.lease.attachment)
800- }
801- preview.lease = nil
802 require.Equal(t, renderWake{coalesced: 1}, preview,831 require.Equal(t, renderWake{coalesced: 1}, preview,
803 "the viewer preview must receive target output without target ACK capacity")832 "the viewer preview must receive target output without target ACK capacity")
804- requireNoWake(t, h.wakes)
805 requireNoWake(t, h.previews)833 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+ }
806 841
807 h.ackReady.Store(true)842 h.ackReady.Store(true)
808 h.rc.notifyAck()843 h.rc.notifyAck()
809- wake := awaitWake(t, h.wakes)
810 if tc.attach {844 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")
812 }848 }
813- wake.lease = nil
814- require.Equal(t, renderWake{coalesced: 1}, wake,
815- "the target primary frame remains pending for its own ACK")
816 requireNoWake(t, h.previews)849 requireNoWake(t, h.previews)
817 })850 })
818 }851 }
@@ -837,9 +870,10 @@ func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *tes
837 require.Len(t, timers, 1)870 require.Len(t, timers, 1)
838 timers[0].ch <- time.Time{}871 timers[0].ch <- time.Time{}
839 preview := awaitWake(t, h.previews)872 preview := awaitWake(t, h.previews)
840- require.Nil(t, preview.lease, "headless preview wakes must not retain a revoked primary lease")
841 require.Equal(t, renderWake{coalesced: 1}, preview)873 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")
843 877
844 h.rc.mu.Lock()878 h.rc.mu.Lock()
845 require.False(t, h.rc.pending)879 require.False(t, h.rc.pending)
@@ -847,9 +881,7 @@ func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *tes
847 require.False(t, h.rc.ackDeferred)881 require.False(t, h.rc.ackDeferred)
848 require.False(t, h.rc.deadlineDue)882 require.False(t, h.rc.deadlineDue)
849 require.Zero(t, h.rc.coalesced)883 require.Zero(t, h.rc.coalesced)
850- require.True(t, h.rc.primaryDetachedLocked())884+ require.Empty(t, h.rc.attachments)
851- require.NotNil(t, h.rc.lease)
852- require.False(t, h.rc.lease.active)
853 h.rc.mu.Unlock()885 h.rc.mu.Unlock()
854 }886 }
855 887
@@ -863,7 +895,7 @@ func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *tes
863 timers[0].ch <- time.Time{}895 timers[0].ch <- time.Time{}
864 wake := awaitWake(t, h.wakes)896 wake := awaitWake(t, h.wakes)
865 require.True(t, wake.reset)897 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")
867 }899 }
868 900
869 func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) {901 func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) {
@@ -872,7 +904,10 @@ func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) {
872 transition func(*renderCoordinator, *attachedClient, *attachedClient)904 transition func(*renderCoordinator, *attachedClient, *attachedClient)
873 }{905 }{
874 {"target detach", func(rc *renderCoordinator, target, _ *attachedClient) { rc.noteDetach(target) }},906 {"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+ }},
876 {"target teardown", func(rc *renderCoordinator, _ *attachedClient, _ *attachedClient) { rc.beginSessionTeardown().finish() }},911 {"target teardown", func(rc *renderCoordinator, _ *attachedClient, _ *attachedClient) { rc.beginSessionTeardown().finish() }},
877 }912 }
878 for _, tc := range cases {913 for _, tc := range cases {
@@ -920,7 +955,6 @@ func TestRenderCoordinatorLifecycleDropsStaleWakes(t *testing.T) {
920 teardown func(rc *renderCoordinator, owner *attachedClient)955 teardown func(rc *renderCoordinator, owner *attachedClient)
921 }{956 }{
922 {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},957 {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},
923- {"park", func(rc *renderCoordinator, owner *attachedClient) { rc.notePark(owner) }},
924 {"session teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.beginSessionTeardown().finish() }},958 {"session teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.beginSessionTeardown().finish() }},
925 }959 }
926 for _, tc := range cases {960 for _, tc := range cases {
@@ -955,7 +989,7 @@ func TestRenderCoordinatorLifecycleDropsStaleWakes(t *testing.T) {
955 stale := h.armedTimers(t)989 stale := h.armedTimers(t)
956 require.NotEmpty(t, stale, "invalidate must arm a deadline timer")990 require.NotEmpty(t, stale, "invalidate must arm a deadline timer")
957 991
958- h.rc.noteReplace(owner, replacement)992+ h.rc.noteDetach(owner)
959 h.rc.attach(replacement)993 h.rc.attach(replacement)
960 for _, timer := range stale {994 for _, timer := range stale {
961 timer.ch <- time.Time{}995 timer.ch <- time.Time{}
@@ -1035,11 +1069,11 @@ func TestRenderCoordinatorResizeMetadata(t *testing.T) {
1035 case "detach":1069 case "detach":
1036 h.rc.noteDetach(staleOwner)1070 h.rc.noteDetach(staleOwner)
1037 case "park":1071 case "park":
1038- h.rc.notePark(staleOwner)1072+ h.rc.noteDetach(staleOwner)
1039 case "session teardown":1073 case "session teardown":
1040 h.rc.beginSessionTeardown().finish()1074 h.rc.beginSessionTeardown().finish()
1041 case "replacement":1075 case "replacement":
1042- h.rc.noteReplace(staleOwner, freshOwner)1076+ h.rc.noteDetach(staleOwner)
1043 h.rc.attach(freshOwner)1077 h.rc.attach(freshOwner)
1044 require.Equal(t, uint64(2), h.rc.recordResizeRequest(sz(100, 50), freshOwner))1078 require.Equal(t, uint64(2), h.rc.recordResizeRequest(sz(100, 50), freshOwner))
1045 }1079 }
@@ -1116,7 +1150,7 @@ func TestProducerInvalidations(t *testing.T) {
1116 name: "floating toggle to visible",1150 name: "floating toggle to visible",
1117 run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {1151 run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1118 fp := newPaneWithStableID(layout.PaneID("floating"), "float-producer", newQuietPTY(), domain.Size{Cols: 40, Rows: 10})1152 fp := newPaneWithStableID(layout.PaneID("floating"), "float-producer", newQuietPTY(), domain.Size{Cols: 40, Rows: 10})
1119- tb := sess.activeTab()1153+ tb := testAttachmentTab(sess)
1120 tb.mu.Lock()1154 tb.mu.Lock()
1121 tb.floating.pane = fp1155 tb.floating.pane = fp
1122 tb.floating.state = floatingHidden1156 tb.floating.state = floatingHidden
@@ -1134,7 +1168,7 @@ func TestProducerInvalidations(t *testing.T) {
1134 ac.proxied = true1168 ac.proxied = true
1135 sess.tabs[1].attention = true1169 sess.tabs[1].attention = true
1136 daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionJumpAttention, nil)1170 daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionJumpAttention, nil)
1137- require.Equal(t, 1, activeTabIndex(sess))1171+ require.Equal(t, 1, testAttachmentTabIndex(sess))
1138 },1172 },
1139 },1173 },
1140 {1174 {
@@ -1275,7 +1309,7 @@ func TestConcurrentPaintInitializesOverlayUnderSendOwnership(t *testing.T) {
1275 func TestStartPaneGoroutinesAccountsForOneReader(t *testing.T) {1309 func TestStartPaneGoroutinesAccountsForOneReader(t *testing.T) {
1276 p, release := newBlockingPTY(t)1310 p, release := newBlockingPTY(t)
1277 d, sess, _, _ := newManualSessionWithPTYs(t, p)1311 d, sess, _, _ := newManualSessionWithPTYs(t, p)
1278- tb := sess.activeTab()1312+ tb := testAttachmentTab(sess)
1279 require.NotNil(t, tb)1313 require.NotNil(t, tb)
1280 d.startPaneGoroutines(sess, tb, tb.focusedPane())1314 d.startPaneGoroutines(sess, tb, tb.focusedPane())
1281 release()1315 release()
@@ -1291,41 +1325,6 @@ func requireWorkerExit(t *testing.T, done <-chan struct{}) {
1291 awaitTestCompletion(t, done, "cancelled coordinator timer worker did not exit")1325 awaitTestCompletion(t, done, "cancelled coordinator timer worker did not exit")
1292 }1326 }
1293 1327
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-
1329 func TestRenderCoordinatorResizeLaneRejectsStaleToken(t *testing.T) {1328 func TestRenderCoordinatorResizeLaneRejectsStaleToken(t *testing.T) {
1330 rc := newRenderCoordinator(renderCoordinatorOptions{})1329 rc := newRenderCoordinator(renderCoordinatorOptions{})
1331 newer := &timerToken{generation: 2, timer: &portsmocks.MockTimer{}, cancel: make(chan struct{}), done: make(chan struct{})}1330 newer := &timerToken{generation: 2, timer: &portsmocks.MockTimer{}, cancel: make(chan struct{}), done: make(chan struct{})}
@@ -1388,7 +1387,6 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
1388 run func(*renderCoordinator, *attachedClient)1387 run func(*renderCoordinator, *attachedClient)
1389 }{1388 }{
1390 {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }},1389 {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }},
1391- {"park", func(rc *renderCoordinator, ac *attachedClient) { rc.notePark(ac) }},
1392 } {1390 } {
1393 t.Run(transition.name, func(t *testing.T) {1391 t.Run(transition.name, func(t *testing.T) {
1394 h := newCoordinatorHarness(t)1392 h := newCoordinatorHarness(t)
@@ -1417,10 +1415,11 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
1417 h.rc.mu.Unlock()1415 h.rc.mu.Unlock()
1418 h.rc.noteSyncEnd(p, 1)1416 h.rc.noteSyncEnd(p, 1)
1419 preview := awaitWake(t, h.previews)1417 preview := awaitWake(t, h.previews)
1420- preview.lease = nil
1421 require.Equal(t, renderWake{urgent: true, coalesced: 1}, preview)1418 require.Equal(t, renderWake{urgent: true, coalesced: 1}, preview)
1422 requireNoWake(t, h.previews)1419 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")
1424 })1423 })
1425 }1424 }
1426 })1425 })
@@ -1431,7 +1430,8 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
1431 h.rc.attach(old)1430 h.rc.attach(old)
1432 h.rc.noteSyncBegin(p, 1)1431 h.rc.noteSyncBegin(p, 1)
1433 watchdog := h.armedTimers(t)[0]1432 watchdog := h.armedTimers(t)[0]
1434- h.rc.noteReplace(old, replacement, false)1433+ h.rc.noteDetach(old)
1434+ h.rc.attachWithReadiness(replacement, false)
1435 require.False(t, h.rc.markAttachmentReady(h.rc.attachmentLease(old)), "old attachment Welcome is stale")1435 require.False(t, h.rc.markAttachmentReady(h.rc.attachmentLease(old)), "old attachment Welcome is stale")
1436 require.True(t, h.rc.markAttachmentReady(h.rc.attachmentLease(replacement)))1436 require.True(t, h.rc.markAttachmentReady(h.rc.attachmentLease(replacement)))
1437 1437
@@ -1455,7 +1455,7 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
1455 wake := awaitWake(t, h.wakes)1455 wake := awaitWake(t, h.wakes)
1456 require.True(t, wake.reset)1456 require.True(t, wake.reset)
1457 require.True(t, wake.urgent)1457 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")
1459 requireNoWake(t, h.wakes)1459 requireNoWake(t, h.wakes)
1460 })1460 })
1461 1461
M internal/usecase/daemon/render_coordinator_types.go +9 -7
@@ -51,17 +51,19 @@ type renderWake struct {
51 reset bool51 reset bool
52 urgent bool52 urgent bool
53 coalesced int53 coalesced int
54- // lease is snapshotted under coordinator ownership. The wake consumer must54+ // lease is optional coordinator-owned lifecycle safety for an attachment
55- // compose only for this exact active attachment incarnation, never by55+ // specific wake. Shared wakes leave it nil and snapshot live attachment
56- // rereading session.client after an attach publication.56+ // leases at dispatch time; each paint then revalidates its exact connection
57- lease *attachmentLease57+ // incarnation.
58+ lease *attachmentLease
59+ attachmentLeases map[*attachedClient]*attachmentLease
58 // watchdog marks a flush forced by the synchronized-output watchdog.60 // watchdog marks a flush forced by the synchronized-output watchdog.
59 watchdog bool61 watchdog bool
60 }62 }
61 63
62-// attachmentLease is the sole lifecycle identity for a primary render64+// attachmentLease is the lifecycle identity for one render attachment. It is
63-// attachment. It is only mutated while renderCoordinator.mu is held; callbacks65+// only mutated while renderCoordinator.mu is held; callbacks carry its pointer
64-// carry its pointer and must revalidate it before every side effect.66+// and must revalidate it before every side effect.
65 type attachmentLease struct {67 type attachmentLease struct {
66 attachment *attachedClient68 attachment *attachedClient
67 ready bool69 ready bool
M internal/usecase/daemon/render_damage_receipt_test.go +6 -6
@@ -27,7 +27,7 @@ func TestPrimaryCaptureAloneRecordsDamageReceipts(t *testing.T) {
27 p.mu.Unlock()27 p.mu.Unlock()
28 28
29 ac.sendMu.Lock()29 ac.sendMu.Lock()
30- state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{30+ state, ok := captureRenderState(sess, ac, renderCaptureRequest{
31 bars: barState{},31 bars: barState{},
32 overlays: capturedOverlayRenderState{},32 overlays: capturedOverlayRenderState{},
33 preview: pickerPreviewEmpty(),33 preview: pickerPreviewEmpty(),
@@ -61,7 +61,7 @@ func TestCaptureComposeEmitAcknowledgesCollapsedPaneDamageOnlyAfterEmission(t *t
61 collapsed.screen.Write([]byte("hidden damage"))61 collapsed.screen.Write([]byte("hidden damage"))
62 62
63 ac.sendMu.Lock()63 ac.sendMu.Lock()
64- state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{bars: barState{}})64+ state, ok := captureRenderState(sess, ac, renderCaptureRequest{bars: barState{}})
65 require.True(t, ok)65 require.True(t, ok)
66 collapsedReceipt := false66 collapsedReceipt := false
67 for _, receipt := range state.receipts {67 for _, receipt := range state.receipts {
@@ -95,7 +95,7 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T
95 fail: func(_ *composedRenderFrame, ac *attachedClient) { ac.replaceTransport(cacheFailTransport{}) },95 fail: func(_ *composedRenderFrame, ac *attachedClient) { ac.replaceTransport(cacheFailTransport{}) },
96 restore: func(sess *session, ac *attachedClient, healthy ports.Transport) {96 restore: func(sess *session, ac *attachedClient, healthy ports.Transport) {
97 sess.mu.Lock()97 sess.mu.Lock()
98- sess.client = ac98+ sess.registerAttachmentLocked(ac)
99 sess.mu.Unlock()99 sess.mu.Unlock()
100 ac.setSession(sess)100 ac.setSession(sess)
101 ac.replaceTransport(healthy)101 ac.replaceTransport(healthy)
@@ -107,7 +107,7 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T
107 // Prepare failures now schedule recovery through the coordinator;107 // Prepare failures now schedule recovery through the coordinator;
108 // retain the failed transaction here until this test explicitly retries.108 // retain the failed transaction here until this test explicitly retries.
109 d.clock = newNoticeClock()109 d.clock = newNoticeClock()
110- d.attachCoordinator(sess, nil, ac, true)110+ d.attachCoordinator(sess, ac, true)
111 healthy := ac.transport()111 healthy := ac.transport()
112 p := sess.tabs[0].focusedPane()112 p := sess.tabs[0].focusedPane()
113 113
@@ -143,7 +143,7 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T
143 func TestPrepareFailureNotifiesAndSchedulesRecovery(t *testing.T) {143 func TestPrepareFailureNotifiesAndSchedulesRecovery(t *testing.T) {
144 clock := newNoticeClock()144 clock := newNoticeClock()
145 d, sess, ac, _ := newNoticeFixture(t, clock)145 d, sess, ac, _ := newNoticeFixture(t, clock)
146- rc := d.attachCoordinator(sess, nil, ac, true)146+ rc := d.attachCoordinator(sess, ac, true)
147 var producers []string147 var producers []string
148 rc.opts.onInvalidate = func(inv renderInvalidation) {148 rc.opts.onInvalidate = func(inv renderInvalidation) {
149 // Both the notice repaint and the explicit recovery must re-enter only149 // Both the notice repaint and the explicit recovery must re-enter only
@@ -223,7 +223,7 @@ func TestRenderDamageReceiptStaleGenerationForcesFullRedraw(t *testing.T) {
223 func captureComposeForReceiptTest(t *testing.T, sess *session, ac *attachedClient) (*capturedRenderState, composedRenderFrame) {223 func captureComposeForReceiptTest(t *testing.T, sess *session, ac *attachedClient) (*capturedRenderState, composedRenderFrame) {
224 t.Helper()224 t.Helper()
225 ac.sendMu.Lock() // emitFrame releases the transaction lock.225 ac.sendMu.Lock() // emitFrame releases the transaction lock.
226- state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{226+ state, ok := captureRenderState(sess, ac, renderCaptureRequest{
227 bars: barState{},227 bars: barState{},
228 overlays: capturedOverlayRenderState{},228 overlays: capturedOverlayRenderState{},
229 preview: pickerPreviewEmpty(),229 preview: pickerPreviewEmpty(),
M internal/usecase/daemon/render_pipeline.go +29 -21
@@ -59,7 +59,7 @@ type composedRenderFrame struct {
59 59
60 // composeFrame is pure with respect to daemon ownership: it consumes only the60 // composeFrame is pure with respect to daemon ownership: it consumes only the
61 // capture, the last committed cache, and an independent attachment-owned61 // 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.
63 const inactivePaneForegroundDimming = 5563 const inactivePaneForegroundDimming = 55
64 64
65 func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...composeCacheInput) composedRenderFrame {65 func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...composeCacheInput) composedRenderFrame {
@@ -459,7 +459,7 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
459 return false459 return false
460 }460 }
461 entry.core().mu.Lock()461 entry.core().mu.Lock()
462- owned := entry.core().client == ac462+ _, owned := entry.core().attachments[ac]
463 entry.core().mu.Unlock()463 entry.core().mu.Unlock()
464 if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry {464 if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry {
465 ac.sendMu.Unlock()465 ac.sendMu.Unlock()
@@ -532,13 +532,13 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
532 sendTr = sendTransport.transport532 sendTr = sendTransport.transport
533 if ac.proxied {533 if ac.proxied {
534 if sess, ok := localSession(entry); ok {534 if sess, ok := localSession(entry); ok {
535- sendErr = ac.sendSessionMetaIfChanged(sess, sendTransport, marks.roleEffect)535+ sendErr = ac.sendSessionMetaIfChanged(sess, sendTransport, marks.attachmentEffect)
536 } else if proxy, ok := entry.(*proxySession); ok {536 } else if proxy, ok := entry.(*proxySession); ok {
537 meta, metaOK := proxy.sessionMetaSnapshot()537 meta, metaOK := proxy.sessionMetaSnapshot()
538 if !metaOK {538 if !metaOK {
539 sendErr = errSessionMetaUnavailable539 sendErr = errSessionMetaUnavailable
540 } else {540 } else {
541- sendErr = ac.sendSessionMetaSnapshot(meta, sendTransport, marks.roleEffect)541+ sendErr = ac.sendSessionMetaSnapshot(meta, sendTransport, marks.attachmentEffect)
542 }542 }
543 }543 }
544 }544 }
@@ -548,21 +548,29 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
548 sendErr = errors.New("client transport is nil")548 sendErr = errors.New("client transport is nil")
549 }549 }
550 if sendErr == nil {550 if sendErr == nil {
551- send := sendTr.Send551+ interruptible := false
552- if async, ok := sendTr.(ports.AsyncTransport); ok {552+ if marks.attachmentEffect != nil {
553- send = async.SendAsync553+ interruptible = marks.attachmentEffect.beginTransportSend(sendTransport)
554+ if !interruptible {
555+ sendErr = errAttachmentTransition
556+ }
554 }557 }
555- interruptible := marks.roleEffect != nil && marks.roleEffect.beginTransportSend(sendTransport)558+ if sendErr == nil {
556- if ac.proxied {559+ send := sendTr.Send
557- sendErr = preparedScreen.send(send)560+ if async, ok := sendTr.(ports.AsyncTransport); ok {
558- } else {561+ send = async.SendAsync
559- sendErr = preparedANSI.send(data, ac.echoAck.Load(), send)562+ }
563+ if ac.proxied {
564+ sendErr = preparedScreen.send(send)
565+ } else {
566+ sendErr = preparedANSI.send(data, ac.echoAck.Load(), send)
567+ }
560 }568 }
561- if interruptible {569+ if marks.attachmentEffect != nil && interruptible {
562 if sendErr != nil {570 if sendErr != nil {
563- marks.roleEffect.reportTransportFailure(sendTransport)571+ marks.attachmentEffect.reportTransportFailure(sendTransport)
564 }572 }
565- marks.roleEffect.endTransportSend()573+ marks.attachmentEffect.endTransportSend()
566 }574 }
567 }575 }
568 endEmit(uint64(len(data)), sendErr == nil)576 endEmit(uint64(len(data)), sendErr == nil)
@@ -611,7 +619,7 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
611 // A transport failure may invalidate the role gate. Release this render's619 // A transport failure may invalidate the role gate. Release this render's
612 // admission first. Detachment freezes the gate and therefore cannot mutate620 // admission first. Detachment freezes the gate and therefore cannot mutate
613 // ownership until any enclosing admitted operation has also ended.621 // ownership until any enclosing admitted operation has also ended.
614- if marks.roleEffect == nil {622+ if marks.attachmentEffect == nil {
615 if sess, ok := localSession(entry); ok {623 if sess, ok := localSession(entry); ok {
616 d.detachOnSendError(sess, ac, sendTr)624 d.detachOnSendError(sess, ac, sendTr)
617 } else if proxy, ok := entry.(*proxySession); ok {625 } else if proxy, ok := entry.(*proxySession); ok {
@@ -619,11 +627,11 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
619 }627 }
620 } else {628 } else {
621 // Capture the exact admitted capability, including its coordinator629 // Capture the exact admitted capability, including its coordinator
622- // lease, before End permits a replacement publication. Reserve cleanup630+ // lease, before End permits a new attachment publication. Reserve
623- // accounting before End so terminal Wait cannot race a later Add.631+ // cleanup accounting before End so terminal Wait cannot race a later Add.
624- token := marks.roleEffect.roleToken()632+ token := marks.attachmentEffect.connectionToken()
625- launchCleanup := d.reserveRoleSendErrorCleanup(token, sendTr)633+ launchCleanup := d.reserveAttachmentSendErrorCleanup(token, sendTr)
626- marks.roleEffect.End()634+ marks.attachmentEffect.End()
627 launchCleanup()635 launchCleanup()
628 }636 }
629 }637 }
M internal/usecase/daemon/render_pipeline_cache_test.go +1 -1
@@ -57,7 +57,7 @@ func TestPipelineCachePublishesOnlyAfterEmission(t *testing.T) {
57 // its original healthy transport, then retry the pending state.57 // its original healthy transport, then retry the pending state.
58 if failure.name == "send" {58 if failure.name == "send" {
59 sess.mu.Lock()59 sess.mu.Lock()
60- sess.client = ac60+ sess.registerAttachmentLocked(ac)
61 sess.mu.Unlock()61 sess.mu.Unlock()
62 ac.setSession(sess)62 ac.setSession(sess)
63 ac.replaceTransport(healthy)63 ac.replaceTransport(healthy)
M internal/usecase/daemon/render_pipeline_test.go +27 -2
@@ -127,10 +127,11 @@ func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) {
127 pty, release := newBlockingPTY(t)127 pty, release := newBlockingPTY(t)
128 t.Cleanup(release)128 t.Cleanup(release)
129 d, sess, _, sends := newManualSessionWithPTYs(t, pty)129 d, sess, _, sends := newManualSessionWithPTYs(t, pty)
130- ac := sess.client130+ ac := sess.snapshotAttachments()[0]
131 ac.sendMu.Lock()131 ac.sendMu.Lock()
132 ac.output.next = ac.output.maxOutstanding132 ac.output.next = ac.output.maxOutstanding
133 ac.output.acked = 0133 ac.output.acked = 0
134+ ac.output.syncCapacityLocked()
134 ac.sendMu.Unlock()135 ac.sendMu.Unlock()
135 p := sess.tabs[0].focusedPane()136 p := sess.tabs[0].focusedPane()
136 p.mu.Lock()137 p.mu.Lock()
@@ -240,7 +241,7 @@ func TestEmitFrameFailedSendDoesNotPublishCursorOrOutputState(t *testing.T) {
240 require.NotEmpty(t, probe.Bytes(), "failed output must not commit the renderer shadow")241 require.NotEmpty(t, probe.Bytes(), "failed output must not commit the renderer shadow")
241 242
242 sess.mu.Lock()243 sess.mu.Lock()
243- sess.client = ac244+ sess.registerAttachmentLocked(ac)
244 sess.mu.Unlock()245 sess.mu.Unlock()
245 ac.setSession(sess)246 ac.setSession(sess)
246 ac.replaceTransport(healthy)247 ac.replaceTransport(healthy)
@@ -674,3 +675,27 @@ func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) {
674 require.Equal(t, warn, got.BoxWarn)675 require.Equal(t, warn, got.BoxWarn)
675 require.NotEqual(t, got.BoxInfo, got.BoxWarn)676 require.NotEqual(t, got.BoxInfo, got.BoxWarn)
676 }677 }
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) {
79 // outer terminal also changes, its completed transaction must cover79 // outer terminal also changes, its completed transaction must cover
80 // that popup rather than emitting a second reset-producing frame.80 // that popup rather than emitting a second reset-producing frame.
81 floating := newPane(layout.PaneID("floating"), nil, domain.Size{Cols: 20, Rows: 8})81 floating := newPane(layout.PaneID("floating"), nil, domain.Size{Cols: 20, Rows: 8})
82- installTestFloating(sess.activeTab(), floating, true)82+ installTestFloating(testAttachmentTab(sess), floating, true)
83 83
84 d.firstPaint(sess, ac, tc.clientSize)84 d.firstPaint(sess, ac, tc.clientSize)
85 85
@@ -313,7 +313,7 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
313 d, sess, ac, sends := newManualSessionWithPTYs(t, activePTY, inactivePTY)313 d, sess, ac, sends := newManualSessionWithPTYs(t, activePTY, inactivePTY)
314 clock := newCoordinatorMockClock(t, 8)314 clock := newCoordinatorMockClock(t, 8)
315 d.clock = clock.clock315 d.clock = clock.clock
316- rc := d.attachCoordinator(sess, nil, ac, true)316+ rc := d.attachCoordinator(sess, ac, true)
317 317
318 d.sessWg.Add(1)318 d.sessWg.Add(1)
319 go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())319 go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
@@ -321,7 +321,7 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
321 awaitPTYReadProcessed(t, inactiveProcessed)321 awaitPTYReadProcessed(t, inactiveProcessed)
322 322
323 sess.mu.Lock()323 sess.mu.Lock()
324- sess.active = 1324+ selectTestAttachmentTabLocked(sess, 1)
325 sess.mu.Unlock()325 sess.mu.Unlock()
326 d.invalidateRender(sess, ac, true, "sync activation")326 d.invalidateRender(sess, ac, true, "sync activation")
327 fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), urgentRenderDeadline)327 fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), urgentRenderDeadline)
@@ -349,7 +349,7 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
349 d, sess, ac, sends := newManualSessionWithPTYs(t, oldPTY, newPTY, parkedPTY)349 d, sess, ac, sends := newManualSessionWithPTYs(t, oldPTY, newPTY, parkedPTY)
350 clock := newCoordinatorMockClock(t, 8)350 clock := newCoordinatorMockClock(t, 8)
351 d.clock = clock.clock351 d.clock = clock.clock
352- rc := d.attachCoordinator(sess, nil, ac, true)352+ rc := d.attachCoordinator(sess, ac, true)
353 353
354 d.sessWg.Add(2)354 d.sessWg.Add(2)
355 go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane())355 go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane())
@@ -358,7 +358,7 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
358 awaitPTYReadProcessed(t, oldProcessed)358 awaitPTYReadProcessed(t, oldProcessed)
359 359
360 sess.mu.Lock()360 sess.mu.Lock()
361- sess.active = 1361+ selectTestAttachmentTabLocked(sess, 1)
362 sess.mu.Unlock()362 sess.mu.Unlock()
363 newSteps <- channelPTYStep{data: []byte("newly active")}363 newSteps <- channelPTYStep{data: []byte("newly active")}
364 awaitPTYReadProcessed(t, newProcessed)364 awaitPTYReadProcessed(t, newProcessed)
@@ -379,111 +379,13 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
379 })379 })
380 }380 }
381 381
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-
480 func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *testing.T) {382 func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *testing.T) {
481 t.Run("detached target completes cross-session preview without later PTY output", func(t *testing.T) {383 t.Run("detached target completes cross-session preview without later PTY output", func(t *testing.T) {
482 pty, steps, processed := newChannelPTY(t)384 pty, steps, processed := newChannelPTY(t)
483 d, target, targetClient, _ := newManualSessionWithPTYs(t, pty)385 d, target, targetClient, _ := newManualSessionWithPTYs(t, pty)
484 clock := newCoordinatorMockClock(t, 8)386 clock := newCoordinatorMockClock(t, 8)
485 d.clock = clock.clock387 d.clock = clock.clock
486- rc := d.attachCoordinator(target, nil, targetClient, true)388+ rc := d.attachCoordinator(target, targetClient, true)
487 389
488 // A viewer in a different session is previewing the headless target.390 // A viewer in a different session is previewing the headless target.
489 viewer := &attachedClient{}391 viewer := &attachedClient{}
@@ -491,7 +393,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
491 viewer.overlays.pickerMu.Lock()393 viewer.overlays.pickerMu.Lock()
492 viewer.overlays.pickerPreview = target.tabs[0]394 viewer.overlays.pickerPreview = target.tabs[0]
493 viewer.overlays.pickerMu.Unlock()395 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: {}}}}
495 previews := make(chan renderWake, 2)397 previews := make(chan renderWake, 2)
496 rc.subscribePreviewFor(viewer, 1, func(w renderWake) { previews <- w })398 rc.subscribePreviewFor(viewer, 1, func(w renderWake) { previews <- w })
497 399
@@ -502,7 +404,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
502 drainCoordinatorTimers(clock) // detach must clear the pending output work.404 drainCoordinatorTimers(clock) // detach must clear the pending output work.
503 405
504 target.mu.Lock()406 target.mu.Lock()
505- target.client = nil407+ clearAttachmentsForTestLocked(target)
506 target.mu.Unlock()408 target.mu.Unlock()
507 rc.noteDetach(targetClient)409 rc.noteDetach(targetClient)
508 steps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")}410 steps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")}
@@ -522,45 +424,42 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
522 d.sessWg.Wait()424 d.sessWg.Wait()
523 })425 })
524 426
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) {
526 pty, steps, processed := newChannelPTY(t)428 pty, steps, processed := newChannelPTY(t)
527- d, target, oldClient, _ := newManualSessionWithPTYs(t, pty)429+ d, target, detached, _ := newManualSessionWithPTYs(t, pty)
528 clock := newCoordinatorMockClock(t, 8)430 clock := newCoordinatorMockClock(t, 8)
529 d.clock = clock.clock431 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)
531 442
532- wakes := make(chan renderWake, 2)
533- rc.opts.wake = func(w renderWake) { wakes <- w }
534 d.sessWg.Add(1)443 d.sessWg.Add(1)
535 go d.ptyReader(target, target.tabs[0], target.tabs[0].focusedPane())444 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")}
537 awaitPTYReadProcessed(t, processed)446 awaitPTYReadProcessed(t, processed)
538- drainCoordinatorTimers(clock)447+ timers := drainCoordinatorTimers(clock)
539 448
540- replacement := &attachedClient{output: newOutputStateStream()}449+ // Detaching one attachment must not discard the session-shared pending
541- rc.noteReplace(oldClient, replacement, true)450+ // invalidation or stop the coordinator from painting the remaining one.
542 target.mu.Lock()451 target.mu.Lock()
543- target.client = replacement452+ require.True(t, target.unregisterAttachmentLocked(detached))
544 target.mu.Unlock()453 target.mu.Unlock()
545- // The replacement's initial full paint remains gated by the batch. Its454+ rc.noteDetach(detached)
546- // reset must survive completion and coalesce with the completion wake.455+ fireCoordinatorTimer(t, rc, timers, minOutputRenderDeadline)
547- rc.invalidateForAttachment(replacement, renderInvalidation{class: invalidateUrgent, reset: true, producer: "replacement first paint"})456+ require.NotEmpty(t, secondSends, "the remaining attachment received no shared output after its peer detached")
548- replacementTimers := drainCoordinatorTimers(clock)457+ before := len(secondSends)
549- steps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")}
550- awaitPTYReadProcessed(t, processed)
551- requireNoWake(t, wakes)
552 458
553- fireCoordinatorTimer(t, rc, replacementTimers, urgentRenderDeadline)459+ steps <- channelPTYStep{data: []byte("after detach")}
554- wake := <-wakes460+ awaitPTYReadProcessed(t, processed)
555- require.True(t, wake.urgent)461+ fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline)
556- require.True(t, wake.reset, "the replacement's cleared batch must repaint a complete frame")462+ require.Greater(t, len(secondSends), before, "shared output stopped after the peer detached")
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- }
564 463
565 steps <- channelPTYStep{err: io.EOF}464 steps <- channelPTYStep{err: io.EOF}
566 d.sessWg.Wait()465 d.sessWg.Wait()
@@ -571,11 +470,11 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
571 d, target, client, _ := newManualSessionWithPTYs(t, pty)470 d, target, client, _ := newManualSessionWithPTYs(t, pty)
572 clock := newCoordinatorMockClock(t, 8)471 clock := newCoordinatorMockClock(t, 8)
573 d.clock = clock.clock472 d.clock = clock.clock
574- rc := d.attachCoordinator(target, nil, client, true)473+ rc := d.attachCoordinator(target, client, true)
575 wakes := make(chan renderWake, 1)474 wakes := make(chan renderWake, 1)
576 rc.opts.wake = func(w renderWake) { wakes <- w }475 rc.opts.wake = func(w renderWake) { wakes <- w }
577 target.mu.Lock()476 target.mu.Lock()
578- target.client = nil477+ clearAttachmentsForTestLocked(target)
579 target.mu.Unlock()478 target.mu.Unlock()
580 rc.noteDetach(client)479 rc.noteDetach(client)
581 480
@@ -601,7 +500,7 @@ func TestPaneRenderableActiveAttachmentDoesNotScanPickerPreviews(t *testing.T) {
601 p, release := newBlockingPTY(t)500 p, release := newBlockingPTY(t)
602 defer release()501 defer release()
603 d, sess, _, _ := newManualSessionWithPTYs(t, p)502 d, sess, _, _ := newManualSessionWithPTYs(t, p)
604- tb := sess.activeTab()503+ tb := testAttachmentTab(sess)
605 pane := tb.focusedPane()504 pane := tb.focusedPane()
606 505
607 // Holding daemon ownership makes a picker scan block. An active attached506 // Holding daemon ownership makes a picker scan block. An active attached
@@ -628,11 +527,11 @@ func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) {
628 name string527 name string
629 setup func(*Daemon, *session, *tab, *pane)528 setup func(*Daemon, *session, *tab, *pane)
630 }{529 }{
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) }},
632 {name: "inactive tab", setup: func(_ *Daemon, sess *session, tb *tab, _ *pane) {531 {name: "inactive tab", setup: func(_ *Daemon, sess *session, tb *tab, _ *pane) {
633 other := newTab(nil, domain.Size{Cols: 80, Rows: 23})532 other := newTab(nil, domain.Size{Cols: 80, Rows: 23})
634 sess.tabs = append([]*tab{other}, sess.tabs...)533 sess.tabs = append([]*tab{other}, sess.tabs...)
635- sess.active = 0534+ selectTestAttachmentTab(sess, 0)
636 require.NotSame(t, other, tb)535 require.NotSame(t, other, tb)
637 }},536 }},
638 {name: "collapsed", setup: func(_ *Daemon, _ *session, tb *tab, p *pane) {537 {name: "collapsed", setup: func(_ *Daemon, _ *session, tb *tab, p *pane) {
@@ -667,39 +566,19 @@ func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) {
667 require.NotEmpty(t, p.screen.Damage(), "active pane damage belongs to coordinator composition")566 require.NotEmpty(t, p.screen.Damage(), "active pane damage belongs to coordinator composition")
668 p.screen.ClearDamage()567 p.screen.ClearDamage()
669 568
670- sess.client = nil569+ clearAttachmentsForTest(sess)
671 viewer := &attachedClient{}570 viewer := &attachedClient{}
672 viewer.initOverlays()571 viewer.initOverlays()
673 viewer.overlays.pickerMu.Lock()572 viewer.overlays.pickerMu.Lock()
674 viewer.overlays.pickerPreview = tb573 viewer.overlays.pickerPreview = tb
675 viewer.overlays.pickerMu.Unlock()574 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: {}}}}
677 p.screen.Write([]byte("preview"))576 p.screen.Write([]byte("preview"))
678 _ = d.paneRenderable(sess, tb, p)577 _ = d.paneRenderable(sess, tb, p)
679 require.NotEmpty(t, p.screen.Damage(), "picker preview damage must remain for coordinator composition")578 require.NotEmpty(t, p.screen.Damage(), "picker preview damage must remain for coordinator composition")
680 })579 })
681 }580 }
682 581
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-
703 func TestAltXClosesFinalTabAndDetaches(t *testing.T) {582 func TestAltXClosesFinalTabAndDetaches(t *testing.T) {
704 d, sess, ac, sends, releases := newManualTabSession(t, 1)583 d, sess, ac, sends, releases := newManualTabSession(t, 1)
705 defer releases[0]()584 defer releases[0]()
@@ -719,7 +598,7 @@ func TestPTYEOFClosesActiveNonFinalTabAndRepaintsRemaining(t *testing.T) {
719 p2, releasePTY2 := newBlockingPTY(t)598 p2, releasePTY2 := newBlockingPTY(t)
720 d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)599 d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)
721 defer releasePTY2()600 defer releasePTY2()
722- sess.active = 0601+ selectTestAttachmentTab(sess, 0)
723 sess.tabs[1].focusedPane().screen.Write([]byte("remaining"))602 sess.tabs[1].focusedPane().screen.Write([]byte("remaining"))
724 603
725 d.sessWg.Add(1)604 d.sessWg.Add(1)
@@ -728,7 +607,7 @@ func TestPTYEOFClosesActiveNonFinalTabAndRepaintsRemaining(t *testing.T) {
728 607
729 require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)608 require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)
730 require.Equal(t, 1, sessionCount(d))609 require.Equal(t, 1, sessionCount(d))
731- require.Equal(t, 0, activeTabIndex(sess))610+ require.Equal(t, 0, testAttachmentTabIndex(sess))
732 f := awaitFrame(t, sends, ports.MsgOutput)611 f := awaitFrame(t, sends, ports.MsgOutput)
733 out, err := ports.UnmarshalOutput(f.Payload)612 out, err := ports.UnmarshalOutput(f.Payload)
734 require.NoError(t, err)613 require.NoError(t, err)
@@ -745,7 +624,7 @@ func TestPTYEOFClosesInactiveNonFinalTabAndRepaintsStatus(t *testing.T) {
745 p2, releasePTY2 := newBlockingPTY(t)624 p2, releasePTY2 := newBlockingPTY(t)
746 d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)625 d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)
747 defer releasePTY1()626 defer releasePTY1()
748- sess.active = 0627+ selectTestAttachmentTab(sess, 0)
749 sess.tabs[0].focusedPane().screen.Write([]byte("active"))628 sess.tabs[0].focusedPane().screen.Write([]byte("active"))
750 629
751 d.sessWg.Add(1)630 d.sessWg.Add(1)
@@ -754,7 +633,7 @@ func TestPTYEOFClosesInactiveNonFinalTabAndRepaintsStatus(t *testing.T) {
754 633
755 require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)634 require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)
756 require.Equal(t, 1, sessionCount(d))635 require.Equal(t, 1, sessionCount(d))
757- require.Equal(t, 0, activeTabIndex(sess))636+ require.Equal(t, 0, testAttachmentTabIndex(sess))
758 f := awaitFrame(t, sends, ports.MsgOutput)637 f := awaitFrame(t, sends, ports.MsgOutput)
759 out, err := ports.UnmarshalOutput(f.Payload)638 out, err := ports.UnmarshalOutput(f.Payload)
760 require.NoError(t, err)639 require.NoError(t, err)
@@ -802,7 +681,7 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
802 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))681 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
803 ac := &attachedClient{tr: tr, output: newOutputStateStream()}682 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
804 ac.initOverlays()683 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}}
806 ac.setSession(sess)685 ac.setSession(sess)
807 686
808 // Client rows are one more than the equivalent case in a single-bar687 // Client rows are one more than the equivalent case in a single-bar
@@ -1062,7 +941,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
1062 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))941 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
1063 ac := &attachedClient{tr: tr, output: newOutputStateStream()}942 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
1064 ac.initOverlays()943 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}}
1066 ac.setSession(sess)945 ac.setSession(sess)
1067 946
1068 d.resize(sess, ac, newSize)947 d.resize(sess, ac, newSize)
@@ -1091,7 +970,7 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) {
1091 sctx, cancel := context.WithCancel(context.Background())970 sctx, cancel := context.WithCancel(context.Background())
1092 ac := &attachedClient{tr: tr, output: newOutputStateStream()}971 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
1093 ac.initOverlays()972 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}
1095 ac.setSession(sess)974 ac.setSession(sess)
1096 d.sessions[sess.id] = sess975 d.sessions[sess.id] = sess
1097 976
@@ -1103,7 +982,7 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) {
1103 982
1104 require.Equal(t, 1, sessionCount(d), "ephemeral session survives failed client send")983 require.Equal(t, 1, sessionCount(d), "ephemeral session survives failed client send")
1105 sess.mu.Lock()984 sess.mu.Lock()
1106- require.Nil(t, sess.client)985+ require.Empty(t, sess.snapshotAttachmentsLocked())
1107 sess.mu.Unlock()986 sess.mu.Unlock()
1108 987
1109 _ = d.killSession(sess, ports.ReasonServerShutdown, false)988 _ = d.killSession(sess, ports.ReasonServerShutdown, false)
@@ -1135,7 +1014,7 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
1135 win := newTestTabWithContext(p, sctx, cancel)1014 win := newTestTabWithContext(p, sctx, cancel)
1136 ac := &attachedClient{tr: tr, output: newOutputStateStream()}1015 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
1137 ac.initOverlays()1016 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}
1139 ac.setSession(sess)1018 ac.setSession(sess)
1140 d.sessions[sess.id] = sess1019 d.sessions[sess.id] = sess
1141 d.sessWg.Add(1)1020 d.sessWg.Add(1)
@@ -1198,7 +1077,7 @@ func TestPaintAlignsFloatingCursorWithCommittedGeometry(t *testing.T) {
1198 floating := newPane("floating", nil, rectSize(committed.Inner))1077 floating := newPane("floating", nil, rectSize(committed.Inner))
1199 floating.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'F'})1078 floating.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'F'})
1200 floating.popupGeometry = committed1079 floating.popupGeometry = committed
1201- installTestFloating(sess.activeTab(), floating, true)1080+ installTestFloating(testAttachmentTab(sess), floating, true)
1202 1081
1203 d.paint(sess, ac, true, nil)1082 d.paint(sess, ac, true, nil)
1204 data := mustOutputData(t, sends)1083 data := mustOutputData(t, sends)
@@ -1300,7 +1179,7 @@ func TestPaintComposesCopyBodyAboveFloating(t *testing.T) {
1300 fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3})1179 fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3})
1301 appendHistoryRow(t, fp.history, testRow("flt-old"))1180 appendHistoryRow(t, fp.history, testRow("flt-old"))
1302 fp.screen.Write([]byte("flt-live"))1181 fp.screen.Write([]byte("flt-live"))
1303- installTestFloating(sess.activeTab(), fp, true)1182+ installTestFloating(testAttachmentTab(sess), fp, true)
1304 1183
1305 d.enterCopyMode(sess, ac)1184 d.enterCopyMode(sess, ac)
1306 data := mustOutputData(t, sends)1185 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
81 func (s *timerSupervisor) wait() { s.workers.Wait() }81 func (s *timerSupervisor) wait() { s.workers.Wait() }
82 82
83 // stopDetachedTimer is intentionally non-blocking. Ordinary lifecycle and83 // stopDetachedTimer is intentionally non-blocking. Ordinary lifecycle and
84-// replacement transitions may cancel/stop stale workers but callback stacks84+// attachment transitions may cancel/stop stale workers but callback stacks
85 // never wait for a worker to finish.85 // never wait for a worker to finish.
86 func stopDetachedTimer(token *timerToken) {86 func stopDetachedTimer(token *timerToken) {
87 if token != nil && token.timer != nil {87 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 {
47 if sess == nil || ac == nil {47 if sess == nil || ac == nil {
48 return layout.ErrNotFound48 return layout.ErrNotFound
49 }49 }
50- target := resolveDaemonActionTarget(sess)50+ target := resolveDaemonActionTargetForAttachment(sess, ac)
51 if target.tab == nil || target.pane == nil {51 if target.tab == nil || target.pane == nil {
52 return layout.ErrNotFound52 return layout.ErrNotFound
53 }53 }
@@ -131,8 +131,10 @@ func (d *Daemon) handleResizeInput(ac *attachedClient, data []byte) {
131 return131 return
132 }132 }
133 for _, request := range requests {133 for _, request := range requests {
134- request.target = resolveDaemonActionTarget(sess)134+ if err := sess.runMutation(func() error {
135- if err := (daemonActions{d: d}).Run(request); err != nil {135+ request.target = resolveDaemonActionTargetForAttachment(sess, ac)
136+ return (daemonActions{d: d}).Run(request)
137+ }); err != nil {
136 d.reportError(sess, resizeUserError(err))138 d.reportError(sess, resizeUserError(err))
137 continue139 continue
138 }140 }
M internal/usecase/daemon/resize_mode_test.go +3 -239
@@ -6,9 +6,7 @@ import (
6 6
7 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
9- scopy "github.com/bnema/vev/internal/usecase/copy"
10 "github.com/bnema/vev/internal/usecase/layout"9 "github.com/bnema/vev/internal/usecase/layout"
11- "github.com/bnema/vev/internal/usecase/mouse"
12 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
13 )11 )
14 12
@@ -64,7 +62,7 @@ func TestResizeControlHeadlessAndErrors(t *testing.T) {
64 require.Equal(t, "pane is not in a split", tooEarly.Text)62 require.Equal(t, "pane is not in a split", tooEarly.Text)
65 63
66 require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)64 require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
67- tb := sess.activeTab()65+ tb := testAttachmentTab(sess)
68 tb.mu.Lock()66 tb.mu.Lock()
69 beforeFocus := tb.tree.Focus67 beforeFocus := tb.tree.Focus
70 beforeGeneration := tb.layoutGeneration68 beforeGeneration := tb.layoutGeneration
@@ -94,67 +92,6 @@ func TestResizeControlHeadlessAndErrors(t *testing.T) {
94 require.Empty(t, equalized.Output)92 require.Empty(t, equalized.Output)
95 }93 }
96 94
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-
158 func TestRouteOverlayDecodesHorizontalArrows(t *testing.T) {95 func TestRouteOverlayDecodesHorizontalArrows(t *testing.T) {
159 var left, right int96 var left, right int
160 pending := []byte(nil)97 pending := []byte(nil)
@@ -170,66 +107,10 @@ func TestRouteOverlayDecodesHorizontalArrows(t *testing.T) {
170 // TestResizeModeKeysAndEscapes exercises the modal parser through the real107 // TestResizeModeKeysAndEscapes exercises the modal parser through the real
171 // action seam. Every directional spelling must commit exactly one mutation;108 // action seam. Every directional spelling must commit exactly one mutation;
172 // parser prefixes are deliberately split across reads.109 // 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-}
229 110
230 func TestResizeModeRendersGuidanceWithoutHidingContentOrCursor(t *testing.T) {111 func TestResizeModeRendersGuidanceWithoutHidingContentOrCursor(t *testing.T) {
231 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)112 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
232- tb := sess.activeTab()113+ tb := testAttachmentTab(sess)
233 second := newPane("pane-2", nil, domain.Size{Cols: 39, Rows: 23})114 second := newPane("pane-2", nil, domain.Size{Cols: 39, Rows: 23})
234 tb.mu.Lock()115 tb.mu.Lock()
235 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"}116 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) {
243 require.Contains(t, data, "\x1b[?25h", "resize leaves the focused pane cursor visible")124 require.Contains(t, data, "\x1b[?25h", "resize leaves the focused pane cursor visible")
244 }125 }
245 126
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-
348 type resizeLockProbePTY struct {127 type resizeLockProbePTY struct {
349 once sync.Once128 once sync.Once
350 entered chan struct{}129 entered chan struct{}
@@ -372,7 +151,7 @@ func TestResizeModeReleasesTabAndOverlayLocksBeforePTY(t *testing.T) {
372 // Build a live tree directly so the probe is not concurrently owned by a151 // Build a live tree directly so the probe is not concurrently owned by a
373 // pane reader; this isolates the lock boundary being asserted.152 // pane reader; this isolates the lock boundary being asserted.
374 d, sess, ac, _ := newManualSessionWithPTYs(t, nil)153 d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
375- tb := sess.activeTab()154+ tb := testAttachmentTab(sess)
376 probe := &resizeLockProbePTY{entered: make(chan struct{}), release: make(chan struct{})}155 probe := &resizeLockProbePTY{entered: make(chan struct{}), release: make(chan struct{})}
377 second := newPane("pane-2", probe, domain.Size{Cols: 39, Rows: 23})156 second := newPane("pane-2", probe, domain.Size{Cols: 39, Rows: 23})
378 tb.mu.Lock()157 tb.mu.Lock()
@@ -398,18 +177,3 @@ func TestResizeModeReleasesTabAndOverlayLocksBeforePTY(t *testing.T) {
398 close(probe.release)177 close(probe.release)
399 <-done178 <-done
400 }179 }
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 {
11 attachment *attachedClient11 attachment *attachedClient
12 lease *attachmentLease12 lease *attachmentLease
13 epoch uint6413 epoch uint64
14- role attachmentRoleToken14+ connection attachmentConnectionToken
15 coordinator *renderCoordinator15 coordinator *renderCoordinator
16 observer ports.RuntimeObserver16 observer ports.RuntimeObserver
17 mark ports.RuntimeMark17 mark ports.RuntimeMark
@@ -24,21 +24,19 @@ func (p resizeCommitPublication) current() bool {
24 if p.attachment == nil {24 if p.attachment == nil {
25 return true25 return true
26 }26 }
27- // Attached resize publication is admitted only through a live role ticket.27+ // Attached resize publication is admitted only through a live attachment effect ticket.
28- if p.role.effect == nil {28+ if p.connection.effect == nil {
29 return false29 return false
30 }30 }
31- if p.role.effect.ended.Load() || p.role.sess != p.session ||31+ if p.connection.effect.ended.Load() || p.connection.sess != p.session ||
32- p.role.ac != p.attachment || p.role.role != attachmentActive || p.role.lease != p.lease ||32+ p.connection.ac != p.attachment || p.connection.lease != p.lease ||
33- p.role.generation != p.attachment.roleGeneration.Load() ||33+ p.connection.generation != p.attachment.connectionGeneration.Load() ||
34- !p.attachment.transportSnapshotCurrent(p.role.transport) ||34+ !p.attachment.transportSnapshotCurrent(p.connection.transport) ||
35 p.attachment.currentSession() != p.session || p.coordinator == nil {35 p.attachment.currentSession() != p.session || p.coordinator == nil {
36 return false36 return false
37 }37 }
38- p.session.mu.Lock()38+ registered := attachmentRegistered(p.session, p.attachment)
39- active := p.session.attachmentRoleLocked(p.attachment) == attachmentActive39+ return registered && p.coordinator.resizeCurrentForLease(p.epoch, p.attachment, p.lease, false)
40- p.session.mu.Unlock()
41- return active && p.coordinator.resizeCurrentForLease(p.epoch, p.attachment, p.lease, false)
42 }40 }
43 41
44 // emit performs observer work only after every resize-owner fence and42 // emit performs observer work only after every resize-owner fence and
@@ -59,7 +57,7 @@ func (p resizeCommitPublication) emit() bool {
59 // reservation with pane ownership. sendMu is acquired before resize fences, so57 // reservation with pane ownership. sendMu is acquired before resize fences, so
60 // the canonical rule never acquires an attachment lock while a pane fence is58 // the canonical rule never acquires an attachment lock while a pane fence is
61 // held. Observer callbacks run only after both lock families are released.59 // 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 {
63 if ac != nil && ticket == nil {61 if ac != nil && ticket == nil {
64 return false62 return false
65 }63 }
@@ -87,7 +85,7 @@ func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *
87 mark: ports.NewRuntimeMark("daemon", ports.RuntimeResizeCommitted, 0, true),85 mark: ports.NewRuntimeMark("daemon", ports.RuntimeResizeCommitted, 0, true),
88 }86 }
89 if ticket != nil {87 if ticket != nil {
90- publication.role = ticket.roleToken()88+ publication.connection = ticket.connectionToken()
91 publication.coordinator = sess.renderCoordinator()89 publication.coordinator = sess.renderCoordinator()
92 }90 }
93 if !publication.current() {91 if !publication.current() {
M internal/usecase/daemon/resume.go +130 -55
@@ -37,8 +37,8 @@ func (d *Daemon) nextResumeTokenLocked() uint64 {
37 // can wait out the detach→park gap. Callers must publish this before clearing37 // can wait out the detach→park gap. Callers must publish this before clearing
38 // the live seat so resume never observes both registries empty for a still-valid38 // the live seat so resume never observes both registries empty for a still-valid
39 // credential. Under d.mu then sess.mu it advertises only when the daemon still39 // 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 snatched40+// registers the exact session and ac remains a registered attachment; otherwise
41-// owner; otherwise it returns 0 and never creates a marker after terminal41+// it returns 0 and never creates a marker after terminal
42 // cleanup. Returns the resume token, or 0 when parking cannot be advertised.42 // cleanup. Returns the resume token, or 0 when parking cannot be advertised.
43 func (d *Daemon) markParkingInFlight(sess *session, ac *attachedClient) uint64 {43 func (d *Daemon) markParkingInFlight(sess *session, ac *attachedClient) uint64 {
44 if sess == nil || ac == nil || !ac.resumeCapable {44 if sess == nil || ac == nil || !ac.resumeCapable {
@@ -50,10 +50,9 @@ func (d *Daemon) markParkingInFlight(sess *session, ac *attachedClient) uint64 {
50 return 050 return 0
51 }51 }
52 sess.mu.Lock()52 sess.mu.Lock()
53- role := sess.attachmentRoleLocked(ac)53+ registered := attachmentRegisteredLocked(sess, ac)
54- owner := role == attachmentActive || role == attachmentSnatched
55 sess.mu.Unlock()54 sess.mu.Unlock()
56- if !owner {55+ if !registered {
57 return 056 return 0
58 }57 }
59 if ac.resumeToken == 0 {58 if ac.resumeToken == 0 {
@@ -96,8 +95,8 @@ func (d *Daemon) clearParkingInFlight(token uint64, ac *attachedClient) {
96 }95 }
97 96
98 // clearParkingInFlightIfAbandoned drops a pre-detach parking marker when this97 // clearParkingInFlightIfAbandoned drops a pre-detach parking marker when this
99-// teardown lost the seat while still the live owner (stale transport fence for98+// teardown lost the seat while still the live owner (stale transport fence).
100-// active or snatched). If another path already detached/unrouted, that path99+// If another path already detached/unrouted, that path
101 // owns park publication.100 // owns park publication.
102 func (d *Daemon) clearParkingInFlightIfAbandoned(sess *session, ac *attachedClient, token uint64) {101 func (d *Daemon) clearParkingInFlightIfAbandoned(sess *session, ac *attachedClient, token uint64) {
103 if token == 0 || ac == nil {102 if token == 0 || ac == nil {
@@ -111,8 +110,7 @@ func (d *Daemon) clearParkingInFlightIfAbandoned(sess *session, ac *attachedClie
111 stillOwner := false110 stillOwner := false
112 if sess != nil {111 if sess != nil {
113 sess.mu.Lock()112 sess.mu.Lock()
114- role := sess.attachmentRoleLocked(ac)113+ stillOwner = attachmentRegisteredLocked(sess, ac)
115- stillOwner = role == attachmentActive || role == attachmentSnatched
116 sess.mu.Unlock()114 sess.mu.Unlock()
117 }115 }
118 if !stillOwner {116 if !stillOwner {
@@ -204,10 +202,6 @@ func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {
204 }202 }
205 203
206 func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {204 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 {
211 if !d.prepareParkAttachment(sess, ac) {205 if !d.prepareParkAttachment(sess, ac) {
212 return false206 return false
213 }207 }
@@ -228,13 +222,6 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach
228 d.mu.Unlock()222 d.mu.Unlock()
229 return false223 return false
230 }224 }
231- if role == attachmentActive {
232- sess.mu.Lock()
233- if sess.client != nil {
234- role = attachmentSnatched
235- }
236- sess.mu.Unlock()
237- }
238 token := ac.resumeToken225 token := ac.resumeToken
239 var (226 var (
240 oldRetirement *parkedAttachmentRetirement227 oldRetirement *parkedAttachmentRetirement
@@ -250,7 +237,7 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach
250 }237 }
251 grace := d.resumeParkGrace238 grace := d.resumeParkGrace
252 timer := d.clock.NewTimer(grace)239 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{})}
254 ac.parked = true241 ac.parked = true
255 d.parked[token] = parked242 d.parked[token] = parked
256 d.clearParkingInFlightLocked(token, ac)243 d.clearParkingInFlightLocked(token, ac)
@@ -265,20 +252,31 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach
265 oldSame.closeDone()252 oldSame.closeDone()
266 }253 }
267 d.log.Info("client parked for resume", "session", sess.name, "grace", grace)254 d.log.Info("client parked for resume", "session", sess.name, "grace", grace)
255+ d.watchParkedTimer(token, parked)
256+ return true
257+}
268 258
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() {
270 select {265 select {
271 case <-timer.C():266 case <-timer.C():
272 d.expireParked(token, parked)267 d.expireParked(token, parked)
273 case <-parked.done:268 case <-parked.done:
274 }269 }
275- }(token, parked)270+ }()
276- return true
277 }271 }
278 272
279 func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {273 func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
280 d.mu.Lock()274 d.mu.Lock()
281 if d.parked[token] == parked {275 if d.parked[token] == parked {
276+ if parked.claimed {
277+ d.mu.Unlock()
278+ return
279+ }
282 d.removeParkedLocked(token, parked)280 d.removeParkedLocked(token, parked)
283 d.mu.Unlock()281 d.mu.Unlock()
284 d.closePickerIfCurrent(parked.ac, nil, parked.pickerGeneration)282 d.closePickerIfCurrent(parked.ac, nil, parked.pickerGeneration)
@@ -308,14 +306,81 @@ func (p *parkedAttachment) closeDone() {
308 p.doneOnce.Do(func() { close(p.done) })306 p.doneOnce.Do(func() { close(p.done) })
309 }307 }
310 308
311-// demoteParkedActiveForSessionLocked preserves resumable predecessors when a309+// commitResumeClaim consumes the old credential only after Welcome has been
312-// new active owner is published. Caller holds d.mu.310+// written successfully. The claim itself is serialized by d.mu, so a racing
313-func (d *Daemon) demoteParkedActiveForSessionLocked(sess *session) {311+// resume cannot acquire the same parked attachment.
314- for _, parked := range d.parked {312+func (d *Daemon) commitResumeClaim(ac *attachedClient) bool {
315- if parked.sess == sess && parked.role == attachmentActive {313+ if d == nil || ac == nil {
316- parked.role = attachmentSnatched314+ return false
317- }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
318 }342 }
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
319 }384 }
320 385
321 type parkedAttachmentRetirement struct {386 type parkedAttachmentRetirement struct {
@@ -329,7 +394,7 @@ func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttach
329 parked.ac.clearPreviousSession()394 parked.ac.clearPreviousSession()
330 parked.ac.resumeToken = 0395 parked.ac.resumeToken = 0
331 parked.ac.parked = false396 parked.ac.parked = false
332- parked.ac.roleGeneration.Add(1)397+ parked.ac.connectionGeneration.Add(1)
333 parked.ac.setSession(nil)398 parked.ac.setSession(nil)
334 return parkedAttachmentRetirement{399 return parkedAttachmentRetirement{
335 parked: parked,400 parked: parked,
@@ -398,11 +463,11 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
398 )463 )
399 if sess != nil {464 if sess != nil {
400 sess.mu.Lock()465 sess.mu.Lock()
401- ac = sess.client466+ for candidate := range sess.attachments {
402- // resumeToken writes run under d.mu; read the live credential here467+ if !candidate.resumeCapable || candidate.resumeToken != h.ResumeToken {
403- // before releasing the daemon lock, then revalidate via the exact468+ continue
404- // transport incarnation through detachIfCurrentTransport.469+ }
405- if ac != nil && ac.resumeCapable && ac.resumeToken == h.ResumeToken {470+ ac = candidate
406 if ac.clientID != h.ClientID {471 if ac.clientID != h.ClientID {
407 clientMismatch = true472 clientMismatch = true
408 } else if ac.proxied != h.Proxied {473 } else if ac.proxied != h.Proxied {
@@ -411,6 +476,7 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
411 oldSnap = ac.transportSnapshot()476 oldSnap = ac.transportSnapshot()
412 credentialMatch = oldSnap.transport != nil477 credentialMatch = oldSnap.transport != nil
413 }478 }
479+ break
414 }480 }
415 sess.mu.Unlock()481 sess.mu.Unlock()
416 }482 }
@@ -494,19 +560,29 @@ func (d *Daemon) resumeParked(h ports.Hello, tr ports.Transport, sz domain.Size)
494 d.beforeResumeParkedSendMu()560 d.beforeResumeParkedSendMu()
495 }561 }
496 ac.sendMu.Lock()562 ac.sendMu.Lock()
497- defer ac.sendMu.Unlock()
498 d.mu.Lock()563 d.mu.Lock()
499- defer d.mu.Unlock()
500 if d.closing {564 if d.closing {
565+ d.mu.Unlock()
566+ ac.sendMu.Unlock()
501 return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}567 return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
502 }568 }
503- if d.parked[h.ResumeToken] != parked {569+ if d.parked[h.ResumeToken] != parked || parked.claimed {
504 if d.sessions[parked.sess.id] == parked.sess {570 if d.sessions[parked.sess.id] == parked.sess {
571+ d.mu.Unlock()
572+ ac.sendMu.Unlock()
505 return nil, nil, false, errResumeTokenLifecycleRace573 return nil, nil, false, errResumeTokenLifecycleRace
506 }574 }
575+ d.mu.Unlock()
576+ ac.sendMu.Unlock()
507 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}577 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
508 }578 }
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
510 }586 }
511 587
512 // resumeParkedLocked completes a validated resume. Caller holds both ac.sendMu588 // 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
523 d.log.Warn("resume rejected", "session", sess.name, "registered", false)599 d.log.Warn("resume rejected", "session", sess.name, "registered", false)
524 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}600 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
525 }601 }
526- sess.mu.Lock()
527- active := sess.client != nil
528- sess.mu.Unlock()
529- if active && parked.role == attachmentActive {
530- parked.role = attachmentSnatched
531- }
532 ac := parked.ac602 ac := parked.ac
603+ if parked.claimed {
604+ return nil, nil, false, errResumeTokenLifecycleRace
605+ }
533 if ac.clientID != h.ClientID || ac.proxied != h.Proxied {606 if ac.clientID != h.ClientID || ac.proxied != h.Proxied {
534 reason := "client id mismatch"607 reason := "client id mismatch"
535 if ac.proxied != h.Proxied {608 if ac.proxied != h.Proxied {
@@ -538,14 +611,17 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
538 d.log.Warn("resume rejected", "session", sess.name, "err", reason)611 d.log.Warn("resume rejected", "session", sess.name, "err", reason)
539 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}612 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
540 }613 }
541- delete(d.parked, h.ResumeToken)614+ // Claim the attachment while retaining the old credential until Welcome is
542- parked.timer.Stop()615+ // accepted. A failed handshake can therefore restore this exact parked
543- parked.closeDone()616+ // entry; another resume sees claimed and loses atomically.
617+ parked.claimed = true
618+ ac.resumeClaimToken = h.ResumeToken
544 // The parked attachment has no session owner and cannot paint. Retire the619 // The parked attachment has no session owner and cannot paint. Retire the
545 // abandoned transport's output chain before binding the replacement so the620 // abandoned transport's output chain before binding the replacement so the
546 // mandatory first paint cannot be blocked by ACKs that died with the link.621 // mandatory first paint cannot be blocked by ACKs that died with the link.
547 ac.rebaseOutput()622 ac.rebaseOutput()
548 ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight))623 ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight))
624+ ac.output.syncCapacityLocked()
549 ac.replaceTransport(tr)625 ac.replaceTransport(tr)
550 ac.size = sz626 ac.size = sz
551 ac.resumeToken = d.nextResumeTokenLocked()627 ac.resumeToken = d.nextResumeTokenLocked()
@@ -562,17 +638,16 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
562 d.mu.Unlock()638 d.mu.Unlock()
563 ac.sendMu.Unlock()639 ac.sendMu.Unlock()
564 transition, err := d.transitionAttachment(attachmentTransitionRequest{640 transition, err := d.transitionAttachment(attachmentTransitionRequest{
565- target: sess,641+ target: sess,
566- next: ac,642+ next: ac,
567- expectedRole: attachmentDetached,643+
568- targetRole: parked.role,
569 expectedTransport: ac.transportSnapshot(),644 expectedTransport: ac.transportSnapshot(),
570 ready: false,645 ready: false,
571 })646 })
572 ac.sendMu.Lock()647 ac.sendMu.Lock()
573 d.mu.Lock()648 d.mu.Lock()
574 if err != nil {649 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"}
576 }651 }
577 d.deferAttachmentTransitionCleanups(transition)652 d.deferAttachmentTransitionCleanups(transition)
578 d.touchMRU(sess)653 d.touchMRU(sess)
M internal/usecase/daemon/resume_test.go +79 -523
@@ -5,7 +5,6 @@ import (
5 "errors"5 "errors"
6 "strings"6 "strings"
7 "sync"7 "sync"
8- "sync/atomic"
9 "testing"8 "testing"
10 "time"9 "time"
11 10
@@ -98,6 +97,53 @@ func helloResumeCapable(intent uint8, name string, token uint64) ports.Hello {
98 }97 }
99 }98 }
100 99
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+
101 func TestNamedLinkLossParks(t *testing.T) {147 func TestNamedLinkLossParks(t *testing.T) {
102 pty, release := newBlockingPTY(t)148 pty, release := newBlockingPTY(t)
103 defer release()149 defer release()
@@ -111,7 +157,7 @@ func TestNamedLinkLossParks(t *testing.T) {
111 157
112 d.clientGone(sess, ac, ac.transport(), false)158 d.clientGone(sess, ac, ac.transport(), false)
113 require.Equal(t, 1, sessionCount(d), "named session survives parked link loss")159 require.Equal(t, 1, sessionCount(d), "named session survives parked link loss")
114- require.Nil(t, sess.client)160+ require.Empty(t, sess.snapshotAttachments())
115 d.mu.Lock()161 d.mu.Lock()
116 _, parked := d.parked[token]162 _, parked := d.parked[token]
117 d.mu.Unlock()163 d.mu.Unlock()
@@ -215,7 +261,7 @@ func TestHandleHelloResumeDefersFreshOutputUntilWelcome(t *testing.T) {
215 welcome := <-tr.sends261 welcome := <-tr.sends
216 require.Equal(t, ports.MsgWelcome, welcome.Type)262 require.Equal(t, ports.MsgWelcome, welcome.Type)
217 sess.mu.Lock()263 sess.mu.Lock()
218- resumed := sess.client264+ resumed := sess.snapshotAttachmentsLocked()[0]
219 sess.mu.Unlock()265 sess.mu.Unlock()
220 require.Same(t, ac, resumed)266 require.Same(t, ac, resumed)
221 267
@@ -254,7 +300,7 @@ func TestEphemeralLinkLossParksAndResumes(t *testing.T) {
254 300
255 d.clientGone(sess, ac, ac.transport(), false)301 d.clientGone(sess, ac, ac.transport(), false)
256 require.Equal(t, 1, sessionCount(d), "ephemeral link loss keeps session alive")302 require.Equal(t, 1, sessionCount(d), "ephemeral link loss keeps session alive")
257- require.Nil(t, sess.client)303+ require.Empty(t, sess.snapshotAttachments())
258 d.mu.Lock()304 d.mu.Lock()
259 _, parked := d.parked[token]305 _, parked := d.parked[token]
260 d.mu.Unlock()306 d.mu.Unlock()
@@ -266,7 +312,7 @@ func TestEphemeralLinkLossParksAndResumes(t *testing.T) {
266 require.Same(t, sess, resumedSess)312 require.Same(t, sess, resumedSess)
267 require.Same(t, ac, resumedAC)313 require.Same(t, ac, resumedAC)
268 require.NotEqual(t, token, resumedAC.resumeToken, "resume rotates token")314 require.NotEqual(t, token, resumedAC.resumeToken, "resume rotates token")
269- require.Same(t, resumedAC, sess.client)315+ require.Contains(t, sess.snapshotAttachments(), resumedAC)
270 }316 }
271 317
272 // TestResumeParkedTokenReplacedDuringWaitFailsClosed covers the lifecycle race318 // TestResumeParkedTokenReplacedDuringWaitFailsClosed covers the lifecycle race
@@ -284,7 +330,7 @@ func TestResumeParkedTokenReplacedDuringWaitFailsClosed(t *testing.T) {
284 token := ac.resumeToken330 token := ac.resumeToken
285 require.NotZero(t, token)331 require.NotZero(t, token)
286 d.clientGone(sess, ac, oldTr, false)332 d.clientGone(sess, ac, oldTr, false)
287- require.Nil(t, sess.client)333+ require.Empty(t, sess.snapshotAttachments())
288 d.mu.Lock()334 d.mu.Lock()
289 parked := d.parked[token]335 parked := d.parked[token]
290 sessionsBefore := len(d.sessions)336 sessionsBefore := len(d.sessions)
@@ -330,7 +376,7 @@ func TestResumeParkedTokenReplacedDuringWaitFailsClosed(t *testing.T) {
330 require.ErrorAs(t, got.err, &pe)376 require.ErrorAs(t, got.err, &pe)
331 require.Equal(t, ports.ErrNoSuchSession, pe.code)377 require.Equal(t, ports.ErrNoSuchSession, pe.code)
332 require.Contains(t, pe.Error(), "resume token is no longer valid")378 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")
334 require.Empty(t, resumeTr.Sends(), "failed resume must not complete a Welcome handshake")380 require.Empty(t, resumeTr.Sends(), "failed resume must not complete a Welcome handshake")
335 d.mu.Lock()381 d.mu.Lock()
336 _, stillParked := d.parked[token]382 _, stillParked := d.parked[token]
@@ -355,7 +401,7 @@ func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) {
355 require.NoError(t, err)401 require.NoError(t, err)
356 token := ac.resumeToken402 token := ac.resumeToken
357 require.NotZero(t, token)403 require.NotZero(t, token)
358- require.Same(t, ac, sess.client)404+ require.Contains(t, sess.snapshotAttachments(), ac)
359 d.mu.Lock()405 d.mu.Lock()
360 _, parkedAtStart := d.parked[token]406 _, parkedAtStart := d.parked[token]
361 sessionsBefore := len(d.sessions)407 sessionsBefore := len(d.sessions)
@@ -404,7 +450,7 @@ func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) {
404 require.ErrorAs(t, got.err, &pe)450 require.ErrorAs(t, got.err, &pe)
405 require.Equal(t, ports.ErrNoSuchSession, pe.code)451 require.Equal(t, ports.ErrNoSuchSession, pe.code)
406 require.Contains(t, pe.Error(), "resume token is no longer valid")452 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")
408 require.Empty(t, resumeTr.Sends(), "failed live resume must not complete a Welcome handshake")454 require.Empty(t, resumeTr.Sends(), "failed live resume must not complete a Welcome handshake")
409 d.mu.Lock()455 d.mu.Lock()
410 _, stillParked := d.parked[token]456 _, stillParked := d.parked[token]
@@ -418,70 +464,6 @@ func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) {
418 // reconnect race: IntentResume arrives with the live attachment's token before464 // reconnect race: IntentResume arrives with the live attachment's token before
419 // transport teardown has parked it. Same client must recover; unknown and465 // transport teardown has parked it. Same client must recover; unknown and
420 // mismatched credentials stay fail-closed.466 // 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.
485 func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {467 func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
486 pty, release := newBlockingPTY(t)468 pty, release := newBlockingPTY(t)
487 defer release()469 defer release()
@@ -492,7 +474,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
492 require.NoError(t, err)474 require.NoError(t, err)
493 token := ac.resumeToken475 token := ac.resumeToken
494 require.NotZero(t, token)476 require.NotZero(t, token)
495- require.Same(t, ac, sess.client)477+ require.Contains(t, sess.snapshotAttachments(), ac)
496 478
497 reachedGap := make(chan struct{})479 reachedGap := make(chan struct{})
498 releaseGap := make(chan struct{})480 releaseGap := make(chan struct{})
@@ -510,7 +492,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
510 }()492 }()
511 awaitTestCompletion(t, reachedGap, "teardown did not pause after detach before park")493 awaitTestCompletion(t, reachedGap, "teardown did not pause after detach before park")
512 494
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")
514 d.mu.Lock()496 d.mu.Lock()
515 parkingInGap := d.parking[token]497 parkingInGap := d.parking[token]
516 _, parkedInGap := d.parked[token]498 _, parkedInGap := d.parked[token]
@@ -558,9 +540,10 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
558 require.NoError(t, got.err, "same-client token must recover across detach-before-park")540 require.NoError(t, got.err, "same-client token must recover across detach-before-park")
559 require.Same(t, sess, got.sess)541 require.Same(t, sess, got.sess)
560 require.Same(t, ac, got.ac)542 require.Same(t, ac, got.ac)
561- require.Same(t, ac, sess.client)543+ require.Contains(t, sess.snapshotAttachments(), ac)
562 require.Same(t, newTr, ac.transport())544 require.Same(t, newTr, ac.transport())
563 require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential")545 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")
564 require.True(t, oldTr.Closed(), "teardown/resume must retire the old transport")547 require.True(t, oldTr.Closed(), "teardown/resume must retire the old transport")
565 require.False(t, newTr.Closed(), "rebound transport must survive")548 require.False(t, newTr.Closed(), "rebound transport must survive")
566 d.mu.Lock()549 d.mu.Lock()
@@ -571,7 +554,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
571 require.False(t, stillParking, "parking marker must be consumed after park/resume")554 require.False(t, stillParking, "parking marker must be consumed after park/resume")
572 555
573 d.clientGone(sess, ac, oldTr, false)556 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")
575 require.Same(t, newTr, ac.transport())558 require.Same(t, newTr, ac.transport())
576 require.False(t, newTr.Closed())559 require.False(t, newTr.Closed())
577 }560 }
@@ -590,7 +573,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
590 require.NoError(t, err)573 require.NoError(t, err)
591 token := ac.resumeToken574 token := ac.resumeToken
592 require.NotZero(t, token)575 require.NotZero(t, token)
593- require.Same(t, ac, sess.client)576+ require.Contains(t, sess.snapshotAttachments(), ac)
594 577
595 reachedGap := make(chan struct{})578 reachedGap := make(chan struct{})
596 releaseGap := make(chan struct{})579 releaseGap := make(chan struct{})
@@ -614,7 +597,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
614 }()597 }()
615 awaitTestCompletion(t, reachedGap, "first live resume did not pause after detach before park")598 awaitTestCompletion(t, reachedGap, "first live resume did not pause after detach before park")
616 599
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")
618 d.mu.Lock()601 d.mu.Lock()
619 parkingInGap := d.parking[token]602 parkingInGap := d.parking[token]
620 _, parkedInGap := d.parked[token]603 _, parkedInGap := d.parked[token]
@@ -660,7 +643,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
660 643
661 require.Same(t, sess, winner.sess)644 require.Same(t, sess, winner.sess)
662 require.Same(t, ac, winner.ac)645 require.Same(t, ac, winner.ac)
663- require.Same(t, ac, sess.client)646+ require.Contains(t, sess.snapshotAttachments(), ac)
664 require.Same(t, winnerTr, ac.transport())647 require.Same(t, winnerTr, ac.transport())
665 require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential")648 require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential")
666 require.True(t, oldTr.Closed(), "winning resume must retire the old transport")649 require.True(t, oldTr.Closed(), "winning resume must retire the old transport")
@@ -672,6 +655,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
672 require.Contains(t, loserErr.Error(), "resume token is no longer valid")655 require.Contains(t, loserErr.Error(), "resume token is no longer valid")
673 require.True(t, loserTr.Closed() || len(loserTr.Sends()) == 0, "losing resume must not complete Welcome")656 require.True(t, loserTr.Closed() || len(loserTr.Sends()) == 0, "losing resume must not complete Welcome")
674 657
658+ require.True(t, d.commitResumeClaim(ac), "successful concurrent resume must consume its parked credential")
675 d.mu.Lock()659 d.mu.Lock()
676 _, oldTokenParked := d.parked[token]660 _, oldTokenParked := d.parked[token]
677 _, stillParking := d.parking[token]661 _, stillParking := d.parking[token]
@@ -680,370 +664,11 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
680 require.False(t, stillParking, "parking marker must be consumed after park/resume")664 require.False(t, stillParking, "parking marker must be consumed after park/resume")
681 665
682 d.clientGone(sess, ac, oldTr, false)666 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")
684 require.Same(t, winnerTr, ac.transport())668 require.Same(t, winnerTr, ac.transport())
685 require.False(t, winnerTr.Closed())669 require.False(t, winnerTr.Closed())
686 }670 }
687 671
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.
1047 func TestExplicitDetachClearsOrphanedSameAttachmentParkingMarker(t *testing.T) {672 func TestExplicitDetachClearsOrphanedSameAttachmentParkingMarker(t *testing.T) {
1048 workPTY, releaseWork := newBlockingPTY(t)673 workPTY, releaseWork := newBlockingPTY(t)
1049 defer releaseWork()674 defer releaseWork()
@@ -1085,7 +710,7 @@ func TestExplicitDetachClearsOrphanedSameAttachmentParkingMarker(t *testing.T) {
1085 awaitTestCompletion(t, waiterArmed, "parking waiter did not arm on the orphaned marker")710 awaitTestCompletion(t, waiterArmed, "parking waiter did not arm on the orphaned marker")
1086 711
1087 d.clientGone(sess, ac, oldTr, true)712 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")
1089 714
1090 waited := awaitTestValue(t, waiterDone, "parking waiter stayed blocked after explicit detach")715 waited := awaitTestValue(t, waiterDone, "parking waiter stayed blocked after explicit detach")
1091 require.True(t, waited, "waiter must observe the same-attachment marker before it is cleared")716 require.True(t, waited, "waiter must observe the same-attachment marker before it is cleared")
@@ -1144,7 +769,7 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) {
1144 win: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, oldTr *closeTrackingTransport, token uint64) {769 win: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, oldTr *closeTrackingTransport, token uint64) {
1145 t.Helper()770 t.Helper()
1146 d.clientGone(sess, ac, oldTr, true)771 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")
1148 },773 },
1149 },774 },
1150 {775 {
@@ -1170,7 +795,7 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) {
1170 require.NoError(t, err)795 require.NoError(t, err)
1171 token := ac.resumeToken796 token := ac.resumeToken
1172 require.NotZero(t, token)797 require.NotZero(t, token)
1173- require.Same(t, ac, sess.client)798+ require.Contains(t, sess.snapshotAttachments(), ac)
1174 799
1175 validated := make(chan struct{})800 validated := make(chan struct{})
1176 releaseMark := make(chan struct{})801 releaseMark := make(chan struct{})
@@ -1227,7 +852,7 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) {
1227 require.NotZero(t, token)852 require.NotZero(t, token)
1228 853
1229 require.True(t, d.detachIfCurrentTransport(sess, ac, ac.transportSnapshot()))854 require.True(t, d.detachIfCurrentTransport(sess, ac, ac.transportSnapshot()))
1230- require.Nil(t, sess.client)855+ require.Empty(t, sess.snapshotAttachments())
1231 require.Zero(t, d.markParkingInFlight(sess, ac), "detached active attachment must not publish a late marker")856 require.Zero(t, d.markParkingInFlight(sess, ac), "detached active attachment must not publish a late marker")
1232 857
1233 d.mu.Lock()858 d.mu.Lock()
@@ -1249,46 +874,6 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) {
1249 // TestMarkParkingInFlightRequiresExactLiveOwnership covers lock-safe rejection874 // TestMarkParkingInFlightRequiresExactLiveOwnership covers lock-safe rejection
1250 // when the attachment is no longer the exact active or snatched owner, while875 // when the attachment is no longer the exact active or snatched owner, while
1251 // still advertising for both live roles.876 // 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.
1292 func TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment(t *testing.T) {877 func TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment(t *testing.T) {
1293 pty, release := newBlockingPTY(t)878 pty, release := newBlockingPTY(t)
1294 defer release()879 defer release()
@@ -1326,7 +911,7 @@ func TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment(t *tes
1326 releaseOnce.Do(func() { close(releaseDetach) })911 releaseOnce.Do(func() { close(releaseDetach) })
1327 awaitTestCompletion(t, goneDone, "stale clientGone did not finish after resume rebound")912 awaitTestCompletion(t, goneDone, "stale clientGone did not finish after resume rebound")
1328 913
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")
1330 require.Same(t, newTr, ac.transport())915 require.Same(t, newTr, ac.transport())
1331 require.False(t, newTr.Closed(), "rebound transport must survive stale old-link cleanup")916 require.False(t, newTr.Closed(), "rebound transport must survive stale old-link cleanup")
1332 require.True(t, oldTr.Closed(), "live resume retires the captured old transport")917 require.True(t, oldTr.Closed(), "live resume retires the captured old transport")
@@ -1346,7 +931,7 @@ func TestResumeLiveAttachmentParkFailureRetiresOldTransport(t *testing.T) {
1346 token := ac.resumeToken931 token := ac.resumeToken
1347 require.NotZero(t, token)932 require.NotZero(t, token)
1348 933
1349- d.afterDetachRoleEffectsFrozen = func() {934+ d.afterDetachAttachmentEffectsFrozen = func() {
1350 d.mu.Lock()935 d.mu.Lock()
1351 d.closing = true936 d.closing = true
1352 d.mu.Unlock()937 d.mu.Unlock()
@@ -1357,7 +942,7 @@ func TestResumeLiveAttachmentParkFailureRetiresOldTransport(t *testing.T) {
1357 var pe *protoErr942 var pe *protoErr
1358 require.ErrorAs(t, err, &pe)943 require.ErrorAs(t, err, &pe)
1359 require.Equal(t, ports.ErrServerShutdown, pe.code)944 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")
1361 require.Nil(t, ac.transport(), "failed live park must revoke the captured old transport")946 require.Nil(t, ac.transport(), "failed live park must revoke the captured old transport")
1362 require.True(t, oldTr.Closed(), "failed live park must close the captured old transport")947 require.True(t, oldTr.Closed(), "failed live park must close the captured old transport")
1363 d.mu.Lock()948 d.mu.Lock()
@@ -1383,7 +968,7 @@ func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) {
1383 require.Same(t, sess, resumedSess)968 require.Same(t, sess, resumedSess)
1384 require.Same(t, ac, resumedAC)969 require.Same(t, ac, resumedAC)
1385 require.NotEqual(t, oldToken, resumedAC.resumeToken, "resume rotates token")970 require.NotEqual(t, oldToken, resumedAC.resumeToken, "resume rotates token")
1386- require.Same(t, resumedAC, sess.client)971+ require.Contains(t, sess.snapshotAttachments(), resumedAC)
1387 }972 }
1388 973
1389 func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {974 func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {
@@ -1458,7 +1043,7 @@ func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing.
1458 token := ac.resumeToken1043 token := ac.resumeToken
1459 require.True(t, sess.detachIfCurrent(ac))1044 require.True(t, sess.detachIfCurrent(ac))
1460 require.True(t, d.parkAttachment(sess, ac))1045 require.True(t, d.parkAttachment(sess, ac))
1461- generation := ac.roleGeneration.Load()1046+ generation := ac.connectionGeneration.Load()
1462 1047
1463 newTr := &closeTrackingTransport{}1048 newTr := &closeTrackingTransport{}
1464 resumedSess, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), newTr, domain.Size{Cols: 80, Rows: 24})1049 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.
1466 require.True(t, ok)1051 require.True(t, ok)
1467 require.Same(t, sess, resumedSess)1052 require.Same(t, sess, resumedSess)
1468 require.Same(t, ac, resumedAC)1053 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")
1470 1055
1471 _ = ac.closeCapturedTransport(oldTr)1056 _ = ac.closeCapturedTransport(oldTr)
1472 require.True(t, oldTr.Closed(), "old transport is closed")1057 require.True(t, oldTr.Closed(), "old transport is closed")
@@ -1490,8 +1075,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
1490 resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr)1075 resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr)
1491 require.NoError(t, err)1076 require.NoError(t, err)
1492 require.Same(t, ac, resumedAC)1077 require.Same(t, ac, resumedAC)
1493- require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedSess.renderCoordinator().attachmentLease(resumedAC)))1078+ d.paint(resumedSess, resumedAC, true, nil)
1494- d.firstPaint(resumedSess, resumedAC, resumedAC.size)
1495 1079
1496 sends := newTr.Sends()1080 sends := newTr.Sends()
1497 require.Len(t, sends, 1)1081 require.Len(t, sends, 1)
@@ -1538,7 +1122,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
1538 require.True(t, sess.detachIfCurrent(ac))1122 require.True(t, sess.detachIfCurrent(ac))
1539 require.True(t, d.parkAttachment(sess, ac))1123 require.True(t, d.parkAttachment(sess, ac))
1540 1124
1541- // Headless close cannot find the parked attachment through sess.client.1125+ // Headless close cannot find the parked attachment through sess.snapshotAttachments()[0].
1542 // Its capture must already have been released before the attachment parked.1126 // Its capture must already have been released before the attachment parked.
1543 require.NoError(t, d.closePane(sess, tb, closed.id, nil, false))1127 require.NoError(t, d.closePane(sess, tb, closed.id, nil, false))
1544 ac.sendMu.Lock()1128 ac.sendMu.Lock()
@@ -1734,7 +1318,7 @@ func TestEphemeralParkExpiryKeepsSession(t *testing.T) {
1734 1318
1735 require.Equal(t, 1, sessionCount(d), "token expiry does not kill ephemeral session")1319 require.Equal(t, 1, sessionCount(d), "token expiry does not kill ephemeral session")
1736 sess.mu.Lock()1320 sess.mu.Lock()
1737- require.Nil(t, sess.client)1321+ require.Empty(t, sess.snapshotAttachmentsLocked())
1738 sess.mu.Unlock()1322 sess.mu.Unlock()
1739 }1323 }
1740 1324
@@ -1828,34 +1412,6 @@ func TestKilledSessionUnblocksParkingWaiterFailsClosed(t *testing.T) {
1828 require.Contains(t, killedErr.Error(), "resume token is no longer valid")1412 require.Contains(t, killedErr.Error(), "resume token is no longer valid")
1829 }1413 }
1830 1414
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-
1859 func TestStaleClientGoneDoesNotDetachOrCloseFreshTransport(t *testing.T) {1415 func TestStaleClientGoneDoesNotDetachOrCloseFreshTransport(t *testing.T) {
1860 p, release := newBlockingPTY(t)1416 p, release := newBlockingPTY(t)
1861 defer release()1417 defer release()
@@ -1869,7 +1425,7 @@ func TestStaleClientGoneDoesNotDetachOrCloseFreshTransport(t *testing.T) {
1869 1425
1870 d.clientGone(sess, ac, oldTr, false)1426 d.clientGone(sess, ac, oldTr, false)
1871 1427
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")
1873 require.False(t, oldTr.Closed(), "stale transport is owned by its own loop/handler")1429 require.False(t, oldTr.Closed(), "stale transport is owned by its own loop/handler")
1874 require.False(t, freshTr.Closed(), "fresh resumed transport must not be closed by stale loop")1430 require.False(t, freshTr.Closed(), "fresh resumed transport must not be closed by stale loop")
1875 }1431 }
M internal/usecase/daemon/resume_wake_incarnation_test.go +42 -0
@@ -66,3 +66,45 @@ func TestParkedResumeRejectsDispatchedWakeFromPriorAttachment(t *testing.T) {
66 d.firstPaint(sess, ac, ac.size)66 d.firstPaint(sess, ac, ac.size)
67 require.NotEmpty(t, newTransport.Sends())67 require.NotEmpty(t, newTransport.Sends())
68 }68 }
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 {
184 }184 }
185 p.commit()185 p.commit()
186 p.stream.state.next = p.next186 p.stream.state.next = p.next
187+ p.stream.state.publishOutstanding()
187 return nil188 return nil
188 }189 }
189 190
M internal/usecase/daemon/session.go +171 -166
@@ -51,14 +51,13 @@ type session struct {
51 teardownWaiters uint51 teardownWaiters uint
52 teardownChanged chan struct{}52 teardownChanged chan struct{}
53 lifecycleStopOnce sync.Once53 lifecycleStopOnce sync.Once
54- // sessionCore.mu guards tabs, active, attachment membership, restoreDone,54+ // sessionCore.mu guards tabs, attachment membership, restoreDone, clipFiles,
55- // clipFiles, and clipboard queue state.55+ // and clipboard queue state.
56 // restoreDone remains attached to a restored session after it is published in56 // restoreDone remains attached to a restored session after it is published in
57 // the live registry, so racing attaches cannot bypass restoration completion.57 // the live registry, so racing attaches cannot bypass restoration completion.
58 restoreDone chan struct{}58 restoreDone chan struct{}
59 themeMu sync.Mutex59 themeMu sync.Mutex
60 tabs []*tab60 tabs []*tab
61- active int
62 clipboardQueue []clipboardForward61 clipboardQueue []clipboardForward
63 clipboardWorkerRunning bool62 clipboardWorkerRunning bool
64 cwd string63 cwd string
@@ -242,6 +241,9 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st
242 }241 }
243 }242 }
244 243
244+ if d.ptys == nil {
245+ return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("daemon: PTY factory is unavailable"))
246+ }
245 tbSize := contentSize(sz, proxied)247 tbSize := contentSize(sz, proxied)
246 var names []string248 var names []string
247 if len(restoredTabNames) > 0 {249 if len(restoredTabNames) > 0 {
@@ -416,27 +418,26 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
416 cwd := from.cwd418 cwd := from.cwd
417 term := from.terminal419 term := from.terminal
418 env := copyEnvironment(from.env)420 env := copyEnvironment(from.env)
419- if from.client != ac {421+ _, attached := from.attachments[ac]
420- from.mu.Unlock()422+ from.mu.Unlock()
423+ if !attached {
421 d.mu.Unlock()424 d.mu.Unlock()
422 return errors.New("client detached")425 return errors.New("client detached")
423 }426 }
424- from.mu.Unlock()
425 427
426 newSess, err := d.createSessionLockedWithMode(name, false, cwd, sz, term, env, ac.proxied)428 newSess, err := d.createSessionLockedWithMode(name, false, cwd, sz, term, env, ac.proxied)
427 d.mu.Unlock()429 d.mu.Unlock()
428 if err != nil {430 if err != nil {
429 return err431 return err
430 }432 }
431- // The newly created target is registered before the role transition, but433+ // The newly created target is registered before the attachment transition, but
432 // remains headless. Freeze/drain must occur with no daemon/routing/session434 // remains headless. Freeze/drain must occur with no daemon/routing/session
433 // lock held; the transition revalidates both lifecycles before publication.435 // lock held; the transition revalidates both lifecycles before publication.
434 transition, err := d.transitionAttachment(attachmentTransitionRequest{436 transition, err := d.transitionAttachment(attachmentTransitionRequest{
435- source: from,437+ source: from,
436- target: newSess,438+ target: newSess,
437- next: ac,439+ next: ac,
438- expectedRole: attachmentActive,440+
439- targetRole: attachmentActive,
440 expectedTransport: ac.transportSnapshot(),441 expectedTransport: ac.transportSnapshot(),
441 ready: true,442 ready: true,
442 })443 })
@@ -453,16 +454,16 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
453 return nil454 return nil
454 }455 }
455 456
456-// createSessionAndSwitchForRole creates no target until the initiating role457+// createSessionAndSwitchForAttachment creates no target until the initiating role
457 // ticket has ended and the exact source capability is frozen and revalidated.458 // 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 {
459 if name == "" {460 if name == "" {
460 return errSessionNameRequired461 return errSessionNameRequired
461 }462 }
462 if err := domain.ValidateSessionName(name); err != nil {463 if err := domain.ValidateSessionName(name); err != nil {
463 return err464 return err
464 }465 }
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 {
466 return errAttachmentTransition467 return errAttachmentTransition
467 }468 }
468 token.effect.bindActionEnd(d, "create-session")469 token.effect.bindActionEnd(d, "create-session")
@@ -471,7 +472,7 @@ func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name s
471 var created *session472 var created *session
472 transition, err := d.transitionAttachment(attachmentTransitionRequest{473 transition, err := d.transitionAttachment(attachmentTransitionRequest{
473 source: token.sess, next: token.ac,474 source: token.sess, next: token.ac,
474- expectedRole: attachmentActive, targetRole: attachmentActive,475+
475 expectedTransport: token.transport, sourceToken: &token, action: "create-session",476 expectedTransport: token.transport, sourceToken: &token, action: "create-session",
476 copySourceEnvironment: true, ready: true,477 copySourceEnvironment: true, ready: true,
477 createTargetLocked: func() (*session, error) {478 createTargetLocked: func() (*session, error) {
@@ -511,14 +512,26 @@ func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name s
511 }512 }
512 513
513 func (d *Daemon) createTab(sess *session, sz domain.Size) error {514 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 {
514 sess.mu.Lock()519 sess.mu.Lock()
515 name := sess.name520 name := sess.name
516 cwd := sess.cwd521 cwd := sess.cwd
517- client := sess.client
518 term := sess.terminal522 term := sess.terminal
519 env := copyEnvironment(sess.env)523 env := copyEnvironment(sess.env)
524+ attachments := sess.snapshotAttachmentsLocked()
520 sess.mu.Unlock()525 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)
522 tabStableID, paneStableID, err := d.newTabPaneStableIDs()535 tabStableID, paneStableID, err := d.newTabPaneStableIDs()
523 if err != nil {536 if err != nil {
524 return err537 return err
@@ -535,8 +548,8 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
535 return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err)548 return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err)
536 }549 }
537 tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)550 tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
538- if client != nil {551+ if themeClient != nil {
539- t := d.effectiveTheme(client.getClientTheme())552+ t := d.effectiveTheme(themeClient.getClientTheme())
540 tb.mu.Lock()553 tb.mu.Lock()
541 p := tb.focusedPane()554 p := tb.focusedPane()
542 tb.mu.Unlock()555 tb.mu.Unlock()
@@ -566,11 +579,13 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
566 // Publish ownership before appending the tab to the visible session.579 // Publish ownership before appending the tab to the visible session.
567 publishPaneOwner(p, sess, tb, 0)580 publishPaneOwner(p, sess, tb, 0)
568 sess.tabs = append(sess.tabs, tb)581 sess.tabs = append(sess.tabs, tb)
569- sess.active = len(sess.tabs) - 1582+ tabIndex := len(sess.tabs) - 1
570- tabIndex := sess.active
571 if !sess.ephemeral {583 if !sess.ephemeral {
572 d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())584 d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
573 }585 }
586+ if ac != nil {
587+ sess.activateAttachmentViewLocked(ac, tabIndex)
588+ }
574 sess.mu.Unlock()589 sess.mu.Unlock()
575 d.mu.Unlock()590 d.mu.Unlock()
576 d.log.Info("tab created", "session", name, "tab", tabIndex)591 d.log.Info("tab created", "session", name, "tab", tabIndex)
@@ -661,8 +676,8 @@ func contentSize(clientSize domain.Size, proxied bool) domain.Size {
661 func (s *session) fullViewportSize() domain.Size {676 func (s *session) fullViewportSize() domain.Size {
662 s.mu.Lock()677 s.mu.Lock()
663 var tb *tab678 var tb *tab
664- if s.active >= 0 && s.active < len(s.tabs) {679+ if len(s.tabs) != 0 {
665- tb = s.tabs[s.active]680+ tb = s.tabs[0]
666 }681 }
667 s.mu.Unlock()682 s.mu.Unlock()
668 683
@@ -698,7 +713,8 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
698 return713 return
699 }714 }
700 if owner.session != sess || owner.tab != tb {715 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
702 }718 }
703 sess.mu.Lock()719 sess.mu.Lock()
704 name := sess.name720 name := sess.name
@@ -709,21 +725,18 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
709 go d.readPanePTY(p)725 go d.readPanePTY(p)
710 }726 }
711 727
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-
715 func (s *session) detachIfCurrent(ac *attachedClient) bool {728 func (s *session) detachIfCurrent(ac *attachedClient) bool {
716 s.mu.Lock()729 s.mu.Lock()
717 defer s.mu.Unlock()730 defer s.mu.Unlock()
718- if s.client == ac {731+ if !attachmentRegisteredLocked(s, ac) || ac.currentAttachmentSession() != s {
719- s.client = nil732+ return false
720- ac.setSession(nil)
721- return true
722 }733 }
723- return false734+ s.unregisterAttachmentLocked(ac)
735+ ac.setSession(nil)
736+ return true
724 }737 }
725 738
726-// detachIfCurrent publishes terminal role invalidation through the attachment739+// detachIfCurrent publishes terminal attachment invalidation through the attachment
727 // gate. Freeze/drain happens before routing or session locks are acquired.740 // gate. Freeze/drain happens before routing or session locks are acquired.
728 func (d *Daemon) detachIfCurrent(sess *session, ac *attachedClient) bool {741 func (d *Daemon) detachIfCurrent(sess *session, ac *attachedClient) bool {
729 return d.detachIfCurrentTransport(sess, ac, transportSnapshot{})742 return d.detachIfCurrentTransport(sess, ac, transportSnapshot{})
@@ -736,22 +749,22 @@ func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, exp
736 if sess == nil || ac == nil {749 if sess == nil || ac == nil {
737 return false750 return false
738 }751 }
739- frozen := freezeRoleEffectGates(ac)752+ frozen := freezeAttachmentEffectGates(ac)
740 defer frozen.unfreeze()753 defer frozen.unfreeze()
741- if d.afterDetachRoleEffectsFrozen != nil {754+ if d.afterDetachAttachmentEffectsFrozen != nil {
742- d.afterDetachRoleEffectsFrozen()755+ d.afterDetachAttachmentEffectsFrozen()
743 }756 }
744 d.notices.routingMu.Lock()757 d.notices.routingMu.Lock()
745 sess.mu.Lock()758 sess.mu.Lock()
746- current := sess.client == ac && ac.currentSession() == sess759+ current := attachmentRegisteredLocked(sess, ac) && ac.currentAttachmentSession() == sess
747 if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) {760 if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) {
748 current = false761 current = false
749 }762 }
750 if current {763 if current {
751- sess.client = nil764+ sess.unregisterAttachmentLocked(ac)
752- ac.roleGeneration.Add(1)765+ ac.connectionGeneration.Add(1)
753 ac.setSession(nil)766 ac.setSession(nil)
754- ac.invalidateFrozenRoleCapability()767+ ac.invalidateFrozenAttachmentCapability()
755 }768 }
756 sess.mu.Unlock()769 sess.mu.Unlock()
757 d.notices.routingMu.Unlock()770 d.notices.routingMu.Unlock()
@@ -766,22 +779,22 @@ func (d *Daemon) detachProxyIfCurrentTransport(p *proxySession, ac *attachedClie
766 if p == nil || ac == nil {779 if p == nil || ac == nil {
767 return false780 return false
768 }781 }
769- frozen := freezeRoleEffectGates(ac)782+ frozen := freezeAttachmentEffectGates(ac)
770 defer frozen.unfreeze()783 defer frozen.unfreeze()
771- if d.afterDetachRoleEffectsFrozen != nil {784+ if d.afterDetachAttachmentEffectsFrozen != nil {
772- d.afterDetachRoleEffectsFrozen()785+ d.afterDetachAttachmentEffectsFrozen()
773 }786 }
774 d.notices.routingMu.Lock()787 d.notices.routingMu.Lock()
775 p.sessionCore.mu.Lock()788 p.sessionCore.mu.Lock()
776- current := p.client == ac && ac.currentAttachmentSession() == attachmentSession(p)789+ current := attachmentRegisteredLocked(attachmentSession(p), ac) && ac.currentAttachmentSession() == attachmentSession(p)
777 if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) {790 if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) {
778 current = false791 current = false
779 }792 }
780 if current {793 if current {
781- p.client = nil794+ unregisterAttachmentSessionLocked(attachmentSession(p), ac)
782- ac.roleGeneration.Add(1)795+ ac.connectionGeneration.Add(1)
783 ac.setSession(nil)796 ac.setSession(nil)
784- ac.invalidateFrozenRoleCapability()797+ ac.invalidateFrozenAttachmentCapability()
785 }798 }
786 p.sessionCore.mu.Unlock()799 p.sessionCore.mu.Unlock()
787 d.notices.routingMu.Unlock()800 d.notices.routingMu.Unlock()
@@ -791,24 +804,36 @@ func (d *Daemon) detachProxyIfCurrentTransport(p *proxySession, ac *attachedClie
791 return current804 return current
792 }805 }
793 806
794-// detachIfRoleCurrent invalidates only the exact role generation and transport807+// detachIfAttachmentCurrent invalidates only the exact attachment generation and transport
795-// whose admitted send failed. A queued stale error cannot detach a later role808+// whose admitted send failed. A queued stale error cannot detach a later
796-// publication or a rebound transport on the same attachment object.809+// attachment publication or a rebound transport on the same attachment object.
797-func (d *Daemon) detachIfRoleCurrent(token attachmentRoleToken) bool {810+func (d *Daemon) detachIfAttachmentCurrent(token attachmentConnectionToken) bool {
798- return d.detachIfRoleCurrentUntil(token, nil)811+ return d.detachIfAttachmentCurrentUntil(token, nil)
799 }812 }
800 813
801-func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func() <-chan struct{}) bool {814+func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken, done func() <-chan struct{}) bool {
802- if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.transport.transport == nil {815+ if token.sess == nil || token.ac == nil || token.transport.transport == nil {
803 return false816 return false
804 }817 }
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)
806 defer frozen.unfreeze()831 defer frozen.unfreeze()
807 if !frozen.acquired || !frozen.drained {832 if !frozen.acquired || !frozen.drained {
808 return false833 return false
809 }834 }
810- if d.afterDetachRoleEffectsFrozen != nil {835+ if d.afterDetachAttachmentEffectsFrozen != nil {
811- d.afterDetachRoleEffectsFrozen()836+ d.afterDetachAttachmentEffectsFrozen()
812 }837 }
813 d.mu.Lock()838 d.mu.Lock()
814 core := token.sess.core()839 core := token.sess.core()
@@ -823,15 +848,14 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func()
823 coordinator.mu.Lock()848 coordinator.mu.Lock()
824 }849 }
825 req := attachmentTransitionRequest{850 req := attachmentTransitionRequest{
826- source: token.sess, next: token.ac, expectedRole: token.role,851+ source: token.sess, next: token.ac, expectedTransport: token.transport,
827- expectedTransport: token.transport,
828 }852 }
829 current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)853 current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
830 if current {854 if current {
831- core.client = nil855+ unregisterAttachmentSessionLocked(token.sess, token.ac)
832- token.ac.roleGeneration.Add(1)856+ token.ac.connectionGeneration.Add(1)
833 token.ac.setSession(nil)857 token.ac.setSession(nil)
834- token.ac.invalidateFrozenRoleCapability()858+ token.ac.invalidateFrozenAttachmentCapability()
835 }859 }
836 if coordinator != nil {860 if coordinator != nil {
837 coordinator.mu.Unlock()861 coordinator.mu.Unlock()
@@ -842,39 +866,6 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func()
842 return current866 return current
843 }867 }
844 868
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-
878 func (d *Daemon) renameSession(sess *session, name string) error {869 func (d *Daemon) renameSession(sess *session, name string) error {
879 if name == "" {870 if name == "" {
880 return errSessionNameRequired871 return errSessionNameRequired
@@ -1007,7 +998,24 @@ func (s *session) persistRecordLocked(updatedAt int64) domain.CatalogueRecord {
1007 return domain.CatalogueRecord{Name: s.name, IncarnationID: s.incarnation, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames}998 return domain.CatalogueRecord{Name: s.name, IncarnationID: s.incarnation, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames}
1008 }999 }
1009 1000
1001+// closeTab is the public close boundary for callers that are not already
1002+// inside session.runMutation. Mutation dispatchers use closeTabLocked.
1010 func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {1003 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 {
1011 if sess == nil || tb == nil {1019 if sess == nil || tb == nil {
1012 return layout.ErrNotFound1020 return layout.ErrNotFound
1013 }1021 }
@@ -1033,7 +1041,14 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
1033 name := sess.name1041 name := sess.name
1034 sess.mu.Unlock()1042 sess.mu.Unlock()
1035 d.mu.Unlock()1043 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 {
1037 d.log.Warn("closing last tab failed", "session", name, "err", err)1052 d.log.Warn("closing last tab failed", "session", name, "err", err)
1038 d.reportError(sess, domain.UserErr(domain.NoticeSnapshotSaturated,1053 d.reportError(sess, domain.UserErr(domain.NoticeSnapshotSaturated,
1039 "couldn't close tab: session state not yet saved; try again", err))1054 "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 {
1043 return nil1058 return nil
1044 }1059 }
1045 ringing := tb.attention1060 ringing := tb.attention
1046- wasActive := idx == sess.active1061+ sess.prepareAttachmentViewsForRemovedTabLocked(tb, idx)
1047 sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)1062 sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)
1048- if sess.active >= len(sess.tabs) {1063+ attachments := sess.snapshotAttachmentsLocked()
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
1055 name := sess.name1064 name := sess.name
1056 if !sess.ephemeral {1065 if !sess.ephemeral {
1057 d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())1066 d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
1058 }1067 }
1068+ sess.invalidateViewsLocked()
1059 sess.mu.Unlock()1069 sess.mu.Unlock()
1060 d.mu.Unlock()1070 d.mu.Unlock()
1061 d.log.Info("tab closed", "session", name)1071 d.log.Info("tab closed", "session", name)
@@ -1065,24 +1075,25 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
1065 tb.mu.Lock()1075 tb.mu.Lock()
1066 panes := tb.panesSnapshot()1076 panes := tb.panesSnapshot()
1067 tb.mu.Unlock()1077 tb.mu.Unlock()
1068- if ac != nil {1078+ for _, ac := range attachments {
1069 ac.pruneCaptureFrames(panes...)1079 ac.pruneCaptureFrames(panes...)
1070 }1080 }
1071 if tb.cancel != nil {1081 if tb.cancel != nil {
1072 tb.cancel()1082 tb.cancel()
1073 }1083 }
1074- d.teardownFloating(tb, ac)1084+ for _, ac := range attachments {
1085+ d.teardownFloating(tb, ac)
1086+ }
1075 if rc := sess.renderCoordinator(); rc != nil {1087 if rc := sess.renderCoordinator(); rc != nil {
1076 for _, p := range panes {1088 for _, p := range panes {
1077 rc.noteSyncPaneRemoved(p)1089 rc.noteSyncPaneRemoved(p)
1078 }1090 }
1079 }1091 }
1080 tb.closeAllPanes()1092 tb.closeAllPanes()
1081- if wasActive {1093+ if repaint {
1082- d.activateTab(sess, destination)1094+ for _, ac := range attachments {
1083- }1095+ d.invalidateRender(sess, ac, true, "session.go")
1084- if repaint && ac != nil {1096+ }
1085- d.invalidateRender(sess, ac, true, "session.go")
1086 }1097 }
1087 if ringing {1098 if ringing {
1088 d.repaintAllAttachedClients()1099 d.repaintAllAttachedClients()
@@ -1160,34 +1171,33 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
1160 }1171 }
1161 1172
1162 type sessionKillAdmission struct {1173 type sessionKillAdmission struct {
1163- token attachmentRoleToken1174+ token attachmentConnectionToken
1164 action string1175 action string
1165 }1176 }
1166 1177
1167 type sessionKillParticipants struct {1178 type sessionKillParticipants struct {
1168- active *attachedClient1179+ attachments map[*attachedClient]struct{}
1169- snatched map[*attachedClient]struct{}
1170 transports map[*attachedClient]transportSnapshot1180 transports map[*attachedClient]transportSnapshot
1171- roleGates []*attachedClient1181+ effectGates []*attachedClient
1172- interrupts []roleTransportInterrupt1182+ interrupts []attachmentTransportInterrupt
1173 source *session1183 source *session
1174- sourceToken *attachmentRoleToken1184+ sourceToken *attachmentConnectionToken
1175 target *session1185 target *session
1176 }1186 }
1177 1187
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 {
1179 return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil, &sessionKillAdmission{token: token, action: action})1189 return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil, &sessionKillAdmission{token: token, action: action})
1180 }1190 }
1181 1191
1182-// snapshotSessionKillParticipants captures role membership under the canonical1192+// snapshotSessionKillParticipants captures attachment membership under the canonical
1183-// architecture locks, but releases every lock before any role gate is frozen or1193+// architecture locks, but releases every lock before any attachment gate is frozen or
1184 // drained. The initiator is part of the same deduplicated global gate set even1194 // drained. The initiator is part of the same deduplicated global gate set even
1185 // when it belongs to a different source session.1195 // when it belongs to a different source session.
1186 func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *sessionKillAdmission) (sessionKillParticipants, bool) {1196 func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *sessionKillAdmission) (sessionKillParticipants, bool) {
1187 snapshot := sessionKillParticipants{target: target}1197 snapshot := sessionKillParticipants{target: target}
1188 if admission != nil {1198 if admission != nil {
1189 token := admission.token1199 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 {
1191 return sessionKillParticipants{}, false1201 return sessionKillParticipants{}, false
1192 }1202 }
1193 var ok bool1203 var ok bool
@@ -1209,21 +1219,18 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
1209 source = target1219 source = target
1210 }1220 }
1211 unlockSessions := lockAttachmentSessions(source, target)1221 unlockSessions := lockAttachmentSessions(source, target)
1212- snapshot.active = target.client1222+ snapshot.attachments = make(map[*attachedClient]struct{}, len(target.attachments))
1213- snapshot.snatched = make(map[*attachedClient]struct{}, len(target.snatched))1223+ snapshot.transports = make(map[*attachedClient]transportSnapshot, len(target.attachments))
1214- snapshot.transports = make(map[*attachedClient]transportSnapshot, 1+len(target.snatched))1224+ for ac := range target.attachments {
1215- if snapshot.active != nil {1225+ snapshot.attachments[ac] = struct{}{}
1216- snapshot.transports[snapshot.active] = snapshot.active.transportSnapshot()
1217- }
1218- for ac := range target.snatched {
1219- snapshot.snatched[ac] = struct{}{}
1220 snapshot.transports[ac] = ac.transportSnapshot()1226 snapshot.transports[ac] = ac.transportSnapshot()
1221 }1227 }
1228+
1222 unlockSessions()1229 unlockSessions()
1223 d.notices.routingMu.Unlock()1230 d.notices.routingMu.Unlock()
1224 d.mu.Unlock()1231 d.mu.Unlock()
1225 1232
1226- seen := make(map[*attachedClient]struct{}, 2+len(snapshot.snatched))1233+ seen := make(map[*attachedClient]struct{}, 1+len(snapshot.attachments))
1227 add := func(ac *attachedClient) {1234 add := func(ac *attachedClient) {
1228 if ac == nil {1235 if ac == nil {
1229 return1236 return
@@ -1232,25 +1239,24 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
1232 return1239 return
1233 }1240 }
1234 seen[ac] = struct{}{}1241 seen[ac] = struct{}{}
1235- snapshot.roleGates = append(snapshot.roleGates, ac)1242+ snapshot.effectGates = append(snapshot.effectGates, ac)
1236 }1243 }
1237 if admission != nil {1244 if admission != nil {
1238 add(admission.token.ac)1245 add(admission.token.ac)
1239 }1246 }
1240- add(snapshot.active)1247+ for ac := range snapshot.attachments {
1241- for ac := range snapshot.snatched {
1242 add(ac)1248 add(ac)
1243 }1249 }
1244 for ac, transport := range snapshot.transports {1250 for ac, transport := range snapshot.transports {
1245 if transport.transport != nil {1251 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})
1247 }1253 }
1248 }1254 }
1249 return snapshot, true1255 return snapshot, true
1250 }1256 }
1251 1257
1252 // sessionKillParticipantsCurrent repeats lifecycle, source-token, and exact1258 // 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.
1254 func (d *Daemon) sessionKillParticipantsCurrent(snapshot sessionKillParticipants) bool {1260 func (d *Daemon) sessionKillParticipantsCurrent(snapshot sessionKillParticipants) bool {
1255 d.mu.Lock()1261 d.mu.Lock()
1256 defer d.mu.Unlock()1262 defer d.mu.Unlock()
@@ -1277,14 +1283,15 @@ func (d *Daemon) sessionKillParticipantsCurrent(snapshot sessionKillParticipants
1277 // session and coordinator locks.1283 // session and coordinator locks.
1278 func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sourceCoordinator *renderCoordinator) bool {1284 func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sourceCoordinator *renderCoordinator) bool {
1279 target := snapshot.target1285 target := snapshot.target
1280- if target.client != snapshot.active || len(target.snatched) != len(snapshot.snatched) {1286+ if len(target.attachments) != len(snapshot.attachments) {
1281 return false1287 return false
1282 }1288 }
1283- for ac := range snapshot.snatched {1289+ for ac := range snapshot.attachments {
1284- if _, current := target.snatched[ac]; !current {1290+ if _, current := target.attachments[ac]; !current {
1285 return false1291 return false
1286 }1292 }
1287 }1293 }
1294+
1288 for ac, transport := range snapshot.transports {1295 for ac, transport := range snapshot.transports {
1289 if !ac.transportSnapshotCurrent(transport) {1296 if !ac.transportSnapshotCurrent(transport) {
1290 return false1297 return false
@@ -1295,8 +1302,7 @@ func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sour
1295 }1302 }
1296 token := *snapshot.sourceToken1303 token := *snapshot.sourceToken
1297 req := attachmentTransitionRequest{1304 req := attachmentTransitionRequest{
1298- source: token.sess, next: token.ac, expectedRole: token.role,1305+ source: token.sess, next: token.ac, expectedTransport: token.transport,
1299- expectedTransport: token.transport,
1300 }1306 }
1301 return transitionSourceTokenCurrentLocked(token, token.sess, sourceCoordinator, req)1307 return transitionSourceTokenCurrentLocked(token, token.sess, sourceCoordinator, req)
1302 }1308 }
@@ -1406,7 +1412,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1406 if sess == nil {1412 if sess == nil {
1407 return nil1413 return nil
1408 }1414 }
1409- // Discover every role whose membership can be invalidated before freezing1415+ // Discover every attachment whose membership can be invalidated before freezing
1410 // any one of them. The common gate helper deduplicates and freezes the whole1416 // any one of them. The common gate helper deduplicates and freezes the whole
1411 // immutable identity set in the same global order used by transitions.1417 // immutable identity set in the same global order used by transitions.
1412 participants, ok := d.snapshotSessionKillParticipants(sess, admission)1418 participants, ok := d.snapshotSessionKillParticipants(sess, admission)
@@ -1414,29 +1420,29 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1414 return nil1420 return nil
1415 }1421 }
1416 if admission != nil {1422 if admission != nil {
1417- d.endActionRoleEffect(admission.token.effect, admission.action)1423+ d.endActionAttachmentEffect(admission.token.effect, admission.action)
1418 }1424 }
1419 action := ""1425 action := ""
1420 if admission != nil {1426 if admission != nil {
1421 action = admission.action1427 action = admission.action
1422 }1428 }
1423- if d.afterRoleEffectParticipantsSnapshotted != nil {1429+ if d.afterAttachmentEffectParticipantsSnapshotted != nil {
1424- d.afterRoleEffectParticipantsSnapshotted(action, participants.roleGates)1430+ d.afterAttachmentEffectParticipantsSnapshotted(action, participants.effectGates)
1425 }1431 }
1426 var drainDone func() <-chan struct{}1432 var drainDone func() <-chan struct{}
1427- var drainDeadline *roleEffectDrainDeadline1433+ var drainDeadline *attachmentEffectDrainDeadline
1428 if deadline != nil {1434 if deadline != nil {
1429 drainDone = deadline.Done1435 drainDone = deadline.Done
1430 } else {1436 } else {
1431- drainDeadline = newRoleEffectDrainDeadline(d.clock)1437+ drainDeadline = newAttachmentEffectDrainDeadline(d.clock)
1432 drainDone = drainDeadline.Done1438 drainDone = drainDeadline.Done
1433 defer drainDeadline.stop()1439 defer drainDeadline.stop()
1434 }1440 }
1435- frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, done: drainDone, afterFrozen: func(ac *attachedClient) {1441+ frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: participants.interrupts, done: drainDone, afterFrozen: func(ac *attachedClient) {
1436- if d.afterRoleEffectGateFrozen != nil {1442+ if d.afterAttachmentEffectGateFrozen != nil {
1437- d.afterRoleEffectGateFrozen(action, ac)1443+ d.afterAttachmentEffectGateFrozen(action, ac)
1438 }1444 }
1439- }}, participants.roleGates...)1445+ }}, participants.effectGates...)
1440 defer func() { frozen.unfreeze() }()1446 defer func() { frozen.unfreeze() }()
1441 if !frozen.acquired {1447 if !frozen.acquired {
1442 // The acquisition helper has already rolled back exactly the gates it1448 // The acquisition helper has already rolled back exactly the gates it
@@ -1540,8 +1546,8 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1540 sess.snapshotMu.Unlock()1546 sess.snapshotMu.Unlock()
1541 }1547 }
1542 }1548 }
1543- // Revalidate the exact source capability, target lifecycle, and active plus1549+ // Revalidate the exact source capability, target lifecycle, and attachment
1544- // snatched memberships under canonical architecture locks immediately before1550+ // memberships under canonical architecture locks immediately before
1545 // publication. A changed preflight aborts without mutating either lifecycle.1551 // publication. A changed preflight aborts without mutating either lifecycle.
1546 d.mu.Lock()1552 d.mu.Lock()
1547 if d.sessions[sess.id] != sess || participants.source != nil && d.sessions[participants.source.id] != participants.source {1553 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
1566 }1572 }
1567 1573
1568 // d.mu -> routingMu -> ordered sessions -> ordered coordinators are held1574 // d.mu -> routingMu -> ordered sessions -> ordered coordinators are held
1569- // here. Registry removal and every role invalidation are one publication1575+ // here. Registry removal and every attachment invalidation are one publication
1570 // while the complete immutable gate set remains frozen.1576 // while the complete immutable gate set remains frozen.
1571 if !d.unregisterSessionLocked(sess) {1577 if !d.unregisterSessionLocked(sess) {
1572 unlockCoordinators()1578 unlockCoordinators()
@@ -1578,23 +1584,20 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1578 d.clearBarScriptsForSession(sess.id)1584 d.clearBarScriptsForSession(sess.id)
1579 d.purgeParkingForSessionLocked(sess)1585 d.purgeParkingForSessionLocked(sess)
1580 parkedRetirements := d.purgeParkedForSessionLocked(sess)1586 parkedRetirements := d.purgeParkedForSessionLocked(sess)
1581- detachedActive := sess.client1587+ attachments := make([]detachedAttachmentSnapshot, 0, len(participants.effectGates))
1582- attachments := make([]detachedAttachmentSnapshot, 0, len(participants.roleGates))
1583 capture := func(attached *attachedClient) {1588 capture := func(attached *attachedClient) {
1584 if attached == nil {1589 if attached == nil {
1585 return1590 return
1586 }1591 }
1587 attachments = append(attachments, detachedAttachmentSnapshot{ac: attached, transport: participants.transports[attached]})1592 attachments = append(attachments, detachedAttachmentSnapshot{ac: attached, transport: participants.transports[attached]})
1588- attached.roleGeneration.Add(1)1593+ attached.connectionGeneration.Add(1)
1589 attached.setSession(nil)1594 attached.setSession(nil)
1590- attached.invalidateFrozenRoleCapability()1595+ attached.invalidateFrozenAttachmentCapability()
1591 }1596 }
1592- capture(sess.client)1597+ for attached := range sess.attachments {
1593- for snatched := range sess.snatched {1598+ capture(attached)
1594- capture(snatched)
1595 }1599 }
1596- sess.client = nil1600+ clear(sess.attachments)
1597- clear(sess.snatched)
1598 stoppedName := sess.name1601 stoppedName := sess.name
1599 stoppedCwd := sess.cwd1602 stoppedCwd := sess.cwd
1600 createdAt := sess.createdAt1603 createdAt := sess.createdAt
@@ -1617,7 +1620,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1617 d.mu.Unlock()1620 d.mu.Unlock()
1618 d.finishParkedAttachmentRetirements(parkedRetirements)1621 d.finishParkedAttachmentRetirements(parkedRetirements)
1619 frozen.unfreeze()1622 frozen.unfreeze()
1620- frozen = frozenRoleEffectGates{}1623+ frozen = frozenAttachmentEffectGates{}
1621 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)1624 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)
1622 1625
1623 var purgeErr error1626 var purgeErr error
@@ -1645,7 +1648,9 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1645 sess.mu.Unlock()1648 sess.mu.Unlock()
1646 for _, tb := range tabs {1649 for _, tb := range tabs {
1647 d.clearDestroyedTabPreview(tb)1650 d.clearDestroyedTabPreview(tb)
1648- d.teardownFloating(tb, detachedActive)1651+ for _, attachment := range attachments {
1652+ d.teardownFloating(tb, attachment.ac)
1653+ }
1649 }1654 }
1650 for _, path := range clipFiles {1655 for _, path := range clipFiles {
1651 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {1656 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
@@ -1758,7 +1763,7 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
1758 if d.procCwd == nil {1763 if d.procCwd == nil {
1759 return1764 return
1760 }1765 }
1761- tb := sess.activeTab()1766+ tb := sess.firstTab()
1762 if tb == nil {1767 if tb == nil {
1763 return1768 return
1764 }1769 }
M internal/usecase/daemon/session_back.go +4 -4
@@ -7,7 +7,7 @@ import (
7 7
8 // backSession toggles this attachment between its current session and the8 // backSession toggles this attachment between its current session and the
9 // immediately preceding successfully activated session.9 // immediately preceding successfully activated session.
10-func (d *Daemon) backSessionForRole(token attachmentRoleToken) error {10+func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error {
11 if d == nil || token.sess == nil || token.ac == nil {11 if d == nil || token.sess == nil || token.ac == nil {
12 return nil12 return nil
13 }13 }
@@ -21,7 +21,7 @@ func (d *Daemon) backSessionForRole(token attachmentRoleToken) error {
21 key := proxy.key21 key := proxy.key
22 pickerTarget.RemoteKey = &key22 pickerTarget.RemoteKey = &key
23 }23 }
24- return d.switchToTargetForRole(token, pickerTarget, sessionHandoffGuard{}, "back-session")24+ return d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{}, "back-session")
25 }25 }
26 26
27 func (d *Daemon) backSession(current *session, ac *attachedClient) {27 func (d *Daemon) backSession(current *session, ac *attachedClient) {
@@ -35,14 +35,14 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
35 return35 return
36 }36 }
37 token := attachmentToken(current, ac, ac.transport())37 token := attachmentToken(current, ac, ac.transport())
38- effect, admitted := ac.beginRoleEffect(token)38+ effect, admitted := ac.beginAttachmentEffect(token)
39 if !admitted {39 if !admitted {
40 d.reportError(current, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", errAttachmentTransition))40 d.reportError(current, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", errAttachmentTransition))
41 return41 return
42 }42 }
43 token.effect = effect43 token.effect = effect
44 defer effect.End()44 defer effect.End()
45- if err := d.backSessionForRole(token); err != nil {45+ if err := d.backSessionForAttachment(token); err != nil {
46 d.reportError(current, err)46 d.reportError(current, err)
47 }47 }
48 }48 }
M internal/usecase/daemon/session_core.go +23 -21
@@ -13,8 +13,8 @@ type attachmentSession interface {
13 core() *sessionCore13 core() *sessionCore
14 snapshotView(viewOptions) sessionView14 snapshotView(viewOptions) sessionView
15 statusSegments(includeTerminalTitle bool) statusSnapshot15 statusSegments(includeTerminalTitle bool) statusSnapshot
16- capturePrimary(*attachedClient, primaryCaptureRequest) (*capturedRenderState, bool)16+ captureRenderState(*attachedClient, renderCaptureRequest) (*capturedRenderState, bool)
17- activateTargetLocked(tabIndex int) bool17+ validTargetTabLocked(tabIndex int) bool
18 isProxy() bool18 isProxy() bool
19 }19 }
20 20
@@ -24,16 +24,20 @@ type attachmentSession interface {
24 type sessionCore struct {24 type sessionCore struct {
25 mu sync.Mutex25 mu sync.Mutex
26 26
27- id domain.SessionID27+ id domain.SessionID
28- name string28+ name string
29- ephemeral bool29+ ephemeral bool
30- caps sessionCapabilities30+ caps sessionCapabilities
31- client *attachedClient31+ // attachments is the session-owned membership registry. Connection
32- snatched map[*attachedClient]struct{}32+ // generation and effect state remain attachment-local; admission and view
33- createdAt int6433+ // code use this collection as their membership source.
34- incarnation domain.IncarnationID34+ attachments map[*attachedClient]struct{}
35- mruAt atomic.Uint6435+ attachmentOrder map[*attachedClient]uint64
36- coordinator atomic.Pointer[renderCoordinator]36+ nextAttachmentID uint64
37+ createdAt int64
38+ incarnation domain.IncarnationID
39+ mruAt atomic.Uint64
40+ coordinator atomic.Pointer[renderCoordinator]
37 }41 }
38 42
39 func (s *session) core() *sessionCore {43 func (s *session) core() *sessionCore {
@@ -45,20 +49,18 @@ func (s *session) core() *sessionCore {
45 49
46 func (s *session) isProxy() bool { return false }50 func (s *session) isProxy() bool { return false }
47 51
48-func (s *session) capturePrimary(ac *attachedClient, req primaryCaptureRequest) (*capturedRenderState, bool) {52+func (s *session) captureRenderState(ac *attachedClient, req renderCaptureRequest) (*capturedRenderState, bool) {
49- return captureLocalPrimaryRenderState(s, ac, req)53+ return captureLocalRenderState(s, ac, req)
50 }54 }
51 55
52-// activateTargetLocked validates and selects a local tab. Caller holds s.mu.56+// validTargetTabLocked validates a target tab index without changing any
53-func (s *session) activateTargetLocked(tabIndex int) bool {57+// attachment view. Client-facing navigation updates attachment views through
58+// selectAttachmentTab.
59+func (s *session) validTargetTabLocked(tabIndex int) bool {
54 if tabIndex < 0 {60 if tabIndex < 0 {
55 return true61 return true
56 }62 }
57- if tabIndex >= len(s.tabs) {63+ return tabIndex < len(s.tabs)
58- return false
59- }
60- s.active = tabIndex
61- return true
62 }64 }
63 65
64 // localSession narrows an attachment entry for operations that own local PTYs,66 // 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 (
9 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
10 )10 )
11 11
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+
12 func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) {24 func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) {
13 sess := &session{}25 sess := &session{}
14 sess.id = domain.SessionID("local")26 sess.id = domain.SessionID("local")
@@ -24,8 +36,8 @@ func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) {
24 36
25 core.mu.Lock()37 core.mu.Lock()
26 require.False(t, sess.mu.TryLock(), "sessionCore.mu must be the mutex promoted as session.mu")38 require.False(t, sess.mu.TryLock(), "sessionCore.mu must be the mutex promoted as session.mu")
27- core.client = ac39+ core.registerAttachmentLocked(ac)
28- require.Equal(t, attachmentActive, sess.attachmentRoleLocked(ac))40+ require.Equal(t, true, sess.attachmentRegisteredLocked(ac))
29 sess.tabs = append(sess.tabs, &tab{name: "logs"})41 sess.tabs = append(sess.tabs, &tab{name: "logs"})
30 require.Len(t, sess.tabs, 2)42 require.Len(t, sess.tabs, 2)
31 core.mu.Unlock()43 core.mu.Unlock()
@@ -37,7 +49,7 @@ func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) {
37 func TestSessionSnapshotViewUsesPromotedMutexForLocalTabAndRoleState(t *testing.T) {49 func TestSessionSnapshotViewUsesPromotedMutexForLocalTabAndRoleState(t *testing.T) {
38 sess := &session{50 sess := &session{
39 sessionCore: sessionCore{id: domain.SessionID("local"), name: "work"},51 sessionCore: sessionCore{id: domain.SessionID("local"), name: "work"},
40- tabs: []*tab{{name: "shell"}},52+ tabs: []*tab{{stableID: "tab-shell", name: "shell"}},
41 }53 }
42 ac := &attachedClient{}54 ac := &attachedClient{}
43 55
@@ -45,9 +57,9 @@ func TestSessionSnapshotViewUsesPromotedMutexForLocalTabAndRoleState(t *testing.
45 // promoted core mutex. snapshotView must observe the same mutex before it57 // promoted core mutex. snapshotView must observe the same mutex before it
46 // can publish either field.58 // can publish either field.
47 sess.core().mu.Lock()59 sess.core().mu.Lock()
48- sess.tabs = append(sess.tabs, &tab{name: "logs"})60+ sess.tabs = append(sess.tabs, &tab{stableID: "tab-logs", name: "logs"})
49- sess.active = 161+ sess.registerAttachmentLocked(ac)
50- sess.client = ac62+ sess.activateAttachmentViewLocked(ac, 1)
51 63
52 preCall := make(chan struct{})64 preCall := make(chan struct{})
53 allowCall := make(chan struct{})65 allowCall := make(chan struct{})
@@ -71,13 +83,14 @@ func TestSessionSnapshotViewUsesPromotedMutexForLocalTabAndRoleState(t *testing.
71 t.Fatal("snapshotView completed while sessionCore.mu guarded local session state")83 t.Fatal("snapshotView completed while sessionCore.mu guarded local session state")
72 case <-timer.C:84 case <-timer.C:
73 }85 }
74- require.Equal(t, attachmentActive, sess.attachmentRoleLocked(ac))86+ require.Equal(t, true, sess.attachmentRegisteredLocked(ac))
75 sess.core().mu.Unlock()87 sess.core().mu.Unlock()
76 88
77 view := awaitTestValue(t, snapshotted, "snapshotView did not complete after sessionCore.mu was released")89 view := awaitTestValue(t, snapshotted, "snapshotView did not complete after sessionCore.mu was released")
78 require.True(t, view.attached)90 require.True(t, view.attached)
79 require.Equal(t, 2, view.tabCount)91 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))
81 }94 }
82 95
83 func TestLocalCreateThenKillRemovesLiveRegistryEntry(t *testing.T) {96 func TestLocalCreateThenKillRemovesLiveRegistryEntry(t *testing.T) {
@@ -134,45 +147,6 @@ func TestSessionCoreLockOrderUsesImmutableIDs(t *testing.T) {
134 second.core().mu.Unlock()147 second.core().mu.Unlock()
135 }148 }
136 149
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-
176 func mustLocalSession(t *testing.T, entry attachmentSession) *session {150 func mustLocalSession(t *testing.T, entry attachmentSession) *session {
177 t.Helper()151 t.Helper()
178 sess, ok := localSession(entry)152 sess, ok := localSession(entry)
M internal/usecase/daemon/session_meta.go +20 -5
@@ -3,6 +3,7 @@ package daemon
3 import (3 import (
4 "errors"4 "errors"
5 5
6+ "github.com/bnema/vev/internal/domain"
6 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
7 )8 )
8 9
@@ -11,17 +12,31 @@ var errSessionMetaUnavailable = errors.New("session metadata is unavailable")
11 // sessionMetaSnapshot captures the authoritative tab order while session.mu12 // sessionMetaSnapshot captures the authoritative tab order while session.mu
12 // owns every field represented on the wire.13 // owns every field represented on the wire.
13 func (s *session) sessionMetaSnapshot() (ports.SessionMeta, bool) {14 func (s *session) sessionMetaSnapshot() (ports.SessionMeta, bool) {
15+ return s.sessionMetaSnapshotFor(nil)
16+}
17+
18+func (s *session) sessionMetaSnapshotFor(ac *attachedClient) (ports.SessionMeta, bool) {
14 if s == nil {19 if s == nil {
15 return ports.SessionMeta{}, false20 return ports.SessionMeta{}, false
16 }21 }
17 s.mu.Lock()22 s.mu.Lock()
18 defer s.mu.Unlock()23 defer s.mu.Unlock()
19- if len(s.tabs) == 0 || s.active < 0 || s.active >= len(s.tabs) {24+ if len(s.tabs) == 0 {
20 return ports.SessionMeta{}, false25 return ports.SessionMeta{}, false
21 }26 }
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+ }
22 meta := ports.SessionMeta{37 meta := ports.SessionMeta{
23 SessionName: s.name,38 SessionName: s.name,
24- Active: uint16(s.active),39+ Active: uint16(active),
25 Tabs: make([]ports.SessionTabMeta, len(s.tabs)),40 Tabs: make([]ports.SessionTabMeta, len(s.tabs)),
26 }41 }
27 for i, tb := range s.tabs {42 for i, tb := range s.tabs {
@@ -63,11 +78,11 @@ func cloneSessionMeta(meta ports.SessionMeta) ports.SessionMeta {
63 // caller holds ac.sendMu, which serializes this snapshot shadow with every78 // caller holds ac.sendMu, which serializes this snapshot shadow with every
64 // output write. The shadow is committed only after the transport accepts the79 // output write. The shadow is committed only after the transport accepts the
65 // metadata frame.80 // 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 {
67 if ac == nil || sess == nil || !ac.proxied {82 if ac == nil || sess == nil || !ac.proxied {
68 return nil83 return nil
69 }84 }
70- meta, ok := sess.sessionMetaSnapshot()85+ meta, ok := sess.sessionMetaSnapshotFor(ac)
71 if !ok {86 if !ok {
72 return errSessionMetaUnavailable87 return errSessionMetaUnavailable
73 }88 }
@@ -76,7 +91,7 @@ func (ac *attachedClient) sendSessionMetaIfChanged(sess *session, expected trans
76 91
77 // sendSessionMetaSnapshot sends an already captured metadata snapshot while92 // sendSessionMetaSnapshot sends an already captured metadata snapshot while
78 // the caller holds ac.sendMu.93 // 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 {
80 if ac == nil || !ac.proxied {95 if ac == nil || !ac.proxied {
81 return nil96 return nil
82 }97 }
M internal/usecase/daemon/session_meta_test.go +21 -2
@@ -116,6 +116,25 @@ func TestSessionMetaShadowUpdatesOnlyAfterSuccessfulSend(t *testing.T) {
116 require.False(t, ac.sessionMetaSent)116 require.False(t, ac.sessionMetaSent)
117 }117 }
118 118
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+
119 func TestProxiedAttachedCommandValidationPreservesRequestID(t *testing.T) {138 func TestProxiedAttachedCommandValidationPreservesRequestID(t *testing.T) {
120 tests := []struct {139 tests := []struct {
121 name string140 name string
@@ -192,12 +211,12 @@ func TestProxiedAttachedCommandValidationPreservesRequestID(t *testing.T) {
192 t.Run(tt.name, func(t *testing.T) {211 t.Run(tt.name, func(t *testing.T) {
193 d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())212 d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
194 ac.proxied = true213 ac.proxied = true
195- d.attachCoordinator(sess, nil, ac, true)214+ d.attachCoordinator(sess, ac, true)
196 payload, err := ports.MarshalCommandRequest(tt.request)215 payload, err := ports.MarshalCommandRequest(tt.request)
197 require.NoError(t, err)216 require.NoError(t, err)
198 217
199 token := sess.attachmentToken(ac, ac.transport())218 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}))
201 220
202 reply := awaitFrame(t, sends, ports.MsgCommandResult)221 reply := awaitFrame(t, sends, ports.MsgCommandResult)
203 result, err := ports.UnmarshalCommandResult(reply.Payload)222 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) {
14 }{14 }{
15 {15 {
16 name: "derives from active tab content size",16 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}}}},
18 want: domain.Size{Cols: 120, Rows: 40},18 want: domain.Size{Cols: 120, Rows: 40},
19 },19 },
20 {20 {
21 name: "round-trips through tabSize",21 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})}}},
23 want: domain.Size{Cols: 80, Rows: 24},23 want: domain.Size{Cols: 80, Rows: 24},
24 },24 },
25 {25 {
@@ -29,7 +29,7 @@ func TestFullViewportSize(t *testing.T) {
29 },29 },
30 {30 {
31 name: "falls back to defaultSize with invalid tab size",31 name: "falls back to defaultSize with invalid tab size",
32- sess: &session{tabs: []*tab{{}}, active: 0},32+ sess: &session{tabs: []*tab{{}}},
33 want: defaultSize,33 want: defaultSize,
34 },34 },
35 }35 }
M internal/usecase/daemon/session_test.go +56 -124
@@ -20,6 +20,7 @@ import (
20 "github.com/bnema/vev/internal/ports"20 "github.com/bnema/vev/internal/ports"
21 portsmocks "github.com/bnema/vev/internal/ports/mocks"21 portsmocks "github.com/bnema/vev/internal/ports/mocks"
22 "github.com/bnema/vev/internal/usecase/picker"22 "github.com/bnema/vev/internal/usecase/picker"
23+ themeui "github.com/bnema/vev/internal/usecase/theme"
23 )24 )
24 25
25 // --- test doubles -----------------------------------------------------------26 // --- test doubles -----------------------------------------------------------
@@ -32,55 +33,6 @@ func expectFloatingPrewarmOpen(factory *portsmocks.MockPTYFactory, normalSize do
32 // stubClock returns timers whose channel never fires, so a scheduler under it33 // stubClock returns timers whose channel never fires, so a scheduler under it
33 // blocks in its debounce loop until the session context is cancelled. Used by34 // blocks in its debounce loop until the session context is cancelled. Used by
34 35
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-
84 func TestHandshakeEphemeralHappy(t *testing.T) {36 func TestHandshakeEphemeralHappy(t *testing.T) {
85 p, releasePTY := newBlockingPTY(t)37 p, releasePTY := newBlockingPTY(t)
86 d := newTestDaemon(t, newFactory(t, p), stubClock{})38 d := newTestDaemon(t, newFactory(t, p), stubClock{})
@@ -200,6 +152,53 @@ func TestKillAllEmptyDaemonSignalsShutdown(t *testing.T) {
200 }152 }
201 }153 }
202 154
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+
203 func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {202 func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
204 p1, release1 := newBlockingPTY(t)203 p1, release1 := newBlockingPTY(t)
205 defer release1()204 defer release1()
@@ -283,53 +282,6 @@ func TestEphemeralNumberingReuse(t *testing.T) {
283 282
284 // --- attach replace ---------------------------------------------------------283 // --- attach replace ---------------------------------------------------------
285 284
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-
333 // --- detach semantics -------------------------------------------------------285 // --- detach semantics -------------------------------------------------------
334 286
335 func TestDetachKeepsEphemeralHeadless(t *testing.T) {287 func TestDetachKeepsEphemeralHeadless(t *testing.T) {
@@ -351,7 +303,7 @@ func TestDetachKeepsEphemeralHeadless(t *testing.T) {
351 require.NotNil(t, sess)303 require.NotNil(t, sess)
352 sess.mu.Lock()304 sess.mu.Lock()
353 require.True(t, sess.ephemeral)305 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")
355 sess.mu.Unlock()307 sess.mu.Unlock()
356 308
357 _ = d.killSession(sess, ports.ReasonServerShutdown, false)309 _ = d.killSession(sess, ports.ReasonServerShutdown, false)
@@ -398,7 +350,7 @@ func TestDetachKeepsNamed(t *testing.T) {
398 require.Equal(t, 1, sessionCount(d), "named session survives detach")350 require.Equal(t, 1, sessionCount(d), "named session survives detach")
399 sess := firstSession(d)351 sess := firstSession(d)
400 sess.mu.Lock()352 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")
402 sess.mu.Unlock()354 sess.mu.Unlock()
403 355
404 _ = d.killSession(sess, ports.ReasonServerShutdown, false)356 _ = d.killSession(sess, ports.ReasonServerShutdown, false)
@@ -1035,29 +987,6 @@ func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) {
1035 }987 }
1036 }988 }
1037 989
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-
1061 func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) {990 func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) {
1062 sz := domain.Size{Cols: 80, Rows: 24}991 sz := domain.Size{Cols: 80, Rows: 24}
1063 p, release := newBlockingPTY(t)992 p, release := newBlockingPTY(t)
@@ -1619,13 +1548,16 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
1619 got := ac.currentSession()1548 got := ac.currentSession()
1620 require.NotNil(t, got)1549 require.NotNil(t, got)
1621 sourceCoordinator.mu.Lock()1550 sourceCoordinator.mu.Lock()
1622- require.NotNil(t, sourceCoordinator.lease)1551+ require.Empty(t, sourceCoordinator.attachments, "source callbacks must be stale after handoff")
1623- require.False(t, sourceCoordinator.lease.active, "source callbacks must be stale after handoff")1552+ require.Empty(t, sourceCoordinator.leases, "source leases must be retired after handoff")
1624 sourceCoordinator.mu.Unlock()1553 sourceCoordinator.mu.Unlock()
1625 destinationCoordinator := got.renderCoordinator()1554 destinationCoordinator := got.renderCoordinator()
1626 require.NotNil(t, destinationCoordinator, "destination coordinator precedes first paint")1555 require.NotNil(t, destinationCoordinator, "destination coordinator precedes first paint")
1627 destinationCoordinator.mu.Lock()1556 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)
1629 destinationCoordinator.mu.Unlock()1561 destinationCoordinator.mu.Unlock()
1630 ac.sendMu.Lock()1562 ac.sendMu.Lock()
1631 require.Equal(t, uint64(1), ac.output.next-ac.output.acked, "only the destination first paint may follow the rebase")1563 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 {
34 name string34 name string
35 ephemeral bool35 ephemeral bool
36 createdAt int6436 createdAt int64
37- active int37+ defaultTab int
38 mruAt uint6438 mruAt uint64
39 attached bool39 attached bool
40 tabCount int40 tabCount int
@@ -53,16 +53,18 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
53 opts.tabDetails = true53 opts.tabDetails = true
54 }54 }
55 s.mu.Lock()55 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.
57 view := sessionView{59 view := sessionView{
58 id: s.id,60 id: s.id,
59 incarnation: s.incarnation,61 incarnation: s.incarnation,
60 name: s.name,62 name: s.name,
61 ephemeral: s.ephemeral,63 ephemeral: s.ephemeral,
62 createdAt: s.createdAt,64 createdAt: s.createdAt,
63- active: s.active,65+ defaultTab: 0,
64 mruAt: s.mruAt.Load(),66 mruAt: s.mruAt.Load(),
65- attached: s.client != nil,67+ attached: len(s.attachments) != 0,
66 tabCount: len(s.tabs),68 tabCount: len(s.tabs),
67 cannotAcceptMoves: s.capabilities().cannotAcceptMoves,69 cannotAcceptMoves: s.capabilities().cannotAcceptMoves,
68 }70 }
@@ -90,6 +92,7 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
90 }92 }
91 view.tabs = append(view.tabs, entry)93 view.tabs = append(view.tabs, entry)
92 }94 }
95+ s.mu.Unlock()
93 return view96 return view
94 }97 }
95 98
@@ -101,7 +104,7 @@ func (view sessionView) pickerView() picker.SessionView {
101 Incarnation: view.incarnation,104 Incarnation: view.incarnation,
102 Name: view.name,105 Name: view.name,
103 TargetName: view.name,106 TargetName: view.name,
104- Active: view.active,107+ Active: view.defaultTab,
105 Tabs: make([]picker.TabEntry, 0, len(view.tabs)),108 Tabs: make([]picker.TabEntry, 0, len(view.tabs)),
106 CannotAcceptMoves: view.cannotAcceptMoves,109 CannotAcceptMoves: view.cannotAcceptMoves,
107 }110 }
M internal/usecase/daemon/session_view_test.go +1 -2
@@ -22,7 +22,7 @@ func TestSnapshotViewCapturesSessionFields(t *testing.T) {
22 name: "alpha",22 name: "alpha",
23 createdAt: 42,23 createdAt: 42,
24 24
25- client: &attachedClient{}}, active: 1,25+ attachments: map[*attachedClient]struct{}{&attachedClient{}: {}}},
26 26
27 tabs: []*tab{27 tabs: []*tab{
28 {stableID: "t1", name: "build"},28 {stableID: "t1", name: "build"},
@@ -38,7 +38,6 @@ func TestSnapshotViewCapturesSessionFields(t *testing.T) {
38 incarnation: domain.IncarnationID{1},38 incarnation: domain.IncarnationID{1},
39 name: "alpha",39 name: "alpha",
40 createdAt: 42,40 createdAt: 42,
41- active: 1,
42 mruAt: 7,41 mruAt: 7,
43 attached: true,42 attached: true,
44 tabCount: 2,43 tabCount: 2,
M internal/usecase/daemon/snapshot.go +1 -1
@@ -42,7 +42,7 @@ type snapshotCapture struct {
42 name string42 name string
43 incarnation domain.IncarnationID43 incarnation domain.IncarnationID
44 createdAt uint6444 createdAt uint64
45- active uint1645+ defaultTab uint16
46 tabs []snapshotCaptureTab46 tabs []snapshotCaptureTab
47 publicationContext context.Context47 publicationContext context.Context
48 sealedRefs map[*vt.HistoryChunk]snapcodec.ObjectRef // set by the single encoder worker48 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
13 name: sess.name,13 name: sess.name,
14 incarnation: sess.incarnation,14 incarnation: sess.incarnation,
15 createdAt: uint64(sess.createdAt),15 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,
17 }19 }
18 ephemeral := sess.ephemeral20 ephemeral := sess.ephemeral
19 fallbackCwd := sess.cwd21 fallbackCwd := sess.cwd
M internal/usecase/daemon/snapshot_incremental.go +1 -1
@@ -114,7 +114,7 @@ func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.Snapsho
114 if err := prepareSnapshotChunkCache(capture); err != nil {114 if err := prepareSnapshotChunkCache(capture); err != nil {
115 return ports.SnapshotPublication{}, err115 return ports.SnapshotPublication{}, err
116 }116 }
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))}
118 objects := make([]ports.SnapshotObject, 0)118 objects := make([]ports.SnapshotObject, 0)
119 capture.sealedRefs = make(map[*vt.HistoryChunk]snapcodec.ObjectRef)119 capture.sealedRefs = make(map[*vt.HistoryChunk]snapcodec.ObjectRef)
120 for _, tab := range capture.tabs {120 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
290 }290 }
291 291
292 func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab, term terminalEnv) *session {292 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)}
294 // Restored tabs remain private until persistAndRegisterRestoredSession.296 // Restored tabs remain private until persistAndRegisterRestoredSession.
295 // Initialize owners now so registration and reader startup cannot expose an297 // Initialize owners now so registration and reader startup cannot expose an
296 // ownerless pane.298 // ownerless pane.
M internal/usecase/daemon/snapshot_restore_test.go +1 -1
@@ -233,7 +233,7 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
233 require.False(t, restored.snapDirty.Load(), "a loaded generation must begin clean")233 require.False(t, restored.snapDirty.Load(), "a loaded generation must begin clean")
234 require.Equal(t, uint64(9), restored.snapshotPublishedGeneration, "the loaded manifest is the repository generation head")234 require.Equal(t, uint64(9), restored.snapshotPublishedGeneration, "the loaded manifest is the repository generation head")
235 require.True(t, restored.terminal.TrueColor, "restored sessions must retain startup terminal capability for future panes")235 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))
237 require.Equal(t, "/snapshot/cwd", restored.cwd)237 require.Equal(t, "/snapshot/cwd", restored.cwd)
238 require.Len(t, restored.tabs, 1)238 require.Len(t, restored.tabs, 1)
239 tab := restored.tabs[0]239 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 {
141 return &clone141 return &clone
142 }142 }
143 143
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-
203 func TestStoppedNamedSessionResumePublishesNextCheckpointInSameDaemon(t *testing.T) {144 func TestStoppedNamedSessionResumePublishesNextCheckpointInSameDaemon(t *testing.T) {
204 keeperPTY, releaseKeeper := newBlockingPTY(t)145 keeperPTY, releaseKeeper := newBlockingPTY(t)
205 firstPTY, releaseFirst := newBlockingPTY(t)146 firstPTY, releaseFirst := newBlockingPTY(t)
@@ -223,7 +164,7 @@ func TestStoppedNamedSessionResumePublishesNextCheckpointInSameDaemon(t *testing
223 sess, _, err := d.route(snapshotLifecycleHello(ports.IntentNew, "work"), &closeTrackingTransport{})164 sess, _, err := d.route(snapshotLifecycleHello(ports.IntentNew, "work"), &closeTrackingTransport{})
224 require.NoError(t, err)165 require.NoError(t, err)
225 166
226- sess.activeTab().focusedPane().screen.Write([]byte("first checkpoint"))167+ testAttachmentTab(sess).focusedPane().screen.Write([]byte("first checkpoint"))
227 markSnapshotDirty(sess)168 markSnapshotDirty(sess)
228 require.True(t, d.scheduleSnapshot(sess))169 require.True(t, d.scheduleSnapshot(sess))
229 awaitSnapshotClean(t, sess)170 awaitSnapshotClean(t, sess)
@@ -247,7 +188,7 @@ func TestStoppedNamedSessionResumePublishesNextCheckpointInSameDaemon(t *testing
247 require.NotSame(t, sess, resumed, "stopped-session attach must create a new live runtime")188 require.NotSame(t, sess, resumed, "stopped-session attach must create a new live runtime")
248 require.Equal(t, sess.incarnation, resumed.incarnation, "resume must retain durable incarnation authority")189 require.Equal(t, sess.incarnation, resumed.incarnation, "resume must retain durable incarnation authority")
249 190
250- resumed.activeTab().focusedPane().screen.Write([]byte("checkpoint after resume"))191+ testAttachmentTab(resumed).focusedPane().screen.Write([]byte("checkpoint after resume"))
251 markSnapshotDirty(resumed)192 markSnapshotDirty(resumed)
252 require.True(t, d.scheduleSnapshot(resumed))193 require.True(t, d.scheduleSnapshot(resumed))
253 awaitSnapshotIdle(t, resumed)194 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
89 p := tb.panes["pane-1"]89 p := tb.panes["pane-1"]
90 p.screen.Write([]byte("hello"))90 p.screen.Write([]byte("hello"))
91 appendHistoryRow(t, p.history, []renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})91 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}
93 if !ephemeral {93 if !ephemeral {
94 sess.incarnation = domain.IncarnationID{1}94 sess.incarnation = domain.IncarnationID{1}
95 }95 }
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 (
6 "strconv"6 "strconv"
7 "time"7 "time"
8 8
9+ "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/usecase/command"10 "github.com/bnema/vev/internal/usecase/command"
10 "github.com/bnema/vev/internal/usecase/palette"11 "github.com/bnema/vev/internal/usecase/palette"
11 themeui "github.com/bnema/vev/internal/usecase/theme"12 themeui "github.com/bnema/vev/internal/usecase/theme"
@@ -181,6 +182,10 @@ type statusTab struct {
181 }182 }
182 183
183 func (s *session) statusSegments(includeTerminalTitle bool) statusSnapshot {184 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 {
184 s.mu.Lock()189 s.mu.Lock()
185 defer s.mu.Unlock()190 defer s.mu.Unlock()
186 name := s.name191 name := s.name
@@ -188,9 +193,19 @@ func (s *session) statusSegments(includeTerminalTitle bool) statusSnapshot {
188 name += "*"193 name += "*"
189 }194 }
190 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}195 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+ }
191 for i, tb := range s.tabs {206 for i, tb := range s.tabs {
192 name := tabDisplayName(tb, i)207 name := tabDisplayName(tb, i)
193- active := i == s.active208+ active := i == activeIndex
194 attention := tb.attention && (!active || tb.attentionVisiblePaint)209 attention := tb.attention && (!active || tb.attentionVisiblePaint)
195 snap.tabs[i] = statusTab{name: name, paneTitle: tb.focusedPaneTitle(includeTerminalTitle), active: active, attention: attention}210 snap.tabs[i] = statusTab{name: name, paneTitle: tb.focusedPaneTitle(includeTerminalTitle), active: active, attention: attention}
196 }211 }
@@ -236,6 +251,10 @@ func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hi
236 // attachment implementation. Bar scripts remain local-session-only because251 // attachment implementation. Bar scripts remain local-session-only because
237 // their existing execution contract depends on local tabs and PTYs.252 // their existing execution contract depends on local tabs and PTYs.
238 func (d *Daemon) barStateForAttachmentPaletteHints(cur attachmentSession, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {253 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 {
239 if attachmentSessionCore(cur) == nil {258 if attachmentSessionCore(cur) == nil {
240 cur = nil259 cur = nil
241 }260 }
@@ -249,7 +268,11 @@ func (d *Daemon) barStateForAttachmentPaletteHints(cur attachmentSession, status
249 if d != nil {268 if d != nil {
250 includeTerminalTitle = d.currentTabsConfig().TerminalTitle269 includeTerminalTitle = d.currentTabsConfig().TerminalTitle
251 }270 }
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+ }
253 }276 }
254 if local, ok := localSession(cur); ok && d != nil {277 if local, ok := localSession(cur); ok && d != nil {
255 state.topRight, state.bottomRight = d.barScriptSnapshot(local)278 state.topRight, state.bottomRight = d.barScriptSnapshot(local)
@@ -269,6 +292,10 @@ func (d *Daemon) barStateFor(cur *session, statusFeedback string) barState {
269 }292 }
270 293
271 func (d *Daemon) barStateBase(cur *session, statusFeedback string) barState {294 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 {
272 state := barState{statusFeedback: statusFeedback}299 state := barState{statusFeedback: statusFeedback}
273 if d != nil {300 if d != nil {
274 state.attentionFrame = d.attentionFrame()301 state.attentionFrame = d.attentionFrame()
@@ -278,7 +305,7 @@ func (d *Daemon) barStateBase(cur *session, statusFeedback string) barState {
278 if d != nil {305 if d != nil {
279 includeTerminalTitle = d.currentTabsConfig().TerminalTitle306 includeTerminalTitle = d.currentTabsConfig().TerminalTitle
280 }307 }
281- state.status = cur.statusSegments(includeTerminalTitle)308+ state.status = cur.statusSegmentsFor(ac, includeTerminalTitle)
282 }309 }
283 if d != nil {310 if d != nil {
284 state.topRight, state.bottomRight = d.barScriptSnapshot(cur)311 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 {
31 return sessionView{}31 return sessionView{}
32 }32 }
33 func (*coreNilAttachment) statusSegments(bool) statusSnapshot { return statusSnapshot{} }33 func (*coreNilAttachment) statusSegments(bool) statusSnapshot { return statusSnapshot{} }
34-func (*coreNilAttachment) capturePrimary(*attachedClient, primaryCaptureRequest) (*capturedRenderState, bool) {34+func (*coreNilAttachment) captureRenderState(*attachedClient, renderCaptureRequest) (*capturedRenderState, bool) {
35 return nil, false35 return nil, false
36 }36 }
37-func (*coreNilAttachment) activateTargetLocked(int) bool { return false }37+func (*coreNilAttachment) validTargetTabLocked(int) bool { return false }
38 func (*coreNilAttachment) isProxy() bool { return false }38 func (*coreNilAttachment) isProxy() bool { return false }
39 39
40 // stubClock returns timers whose channel never fires, so a scheduler under it40 // stubClock returns timers whose channel never fires, so a scheduler under it
@@ -46,7 +46,7 @@ func TestApplyConfigThemeRepaintInvalidatesComposedFrameCache(t *testing.T) {
46 defer releasePTY()46 defer releasePTY()
47 d.ApplyConfig(domain.Config{Theme: domain.ThemeLight})47 d.ApplyConfig(domain.Config{Theme: domain.ThemeLight})
48 48
49- win := sess.activeTab()49+ win := testAttachmentTab(sess)
50 left := win.focusedPane()50 left := win.focusedPane()
51 right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4})51 right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4})
52 win.mu.Lock()52 win.mu.Lock()
@@ -89,13 +89,13 @@ func TestApplyConfigThemeRepaintInvalidatesComposedFrameCache(t *testing.T) {
89 func TestStatusCompositionGolden(t *testing.T) {89 func TestStatusCompositionGolden(t *testing.T) {
90 p1, releasePTY1 := newBlockingPTY(t)90 p1, releasePTY1 := newBlockingPTY(t)
91 p2, releasePTY2 := newBlockingPTY(t)91 p2, releasePTY2 := newBlockingPTY(t)
92- _, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)92+ _, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2)
93 defer releasePTY1()93 defer releasePTY1()
94 defer releasePTY2()94 defer releasePTY2()
95- sess.active = 195+ selectTestAttachmentTab(sess, 1)
96 sess.name = "work"96 sess.name = "work"
97 97
98- win := sess.activeTab()98+ win := testAttachmentTab(sess)
99 win.focusedPane().screen.Resize(12, 2)99 win.focusedPane().screen.Resize(12, 2)
100 win.size = domain.Size{Cols: 12, Rows: 2}100 win.size = domain.Size{Cols: 12, Rows: 2}
101 win.focusedPane().screen.Write([]byte("hello"))101 win.focusedPane().screen.Write([]byte("hello"))
@@ -106,7 +106,7 @@ func TestStatusCompositionGolden(t *testing.T) {
106 panes: []capturedPaneRenderState{{106 panes: []capturedPaneRenderState{{
107 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()},107 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()},
108 }},108 }},
109- bars: barState{status: sess.statusSegments(true)},109+ bars: barState{status: sess.statusSegmentsFor(ac, true)},
110 }110 }
111 composed := composeFrame(frameState, composeCacheInput{})111 composed := composeFrame(frameState, composeCacheInput{})
112 frame, damage := composed.frame, composed.damage112 frame, damage := composed.frame, composed.damage
@@ -418,7 +418,7 @@ func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {
418 p, release := newBlockingPTY(t)418 p, release := newBlockingPTY(t)
419 _, sess, ac, _ := newManualSessionWithPTYs(t, p)419 _, sess, ac, _ := newManualSessionWithPTYs(t, p)
420 defer release()420 defer release()
421- win := sess.activeTab()421+ win := testAttachmentTab(sess)
422 win.focusedPane().screen.Resize(12, 2)422 win.focusedPane().screen.Resize(12, 2)
423 win.size = domain.Size{Cols: 12, Rows: 2}423 win.size = domain.Size{Cols: 12, Rows: 2}
424 ac.setThemeForTest(themeui.Theme{424 ac.setThemeForTest(themeui.Theme{
@@ -462,7 +462,7 @@ func TestApplyThemePropagatesToFloatingPane(t *testing.T) {
462 p, releasePTY := newBlockingPTY(t)462 p, releasePTY := newBlockingPTY(t)
463 defer releasePTY()463 defer releasePTY()
464 d, sess, ac, _ := newManualSessionWithPTYs(t, p)464 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
465- tb := sess.activeTab()465+ tb := testAttachmentTab(sess)
466 floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5})466 floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5})
467 installTestFloating(tb, floating, true)467 installTestFloating(tb, floating, true)
468 clientTheme := ports.Theme{468 clientTheme := ports.Theme{
@@ -541,7 +541,7 @@ func TestAttachClientAppliesForcedThemeBeforeMsgTheme(t *testing.T) {
541 }541 }
542 tr, _ := newCapturingTransport(t)542 tr, _ := newCapturingTransport(t)
543 543
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{})
545 require.NoError(t, err)545 require.NoError(t, err)
546 546
547 require.Equal(t, tc.want, ac.getAppliedTheme().Raw)547 require.Equal(t, tc.want, ac.getAppliedTheme().Raw)
@@ -581,7 +581,7 @@ func TestAttachClientClearsStaleColorSchemeOnReplacement(t *testing.T) {
581 assertSessionColorScheme(t, sess, true)581 assertSessionColorScheme(t, sess, true)
582 582
583 tr, _ := newCapturingTransport(t)583 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{})
585 require.NoError(t, err)585 require.NoError(t, err)
586 586
587 assertSessionColorSchemeUnknown(t, sess)587 assertSessionColorSchemeUnknown(t, sess)
@@ -690,11 +690,11 @@ func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) {
690 })690 })
691 tr, _ := newCapturingTransport(t)691 tr, _ := newCapturingTransport(t)
692 692
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{})
694 require.NoError(t, err)694 require.NoError(t, err)
695 695
696 var got []byte696 var got []byte
697- tb := sess.activeTab()697+ tb := testAttachmentTab(sess)
698 tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }698 tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
699 tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))699 tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
700 require.Empty(t, got)700 require.Empty(t, got)
@@ -710,7 +710,7 @@ func TestClientGoneResetsScreenDefaultColors(t *testing.T) {
710 })710 })
711 711
712 // Sanity: the tab answers OSC 10/11 while ac is attached.712 // Sanity: the tab answers OSC 10/11 while ac is attached.
713- tb := sess.activeTab()713+ tb := testAttachmentTab(sess)
714 var before []byte714 var before []byte
715 tb.focusedPane().screen.OnResponse = func(b []byte) { before = append(before, b...) }715 tb.focusedPane().screen.OnResponse = func(b []byte) { before = append(before, b...) }
716 tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))716 tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
@@ -724,40 +724,6 @@ func TestClientGoneResetsScreenDefaultColors(t *testing.T) {
724 require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone")724 require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone")
725 }725 }
726 726
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-
761 func TestDetachOnSendErrorResetsScreenDefaultColors(t *testing.T) {727 func TestDetachOnSendErrorResetsScreenDefaultColors(t *testing.T) {
762 p, release := newBlockingPTY(t)728 p, release := newBlockingPTY(t)
763 d, sess, ac, _ := newManualSessionWithPTYs(t, p)729 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
@@ -769,7 +735,7 @@ func TestDetachOnSendErrorResetsScreenDefaultColors(t *testing.T) {
769 735
770 d.detachOnSendError(sess, ac, ac.transport())736 d.detachOnSendError(sess, ac, ac.transport())
771 737
772- tb := sess.activeTab()738+ tb := testAttachmentTab(sess)
773 var got []byte739 var got []byte
774 tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }740 tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
775 tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))741 tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
@@ -788,40 +754,20 @@ func TestDetachOnSendErrorParkPreservesScreenDefaultColors(t *testing.T) {
788 754
789 d.detachOnSendError(sess, ac, ac.transport())755 d.detachOnSendError(sess, ac, ac.transport())
790 756
791- tb := sess.activeTab()757+ tb := testAttachmentTab(sess)
792 var got []byte758 var got []byte
793 tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }759 tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
794 tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))760 tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
795 require.NotEmpty(t, got, "parked clients resume the same attachment, so screen default colors must be preserved")761 require.NotEmpty(t, got, "parked clients resume the same attachment, so screen default colors must be preserved")
796 }762 }
797 763
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-
818 func TestStatusMarksEphemeralSession(t *testing.T) {764 func TestStatusMarksEphemeralSession(t *testing.T) {
819 p, release := newBlockingPTY(t)765 p, release := newBlockingPTY(t)
820 _, sess, _, _ := newManualSessionWithPTYs(t, p)766 _, sess, _, _ := newManualSessionWithPTYs(t, p)
821 defer release()767 defer release()
822 sess.name = "0"768 sess.name = "0"
823 sess.ephemeral = true769 sess.ephemeral = true
824- win := sess.activeTab()770+ win := testAttachmentTab(sess)
825 win.focusedPane().screen.Resize(12, 2)771 win.focusedPane().screen.Resize(12, 2)
826 win.size = domain.Size{Cols: 12, Rows: 2}772 win.size = domain.Size{Cols: 12, Rows: 2}
827 773
@@ -888,7 +834,7 @@ func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
888 _, sess, _, _ := newManualSessionWithPTYs(t, p)834 _, sess, _, _ := newManualSessionWithPTYs(t, p)
889 defer releasePTY()835 defer releasePTY()
890 sess.name = "work"836 sess.name = "work"
891- win := sess.activeTab()837+ win := testAttachmentTab(sess)
892 win.focusedPane().screen.Resize(30, 2)838 win.focusedPane().screen.Resize(30, 2)
893 win.size = domain.Size{Cols: 30, Rows: 2}839 win.size = domain.Size{Cols: 30, Rows: 2}
894 840
@@ -960,7 +906,7 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
960 _, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)906 _, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
961 defer releasePTY1()907 defer releasePTY1()
962 defer releasePTY2()908 defer releasePTY2()
963- win := sess.activeTab()909+ win := testAttachmentTab(sess)
964 win.focusedPane().screen.Resize(18, 2)910 win.focusedPane().screen.Resize(18, 2)
965 win.size = domain.Size{Cols: 18, Rows: 2}911 win.size = domain.Size{Cols: 18, Rows: 2}
966 sess.mu.Lock()912 sess.mu.Lock()
@@ -1139,7 +1085,7 @@ func TestCapturePrimaryRenderStatePreservesContextualMRUModeThroughScratchReuse(
1139 _, sess, ac, _ := newManualSessionWithPTYs(t, nil)1085 _, sess, ac, _ := newManualSessionWithPTYs(t, nil)
1140 capture := func(bars barState) capturedRenderState {1086 capture := func(bars barState) capturedRenderState {
1141 t.Helper()1087 t.Helper()
1142- state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{1088+ state, ok := captureRenderState(sess, ac, renderCaptureRequest{
1143 bars: bars,1089 bars: bars,
1144 overlays: capturedOverlayRenderState{},1090 overlays: capturedOverlayRenderState{},
1145 preview: picker.Preview{},1091 preview: picker.Preview{},
M internal/usecase/daemon/structured_diagnostics_test.go +3 -3
@@ -25,7 +25,7 @@ func TestP4StructuredRuntimeDiagnosticsRecordAcceptedSnapshotAndDelta(t *testing
25 emit := func(reset bool) ports.ScreenUpdate {25 emit := func(reset bool) ports.ScreenUpdate {
26 t.Helper()26 t.Helper()
27 ac.sendMu.Lock()27 ac.sendMu.Lock()
28- state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: reset})28+ state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: reset})
29 require.True(t, ok)29 require.True(t, ok)
30 composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)30 composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
31 require.True(t, d.emitFrame(proxy, ac, state, composed))31 require.True(t, d.emitFrame(proxy, ac, state, composed))
@@ -75,7 +75,7 @@ func TestP4StructuredRuntimeDiagnosticsDoNotRecordFailedSend(t *testing.T) {
75 require.True(t, applyTestScreenText(proxy, 0, 0, "failed"))75 require.True(t, applyTestScreenText(proxy, 0, 0, "failed"))
76 76
77 ac.sendMu.Lock()77 ac.sendMu.Lock()
78- state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: true})78+ state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: true})
79 require.True(t, ok)79 require.True(t, ok)
80 composed := composeFrame(*state, composeCacheInput{})80 composed := composeFrame(*state, composeCacheInput{})
81 require.True(t, d.emitFrame(proxy, ac, state, composed))81 require.True(t, d.emitFrame(proxy, ac, state, composed))
@@ -122,7 +122,7 @@ func TestP4StructuredRuntimeDiagnosticsObserverRunsOutsideAttachmentAndProxyLock
122 done := make(chan struct{})122 done := make(chan struct{})
123 go func() {123 go func() {
124 ac.sendMu.Lock()124 ac.sendMu.Lock()
125- state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: true})125+ state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: true})
126 if !ok {126 if !ok {
127 ac.sendMu.Unlock()127 ac.sendMu.Unlock()
128 close(done)128 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
12 return d.applyHostThemeLocked(sess, ac, t, clearUnknownScheme)12 return d.applyHostThemeLocked(sess, ac, t, clearUnknownScheme)
13 }13 }
14 14
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-
23 func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {15 func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
24 // Resolve while applying, never while composing. The theme mutex only16 // Resolve while applying, never while composing. The theme mutex only
25 // publishes the completed value and is not held across session/tab/pane17 // 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
28 t = applied.Raw20 t = applied.Raw
29 sess.mu.Lock()21 sess.mu.Lock()
30 if ac != nil {22 if ac != nil {
31- if sess.client != ac {23+ if _, attached := sess.attachments[ac]; !attached {
32 sess.mu.Unlock()24 sess.mu.Unlock()
33 return false25 return false
34 }26 }
35- } else if sess.client != nil {
36- sess.mu.Unlock()
37- return false
38 }27 }
39 tabs := append([]*tab(nil), sess.tabs...)28 tabs := append([]*tab(nil), sess.tabs...)
40 sess.mu.Unlock()29 sess.mu.Unlock()
M internal/usecase/daemon/transactional_resize.go +38 -10
@@ -362,10 +362,7 @@ func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) {
362 return362 return
363 }363 }
364 markSnapshotDirty(sess)364 markSnapshotDirty(sess)
365- sess.mu.Lock()365+ for _, ac := range sess.snapshotAttachments() {
366- ac := sess.client
367- sess.mu.Unlock()
368- if ac != nil {
369 d.invalidateRender(sess, ac, true, "transactional_resize.go")366 d.invalidateRender(sess, ac, true, "transactional_resize.go")
370 }367 }
371 }368 }
@@ -558,7 +555,6 @@ func (d *Daemon) applySessionLayoutWithMode(sess *session, size domain.Size, pro
558 }555 }
559 sess.mu.Lock()556 sess.mu.Lock()
560 tabs := append([]*tab(nil), sess.tabs...)557 tabs := append([]*tab(nil), sess.tabs...)
561- active := sess.active
562 sess.mu.Unlock()558 sess.mu.Unlock()
563 plans := make([]*preparedTabLayout, 0, len(tabs))559 plans := make([]*preparedTabLayout, 0, len(tabs))
564 for _, tb := range tabs {560 for _, tb := range tabs {
@@ -633,14 +629,17 @@ func (d *Daemon) applySessionLayoutWithMode(sess *session, size domain.Size, pro
633 }629 }
634 }630 }
635 }631 }
636- if active >= 0 && active < len(tabs) {632+ for _, tb := range tabs {
637- floatingFailed, ok := d.applyVisibleFloatingLayout(sess, tabs[active], current)633+ floatingFailed, ok := d.applyVisibleFloatingLayout(sess, tb, current)
638 if !ok {634 if !ok {
639 return nil, false635 return nil, false
640 }636 }
641 failed = append(failed, floatingFailed...)637 failed = append(failed, floatingFailed...)
642 }638 }
643 failedCurrent := len(failed) == 0 || resizeMembersOwnerCurrent(failed)639 failedCurrent := len(failed) == 0 || resizeMembersOwnerCurrent(failed)
640+ if current != nil && !current() {
641+ return nil, false
642+ }
644 if len(failed) != 0 && failedCurrent {643 if len(failed) != 0 && failedCurrent {
645 d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed,644 d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed,
646 "pane resize failed; retrying in background", failed[len(failed)-1].err)645 "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
648 if len(failed) != 0 && !failedCurrent {647 if len(failed) != 0 && !failedCurrent {
649 return nil, false648 return nil, false
650 }649 }
650+ if current != nil {
651+ d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostSnapshotDirty)
652+ if !current() {
653+ return nil, false
654+ }
655+ }
651 markSnapshotDirty(sess)656 markSnapshotDirty(sess)
652 d.observeRuntime(ports.RuntimeResizeCommitted, 0, true)657 d.observeRuntime(ports.RuntimeResizeCommitted, 0, true)
653 return failed, true658 return failed, true
@@ -660,7 +665,13 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *
660 return false665 return false
661 }666 }
662 snap := rc.resizeSnapshot()667 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+ }
664 if !current() {675 if !current() {
665 return false676 return false
666 }677 }
@@ -671,21 +682,38 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *
671 if !ok {682 if !ok {
672 return false683 return false
673 }684 }
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)
674 ac.sendMu.Lock()689 ac.sendMu.Lock()
690+ if !current() {
691+ ac.sendMu.Unlock()
692+ return false
693+ }
675 ac.size = snap.size694 ac.size = snap.size
676 ac.sendMu.Unlock()695 ac.sendMu.Unlock()
696+ if !current() {
697+ return false
698+ }
677 if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) {699 if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) {
678 rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) })700 rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) })
679 }701 }
702+ if !current() {
703+ return false
704+ }
680 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)705 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
681 // A successful transaction publishes exactly one full S2 frame. The706 // A successful transaction publishes exactly one full S2 frame. The
682 // coordinator is the only emission route and stale epochs never reach it.707 // 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"}) {
684 return false709 return false
685 }710 }
686 // The resize debounce has already elapsed. Consume this sticky reset now;711 // The resize debounce has already elapsed. Consume this sticky reset now;
687 // fire preserves ACK and synchronized-output gates rather than scheduling a712 // fire preserves ACK and synchronized-output gates rather than scheduling a
688 // second urgent deadline.713 // second urgent deadline.
714+ if !current() {
715+ return false
716+ }
689 rc.fireCurrent(false)717 rc.fireCurrent(false)
690 return true718 return true
691 }719 }
@@ -824,7 +852,7 @@ func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, s
824 if ac == nil {852 if ac == nil {
825 return d.requestTransactionalResizeForLease(sess, nil, nil, size, immediate)853 return d.requestTransactionalResizeForLease(sess, nil, nil, size, immediate)
826 }854 }
827- rc := d.attachCoordinator(sess, nil, ac, true)855+ rc := d.attachCoordinator(sess, ac, true)
828 return d.requestTransactionalResizeForLease(sess, ac, rc.attachmentLease(ac), size, immediate)856 return d.requestTransactionalResizeForLease(sess, ac, rc.attachmentLease(ac), size, immediate)
829 }857 }
830 858
M internal/usecase/daemon/transactional_resize_test.go +60 -21
@@ -131,7 +131,7 @@ func TestReplayResizePendingBuffersSuccessFailureAndBatchOrder(t *testing.T) {
131 t.Run("success resizes then replays", func(t *testing.T) {131 t.Run("success resizes then replays", func(t *testing.T) {
132 pty := &transactionalResizePTY{}132 pty := &transactionalResizePTY{}
133 d, sess, _, _ := newManualSessionWithPTYs(t, pty)133 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
134- tb, p := sess.activeTab(), sess.activeTab().focusedPane()134+ tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
135 p.resizeApplying = true135 p.resizeApplying = true
136 p.resizePending = []byte("A\x1b[1;81HB")136 p.resizePending = []byte("A\x1b[1;81HB")
137 d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 120, Height: 23})137 d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 120, Height: 23})
@@ -142,7 +142,7 @@ func TestReplayResizePendingBuffersSuccessFailureAndBatchOrder(t *testing.T) {
142 t.Run("failure retains old parser width", func(t *testing.T) {142 t.Run("failure retains old parser width", func(t *testing.T) {
143 pty := &transactionalResizePTY{}143 pty := &transactionalResizePTY{}
144 d, sess, _, _ := newManualSessionWithPTYs(t, pty)144 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
145- tb, p := sess.activeTab(), sess.activeTab().focusedPane()145+ tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
146 p.resizeApplying = true146 p.resizeApplying = true
147 p.resizePending = []byte("\x1b[1;81HB")147 p.resizePending = []byte("\x1b[1;81HB")
148 d.replayResizePending(sess, tb, p, false, domain.Rect{Width: 120, Height: 23})148 d.replayResizePending(sess, tb, p, false, domain.Rect{Width: 120, Height: 23})
@@ -152,7 +152,7 @@ func TestReplayResizePendingBuffersSuccessFailureAndBatchOrder(t *testing.T) {
152 t.Run("batches retain read order", func(t *testing.T) {152 t.Run("batches retain read order", func(t *testing.T) {
153 pty := &transactionalResizePTY{}153 pty := &transactionalResizePTY{}
154 d, sess, _, _ := newManualSessionWithPTYs(t, pty)154 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
155- tb, p := sess.activeTab(), sess.activeTab().focusedPane()155+ tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
156 p.screen.OnResponse = func([]byte) { p.ptyResponses = append(p.ptyResponses, 'r') }156 p.screen.OnResponse = func([]byte) { p.ptyResponses = append(p.ptyResponses, 'r') }
157 pty.onWrite = func([]byte) {157 pty.onWrite = func([]byte) {
158 p.mu.Lock()158 p.mu.Lock()
@@ -171,7 +171,7 @@ func TestProcessPTYDataRetainsCallbacksDuringResizeReplay(t *testing.T) {
171 pty := &transactionalResizePTY{onWrite: func(b []byte) { responses <- b }}171 pty := &transactionalResizePTY{onWrite: func(b []byte) { responses <- b }}
172 d, sess, ac, sends := newManualSessionWithPTYs(t, pty)172 d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
173 publishActiveClipboardCapability(d, sess, ac, ac.transport())173 publishActiveClipboardCapability(d, sess, ac, ac.transport())
174- tb, p := sess.activeTab(), sess.activeTab().focusedPane()174+ tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
175 p.screen.OnResponse = func(b []byte) { p.ptyResponses = append(p.ptyResponses, b...) }175 p.screen.OnResponse = func(b []byte) { p.ptyResponses = append(p.ptyResponses, b...) }
176 p.screen.OnBell = func() { p.ptyAttention = true }176 p.screen.OnBell = func() { p.ptyAttention = true }
177 p.screen.OnClipboard = func(b64 string) { p.ptyClipboards = append(p.ptyClipboards, b64) }177 p.screen.OnClipboard = func(b64 string) { p.ptyClipboards = append(p.ptyClipboards, b64) }
@@ -207,7 +207,7 @@ func TestProcessPTYDataRetainsCallbacksDuringResizeReplay(t *testing.T) {
207 func TestApplySessionLayoutStopsRetryWhenSessionCanceled(t *testing.T) {207 func TestApplySessionLayoutStopsRetryWhenSessionCanceled(t *testing.T) {
208 pty := &transactionalResizePTY{}208 pty := &transactionalResizePTY{}
209 d, sess, _, _ := newManualSessionWithPTYs(t, pty)209 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
210- tb, p := sess.activeTab(), sess.activeTab().focusedPane()210+ tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
211 d.beforeSessionResizePublication = sess.cancel211 d.beforeSessionResizePublication = sess.cancel
212 212
213 _, ok := d.applySessionLayout(sess, domain.Size{Cols: 100, Rows: 30}, nil, nil)213 _, ok := d.applySessionLayout(sess, domain.Size{Cols: 100, Rows: 30}, nil, nil)
@@ -226,7 +226,7 @@ func TestApplySessionLayoutStopsRetryWhenSessionCanceled(t *testing.T) {
226 func TestTransactionalResizeApplyDoesNotHoldTabOrPaneLocks(t *testing.T) {226 func TestTransactionalResizeApplyDoesNotHoldTabOrPaneLocks(t *testing.T) {
227 pty := &transactionalResizePTY{}227 pty := &transactionalResizePTY{}
228 d, sess, _, _ := newManualSessionWithPTYs(t, pty)228 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
229- tb := sess.activeTab()229+ tb := testAttachmentTab(sess)
230 p := tb.focusedPane()230 p := tb.focusedPane()
231 var tabUnlocked, paneUnlocked bool231 var tabUnlocked, paneUnlocked bool
232 pty.onResize = func() {232 pty.onResize = func() {
@@ -291,7 +291,7 @@ func TestTransactionalResizePartialFailureCommitsOnlySuccessfulPTYState(t *testi
291 ok := &transactionalResizePTY{}291 ok := &transactionalResizePTY{}
292 failed := &transactionalResizePTY{errs: []error{errors.New("scripted resize failure")}}292 failed := &transactionalResizePTY{errs: []error{errors.New("scripted resize failure")}}
293 d, sess, ac, _ := newManualSessionWithPTYs(t, ok)293 d, sess, ac, _ := newManualSessionWithPTYs(t, ok)
294- tb := sess.activeTab()294+ tb := testAttachmentTab(sess)
295 second := newPane("pane-2", failed, domain.Size{Cols: 80, Rows: 23})295 second := newPane("pane-2", failed, domain.Size{Cols: 80, Rows: 23})
296 tb.mu.Lock()296 tb.mu.Lock()
297 tb.panes[second.id] = second297 tb.panes[second.id] = second
@@ -332,7 +332,7 @@ func TestTransactionalResizeRetryMarksNamedSnapshotDirtyOnlyOnSuccess(t *testing
332 pty := &transactionalResizePTY{errs: tc.errs}332 pty := &transactionalResizePTY{errs: tc.errs}
333 d, sess, ac, _ := newManualSessionWithPTYs(t, pty)333 d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
334 sess.snapEligible.Store(true)334 sess.snapEligible.Store(true)
335- tb, p := sess.activeTab(), sess.activeTab().focusedPane()335+ tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
336 336
337 require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 100, Rows: 30}, true))337 require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 100, Rows: 30}, true))
338 rc := sess.renderCoordinator()338 rc := sess.renderCoordinator()
@@ -356,7 +356,7 @@ func TestTransactionalResizeRetryTargetsNewestCommittedEpoch(t *testing.T) {
356 ok := &transactionalResizePTY{}356 ok := &transactionalResizePTY{}
357 failed := &transactionalResizePTY{errs: []error{errors.New("first epoch fails"), nil}}357 failed := &transactionalResizePTY{errs: []error{errors.New("first epoch fails"), nil}}
358 d, sess, ac, _ := newManualSessionWithPTYs(t, ok)358 d, sess, ac, _ := newManualSessionWithPTYs(t, ok)
359- tb := sess.activeTab()359+ tb := testAttachmentTab(sess)
360 retry := newPane("retry", failed, domain.Size{Cols: 80, Rows: 23})360 retry := newPane("retry", failed, domain.Size{Cols: 80, Rows: 23})
361 tb.mu.Lock()361 tb.mu.Lock()
362 tb.panes[retry.id] = retry362 tb.panes[retry.id] = retry
@@ -386,10 +386,8 @@ func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) {
386 name string386 name string
387 run func(*renderCoordinator, *attachedClient, *attachedClient)387 run func(*renderCoordinator, *attachedClient, *attachedClient)
388 }{388 }{
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) }},
390 {"detach", func(rc *renderCoordinator, old, _ *attachedClient) { rc.noteDetach(old) }},390 {"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) }},
393 } {391 } {
394 t.Run(transition.name, func(t *testing.T) {392 t.Run(transition.name, func(t *testing.T) {
395 rc := newRenderCoordinator(renderCoordinatorOptions{})393 rc := newRenderCoordinator(renderCoordinatorOptions{})
@@ -412,7 +410,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing
412 d, sess, ac, _ := newManualSessionWithPTYs(t, first, second)410 d, sess, ac, _ := newManualSessionWithPTYs(t, first, second)
413 observer := &daemonRuntimeObserver{}411 observer := &daemonRuntimeObserver{}
414 d.runtimeObserver = observer412 d.runtimeObserver = observer
415- rc := d.attachCoordinator(sess, nil, ac, true)413+ rc := d.attachCoordinator(sess, ac, true)
416 lease := rc.attachmentLease(ac)414 lease := rc.attachmentLease(ac)
417 var newer uint64415 var newer uint64
418 d.beforeSessionResizePublication = func() {416 d.beforeSessionResizePublication = func() {
@@ -462,10 +460,51 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing
462 require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}}, second.requested())460 require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}}, second.requested())
463 }461 }
464 462
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+
465 func TestStaleRemovedMemberGateIsCanceledOnceByFreshPlan(t *testing.T) {504 func TestStaleRemovedMemberGateIsCanceledOnceByFreshPlan(t *testing.T) {
466 pty := &transactionalResizePTY{}505 pty := &transactionalResizePTY{}
467 d, sess, _, _ := newManualSessionWithPTYs(t, pty)506 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
468- tb := sess.activeTab()507+ tb := testAttachmentTab(sess)
469 p := tb.focusedPane()508 p := tb.focusedPane()
470 p.resizeApplying = true509 p.resizeApplying = true
471 p.resizePending = []byte("x")510 p.resizePending = []byte("x")
@@ -489,7 +528,7 @@ func TestStaleRemovedMemberGateIsCanceledOnceByFreshPlan(t *testing.T) {
489 func TestHeadlessResizeRetriesInvalidatedPlan(t *testing.T) {528 func TestHeadlessResizeRetriesInvalidatedPlan(t *testing.T) {
490 pty := &transactionalResizePTY{}529 pty := &transactionalResizePTY{}
491 d, sess, _, _ := newManualSessionWithPTYs(t, pty)530 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
492- tb := sess.activeTab()531+ tb := testAttachmentTab(sess)
493 var invalidate sync.Once532 var invalidate sync.Once
494 pty.onResize = func() {533 pty.onResize = func() {
495 invalidate.Do(func() {534 invalidate.Do(func() {
@@ -509,14 +548,14 @@ func TestHeadlessResizeRetriesInvalidatedPlan(t *testing.T) {
509 func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) {548 func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) {
510 popupPTY := &transactionalResizePTY{errs: []error{errors.New("initial popup failure")}}549 popupPTY := &transactionalResizePTY{errs: []error{errors.New("initial popup failure")}}
511 d, source, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})550 d, source, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
512- tb := source.activeTab()551+ tb := testAttachmentTab(source)
513 popup := newPane("popup", popupPTY, domain.Size{Cols: 80, Rows: 23})552 popup := newPane("popup", popupPTY, domain.Size{Cols: 80, Rows: 23})
514 tb.mu.Lock()553 tb.mu.Lock()
515 tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7}554 tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7}
516 tb.mu.Unlock()555 tb.mu.Unlock()
517 owner := publishPaneOwner(popup, source, tb, 7)556 owner := publishPaneOwner(popup, source, tb, 7)
518 557
519- rc := d.attachCoordinator(source, nil, ac, true)558+ rc := d.attachCoordinator(source, ac, true)
520 lease := rc.attachmentLease(ac)559 lease := rc.attachmentLease(ac)
521 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)560 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
522 require.True(t, d.runResizeTransaction(source, ac, lease, epoch))561 require.True(t, d.runResizeTransaction(source, ac, lease, epoch))
@@ -561,7 +600,7 @@ func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) {
561 d.ApplyConfig(domain.Config{Floating: cfg})600 d.ApplyConfig(domain.Config{Floating: cfg})
562 require.True(t, d.requestTransactionalResize(sess, nil, tc.from, true))601 require.True(t, d.requestTransactionalResize(sess, nil, tc.from, true))
563 602
564- tb := sess.activeTab()603+ tb := testAttachmentTab(sess)
565 fromGeometry := calculateContentFloatingGeometry(tabSize(tc.from), cfg)604 fromGeometry := calculateContentFloatingGeometry(tabSize(tc.from), cfg)
566 toGeometry := calculateContentFloatingGeometry(tabSize(tc.to), cfg)605 toGeometry := calculateContentFloatingGeometry(tabSize(tc.to), cfg)
567 entered, release := make(chan struct{}), make(chan struct{})606 entered, release := make(chan struct{}), make(chan struct{})
@@ -735,13 +774,13 @@ func TestZeroInnerFloatingDrawerRejectsStaleRequestWithoutPhysicalPublication(t
735 func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) {774 func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) {
736 popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}}775 popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}}
737 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})776 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
738- tb := sess.activeTab()777+ tb := testAttachmentTab(sess)
739 popup := newPane("popup", popupPTY, domain.Size{Cols: 80, Rows: 23})778 popup := newPane("popup", popupPTY, domain.Size{Cols: 80, Rows: 23})
740 tb.mu.Lock()779 tb.mu.Lock()
741 tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7}780 tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7}
742 tb.mu.Unlock()781 tb.mu.Unlock()
743 782
744- rc := d.attachCoordinator(sess, nil, ac, true)783+ rc := d.attachCoordinator(sess, ac, true)
745 lease := rc.attachmentLease(ac)784 lease := rc.attachmentLease(ac)
746 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)785 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
747 require.NotZero(t, epoch)786 require.NotZero(t, epoch)
@@ -823,7 +862,7 @@ func TestTransactionalResizeFloatingMouseCopyAndSearchContract(t *testing.T) {
823 entered, release := make(chan struct{}), make(chan struct{})862 entered, release := make(chan struct{}), make(chan struct{})
824 pty := &transactionalResizePTY{errs: tc.errs, onResize: func() { close(entered); <-release }}863 pty := &transactionalResizePTY{errs: tc.errs, onResize: func() { close(entered); <-release }}
825 d, sess, ac, _ := newManualSessionWithPTYs(t, pty)864 d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
826- tb := sess.activeTab()865+ tb := testAttachmentTab(sess)
827 p := tb.focusedPane()866 p := tb.focusedPane()
828 old := p.rect867 old := p.rect
829 d.enterCopyMode(sess, ac)868 d.enterCopyMode(sess, ac)
M internal/usecase/daemon/transport_observability_test.go +3 -3
@@ -20,7 +20,7 @@ func TestTransportObservabilityDaemonBoundaries(t *testing.T) {
20 // One in-flight state makes the second public resize take the ACK-blocked20 // One in-flight state makes the second public resize take the ACK-blocked
21 // path. The real coordinator owns both renders and the ACK wake.21 // path. The real coordinator owns both renders and the ACK wake.
22 ac.output = newOutputStateStream(1)22 ac.output = newOutputStateStream(1)
23- rc := d.attachCoordinator(sess, nil, ac, true)23+ rc := d.attachCoordinator(sess, ac, true)
24 24
25 if !d.resizeForFirstPaint(sess, ac, domain.Size{Cols: 90, Rows: 25}) {25 if !d.resizeForFirstPaint(sess, ac, domain.Size{Cols: 90, Rows: 25}) {
26 t.Fatal("first resize was not accepted")26 t.Fatal("first resize was not accepted")
@@ -125,7 +125,7 @@ func TestTransportObservabilityBlockedRenderObserverReleasesArchitectureLocks(t
125 }125 }
126 ac.ackOutputState(output.NewStateNum)126 ac.ackOutputState(output.NewStateNum)
127 127
128- pane := sess.activeTab().focusedPane()128+ pane := testAttachmentTab(sess).focusedPane()
129 for _, lock := range []struct {129 for _, lock := range []struct {
130 name string130 name string
131 fn func()131 fn func()
@@ -141,7 +141,7 @@ func TestTransportObservabilityBlockedRenderObserverReleasesArchitectureLocks(t
141 141
142 livePTY := make(chan struct{})142 livePTY := make(chan struct{})
143 go func() {143 go func() {
144- d.processPTYData(sess, sess.activeTab(), pane, []byte("live"), false)144+ d.processPTYData(sess, testAttachmentTab(sess), pane, []byte("live"), false)
145 close(livePTY)145 close(livePTY)
146 }()146 }()
147 awaitDaemonObserver(t, livePTY, "live PTY processing")147 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 2

Tags 13

↑↓ moveenter openesc close