detached 5e4aa1c

fix(daemon): close review gaps for unified attachments

commit
5e4aa1c45199efb56d87e23262cdf63ac47f6faf
author
brice · · 2026-08-03 23:35 UTC
parent
457134c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
67 files · +1560 -373
M .coderabbit.yaml +1 -0
@@ -1,3 +1,4 @@
1+# Temporary PR #173 review scope; remove after the clean reviewed-head merge.
12 reviews:
23 path_filters:
34 - "!internal/usecase/daemon/copy_enter_allocation_budget_race_test.go"
@@ -1,3 +1,4 @@
1+# Temporary PR #173 review scope; remove after the clean reviewed-head merge.
1 reviews:2 reviews:
2 path_filters:3 path_filters:
3 - "!internal/usecase/daemon/copy_enter_allocation_budget_race_test.go"4 - "!internal/usecase/daemon/copy_enter_allocation_budget_race_test.go"
M internal/app/daemon_integration_test.go +13 -2
@@ -265,7 +265,9 @@ func TestIntegration_TwoAttachmentsReceiveSharedMutationPTYOutput(t *testing.T)
265265 sz := domain.Size{Cols: 80, Rows: 24}
266266 dir, _ := startDaemon(t)
267267 firstShell := shellFixture(t, "first")
268- secondShell := shellFixture(t, "second")
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))
269271 firstEnv := []string{
270272 "VEV_TEST_ENV=first", "SHELL=" + firstShell, "XDG_RUNTIME_DIR=/run/first", "WAYLAND_DISPLAY=wayland-first",
271273 "TERM=client", "COLORTERM=client", "TERM_PROGRAM=client", "VEV=client",
@@ -289,6 +291,15 @@ func TestIntegration_TwoAttachmentsReceiveSharedMutationPTYOutput(t *testing.T)
289291 awaitText(t, p2, sz, "Commands")
290292 require.NoError(t, tr2.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("CNT\r")})}))
291293 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)
292303 }
293304
294305 func TestIntegration_AttachFirstOutput(t *testing.T) {
@@ -359,7 +370,7 @@ func TestIntegration_CommandPaletteRenamesEphemeralSession(t *testing.T) {
359370 require.Equal(t, ports.MsgSessions, f.Type)
360371 sessions, err := ports.UnmarshalSessions(f.Payload)
361372 require.NoError(t, err)
362- require.Len(t, sessions.Sessions, 1)
373+ require.NotEmpty(t, sessions.Sessions)
363374 require.Equal(t, "work", sessions.Sessions[0].Name)
364375 require.False(t, sessions.Sessions[0].Ephemeral)
365376 }
@@ -265,7 +265,9 @@ func TestIntegration_TwoAttachmentsReceiveSharedMutationPTYOutput(t *testing.T)
265 sz := domain.Size{Cols: 80, Rows: 24}265 sz := domain.Size{Cols: 80, Rows: 24}
266 dir, _ := startDaemon(t)266 dir, _ := startDaemon(t)
267 firstShell := shellFixture(t, "first")267 firstShell := shellFixture(t, "first")
268- secondShell := shellFixture(t, "second")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))
269 firstEnv := []string{271 firstEnv := []string{
270 "VEV_TEST_ENV=first", "SHELL=" + firstShell, "XDG_RUNTIME_DIR=/run/first", "WAYLAND_DISPLAY=wayland-first",272 "VEV_TEST_ENV=first", "SHELL=" + firstShell, "XDG_RUNTIME_DIR=/run/first", "WAYLAND_DISPLAY=wayland-first",
271 "TERM=client", "COLORTERM=client", "TERM_PROGRAM=client", "VEV=client",273 "TERM=client", "COLORTERM=client", "TERM_PROGRAM=client", "VEV=client",
@@ -289,6 +291,15 @@ func TestIntegration_TwoAttachmentsReceiveSharedMutationPTYOutput(t *testing.T)
289 awaitText(t, p2, sz, "Commands")291 awaitText(t, p2, sz, "Commands")
290 require.NoError(t, tr2.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("CNT\r")})}))292 require.NoError(t, tr2.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("CNT\r")})}))
291 awaitText(t, p2, sz, "SHELL_COMMAND=second")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)
292 }303 }
293 304
294 func TestIntegration_AttachFirstOutput(t *testing.T) {305 func TestIntegration_AttachFirstOutput(t *testing.T) {
@@ -359,7 +370,7 @@ func TestIntegration_CommandPaletteRenamesEphemeralSession(t *testing.T) {
359 require.Equal(t, ports.MsgSessions, f.Type)370 require.Equal(t, ports.MsgSessions, f.Type)
360 sessions, err := ports.UnmarshalSessions(f.Payload)371 sessions, err := ports.UnmarshalSessions(f.Payload)
361 require.NoError(t, err)372 require.NoError(t, err)
362- require.Len(t, sessions.Sessions, 1)373+ require.NotEmpty(t, sessions.Sessions)
363 require.Equal(t, "work", sessions.Sessions[0].Name)374 require.Equal(t, "work", sessions.Sessions[0].Name)
364 require.False(t, sessions.Sessions[0].Ephemeral)375 require.False(t, sessions.Sessions[0].Ephemeral)
365 }376 }
M internal/usecase/daemon/attachment_connection.go +2 -2
@@ -70,7 +70,7 @@ func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attach
7070 }
7171
7272 // current validates every immutable identity captured by the token. Membership
73-// is the sole authority; there is no owner, replacement, or compatibility role.
73+// is the sole authority; there is no owner, replacement, or compatibility distinction.
7474 func (t attachmentConnectionToken) current() bool {
7575 return t.sess != nil && t.ac != nil &&
7676 t.ac.connectionGeneration.Load() == t.generation &&
@@ -101,7 +101,7 @@ func (t attachmentConnectionToken) attachmentEffectCurrent() bool {
101101 }
102102
103103 func beginAttachmentLeaseEffect(sess attachmentSession, ac *attachedClient, lease *attachmentLease) (*attachmentEffectTicket, bool) {
104- if sess == nil || ac == nil || lease == nil {
104+ if sess == nil || ac == nil || lease == nil || lease.attachment != ac {
105105 return nil, false
106106 }
107107 token := attachmentToken(sess, ac, ac.transport())
@@ -70,7 +70,7 @@ func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attach
70 }70 }
71 71
72 // current validates every immutable identity captured by the token. Membership72 // current validates every immutable identity captured by the token. Membership
73-// is the sole authority; there is no owner, replacement, or compatibility role.73+// is the sole authority; there is no owner, replacement, or compatibility distinction.
74 func (t attachmentConnectionToken) current() bool {74 func (t attachmentConnectionToken) current() bool {
75 return t.sess != nil && t.ac != nil &&75 return t.sess != nil && t.ac != nil &&
76 t.ac.connectionGeneration.Load() == t.generation &&76 t.ac.connectionGeneration.Load() == t.generation &&
@@ -101,7 +101,7 @@ func (t attachmentConnectionToken) attachmentEffectCurrent() bool {
101 }101 }
102 102
103 func beginAttachmentLeaseEffect(sess attachmentSession, ac *attachedClient, lease *attachmentLease) (*attachmentEffectTicket, bool) {103 func beginAttachmentLeaseEffect(sess attachmentSession, ac *attachedClient, lease *attachmentLease) (*attachmentEffectTicket, bool) {
104- if sess == nil || ac == nil || lease == nil {104+ if sess == nil || ac == nil || lease == nil || lease.attachment != ac {
105 return nil, false105 return nil, false
106 }106 }
107 token := attachmentToken(sess, ac, ac.transport())107 token := attachmentToken(sess, ac, ac.transport())
M internal/usecase/daemon/attachment_effect_freeze.go +1 -6
@@ -67,17 +67,12 @@ type frozenAttachmentEffectGates struct {
6767 drained bool
6868 }
6969
70-func (f frozenAttachmentEffectGates) interrupted(ac *attachedClient, expected transportSnapshot) bool {
71- interrupted, ok := f.interruptions[ac]
72- return ok && interrupted.transport == expected.transport && interrupted.incarnation == expected.incarnation
73-}
74-
7570 type attachmentTransportInterrupt struct {
7671 ac *attachedClient
7772 transport transportSnapshot
7873 }
7974
80-// attachmentEffectFreezeOptions controls one role-gate freeze operation. A freeze
75+// attachmentEffectFreezeOptions controls one attachment-effect-gate freeze operation. A freeze
8176 // must be called without daemon, routing, session, coordinator, send, overlay,
8277 // router, or pane locks held.
8378 type attachmentEffectFreezeOptions struct {
@@ -67,17 +67,12 @@ type frozenAttachmentEffectGates struct {
67 drained bool67 drained bool
68 }68 }
69 69
70-func (f frozenAttachmentEffectGates) interrupted(ac *attachedClient, expected transportSnapshot) bool {
71- interrupted, ok := f.interruptions[ac]
72- return ok && interrupted.transport == expected.transport && interrupted.incarnation == expected.incarnation
73-}
74-
75 type attachmentTransportInterrupt struct {70 type attachmentTransportInterrupt struct {
76 ac *attachedClient71 ac *attachedClient
77 transport transportSnapshot72 transport transportSnapshot
78 }73 }
79 74
80-// attachmentEffectFreezeOptions controls one role-gate freeze operation. A freeze75+// attachmentEffectFreezeOptions controls one attachment-effect-gate freeze operation. A freeze
81 // must be called without daemon, routing, session, coordinator, send, overlay,76 // must be called without daemon, routing, session, coordinator, send, overlay,
82 // router, or pane locks held.77 // router, or pane locks held.
83 type attachmentEffectFreezeOptions struct {78 type attachmentEffectFreezeOptions struct {
M internal/usecase/daemon/attachment_effect_gate.go +7 -0
@@ -241,6 +241,13 @@ func (ac *attachedClient) beginCurrentAttachmentEffect(sess *session, tr ports.T
241241 continue
242242 }
243243 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+ }
244251 // Publication may have completed between token capture and admission.
245252 // Re-read the registry and exact generation instead of using stale authority.
246253 }
@@ -241,6 +241,13 @@ func (ac *attachedClient) beginCurrentAttachmentEffect(sess *session, tr ports.T
241 continue241 continue
242 }242 }
243 g.mu.Unlock()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+ }
244 // Publication may have completed between token capture and admission.251 // Publication may have completed between token capture and admission.
245 // Re-read the registry and exact generation instead of using stale authority.252 // Re-read the registry and exact generation instead of using stale authority.
246 }253 }
M internal/usecase/daemon/attachment_effect_gate_test.go +74 -42
@@ -118,7 +118,7 @@ func (t *teardownBlockingTransport) Closed() bool {
118118 }
119119 }
120120
121-func startBlockedRoleSend(t *testing.T, token attachmentConnectionToken) <-chan struct{} {
121+func startBlockedAttachmentSend(t *testing.T, token attachmentConnectionToken) <-chan struct{} {
122122 t.Helper()
123123 ticket, admitted := token.ac.beginAttachmentEffect(token)
124124 require.True(t, admitted)
@@ -131,7 +131,7 @@ func startBlockedRoleSend(t *testing.T, token attachmentConnectionToken) <-chan
131131 return done
132132 }
133133
134-func requireRoleGateRetired(t *testing.T, ac *attachedClient) {
134+func requireAttachmentEffectGateRetired(t *testing.T, ac *attachedClient) {
135135 t.Helper()
136136 ac.attachmentEffects.mu.Lock()
137137 defer ac.attachmentEffects.mu.Unlock()
@@ -140,7 +140,39 @@ func requireRoleGateRetired(t *testing.T, ac *attachedClient) {
140140 require.Empty(t, ac.attachmentEffects.transportEffects)
141141 }
142142
143-func roleGatePublicationSnapshot(ac *attachedClient) (attachmentEffectPhase, attachmentCapability) {
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) {
144176 ac.attachmentEffects.mu.Lock()
145177 defer ac.attachmentEffects.mu.Unlock()
146178 return ac.attachmentEffects.phase, ac.attachmentEffects.capability
@@ -164,7 +196,7 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
164196 sess.registerAttachmentLocked(later)
165197 sess.mu.Unlock()
166198
167- rc := d.attachCoordinator(sess, nil, first, true)
199+ rc := d.attachCoordinator(sess, first, true)
168200 firstToken := sess.attachmentToken(first, firstTransport)
169201 firstToken.lease = rc.attachmentLease(first)
170202 first.publishAttachmentCapability(firstToken)
@@ -189,9 +221,9 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
189221 }
190222 })
191223
192- firstPhase, firstCapability := roleGatePublicationSnapshot(first)
193- blockedPhase, blockedCapability := roleGatePublicationSnapshot(blocked)
194- laterPhase, laterCapability := roleGatePublicationSnapshot(later)
224+ firstPhase, firstCapability := attachmentEffectGatePublicationSnapshot(first)
225+ blockedPhase, blockedCapability := attachmentEffectGatePublicationSnapshot(blocked)
226+ laterPhase, laterCapability := attachmentEffectGatePublicationSnapshot(later)
195227 require.Equal(t, attachmentEffectsStable, firstPhase)
196228 require.Equal(t, attachmentEffectsFrozen, blockedPhase)
197229 require.Equal(t, attachmentEffectsStable, laterPhase)
@@ -209,7 +241,7 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
209241 killDone := make(chan error, 1)
210242 go func() { killDone <- d.killSession(sess, ports.ReasonSessionKilled, false) }()
211243
212- require.Same(t, first, awaitTestValue(t, frozenByKill, "teardown did not acquire the first role gate"))
244+ require.Same(t, first, awaitTestValue(t, frozenByKill, "teardown did not acquire the first attachment effect gate"))
213245 deadline := awaitTestValue(t, clock.timers, "teardown did not arm the gate acquisition deadline")
214246 require.Equal(t, detachNotifyTimeout, deadline.duration)
215247 deadline.ch <- time.Now()
@@ -242,9 +274,9 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
242274 require.False(t, blockedTransport.Closed())
243275 require.False(t, laterTransport.Closed())
244276
245- firstPhase, gotFirstCapability := roleGatePublicationSnapshot(first)
246- blockedPhase, gotBlockedCapability := roleGatePublicationSnapshot(blocked)
247- laterPhase, gotLaterCapability := roleGatePublicationSnapshot(later)
277+ firstPhase, gotFirstCapability := attachmentEffectGatePublicationSnapshot(first)
278+ blockedPhase, gotBlockedCapability := attachmentEffectGatePublicationSnapshot(blocked)
279+ laterPhase, gotLaterCapability := attachmentEffectGatePublicationSnapshot(later)
248280 require.Equal(t, attachmentEffectsStable, firstPhase, "partially acquired gate was not rolled back")
249281 require.Equal(t, attachmentEffectsFrozen, blockedPhase, "teardown released a gate owned by another transition")
250282 require.Equal(t, attachmentEffectsStable, laterPhase, "unacquired later gate was frozen or invalidated")
@@ -260,9 +292,9 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
260292 complete.unfreeze()
261293 for _, token := range []attachmentConnectionToken{firstToken, blockedToken, laterToken} {
262294 ticket, admitted := token.ac.beginAttachmentEffect(token)
263- require.True(t, admitted, "pre-publication abort changed a role capability")
295+ require.True(t, admitted, "pre-publication abort changed an attachment capability")
264296 ticket.End()
265- requireRoleGateRetired(t, token.ac)
297+ requireAttachmentEffectGateRetired(t, token.ac)
266298 }
267299 }
268300
@@ -270,7 +302,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
270302 d, sess, snatched, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
271303 snatchedTransport := newTeardownBlockingTransport()
272304 snatched.replaceTransport(snatchedTransport)
273- d.attachCoordinator(sess, nil, snatched, true)
305+ d.attachCoordinator(sess, snatched, true)
274306 snatched.publishAttachmentCapability(sess.attachmentToken(snatched, snatchedTransport))
275307
276308 activeTransport := newTeardownBlockingTransport()
@@ -290,12 +322,12 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
290322 d.mu.Lock()
291323 d.sessions[unrelated.id] = unrelated
292324 d.mu.Unlock()
293- d.attachCoordinator(unrelated, nil, unrelatedClient, true)
325+ d.attachCoordinator(unrelated, unrelatedClient, true)
294326
295327 snatchedToken := sess.attachmentToken(snatched, snatchedTransport)
296328 activeToken := sess.attachmentToken(active, activeTransport)
297- snatchedDone := startBlockedRoleSend(t, snatchedToken)
298- activeDone := startBlockedRoleSend(t, activeToken)
329+ snatchedDone := startBlockedAttachmentSend(t, snatchedToken)
330+ activeDone := startBlockedAttachmentSend(t, activeToken)
299331 awaitTestCompletion(t, snatchedTransport.sendEntered, "snatched send did not block")
300332 awaitTestCompletion(t, activeTransport.sendEntered, "active send did not block")
301333
@@ -309,8 +341,8 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
309341 _ = activeTransport.Close()
310342 t.Fatal("session kill remained pinned behind admitted participant sends")
311343 }
312- awaitTestCompletion(t, snatchedDone, "snatched role ticket did not retire")
313- awaitTestCompletion(t, activeDone, "active role ticket did not retire")
344+ awaitTestCompletion(t, snatchedDone, "snatched attachment effect ticket did not retire")
345+ awaitTestCompletion(t, activeDone, "active attachment effect ticket did not retire")
314346 d.waitNotifies()
315347
316348 require.True(t, snatchedTransport.Closed())
@@ -318,19 +350,19 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
318350 require.False(t, unrelatedTransport.Closed(), "unrelated session transport was interrupted")
319351 require.Contains(t, unrelated.snapshotAttachments(), unrelatedClient)
320352 require.Same(t, unrelated, unrelatedClient.currentSession())
321- requireRoleGateRetired(t, snatched)
322- requireRoleGateRetired(t, active)
353+ requireAttachmentEffectGateRetired(t, snatched)
354+ requireAttachmentEffectGateRetired(t, active)
323355 }
324356
325357 func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *testing.T) {
326358 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
327359 stale := newTeardownBlockingTransport()
328360 ac.replaceTransport(stale)
329- d.attachCoordinator(sess, nil, ac, true)
361+ d.attachCoordinator(sess, ac, true)
330362 ac.publishAttachmentCapability(sess.attachmentToken(ac, stale))
331363
332364 token := sess.attachmentToken(ac, stale)
333- sendDone := startBlockedRoleSend(t, token)
365+ sendDone := startBlockedAttachmentSend(t, token)
334366 awaitTestCompletion(t, stale.sendEntered, "captured incarnation send did not block")
335367 fresh := &closeTrackingTransport{}
336368 d.afterAttachmentEffectParticipantsSnapshotted = func(string, []*attachedClient) {
@@ -338,7 +370,7 @@ func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *test
338370 }
339371
340372 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
341- awaitTestCompletion(t, sendDone, "captured incarnation role ticket did not retire")
373+ awaitTestCompletion(t, sendDone, "captured incarnation attachment effect ticket did not retire")
342374 require.True(t, stale.Closed(), "exact captured in-flight transport was not interrupted")
343375 require.False(t, fresh.Closed(), "newer transport incarnation was interrupted")
344376 require.Same(t, fresh, ac.transport())
@@ -348,14 +380,14 @@ func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *test
348380 sess.mu.Lock()
349381 require.Contains(t, sess.snapshotAttachmentsLocked(), ac)
350382 sess.mu.Unlock()
351- requireRoleGateRetired(t, ac)
383+ requireAttachmentEffectGateRetired(t, ac)
352384 }
353385
354386 func TestShutdownSignalsServeWhenParticipantGateAcquisitionTimesOut(t *testing.T) {
355387 d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
356388 transport := &closeTrackingTransport{}
357389 active.replaceTransport(transport)
358- d.attachCoordinator(sess, nil, active, true)
390+ d.attachCoordinator(sess, active, true)
359391 active.publishAttachmentCapability(sess.attachmentToken(active, transport))
360392
361393 owner := freezeAttachmentEffectGates(active)
@@ -386,11 +418,11 @@ func TestShutdownInterruptsBlockedParticipantSend(t *testing.T) {
386418 d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
387419 blocked := newTeardownBlockingTransport()
388420 active.replaceTransport(blocked)
389- d.attachCoordinator(sess, nil, active, true)
421+ d.attachCoordinator(sess, active, true)
390422 active.publishAttachmentCapability(sess.attachmentToken(active, blocked))
391423
392424 token := sess.attachmentToken(active, blocked)
393- sendDone := startBlockedRoleSend(t, token)
425+ sendDone := startBlockedAttachmentSend(t, token)
394426 awaitTestCompletion(t, blocked.sendEntered, "active send did not block")
395427
396428 shutdownDone := make(chan bool, 1)
@@ -402,17 +434,17 @@ func TestShutdownInterruptsBlockedParticipantSend(t *testing.T) {
402434 _ = blocked.Close()
403435 t.Fatal("shutdown remained pinned behind admitted participant send")
404436 }
405- awaitTestCompletion(t, sendDone, "shutdown-interrupted role ticket did not retire")
437+ awaitTestCompletion(t, sendDone, "shutdown-interrupted attachment effect ticket did not retire")
406438 d.waitNotifies()
407439 require.True(t, blocked.Closed())
408- requireRoleGateRetired(t, active)
440+ requireAttachmentEffectGateRetired(t, active)
409441 }
410442
411-func TestRenderSendFailureCleanupIsDeadlineBoundedWhileRoleGateIsBusy(t *testing.T) {
443+func TestRenderSendFailureCleanupIsDeadlineBoundedWhileAttachmentEffectGateIsBusy(t *testing.T) {
412444 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
413445 transport := &closeTrackingTransport{}
414446 ac.replaceTransport(transport)
415- rc := d.attachCoordinator(sess, nil, ac, true)
447+ rc := d.attachCoordinator(sess, ac, true)
416448 token := sess.attachmentToken(ac, transport)
417449 token.lease = rc.attachmentLease(ac)
418450 ac.publishAttachmentCapability(token)
@@ -442,7 +474,7 @@ func TestAttachmentEffectGateReplacementInterruptsBlockedOldRenderBeforePublicat
442474 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
443475 oldTransport := newBlockedRenderReplacementTransport(d, sess)
444476 old.replaceTransport(oldTransport)
445- rc := d.attachCoordinator(sess, nil, old, true)
477+ rc := d.attachCoordinator(sess, old, true)
446478 lease := rc.attachmentLease(old)
447479 token := sess.attachmentToken(old, oldTransport)
448480 token.lease = lease
@@ -497,7 +529,7 @@ func TestAttachmentEffectGateAdmittedEffectCompletesBeforeConflictingTransition(
497529 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
498530 oldTransport := newDatagramTestTransport()
499531 old.replaceTransport(oldTransport)
500- rc := d.attachCoordinator(sess, nil, old, true)
532+ rc := d.attachCoordinator(sess, old, true)
501533 token := sess.attachmentToken(old, oldTransport)
502534 token.lease = rc.attachmentLease(old)
503535 old.publishAttachmentCapability(token)
@@ -598,7 +630,7 @@ func TestAttachmentEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *tes
598630 oldTransport := newDatagramTestTransport()
599631 old.replaceTransport(oldTransport)
600632 old.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: old}, &d.bindings)
601- rc := d.attachCoordinator(sess, nil, old, true)
633+ rc := d.attachCoordinator(sess, old, true)
602634 token := sess.attachmentToken(old, oldTransport)
603635 token.lease = rc.attachmentLease(old)
604636 if tt.setup != nil {
@@ -651,7 +683,7 @@ func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {
651683 target.tabs[1].attentionAt = time.Unix(1, 0)
652684 d.sessions[target.id] = target
653685
654- rc := d.attachCoordinator(source, nil, ac, true)
686+ rc := d.attachCoordinator(source, ac, true)
655687 token := source.attachmentToken(ac, ac.transport())
656688 token.lease = rc.attachmentLease(ac)
657689 ac.publishAttachmentCapability(token)
@@ -669,7 +701,7 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T)
669701 d.enterPicker(sess, old)
670702
671703 oldTransport := old.transport()
672- rc := d.attachCoordinator(sess, nil, old, true)
704+ rc := d.attachCoordinator(sess, old, true)
673705 token := sess.attachmentToken(old, oldTransport)
674706 token.lease = rc.attachmentLease(old)
675707 old.publishAttachmentCapability(token)
@@ -713,7 +745,7 @@ func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) {
713745 defer release()
714746 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
715747 d.enterPicker(sess, ac)
716- rc := d.attachCoordinator(sess, nil, ac, true)
748+ rc := d.attachCoordinator(sess, ac, true)
717749 token := sess.attachmentToken(ac, ac.transport())
718750 token.lease = rc.attachmentLease(ac)
719751 ac.publishAttachmentCapability(token)
@@ -730,7 +762,7 @@ func TestAttachmentEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *test
730762 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
731763 oldTransport := newDatagramTestTransport()
732764 old.replaceTransport(oldTransport)
733- rc := d.attachCoordinator(sess, nil, old, true)
765+ rc := d.attachCoordinator(sess, old, true)
734766 token := sess.attachmentToken(old, oldTransport)
735767 token.lease = rc.attachmentLease(old)
736768
@@ -764,7 +796,7 @@ func TestAttachmentEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testi
764796 d, first, a, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
765797 aTransport := newDatagramTestTransport()
766798 a.replaceTransport(aTransport)
767- d.attachCoordinator(first, nil, a, true)
799+ d.attachCoordinator(first, a, true)
768800 first.attachmentToken(a, aTransport)
769801
770802 bTransport := newDatagramTestTransport()
@@ -775,7 +807,7 @@ func TestAttachmentEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testi
775807 d.mu.Lock()
776808 d.sessions[second.id] = second
777809 d.mu.Unlock()
778- d.attachCoordinator(second, nil, b, true)
810+ d.attachCoordinator(second, b, true)
779811 second.attachmentToken(b, bTransport)
780812
781813 start := make(chan struct{})
@@ -822,7 +854,7 @@ func TestAttachmentEffectGateFrozenConnectionRejectsLateEffect(t *testing.T) {
822854 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
823855 transport := newDatagramTestTransport()
824856 ac.replaceTransport(transport)
825- rc := d.attachCoordinator(sess, nil, ac, true)
857+ rc := d.attachCoordinator(sess, ac, true)
826858 token := sess.attachmentToken(ac, transport)
827859 token.lease = rc.attachmentLease(ac)
828860 ac.publishAttachmentCapability(token)
@@ -118,7 +118,7 @@ func (t *teardownBlockingTransport) Closed() bool {
118 }118 }
119 }119 }
120 120
121-func startBlockedRoleSend(t *testing.T, token attachmentConnectionToken) <-chan struct{} {121+func startBlockedAttachmentSend(t *testing.T, token attachmentConnectionToken) <-chan struct{} {
122 t.Helper()122 t.Helper()
123 ticket, admitted := token.ac.beginAttachmentEffect(token)123 ticket, admitted := token.ac.beginAttachmentEffect(token)
124 require.True(t, admitted)124 require.True(t, admitted)
@@ -131,7 +131,7 @@ func startBlockedRoleSend(t *testing.T, token attachmentConnectionToken) <-chan
131 return done131 return done
132 }132 }
133 133
134-func requireRoleGateRetired(t *testing.T, ac *attachedClient) {134+func requireAttachmentEffectGateRetired(t *testing.T, ac *attachedClient) {
135 t.Helper()135 t.Helper()
136 ac.attachmentEffects.mu.Lock()136 ac.attachmentEffects.mu.Lock()
137 defer ac.attachmentEffects.mu.Unlock()137 defer ac.attachmentEffects.mu.Unlock()
@@ -140,7 +140,39 @@ func requireRoleGateRetired(t *testing.T, ac *attachedClient) {
140 require.Empty(t, ac.attachmentEffects.transportEffects)140 require.Empty(t, ac.attachmentEffects.transportEffects)
141 }141 }
142 142
143-func roleGatePublicationSnapshot(ac *attachedClient) (attachmentEffectPhase, attachmentCapability) {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) {
144 ac.attachmentEffects.mu.Lock()176 ac.attachmentEffects.mu.Lock()
145 defer ac.attachmentEffects.mu.Unlock()177 defer ac.attachmentEffects.mu.Unlock()
146 return ac.attachmentEffects.phase, ac.attachmentEffects.capability178 return ac.attachmentEffects.phase, ac.attachmentEffects.capability
@@ -164,7 +196,7 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
164 sess.registerAttachmentLocked(later)196 sess.registerAttachmentLocked(later)
165 sess.mu.Unlock()197 sess.mu.Unlock()
166 198
167- rc := d.attachCoordinator(sess, nil, first, true)199+ rc := d.attachCoordinator(sess, first, true)
168 firstToken := sess.attachmentToken(first, firstTransport)200 firstToken := sess.attachmentToken(first, firstTransport)
169 firstToken.lease = rc.attachmentLease(first)201 firstToken.lease = rc.attachmentLease(first)
170 first.publishAttachmentCapability(firstToken)202 first.publishAttachmentCapability(firstToken)
@@ -189,9 +221,9 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
189 }221 }
190 })222 })
191 223
192- firstPhase, firstCapability := roleGatePublicationSnapshot(first)224+ firstPhase, firstCapability := attachmentEffectGatePublicationSnapshot(first)
193- blockedPhase, blockedCapability := roleGatePublicationSnapshot(blocked)225+ blockedPhase, blockedCapability := attachmentEffectGatePublicationSnapshot(blocked)
194- laterPhase, laterCapability := roleGatePublicationSnapshot(later)226+ laterPhase, laterCapability := attachmentEffectGatePublicationSnapshot(later)
195 require.Equal(t, attachmentEffectsStable, firstPhase)227 require.Equal(t, attachmentEffectsStable, firstPhase)
196 require.Equal(t, attachmentEffectsFrozen, blockedPhase)228 require.Equal(t, attachmentEffectsFrozen, blockedPhase)
197 require.Equal(t, attachmentEffectsStable, laterPhase)229 require.Equal(t, attachmentEffectsStable, laterPhase)
@@ -209,7 +241,7 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
209 killDone := make(chan error, 1)241 killDone := make(chan error, 1)
210 go func() { killDone <- d.killSession(sess, ports.ReasonSessionKilled, false) }()242 go func() { killDone <- d.killSession(sess, ports.ReasonSessionKilled, false) }()
211 243
212- require.Same(t, first, awaitTestValue(t, frozenByKill, "teardown did not acquire the first role gate"))244+ require.Same(t, first, awaitTestValue(t, frozenByKill, "teardown did not acquire the first attachment effect gate"))
213 deadline := awaitTestValue(t, clock.timers, "teardown did not arm the gate acquisition deadline")245 deadline := awaitTestValue(t, clock.timers, "teardown did not arm the gate acquisition deadline")
214 require.Equal(t, detachNotifyTimeout, deadline.duration)246 require.Equal(t, detachNotifyTimeout, deadline.duration)
215 deadline.ch <- time.Now()247 deadline.ch <- time.Now()
@@ -242,9 +274,9 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
242 require.False(t, blockedTransport.Closed())274 require.False(t, blockedTransport.Closed())
243 require.False(t, laterTransport.Closed())275 require.False(t, laterTransport.Closed())
244 276
245- firstPhase, gotFirstCapability := roleGatePublicationSnapshot(first)277+ firstPhase, gotFirstCapability := attachmentEffectGatePublicationSnapshot(first)
246- blockedPhase, gotBlockedCapability := roleGatePublicationSnapshot(blocked)278+ blockedPhase, gotBlockedCapability := attachmentEffectGatePublicationSnapshot(blocked)
247- laterPhase, gotLaterCapability := roleGatePublicationSnapshot(later)279+ laterPhase, gotLaterCapability := attachmentEffectGatePublicationSnapshot(later)
248 require.Equal(t, attachmentEffectsStable, firstPhase, "partially acquired gate was not rolled back")280 require.Equal(t, attachmentEffectsStable, firstPhase, "partially acquired gate was not rolled back")
249 require.Equal(t, attachmentEffectsFrozen, blockedPhase, "teardown released a gate owned by another transition")281 require.Equal(t, attachmentEffectsFrozen, blockedPhase, "teardown released a gate owned by another transition")
250 require.Equal(t, attachmentEffectsStable, laterPhase, "unacquired later gate was frozen or invalidated")282 require.Equal(t, attachmentEffectsStable, laterPhase, "unacquired later gate was frozen or invalidated")
@@ -260,9 +292,9 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
260 complete.unfreeze()292 complete.unfreeze()
261 for _, token := range []attachmentConnectionToken{firstToken, blockedToken, laterToken} {293 for _, token := range []attachmentConnectionToken{firstToken, blockedToken, laterToken} {
262 ticket, admitted := token.ac.beginAttachmentEffect(token)294 ticket, admitted := token.ac.beginAttachmentEffect(token)
263- require.True(t, admitted, "pre-publication abort changed a role capability")295+ require.True(t, admitted, "pre-publication abort changed an attachment capability")
264 ticket.End()296 ticket.End()
265- requireRoleGateRetired(t, token.ac)297+ requireAttachmentEffectGateRetired(t, token.ac)
266 }298 }
267 }299 }
268 300
@@ -270,7 +302,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
270 d, sess, snatched, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})302 d, sess, snatched, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
271 snatchedTransport := newTeardownBlockingTransport()303 snatchedTransport := newTeardownBlockingTransport()
272 snatched.replaceTransport(snatchedTransport)304 snatched.replaceTransport(snatchedTransport)
273- d.attachCoordinator(sess, nil, snatched, true)305+ d.attachCoordinator(sess, snatched, true)
274 snatched.publishAttachmentCapability(sess.attachmentToken(snatched, snatchedTransport))306 snatched.publishAttachmentCapability(sess.attachmentToken(snatched, snatchedTransport))
275 307
276 activeTransport := newTeardownBlockingTransport()308 activeTransport := newTeardownBlockingTransport()
@@ -290,12 +322,12 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
290 d.mu.Lock()322 d.mu.Lock()
291 d.sessions[unrelated.id] = unrelated323 d.sessions[unrelated.id] = unrelated
292 d.mu.Unlock()324 d.mu.Unlock()
293- d.attachCoordinator(unrelated, nil, unrelatedClient, true)325+ d.attachCoordinator(unrelated, unrelatedClient, true)
294 326
295 snatchedToken := sess.attachmentToken(snatched, snatchedTransport)327 snatchedToken := sess.attachmentToken(snatched, snatchedTransport)
296 activeToken := sess.attachmentToken(active, activeTransport)328 activeToken := sess.attachmentToken(active, activeTransport)
297- snatchedDone := startBlockedRoleSend(t, snatchedToken)329+ snatchedDone := startBlockedAttachmentSend(t, snatchedToken)
298- activeDone := startBlockedRoleSend(t, activeToken)330+ activeDone := startBlockedAttachmentSend(t, activeToken)
299 awaitTestCompletion(t, snatchedTransport.sendEntered, "snatched send did not block")331 awaitTestCompletion(t, snatchedTransport.sendEntered, "snatched send did not block")
300 awaitTestCompletion(t, activeTransport.sendEntered, "active send did not block")332 awaitTestCompletion(t, activeTransport.sendEntered, "active send did not block")
301 333
@@ -309,8 +341,8 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
309 _ = activeTransport.Close()341 _ = activeTransport.Close()
310 t.Fatal("session kill remained pinned behind admitted participant sends")342 t.Fatal("session kill remained pinned behind admitted participant sends")
311 }343 }
312- awaitTestCompletion(t, snatchedDone, "snatched role ticket did not retire")344+ awaitTestCompletion(t, snatchedDone, "snatched attachment effect ticket did not retire")
313- awaitTestCompletion(t, activeDone, "active role ticket did not retire")345+ awaitTestCompletion(t, activeDone, "active attachment effect ticket did not retire")
314 d.waitNotifies()346 d.waitNotifies()
315 347
316 require.True(t, snatchedTransport.Closed())348 require.True(t, snatchedTransport.Closed())
@@ -318,19 +350,19 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
318 require.False(t, unrelatedTransport.Closed(), "unrelated session transport was interrupted")350 require.False(t, unrelatedTransport.Closed(), "unrelated session transport was interrupted")
319 require.Contains(t, unrelated.snapshotAttachments(), unrelatedClient)351 require.Contains(t, unrelated.snapshotAttachments(), unrelatedClient)
320 require.Same(t, unrelated, unrelatedClient.currentSession())352 require.Same(t, unrelated, unrelatedClient.currentSession())
321- requireRoleGateRetired(t, snatched)353+ requireAttachmentEffectGateRetired(t, snatched)
322- requireRoleGateRetired(t, active)354+ requireAttachmentEffectGateRetired(t, active)
323 }355 }
324 356
325 func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *testing.T) {357 func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *testing.T) {
326 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})358 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
327 stale := newTeardownBlockingTransport()359 stale := newTeardownBlockingTransport()
328 ac.replaceTransport(stale)360 ac.replaceTransport(stale)
329- d.attachCoordinator(sess, nil, ac, true)361+ d.attachCoordinator(sess, ac, true)
330 ac.publishAttachmentCapability(sess.attachmentToken(ac, stale))362 ac.publishAttachmentCapability(sess.attachmentToken(ac, stale))
331 363
332 token := sess.attachmentToken(ac, stale)364 token := sess.attachmentToken(ac, stale)
333- sendDone := startBlockedRoleSend(t, token)365+ sendDone := startBlockedAttachmentSend(t, token)
334 awaitTestCompletion(t, stale.sendEntered, "captured incarnation send did not block")366 awaitTestCompletion(t, stale.sendEntered, "captured incarnation send did not block")
335 fresh := &closeTrackingTransport{}367 fresh := &closeTrackingTransport{}
336 d.afterAttachmentEffectParticipantsSnapshotted = func(string, []*attachedClient) {368 d.afterAttachmentEffectParticipantsSnapshotted = func(string, []*attachedClient) {
@@ -338,7 +370,7 @@ func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *test
338 }370 }
339 371
340 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))372 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
341- awaitTestCompletion(t, sendDone, "captured incarnation role ticket did not retire")373+ awaitTestCompletion(t, sendDone, "captured incarnation attachment effect ticket did not retire")
342 require.True(t, stale.Closed(), "exact captured in-flight transport was not interrupted")374 require.True(t, stale.Closed(), "exact captured in-flight transport was not interrupted")
343 require.False(t, fresh.Closed(), "newer transport incarnation was interrupted")375 require.False(t, fresh.Closed(), "newer transport incarnation was interrupted")
344 require.Same(t, fresh, ac.transport())376 require.Same(t, fresh, ac.transport())
@@ -348,14 +380,14 @@ func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *test
348 sess.mu.Lock()380 sess.mu.Lock()
349 require.Contains(t, sess.snapshotAttachmentsLocked(), ac)381 require.Contains(t, sess.snapshotAttachmentsLocked(), ac)
350 sess.mu.Unlock()382 sess.mu.Unlock()
351- requireRoleGateRetired(t, ac)383+ requireAttachmentEffectGateRetired(t, ac)
352 }384 }
353 385
354 func TestShutdownSignalsServeWhenParticipantGateAcquisitionTimesOut(t *testing.T) {386 func TestShutdownSignalsServeWhenParticipantGateAcquisitionTimesOut(t *testing.T) {
355 d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})387 d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
356 transport := &closeTrackingTransport{}388 transport := &closeTrackingTransport{}
357 active.replaceTransport(transport)389 active.replaceTransport(transport)
358- d.attachCoordinator(sess, nil, active, true)390+ d.attachCoordinator(sess, active, true)
359 active.publishAttachmentCapability(sess.attachmentToken(active, transport))391 active.publishAttachmentCapability(sess.attachmentToken(active, transport))
360 392
361 owner := freezeAttachmentEffectGates(active)393 owner := freezeAttachmentEffectGates(active)
@@ -386,11 +418,11 @@ func TestShutdownInterruptsBlockedParticipantSend(t *testing.T) {
386 d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})418 d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
387 blocked := newTeardownBlockingTransport()419 blocked := newTeardownBlockingTransport()
388 active.replaceTransport(blocked)420 active.replaceTransport(blocked)
389- d.attachCoordinator(sess, nil, active, true)421+ d.attachCoordinator(sess, active, true)
390 active.publishAttachmentCapability(sess.attachmentToken(active, blocked))422 active.publishAttachmentCapability(sess.attachmentToken(active, blocked))
391 423
392 token := sess.attachmentToken(active, blocked)424 token := sess.attachmentToken(active, blocked)
393- sendDone := startBlockedRoleSend(t, token)425+ sendDone := startBlockedAttachmentSend(t, token)
394 awaitTestCompletion(t, blocked.sendEntered, "active send did not block")426 awaitTestCompletion(t, blocked.sendEntered, "active send did not block")
395 427
396 shutdownDone := make(chan bool, 1)428 shutdownDone := make(chan bool, 1)
@@ -402,17 +434,17 @@ func TestShutdownInterruptsBlockedParticipantSend(t *testing.T) {
402 _ = blocked.Close()434 _ = blocked.Close()
403 t.Fatal("shutdown remained pinned behind admitted participant send")435 t.Fatal("shutdown remained pinned behind admitted participant send")
404 }436 }
405- awaitTestCompletion(t, sendDone, "shutdown-interrupted role ticket did not retire")437+ awaitTestCompletion(t, sendDone, "shutdown-interrupted attachment effect ticket did not retire")
406 d.waitNotifies()438 d.waitNotifies()
407 require.True(t, blocked.Closed())439 require.True(t, blocked.Closed())
408- requireRoleGateRetired(t, active)440+ requireAttachmentEffectGateRetired(t, active)
409 }441 }
410 442
411-func TestRenderSendFailureCleanupIsDeadlineBoundedWhileRoleGateIsBusy(t *testing.T) {443+func TestRenderSendFailureCleanupIsDeadlineBoundedWhileAttachmentEffectGateIsBusy(t *testing.T) {
412 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})444 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
413 transport := &closeTrackingTransport{}445 transport := &closeTrackingTransport{}
414 ac.replaceTransport(transport)446 ac.replaceTransport(transport)
415- rc := d.attachCoordinator(sess, nil, ac, true)447+ rc := d.attachCoordinator(sess, ac, true)
416 token := sess.attachmentToken(ac, transport)448 token := sess.attachmentToken(ac, transport)
417 token.lease = rc.attachmentLease(ac)449 token.lease = rc.attachmentLease(ac)
418 ac.publishAttachmentCapability(token)450 ac.publishAttachmentCapability(token)
@@ -442,7 +474,7 @@ func TestAttachmentEffectGateReplacementInterruptsBlockedOldRenderBeforePublicat
442 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})474 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
443 oldTransport := newBlockedRenderReplacementTransport(d, sess)475 oldTransport := newBlockedRenderReplacementTransport(d, sess)
444 old.replaceTransport(oldTransport)476 old.replaceTransport(oldTransport)
445- rc := d.attachCoordinator(sess, nil, old, true)477+ rc := d.attachCoordinator(sess, old, true)
446 lease := rc.attachmentLease(old)478 lease := rc.attachmentLease(old)
447 token := sess.attachmentToken(old, oldTransport)479 token := sess.attachmentToken(old, oldTransport)
448 token.lease = lease480 token.lease = lease
@@ -497,7 +529,7 @@ func TestAttachmentEffectGateAdmittedEffectCompletesBeforeConflictingTransition(
497 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})529 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
498 oldTransport := newDatagramTestTransport()530 oldTransport := newDatagramTestTransport()
499 old.replaceTransport(oldTransport)531 old.replaceTransport(oldTransport)
500- rc := d.attachCoordinator(sess, nil, old, true)532+ rc := d.attachCoordinator(sess, old, true)
501 token := sess.attachmentToken(old, oldTransport)533 token := sess.attachmentToken(old, oldTransport)
502 token.lease = rc.attachmentLease(old)534 token.lease = rc.attachmentLease(old)
503 old.publishAttachmentCapability(token)535 old.publishAttachmentCapability(token)
@@ -598,7 +630,7 @@ func TestAttachmentEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *tes
598 oldTransport := newDatagramTestTransport()630 oldTransport := newDatagramTestTransport()
599 old.replaceTransport(oldTransport)631 old.replaceTransport(oldTransport)
600 old.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: old}, &d.bindings)632 old.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: old}, &d.bindings)
601- rc := d.attachCoordinator(sess, nil, old, true)633+ rc := d.attachCoordinator(sess, old, true)
602 token := sess.attachmentToken(old, oldTransport)634 token := sess.attachmentToken(old, oldTransport)
603 token.lease = rc.attachmentLease(old)635 token.lease = rc.attachmentLease(old)
604 if tt.setup != nil {636 if tt.setup != nil {
@@ -651,7 +683,7 @@ func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {
651 target.tabs[1].attentionAt = time.Unix(1, 0)683 target.tabs[1].attentionAt = time.Unix(1, 0)
652 d.sessions[target.id] = target684 d.sessions[target.id] = target
653 685
654- rc := d.attachCoordinator(source, nil, ac, true)686+ rc := d.attachCoordinator(source, ac, true)
655 token := source.attachmentToken(ac, ac.transport())687 token := source.attachmentToken(ac, ac.transport())
656 token.lease = rc.attachmentLease(ac)688 token.lease = rc.attachmentLease(ac)
657 ac.publishAttachmentCapability(token)689 ac.publishAttachmentCapability(token)
@@ -669,7 +701,7 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T)
669 d.enterPicker(sess, old)701 d.enterPicker(sess, old)
670 702
671 oldTransport := old.transport()703 oldTransport := old.transport()
672- rc := d.attachCoordinator(sess, nil, old, true)704+ rc := d.attachCoordinator(sess, old, true)
673 token := sess.attachmentToken(old, oldTransport)705 token := sess.attachmentToken(old, oldTransport)
674 token.lease = rc.attachmentLease(old)706 token.lease = rc.attachmentLease(old)
675 old.publishAttachmentCapability(token)707 old.publishAttachmentCapability(token)
@@ -713,7 +745,7 @@ func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) {
713 defer release()745 defer release()
714 d, sess, ac, _ := newManualSessionWithPTYs(t, p)746 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
715 d.enterPicker(sess, ac)747 d.enterPicker(sess, ac)
716- rc := d.attachCoordinator(sess, nil, ac, true)748+ rc := d.attachCoordinator(sess, ac, true)
717 token := sess.attachmentToken(ac, ac.transport())749 token := sess.attachmentToken(ac, ac.transport())
718 token.lease = rc.attachmentLease(ac)750 token.lease = rc.attachmentLease(ac)
719 ac.publishAttachmentCapability(token)751 ac.publishAttachmentCapability(token)
@@ -730,7 +762,7 @@ func TestAttachmentEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *test
730 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})762 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
731 oldTransport := newDatagramTestTransport()763 oldTransport := newDatagramTestTransport()
732 old.replaceTransport(oldTransport)764 old.replaceTransport(oldTransport)
733- rc := d.attachCoordinator(sess, nil, old, true)765+ rc := d.attachCoordinator(sess, old, true)
734 token := sess.attachmentToken(old, oldTransport)766 token := sess.attachmentToken(old, oldTransport)
735 token.lease = rc.attachmentLease(old)767 token.lease = rc.attachmentLease(old)
736 768
@@ -764,7 +796,7 @@ func TestAttachmentEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testi
764 d, first, a, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})796 d, first, a, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
765 aTransport := newDatagramTestTransport()797 aTransport := newDatagramTestTransport()
766 a.replaceTransport(aTransport)798 a.replaceTransport(aTransport)
767- d.attachCoordinator(first, nil, a, true)799+ d.attachCoordinator(first, a, true)
768 first.attachmentToken(a, aTransport)800 first.attachmentToken(a, aTransport)
769 801
770 bTransport := newDatagramTestTransport()802 bTransport := newDatagramTestTransport()
@@ -775,7 +807,7 @@ func TestAttachmentEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testi
775 d.mu.Lock()807 d.mu.Lock()
776 d.sessions[second.id] = second808 d.sessions[second.id] = second
777 d.mu.Unlock()809 d.mu.Unlock()
778- d.attachCoordinator(second, nil, b, true)810+ d.attachCoordinator(second, b, true)
779 second.attachmentToken(b, bTransport)811 second.attachmentToken(b, bTransport)
780 812
781 start := make(chan struct{})813 start := make(chan struct{})
@@ -822,7 +854,7 @@ func TestAttachmentEffectGateFrozenConnectionRejectsLateEffect(t *testing.T) {
822 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})854 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
823 transport := newDatagramTestTransport()855 transport := newDatagramTestTransport()
824 ac.replaceTransport(transport)856 ac.replaceTransport(transport)
825- rc := d.attachCoordinator(sess, nil, ac, true)857+ rc := d.attachCoordinator(sess, ac, true)
826 token := sess.attachmentToken(ac, transport)858 token := sess.attachmentToken(ac, transport)
827 token.lease = rc.attachmentLease(ac)859 token.lease = rc.attachmentLease(ac)
828 ac.publishAttachmentCapability(token)860 ac.publishAttachmentCapability(token)
M internal/usecase/daemon/attachment_registry.go +3 -44
@@ -22,13 +22,6 @@ type attachmentView struct {
2222 revision uint64
2323 }
2424
25-// attachmentViewSnapshot is the immutable value returned to callers that need
26-// to inspect attachment-local state without retaining its mutex.
27-type attachmentViewSnapshot struct {
28- attachment *attachedClient
29- view attachmentView
30-}
31-
3225 // viewInvalidation records the order in which attachment views observed one
3326 // shared session mutation. The order is stable client-ID order, not map
3427 // iteration or lock-acquisition order, which keeps concurrent attaches
@@ -168,25 +161,6 @@ func (c *sessionCore) snapshotAttachmentsLocked() []*attachedClient {
168161 return out
169162 }
170163
171-func (s *session) snapshotAttachmentsLocked() []*attachedClient {
172- if s == nil || len(s.attachments) == 0 {
173- return nil
174- }
175- out := make([]*attachedClient, 0, len(s.attachments))
176- for ac := range s.attachments {
177- out = append(out, ac)
178- }
179- slices.SortStableFunc(out, func(a, b *attachedClient) int {
180- // ClientID is the stable wire identity, so concurrent registration order
181- // cannot affect snapshots or invalidation order.
182- if id := bytes.Compare(a.clientID[:], b.clientID[:]); id != 0 {
183- return id
184- }
185- return cmp.Compare(s.attachmentOrder[a], s.attachmentOrder[b])
186- })
187- return out
188-}
189-
190164 func attachmentRegisteredLocked(entry attachmentSession, ac *attachedClient) bool {
191165 if entry == nil || ac == nil || entry.core() == nil {
192166 return false
@@ -244,18 +218,6 @@ func (s *session) snapshotAttachments() []*attachedClient {
244218 return s.snapshotAttachmentsLocked()
245219 }
246220
247-func (s *session) snapshotAttachmentViews() []attachmentViewSnapshot {
248- attachments := s.snapshotAttachments()
249- if len(attachments) == 0 {
250- return nil
251- }
252- views := make([]attachmentViewSnapshot, 0, len(attachments))
253- for _, ac := range attachments {
254- views = append(views, attachmentViewSnapshot{attachment: ac, view: ac.viewSnapshot()})
255- }
256- return views
257-}
258-
259221 // repairAttachmentViewLocked validates stable targets and chooses the nearest
260222 // surviving target when a tab or pane was removed. Caller holds s.mu.
261223 func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachmentView) attachmentView {
@@ -302,12 +264,10 @@ func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachment
302264 } else {
303265 view.paneID = ""
304266 }
305- targetTab.mu.Unlock()
306267 if view.windowRows <= 0 {
307- targetTab.mu.Lock()
308268 view.windowRows = targetTab.size.Rows
309- targetTab.mu.Unlock()
310269 }
270+ targetTab.mu.Unlock()
311271 if view.windowTop < 0 {
312272 view.windowTop = 0
313273 }
@@ -402,7 +362,6 @@ func (s *session) tabForAttachment(ac *attachedClient) *tab {
402362 if s == nil || ac == nil {
403363 return nil
404364 }
405- s.repairAttachmentView(ac)
406365 s.mu.Lock()
407366 defer s.mu.Unlock()
408367 if _, ok := s.attachments[ac]; !ok {
@@ -618,8 +577,8 @@ func (s *session) repairAttachmentViews() []viewInvalidation {
618577 }
619578
620579 // repairAttachmentViewsLocked repairs all live attachment targets after a tab
621-// or pane mutation. It is intended to run at the same mutation linearization
622-// point as the shared membership change.
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.
623582 func (s *session) repairAttachmentViewsLocked() []viewInvalidation {
624583 if s == nil {
625584 return nil
@@ -22,13 +22,6 @@ type attachmentView struct {
22 revision uint6422 revision uint64
23 }23 }
24 24
25-// attachmentViewSnapshot is the immutable value returned to callers that need
26-// to inspect attachment-local state without retaining its mutex.
27-type attachmentViewSnapshot struct {
28- attachment *attachedClient
29- view attachmentView
30-}
31-
32 // viewInvalidation records the order in which attachment views observed one25 // viewInvalidation records the order in which attachment views observed one
33 // shared session mutation. The order is stable client-ID order, not map26 // shared session mutation. The order is stable client-ID order, not map
34 // iteration or lock-acquisition order, which keeps concurrent attaches27 // iteration or lock-acquisition order, which keeps concurrent attaches
@@ -168,25 +161,6 @@ func (c *sessionCore) snapshotAttachmentsLocked() []*attachedClient {
168 return out161 return out
169 }162 }
170 163
171-func (s *session) snapshotAttachmentsLocked() []*attachedClient {
172- if s == nil || len(s.attachments) == 0 {
173- return nil
174- }
175- out := make([]*attachedClient, 0, len(s.attachments))
176- for ac := range s.attachments {
177- out = append(out, ac)
178- }
179- slices.SortStableFunc(out, func(a, b *attachedClient) int {
180- // ClientID is the stable wire identity, so concurrent registration order
181- // cannot affect snapshots or invalidation order.
182- if id := bytes.Compare(a.clientID[:], b.clientID[:]); id != 0 {
183- return id
184- }
185- return cmp.Compare(s.attachmentOrder[a], s.attachmentOrder[b])
186- })
187- return out
188-}
189-
190 func attachmentRegisteredLocked(entry attachmentSession, ac *attachedClient) bool {164 func attachmentRegisteredLocked(entry attachmentSession, ac *attachedClient) bool {
191 if entry == nil || ac == nil || entry.core() == nil {165 if entry == nil || ac == nil || entry.core() == nil {
192 return false166 return false
@@ -244,18 +218,6 @@ func (s *session) snapshotAttachments() []*attachedClient {
244 return s.snapshotAttachmentsLocked()218 return s.snapshotAttachmentsLocked()
245 }219 }
246 220
247-func (s *session) snapshotAttachmentViews() []attachmentViewSnapshot {
248- attachments := s.snapshotAttachments()
249- if len(attachments) == 0 {
250- return nil
251- }
252- views := make([]attachmentViewSnapshot, 0, len(attachments))
253- for _, ac := range attachments {
254- views = append(views, attachmentViewSnapshot{attachment: ac, view: ac.viewSnapshot()})
255- }
256- return views
257-}
258-
259 // repairAttachmentViewLocked validates stable targets and chooses the nearest221 // repairAttachmentViewLocked validates stable targets and chooses the nearest
260 // surviving target when a tab or pane was removed. Caller holds s.mu.222 // surviving target when a tab or pane was removed. Caller holds s.mu.
261 func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachmentView) attachmentView {223 func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachmentView) attachmentView {
@@ -302,12 +264,10 @@ func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachment
302 } else {264 } else {
303 view.paneID = ""265 view.paneID = ""
304 }266 }
305- targetTab.mu.Unlock()
306 if view.windowRows <= 0 {267 if view.windowRows <= 0 {
307- targetTab.mu.Lock()
308 view.windowRows = targetTab.size.Rows268 view.windowRows = targetTab.size.Rows
309- targetTab.mu.Unlock()
310 }269 }
270+ targetTab.mu.Unlock()
311 if view.windowTop < 0 {271 if view.windowTop < 0 {
312 view.windowTop = 0272 view.windowTop = 0
313 }273 }
@@ -402,7 +362,6 @@ func (s *session) tabForAttachment(ac *attachedClient) *tab {
402 if s == nil || ac == nil {362 if s == nil || ac == nil {
403 return nil363 return nil
404 }364 }
405- s.repairAttachmentView(ac)
406 s.mu.Lock()365 s.mu.Lock()
407 defer s.mu.Unlock()366 defer s.mu.Unlock()
408 if _, ok := s.attachments[ac]; !ok {367 if _, ok := s.attachments[ac]; !ok {
@@ -618,8 +577,8 @@ func (s *session) repairAttachmentViews() []viewInvalidation {
618 }577 }
619 578
620 // repairAttachmentViewsLocked repairs all live attachment targets after a tab579 // repairAttachmentViewsLocked repairs all live attachment targets after a tab
621-// or pane mutation. It is intended to run at the same mutation linearization580+// or pane mutation. Caller holds dispatchMu and s.mu; it is intended to run at
622-// point as the shared membership change.581+// the same mutation linearization point as the shared membership change.
623 func (s *session) repairAttachmentViewsLocked() []viewInvalidation {582 func (s *session) repairAttachmentViewsLocked() []viewInvalidation {
624 if s == nil {583 if s == nil {
625 return nil584 return nil
M internal/usecase/daemon/attachment_registry_test.go +1 -7
@@ -123,16 +123,10 @@ func TestAttachmentRegistryConcurrentRegistrationSnapshotIsStable(t *testing.T)
123123 t.Fatalf("snapshot[%d] id = %d, want %d", i, got[i].clientID[0], want)
124124 }
125125 }
126- invalidations := sess.repairAttachmentViewsLockedForTest()
126+ invalidations := sess.repairAttachmentViews()
127127 for i, want := range []byte{1, 2, 3} {
128128 if invalidations[i].attachment.clientID[0] != want {
129129 t.Fatalf("invalidation[%d] id = %d, want %d", i, invalidations[i].attachment.clientID[0], want)
130130 }
131131 }
132132 }
133-
134-func (s *session) repairAttachmentViewsLockedForTest() []viewInvalidation {
135- s.mu.Lock()
136- defer s.mu.Unlock()
137- return s.repairAttachmentViewsLocked()
138-}
@@ -123,16 +123,10 @@ func TestAttachmentRegistryConcurrentRegistrationSnapshotIsStable(t *testing.T)
123 t.Fatalf("snapshot[%d] id = %d, want %d", i, got[i].clientID[0], want)123 t.Fatalf("snapshot[%d] id = %d, want %d", i, got[i].clientID[0], want)
124 }124 }
125 }125 }
126- invalidations := sess.repairAttachmentViewsLockedForTest()126+ invalidations := sess.repairAttachmentViews()
127 for i, want := range []byte{1, 2, 3} {127 for i, want := range []byte{1, 2, 3} {
128 if invalidations[i].attachment.clientID[0] != want {128 if invalidations[i].attachment.clientID[0] != want {
129 t.Fatalf("invalidation[%d] id = %d, want %d", i, invalidations[i].attachment.clientID[0], want)129 t.Fatalf("invalidation[%d] id = %d, want %d", i, invalidations[i].attachment.clientID[0], want)
130 }130 }
131 }131 }
132 }132 }
133-
134-func (s *session) repairAttachmentViewsLockedForTest() []viewInvalidation {
135- s.mu.Lock()
136- defer s.mu.Unlock()
137- return s.repairAttachmentViewsLocked()
138-}
M internal/usecase/daemon/attachment_transition.go +4 -5
@@ -111,7 +111,7 @@ func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (
111111
112112 // freezeAttachmentTransition drains the snapshotted connection without any
113113 // architecture lock held. The returned guard remains frozen through publication.
114-func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, participants attachmentTransitionParticipants) (frozenAttachmentEffectGates, bool, error) {
114+func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, participants attachmentTransitionParticipants) (frozenAttachmentEffectGates, error) {
115115 if d.afterAttachmentEffectParticipantsSnapshotted != nil {
116116 d.afterAttachmentEffectParticipantsSnapshotted(req.action, participants.clients)
117117 }
@@ -121,13 +121,12 @@ func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, par
121121 }
122122 }}, participants.clients...)
123123 if !frozen.acquired || !frozen.drained {
124- return frozen, false, errAttachmentTransition
124+ return frozen, errAttachmentTransition
125125 }
126- interrupted := frozen.interrupted(nil, transportSnapshot{})
127126 if d.afterAttachmentEffectsFrozen != nil {
128127 d.afterAttachmentEffectsFrozen()
129128 }
130- return frozen, interrupted, nil
129+ return frozen, nil
131130 }
132131
133132 // publishAttachmentTransition revalidates the frozen source, creates an
@@ -159,7 +158,7 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
159158 d.endActionAttachmentEffect(req.sourceToken.effect, req.action)
160159 }
161160
162- frozen, _, err := d.freezeAttachmentTransition(req, participants)
161+ frozen, err := d.freezeAttachmentTransition(req, participants)
163162 defer frozen.unfreeze()
164163 if err != nil {
165164 return attachmentTransitionResult{}, err
@@ -111,7 +111,7 @@ func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (
111 111
112 // freezeAttachmentTransition drains the snapshotted connection without any112 // freezeAttachmentTransition drains the snapshotted connection without any
113 // architecture lock held. The returned guard remains frozen through publication.113 // architecture lock held. The returned guard remains frozen through publication.
114-func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, participants attachmentTransitionParticipants) (frozenAttachmentEffectGates, bool, error) {114+func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, participants attachmentTransitionParticipants) (frozenAttachmentEffectGates, error) {
115 if d.afterAttachmentEffectParticipantsSnapshotted != nil {115 if d.afterAttachmentEffectParticipantsSnapshotted != nil {
116 d.afterAttachmentEffectParticipantsSnapshotted(req.action, participants.clients)116 d.afterAttachmentEffectParticipantsSnapshotted(req.action, participants.clients)
117 }117 }
@@ -121,13 +121,12 @@ func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, par
121 }121 }
122 }}, participants.clients...)122 }}, participants.clients...)
123 if !frozen.acquired || !frozen.drained {123 if !frozen.acquired || !frozen.drained {
124- return frozen, false, errAttachmentTransition124+ return frozen, errAttachmentTransition
125 }125 }
126- interrupted := frozen.interrupted(nil, transportSnapshot{})
127 if d.afterAttachmentEffectsFrozen != nil {126 if d.afterAttachmentEffectsFrozen != nil {
128 d.afterAttachmentEffectsFrozen()127 d.afterAttachmentEffectsFrozen()
129 }128 }
130- return frozen, interrupted, nil129+ return frozen, nil
131 }130 }
132 131
133 // publishAttachmentTransition revalidates the frozen source, creates an132 // publishAttachmentTransition revalidates the frozen source, creates an
@@ -159,7 +158,7 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
159 d.endActionAttachmentEffect(req.sourceToken.effect, req.action)158 d.endActionAttachmentEffect(req.sourceToken.effect, req.action)
160 }159 }
161 160
162- frozen, _, err := d.freezeAttachmentTransition(req, participants)161+ frozen, err := d.freezeAttachmentTransition(req, participants)
163 defer frozen.unfreeze()162 defer frozen.unfreeze()
164 if err != nil {163 if err != nil {
165 return attachmentTransitionResult{}, err164 return attachmentTransitionResult{}, err
M internal/usecase/daemon/client.go +23 -20
@@ -43,7 +43,7 @@ type attachedClient struct {
4343 overlayOnce sync.Once
4444 clientID [16]byte
4545 // connectionGeneration is the wire-facing capability generation. attachmentEffects is
46- // the linearization gate for every attachment-bound observable operation. A role
46+ // the linearization gate for every attachment-bound observable operation. An attachment
4747 // transition freezes and drains this gate before changing either generation
4848 // or the session/coordinator registries.
4949 connectionGeneration atomic.Uint64
@@ -583,13 +583,13 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size,
583583 // attachCoordinator installs the coordinator lease for an attachment. Session
584584 // membership remains independent, so installing one attachment never displaces
585585 // another.
586-func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient, ready bool) *renderCoordinator {
587- rc, cleanup := d.attachCoordinatorDeferred(sess, old, current, ready)
586+func (d *Daemon) attachCoordinator(sess *session, current *attachedClient, ready bool) *renderCoordinator {
587+ rc, cleanup := d.attachCoordinatorDeferred(sess, current, ready)
588588 cleanup.finish()
589589 return rc
590590 }
591591
592-func (d *Daemon) attachCoordinatorDeferred(sess *session, _ *attachedClient, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) {
592+func (d *Daemon) attachCoordinatorDeferred(sess *session, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) {
593593 rc := d.ensureRenderCoordinator(sess)
594594 if current != nil {
595595 rc.attachWithReadiness(current, ready)
@@ -631,7 +631,7 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess
631631 // attachment's membership and transport fence after that snapshot, so
632632 // a detached peer cannot stop remaining attachments receiving output.
633633 attachments := snapshotAttachmentSession(entry)
634- for index, attachment := range attachments {
634+ for _, attachment := range attachments {
635635 if !rc.wakeCurrent(w) {
636636 return
637637 }
@@ -639,24 +639,27 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess
639639 if lease == nil || !rc.leaseCurrent(lease, true) {
640640 continue
641641 }
642- // Pane damage is session-shared; later attachments need a complete
643- // frame because the first capture consumes shared damage.
644- d.paint(entry, attachment, w.reset || index != 0, lease)
645- }
646- },
647- ackReady: func() bool {
648- attachments := snapshotAttachmentSession(entry)
649- for _, attached := range attachments {
650- attached.sendMu.Lock()
651- ready := attached.output == nil || !attached.output.atCapacity()
652- attached.sendMu.Unlock()
653- if !ready {
654- return false
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)
655653 }
656654 }
657- return true
658655 },
659656 })
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+ }
660663 installAttachmentRenderCoordinator(entry, rc)
661664 return rc
662665 }
@@ -756,7 +759,7 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi
756759 if lease == nil {
757760 d.invalidateRenderNow(sess, ac, true, "client.go")
758761 } else if rc := sess.renderCoordinator(); rc != nil && rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"}) {
759- rc.fireCurrent(false)
762+ rc.fireCurrentForLease(lease)
760763 }
761764 }
762765 }
@@ -43,7 +43,7 @@ type attachedClient struct {
43 overlayOnce sync.Once43 overlayOnce sync.Once
44 clientID [16]byte44 clientID [16]byte
45 // connectionGeneration is the wire-facing capability generation. attachmentEffects is45 // connectionGeneration is the wire-facing capability generation. attachmentEffects is
46- // the linearization gate for every attachment-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 connectionGeneration atomic.Uint6449 connectionGeneration atomic.Uint64
@@ -583,13 +583,13 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size,
583 // attachCoordinator installs the coordinator lease for an attachment. Session583 // attachCoordinator installs the coordinator lease for an attachment. Session
584 // membership remains independent, so installing one attachment never displaces584 // membership remains independent, so installing one attachment never displaces
585 // another.585 // another.
586-func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient, ready bool) *renderCoordinator {586+func (d *Daemon) attachCoordinator(sess *session, current *attachedClient, ready bool) *renderCoordinator {
587- rc, cleanup := d.attachCoordinatorDeferred(sess, old, current, ready)587+ rc, cleanup := d.attachCoordinatorDeferred(sess, current, ready)
588 cleanup.finish()588 cleanup.finish()
589 return rc589 return rc
590 }590 }
591 591
592-func (d *Daemon) attachCoordinatorDeferred(sess *session, _ *attachedClient, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) {592+func (d *Daemon) attachCoordinatorDeferred(sess *session, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) {
593 rc := d.ensureRenderCoordinator(sess)593 rc := d.ensureRenderCoordinator(sess)
594 if current != nil {594 if current != nil {
595 rc.attachWithReadiness(current, ready)595 rc.attachWithReadiness(current, ready)
@@ -631,7 +631,7 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess
631 // attachment's membership and transport fence after that snapshot, so631 // attachment's membership and transport fence after that snapshot, so
632 // a detached peer cannot stop remaining attachments receiving output.632 // a detached peer cannot stop remaining attachments receiving output.
633 attachments := snapshotAttachmentSession(entry)633 attachments := snapshotAttachmentSession(entry)
634- for index, attachment := range attachments {634+ for _, attachment := range attachments {
635 if !rc.wakeCurrent(w) {635 if !rc.wakeCurrent(w) {
636 return636 return
637 }637 }
@@ -639,24 +639,27 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess
639 if lease == nil || !rc.leaseCurrent(lease, true) {639 if lease == nil || !rc.leaseCurrent(lease, true) {
640 continue640 continue
641 }641 }
642- // Pane damage is session-shared; later attachments need a complete642+ // Pane damage is session-shared; every fan-out attachment needs a
643- // frame because the first capture consumes shared damage.643+ // complete frame because any peer may capture it first. A handshake
644- d.paint(entry, attachment, w.reset || index != 0, lease)644+ // target is the one synchronous exception: its first paint must finish
645- }645+ // before the handshake releases its lifecycle gate.
646- },646+ fanoutReset := w.reset || len(attachments) > 1
647- ackReady: func() bool {647+ if w.lease != nil && w.lease.attachment == attachment {
648- attachments := snapshotAttachmentSession(entry)648+ d.paint(entry, attachment, fanoutReset, lease)
649- for _, attached := range attachments {649+ } else if len(attachments) > 1 {
650- attached.sendMu.Lock()650+ go d.paint(entry, attachment, fanoutReset, lease)
651- ready := attached.output == nil || !attached.output.atCapacity()651+ } else {
652- attached.sendMu.Unlock()652+ d.paint(entry, attachment, fanoutReset, lease)
653- if !ready {
654- return false
655 }653 }
656 }654 }
657- return true
658 },655 },
659 })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+ }
660 installAttachmentRenderCoordinator(entry, rc)663 installAttachmentRenderCoordinator(entry, rc)
661 return rc664 return rc
662 }665 }
@@ -756,7 +759,7 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi
756 if lease == nil {759 if lease == nil {
757 d.invalidateRenderNow(sess, ac, true, "client.go")760 d.invalidateRenderNow(sess, ac, true, "client.go")
758 } 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"}) {
759- rc.fireCurrent(false)762+ rc.fireCurrentForLease(lease)
760 }763 }
761 }764 }
762 }765 }
M internal/usecase/daemon/client_frame_routing.go +2 -4
@@ -218,10 +218,8 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
218218
219219 // The frame's attachment token is the target capability. No registry/name lookup is
220220 // performed, so the command cannot escape to another remote session.
221- var err error
222- _ = sess.runMutation(func() error {
223- err = cmd.Run(paletteExec{d: d, sess: sess, ac: token.ac, effect: token.effect}, request.Args)
224- return nil
221+ err := sess.runMutation(func() error {
222+ return cmd.Run(paletteExec{d: d, sess: sess, ac: token.ac, effect: token.effect}, request.Args)
225223 })
226224 if err == nil {
227225 result.OK = true
@@ -218,10 +218,8 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
218 218
219 // The frame's attachment token is the target capability. No registry/name lookup is219 // The frame's attachment token is the target capability. No registry/name lookup is
220 // performed, so the command cannot escape to another remote session.220 // performed, so the command cannot escape to another remote session.
221- var err error221+ err := sess.runMutation(func() error {
222- _ = sess.runMutation(func() error {222+ return cmd.Run(paletteExec{d: d, sess: sess, ac: token.ac, effect: token.effect}, request.Args)
223- err = cmd.Run(paletteExec{d: d, sess: sess, ac: token.ac, effect: token.effect}, request.Args)
224- return nil
225 })223 })
226 if err == nil {224 if err == nil {
227 result.OK = true225 result.OK = true
M internal/usecase/daemon/clipboard_test.go +1 -1
@@ -203,7 +203,7 @@ func chunkReadPTY(t *testing.T, chunks ...[]byte) *portsmocks.MockPTY {
203203 }
204204
205205 func publishActiveClipboardCapability(d *Daemon, sess *session, ac *attachedClient, tr ports.Transport) {
206- rc := d.attachCoordinator(sess, nil, ac, true)
206+ rc := d.attachCoordinator(sess, ac, true)
207207 token := sess.attachmentToken(ac, tr)
208208 token.lease = rc.attachmentLease(ac)
209209 ac.publishAttachmentCapability(token)
@@ -203,7 +203,7 @@ 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.publishAttachmentCapability(token)209 ac.publishAttachmentCapability(token)
M internal/usecase/daemon/control.go +27 -11
@@ -161,10 +161,7 @@ func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, u
161161 if len(locals) != 1 {
162162 return nil, ports.ErrAmbiguousTarget, "several sessions are live; use -s <session> or run from inside a pane"
163163 }
164- if len(locals) != 0 {
165- return locals[0], 0, ""
166- }
167- return nil, ports.ErrNoSuchTarget, "no live sessions"
164+ return locals[0], 0, ""
168165 }
169166
170167 func (s *session) containsStableIDs(tabID, paneID string) bool {
@@ -257,6 +254,7 @@ type daemonActionTarget struct {
257254 type daemonActionRequest struct {
258255 kind daemonActionKind
259256 target daemonActionTarget
257+ effect *attachmentEffectTicket
260258 direction layout.Direction
261259 axis layout.Axis
262260 delta int
@@ -300,7 +298,7 @@ func (a daemonActions) Run(request daemonActionRequest) error {
300298 case daemonActionCreateTab:
301299 return a.d.createTabForAttachment(target.session, target.attachment, request.viewport)
302300 case daemonActionCloseTab:
303- return a.d.closeTab(target.session, target.tab, true)
301+ return a.d.closeTabLockedWithEffect(target.session, target.tab, true, request.effect)
304302 case daemonActionSplitPane:
305303 return a.d.splitPaneAt(target.session, target.tab, target.pane, request.direction)
306304 case daemonActionStackPane:
@@ -314,7 +312,7 @@ func (a daemonActions) Run(request daemonActionRequest) error {
314312 if !a.d.hasDaemonActionPaneTarget(target) {
315313 return layout.ErrNotFound
316314 }
317- 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 {
318316 return err
319317 }
320318 if a.d.hasDaemonActionPaneTarget(target) {
@@ -454,8 +452,17 @@ func (e controlExec) MovePane(destinationSession, destinationTabID string) error
454452 if e.sess == nil || e.tab == nil || e.target.pane == nil {
455453 return errMovePaneInvalid
456454 }
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+ }
457463 return e.d.movePane(movePaneRequest{
458- Attachment: e.target.attachment,
464+ Attachment: attachment,
465+ AttachmentToken: token,
459466 Source: sessionMoveLocator(e.sess),
460467 SourceTabID: domain.TabStableID(e.tab.stableID),
461468 SourcePaneID: domain.PaneStableID(e.target.pane.stableID),
@@ -472,11 +479,20 @@ func (e controlExec) MoveTab(destinationSession string) error {
472479 if e.sess == nil || e.tab == nil {
473480 return errMovePaneInvalid
474481 }
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+ }
475490 return e.d.moveTab(moveTabRequest{
476- Attachment: e.target.attachment,
477- Source: sessionMoveLocator(e.sess),
478- SourceTabID: domain.TabStableID(e.tab.stableID),
479- Destination: sessionMoveLocator(destination),
491+ Attachment: attachment,
492+ AttachmentToken: token,
493+ Source: sessionMoveLocator(e.sess),
494+ SourceTabID: domain.TabStableID(e.tab.stableID),
495+ Destination: sessionMoveLocator(destination),
480496 })
481497 }
482498
@@ -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- if len(locals) != 0 {164+ return locals[0], 0, ""
165- return locals[0], 0, ""
166- }
167- return nil, ports.ErrNoSuchTarget, "no live sessions"
168 }165 }
169 166
170 func (s *session) containsStableIDs(tabID, paneID string) bool {167 func (s *session) containsStableIDs(tabID, paneID string) bool {
@@ -257,6 +254,7 @@ type daemonActionTarget struct {
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
@@ -300,7 +298,7 @@ func (a daemonActions) Run(request daemonActionRequest) error {
300 case daemonActionCreateTab:298 case daemonActionCreateTab:
301 return a.d.createTabForAttachment(target.session, target.attachment, request.viewport)299 return a.d.createTabForAttachment(target.session, target.attachment, request.viewport)
302 case daemonActionCloseTab:300 case daemonActionCloseTab:
303- return a.d.closeTab(target.session, target.tab, true)301+ return a.d.closeTabLockedWithEffect(target.session, target.tab, true, request.effect)
304 case daemonActionSplitPane:302 case daemonActionSplitPane:
305 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)
306 case daemonActionStackPane:304 case daemonActionStackPane:
@@ -314,7 +312,7 @@ func (a daemonActions) Run(request daemonActionRequest) error {
314 if !a.d.hasDaemonActionPaneTarget(target) {312 if !a.d.hasDaemonActionPaneTarget(target) {
315 return layout.ErrNotFound313 return layout.ErrNotFound
316 }314 }
317- 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 {
318 return err316 return err
319 }317 }
320 if a.d.hasDaemonActionPaneTarget(target) {318 if a.d.hasDaemonActionPaneTarget(target) {
@@ -454,8 +452,17 @@ func (e controlExec) MovePane(destinationSession, destinationTabID string) error
454 if e.sess == nil || e.tab == nil || e.target.pane == nil {452 if e.sess == nil || e.tab == nil || e.target.pane == nil {
455 return errMovePaneInvalid453 return errMovePaneInvalid
456 }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+ }
457 return e.d.movePane(movePaneRequest{463 return e.d.movePane(movePaneRequest{
458- Attachment: e.target.attachment,464+ Attachment: attachment,
465+ AttachmentToken: token,
459 Source: sessionMoveLocator(e.sess),466 Source: sessionMoveLocator(e.sess),
460 SourceTabID: domain.TabStableID(e.tab.stableID),467 SourceTabID: domain.TabStableID(e.tab.stableID),
461 SourcePaneID: domain.PaneStableID(e.target.pane.stableID),468 SourcePaneID: domain.PaneStableID(e.target.pane.stableID),
@@ -472,11 +479,20 @@ func (e controlExec) MoveTab(destinationSession string) error {
472 if e.sess == nil || e.tab == nil {479 if e.sess == nil || e.tab == nil {
473 return errMovePaneInvalid480 return errMovePaneInvalid
474 }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+ }
475 return e.d.moveTab(moveTabRequest{490 return e.d.moveTab(moveTabRequest{
476- Attachment: e.target.attachment,491+ Attachment: attachment,
477- Source: sessionMoveLocator(e.sess),492+ AttachmentToken: token,
478- SourceTabID: domain.TabStableID(e.tab.stableID),493+ Source: sessionMoveLocator(e.sess),
479- Destination: sessionMoveLocator(destination),494+ SourceTabID: domain.TabStableID(e.tab.stableID),
495+ Destination: sessionMoveLocator(destination),
480 })496 })
481 }497 }
482 498
M internal/usecase/daemon/control_test.go +26 -4
@@ -814,6 +814,27 @@ func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
814814 require.NotEqual(t, original, target.tree.Focus, "second command must execute after the split creates its right neighbor")
815815 }
816816
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+
817838 func TestHandleCommandMovePaneUsesActiveFocusedSourceWithSessionFlag(t *testing.T) {
818839 d := newTestDaemon(t, nil, stubClock{})
819840 source := addControlSession(d, "work", "t_active", "p_active")
@@ -889,28 +910,29 @@ func TestHandleCommandMovePaneSelfUsesStableSourceIDs(t *testing.T) {
889910 func TestHandleCommandMoveTabUsesActiveTabWithoutSelf(t *testing.T) {
890911 d := newTestDaemon(t, nil, stubClock{})
891912 source := addControlSession(d, "work", "t_first", "p_first")
913+ publishTiledPaneOwners(source, source.tabs[0])
892914 second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
893915 second.ctx, second.cancel = context.WithCancel(d.serveCtx)
894916 publishTiledPaneOwners(source, second)
895917 source.mu.Lock()
896918 source.tabs = append(source.tabs, second)
897- selectTestAttachmentTabLocked(source, 1)
919+ selectTestAttachmentTabLocked(source, 0)
898920 source.mu.Unlock()
899921 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
900922
901923 result := sendCommand(t, d, ports.CommandRequest{
902- Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work", Self: true,
924+ Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work", Self: false,
903925 TargetTab: "t_second", TargetPane: "p_second",
904926 })
905927 require.True(t, result.OK, result.Text)
906928
907929 source.mu.Lock()
908930 require.Len(t, source.tabs, 1)
909- require.Equal(t, "t_first", source.tabs[0].stableID)
931+ require.Equal(t, "t_second", source.tabs[0].stableID)
910932 source.mu.Unlock()
911933 destination.mu.Lock()
912934 require.Len(t, destination.tabs, 2)
913- require.Equal(t, "t_second", destination.tabs[1].stableID)
935+ require.Equal(t, "t_first", destination.tabs[1].stableID)
914936 destination.mu.Unlock()
915937 }
916938
@@ -814,6 +814,27 @@ func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
814 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")
815 }815 }
816 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+
817 func TestHandleCommandMovePaneUsesActiveFocusedSourceWithSessionFlag(t *testing.T) {838 func TestHandleCommandMovePaneUsesActiveFocusedSourceWithSessionFlag(t *testing.T) {
818 d := newTestDaemon(t, nil, stubClock{})839 d := newTestDaemon(t, nil, stubClock{})
819 source := addControlSession(d, "work", "t_active", "p_active")840 source := addControlSession(d, "work", "t_active", "p_active")
@@ -889,28 +910,29 @@ func TestHandleCommandMovePaneSelfUsesStableSourceIDs(t *testing.T) {
889 func TestHandleCommandMoveTabUsesActiveTabWithoutSelf(t *testing.T) {910 func TestHandleCommandMoveTabUsesActiveTabWithoutSelf(t *testing.T) {
890 d := newTestDaemon(t, nil, stubClock{})911 d := newTestDaemon(t, nil, stubClock{})
891 source := addControlSession(d, "work", "t_first", "p_first")912 source := addControlSession(d, "work", "t_first", "p_first")
913+ publishTiledPaneOwners(source, source.tabs[0])
892 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})
893 second.ctx, second.cancel = context.WithCancel(d.serveCtx)915 second.ctx, second.cancel = context.WithCancel(d.serveCtx)
894 publishTiledPaneOwners(source, second)916 publishTiledPaneOwners(source, second)
895 source.mu.Lock()917 source.mu.Lock()
896 source.tabs = append(source.tabs, second)918 source.tabs = append(source.tabs, second)
897- selectTestAttachmentTabLocked(source, 1)919+ selectTestAttachmentTabLocked(source, 0)
898 source.mu.Unlock()920 source.mu.Unlock()
899 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")921 destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
900 922
901 result := sendCommand(t, d, ports.CommandRequest{923 result := sendCommand(t, d, ports.CommandRequest{
902- Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work", Self: true,924+ Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work", Self: false,
903 TargetTab: "t_second", TargetPane: "p_second",925 TargetTab: "t_second", TargetPane: "p_second",
904 })926 })
905 require.True(t, result.OK, result.Text)927 require.True(t, result.OK, result.Text)
906 928
907 source.mu.Lock()929 source.mu.Lock()
908 require.Len(t, source.tabs, 1)930 require.Len(t, source.tabs, 1)
909- require.Equal(t, "t_first", source.tabs[0].stableID)931+ require.Equal(t, "t_second", source.tabs[0].stableID)
910 source.mu.Unlock()932 source.mu.Unlock()
911 destination.mu.Lock()933 destination.mu.Lock()
912 require.Len(t, destination.tabs, 2)934 require.Len(t, destination.tabs, 2)
913- require.Equal(t, "t_second", destination.tabs[1].stableID)935+ require.Equal(t, "t_first", destination.tabs[1].stableID)
914 destination.mu.Unlock()936 destination.mu.Unlock()
915 }937 }
916 938
M internal/usecase/daemon/daemon.go +4 -2
@@ -163,11 +163,13 @@ type Daemon struct {
163163 // afterAttachmentTransitionCoordinatorsLocked is a deterministic lock-order
164164 // seam used by transition validation tests.
165165 afterAttachmentTransitionCoordinatorsLocked func()
166- // afterMoveLifecycleReserved and afterMovePaneSourceSnapshot are test-only
167- // seams. They run before role/fence work and after the pre-fence source
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
168169 // attachment snapshot respectively. beforeMovePaneCommit runs inside the
169170 // non-failing publication section. None is set in production, and none may
170171 // perform external work while locks are held.
172+ beforeMoveDispatch func()
171173 afterMoveLifecycleReserved func()
172174 afterMoveLifecycleGateBeforeTeardownLocks func()
173175 afterMovePaneSourceSnapshot func()
@@ -163,11 +163,13 @@ type Daemon struct {
163 // afterAttachmentTransitionCoordinatorsLocked is a deterministic lock-order163 // afterAttachmentTransitionCoordinatorsLocked is a deterministic lock-order
164 // seam used by transition validation tests.164 // seam used by transition validation tests.
165 afterAttachmentTransitionCoordinatorsLocked func()165 afterAttachmentTransitionCoordinatorsLocked func()
166- // afterMoveLifecycleReserved and afterMovePaneSourceSnapshot are test-only166+ // beforeMoveDispatch, afterMoveLifecycleReserved, and
167- // seams. They run before role/fence work and after the pre-fence source167+ // afterMovePaneSourceSnapshot are test-only seams. They run before dispatch
168+ // admission, after lifecycle reservation, and after the pre-fence source
168 // attachment snapshot respectively. beforeMovePaneCommit runs inside the169 // attachment snapshot respectively. beforeMovePaneCommit runs inside the
169 // non-failing publication section. None is set in production, and none may170 // non-failing publication section. None is set in production, and none may
170 // perform external work while locks are held.171 // perform external work while locks are held.
172+ beforeMoveDispatch func()
171 afterMoveLifecycleReserved func()173 afterMoveLifecycleReserved func()
172 afterMoveLifecycleGateBeforeTeardownLocks func()174 afterMoveLifecycleGateBeforeTeardownLocks func()
173 afterMovePaneSourceSnapshot func()175 afterMovePaneSourceSnapshot func()
M internal/usecase/daemon/floating.go +17 -11
@@ -178,15 +178,8 @@ func (d *Daemon) activateTabAfterResizeForLease(sess *session, tb *tab, outerRes
178178 if d == nil || sess == nil || tb == nil {
179179 return false
180180 }
181- // A headless session has no actual terminal destination. Deferring warmup
182- // until firstPaint keeps restored tabs cold and avoids launching children
183- // merely because a tab was manipulated during teardown.
184- if ac == nil {
185- attachments := sess.snapshotAttachments()
186- if len(attachments) != 0 {
187- ac = attachments[0]
188- }
189- }
181+ // A headless session has no attachment-local copy mode to clear. Keep its
182+ // geometry work session-wide instead of choosing an arbitrary attachment.
190183 if rc := sess.renderCoordinator(); rc != nil && rc.opts.onActivateTabAfterResize != nil {
191184 rc.opts.onActivateTabAfterResize(lease != nil)
192185 }
@@ -196,11 +189,24 @@ func (d *Daemon) activateTabAfterResizeForLease(sess *session, tb *tab, outerRes
196189 return false
197190 }
198191 }
199- d.exitCopyMode(ac)
192+ d.ensureFloatingWarm(sess, tb)
200193 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+ }
201207 return false
202208 }
203- d.ensureFloatingWarm(sess, tb)
209+ d.exitCopyMode(ac)
204210 if outerResizeAccepted {
205211 return false
206212 }
@@ -178,15 +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- attachments := sess.snapshotAttachments()
186- if len(attachments) != 0 {
187- ac = attachments[0]
188- }
189- }
190 if rc := sess.renderCoordinator(); rc != nil && rc.opts.onActivateTabAfterResize != nil {183 if rc := sess.renderCoordinator(); rc != nil && rc.opts.onActivateTabAfterResize != nil {
191 rc.opts.onActivateTabAfterResize(lease != nil)184 rc.opts.onActivateTabAfterResize(lease != nil)
192 }185 }
@@ -196,11 +189,24 @@ func (d *Daemon) activateTabAfterResizeForLease(sess *session, tb *tab, outerRes
196 return false189 return false
197 }190 }
198 }191 }
199- d.exitCopyMode(ac)192+ d.ensureFloatingWarm(sess, tb)
200 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+ }
201 return false207 return false
202 }208 }
203- d.ensureFloatingWarm(sess, tb)209+ d.exitCopyMode(ac)
204 if outerResizeAccepted {210 if outerResizeAccepted {
205 return false211 return false
206 }212 }
M internal/usecase/daemon/input.go +3 -2
@@ -395,8 +395,8 @@ type daemonKeyHandler struct {
395395 }
396396
397397 // acquireAttachmentEffect preserves a synchronous frame's existing admission and
398-// gives delayed router callbacks a fresh ticket for the exact captured role.
399-// Direct/headless callers without a role token retain their existing behavior.
398+// gives delayed router callbacks a fresh ticket for the exact captured attachment.
399+// Direct/headless callers without an attachment token retain their existing behavior.
400400 func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) {
401401 if h.connectionToken.ac != nil {
402402 if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {
@@ -459,6 +459,7 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
459459 defer effect.End()
460460 }
461461 runAction := func(request daemonActionRequest) {
462+ request.effect = effect
462463 runner := h.actions
463464 if runner == nil {
464465 runner = daemonActions{d: h.d}
@@ -395,8 +395,8 @@ type daemonKeyHandler struct {
395 }395 }
396 396
397 // acquireAttachmentEffect preserves a synchronous frame's existing admission and397 // acquireAttachmentEffect preserves a synchronous frame's existing admission and
398-// 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.
399-// Direct/headless callers without a role token retain their existing behavior.399+// Direct/headless callers without an attachment token retain their existing behavior.
400 func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) {400 func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) {
401 if h.connectionToken.ac != nil {401 if h.connectionToken.ac != nil {
402 if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {402 if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {
@@ -459,6 +459,7 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
459 defer effect.End()459 defer effect.End()
460 }460 }
461 runAction := func(request daemonActionRequest) {461 runAction := func(request daemonActionRequest) {
462+ request.effect = effect
462 runner := h.actions463 runner := h.actions
463 if runner == nil {464 if runner == nil {
464 runner = daemonActions{d: h.d}465 runner = daemonActions{d: h.d}
M internal/usecase/daemon/lifecycle_fence_test.go +11 -5
@@ -15,8 +15,9 @@ func TestValidateAttachmentTransitionLocksSourceCoordinatorBeforeLeaseValidation
1515 ac := &attachedClient{tr: transport}
1616 ac.setSession(source)
1717 source.mu.Lock()
18- require.True(t, source.registerAttachmentLocked(ac))
18+ registered := source.registerAttachmentLocked(ac)
1919 source.mu.Unlock()
20+ require.True(t, registered)
2021 sourceCoordinator := newRenderCoordinator(renderCoordinatorOptions{})
2122 source.installRenderCoordinator(sourceCoordinator)
2223 sourceCoordinator.attach(ac)
@@ -26,10 +27,11 @@ func TestValidateAttachmentTransitionLocksSourceCoordinatorBeforeLeaseValidation
2627 d.sessions[target.id] = target
2728
2829 checked := false
30+ coordinatorUnlocked := false
2931 d.afterAttachmentTransitionCoordinatorsLocked = func() {
30- if sourceCoordinator.mu.TryLock() {
32+ coordinatorUnlocked = sourceCoordinator.mu.TryLock()
33+ if coordinatorUnlocked {
3134 sourceCoordinator.mu.Unlock()
32- t.Fatalf("source coordinator was not held during lease validation")
3335 }
3436 checked = true
3537 }
@@ -45,10 +47,13 @@ func TestValidateAttachmentTransitionLocksSourceCoordinatorBeforeLeaseValidation
4547 unlockSessions()
4648 d.notices.routingMu.Unlock()
4749 d.mu.Unlock()
50+ if publication != nil {
51+ publication.unlockCoordinators()
52+ }
4853 require.NoError(t, err)
54+ require.False(t, coordinatorUnlocked, "source coordinator was not held during lease validation")
4955 require.True(t, checked)
5056 require.NotNil(t, publication)
51- publication.unlockCoordinators()
5257 }
5358
5459 func TestAttachmentTokenRevalidatesTheCapturedTransportIncarnation(t *testing.T) {
@@ -58,8 +63,9 @@ func TestAttachmentTokenRevalidatesTheCapturedTransportIncarnation(t *testing.T)
5863 sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}}
5964 ac.setSession(sess)
6065 sess.mu.Lock()
61- require.True(t, sess.registerAttachmentLocked(ac))
66+ registered := sess.registerAttachmentLocked(ac)
6267 sess.mu.Unlock()
68+ require.True(t, registered)
6369
6470 captured := make(chan struct{})
6571 release := make(chan struct{})
@@ -15,8 +15,9 @@ func TestValidateAttachmentTransitionLocksSourceCoordinatorBeforeLeaseValidation
15 ac := &attachedClient{tr: transport}15 ac := &attachedClient{tr: transport}
16 ac.setSession(source)16 ac.setSession(source)
17 source.mu.Lock()17 source.mu.Lock()
18- require.True(t, source.registerAttachmentLocked(ac))18+ registered := source.registerAttachmentLocked(ac)
19 source.mu.Unlock()19 source.mu.Unlock()
20+ require.True(t, registered)
20 sourceCoordinator := newRenderCoordinator(renderCoordinatorOptions{})21 sourceCoordinator := newRenderCoordinator(renderCoordinatorOptions{})
21 source.installRenderCoordinator(sourceCoordinator)22 source.installRenderCoordinator(sourceCoordinator)
22 sourceCoordinator.attach(ac)23 sourceCoordinator.attach(ac)
@@ -26,10 +27,11 @@ func TestValidateAttachmentTransitionLocksSourceCoordinatorBeforeLeaseValidation
26 d.sessions[target.id] = target27 d.sessions[target.id] = target
27 28
28 checked := false29 checked := false
30+ coordinatorUnlocked := false
29 d.afterAttachmentTransitionCoordinatorsLocked = func() {31 d.afterAttachmentTransitionCoordinatorsLocked = func() {
30- if sourceCoordinator.mu.TryLock() {32+ coordinatorUnlocked = sourceCoordinator.mu.TryLock()
33+ if coordinatorUnlocked {
31 sourceCoordinator.mu.Unlock()34 sourceCoordinator.mu.Unlock()
32- t.Fatalf("source coordinator was not held during lease validation")
33 }35 }
34 checked = true36 checked = true
35 }37 }
@@ -45,10 +47,13 @@ func TestValidateAttachmentTransitionLocksSourceCoordinatorBeforeLeaseValidation
45 unlockSessions()47 unlockSessions()
46 d.notices.routingMu.Unlock()48 d.notices.routingMu.Unlock()
47 d.mu.Unlock()49 d.mu.Unlock()
50+ if publication != nil {
51+ publication.unlockCoordinators()
52+ }
48 require.NoError(t, err)53 require.NoError(t, err)
54+ require.False(t, coordinatorUnlocked, "source coordinator was not held during lease validation")
49 require.True(t, checked)55 require.True(t, checked)
50 require.NotNil(t, publication)56 require.NotNil(t, publication)
51- publication.unlockCoordinators()
52 }57 }
53 58
54 func TestAttachmentTokenRevalidatesTheCapturedTransportIncarnation(t *testing.T) {59 func TestAttachmentTokenRevalidatesTheCapturedTransportIncarnation(t *testing.T) {
@@ -58,8 +63,9 @@ func TestAttachmentTokenRevalidatesTheCapturedTransportIncarnation(t *testing.T)
58 sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}}63 sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}}
59 ac.setSession(sess)64 ac.setSession(sess)
60 sess.mu.Lock()65 sess.mu.Lock()
61- require.True(t, sess.registerAttachmentLocked(ac))66+ registered := sess.registerAttachmentLocked(ac)
62 sess.mu.Unlock()67 sess.mu.Unlock()
68+ require.True(t, registered)
63 69
64 captured := make(chan struct{})70 captured := make(chan struct{})
65 release := make(chan struct{})71 release := make(chan struct{})
M internal/usecase/daemon/move.go +14 -8
@@ -46,6 +46,20 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
4646 return errMoveStaleTarget
4747 }
4848
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+
4963 reservation, err := d.reserveMoveLifecycles(source, destination)
5064 if err != nil {
5165 return errMoveStaleTarget
@@ -60,14 +74,6 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
6074 d.afterMoveLifecycleReserved()
6175 }
6276
63- unlockDispatch := lockMoveDispatch(source, destination)
64- dispatchHeld := true
65- defer func() {
66- if dispatchHeld {
67- unlockDispatch()
68- }
69- }()
70-
7177 // Snapshot the exact live objects and generations before waiting on resize
7278 // fences. The locked commit repeats every authority check before publication.
7379 admission, err := d.snapshotMovePaneAdmission(req, source, destination)
@@ -46,6 +46,20 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
46 return errMoveStaleTarget46 return errMoveStaleTarget
47 }47 }
48 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+
49 reservation, err := d.reserveMoveLifecycles(source, destination)63 reservation, err := d.reserveMoveLifecycles(source, destination)
50 if err != nil {64 if err != nil {
51 return errMoveStaleTarget65 return errMoveStaleTarget
@@ -60,14 +74,6 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
60 d.afterMoveLifecycleReserved()74 d.afterMoveLifecycleReserved()
61 }75 }
62 76
63- unlockDispatch := lockMoveDispatch(source, destination)
64- dispatchHeld := true
65- defer func() {
66- if dispatchHeld {
67- unlockDispatch()
68- }
69- }()
70-
71 // Snapshot the exact live objects and generations before waiting on resize77 // Snapshot the exact live objects and generations before waiting on resize
72 // fences. The locked commit repeats every authority check before publication.78 // fences. The locked commit repeats every authority check before publication.
73 admission, err := d.snapshotMovePaneAdmission(req, source, destination)79 admission, err := d.snapshotMovePaneAdmission(req, source, destination)
M internal/usecase/daemon/move_attachments.go +3 -2
@@ -29,8 +29,9 @@ func detachMoveAttachmentsLocked(sess *session, transports map[*attachedClient]t
2929 if sess == nil || len(sess.attachments) == 0 {
3030 return nil
3131 }
32- retired := make([]detachedAttachmentSnapshot, 0, len(sess.attachments))
33- for ac := range sess.attachments {
32+ attachments := sess.snapshotAttachmentsLocked()
33+ retired := make([]detachedAttachmentSnapshot, 0, len(attachments))
34+ for _, ac := range attachments {
3435 retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: transports[ac]})
3536 ac.connectionGeneration.Add(1)
3637 ac.setSession(nil)
@@ -29,8 +29,9 @@ func detachMoveAttachmentsLocked(sess *session, transports map[*attachedClient]t
29 if sess == nil || len(sess.attachments) == 0 {29 if sess == nil || len(sess.attachments) == 0 {
30 return nil30 return nil
31 }31 }
32- retired := make([]detachedAttachmentSnapshot, 0, len(sess.attachments))32+ attachments := sess.snapshotAttachmentsLocked()
33- for ac := range sess.attachments {33+ retired := make([]detachedAttachmentSnapshot, 0, len(attachments))
34+ for _, ac := range attachments {
34 retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: transports[ac]})35 retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: transports[ac]})
35 ac.connectionGeneration.Add(1)36 ac.connectionGeneration.Add(1)
36 ac.setSession(nil)37 ac.setSession(nil)
M internal/usecase/daemon/move_cleanup_test.go +19 -2
@@ -9,6 +9,23 @@ import (
99 scopy "github.com/bnema/vev/internal/usecase/copy"
1010 )
1111
12+func TestDetachMoveAttachmentsUsesStableSnapshotOrder(t *testing.T) {
13+ first := &attachedClient{clientID: [16]byte{1}}
14+ second := &attachedClient{clientID: [16]byte{2}}
15+ sess := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{second: {}, first: {}}}}
16+ frozen := freezeAttachmentEffectGates(first, second)
17+ require.True(t, frozen.acquired)
18+ defer frozen.unfreeze()
19+
20+ sess.mu.Lock()
21+ retired := detachMoveAttachmentsLocked(sess, nil)
22+ sess.mu.Unlock()
23+
24+ require.Len(t, retired, 2)
25+ require.Same(t, first, retired[0].ac)
26+ require.Same(t, second, retired[1].ac)
27+}
28+
1229 func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {
1330 movedPTY, releaseMoved := newBlockingPTY(t)
1431 remainingPTY, releaseRemaining := newBlockingPTY(t)
@@ -18,7 +35,7 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {
1835 defer releaseDestination()
1936
2037 d, source, client, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY)
21- require.NotNil(t, d.attachCoordinator(source, nil, client, true))
38+ require.NotNil(t, d.attachCoordinator(source, client, true))
2239 sourceTab := source.tabs[0]
2340 sourceTab.stableID = "source-tab"
2441 moved := sourceTab.focusedPane()
@@ -100,7 +117,7 @@ func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) {
100117 sourceTab.stableID = "source-tab"
101118 remainingTab.stableID = "remaining-tab"
102119 moved := sourceTab.focusedPane()
103- require.NotNil(t, d.attachCoordinator(source, nil, displaced, true))
120+ require.NotNil(t, d.attachCoordinator(source, displaced, true))
104121
105122 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}
106123 destinationTab := destination.tabs[0]
@@ -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()
@@ -100,7 +117,7 @@ func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) {
100 sourceTab.stableID = "source-tab"117 sourceTab.stableID = "source-tab"
101 remainingTab.stableID = "remaining-tab"118 remainingTab.stableID = "remaining-tab"
102 moved := sourceTab.focusedPane()119 moved := sourceTab.focusedPane()
103- require.NotNil(t, d.attachCoordinator(source, nil, displaced, true))120+ require.NotNil(t, d.attachCoordinator(source, displaced, true))
104 121
105 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})}}
106 destinationTab := destination.tabs[0]123 destinationTab := destination.tabs[0]
M internal/usecase/daemon/move_gate_race_test.go +134 -1
@@ -19,7 +19,7 @@ func newMoveGateRaceFixture(t *testing.T) (*Daemon, *session, *tab, *pane, *sess
1919 sourceTab := source.tabs[0]
2020 sourceTab.stableID = "source-tab"
2121 moved := sourceTab.focusedPane()
22- require.NotNil(t, d.attachCoordinator(source, nil, client, true))
22+ require.NotNil(t, d.attachCoordinator(source, client, true))
2323
2424 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}
2525 destinationTab := destination.tabs[0]
@@ -111,6 +111,139 @@ func TestMovePaneGateReservationRaceKillWinsWithoutDeadlock(t *testing.T) {
111111 require.NotSame(t, moved, destination.tabs[0].panes[moved.id], "kill winner must not publish a partial destination move")
112112 }
113113
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+
114247 func TestMovePaneGateReservationRaceMoveWinsWithoutDeadlock(t *testing.T) {
115248 d, source, sourceTab, moved, destination, destinationTab, releasePTYs := newMoveGateRaceFixture(t)
116249 defer releasePTYs()
@@ -19,7 +19,7 @@ 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 destinationTab := destination.tabs[0]25 destinationTab := destination.tabs[0]
@@ -111,6 +111,139 @@ func TestMovePaneGateReservationRaceKillWinsWithoutDeadlock(t *testing.T) {
111 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")
112 }112 }
113 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+
114 func TestMovePaneGateReservationRaceMoveWinsWithoutDeadlock(t *testing.T) {247 func TestMovePaneGateReservationRaceMoveWinsWithoutDeadlock(t *testing.T) {
115 d, source, sourceTab, moved, destination, destinationTab, releasePTYs := newMoveGateRaceFixture(t)248 d, source, sourceTab, moved, destination, destinationTab, releasePTYs := newMoveGateRaceFixture(t)
116 defer releasePTYs()249 defer releasePTYs()
M internal/usecase/daemon/move_tab.go +18 -11
@@ -50,12 +50,26 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
5050 source := moveSessionForLocatorLocked(d, req.Source)
5151 destination := moveSessionForLocatorLocked(d, req.Destination)
5252 d.mu.Unlock()
53- if source == nil || destination == nil || source == destination {
54- if source == destination && source != nil {
55- return errMovePaneInvalid
56- }
53+ if source == nil || destination == nil {
5754 return errMoveStaleTarget
5855 }
56+ if source == destination {
57+ return errMovePaneInvalid
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+
5973 reservation, err := d.reserveMoveLifecycles(source, destination)
6074 if err != nil {
6175 return errMoveStaleTarget
@@ -69,13 +83,6 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
6983 if d.afterMoveLifecycleReserved != nil {
7084 d.afterMoveLifecycleReserved()
7185 }
72- unlockDispatch := lockMoveDispatch(source, destination)
73- dispatchHeld := true
74- defer func() {
75- if dispatchHeld {
76- unlockDispatch()
77- }
78- }()
7986
8087 admission, err := d.snapshotMoveTabAdmission(req, source, destination)
8188 if err != nil {
@@ -50,12 +50,26 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
50 source := moveSessionForLocatorLocked(d, req.Source)50 source := moveSessionForLocatorLocked(d, req.Source)
51 destination := moveSessionForLocatorLocked(d, req.Destination)51 destination := moveSessionForLocatorLocked(d, req.Destination)
52 d.mu.Unlock()52 d.mu.Unlock()
53- if source == nil || destination == nil || source == destination {53+ if source == nil || destination == nil {
54- if source == destination && source != nil {
55- return errMovePaneInvalid
56- }
57 return errMoveStaleTarget54 return errMoveStaleTarget
58 }55 }
56+ if source == destination {
57+ return errMovePaneInvalid
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+
59 reservation, err := d.reserveMoveLifecycles(source, destination)73 reservation, err := d.reserveMoveLifecycles(source, destination)
60 if err != nil {74 if err != nil {
61 return errMoveStaleTarget75 return errMoveStaleTarget
@@ -69,13 +83,6 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
69 if d.afterMoveLifecycleReserved != nil {83 if d.afterMoveLifecycleReserved != nil {
70 d.afterMoveLifecycleReserved()84 d.afterMoveLifecycleReserved()
71 }85 }
72- unlockDispatch := lockMoveDispatch(source, destination)
73- dispatchHeld := true
74- defer func() {
75- if dispatchHeld {
76- unlockDispatch()
77- }
78- }()
79 86
80 admission, err := d.snapshotMoveTabAdmission(req, source, destination)87 admission, err := d.snapshotMoveTabAdmission(req, source, destination)
81 if err != nil {88 if err != nil {
M internal/usecase/daemon/move_tab_test.go +2 -2
@@ -152,7 +152,7 @@ func TestMoveTabFinalSourceVisibleFloatingRejectsStaleSlotTransitions(t *testing
152152 d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
153153 moved := source.tabs[0]
154154 moved.stableID = "moved-tab"
155- require.NotNil(t, d.attachCoordinator(source, nil, client, true))
155+ require.NotNil(t, d.attachCoordinator(source, client, true))
156156 destination := addMoveTabTestSession(d, "destination", "destination-tab")
157157 floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8})
158158 moved.mu.Lock()
@@ -453,7 +453,7 @@ func (r *moveTabPurgeRepository) DeleteIncarnation(_ context.Context, id domain.
453453
454454 func TestMoveTabFinalNamedSourcePurgesAfterDestinationAdmissionAndFailureDoesNotRollback(t *testing.T) {
455455 d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
456- require.NotNil(t, d.attachCoordinator(source, nil, client, true))
456+ require.NotNil(t, d.attachCoordinator(source, client, true))
457457 source.incarnation = domain.IncarnationID{4}
458458 moved := source.tabs[0]
459459 moved.stableID = "moved-tab"
@@ -152,7 +152,7 @@ func TestMoveTabFinalSourceVisibleFloatingRejectsStaleSlotTransitions(t *testing
152 d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())152 d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
153 moved := source.tabs[0]153 moved := source.tabs[0]
154 moved.stableID = "moved-tab"154 moved.stableID = "moved-tab"
155- require.NotNil(t, d.attachCoordinator(source, nil, client, true))155+ require.NotNil(t, d.attachCoordinator(source, client, true))
156 destination := addMoveTabTestSession(d, "destination", "destination-tab")156 destination := addMoveTabTestSession(d, "destination", "destination-tab")
157 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})
158 moved.mu.Lock()158 moved.mu.Lock()
@@ -453,7 +453,7 @@ func (r *moveTabPurgeRepository) DeleteIncarnation(_ context.Context, id domain.
453 453
454 func TestMoveTabFinalNamedSourcePurgesAfterDestinationAdmissionAndFailureDoesNotRollback(t *testing.T) {454 func TestMoveTabFinalNamedSourcePurgesAfterDestinationAdmissionAndFailureDoesNotRollback(t *testing.T) {
455 d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())455 d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
456- require.NotNil(t, d.attachCoordinator(source, nil, client, true))456+ require.NotNil(t, d.attachCoordinator(source, client, true))
457 source.incarnation = domain.IncarnationID{4}457 source.incarnation = domain.IncarnationID{4}
458 moved := source.tabs[0]458 moved := source.tabs[0]
459 moved.stableID = "moved-tab"459 moved.stableID = "moved-tab"
M internal/usecase/daemon/multi_attachment_callers_test.go +8 -3
@@ -84,10 +84,15 @@ func TestPaneRemovalRepairsEachAttachmentViewIndependently(t *testing.T) {
8484 defer sess.mu.Unlock()
8585 tb := sess.tabs[0]
8686 tb.mu.Lock()
87- require.NoError(t, tb.tree.Close(firstPane.id))
88- delete(tb.panes, firstPane.id)
89- tb.bumpLayoutGenerationLocked()
87+ closeErr := tb.tree.Close(firstPane.id)
88+ if closeErr == nil {
89+ delete(tb.panes, firstPane.id)
90+ tb.bumpLayoutGenerationLocked()
91+ }
9092 tb.mu.Unlock()
93+ if closeErr != nil {
94+ return closeErr
95+ }
9196 sess.repairAttachmentViewsLocked()
9297 return nil
9398 }))
@@ -84,10 +84,15 @@ func TestPaneRemovalRepairsEachAttachmentViewIndependently(t *testing.T) {
84 defer sess.mu.Unlock()84 defer sess.mu.Unlock()
85 tb := sess.tabs[0]85 tb := sess.tabs[0]
86 tb.mu.Lock()86 tb.mu.Lock()
87- require.NoError(t, tb.tree.Close(firstPane.id))87+ closeErr := tb.tree.Close(firstPane.id)
88- delete(tb.panes, firstPane.id)88+ if closeErr == nil {
89- tb.bumpLayoutGenerationLocked()89+ delete(tb.panes, firstPane.id)
90+ tb.bumpLayoutGenerationLocked()
91+ }
90 tb.mu.Unlock()92 tb.mu.Unlock()
93+ if closeErr != nil {
94+ return closeErr
95+ }
91 sess.repairAttachmentViewsLocked()96 sess.repairAttachmentViewsLocked()
92 return nil97 return nil
93 }))98 }))
M internal/usecase/daemon/navigation_attachment_handoff_test.go +2 -2
@@ -97,7 +97,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
9797 d.sessions[target.id] = target
9898 d.mu.Unlock()
9999
100- rc := d.attachCoordinator(source, nil, old, true)
100+ rc := d.attachCoordinator(source, old, true)
101101 token := source.attachmentToken(old, old.transport())
102102 token.lease = rc.attachmentLease(old)
103103 old.publishAttachmentCapability(token)
@@ -152,7 +152,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
152152 d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 7, state: ports.SessionStopped}
153153 d.mu.Unlock()
154154
155- rc := d.attachCoordinator(source, nil, old, true)
155+ rc := d.attachCoordinator(source, old, true)
156156 token := source.attachmentToken(old, old.transport())
157157 token.lease = rc.attachmentLease(old)
158158 old.publishAttachmentCapability(token)
@@ -97,7 +97,7 @@ 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.publishAttachmentCapability(token)103 old.publishAttachmentCapability(token)
@@ -152,7 +152,7 @@ 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.publishAttachmentCapability(token)158 old.publishAttachmentCapability(token)
M internal/usecase/daemon/navigation_attachment_incarnation_test.go +1 -1
@@ -47,7 +47,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
4747 d.sessions[target.id] = target
4848 d.mu.Unlock()
4949
50- rc := d.attachCoordinator(source, nil, ac, true)
50+ rc := d.attachCoordinator(source, ac, true)
5151 token := source.attachmentToken(ac, ac.transport())
5252 token.lease = rc.attachmentLease(ac)
5353 ac.publishAttachmentCapability(token)
@@ -47,7 +47,7 @@ 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.publishAttachmentCapability(token)53 ac.publishAttachmentCapability(token)
M internal/usecase/daemon/navigation_overflow.go +5 -1
@@ -143,13 +143,17 @@ func (d *Daemon) prepareTabOverflowForAttachment(sess *session, ac *attachedClie
143143 sess.mu.Unlock()
144144 return tabOverflowCandidate{}, false
145145 }
146- sourceIndex := 0
146+ sourceIndex := -1
147147 for i, tb := range sess.tabs {
148148 if tb == expectedSource {
149149 sourceIndex = i
150150 break
151151 }
152152 }
153+ if sourceIndex < 0 {
154+ sess.mu.Unlock()
155+ return tabOverflowCandidate{}, false
156+ }
153157 targetIndex := sourceIndex + delta
154158 if targetIndex < 0 || targetIndex >= len(sess.tabs) {
155159 sess.mu.Unlock()
@@ -143,13 +143,17 @@ func (d *Daemon) prepareTabOverflowForAttachment(sess *session, ac *attachedClie
143 sess.mu.Unlock()143 sess.mu.Unlock()
144 return tabOverflowCandidate{}, false144 return tabOverflowCandidate{}, false
145 }145 }
146- sourceIndex := 0146+ sourceIndex := -1
147 for i, tb := range sess.tabs {147 for i, tb := range sess.tabs {
148 if tb == expectedSource {148 if tb == expectedSource {
149 sourceIndex = i149 sourceIndex = i
150 break150 break
151 }151 }
152 }152 }
153+ if sourceIndex < 0 {
154+ sess.mu.Unlock()
155+ return tabOverflowCandidate{}, false
156+ }
153 targetIndex := sourceIndex + delta157 targetIndex := sourceIndex + delta
154 if targetIndex < 0 || targetIndex >= len(sess.tabs) {158 if targetIndex < 0 || targetIndex >= len(sess.tabs) {
155 sess.mu.Unlock()159 sess.mu.Unlock()
M internal/usecase/daemon/navigation_overflow_test.go +14 -0
@@ -42,6 +42,20 @@ func TestResolveOverflowObeysAxisConfigurationAndWalls(t *testing.T) {
4242 }
4343 }
4444
45+func TestPrepareTabOverflowRejectsMissingExpectedSource(t *testing.T) {
46+ d, sess, _, _ := newManualSessionWithPTYs(t, nil, nil)
47+ target := sess.tabs[1]
48+ target.mu.Lock()
49+ target.size = domain.Size{Cols: 41, Rows: 10}
50+ target.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"}
51+ target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
52+ target.mu.Unlock()
53+
54+ missing := &tab{stableID: "missing-source"}
55+ _, ok := d.prepareTabOverflowForAttachment(sess, nil, missing, layout.Right, domain.Rect{Width: 20, Height: 10}, 1)
56+ require.False(t, ok)
57+}
58+
4559 func TestKeyboardHorizontalOverflowLandsOnFacingEdge(t *testing.T) {
4660 tests := []struct {
4761 name string
@@ -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
M internal/usecase/daemon/notify_test.go +1 -1
@@ -57,7 +57,7 @@ func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing.
5757
5858 func TestMalformedClientNoticeIsIgnored(t *testing.T) {
5959 d, sess, ac, _ := newNoticeFixture(t, &noticeClock{})
60- d.attachCoordinator(sess, nil, ac, true)
60+ d.attachCoordinator(sess, ac, true)
6161 tr, ok := ac.tr.(*portsmocks.MockTransport)
6262 require.True(t, ok, "attached client transport must be a MockTransport")
6363 frames := []ports.Frame{
@@ -57,7 +57,7 @@ func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing.
57 57
58 func TestMalformedClientNoticeIsIgnored(t *testing.T) {58 func TestMalformedClientNoticeIsIgnored(t *testing.T) {
59 d, sess, ac, _ := newNoticeFixture(t, &noticeClock{})59 d, sess, ac, _ := newNoticeFixture(t, &noticeClock{})
60- d.attachCoordinator(sess, nil, ac, true)60+ d.attachCoordinator(sess, ac, true)
61 tr, ok := ac.tr.(*portsmocks.MockTransport)61 tr, ok := ac.tr.(*portsmocks.MockTransport)
62 require.True(t, ok, "attached client transport must be a MockTransport")62 require.True(t, ok, "attached client transport must be a MockTransport")
63 frames := []ports.Frame{63 frames := []ports.Frame{
M internal/usecase/daemon/output_state_stream.go +38 -7
@@ -1,6 +1,8 @@
11 package daemon
22
33 import (
4+ "sync/atomic"
5+
46 "github.com/bnema/vev/internal/ports"
57 "github.com/bnema/vev/pkg/renderer"
68 )
@@ -15,11 +17,13 @@ import (
1517 // Callers serialize access with attachedClient.sendMu. A prepared transaction
1618 // must be sent or committed as a no-op before the next transaction is prepared.
1719 type outputStateStream struct {
18- renderer *renderer.Renderer
19- next uint64
20- acked uint64
21- maxOutstanding uint64
22- forceSnapshot bool
20+ renderer *renderer.Renderer
21+ next uint64
22+ acked uint64
23+ maxOutstanding uint64
24+ outstandingAtomic atomic.Uint64
25+ maxOutstandingAtomic atomic.Uint64
26+ forceSnapshot bool
2327 }
2428
2529 func newOutputStateStream(windowSize ...uint8) *outputStateStream {
@@ -27,10 +31,13 @@ func newOutputStateStream(windowSize ...uint8) *outputStateStream {
2731 if len(windowSize) > 0 {
2832 window = normalizeOutputWindow(windowSize[0])
2933 }
30- return &outputStateStream{
34+ stream := &outputStateStream{
3135 renderer: renderer.New(renderer.Capabilities{}),
3236 maxOutstanding: uint64(window),
3337 }
38+ stream.maxOutstandingAtomic.Store(uint64(window))
39+ stream.publishOutstanding()
40+ return stream
3441 }
3542
3643 type preparedOutput struct {
@@ -73,6 +80,7 @@ func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame
7380 }
7481 p.commit()
7582 p.stream.next = p.next
83+ p.stream.publishOutstanding()
7684 return nil
7785 }
7886
@@ -98,16 +106,39 @@ func (s *outputStateStream) ack(state uint64) {
98106 return
99107 }
100108 s.acked = state
109+ s.publishOutstanding()
101110 }
102111
103112 func (s *outputStateStream) rebase() {
104113 s.acked = s.next
114+ s.publishOutstanding()
115+ s.maxOutstandingAtomic.Store(s.maxOutstanding)
105116 s.renderer.Reset()
106117 s.forceSnapshot = true
107118 }
108119
120+func (s *outputStateStream) publishOutstanding() {
121+ if s == nil {
122+ return
123+ }
124+ s.outstandingAtomic.Store(s.next - s.acked)
125+}
126+
127+// syncCapacityLocked refreshes the lock-free readiness snapshot after a
128+// caller mutates the legacy fields under attachedClient.sendMu.
129+func (s *outputStateStream) syncCapacityLocked() {
130+ if s == nil {
131+ return
132+ }
133+ s.maxOutstandingAtomic.Store(s.maxOutstanding)
134+ s.publishOutstanding()
135+}
136+
109137 func (s *outputStateStream) atCapacity() bool {
110- return s.outstanding() >= s.maxOutstanding
138+ if s == nil {
139+ return false
140+ }
141+ return s.outstandingAtomic.Load() >= s.maxOutstandingAtomic.Load()
111142 }
112143
113144 func (s *outputStateStream) outstanding() uint64 { return s.next - s.acked }
@@ -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
22
33 import (
44 "errors"
5+ "runtime"
56 "testing"
67
78 "github.com/bnema/vev/internal/ports"
@@ -35,6 +36,44 @@ func TestOutputStateStreamBuildsPipelinedDependencyChain(t *testing.T) {
3536 require.Equal(t, uint64(2), stream.outstanding())
3637 }
3738
39+func TestOutputStateStreamCapacityProbeDoesNotRaceWithSend(t *testing.T) {
40+ stream := newOutputStateStream(1)
41+ frame := renderer.NewFrame(3, 1)
42+ fillOutputStateRows(frame, []string{"abc"})
43+ prepared, err := stream.prepare(frame, nil, true)
44+ require.NoError(t, err)
45+
46+ entered := make(chan struct{})
47+ release := make(chan struct{})
48+ sendDone := make(chan error, 1)
49+ go func() {
50+ sendDone <- prepared.send(prepared.data, 0, func(ports.Frame) error {
51+ close(entered)
52+ <-release
53+ return nil
54+ })
55+ }()
56+ <-entered
57+
58+ probed := make(chan struct{})
59+ go func() {
60+ for range 100_000 {
61+ _ = stream.atCapacity()
62+ runtime.Gosched()
63+ }
64+ close(probed)
65+ }()
66+ close(release)
67+ require.NoError(t, <-sendDone)
68+ <-probed
69+ require.True(t, stream.atCapacity())
70+
71+ stream.ack(1)
72+ require.False(t, stream.atCapacity())
73+ stream.rebase()
74+ require.False(t, stream.atCapacity())
75+}
76+
3877 func TestOutputStateStreamFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T) {
3978 stream := newOutputStateStream()
4079 initial := renderer.NewFrame(3, 1)
@@ -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/palette.go +1 -0
@@ -500,6 +500,7 @@ type paletteExec struct {
500500 }
501501
502502 func (e paletteExec) runAction(request daemonActionRequest) error {
503+ request.effect = e.effect
503504 if request.target.session == nil {
504505 request.target = resolveDaemonActionTargetForAttachment(e.sess, e.ac)
505506 }
@@ -500,6 +500,7 @@ type paletteExec struct {
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 = resolveDaemonActionTargetForAttachment(e.sess, e.ac)505 request.target = resolveDaemonActionTargetForAttachment(e.sess, e.ac)
505 }506 }
M internal/usecase/daemon/pane_actions.go +36 -4
@@ -241,6 +241,11 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
241241 return true
242242 }
243243 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()
244249 sess.mu.Lock()
245250 ownedTab := slices.Contains(sess.tabs, tb)
246251 if p.owner.Load() != owner {
@@ -285,11 +290,16 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
285290 p.clearOwnerLocked()
286291 p.mu.Unlock()
287292 tb.mu.Unlock()
293+ sess.invalidateViewsLocked()
288294 sess.mu.Unlock()
289- sess.repairAttachmentViews()
290295
291296 if finalPane {
292- _ = 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)
293303 return true
294304 }
295305 d.applyTabLayout(sess, tb)
@@ -310,7 +320,25 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
310320 return true
311321 }
312322
323+// closePane is the public close boundary for callers that are not already
324+// inside session.runMutation. Mutation dispatchers use closePaneLocked.
313325 func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool) error {
326+ if sess == nil {
327+ return layout.ErrNotFound
328+ }
329+ sess.dispatchMu.Lock()
330+ defer sess.dispatchMu.Unlock()
331+ return d.closePaneLocked(sess, tb, id, ac, repaint)
332+}
333+
334+// closePaneLocked requires sess.dispatchMu before entering any architecture
335+// lock. It is used by existing runMutation callers to avoid nested dispatch
336+// locking.
337+func (d *Daemon) closePaneLocked(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool) error {
338+ return d.closePaneLockedWithEffect(sess, tb, id, ac, repaint, nil)
339+}
340+
341+func (d *Daemon) closePaneLockedWithEffect(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool, effect *attachmentEffectTicket) error {
314342 if tb == nil {
315343 return layout.ErrNotFound
316344 }
@@ -330,7 +358,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
330358 }
331359 viewer.pruneCaptureFrames(p)
332360 }
333- return d.closeTab(sess, tb, repaint)
361+ return d.closeTabLockedWithEffect(sess, tb, repaint, effect)
334362 }
335363 if err := tb.tree.Close(id); err != nil {
336364 tb.mu.Unlock()
@@ -359,6 +387,10 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
359387 if repaint {
360388 if ac != nil {
361389 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+ }
362394 }
363395 }
364396 return nil
@@ -459,7 +491,7 @@ func (d *Daemon) finishPaneFocusForClient(sess *session, ac *attachedClient, tb
459491 if moved {
460492 d.exitCopyMode(ac)
461493 if hasPlacement && pl.InStack {
462- d.refreshPaneTitleOnFocus(sess, newFocus)
494+ d.refreshPaneTitleOnFocus(sess, newFocus, tb)
463495 }
464496 }
465497 d.invalidateRender(sess, ac, true, producer)
@@ -241,6 +241,11 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
241 return true241 return true
242 }242 }
243 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()
244 sess.mu.Lock()249 sess.mu.Lock()
245 ownedTab := slices.Contains(sess.tabs, tb)250 ownedTab := slices.Contains(sess.tabs, tb)
246 if p.owner.Load() != owner {251 if p.owner.Load() != owner {
@@ -285,11 +290,16 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
285 p.clearOwnerLocked()290 p.clearOwnerLocked()
286 p.mu.Unlock()291 p.mu.Unlock()
287 tb.mu.Unlock()292 tb.mu.Unlock()
293+ sess.invalidateViewsLocked()
288 sess.mu.Unlock()294 sess.mu.Unlock()
289- sess.repairAttachmentViews()
290 295
291 if finalPane {296 if finalPane {
292- _ = 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)
293 return true303 return true
294 }304 }
295 d.applyTabLayout(sess, tb)305 d.applyTabLayout(sess, tb)
@@ -310,7 +320,25 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
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 {
326+ if sess == nil {
327+ return layout.ErrNotFound
328+ }
329+ sess.dispatchMu.Lock()
330+ defer sess.dispatchMu.Unlock()
331+ return d.closePaneLocked(sess, tb, id, ac, repaint)
332+}
333+
334+// closePaneLocked requires sess.dispatchMu before entering any architecture
335+// lock. It is used by existing runMutation callers to avoid nested dispatch
336+// locking.
337+func (d *Daemon) closePaneLocked(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool) error {
338+ return d.closePaneLockedWithEffect(sess, tb, id, ac, repaint, nil)
339+}
340+
341+func (d *Daemon) closePaneLockedWithEffect(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool, effect *attachmentEffectTicket) error {
314 if tb == nil {342 if tb == nil {
315 return layout.ErrNotFound343 return layout.ErrNotFound
316 }344 }
@@ -330,7 +358,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
330 }358 }
331 viewer.pruneCaptureFrames(p)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()
@@ -359,6 +387,10 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
359 if repaint {387 if repaint {
360 if ac != nil {388 if ac != nil {
361 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+ }
362 }394 }
363 }395 }
364 return nil396 return nil
@@ -459,7 +491,7 @@ func (d *Daemon) finishPaneFocusForClient(sess *session, ac *attachedClient, tb
459 if moved {491 if moved {
460 d.exitCopyMode(ac)492 d.exitCopyMode(ac)
461 if hasPlacement && pl.InStack {493 if hasPlacement && pl.InStack {
462- d.refreshPaneTitleOnFocus(sess, newFocus)494+ d.refreshPaneTitleOnFocus(sess, newFocus, tb)
463 }495 }
464 }496 }
465 d.invalidateRender(sess, ac, true, producer)497 d.invalidateRender(sess, ac, true, producer)
M internal/usecase/daemon/pane_actions_test.go +110 -0
@@ -635,6 +635,116 @@ func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) {
635635 newPTY.AssertNotCalled(t, "Resize", mock.Anything)
636636 }
637637
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+
638748 func TestCloseFocusedPaneRemovesPaneReflowsAndIsIdempotent(t *testing.T) {
639749 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
640750 tb := testAttachmentTab(sess)
@@ -635,6 +635,116 @@ func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) {
635 newPTY.AssertNotCalled(t, "Resize", mock.Anything)635 newPTY.AssertNotCalled(t, "Resize", mock.Anything)
636 }636 }
637 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+
638 func TestCloseFocusedPaneRemovesPaneReflowsAndIsIdempotent(t *testing.T) {748 func TestCloseFocusedPaneRemovesPaneReflowsAndIsIdempotent(t *testing.T) {
639 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})749 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
640 tb := testAttachmentTab(sess)750 tb := testAttachmentTab(sess)
M internal/usecase/daemon/pane_titles.go +27 -16
@@ -137,32 +137,43 @@ func (d *Daemon) paneTitleFallback() string {
137137 return fallback
138138 }
139139
140-func (d *Daemon) refreshPaneTitle(sess *session, id layout.PaneID) string {
141- return d.refreshPaneTitleCached(sess, id, false)
140+func (d *Daemon) refreshPaneTitle(sess *session, id layout.PaneID, owningTab ...*tab) string {
141+ return d.refreshPaneTitleCached(sess, id, false, owningTab...)
142142 }
143143
144-func (d *Daemon) refreshPaneTitleOnFocus(sess *session, id layout.PaneID) string {
145- return d.refreshPaneTitleCached(sess, id, true)
144+func (d *Daemon) refreshPaneTitleOnFocus(sess *session, id layout.PaneID, owningTab ...*tab) string {
145+ return d.refreshPaneTitleCached(sess, id, true, owningTab...)
146146 }
147147
148-// refreshPaneTitleCached retains the ID lookup used by normal layout panes.
149-func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force bool) string {
148+// refreshPaneTitleCached resolves the pane in its owning tab. A caller with a
149+// current tab supplies it explicitly; direct callers refresh every matching
150+// tab because layout pane IDs are local to a tab and may repeat.
151+func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force bool, owningTab ...*tab) string {
150152 fallback := d.paneTitleFallback()
151153 if sess == nil {
152154 return fallback
153155 }
154- tb := sess.firstTab()
155- if tb == nil {
156- return fallback
156+ tabs := owningTab
157+ if len(tabs) == 0 || tabs[0] == nil {
158+ sess.mu.Lock()
159+ tabs = append([]*tab(nil), sess.tabs...)
160+ sess.mu.Unlock()
157161 }
158- tb.mu.Lock()
159- p := tb.panes[id]
160- tb.mu.Unlock()
161- if p == nil {
162- return fallback
162+ title := fallback
163+ for _, tb := range tabs {
164+ if tb == nil {
165+ continue
166+ }
167+ tb.mu.Lock()
168+ p := tb.panes[id]
169+ tb.mu.Unlock()
170+ if p == nil {
171+ continue
172+ }
173+ p.setDisplayFallback(fallback)
174+ title = d.refreshPaneDisplayTitle(sess, p, force)
163175 }
164- p.setDisplayFallback(fallback)
165- return d.refreshPaneDisplayTitle(sess, p, force)
176+ return title
166177 }
167178
168179 // refreshPaneDisplayTitle refreshes only the process-name portion of p's
@@ -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.firstTab()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 +21 -0
@@ -89,6 +89,27 @@ func TestRefreshPaneTitleUsesForegroundProcessComm(t *testing.T) {
8989 require.Equal(t, "vim", p.title.processName)
9090 }
9191
92+func TestRefreshPaneTitleUsesProvidedOwningTab(t *testing.T) {
93+ secondPTY := portsmocks.NewMockPTY(t)
94+ secondPTY.EXPECT().ForegroundPgid().Return(222, nil).Once()
95+ _, sess, _, _ := newManualSessionWithPTYs(t, nil)
96+ second := newTabWithStableID("second-tab", "second-pane", secondPTY, domain.Size{Cols: 80, Rows: 23})
97+ sess.mu.Lock()
98+ sess.tabs = append(sess.tabs, second)
99+ sess.mu.Unlock()
100+ d := newTestDaemon(t, nil, stubClock{})
101+ d.procComm = func(pid int) (string, error) {
102+ require.Equal(t, 222, pid)
103+ return "vim", nil
104+ }
105+
106+ require.Equal(t, "vim", d.refreshPaneTitle(sess, "pane-1", second))
107+ secondPane := second.panes["pane-1"]
108+ secondPane.mu.Lock()
109+ require.Equal(t, "vim", secondPane.title.processName)
110+ secondPane.mu.Unlock()
111+}
112+
92113 func TestRefreshPaneTitleCachesByTTLAndRefreshesOnFocus(t *testing.T) {
93114 pty := portsmocks.NewMockPTY(t)
94115 pty.EXPECT().ForegroundPgid().Return(1234, nil).Twice()
@@ -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()
M internal/usecase/daemon/performance_benchmark_test.go +1 -1
@@ -1456,7 +1456,7 @@ func newPerformanceFixtureWithCleanup(t testing.TB, config performanceConfig, re
14561456 compose: func() { fixture.renderCompositions++ },
14571457 emit: func() { fixture.renderEmissions++ },
14581458 }
1459- d.attachCoordinator(sess, nil, ac, true)
1459+ d.attachCoordinator(sess, ac, true)
14601460
14611461 // Prime the real renderer shadow before measurements. Subsequent paints use
14621462 // actual production diffs rather than the initial full frame.
@@ -1456,7 +1456,7 @@ func newPerformanceFixtureWithCleanup(t testing.TB, config performanceConfig, re
1456 compose: func() { fixture.renderCompositions++ },1456 compose: func() { fixture.renderCompositions++ },
1457 emit: func() { fixture.renderEmissions++ },1457 emit: func() { fixture.renderEmissions++ },
1458 }1458 }
1459- d.attachCoordinator(sess, nil, ac, true)1459+ d.attachCoordinator(sess, ac, true)
1460 1460
1461 // Prime the real renderer shadow before measurements. Subsequent paints use1461 // Prime the real renderer shadow before measurements. Subsequent paints use
1462 // actual production diffs rather than the initial full frame.1462 // actual production diffs rather than the initial full frame.
M internal/usecase/daemon/picker.go +6 -7
@@ -423,7 +423,7 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
423423 if targetSess == ac.currentAttachmentSession() {
424424 return
425425 }
426- rc := d.attachCoordinator(targetSess, nil, nil, false)
426+ rc := d.attachCoordinator(targetSess, nil, false)
427427 rc.subscribePreviewFor(ac, generation, func(renderWake) {
428428 if pickerPreviewCurrent(ac, targetSess, next, generation) {
429429 if owner := ac.currentAttachmentSession(); owner != nil {
@@ -640,7 +640,7 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnecti
640640 activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,
641641 })
642642 if err != nil {
643- // Losing the exact source role is a benign stale action, not a notice for
643+ // Losing the exact source attachment is a benign stale action, not a notice for
644644 // whichever attachment replaced the initiator.
645645 if !token.attachmentCurrent() {
646646 return nil
@@ -692,8 +692,8 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
692692 }
693693
694694 // switchToRemoteTargetForAttachment performs dialing after the initiating effect has
695-// ended and without role or architecture locks. transitionAttachment then
696-// freezes and revalidates the exact source role and exact structured-key proxy
695+// ended and without attachment-effect or architecture locks. transitionAttachment then
696+// freezes and revalidates the exact source attachment and exact structured-key proxy
697697 // before publishing ownership.
698698 func (d *Daemon) switchToRemoteTargetForAttachment(token attachmentConnectionToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, action string) error {
699699 if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" {
@@ -1085,15 +1085,14 @@ func targetMatchesLifecycle(target picker.Target, name string, createdAt int64)
10851085
10861086 // stealClientForTarget is retained for direct-ID callers and tests. Named
10871087 // targets must use switchToTarget so resolution and commit share d.mu.
1088-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) {
10891089 d.mu.Lock()
10901090 _, transition, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{}, nil, "")
10911091 d.mu.Unlock()
10921092 if !switched {
1093- return nil
1093+ return
10941094 }
10951095 d.deferAttachmentTransitionCleanups(transition)
1096- return nil
10971096 }
10981097
10991098 // resumeStoppedAndSwitch is retained for direct callers and tests. It resolves
@@ -423,7 +423,7 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
423 if targetSess == ac.currentAttachmentSession() {423 if targetSess == ac.currentAttachmentSession() {
424 return424 return
425 }425 }
426- rc := d.attachCoordinator(targetSess, nil, nil, false)426+ rc := d.attachCoordinator(targetSess, nil, false)
427 rc.subscribePreviewFor(ac, generation, func(renderWake) {427 rc.subscribePreviewFor(ac, generation, func(renderWake) {
428 if pickerPreviewCurrent(ac, targetSess, next, generation) {428 if pickerPreviewCurrent(ac, targetSess, next, generation) {
429 if owner := ac.currentAttachmentSession(); owner != nil {429 if owner := ac.currentAttachmentSession(); owner != nil {
@@ -640,7 +640,7 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnecti
640 activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,640 activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,
641 })641 })
642 if err != nil {642 if err != nil {
643- // 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
644 // whichever attachment replaced the initiator.644 // whichever attachment replaced the initiator.
645 if !token.attachmentCurrent() {645 if !token.attachmentCurrent() {
646 return nil646 return nil
@@ -692,8 +692,8 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
692 }692 }
693 693
694 // switchToRemoteTargetForAttachment performs dialing after the initiating effect has694 // switchToRemoteTargetForAttachment performs dialing after the initiating effect has
695-// ended and without role or architecture locks. transitionAttachment then695+// ended and without attachment-effect or architecture locks. transitionAttachment then
696-// freezes and revalidates the exact source role and exact structured-key proxy696+// freezes and revalidates the exact source attachment and exact structured-key proxy
697 // before publishing ownership.697 // before publishing ownership.
698 func (d *Daemon) switchToRemoteTargetForAttachment(token attachmentConnectionToken, 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 {
699 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 != "" {
@@ -1085,15 +1085,14 @@ func targetMatchesLifecycle(target picker.Target, name string, createdAt int64)
1085 1085
1086 // stealClientForTarget is retained for direct-ID callers and tests. Named1086 // stealClientForTarget is retained for direct-ID callers and tests. Named
1087 // targets must use switchToTarget so resolution and commit share d.mu.1087 // targets must use switchToTarget so resolution and commit share d.mu.
1088-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) {
1089 d.mu.Lock()1089 d.mu.Lock()
1090 _, transition, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{}, nil, "")1090 _, transition, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{}, nil, "")
1091 d.mu.Unlock()1091 d.mu.Unlock()
1092 if !switched {1092 if !switched {
1093- return nil1093+ return
1094 }1094 }
1095 d.deferAttachmentTransitionCleanups(transition)1095 d.deferAttachmentTransitionCleanups(transition)
1096- return nil
1097 }1096 }
1098 1097
1099 // resumeStoppedAndSwitch is retained for direct callers and tests. It resolves1098 // resumeStoppedAndSwitch is retained for direct callers and tests. It resolves
M internal/usecase/daemon/picker_test.go +1 -1
@@ -1550,7 +1550,7 @@ func TestPickerAttachmentEffectDeleteRemovesSelectedSessionAndRefreshes(t *testi
15501550 current.mu.Lock()
15511551 current.registerAttachmentLocked(ac)
15521552 current.mu.Unlock()
1553- require.NotNil(t, d.attachCoordinator(current, nil, ac, true))
1553+ require.NotNil(t, d.attachCoordinator(current, ac, true))
15541554
15551555 d.enterPicker(current, ac)
15561556 d.handlePickerInput(ac, []byte("k"))
@@ -1550,7 +1550,7 @@ func TestPickerAttachmentEffectDeleteRemovesSelectedSessionAndRefreshes(t *testi
1550 current.mu.Lock()1550 current.mu.Lock()
1551 current.registerAttachmentLocked(ac)1551 current.registerAttachmentLocked(ac)
1552 current.mu.Unlock()1552 current.mu.Unlock()
1553- require.NotNil(t, d.attachCoordinator(current, nil, ac, true))1553+ require.NotNil(t, d.attachCoordinator(current, ac, true))
1554 1554
1555 d.enterPicker(current, ac)1555 d.enterPicker(current, ac)
1556 d.handlePickerInput(ac, []byte("k"))1556 d.handlePickerInput(ac, []byte("k"))
M internal/usecase/daemon/proxied_contract_test.go +4 -4
@@ -205,7 +205,7 @@ func TestProxyResizeUpdatesAttachmentViewport(t *testing.T) {
205205 func TestProxiedTransactionalResizeUsesReceivedContentGeometry(t *testing.T) {
206206 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
207207 ac.proxied = true
208- rc := d.attachCoordinator(sess, nil, ac, true)
208+ rc := d.attachCoordinator(sess, ac, true)
209209 lease := rc.attachmentLease(ac)
210210 require.NotNil(t, lease)
211211
@@ -262,7 +262,7 @@ func TestProxiedModeCannotChangeAcrossResume(t *testing.T) {
262262 func TestOutputResetRebasesFullWindowAndSchedulesBaseZeroPaint(t *testing.T) {
263263 d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
264264 ac.proxied = true
265- rc := d.attachCoordinator(sess, nil, ac, true)
265+ rc := d.attachCoordinator(sess, ac, true)
266266 lease := rc.attachmentLease(ac)
267267 require.NotNil(t, lease)
268268
@@ -304,7 +304,7 @@ func TestOutputResetRequiresProxiedActiveRoleAndStrictPayload(t *testing.T) {
304304 t.Run(tt.name, func(t *testing.T) {
305305 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
306306 ac.proxied = tt.proxied
307- d.attachCoordinator(sess, nil, ac, true)
307+ d.attachCoordinator(sess, ac, true)
308308 ac.sendMu.Lock()
309309 ac.output.next, ac.output.acked = 4, 0
310310 ac.sendMu.Unlock()
@@ -325,7 +325,7 @@ func TestOutputResetRequiresProxiedActiveRoleAndStrictPayload(t *testing.T) {
325325 func TestOutputResetRevalidatesTransportUnderSendLock(t *testing.T) {
326326 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
327327 ac.proxied = true
328- d.attachCoordinator(sess, nil, ac, true)
328+ d.attachCoordinator(sess, ac, true)
329329 ac.sendMu.Lock()
330330 ac.output.next, ac.output.acked = 4, 0
331331 ac.sendMu.Unlock()
@@ -205,7 +205,7 @@ func TestProxyResizeUpdatesAttachmentViewport(t *testing.T) {
205 func TestProxiedTransactionalResizeUsesReceivedContentGeometry(t *testing.T) {205 func TestProxiedTransactionalResizeUsesReceivedContentGeometry(t *testing.T) {
206 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())206 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
207 ac.proxied = true207 ac.proxied = true
208- rc := d.attachCoordinator(sess, nil, ac, true)208+ rc := d.attachCoordinator(sess, ac, true)
209 lease := rc.attachmentLease(ac)209 lease := rc.attachmentLease(ac)
210 require.NotNil(t, lease)210 require.NotNil(t, lease)
211 211
@@ -262,7 +262,7 @@ func TestProxiedModeCannotChangeAcrossResume(t *testing.T) {
262 func TestOutputResetRebasesFullWindowAndSchedulesBaseZeroPaint(t *testing.T) {262 func TestOutputResetRebasesFullWindowAndSchedulesBaseZeroPaint(t *testing.T) {
263 d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())263 d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
264 ac.proxied = true264 ac.proxied = true
265- rc := d.attachCoordinator(sess, nil, ac, true)265+ rc := d.attachCoordinator(sess, ac, true)
266 lease := rc.attachmentLease(ac)266 lease := rc.attachmentLease(ac)
267 require.NotNil(t, lease)267 require.NotNil(t, lease)
268 268
@@ -304,7 +304,7 @@ func TestOutputResetRequiresProxiedActiveRoleAndStrictPayload(t *testing.T) {
304 t.Run(tt.name, func(t *testing.T) {304 t.Run(tt.name, func(t *testing.T) {
305 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())305 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
306 ac.proxied = tt.proxied306 ac.proxied = tt.proxied
307- d.attachCoordinator(sess, nil, ac, true)307+ d.attachCoordinator(sess, ac, true)
308 ac.sendMu.Lock()308 ac.sendMu.Lock()
309 ac.output.next, ac.output.acked = 4, 0309 ac.output.next, ac.output.acked = 4, 0
310 ac.sendMu.Unlock()310 ac.sendMu.Unlock()
@@ -325,7 +325,7 @@ func TestOutputResetRequiresProxiedActiveRoleAndStrictPayload(t *testing.T) {
325 func TestOutputResetRevalidatesTransportUnderSendLock(t *testing.T) {325 func TestOutputResetRevalidatesTransportUnderSendLock(t *testing.T) {
326 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())326 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
327 ac.proxied = true327 ac.proxied = true
328- d.attachCoordinator(sess, nil, ac, true)328+ d.attachCoordinator(sess, ac, true)
329 ac.sendMu.Lock()329 ac.sendMu.Lock()
330 ac.output.next, ac.output.acked = 4, 0330 ac.output.next, ac.output.acked = 4, 0
331 ac.sendMu.Unlock()331 ac.sendMu.Unlock()
M internal/usecase/daemon/proxy_input.go +1 -1
@@ -232,7 +232,7 @@ func (d *Daemon) focusDirProxied(sess *session, ac *attachedClient, dir layout.D
232232 if target.pane != nil {
233233 oldFocus = target.pane.id
234234 }
235- span, err := d.focusDirAt(sess, target.tab, target.pane, dir)
235+ span, err := d.focusDirAt(sess, target.tab, target.pane, dir, ac)
236236 if err == nil {
237237 if ac != nil {
238238 d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "proxy_input.go")
@@ -232,7 +232,7 @@ func (d *Daemon) focusDirProxied(sess *session, ac *attachedClient, dir layout.D
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")
M internal/usecase/daemon/proxy_input_test.go +7 -7
@@ -516,7 +516,7 @@ func TestProxyAttachedCommandRejectsTargetsAndExecutesExactActiveSession(t *test
516516 sess.tabs = append(sess.tabs, second)
517517 selectTestAttachmentTabLocked(sess, 0)
518518 sess.mu.Unlock()
519- d.attachCoordinator(sess, nil, ac, true)
519+ d.attachCoordinator(sess, ac, true)
520520
521521 send := func(request ports.CommandRequest) ports.CommandResult {
522522 payload, err := ports.MarshalCommandRequest(request)
@@ -620,7 +620,7 @@ func TestProxyPickerSelectionDialsOutsideLocksAndRevalidatesExactRoleAndKey(t *t
620620 dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once()
621621 d.remoteDialerFactory = factory
622622 d.remoteTransportMode = ports.RemoteTransportUDP
623- d.attachCoordinator(source, nil, ac, true)
623+ d.attachCoordinator(source, ac, true)
624624
625625 token := source.attachmentToken(ac, ac.transport())
626626 effect, admitted := ac.beginAttachmentEffect(token)
@@ -656,7 +656,7 @@ func TestProxyPickerSelectionDialsOutsideLocksAndRevalidatesExactRoleAndKey(t *t
656656
657657 func TestRemotePickerEnterRoutesStructuredKeyThroughProxyOwnership(t *testing.T) {
658658 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
659- d.attachCoordinator(local, nil, ac, true)
659+ d.attachCoordinator(local, ac, true)
660660 key := domain.RemoteSessionKey{Host: "arch", Name: "enter"}
661661 transport := newProxyTestTransport()
662662 transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
@@ -803,7 +803,7 @@ func TestRemotePickerRejectsReplacedLocalTab(t *testing.T) {
803803
804804 func TestRemotePickerCanReselectWarmProxyAfterReturningLocal(t *testing.T) {
805805 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
806- d.attachCoordinator(local, nil, ac, true)
806+ d.attachCoordinator(local, ac, true)
807807 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
808808 remote := newProxyTestTransport()
809809 remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
@@ -895,7 +895,7 @@ func TestConnectionLoopRetriesRoleResolutionAcrossHandoff(t *testing.T) {
895895 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
896896 client := newProxyTestTransport()
897897 ac.replaceTransport(client)
898- d.attachCoordinator(local, nil, ac, true)
898+ d.attachCoordinator(local, ac, true)
899899
900900 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
901901 remote := newProxyTestTransport()
@@ -934,7 +934,7 @@ func TestConnectionLoopCleansCurrentProxyAfterHandoffReceiveError(t *testing.T)
934934 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
935935 client := newProxyTestTransport()
936936 ac.replaceTransport(client)
937- d.attachCoordinator(local, nil, ac, true)
937+ d.attachCoordinator(local, ac, true)
938938
939939 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
940940 remote := newProxyTestTransport()
@@ -991,7 +991,7 @@ func TestConnectionLoopFollowsClientFromLocalToRemoteProxy(t *testing.T) {
991991 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
992992 client := newProxyTestTransport()
993993 ac.replaceTransport(client)
994- d.attachCoordinator(local, nil, ac, true)
994+ d.attachCoordinator(local, ac, true)
995995
996996 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
997997 remote := newProxyTestTransport()
@@ -516,7 +516,7 @@ func TestProxyAttachedCommandRejectsTargetsAndExecutesExactActiveSession(t *test
516 sess.tabs = append(sess.tabs, second)516 sess.tabs = append(sess.tabs, second)
517 selectTestAttachmentTabLocked(sess, 0)517 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)
@@ -620,7 +620,7 @@ 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.beginAttachmentEffect(token)626 effect, admitted := ac.beginAttachmentEffect(token)
@@ -656,7 +656,7 @@ func TestProxyPickerSelectionDialsOutsideLocksAndRevalidatesExactRoleAndKey(t *t
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)}
@@ -803,7 +803,7 @@ func TestRemotePickerRejectsReplacedLocalTab(t *testing.T) {
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)}
@@ -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()
@@ -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()
@@ -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()
M internal/usecase/daemon/proxy_lifecycle_test.go +80 -5
@@ -174,7 +174,7 @@ func TestProxyWarmLifecycleFollowsAttachmentTransitions(t *testing.T) {
174174 clock := newProxyLifecycleClock()
175175 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
176176 d.clock = clock
177- localCoordinator := d.attachCoordinator(local, nil, ac, true)
177+ localCoordinator := d.attachCoordinator(local, ac, true)
178178 localToken := attachmentToken(local, ac, ac.transport())
179179 localToken.lease = localCoordinator.attachmentLease(ac)
180180
@@ -466,7 +466,7 @@ func TestProxyLinkStateDisplayAndExactGeneration(t *testing.T) {
466466 require.Equal(t, proxy.name+" [dead]", proxy.statusSegments(false).session)
467467 }
468468
469-func TestIncomingDirectAttachTerminatesProxiedRemoteAttachment(t *testing.T) {
469+func TestIncomingDirectAttachPreservesProxiedRemoteAttachment(t *testing.T) {
470470 d := newTestDaemon(t, nil, newProxyLifecycleClock())
471471 sess := &session{sessionCore: sessionCore{id: "work", name: "work"}}
472472 oldTransport := newProxyTestTransport()
@@ -477,7 +477,7 @@ func TestIncomingDirectAttachTerminatesProxiedRemoteAttachment(t *testing.T) {
477477 d.mu.Lock()
478478 require.True(t, d.registerSessionLocked(sess))
479479 d.mu.Unlock()
480- d.attachCoordinator(sess, nil, old, true)
480+ d.attachCoordinator(sess, old, true)
481481
482482 newTransport := newProxyTestTransport()
483483 next := &attachedClient{tr: newTransport, output: newOutputStateStream()}
@@ -535,7 +535,7 @@ func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T)
535535 clock := newProxyLifecycleClock()
536536 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
537537 d.clock = clock
538- coordinator := d.attachCoordinator(local, nil, ac, true)
538+ coordinator := d.attachCoordinator(local, ac, true)
539539 localToken := attachmentToken(local, ac, ac.transport())
540540 localToken.lease = coordinator.attachmentLease(ac)
541541
@@ -1271,18 +1271,34 @@ func TestOpenProxySessionSameKeyWaiterCanCancel(t *testing.T) {
12711271
12721272 type warmHookClock struct {
12731273 timers chan *signalTimer
1274+
1275+ mu sync.Mutex
1276+ hook func()
12741277 }
12751278
12761279 func (c *warmHookClock) Now() time.Time { return time.Time{} }
12771280
12781281 func (c *warmHookClock) NewTimer(d time.Duration) ports.Timer {
12791282 timer := &signalTimer{ch: make(chan time.Time, 1), duration: d}
1283+ c.mu.Lock()
1284+ hook := c.hook
1285+ c.hook = nil
1286+ c.mu.Unlock()
1287+ if hook != nil {
1288+ hook()
1289+ }
12801290 if c.timers != nil {
12811291 c.timers <- timer
12821292 }
12831293 return timer
12841294 }
12851295
1296+func (c *warmHookClock) setHook(hook func()) {
1297+ c.mu.Lock()
1298+ c.hook = hook
1299+ c.mu.Unlock()
1300+}
1301+
12861302 func registerLifecycleProxy(t *testing.T, d *Daemon, key domain.RemoteSessionKey) *proxySession {
12871303 t.Helper()
12881304 p, err := newProxySession(key, domain.Size{Cols: 80, Rows: 24})
@@ -1338,6 +1354,65 @@ func armWarmTimer(t *testing.T, d *Daemon, p *proxySession, clock *warmHookClock
13381354 // a re-arm that loses its revalidation must leave the incumbent timer both
13391355 // installed and current, otherwise a registered proxy is left with no path out
13401356 // of the live registry.
1357+func TestArmProxyWarmKeepsExpiryPathWhenPublicationFails(t *testing.T) {
1358+ tests := []struct {
1359+ name string
1360+ race func(*Daemon, *proxySession) func()
1361+ }{
1362+ {
1363+ name: "fresh attachment publishes during arm",
1364+ race: func(_ *Daemon, p *proxySession) func() {
1365+ ac := &attachedClient{}
1366+ p.sessionCore.mu.Lock()
1367+ p.sessionCore.registerAttachmentLocked(ac)
1368+ p.sessionCore.mu.Unlock()
1369+ return func() {
1370+ p.sessionCore.mu.Lock()
1371+ p.sessionCore.unregisterAttachmentLocked(ac)
1372+ p.sessionCore.mu.Unlock()
1373+ }
1374+ },
1375+ },
1376+ {
1377+ name: "daemon shutdown publishes during arm",
1378+ race: func(d *Daemon, _ *proxySession) func() {
1379+ d.mu.Lock()
1380+ d.closing = true
1381+ d.mu.Unlock()
1382+ return func() {
1383+ d.mu.Lock()
1384+ d.closing = false
1385+ d.mu.Unlock()
1386+ }
1387+ },
1388+ },
1389+ }
1390+ for _, tt := range tests {
1391+ t.Run(tt.name, func(t *testing.T) {
1392+ d, p, clock := newProxyLifecycleFixture(t)
1393+ token, timer := armWarmTimer(t, d, p, clock)
1394+
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")
1398+ awaitTestValue(t, clock.timers, "raced re-arm never reached its clock")
1399+
1400+ retained, generation := proxyWarmToken(p)
1401+ require.NotNil(t, retained, "failed re-arm stripped the proxy of its only expiry path")
1402+ require.Same(t, token, retained, "failed re-arm replaced the armed expiry timer")
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")
1405+
1406+ require.NotNil(t, undo, "publication race did not run")
1407+ undo()
1408+ timer.ch <- time.Time{}
1409+ awaitTestCompletion(t, token.done, "retained warm timer never ran to completion")
1410+ require.False(t, proxyRegistered(d, p), "retained warm timer did not expire the dormant proxy")
1411+ require.True(t, proxyExpired(p), "expiry did not publish terminal lifecycle state")
1412+ awaitTestCompletion(t, d.done, "last proxy expiry did not complete the daemon")
1413+ })
1414+ }
1415+}
13411416
13421417 func TestDetachProxyIfCurrentTransportArmsExpiredProxy(t *testing.T) {
13431418 d, p, clock := newProxyLifecycleFixture(t)
@@ -1475,7 +1550,7 @@ func newProxyRoleDetachFixture(t *testing.T) (*Daemon, *proxySession, *attachedC
14751550 d.clock = clock
14761551 clientTransport := &closeTrackingTransport{}
14771552 ac.replaceTransport(clientTransport)
1478- localCoordinator := d.attachCoordinator(local, nil, ac, true)
1553+ localCoordinator := d.attachCoordinator(local, ac, true)
14791554 localToken := attachmentToken(local, ac, clientTransport)
14801555 localToken.lease = localCoordinator.attachmentLease(ac)
14811556 proxy := registerLifecycleProxy(t, d, domain.RemoteSessionKey{Host: "arch", Name: "detach"})
@@ -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
@@ -466,7 +466,7 @@ func TestProxyLinkStateDisplayAndExactGeneration(t *testing.T) {
466 require.Equal(t, proxy.name+" [dead]", proxy.statusSegments(false).session)466 require.Equal(t, proxy.name+" [dead]", proxy.statusSegments(false).session)
467 }467 }
468 468
469-func TestIncomingDirectAttachTerminatesProxiedRemoteAttachment(t *testing.T) {469+func TestIncomingDirectAttachPreservesProxiedRemoteAttachment(t *testing.T) {
470 d := newTestDaemon(t, nil, newProxyLifecycleClock())470 d := newTestDaemon(t, nil, newProxyLifecycleClock())
471 sess := &session{sessionCore: sessionCore{id: "work", name: "work"}}471 sess := &session{sessionCore: sessionCore{id: "work", name: "work"}}
472 oldTransport := newProxyTestTransport()472 oldTransport := newProxyTestTransport()
@@ -477,7 +477,7 @@ func TestIncomingDirectAttachTerminatesProxiedRemoteAttachment(t *testing.T) {
477 d.mu.Lock()477 d.mu.Lock()
478 require.True(t, d.registerSessionLocked(sess))478 require.True(t, d.registerSessionLocked(sess))
479 d.mu.Unlock()479 d.mu.Unlock()
480- d.attachCoordinator(sess, nil, old, true)480+ d.attachCoordinator(sess, old, true)
481 481
482 newTransport := newProxyTestTransport()482 newTransport := newProxyTestTransport()
483 next := &attachedClient{tr: newTransport, output: newOutputStateStream()}483 next := &attachedClient{tr: newTransport, output: newOutputStateStream()}
@@ -535,7 +535,7 @@ func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T)
535 clock := newProxyLifecycleClock()535 clock := newProxyLifecycleClock()
536 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())536 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
537 d.clock = clock537 d.clock = clock
538- coordinator := d.attachCoordinator(local, nil, ac, true)538+ coordinator := d.attachCoordinator(local, ac, true)
539 localToken := attachmentToken(local, ac, ac.transport())539 localToken := attachmentToken(local, ac, ac.transport())
540 localToken.lease = coordinator.attachmentLease(ac)540 localToken.lease = coordinator.attachmentLease(ac)
541 541
@@ -1271,18 +1271,34 @@ func TestOpenProxySessionSameKeyWaiterCanCancel(t *testing.T) {
1271 1271
1272 type warmHookClock struct {1272 type warmHookClock struct {
1273 timers chan *signalTimer1273 timers chan *signalTimer
1274+
1275+ mu sync.Mutex
1276+ hook func()
1274 }1277 }
1275 1278
1276 func (c *warmHookClock) Now() time.Time { return time.Time{} }1279 func (c *warmHookClock) Now() time.Time { return time.Time{} }
1277 1280
1278 func (c *warmHookClock) NewTimer(d time.Duration) ports.Timer {1281 func (c *warmHookClock) NewTimer(d time.Duration) ports.Timer {
1279 timer := &signalTimer{ch: make(chan time.Time, 1), duration: d}1282 timer := &signalTimer{ch: make(chan time.Time, 1), duration: d}
1283+ c.mu.Lock()
1284+ hook := c.hook
1285+ c.hook = nil
1286+ c.mu.Unlock()
1287+ if hook != nil {
1288+ hook()
1289+ }
1280 if c.timers != nil {1290 if c.timers != nil {
1281 c.timers <- timer1291 c.timers <- timer
1282 }1292 }
1283 return timer1293 return timer
1284 }1294 }
1285 1295
1296+func (c *warmHookClock) setHook(hook func()) {
1297+ c.mu.Lock()
1298+ c.hook = hook
1299+ c.mu.Unlock()
1300+}
1301+
1286 func registerLifecycleProxy(t *testing.T, d *Daemon, key domain.RemoteSessionKey) *proxySession {1302 func registerLifecycleProxy(t *testing.T, d *Daemon, key domain.RemoteSessionKey) *proxySession {
1287 t.Helper()1303 t.Helper()
1288 p, err := newProxySession(key, domain.Size{Cols: 80, Rows: 24})1304 p, err := newProxySession(key, domain.Size{Cols: 80, Rows: 24})
@@ -1338,6 +1354,65 @@ func armWarmTimer(t *testing.T, d *Daemon, p *proxySession, clock *warmHookClock
1338 // a re-arm that loses its revalidation must leave the incumbent timer both1354 // a re-arm that loses its revalidation must leave the incumbent timer both
1339 // installed and current, otherwise a registered proxy is left with no path out1355 // installed and current, otherwise a registered proxy is left with no path out
1340 // of the live registry.1356 // of the live registry.
1357+func TestArmProxyWarmKeepsExpiryPathWhenPublicationFails(t *testing.T) {
1358+ tests := []struct {
1359+ name string
1360+ race func(*Daemon, *proxySession) func()
1361+ }{
1362+ {
1363+ name: "fresh attachment publishes during arm",
1364+ race: func(_ *Daemon, p *proxySession) func() {
1365+ ac := &attachedClient{}
1366+ p.sessionCore.mu.Lock()
1367+ p.sessionCore.registerAttachmentLocked(ac)
1368+ p.sessionCore.mu.Unlock()
1369+ return func() {
1370+ p.sessionCore.mu.Lock()
1371+ p.sessionCore.unregisterAttachmentLocked(ac)
1372+ p.sessionCore.mu.Unlock()
1373+ }
1374+ },
1375+ },
1376+ {
1377+ name: "daemon shutdown publishes during arm",
1378+ race: func(d *Daemon, _ *proxySession) func() {
1379+ d.mu.Lock()
1380+ d.closing = true
1381+ d.mu.Unlock()
1382+ return func() {
1383+ d.mu.Lock()
1384+ d.closing = false
1385+ d.mu.Unlock()
1386+ }
1387+ },
1388+ },
1389+ }
1390+ for _, tt := range tests {
1391+ t.Run(tt.name, func(t *testing.T) {
1392+ d, p, clock := newProxyLifecycleFixture(t)
1393+ token, timer := armWarmTimer(t, d, p, clock)
1394+
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")
1398+ awaitTestValue(t, clock.timers, "raced re-arm never reached its clock")
1399+
1400+ retained, generation := proxyWarmToken(p)
1401+ require.NotNil(t, retained, "failed re-arm stripped the proxy of its only expiry path")
1402+ require.Same(t, token, retained, "failed re-arm replaced the armed expiry timer")
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")
1405+
1406+ require.NotNil(t, undo, "publication race did not run")
1407+ undo()
1408+ timer.ch <- time.Time{}
1409+ awaitTestCompletion(t, token.done, "retained warm timer never ran to completion")
1410+ require.False(t, proxyRegistered(d, p), "retained warm timer did not expire the dormant proxy")
1411+ require.True(t, proxyExpired(p), "expiry did not publish terminal lifecycle state")
1412+ awaitTestCompletion(t, d.done, "last proxy expiry did not complete the daemon")
1413+ })
1414+ }
1415+}
1341 1416
1342 func TestDetachProxyIfCurrentTransportArmsExpiredProxy(t *testing.T) {1417 func TestDetachProxyIfCurrentTransportArmsExpiredProxy(t *testing.T) {
1343 d, p, clock := newProxyLifecycleFixture(t)1418 d, p, clock := newProxyLifecycleFixture(t)
@@ -1475,7 +1550,7 @@ func newProxyRoleDetachFixture(t *testing.T) (*Daemon, *proxySession, *attachedC
1475 d.clock = clock1550 d.clock = clock
1476 clientTransport := &closeTrackingTransport{}1551 clientTransport := &closeTrackingTransport{}
1477 ac.replaceTransport(clientTransport)1552 ac.replaceTransport(clientTransport)
1478- localCoordinator := d.attachCoordinator(local, nil, ac, true)1553+ localCoordinator := d.attachCoordinator(local, ac, true)
1479 localToken := attachmentToken(local, ac, clientTransport)1554 localToken := attachmentToken(local, ac, clientTransport)
1480 localToken.lease = localCoordinator.attachmentLease(ac)1555 localToken.lease = localCoordinator.attachmentLease(ac)
1481 proxy := registerLifecycleProxy(t, d, domain.RemoteSessionKey{Host: "arch", Name: "detach"})1556 proxy := registerLifecycleProxy(t, d, domain.RemoteSessionKey{Host: "arch", Name: "detach"})
M internal/usecase/daemon/proxy_link.go +21 -11
@@ -651,20 +651,27 @@ func (d *Daemon) runProxyTransport(ctx context.Context, p *proxySession, generat
651651 }
652652 }
653653
654-// handleProxySideEffect forwards a stateless remote effect to the exact local
655-// attachment that currently owns this proxy. Connection admission and transport
656-// incarnation checks make a stale handoff a harmless drop rather than a send
657-// to the client's next session.
654+// handleProxySideEffect fans out a stateless remote effect to every attachment
655+// currently owning this proxy. Each attachment gets an independent capability,
656+// effect ticket, output snapshot, and exact transport-incarnation fence.
658657 func (d *Daemon) handleProxySideEffect(p *proxySession, generation uint64, out ports.Output) error {
659658 if p == nil || !p.currentLinkGeneration(generation) {
660659 return nil
661660 }
662- attachments := snapshotAttachmentSession(p)
663- if len(attachments) == 0 {
664- return nil
661+ var sendErr error
662+ for _, ac := range snapshotAttachmentSession(p) {
663+ if !p.currentLinkGeneration(generation) {
664+ break
665+ }
666+ if err := d.sendProxySideEffect(p, generation, ac, out); err != nil && sendErr == nil {
667+ sendErr = err
668+ }
665669 }
666- ac := attachments[0]
667- if ac.output == nil {
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) {
668675 return nil
669676 }
670677 expected := ac.transportSnapshot()
@@ -672,7 +679,7 @@ func (d *Daemon) handleProxySideEffect(p *proxySession, generation uint64, out p
672679 return nil
673680 }
674681 token := attachmentToken(p, ac, expected.transport)
675- if token.sess != p {
682+ if token.sess != p || !token.current() {
676683 return nil
677684 }
678685 ticket, admitted := ac.beginAttachmentEffect(token)
@@ -680,10 +687,13 @@ func (d *Daemon) handleProxySideEffect(p *proxySession, generation uint64, out p
680687 return nil
681688 }
682689 defer ticket.End()
683- if !p.currentLinkGeneration(generation) {
690+ if !p.currentLinkGeneration(generation) || !token.current() {
684691 return nil
685692 }
686693 frame := ac.output.sideEffect(out.Data, ac.echoAck.Load())
694+ if !p.currentLinkGeneration(generation) || !token.current() {
695+ return nil
696+ }
687697 if err := ac.sendExpectedTransportForAttachment(expected, frame, ticket); err != nil {
688698 if errors.Is(err, errAttachmentTransition) {
689699 return nil
@@ -651,20 +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. Connection 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- attachments := snapshotAttachmentSession(p)661+ var sendErr error
663- if len(attachments) == 0 {662+ for _, ac := range snapshotAttachmentSession(p) {
664- return nil663+ if !p.currentLinkGeneration(generation) {
664+ break
665+ }
666+ if err := d.sendProxySideEffect(p, generation, ac, out); err != nil && sendErr == nil {
667+ sendErr = err
668+ }
665 }669 }
666- ac := attachments[0]670+ return sendErr
667- if ac.output == nil {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) {
668 return nil675 return nil
669 }676 }
670 expected := ac.transportSnapshot()677 expected := ac.transportSnapshot()
@@ -672,7 +679,7 @@ func (d *Daemon) handleProxySideEffect(p *proxySession, generation uint64, out p
672 return nil679 return nil
673 }680 }
674 token := attachmentToken(p, ac, expected.transport)681 token := attachmentToken(p, ac, expected.transport)
675- if token.sess != p {682+ if token.sess != p || !token.current() {
676 return nil683 return nil
677 }684 }
678 ticket, admitted := ac.beginAttachmentEffect(token)685 ticket, admitted := ac.beginAttachmentEffect(token)
@@ -680,10 +687,13 @@ func (d *Daemon) handleProxySideEffect(p *proxySession, generation uint64, out p
680 return nil687 return nil
681 }688 }
682 defer ticket.End()689 defer ticket.End()
683- if !p.currentLinkGeneration(generation) {690+ if !p.currentLinkGeneration(generation) || !token.current() {
684 return nil691 return nil
685 }692 }
686 frame := ac.output.sideEffect(out.Data, ac.echoAck.Load())693 frame := ac.output.sideEffect(out.Data, ac.echoAck.Load())
694+ if !p.currentLinkGeneration(generation) || !token.current() {
695+ return nil
696+ }
687 if err := ac.sendExpectedTransportForAttachment(expected, frame, ticket); err != nil {697 if err := ac.sendExpectedTransportForAttachment(expected, frame, ticket); err != nil {
688 if errors.Is(err, errAttachmentTransition) {698 if errors.Is(err, errAttachmentTransition) {
689 return nil699 return nil
M internal/usecase/daemon/proxy_output_test.go +63 -0
@@ -987,6 +987,69 @@ func TestProxySideEffectForwardsOSC52ToCurrentThinClient(t *testing.T) {
987987 }
988988 }
989989
990+func TestProxySideEffectFansOutToEveryActiveAttachment(t *testing.T) {
991+ d := newTestDaemon(t, nil, stubClock{})
992+ remote := newProxyTestTransport()
993+ firstTransport := newProxyTestTransport()
994+ proxy, first := newAttachedProxyFixture(t, d, firstTransport, remote)
995+ secondTransport := newProxyTestTransport()
996+ second := &attachedClient{tr: secondTransport, output: newOutputStateStream()}
997+ second.setSession(proxy)
998+ proxy.sessionCore.mu.Lock()
999+ require.True(t, proxy.sessionCore.registerAttachmentLocked(second))
1000+ proxy.sessionCore.mu.Unlock()
1001+ first.echoAck.Store(11)
1002+ second.echoAck.Store(22)
1003+
1004+ result, err := d.handleLinkFrame(proxy, 1, proxySideEffectFrame([]byte("effect")))
1005+ require.Equal(t, proxyLinkResume, result)
1006+ require.NoError(t, err)
1007+ for _, tc := range []struct {
1008+ name string
1009+ ch <-chan ports.Frame
1010+ ack uint64
1011+ }{
1012+ {name: "first", ch: firstTransport.sent, ack: 11},
1013+ {name: "second", ch: secondTransport.sent, ack: 22},
1014+ } {
1015+ t.Run(tc.name, func(t *testing.T) {
1016+ frame := awaitTestValue(t, tc.ch, tc.name+" attachment did not receive side effect")
1017+ out, err := ports.UnmarshalOutput(frame.Payload)
1018+ require.NoError(t, err)
1019+ require.Equal(t, tc.ack, out.EchoAck)
1020+ require.Equal(t, []byte("effect"), out.Data)
1021+ })
1022+ }
1023+}
1024+
1025+func TestProxySideEffectSkipsStaleAttachmentAndForwardsLiveAttachment(t *testing.T) {
1026+ d := newTestDaemon(t, nil, stubClock{})
1027+ remote := newProxyTestTransport()
1028+ staleTransport := newProxyTestTransport()
1029+ proxy, stale := newAttachedProxyFixture(t, d, staleTransport, remote)
1030+ liveTransport := newProxyTestTransport()
1031+ live := &attachedClient{tr: liveTransport, output: newOutputStateStream()}
1032+ live.setSession(proxy)
1033+ proxy.sessionCore.mu.Lock()
1034+ require.True(t, proxy.sessionCore.registerAttachmentLocked(live))
1035+ proxy.sessionCore.mu.Unlock()
1036+ staleToken := attachmentToken(proxy, stale, stale.transport())
1037+ staleEffect, admitted := stale.beginAttachmentEffect(staleToken)
1038+ require.True(t, admitted)
1039+ staleEffect.End()
1040+ stale.connectionGeneration.Add(1)
1041+
1042+ result, err := d.handleLinkFrame(proxy, 1, proxySideEffectFrame([]byte("effect")))
1043+ require.Equal(t, proxyLinkResume, result)
1044+ require.NoError(t, err)
1045+ awaitTestValue(t, liveTransport.sent, "live attachment did not receive side effect")
1046+ select {
1047+ case frame := <-staleTransport.sent:
1048+ t.Fatalf("stale attachment received side effect: %v", frame.Type)
1049+ default:
1050+ }
1051+}
1052+
9901053 func TestProxySideEffectRejectsStateBearingAndMalformedMsgOutput(t *testing.T) {
9911054 tests := []struct {
9921055 name string
@@ -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
M internal/usecase/daemon/proxy_render_test.go +1 -1
@@ -101,7 +101,7 @@ func TestProxyRenderPaintsAfterLocalToRemoteHandoff(t *testing.T) {
101101
102102 func TestProxyRenderTransitionsPaintBothLocalAndRemoteSessions(t *testing.T) {
103103 d, local, ac, sent := newManualSessionWithPTYs(t, newQuietPTY())
104- localCoordinator := d.attachCoordinator(local, nil, ac, true)
104+ localCoordinator := d.attachCoordinator(local, ac, true)
105105 localToken := attachmentToken(local, ac, ac.transport())
106106 localToken.lease = localCoordinator.attachmentLease(ac)
107107
@@ -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
M internal/usecase/daemon/render.go +1 -1
@@ -404,7 +404,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
404404 hasFloating := tb.floating.state == floatingVisible && floating != nil
405405 tb.mu.Unlock()
406406 for _, id := range titleIDs {
407- d.refreshPaneTitle(sess, id)
407+ d.refreshPaneTitle(sess, id, tb)
408408 }
409409 if hasFloating {
410410 d.refreshPaneDisplayTitle(sess, floating, false)
@@ -404,7 +404,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
404 hasFloating := tb.floating.state == floatingVisible && floating != nil404 hasFloating := tb.floating.state == floatingVisible && floating != nil
405 tb.mu.Unlock()405 tb.mu.Unlock()
406 for _, id := range titleIDs {406 for _, id := range titleIDs {
407- d.refreshPaneTitle(sess, id)407+ d.refreshPaneTitle(sess, id, tb)
408 }408 }
409 if hasFloating {409 if hasFloating {
410 d.refreshPaneDisplayTitle(sess, floating, false)410 d.refreshPaneDisplayTitle(sess, floating, false)
M internal/usecase/daemon/render_coordinator.go +258 -64
@@ -25,9 +25,23 @@ type renderCoordinator struct {
2525 // viewers may consume a coalesced target snapshot while output is
2626 // still ACK-blocked.
2727 pendingPreview bool
28- // ackDeferred records that an expired deadline or watchdog was blocked by
29- // output-window capacity. ACK notifications may flush only this state.
30- ackDeferred bool
28+ // ackDeferred records that at least one attachment's render wake was
29+ // blocked by output-window capacity. ACK notifications use ackDeferredFor
30+ // to flush only the acknowledging attachment's work.
31+ ackDeferred bool
32+ ackDeferredFor map[*attachedClient]bool
33+ // pendingGeneration identifies the latest coalesced session mutation.
34+ // delivered records which attachment incarnations have consumed it, so a
35+ // blocked peer can catch up without replaying the wake to healthy peers.
36+ pendingGeneration uint64
37+ delivered map[*attachedClient]uint64
38+ // pendingShared is set by session-owned producers. Attachment-owned
39+ // invalidations retain only their exact target lease here.
40+ pendingShared bool
41+ pendingTargets map[*attachedClient]*attachmentLease
42+ // ackReadyFor is production's attachment-scoped ACK-window probe. The
43+ // legacy options callback remains for coordinator-only tests.
44+ ackReadyFor func(*attachedClient) bool
3145 // ackBlocked retains the first rejected ACK-capacity probe until the
3246 // matching consume or lifecycle closure releases that exact interval.
3347 ackBlocked *ackBlockedSpan
@@ -161,9 +175,14 @@ func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpoch(source *att
161175 }
162176 reservation := &renderInvalidationReservation{coordinator: c, invalidation: inv, onInvalidate: c.opts.onInvalidate}
163177 c.metrics.invalidations.Add(1)
178+ c.pendingGeneration++
179+ if c.pendingGeneration == 0 {
180+ c.pendingGeneration = 1
181+ }
164182 wasPending, wasUrgent, wasPreviewPending := c.pending, c.pendingUrgent, c.pendingPreview
165183 if !wasPending {
166184 c.ackDeferred = false
185+ c.ackDeferredFor = nil
167186 c.deadlineDue = false
168187 if c.opts.observer != nil {
169188 c.queueMarked = true
@@ -173,11 +192,26 @@ func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpoch(source *att
173192 }
174193 }
175194 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+ }
176210 c.pendingReset = c.pendingReset || inv.reset
177211 c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent
178212 c.pendingPreview = c.pendingPreview || len(c.previewWakes) != 0
179213 c.coalesced++
180- reservation.arm = !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue
214+ reservation.arm = !wasPending || !c.armed || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue
181215 if reservation.arm {
182216 _, reservation.old = c.normalLane.replaceLocked()
183217 }
@@ -250,8 +284,8 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie
250284 }
251285
252286 // notifyAck reports that the client acknowledged an output state, releasing
253-// at most one deadline/watchdog-deferred wake. It never bypasses an unexpired
254-// normal or urgent deadline.
287+// at most one attachment's blocked wake. It never bypasses an unexpired normal
288+// or urgent deadline.
255289 func (c *renderCoordinator) notifyAck() { c.notifyAckForLease(nil) }
256290
257291 // notifyAckForLease prevents an acknowledgement captured by an obsolete active
@@ -259,11 +293,16 @@ func (c *renderCoordinator) notifyAck() { c.notifyAckForLease(nil) }
259293 // reserved for coordinator-owned/direct test callers.
260294 func (c *renderCoordinator) notifyAckForLease(lease *attachmentLease) {
261295 c.mu.Lock()
262- 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 {
263302 c.mu.Unlock()
264303 return
265304 }
266- if (!c.ackDeferred && !c.deadlineDue) || !c.pending {
305+ if !c.pending {
267306 c.mu.Unlock()
268307 return
269308 }
@@ -541,6 +580,12 @@ func (c *renderCoordinator) installLeaseLocked(ac *attachedClient, ready bool) *
541580 }
542581 lease := &attachmentLease{attachment: ac, ready: ready, active: true}
543582 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+ }
544589 return lease
545590 }
546591
@@ -667,6 +712,8 @@ func (c *renderCoordinator) beginDetachLocked(ac *attachedClient) renderLifecycl
667712 lease.active = false
668713 delete(c.leases, ac)
669714 }
715+ delete(c.delivered, ac)
716+ delete(c.ackDeferredFor, ac)
670717 // Shared pending output belongs to the session, not the detached
671718 // attachment. Keep it alive while another attachment remains registered.
672719 if len(c.attachments) != 0 {
@@ -686,7 +733,11 @@ func (c *renderCoordinator) resetAttachmentLifecycleLocked() renderLifecycleClea
686733 c.pendingReset = false
687734 c.pendingUrgent = false
688735 c.ackDeferred = false
736+ c.ackDeferredFor = nil
737+ c.delivered = nil
689738 c.pendingPreview = false
739+ c.pendingShared = false
740+ c.pendingTargets = nil
690741 c.coalesced = 0
691742 _, timer := c.normalLane.replaceLocked()
692743 c.armed = false
@@ -728,7 +779,11 @@ func (c *renderCoordinator) beginSessionTeardown() renderLifecycleCleanup {
728779 _, resizeTimer := c.resizeLane.replaceLocked()
729780 _, retryTimer := c.retryLane.replaceLocked()
730781 c.ackDeferred = false
782+ c.ackDeferredFor = nil
783+ c.delivered = nil
731784 c.pendingPreview = false
785+ c.pendingShared = false
786+ c.pendingTargets = nil
732787 _, timer := c.normalLane.replaceLocked()
733788 c.armed = false
734789 workers := c.detachSyncTimersLocked()
@@ -804,35 +859,73 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
804859 c.fireWithTimerToken(token, gen, watchdog, deadline)
805860 }
806861
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+
807873 // fireFromTimer consumes a normal deadline using its exact immutable token.
808874 func (c *renderCoordinator) fireFromTimer(token *timerToken, watchdog, deadline bool) {
809875 c.fireWithTimerToken(token, token.generation, watchdog, deadline)
810876 }
811877
812878 func (c *renderCoordinator) fireWithTimerToken(token *timerToken, gen uint64, watchdog, deadline bool) {
813- // Readiness probes enter the attachment send path, which may in turn read
814- // coordinator metadata. Never hold c.mu across that external callback.
879+ c.fireWithTimerTokenAndLease(token, gen, watchdog, deadline, nil)
880+}
881+
882+func (c *renderCoordinator) attachmentAckReady(ac *attachedClient) bool {
883+ if c.ackReadyFor != nil {
884+ return c.ackReadyFor(ac)
885+ }
886+ if c.opts.ackReady != nil {
887+ return c.opts.ackReady()
888+ }
889+ return true
890+}
891+
892+func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen uint64, watchdog, deadline bool, syncLease *attachmentLease) {
893+ // Readiness probes enter each attachment's send path, which may in turn
894+ // read coordinator metadata. Never hold c.mu across those callbacks.
815895 c.mu.Lock()
816896 if !c.fireValidLocked(token, gen, watchdog) {
817897 c.mu.Unlock()
818898 return
819899 }
820- // Welcome holds attachment.sendMu while its transport Send is in flight.
821- // Do not probe ACK capacity through that lock before this incarnation is
822- // ready: a deadline that expires during the handshake must complete and
823- // leave work pending, rather than wedging behind Welcome.
824- hasLiveAttachment, allAttachmentsReady := false, true
825- for _, lease := range c.leases {
826- if lease.active {
827- hasLiveAttachment = true
828- allAttachmentsReady = allAttachmentsReady && lease.ready
900+ if c.pendingGeneration == 0 {
901+ c.pendingGeneration = 1
902+ }
903+ leases := make(map[*attachedClient]*attachmentLease, len(c.leases))
904+ for ac, lease := range c.leases {
905+ if lease != nil && lease.active {
906+ leases[ac] = lease
829907 }
830908 }
831- handshakePending := hasLiveAttachment && !allAttachmentsReady
832- previewOnly := !hasLiveAttachment && len(c.previewWakes) != 0
833- ackReady := c.opts.ackReady
909+ previewOnly := len(leases) == 0 && len(c.previewWakes) != 0
910+ legacyAckReady := c.opts.ackReady
834911 c.mu.Unlock()
835- ready := !handshakePending && (previewOnly || 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+ }
836929
837930 // The readiness callback can detach, replace, or publish a sync batch.
838931 // syncGateOpenLocked retries predicate evaluation on every registry change
@@ -850,70 +943,171 @@ func (c *renderCoordinator) fireWithTimerToken(token *timerToken, gen uint64, wa
850943 c.mu.Unlock()
851944 return
852945 }
853- // Shared invalidations are consumed once and dispatched as a shared wake.
854- // Capture each live lease so a park/resume of the same attachment object
855- // cannot let this already-dispatched wake paint the new connection.
946+
947+ // Re-snapshot after every external readiness probe. A replacement may have
948+ // retired one of the leases we probed while the callback was unlocked.
949+ leases = make(map[*attachedClient]*attachmentLease, len(c.leases))
950+ for ac, lease := range c.leases {
951+ if lease != nil && lease.active {
952+ leases[ac] = lease
953+ }
954+ }
856955 var attachmentLeases map[*attachedClient]*attachmentLease
857- if len(c.leases) != 0 {
858- attachmentLeases = make(map[*attachedClient]*attachmentLease, len(c.leases))
859- for ac, lease := range c.leases {
860- if lease != nil && lease.active {
956+ if len(leases) != 0 {
957+ attachmentLeases = make(map[*attachedClient]*attachmentLease, len(leases))
958+ }
959+ blockedByAck := false
960+ allDelivered := true
961+ if len(leases) != 0 {
962+ for ac, lease := range leases {
963+ if !c.pendingShared && len(c.pendingTargets) != 0 {
964+ targetLease, targeted := c.pendingTargets[ac]
965+ if !targeted || targetLease != lease {
966+ continue
967+ }
968+ }
969+ if c.delivered[ac] == c.pendingGeneration {
970+ continue
971+ }
972+ allDelivered = false
973+ if !lease.ready {
974+ continue
975+ }
976+ canReady, probed := ready[lease]
977+ if !probed {
978+ continue
979+ }
980+ if canReady {
861981 attachmentLeases[ac] = lease
982+ if c.ackDeferredFor != nil {
983+ delete(c.ackDeferredFor, ac)
984+ }
985+ continue
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
8621010 }
8631011 }
8641012 }
1013+ if len(leases) == 0 && legacyOutput {
1014+ allDelivered = true
1015+ }
8651016 w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, attachmentLeases: attachmentLeases, watchdog: watchdog}
866- if !ready || handshakePending {
867- var ackStart *ackBlockedSpan
868- if !ready && deadline {
869- c.ackDeferred = true
870- if c.ackBlocked == nil && c.opts.observer != nil {
871- // Install the span while coordinator state is owned. Its Start
872- // publication runs unlocked, and its own handoff defers End until
873- // that call has returned.
874- ackStart = newACKBlockedSpan(c.opts.observer)
875- c.ackBlocked = ackStart
876- }
1017+ if syncLease != nil {
1018+ if selected, ok := attachmentLeases[syncLease.attachment]; ok && selected == syncLease {
1019+ w.lease = syncLease
8771020 }
878- 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 {
8791036 c.mu.Unlock()
1037+ stopDetachedTimer(worker)
8801038 if ackStart != nil {
8811039 ackStart.publishStart()
8821040 }
883- c.notifyPreviews(w, preview, previews)
1041+ c.notifyPreviews(w, previews)
8841042 return
8851043 }
886- preview, previews := c.takePendingPreviewsLocked()
887- c.metrics.wakes.Add(1)
888- c.metrics.coalesced.Add(uint64(w.coalesced))
889- if !w.urgent {
890- if w.coalesced > 1 {
891- c.outputPressure += w.coalesced - 1
892- if c.outputPressure > int((maxOutputRenderDeadline-minOutputRenderDeadline)/time.Millisecond) {
893- c.outputPressure = int((maxOutputRenderDeadline - minOutputRenderDeadline) / time.Millisecond)
1044+
1045+ if outputReady {
1046+ for ac := range attachmentLeases {
1047+ if c.delivered == nil {
1048+ c.delivered = make(map[*attachedClient]uint64)
1049+ }
1050+ c.delivered[ac] = c.pendingGeneration
1051+ }
1052+ }
1053+ if len(leases) != 0 {
1054+ allDelivered = true
1055+ for ac, lease := range leases {
1056+ if !c.pendingShared && len(c.pendingTargets) != 0 {
1057+ targetLease, targeted := c.pendingTargets[ac]
1058+ if !targeted || targetLease != lease {
1059+ continue
1060+ }
1061+ }
1062+ if c.delivered[ac] != c.pendingGeneration {
1063+ allDelivered = false
1064+ break
1065+ }
1066+ }
1067+ }
1068+ complete := allDelivered || (len(leases) == 0 && outputReady)
1069+ if complete {
1070+ c.pending, c.pendingReset, c.pendingUrgent, c.ackDeferred, c.deadlineDue, c.coalesced, c.armed = false, false, false, false, false, 0, false
1071+ c.ackDeferredFor = nil
1072+ c.delivered = nil
1073+ c.pendingShared = false
1074+ c.pendingTargets = nil
1075+ }
1076+ if outputReady {
1077+ c.metrics.wakes.Add(1)
1078+ c.metrics.coalesced.Add(uint64(w.coalesced))
1079+ if !w.urgent {
1080+ if w.coalesced > 1 {
1081+ c.outputPressure += w.coalesced - 1
1082+ if c.outputPressure > int((maxOutputRenderDeadline-minOutputRenderDeadline)/time.Millisecond) {
1083+ c.outputPressure = int((maxOutputRenderDeadline - minOutputRenderDeadline) / time.Millisecond)
1084+ }
1085+ } else if c.outputPressure > 0 {
1086+ c.outputPressure--
8941087 }
895- } else if c.outputPressure > 0 {
896- c.outputPressure--
8971088 }
8981089 }
899- worker := c.detachNormalTimerLocked()
900- c.pending, c.pendingReset, c.pendingUrgent, c.ackDeferred, c.deadlineDue, c.coalesced, c.armed = false, false, false, false, false, 0, false
9011090 observer, queueCorrelation, queueMarked := c.opts.observer, c.queueCorrelation, c.queueMarked
9021091 ackBlocked := c.ackBlocked
903- c.queueMarked, c.ackBlocked = false, nil
1092+ if complete {
1093+ c.queueMarked, c.ackBlocked = false, nil
1094+ }
9041095 wake := c.opts.wake
9051096 c.mu.Unlock()
9061097 stopDetachedTimer(worker)
907- if queueMarked && observer != nil {
1098+ if ackStart != nil {
1099+ ackStart.publishStart()
1100+ }
1101+ if complete && queueMarked && observer != nil {
9081102 observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueDequeued, 0, true))
9091103 }
910- if ackBlocked != nil {
1104+ if complete && ackBlocked != nil {
9111105 ackBlocked.finish(true)
9121106 }
913- if wake != nil {
1107+ if outputReady && wake != nil {
9141108 wake(w)
9151109 }
916- c.notifyPreviews(w, preview, previews)
1110+ c.notifyPreviews(w, previews)
9171111 }
9181112
9191113 func (c *renderCoordinator) fireValidLocked(token *timerToken, gen uint64, watchdog bool) bool {
@@ -957,19 +1151,19 @@ func (c *renderCoordinator) syncGateOpenLocked() bool {
9571151
9581152 // takePendingPreviewsLocked snapshots and consumes picker preview delivery
9591153 // for this target generation. Preview callbacks are never ACK-gated.
960-func (c *renderCoordinator) takePendingPreviewsLocked() (func(renderWake), []func(renderWake)) {
1154+func (c *renderCoordinator) takePendingPreviewsLocked() []func(renderWake) {
9611155 if !c.pendingPreview {
962- return nil, nil
1156+ return nil
9631157 }
9641158 c.pendingPreview = false
9651159 previews := make([]func(renderWake), 0, len(c.previewWakes))
9661160 for _, subscription := range c.previewWakes {
9671161 previews = append(previews, subscription.fn)
9681162 }
969- return nil, previews
1163+ return previews
9701164 }
9711165
972-func (c *renderCoordinator) notifyPreviews(w renderWake, _ func(renderWake), previews []func(renderWake)) {
1166+func (c *renderCoordinator) notifyPreviews(w renderWake, previews []func(renderWake)) {
9731167 for _, fn := range previews {
9741168 fn(w)
9751169 }
@@ -25,9 +25,23 @@ type renderCoordinator struct {
25 // viewers may consume a coalesced target snapshot while output 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
@@ -161,9 +175,14 @@ func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpoch(source *att
161 }175 }
162 reservation := &renderInvalidationReservation{coordinator: c, invalidation: inv, onInvalidate: c.opts.onInvalidate}176 reservation := &renderInvalidationReservation{coordinator: c, invalidation: inv, onInvalidate: c.opts.onInvalidate}
163 c.metrics.invalidations.Add(1)177 c.metrics.invalidations.Add(1)
178+ c.pendingGeneration++
179+ if c.pendingGeneration == 0 {
180+ c.pendingGeneration = 1
181+ }
164 wasPending, wasUrgent, wasPreviewPending := c.pending, c.pendingUrgent, c.pendingPreview182 wasPending, wasUrgent, wasPreviewPending := c.pending, c.pendingUrgent, c.pendingPreview
165 if !wasPending {183 if !wasPending {
166 c.ackDeferred = false184 c.ackDeferred = false
185+ c.ackDeferredFor = nil
167 c.deadlineDue = false186 c.deadlineDue = false
168 if c.opts.observer != nil {187 if c.opts.observer != nil {
169 c.queueMarked = true188 c.queueMarked = true
@@ -173,11 +192,26 @@ func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpoch(source *att
173 }192 }
174 }193 }
175 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+ }
176 c.pendingReset = c.pendingReset || inv.reset210 c.pendingReset = c.pendingReset || inv.reset
177 c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent211 c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent
178 c.pendingPreview = c.pendingPreview || len(c.previewWakes) != 0212 c.pendingPreview = c.pendingPreview || len(c.previewWakes) != 0
179 c.coalesced++213 c.coalesced++
180- reservation.arm = !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue214+ reservation.arm = !wasPending || !c.armed || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue
181 if reservation.arm {215 if reservation.arm {
182 _, reservation.old = c.normalLane.replaceLocked()216 _, reservation.old = c.normalLane.replaceLocked()
183 }217 }
@@ -250,8 +284,8 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie
250 }284 }
251 285
252 // notifyAck reports that the client acknowledged an output state, releasing286 // notifyAck reports that the client acknowledged an output state, releasing
253-// 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
254-// normal or urgent deadline.288+// or urgent deadline.
255 func (c *renderCoordinator) notifyAck() { c.notifyAckForLease(nil) }289 func (c *renderCoordinator) notifyAck() { c.notifyAckForLease(nil) }
256 290
257 // notifyAckForLease prevents an acknowledgement captured by an obsolete active291 // notifyAckForLease prevents an acknowledgement captured by an obsolete active
@@ -259,11 +293,16 @@ func (c *renderCoordinator) notifyAck() { c.notifyAckForLease(nil) }
259 // reserved for coordinator-owned/direct test callers.293 // reserved for coordinator-owned/direct test callers.
260 func (c *renderCoordinator) notifyAckForLease(lease *attachmentLease) {294 func (c *renderCoordinator) notifyAckForLease(lease *attachmentLease) {
261 c.mu.Lock()295 c.mu.Lock()
262- 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 {
263 c.mu.Unlock()302 c.mu.Unlock()
264 return303 return
265 }304 }
266- if (!c.ackDeferred && !c.deadlineDue) || !c.pending {305+ if !c.pending {
267 c.mu.Unlock()306 c.mu.Unlock()
268 return307 return
269 }308 }
@@ -541,6 +580,12 @@ func (c *renderCoordinator) installLeaseLocked(ac *attachedClient, ready bool) *
541 }580 }
542 lease := &attachmentLease{attachment: ac, ready: ready, active: true}581 lease := &attachmentLease{attachment: ac, ready: ready, active: true}
543 c.leases[ac] = lease582 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+ }
544 return lease589 return lease
545 }590 }
546 591
@@ -667,6 +712,8 @@ func (c *renderCoordinator) beginDetachLocked(ac *attachedClient) renderLifecycl
667 lease.active = false712 lease.active = false
668 delete(c.leases, ac)713 delete(c.leases, ac)
669 }714 }
715+ delete(c.delivered, ac)
716+ delete(c.ackDeferredFor, ac)
670 // Shared pending output belongs to the session, not the detached717 // Shared pending output belongs to the session, not the detached
671 // attachment. Keep it alive while another attachment remains registered.718 // attachment. Keep it alive while another attachment remains registered.
672 if len(c.attachments) != 0 {719 if len(c.attachments) != 0 {
@@ -686,7 +733,11 @@ func (c *renderCoordinator) resetAttachmentLifecycleLocked() renderLifecycleClea
686 c.pendingReset = false733 c.pendingReset = false
687 c.pendingUrgent = false734 c.pendingUrgent = false
688 c.ackDeferred = false735 c.ackDeferred = false
736+ c.ackDeferredFor = nil
737+ c.delivered = nil
689 c.pendingPreview = false738 c.pendingPreview = false
739+ c.pendingShared = false
740+ c.pendingTargets = nil
690 c.coalesced = 0741 c.coalesced = 0
691 _, timer := c.normalLane.replaceLocked()742 _, timer := c.normalLane.replaceLocked()
692 c.armed = false743 c.armed = false
@@ -728,7 +779,11 @@ func (c *renderCoordinator) beginSessionTeardown() renderLifecycleCleanup {
728 _, resizeTimer := c.resizeLane.replaceLocked()779 _, resizeTimer := c.resizeLane.replaceLocked()
729 _, retryTimer := c.retryLane.replaceLocked()780 _, retryTimer := c.retryLane.replaceLocked()
730 c.ackDeferred = false781 c.ackDeferred = false
782+ c.ackDeferredFor = nil
783+ c.delivered = nil
731 c.pendingPreview = false784 c.pendingPreview = false
785+ c.pendingShared = false
786+ c.pendingTargets = nil
732 _, timer := c.normalLane.replaceLocked()787 _, timer := c.normalLane.replaceLocked()
733 c.armed = false788 c.armed = false
734 workers := c.detachSyncTimersLocked()789 workers := c.detachSyncTimersLocked()
@@ -804,35 +859,73 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
804 c.fireWithTimerToken(token, gen, watchdog, deadline)859 c.fireWithTimerToken(token, gen, watchdog, deadline)
805 }860 }
806 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+
807 // fireFromTimer consumes a normal deadline using its exact immutable token.873 // fireFromTimer consumes a normal deadline using its exact immutable token.
808 func (c *renderCoordinator) fireFromTimer(token *timerToken, watchdog, deadline bool) {874 func (c *renderCoordinator) fireFromTimer(token *timerToken, watchdog, deadline bool) {
809 c.fireWithTimerToken(token, token.generation, watchdog, deadline)875 c.fireWithTimerToken(token, token.generation, watchdog, deadline)
810 }876 }
811 877
812 func (c *renderCoordinator) fireWithTimerToken(token *timerToken, gen uint64, watchdog, deadline bool) {878 func (c *renderCoordinator) fireWithTimerToken(token *timerToken, gen uint64, watchdog, deadline bool) {
813- // Readiness probes enter the attachment send path, which may in turn read879+ c.fireWithTimerTokenAndLease(token, gen, watchdog, deadline, nil)
814- // 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.
815 c.mu.Lock()895 c.mu.Lock()
816 if !c.fireValidLocked(token, gen, watchdog) {896 if !c.fireValidLocked(token, gen, watchdog) {
817 c.mu.Unlock()897 c.mu.Unlock()
818 return898 return
819 }899 }
820- // Welcome holds attachment.sendMu while its transport Send is in flight.900+ if c.pendingGeneration == 0 {
821- // Do not probe ACK capacity through that lock before this incarnation is901+ c.pendingGeneration = 1
822- // ready: a deadline that expires during the handshake must complete and902+ }
823- // leave work pending, rather than wedging behind Welcome.903+ leases := make(map[*attachedClient]*attachmentLease, len(c.leases))
824- hasLiveAttachment, allAttachmentsReady := false, true904+ for ac, lease := range c.leases {
825- for _, lease := range c.leases {905+ if lease != nil && lease.active {
826- if lease.active {906+ leases[ac] = lease
827- hasLiveAttachment = true
828- allAttachmentsReady = allAttachmentsReady && lease.ready
829 }907 }
830 }908 }
831- handshakePending := hasLiveAttachment && !allAttachmentsReady909+ previewOnly := len(leases) == 0 && len(c.previewWakes) != 0
832- previewOnly := !hasLiveAttachment && len(c.previewWakes) != 0910+ legacyAckReady := c.opts.ackReady
833- ackReady := c.opts.ackReady
834 c.mu.Unlock()911 c.mu.Unlock()
835- ready := !handshakePending && (previewOnly || 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+ }
836 929
837 // The readiness callback can detach, replace, or publish a sync batch.930 // The readiness callback can detach, replace, or publish a sync batch.
838 // syncGateOpenLocked retries predicate evaluation on every registry change931 // syncGateOpenLocked retries predicate evaluation on every registry change
@@ -850,70 +943,171 @@ func (c *renderCoordinator) fireWithTimerToken(token *timerToken, gen uint64, wa
850 c.mu.Unlock()943 c.mu.Unlock()
851 return944 return
852 }945 }
853- // Shared invalidations are consumed once and dispatched as a shared wake.946+
854- // Capture each live lease so a park/resume of the same attachment object947+ // Re-snapshot after every external readiness probe. A replacement may have
855- // cannot let this already-dispatched wake paint the new connection.948+ // retired one of the leases we probed while the callback was unlocked.
949+ leases = make(map[*attachedClient]*attachmentLease, len(c.leases))
950+ for ac, lease := range c.leases {
951+ if lease != nil && lease.active {
952+ leases[ac] = lease
953+ }
954+ }
856 var attachmentLeases map[*attachedClient]*attachmentLease955 var attachmentLeases map[*attachedClient]*attachmentLease
857- if len(c.leases) != 0 {956+ if len(leases) != 0 {
858- attachmentLeases = make(map[*attachedClient]*attachmentLease, len(c.leases))957+ attachmentLeases = make(map[*attachedClient]*attachmentLease, len(leases))
859- for ac, lease := range c.leases {958+ }
860- if lease != nil && lease.active {959+ blockedByAck := false
960+ allDelivered := true
961+ if len(leases) != 0 {
962+ for ac, lease := range leases {
963+ if !c.pendingShared && len(c.pendingTargets) != 0 {
964+ targetLease, targeted := c.pendingTargets[ac]
965+ if !targeted || targetLease != lease {
966+ continue
967+ }
968+ }
969+ if c.delivered[ac] == c.pendingGeneration {
970+ continue
971+ }
972+ allDelivered = false
973+ if !lease.ready {
974+ continue
975+ }
976+ canReady, probed := ready[lease]
977+ if !probed {
978+ continue
979+ }
980+ if canReady {
861 attachmentLeases[ac] = lease981 attachmentLeases[ac] = lease
982+ if c.ackDeferredFor != nil {
983+ delete(c.ackDeferredFor, ac)
984+ }
985+ continue
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
862 }1010 }
863 }1011 }
864 }1012 }
1013+ if len(leases) == 0 && legacyOutput {
1014+ allDelivered = true
1015+ }
865 w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, attachmentLeases: attachmentLeases, watchdog: watchdog}1016 w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, attachmentLeases: attachmentLeases, watchdog: watchdog}
866- if !ready || handshakePending {1017+ if syncLease != nil {
867- var ackStart *ackBlockedSpan1018+ if selected, ok := attachmentLeases[syncLease.attachment]; ok && selected == syncLease {
868- if !ready && deadline {1019+ w.lease = syncLease
869- c.ackDeferred = true
870- if c.ackBlocked == nil && c.opts.observer != nil {
871- // Install the span while coordinator state is owned. Its Start
872- // publication runs unlocked, and its own handoff defers End until
873- // that call has returned.
874- ackStart = newACKBlockedSpan(c.opts.observer)
875- c.ackBlocked = ackStart
876- }
877 }1020 }
878- 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 {
879 c.mu.Unlock()1036 c.mu.Unlock()
1037+ stopDetachedTimer(worker)
880 if ackStart != nil {1038 if ackStart != nil {
881 ackStart.publishStart()1039 ackStart.publishStart()
882 }1040 }
883- c.notifyPreviews(w, preview, previews)1041+ c.notifyPreviews(w, previews)
884 return1042 return
885 }1043 }
886- preview, previews := c.takePendingPreviewsLocked()1044+
887- c.metrics.wakes.Add(1)1045+ if outputReady {
888- c.metrics.coalesced.Add(uint64(w.coalesced))1046+ for ac := range attachmentLeases {
889- if !w.urgent {1047+ if c.delivered == nil {
890- if w.coalesced > 1 {1048+ c.delivered = make(map[*attachedClient]uint64)
891- c.outputPressure += w.coalesced - 11049+ }
892- if c.outputPressure > int((maxOutputRenderDeadline-minOutputRenderDeadline)/time.Millisecond) {1050+ c.delivered[ac] = c.pendingGeneration
893- 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--
894 }1087 }
895- } else if c.outputPressure > 0 {
896- c.outputPressure--
897 }1088 }
898 }1089 }
899- worker := c.detachNormalTimerLocked()
900- c.pending, c.pendingReset, c.pendingUrgent, c.ackDeferred, c.deadlineDue, c.coalesced, c.armed = false, false, false, false, false, 0, false
901 observer, queueCorrelation, queueMarked := c.opts.observer, c.queueCorrelation, c.queueMarked1090 observer, queueCorrelation, queueMarked := c.opts.observer, c.queueCorrelation, c.queueMarked
902 ackBlocked := c.ackBlocked1091 ackBlocked := c.ackBlocked
903- c.queueMarked, c.ackBlocked = false, nil1092+ if complete {
1093+ c.queueMarked, c.ackBlocked = false, nil
1094+ }
904 wake := c.opts.wake1095 wake := c.opts.wake
905 c.mu.Unlock()1096 c.mu.Unlock()
906 stopDetachedTimer(worker)1097 stopDetachedTimer(worker)
907- if queueMarked && observer != nil {1098+ if ackStart != nil {
1099+ ackStart.publishStart()
1100+ }
1101+ if complete && queueMarked && observer != nil {
908 observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueDequeued, 0, true))1102 observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueDequeued, 0, true))
909 }1103 }
910- if ackBlocked != nil {1104+ if complete && ackBlocked != nil {
911 ackBlocked.finish(true)1105 ackBlocked.finish(true)
912 }1106 }
913- if wake != nil {1107+ if outputReady && wake != nil {
914 wake(w)1108 wake(w)
915 }1109 }
916- c.notifyPreviews(w, preview, previews)1110+ c.notifyPreviews(w, previews)
917 }1111 }
918 1112
919 func (c *renderCoordinator) fireValidLocked(token *timerToken, gen uint64, watchdog bool) bool {1113 func (c *renderCoordinator) fireValidLocked(token *timerToken, gen uint64, watchdog bool) bool {
@@ -957,19 +1151,19 @@ func (c *renderCoordinator) syncGateOpenLocked() bool {
957 1151
958 // takePendingPreviewsLocked snapshots and consumes picker preview delivery1152 // takePendingPreviewsLocked snapshots and consumes picker preview delivery
959 // for this target generation. Preview callbacks are never ACK-gated.1153 // for this target generation. Preview callbacks are never ACK-gated.
960-func (c *renderCoordinator) takePendingPreviewsLocked() (func(renderWake), []func(renderWake)) {1154+func (c *renderCoordinator) takePendingPreviewsLocked() []func(renderWake) {
961 if !c.pendingPreview {1155 if !c.pendingPreview {
962- return nil, nil1156+ return nil
963 }1157 }
964 c.pendingPreview = false1158 c.pendingPreview = false
965 previews := make([]func(renderWake), 0, len(c.previewWakes))1159 previews := make([]func(renderWake), 0, len(c.previewWakes))
966 for _, subscription := range c.previewWakes {1160 for _, subscription := range c.previewWakes {
967 previews = append(previews, subscription.fn)1161 previews = append(previews, subscription.fn)
968 }1162 }
969- return nil, previews1163+ return previews
970 }1164 }
971 1165
972-func (c *renderCoordinator) notifyPreviews(w renderWake, _ func(renderWake), previews []func(renderWake)) {1166+func (c *renderCoordinator) notifyPreviews(w renderWake, previews []func(renderWake)) {
973 for _, fn := range previews {1167 for _, fn := range previews {
974 fn(w)1168 fn(w)
975 }1169 }
M internal/usecase/daemon/render_coordinator_handshake_test.go +130 -0
@@ -1,12 +1,55 @@
11 package daemon
22
33 import (
4+ "io"
5+ "sync"
46 "testing"
57 "time"
68
79 "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
813 )
914
15+// fanoutBlockingTransport blocks only the first output send. The paired
16+// healthy attachment must still receive its frame while this peer is stuck.
17+type fanoutBlockingTransport struct {
18+ sent chan ports.Frame
19+ entered chan struct{}
20+ release chan struct{}
21+ done chan struct{}
22+ enteredOne sync.Once
23+ releaseOne sync.Once
24+ doneOne sync.Once
25+}
26+
27+func newFanoutBlockingTransport() *fanoutBlockingTransport {
28+ return &fanoutBlockingTransport{
29+ sent: make(chan ports.Frame, 8),
30+ entered: make(chan struct{}),
31+ release: make(chan struct{}),
32+ done: make(chan struct{}),
33+ }
34+}
35+
36+func (t *fanoutBlockingTransport) Send(frame ports.Frame) error {
37+ t.sent <- frame
38+ if frame.Type == ports.MsgOutput {
39+ t.enteredOne.Do(func() { close(t.entered) })
40+ <-t.release
41+ }
42+ t.doneOne.Do(func() { close(t.done) })
43+ return nil
44+}
45+
46+func (t *fanoutBlockingTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
47+
48+func (t *fanoutBlockingTransport) Close() error {
49+ t.releaseOne.Do(func() { close(t.release) })
50+ return nil
51+}
52+
1053 // Fresh producer state may arrive after route has published an attachment but
1154 // before the transport has accepted Welcome. It must remain pending until the
1255 // exact attachment incarnation completes that handshake.
@@ -36,6 +79,93 @@ func TestRenderCoordinatorFreshWakeWaitsForWelcome(t *testing.T) {
3679 requireNoWake(t, h.wakes)
3780 }
3881
82+func TestRenderCoordinatorFanoutDoesNotWaitForSlowTransport(t *testing.T) {
83+ d, sess, healthy, healthySends, _ := newManualTabSession(t, 1)
84+ slowTransport := newFanoutBlockingTransport()
85+ slow := &attachedClient{
86+ tr: slowTransport,
87+ output: newOutputStateStream(),
88+ size: domain.Size{Cols: 80, Rows: 24},
89+ }
90+ slow.initOverlays()
91+ slow.setSession(sess)
92+ sess.core().mu.Lock()
93+ sess.core().attachments[slow] = struct{}{}
94+ sess.core().mu.Unlock()
95+
96+ rc := d.ensureRenderCoordinator(sess)
97+ rc.opts.clock = nil // publish, then fire synchronously below
98+ rc.attach(healthy)
99+ rc.attach(slow)
100+ rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "fanout"})
101+ rc.fireCurrent(false)
102+
103+ select {
104+ case frame := <-healthySends:
105+ require.Equal(t, ports.MsgOutput, frame.Type, "healthy attachment did not receive output")
106+ case <-time.After(2 * time.Second):
107+ t.Fatal("healthy attachment remained blocked behind the slow transport")
108+ }
109+ select {
110+ case <-slowTransport.entered:
111+ case <-time.After(2 * time.Second):
112+ t.Fatal("slow transport did not enter its blocked output send")
113+ }
114+ slowTransport.releaseOne.Do(func() { close(slowTransport.release) })
115+ select {
116+ case <-slowTransport.done:
117+ case <-time.After(2 * time.Second):
118+ t.Fatal("slow output send did not finish after release")
119+ }
120+}
121+
122+func TestRenderCoordinatorWelcomeReadinessIsAttachmentScoped(t *testing.T) {
123+ h := newCoordinatorHarness(t)
124+ slow, healthy := &attachedClient{}, &attachedClient{}
125+ h.rc.attachWithReadiness(slow, false)
126+ h.rc.attachWithReadiness(healthy, true)
127+ slowLease := h.rc.attachmentLease(slow)
128+ require.NotNil(t, slowLease)
129+
130+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true, producer: "pty"})
131+ h.rc.fireCurrent(false)
132+ wake := awaitTestValue(t, h.wakes, "ready attachment did not receive output while peer handshakes")
133+ require.Contains(t, wake.attachmentLeases, healthy)
134+ _, slowSelected := wake.attachmentLeases[slow]
135+ require.False(t, slowSelected)
136+ h.rc.mu.Lock()
137+ require.True(t, h.rc.pending, "the handshaking peer must retain the shared mutation")
138+ h.rc.mu.Unlock()
139+
140+ require.True(t, h.rc.markAttachmentReady(slowLease))
141+ h.rc.fireCurrentForLease(slowLease)
142+ wake = awaitTestValue(t, h.wakes, "handshaken attachment did not receive deferred output")
143+ require.Contains(t, wake.attachmentLeases, slow)
144+ _, healthySelected := wake.attachmentLeases[healthy]
145+ require.False(t, healthySelected, "a deferred peer must not replay output to a healthy peer")
146+ h.rc.mu.Lock()
147+ require.False(t, h.rc.pending, "the shared mutation must clear after the handshake catches up")
148+ h.rc.mu.Unlock()
149+}
150+
151+// A wake captured for an old attachment generation must not become valid after
152+// the same attachment object is rebound to a replacement connection.
153+func TestRenderCoordinatorStaleGenerationWakeIsFenced(t *testing.T) {
154+ h := newCoordinatorHarness(t)
155+ ac := &attachedClient{}
156+ h.rc.attachWithReadiness(ac, true)
157+ oldLease := h.rc.attachmentLease(ac)
158+ require.NotNil(t, oldLease)
159+ h.rc.invalidateForLease(ac, oldLease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "old"})
160+ h.rc.fireCurrentForLease(oldLease)
161+ wake := awaitTestValue(t, h.wakes, "old attachment wake was not published")
162+ require.True(t, h.rc.wakeCurrent(wake))
163+
164+ h.rc.noteDetach(ac)
165+ h.rc.attachWithReadiness(ac, true)
166+ require.False(t, h.rc.wakeCurrent(wake), "a stale generation wake must not target the replacement connection")
167+}
168+
39169 // Parking and resuming the same client object is a new incarnation: a failed
40170 // Welcome on the replacement transport must not revive readiness from before
41171 // the park.
@@ -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.
@@ -36,6 +79,93 @@ func TestRenderCoordinatorFreshWakeWaitsForWelcome(t *testing.T) {
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.
M internal/usecase/daemon/render_coordinator_test.go +31 -2
@@ -378,6 +378,37 @@ func TestRenderCoordinatorUrgentDeadlineCannotBeExtended(t *testing.T) {
378378
379379 // --- ACK gating ----------------------------------------------------------------
380380
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+
381412 func TestRenderCoordinatorAckReadinessReentersWithoutBlockingResize(t *testing.T) {
382413 owner := &attachedClient{}
383414 wakes := make(chan renderWake, 1)
@@ -923,7 +954,6 @@ func TestRenderCoordinatorLifecycleDropsStaleWakes(t *testing.T) {
923954 name string
924955 teardown func(rc *renderCoordinator, owner *attachedClient)
925956 }{
926- {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},
927957 {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},
928958 {"session teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.beginSessionTeardown().finish() }},
929959 }
@@ -1357,7 +1387,6 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
13571387 run func(*renderCoordinator, *attachedClient)
13581388 }{
13591389 {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }},
1360- {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }},
13611390 } {
13621391 t.Run(transition.name, func(t *testing.T) {
13631392 h := newCoordinatorHarness(t)
@@ -378,6 +378,37 @@ func TestRenderCoordinatorUrgentDeadlineCannotBeExtended(t *testing.T) {
378 378
379 // --- ACK gating ----------------------------------------------------------------379 // --- ACK gating ----------------------------------------------------------------
380 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+
381 func TestRenderCoordinatorAckReadinessReentersWithoutBlockingResize(t *testing.T) {412 func TestRenderCoordinatorAckReadinessReentersWithoutBlockingResize(t *testing.T) {
382 owner := &attachedClient{}413 owner := &attachedClient{}
383 wakes := make(chan renderWake, 1)414 wakes := make(chan renderWake, 1)
@@ -923,7 +954,6 @@ func TestRenderCoordinatorLifecycleDropsStaleWakes(t *testing.T) {
923 name string954 name string
924 teardown func(rc *renderCoordinator, owner *attachedClient)955 teardown func(rc *renderCoordinator, owner *attachedClient)
925 }{956 }{
926- {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},
927 {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},957 {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},
928 {"session teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.beginSessionTeardown().finish() }},958 {"session teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.beginSessionTeardown().finish() }},
929 }959 }
@@ -1357,7 +1387,6 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
1357 run func(*renderCoordinator, *attachedClient)1387 run func(*renderCoordinator, *attachedClient)
1358 }{1388 }{
1359 {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }},1389 {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }},
1360- {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }},
1361 } {1390 } {
1362 t.Run(transition.name, func(t *testing.T) {1391 t.Run(transition.name, func(t *testing.T) {
1363 h := newCoordinatorHarness(t)1392 h := newCoordinatorHarness(t)
M internal/usecase/daemon/render_damage_receipt_test.go +2 -2
@@ -107,7 +107,7 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T
107107 // Prepare failures now schedule recovery through the coordinator;
108108 // retain the failed transaction here until this test explicitly retries.
109109 d.clock = newNoticeClock()
110- d.attachCoordinator(sess, nil, ac, true)
110+ d.attachCoordinator(sess, ac, true)
111111 healthy := ac.transport()
112112 p := sess.tabs[0].focusedPane()
113113
@@ -143,7 +143,7 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T
143143 func TestPrepareFailureNotifiesAndSchedulesRecovery(t *testing.T) {
144144 clock := newNoticeClock()
145145 d, sess, ac, _ := newNoticeFixture(t, clock)
146- rc := d.attachCoordinator(sess, nil, ac, true)
146+ rc := d.attachCoordinator(sess, ac, true)
147147 var producers []string
148148 rc.opts.onInvalidate = func(inv renderInvalidation) {
149149 // Both the notice repaint and the explicit recovery must re-enter only
@@ -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
M internal/usecase/daemon/render_pipeline.go +17 -9
@@ -548,17 +548,25 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
548548 sendErr = errors.New("client transport is nil")
549549 }
550550 if sendErr == nil {
551- send := sendTr.Send
552- if async, ok := sendTr.(ports.AsyncTransport); ok {
553- send = async.SendAsync
551+ interruptible := false
552+ if marks.attachmentEffect != nil {
553+ interruptible = marks.attachmentEffect.beginTransportSend(sendTransport)
554+ if !interruptible {
555+ sendErr = errAttachmentTransition
556+ }
554557 }
555- interruptible := marks.attachmentEffect != nil && marks.attachmentEffect.beginTransportSend(sendTransport)
556- if ac.proxied {
557- sendErr = preparedScreen.send(send)
558- } else {
559- sendErr = preparedANSI.send(data, ac.echoAck.Load(), send)
558+ if sendErr == nil {
559+ send := sendTr.Send
560+ if async, ok := sendTr.(ports.AsyncTransport); ok {
561+ send = async.SendAsync
562+ }
563+ if ac.proxied {
564+ sendErr = preparedScreen.send(send)
565+ } else {
566+ sendErr = preparedANSI.send(data, ac.echoAck.Load(), send)
567+ }
560568 }
561- if interruptible {
569+ if marks.attachmentEffect != nil && interruptible {
562570 if sendErr != nil {
563571 marks.attachmentEffect.reportTransportFailure(sendTransport)
564572 }
@@ -548,17 +548,25 @@ 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.attachmentEffect != nil && marks.attachmentEffect.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.attachmentEffect.reportTransportFailure(sendTransport)571 marks.attachmentEffect.reportTransportFailure(sendTransport)
564 }572 }
M internal/usecase/daemon/render_pipeline_test.go +25 -0
@@ -131,6 +131,7 @@ func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) {
131131 ac.sendMu.Lock()
132132 ac.output.next = ac.output.maxOutstanding
133133 ac.output.acked = 0
134+ ac.output.syncCapacityLocked()
134135 ac.sendMu.Unlock()
135136 p := sess.tabs[0].focusedPane()
136137 p.mu.Lock()
@@ -674,3 +675,27 @@ func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) {
674675 require.Equal(t, warn, got.BoxWarn)
675676 require.NotEqual(t, got.BoxInfo, got.BoxWarn)
676677 }
678+
679+func TestEmitFrameSkipsTransportSendWhenAttachmentEffectFenceRejects(t *testing.T) {
680+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
681+ token := sess.attachmentToken(ac, ac.transport())
682+ effect, admitted := ac.beginAttachmentEffect(token)
683+ require.True(t, admitted)
684+ ac.attachmentEffects.mu.Lock()
685+ ac.attachmentEffects.capability.transport = transportSnapshot{}
686+ ac.attachmentEffects.mu.Unlock()
687+
688+ state := cacheState("stale", 1)
689+ state.attachment = ac
690+ composed := composeFrame(state, composeCacheInput{})
691+ ac.sendMu.Lock()
692+ require.True(t, d.emitFrame(sess, ac, &state, composed, &runtimeMarkBatch{attachmentEffect: effect}))
693+ d.attachmentCleanupWg.Wait()
694+
695+ require.Zero(t, ac.output.next, "rejected transport effect must not commit output state")
696+ select {
697+ case frame := <-sends:
698+ t.Fatalf("rejected transport effect was sent: %v", frame.Type)
699+ default:
700+ }
701+}
@@ -131,6 +131,7 @@ func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) {
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()
@@ -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 +5 -5
@@ -313,7 +313,7 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
313313 d, sess, ac, sends := newManualSessionWithPTYs(t, activePTY, inactivePTY)
314314 clock := newCoordinatorMockClock(t, 8)
315315 d.clock = clock.clock
316- rc := d.attachCoordinator(sess, nil, ac, true)
316+ rc := d.attachCoordinator(sess, ac, true)
317317
318318 d.sessWg.Add(1)
319319 go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
@@ -349,7 +349,7 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
349349 d, sess, ac, sends := newManualSessionWithPTYs(t, oldPTY, newPTY, parkedPTY)
350350 clock := newCoordinatorMockClock(t, 8)
351351 d.clock = clock.clock
352- rc := d.attachCoordinator(sess, nil, ac, true)
352+ rc := d.attachCoordinator(sess, ac, true)
353353
354354 d.sessWg.Add(2)
355355 go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane())
@@ -385,7 +385,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
385385 d, target, targetClient, _ := newManualSessionWithPTYs(t, pty)
386386 clock := newCoordinatorMockClock(t, 8)
387387 d.clock = clock.clock
388- rc := d.attachCoordinator(target, nil, targetClient, true)
388+ rc := d.attachCoordinator(target, targetClient, true)
389389
390390 // A viewer in a different session is previewing the headless target.
391391 viewer := &attachedClient{}
@@ -429,7 +429,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
429429 d, target, detached, _ := newManualSessionWithPTYs(t, pty)
430430 clock := newCoordinatorMockClock(t, 8)
431431 d.clock = clock.clock
432- rc := d.attachCoordinator(target, nil, detached, true)
432+ rc := d.attachCoordinator(target, detached, true)
433433
434434 secondTransport, secondSends := newCapturingTransport(t)
435435 remaining := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: detached.size}
@@ -470,7 +470,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
470470 d, target, client, _ := newManualSessionWithPTYs(t, pty)
471471 clock := newCoordinatorMockClock(t, 8)
472472 d.clock = clock.clock
473- rc := d.attachCoordinator(target, nil, client, true)
473+ rc := d.attachCoordinator(target, client, true)
474474 wakes := make(chan renderWake, 1)
475475 rc.opts.wake = func(w renderWake) { wakes <- w }
476476 target.mu.Lock()
@@ -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())
@@ -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())
@@ -385,7 +385,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
385 d, target, targetClient, _ := newManualSessionWithPTYs(t, pty)385 d, target, targetClient, _ := newManualSessionWithPTYs(t, pty)
386 clock := newCoordinatorMockClock(t, 8)386 clock := newCoordinatorMockClock(t, 8)
387 d.clock = clock.clock387 d.clock = clock.clock
388- rc := d.attachCoordinator(target, nil, targetClient, true)388+ rc := d.attachCoordinator(target, targetClient, true)
389 389
390 // A viewer in a different session is previewing the headless target.390 // A viewer in a different session is previewing the headless target.
391 viewer := &attachedClient{}391 viewer := &attachedClient{}
@@ -429,7 +429,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
429 d, target, detached, _ := newManualSessionWithPTYs(t, pty)429 d, target, detached, _ := newManualSessionWithPTYs(t, pty)
430 clock := newCoordinatorMockClock(t, 8)430 clock := newCoordinatorMockClock(t, 8)
431 d.clock = clock.clock431 d.clock = clock.clock
432- rc := d.attachCoordinator(target, nil, detached, true)432+ rc := d.attachCoordinator(target, detached, true)
433 433
434 secondTransport, secondSends := newCapturingTransport(t)434 secondTransport, secondSends := newCapturingTransport(t)
435 remaining := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: detached.size}435 remaining := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: detached.size}
@@ -470,7 +470,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
470 d, target, client, _ := newManualSessionWithPTYs(t, pty)470 d, target, client, _ := newManualSessionWithPTYs(t, pty)
471 clock := newCoordinatorMockClock(t, 8)471 clock := newCoordinatorMockClock(t, 8)
472 d.clock = clock.clock472 d.clock = clock.clock
473- rc := d.attachCoordinator(target, nil, client, true)473+ rc := d.attachCoordinator(target, client, true)
474 wakes := make(chan renderWake, 1)474 wakes := make(chan renderWake, 1)
475 rc.opts.wake = func(w renderWake) { wakes <- w }475 rc.opts.wake = func(w renderWake) { wakes <- w }
476 target.mu.Lock()476 target.mu.Lock()
M internal/usecase/daemon/resize_publication.go +1 -1
@@ -24,7 +24,7 @@ func (p resizeCommitPublication) current() bool {
2424 if p.attachment == nil {
2525 return true
2626 }
27- // Attached resize publication is admitted only through a live role ticket.
27+ // Attached resize publication is admitted only through a live attachment effect ticket.
2828 if p.connection.effect == nil {
2929 return false
3030 }
@@ -24,7 +24,7 @@ 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.connection.effect == nil {28 if p.connection.effect == nil {
29 return false29 return false
30 }30 }
M internal/usecase/daemon/resume.go +32 -5
@@ -252,15 +252,22 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
252252 oldSame.closeDone()
253253 }
254254 d.log.Info("client parked for resume", "session", sess.name, "grace", grace)
255+ d.watchParkedTimer(token, parked)
256+ return true
257+}
255258
256- 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() {
257265 select {
258266 case <-timer.C():
259267 d.expireParked(token, parked)
260268 case <-parked.done:
261269 }
262- }(token, parked)
263- return true
270+ }()
264271 }
265272
266273 func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
@@ -309,9 +316,12 @@ func (d *Daemon) commitResumeClaim(ac *attachedClient) bool {
309316 d.mu.Lock()
310317 defer d.mu.Unlock()
311318 token := ac.resumeClaimToken
319+ if token == 0 {
320+ return true
321+ }
312322 parked := d.parked[token]
313- if token == 0 || parked == nil || parked.ac != ac || !parked.claimed {
314- return token == 0
323+ if parked == nil || parked.ac != ac || !parked.claimed {
324+ return false
315325 }
316326 delete(d.parked, token)
317327 parked.claimed = false
@@ -348,9 +358,25 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
348358 sess.unregisterAttachmentLocked(ac)
349359 sess.mu.Unlock()
350360 }
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
351376 captured := ac.transportSnapshot().transport
352377 ac.setSession(nil)
353378 d.mu.Unlock()
379+ d.watchParkedTimer(token, rearmed)
354380 if captured != nil {
355381 _ = ac.closeCapturedTransport(ac.revokeTransport(captured))
356382 }
@@ -595,6 +621,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
595621 // mandatory first paint cannot be blocked by ACKs that died with the link.
596622 ac.rebaseOutput()
597623 ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight))
624+ ac.output.syncCapacityLocked()
598625 ac.replaceTransport(tr)
599626 ac.size = sz
600627 ac.resumeToken = d.nextResumeTokenLocked()
@@ -252,15 +252,22 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
252 oldSame.closeDone()252 oldSame.closeDone()
253 }253 }
254 d.log.Info("client parked for resume", "session", sess.name, "grace", grace)254 d.log.Info("client parked for resume", "session", sess.name, "grace", grace)
255+ d.watchParkedTimer(token, parked)
256+ return true
257+}
255 258
256- 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() {
257 select {265 select {
258 case <-timer.C():266 case <-timer.C():
259 d.expireParked(token, parked)267 d.expireParked(token, parked)
260 case <-parked.done:268 case <-parked.done:
261 }269 }
262- }(token, parked)270+ }()
263- return true
264 }271 }
265 272
266 func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {273 func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
@@ -309,9 +316,12 @@ func (d *Daemon) commitResumeClaim(ac *attachedClient) bool {
309 d.mu.Lock()316 d.mu.Lock()
310 defer d.mu.Unlock()317 defer d.mu.Unlock()
311 token := ac.resumeClaimToken318 token := ac.resumeClaimToken
319+ if token == 0 {
320+ return true
321+ }
312 parked := d.parked[token]322 parked := d.parked[token]
313- if token == 0 || parked == nil || parked.ac != ac || !parked.claimed {323+ if parked == nil || parked.ac != ac || !parked.claimed {
314- return token == 0324+ return false
315 }325 }
316 delete(d.parked, token)326 delete(d.parked, token)
317 parked.claimed = false327 parked.claimed = false
@@ -348,9 +358,25 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
348 sess.unregisterAttachmentLocked(ac)358 sess.unregisterAttachmentLocked(ac)
349 sess.mu.Unlock()359 sess.mu.Unlock()
350 }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
351 captured := ac.transportSnapshot().transport376 captured := ac.transportSnapshot().transport
352 ac.setSession(nil)377 ac.setSession(nil)
353 d.mu.Unlock()378 d.mu.Unlock()
379+ d.watchParkedTimer(token, rearmed)
354 if captured != nil {380 if captured != nil {
355 _ = ac.closeCapturedTransport(ac.revokeTransport(captured))381 _ = ac.closeCapturedTransport(ac.revokeTransport(captured))
356 }382 }
@@ -595,6 +621,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
595 // 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.
596 ac.rebaseOutput()622 ac.rebaseOutput()
597 ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight))623 ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight))
624+ ac.output.syncCapacityLocked()
598 ac.replaceTransport(tr)625 ac.replaceTransport(tr)
599 ac.size = sz626 ac.size = sz
600 ac.resumeToken = d.nextResumeTokenLocked()627 ac.resumeToken = d.nextResumeTokenLocked()
M internal/usecase/daemon/resume_test.go +47 -0
@@ -97,6 +97,53 @@ func helloResumeCapable(intent uint8, name string, token uint64) ports.Hello {
9797 }
9898 }
9999
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+
100147 func TestNamedLinkLossParks(t *testing.T) {
101148 pty, release := newBlockingPTY(t)
102149 defer release()
@@ -97,6 +97,53 @@ func helloResumeCapable(intent uint8, name string, token uint64) ports.Hello {
97 }97 }
98 }98 }
99 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+
100 func TestNamedLinkLossParks(t *testing.T) {147 func TestNamedLinkLossParks(t *testing.T) {
101 pty, release := newBlockingPTY(t)148 pty, release := newBlockingPTY(t)
102 defer release()149 defer release()
M internal/usecase/daemon/resume_wake_incarnation_test.go +1 -1
@@ -69,7 +69,7 @@ func TestParkedResumeRejectsDispatchedWakeFromPriorAttachment(t *testing.T) {
6969
7070 func TestSameAttachmentRebindRejectsQueuedWakeAndFansOutFreshGeneration(t *testing.T) {
7171 d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
72- rc := d.attachCoordinator(sess, nil, ac, true)
72+ rc := d.attachCoordinator(sess, ac, true)
7373 oldLease := rc.attachmentLease(ac)
7474 require.NotNil(t, oldLease)
7575
@@ -69,7 +69,7 @@ func TestParkedResumeRejectsDispatchedWakeFromPriorAttachment(t *testing.T) {
69 69
70 func TestSameAttachmentRebindRejectsQueuedWakeAndFansOutFreshGeneration(t *testing.T) {70 func TestSameAttachmentRebindRejectsQueuedWakeAndFansOutFreshGeneration(t *testing.T) {
71 d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())71 d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
72- rc := d.attachCoordinator(sess, nil, ac, true)72+ rc := d.attachCoordinator(sess, ac, true)
73 oldLease := rc.attachmentLease(ac)73 oldLease := rc.attachmentLease(ac)
74 require.NotNil(t, oldLease)74 require.NotNil(t, oldLease)
75 75
M internal/usecase/daemon/screen_output_stream.go +1 -0
@@ -184,6 +184,7 @@ func (p *preparedStructuredOutput) send(sender func(ports.Frame) error) error {
184184 }
185185 p.commit()
186186 p.stream.state.next = p.next
187+ p.stream.state.publishOutstanding()
187188 return nil
188189 }
189190
@@ -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 +37 -5
@@ -242,7 +242,7 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st
242242 }
243243
244244 if d.ptys == nil {
245- return nil, errors.New("daemon: PTY factory is unavailable")
245+ return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("daemon: PTY factory is unavailable"))
246246 }
247247 tbSize := contentSize(sz, proxied)
248248 var names []string
@@ -523,7 +523,14 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, sz do
523523 env := copyEnvironment(sess.env)
524524 attachments := sess.snapshotAttachmentsLocked()
525525 sess.mu.Unlock()
526- proxied := len(attachments) != 0 && attachments[0].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
527534 tbSize := contentSize(sz, proxied)
528535 tabStableID, paneStableID, err := d.newTabPaneStableIDs()
529536 if err != nil {
@@ -541,8 +548,8 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, sz do
541548 return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err)
542549 }
543550 tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
544- if len(attachments) != 0 {
545- t := d.effectiveTheme(attachments[0].getClientTheme())
551+ if themeClient != nil {
552+ t := d.effectiveTheme(themeClient.getClientTheme())
546553 tb.mu.Lock()
547554 p := tb.focusedPane()
548555 tb.mu.Unlock()
@@ -706,6 +713,7 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
706713 return
707714 }
708715 if owner.session != sess || owner.tab != tb {
716+ d.log.Warn("pane owner mismatch", "pane", p.id)
709717 return
710718 }
711719 sess.mu.Lock()
@@ -990,7 +998,24 @@ func (s *session) persistRecordLocked(updatedAt int64) domain.CatalogueRecord {
990998 return domain.CatalogueRecord{Name: s.name, IncarnationID: s.incarnation, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames}
991999 }
9921000
1001+// closeTab is the public close boundary for callers that are not already
1002+// inside session.runMutation. Mutation dispatchers use closeTabLocked.
9931003 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 {
9941019 if sess == nil || tb == nil {
9951020 return layout.ErrNotFound
9961021 }
@@ -1016,7 +1041,14 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
10161041 name := sess.name
10171042 sess.mu.Unlock()
10181043 d.mu.Unlock()
1019- 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 {
10201052 d.log.Warn("closing last tab failed", "session", name, "err", err)
10211053 d.reportError(sess, domain.UserErr(domain.NoticeSnapshotSaturated,
10221054 "couldn't close tab: session state not yet saved; try again", err))
@@ -242,7 +242,7 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st
242 }242 }
243 243
244 if d.ptys == nil {244 if d.ptys == nil {
245- return nil, errors.New("daemon: PTY factory is unavailable")245+ return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("daemon: PTY factory is unavailable"))
246 }246 }
247 tbSize := contentSize(sz, proxied)247 tbSize := contentSize(sz, proxied)
248 var names []string248 var names []string
@@ -523,7 +523,14 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, sz do
523 env := copyEnvironment(sess.env)523 env := copyEnvironment(sess.env)
524 attachments := sess.snapshotAttachmentsLocked()524 attachments := sess.snapshotAttachmentsLocked()
525 sess.mu.Unlock()525 sess.mu.Unlock()
526- proxied := len(attachments) != 0 && attachments[0].proxied526+ // 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
527 tbSize := contentSize(sz, proxied)534 tbSize := contentSize(sz, proxied)
528 tabStableID, paneStableID, err := d.newTabPaneStableIDs()535 tabStableID, paneStableID, err := d.newTabPaneStableIDs()
529 if err != nil {536 if err != nil {
@@ -541,8 +548,8 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, sz do
541 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)
542 }549 }
543 tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)550 tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
544- if len(attachments) != 0 {551+ if themeClient != nil {
545- t := d.effectiveTheme(attachments[0].getClientTheme())552+ t := d.effectiveTheme(themeClient.getClientTheme())
546 tb.mu.Lock()553 tb.mu.Lock()
547 p := tb.focusedPane()554 p := tb.focusedPane()
548 tb.mu.Unlock()555 tb.mu.Unlock()
@@ -706,6 +713,7 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
706 return713 return
707 }714 }
708 if owner.session != sess || owner.tab != tb {715 if owner.session != sess || owner.tab != tb {
716+ d.log.Warn("pane owner mismatch", "pane", p.id)
709 return717 return
710 }718 }
711 sess.mu.Lock()719 sess.mu.Lock()
@@ -990,7 +998,24 @@ func (s *session) persistRecordLocked(updatedAt int64) domain.CatalogueRecord {
990 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}
991 }999 }
992 1000
1001+// closeTab is the public close boundary for callers that are not already
1002+// inside session.runMutation. Mutation dispatchers use closeTabLocked.
993 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 {
994 if sess == nil || tb == nil {1019 if sess == nil || tb == nil {
995 return layout.ErrNotFound1020 return layout.ErrNotFound
996 }1021 }
@@ -1016,7 +1041,14 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
1016 name := sess.name1041 name := sess.name
1017 sess.mu.Unlock()1042 sess.mu.Unlock()
1018 d.mu.Unlock()1043 d.mu.Unlock()
1019- 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 {
1020 d.log.Warn("closing last tab failed", "session", name, "err", err)1052 d.log.Warn("closing last tab failed", "session", name, "err", err)
1021 d.reportError(sess, domain.UserErr(domain.NoticeSnapshotSaturated,1053 d.reportError(sess, domain.UserErr(domain.NoticeSnapshotSaturated,
1022 "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))
M internal/usecase/daemon/session_meta_test.go +20 -1
@@ -116,6 +116,25 @@ func TestSessionMetaShadowUpdatesOnlyAfterSuccessfulSend(t *testing.T) {
116116 require.False(t, ac.sessionMetaSent)
117117 }
118118
119+func TestProxiedAttachedCommandMutationFailureDoesNotReportExecution(t *testing.T) {
120+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
121+ ac.proxied = true
122+ d.attachCoordinator(sess, ac, true)
123+ d.ptys = failingPTYFactory{err: errors.New("tab open failed")}
124+ token := sess.attachmentToken(ac, ac.transport())
125+
126+ result := d.executeAttachedCommand(token, ports.CommandRequest{
127+ Version: ports.ProtocolVersion, RequestID: 41, Attached: true, Slug: "new-tab",
128+ })
129+
130+ require.False(t, result.OK)
131+ require.Equal(t, ports.ErrInternal, result.Code)
132+ require.Contains(t, result.Text, "tab open failed")
133+ sess.mu.Lock()
134+ require.Len(t, sess.tabs, 1, "failed attached command must not publish a new tab")
135+ sess.mu.Unlock()
136+}
137+
119138 func TestProxiedAttachedCommandValidationPreservesRequestID(t *testing.T) {
120139 tests := []struct {
121140 name string
@@ -192,7 +211,7 @@ func TestProxiedAttachedCommandValidationPreservesRequestID(t *testing.T) {
192211 t.Run(tt.name, func(t *testing.T) {
193212 d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
194213 ac.proxied = true
195- d.attachCoordinator(sess, nil, ac, true)
214+ d.attachCoordinator(sess, ac, true)
196215 payload, err := ports.MarshalCommandRequest(tt.request)
197216 require.NoError(t, err)
198217
@@ -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,7 +211,7 @@ 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
M internal/usecase/daemon/session_test.go +48 -0
@@ -20,6 +20,7 @@ import (
2020 "github.com/bnema/vev/internal/ports"
2121 portsmocks "github.com/bnema/vev/internal/ports/mocks"
2222 "github.com/bnema/vev/internal/usecase/picker"
23+ themeui "github.com/bnema/vev/internal/usecase/theme"
2324 )
2425
2526 // --- test doubles -----------------------------------------------------------
@@ -151,6 +152,53 @@ func TestKillAllEmptyDaemonSignalsShutdown(t *testing.T) {
151152 }
152153 }
153154
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+
154202 func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
155203 p1, release1 := newBlockingPTY(t)
156204 defer release1()
@@ -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 -----------------------------------------------------------
@@ -151,6 +152,53 @@ func TestKillAllEmptyDaemonSignalsShutdown(t *testing.T) {
151 }152 }
152 }153 }
153 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+
154 func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {202 func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
155 p1, release1 := newBlockingPTY(t)203 p1, release1 := newBlockingPTY(t)
156 defer release1()204 defer release1()
M internal/usecase/daemon/session_view.go +0 -3
@@ -93,9 +93,6 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
9393 view.tabs = append(view.tabs, entry)
9494 }
9595 s.mu.Unlock()
96- if !view.attached {
97- view.attached = len(s.snapshotAttachmentViews()) != 0
98- }
9996 return view
10097 }
10198
@@ -93,9 +93,6 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
93 view.tabs = append(view.tabs, entry)93 view.tabs = append(view.tabs, entry)
94 }94 }
95 s.mu.Unlock()95 s.mu.Unlock()
96- if !view.attached {
97- view.attached = len(s.snapshotAttachmentViews()) != 0
98- }
99 return view96 return view
100 }97 }
101 98
M internal/usecase/daemon/transactional_resize.go +1 -1
@@ -852,7 +852,7 @@ func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, s
852852 if ac == nil {
853853 return d.requestTransactionalResizeForLease(sess, nil, nil, size, immediate)
854854 }
855- rc := d.attachCoordinator(sess, nil, ac, true)
855+ rc := d.attachCoordinator(sess, ac, true)
856856 return d.requestTransactionalResizeForLease(sess, ac, rc.attachmentLease(ac), size, immediate)
857857 }
858858
@@ -852,7 +852,7 @@ func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, s
852 if ac == nil {852 if ac == nil {
853 return d.requestTransactionalResizeForLease(sess, nil, nil, size, immediate)853 return d.requestTransactionalResizeForLease(sess, nil, nil, size, immediate)
854 }854 }
855- rc := d.attachCoordinator(sess, nil, ac, true)855+ rc := d.attachCoordinator(sess, ac, true)
856 return d.requestTransactionalResizeForLease(sess, ac, rc.attachmentLease(ac), size, immediate)856 return d.requestTransactionalResizeForLease(sess, ac, rc.attachmentLease(ac), size, immediate)
857 }857 }
858 858
M internal/usecase/daemon/transactional_resize_test.go +4 -5
@@ -388,7 +388,6 @@ func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) {
388388 }{
389389 {"detach and attach", func(rc *renderCoordinator, old, next *attachedClient) { rc.noteDetach(old); rc.attach(next) }},
390390 {"detach", func(rc *renderCoordinator, old, _ *attachedClient) { rc.noteDetach(old) }},
391- {"resume", func(rc *renderCoordinator, old, next *attachedClient) { rc.noteDetach(old); rc.attach(next) }},
392391 } {
393392 t.Run(transition.name, func(t *testing.T) {
394393 rc := newRenderCoordinator(renderCoordinatorOptions{})
@@ -411,7 +410,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing
411410 d, sess, ac, _ := newManualSessionWithPTYs(t, first, second)
412411 observer := &daemonRuntimeObserver{}
413412 d.runtimeObserver = observer
414- rc := d.attachCoordinator(sess, nil, ac, true)
413+ rc := d.attachCoordinator(sess, ac, true)
415414 lease := rc.attachmentLease(ac)
416415 var newer uint64
417416 d.beforeSessionResizePublication = func() {
@@ -472,7 +471,7 @@ func TestTransactionalResizeRechecksLeaseAtAttachmentPublication(t *testing.T) {
472471 t.Run(tc.name, func(t *testing.T) {
473472 pty := &transactionalResizePTY{}
474473 d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
475- rc := d.attachCoordinator(sess, nil, ac, true)
474+ rc := d.attachCoordinator(sess, ac, true)
476475 lease := rc.attachmentLease(ac)
477476 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)
478477 require.NotZero(t, epoch)
@@ -556,7 +555,7 @@ func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) {
556555 tb.mu.Unlock()
557556 owner := publishPaneOwner(popup, source, tb, 7)
558557
559- rc := d.attachCoordinator(source, nil, ac, true)
558+ rc := d.attachCoordinator(source, ac, true)
560559 lease := rc.attachmentLease(ac)
561560 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
562561 require.True(t, d.runResizeTransaction(source, ac, lease, epoch))
@@ -781,7 +780,7 @@ func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T)
781780 tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7}
782781 tb.mu.Unlock()
783782
784- rc := d.attachCoordinator(sess, nil, ac, true)
783+ rc := d.attachCoordinator(sess, ac, true)
785784 lease := rc.attachmentLease(ac)
786785 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
787786 require.NotZero(t, epoch)
@@ -388,7 +388,6 @@ func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) {
388 }{388 }{
389 {"detach and attach", func(rc *renderCoordinator, old, next *attachedClient) { rc.noteDetach(old); rc.attach(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- {"resume", func(rc *renderCoordinator, old, next *attachedClient) { rc.noteDetach(old); rc.attach(next) }},
392 } {391 } {
393 t.Run(transition.name, func(t *testing.T) {392 t.Run(transition.name, func(t *testing.T) {
394 rc := newRenderCoordinator(renderCoordinatorOptions{})393 rc := newRenderCoordinator(renderCoordinatorOptions{})
@@ -411,7 +410,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing
411 d, sess, ac, _ := newManualSessionWithPTYs(t, first, second)410 d, sess, ac, _ := newManualSessionWithPTYs(t, first, second)
412 observer := &daemonRuntimeObserver{}411 observer := &daemonRuntimeObserver{}
413 d.runtimeObserver = observer412 d.runtimeObserver = observer
414- rc := d.attachCoordinator(sess, nil, ac, true)413+ rc := d.attachCoordinator(sess, ac, true)
415 lease := rc.attachmentLease(ac)414 lease := rc.attachmentLease(ac)
416 var newer uint64415 var newer uint64
417 d.beforeSessionResizePublication = func() {416 d.beforeSessionResizePublication = func() {
@@ -472,7 +471,7 @@ func TestTransactionalResizeRechecksLeaseAtAttachmentPublication(t *testing.T) {
472 t.Run(tc.name, func(t *testing.T) {471 t.Run(tc.name, func(t *testing.T) {
473 pty := &transactionalResizePTY{}472 pty := &transactionalResizePTY{}
474 d, sess, ac, _ := newManualSessionWithPTYs(t, pty)473 d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
475- rc := d.attachCoordinator(sess, nil, ac, true)474+ rc := d.attachCoordinator(sess, ac, true)
476 lease := rc.attachmentLease(ac)475 lease := rc.attachmentLease(ac)
477 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)476 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)
478 require.NotZero(t, epoch)477 require.NotZero(t, epoch)
@@ -556,7 +555,7 @@ func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) {
556 tb.mu.Unlock()555 tb.mu.Unlock()
557 owner := publishPaneOwner(popup, source, tb, 7)556 owner := publishPaneOwner(popup, source, tb, 7)
558 557
559- rc := d.attachCoordinator(source, nil, ac, true)558+ rc := d.attachCoordinator(source, ac, true)
560 lease := rc.attachmentLease(ac)559 lease := rc.attachmentLease(ac)
561 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)560 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
562 require.True(t, d.runResizeTransaction(source, ac, lease, epoch))561 require.True(t, d.runResizeTransaction(source, ac, lease, epoch))
@@ -781,7 +780,7 @@ func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T)
781 tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7}780 tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7}
782 tb.mu.Unlock()781 tb.mu.Unlock()
783 782
784- rc := d.attachCoordinator(sess, nil, ac, true)783+ rc := d.attachCoordinator(sess, ac, true)
785 lease := rc.attachmentLease(ac)784 lease := rc.attachmentLease(ac)
786 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)785 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
787 require.NotZero(t, epoch)786 require.NotZero(t, epoch)
M internal/usecase/daemon/transport_observability_test.go +1 -1
@@ -20,7 +20,7 @@ func TestTransportObservabilityDaemonBoundaries(t *testing.T) {
2020 // One in-flight state makes the second public resize take the ACK-blocked
2121 // path. The real coordinator owns both renders and the ACK wake.
2222 ac.output = newOutputStateStream(1)
23- rc := d.attachCoordinator(sess, nil, ac, true)
23+ rc := d.attachCoordinator(sess, ac, true)
2424
2525 if !d.resizeForFirstPaint(sess, ac, domain.Size{Cols: 90, Rows: 25}) {
2626 t.Fatal("first resize was not accepted")
@@ -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")

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close