fix(daemon): close review gaps for unified attachments
- commit
- 5e4aa1c45199efb56d87e23262cdf63ac47f6faf
- author
- brice · · 2026-08-03 23:35 UTC
- parent
- 457134c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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. | |
| 1 | 2 | reviews: | |
| 2 | 3 | path_filters: | |
| 3 | 4 | - "!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) | |||
| 265 | 265 | sz := domain.Size{Cols: 80, Rows: 24} | |
| 266 | 266 | dir, _ := startDaemon(t) | |
| 267 | 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 | 271 | firstEnv := []string{ | |
| 270 | 272 | "VEV_TEST_ENV=first", "SHELL=" + firstShell, "XDG_RUNTIME_DIR=/run/first", "WAYLAND_DISPLAY=wayland-first", | |
| 271 | 273 | "TERM=client", "COLORTERM=client", "TERM_PROGRAM=client", "VEV=client", | |
| @@ -289,6 +291,15 @@ func TestIntegration_TwoAttachmentsReceiveSharedMutationPTYOutput(t *testing.T) | |||
| 289 | 291 | awaitText(t, p2, sz, "Commands") | |
| 290 | 292 | require.NoError(t, tr2.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("CNT\r")})})) | |
| 291 | 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 | 305 | func TestIntegration_AttachFirstOutput(t *testing.T) { | |
| @@ -359,7 +370,7 @@ func TestIntegration_CommandPaletteRenamesEphemeralSession(t *testing.T) { | |||
| 359 | 370 | require.Equal(t, ports.MsgSessions, f.Type) | |
| 360 | 371 | sessions, err := ports.UnmarshalSessions(f.Payload) | |
| 361 | 372 | require.NoError(t, err) | |
| 362 | - | require.Len(t, sessions.Sessions, 1) | |
| 373 | + | require.NotEmpty(t, sessions.Sessions) | |
| 363 | 374 | require.Equal(t, "work", sessions.Sessions[0].Name) | |
| 364 | 375 | require.False(t, sessions.Sessions[0].Ephemeral) | |
| 365 | 376 | } | |
| @@ -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 | |||
| 70 | 70 | } | |
| 71 | 71 | ||
| 72 | 72 | // 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 | 74 | func (t attachmentConnectionToken) current() bool { | |
| 75 | 75 | return t.sess != nil && t.ac != nil && | |
| 76 | 76 | t.ac.connectionGeneration.Load() == t.generation && | |
| @@ -101,7 +101,7 @@ func (t attachmentConnectionToken) attachmentEffectCurrent() bool { | |||
| 101 | 101 | } | |
| 102 | 102 | ||
| 103 | 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 | 105 | return nil, false | |
| 106 | 106 | } | |
| 107 | 107 | 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. Membership | 72 | // 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, false | 105 | 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 { | |||
| 67 | 67 | 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 | 70 | type attachmentTransportInterrupt struct { | |
| 76 | 71 | ac *attachedClient | |
| 77 | 72 | transport transportSnapshot | |
| 78 | 73 | } | |
| 79 | 74 | ||
| 80 | - | // attachmentEffectFreezeOptions controls one role-gate freeze operation. A freeze | |
| 75 | + | // attachmentEffectFreezeOptions controls one attachment-effect-gate freeze operation. A freeze | |
| 81 | 76 | // must be called without daemon, routing, session, coordinator, send, overlay, | |
| 82 | 77 | // router, or pane locks held. | |
| 83 | 78 | type attachmentEffectFreezeOptions struct { | |
| @@ -67,17 +67,12 @@ type frozenAttachmentEffectGates struct { | |||||
| 67 | drained bool | 67 | 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 *attachedClient | 71 | ac *attachedClient | ||
| 77 | transport transportSnapshot | 72 | transport transportSnapshot | ||
| 78 | } | 73 | } | ||
| 79 | 74 | ||||
| 80 | - | // attachmentEffectFreezeOptions controls one role-gate freeze operation. A freeze | 75 | + | // 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 | |||
| 241 | 241 | continue | |
| 242 | 242 | } | |
| 243 | 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 | 251 | // Publication may have completed between token capture and admission. | |
| 245 | 252 | // Re-read the registry and exact generation instead of using stale authority. | |
| 246 | 253 | } | |
| @@ -241,6 +241,13 @@ func (ac *attachedClient) beginCurrentAttachmentEffect(sess *session, tr ports.T | |||||
| 241 | continue | 241 | 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 { | |||
| 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 | 122 | t.Helper() | |
| 123 | 123 | ticket, admitted := token.ac.beginAttachmentEffect(token) | |
| 124 | 124 | require.True(t, admitted) | |
| @@ -131,7 +131,7 @@ func startBlockedRoleSend(t *testing.T, token attachmentConnectionToken) <-chan | |||
| 131 | 131 | return done | |
| 132 | 132 | } | |
| 133 | 133 | ||
| 134 | - | func requireRoleGateRetired(t *testing.T, ac *attachedClient) { | |
| 134 | + | func requireAttachmentEffectGateRetired(t *testing.T, ac *attachedClient) { | |
| 135 | 135 | t.Helper() | |
| 136 | 136 | ac.attachmentEffects.mu.Lock() | |
| 137 | 137 | defer ac.attachmentEffects.mu.Unlock() | |
| @@ -140,7 +140,39 @@ func requireRoleGateRetired(t *testing.T, ac *attachedClient) { | |||
| 140 | 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 | 176 | ac.attachmentEffects.mu.Lock() | |
| 145 | 177 | defer ac.attachmentEffects.mu.Unlock() | |
| 146 | 178 | return ac.attachmentEffects.phase, ac.attachmentEffects.capability | |
| @@ -164,7 +196,7 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi | |||
| 164 | 196 | sess.registerAttachmentLocked(later) | |
| 165 | 197 | sess.mu.Unlock() | |
| 166 | 198 | ||
| 167 | - | rc := d.attachCoordinator(sess, nil, first, true) | |
| 199 | + | rc := d.attachCoordinator(sess, first, true) | |
| 168 | 200 | firstToken := sess.attachmentToken(first, firstTransport) | |
| 169 | 201 | firstToken.lease = rc.attachmentLease(first) | |
| 170 | 202 | first.publishAttachmentCapability(firstToken) | |
| @@ -189,9 +221,9 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi | |||
| 189 | 221 | } | |
| 190 | 222 | }) | |
| 191 | 223 | ||
| 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) | |
| 195 | 227 | require.Equal(t, attachmentEffectsStable, firstPhase) | |
| 196 | 228 | require.Equal(t, attachmentEffectsFrozen, blockedPhase) | |
| 197 | 229 | require.Equal(t, attachmentEffectsStable, laterPhase) | |
| @@ -209,7 +241,7 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi | |||
| 209 | 241 | killDone := make(chan error, 1) | |
| 210 | 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 | 245 | deadline := awaitTestValue(t, clock.timers, "teardown did not arm the gate acquisition deadline") | |
| 214 | 246 | require.Equal(t, detachNotifyTimeout, deadline.duration) | |
| 215 | 247 | deadline.ch <- time.Now() | |
| @@ -242,9 +274,9 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi | |||
| 242 | 274 | require.False(t, blockedTransport.Closed()) | |
| 243 | 275 | require.False(t, laterTransport.Closed()) | |
| 244 | 276 | ||
| 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) | |
| 248 | 280 | require.Equal(t, attachmentEffectsStable, firstPhase, "partially acquired gate was not rolled back") | |
| 249 | 281 | require.Equal(t, attachmentEffectsFrozen, blockedPhase, "teardown released a gate owned by another transition") | |
| 250 | 282 | require.Equal(t, attachmentEffectsStable, laterPhase, "unacquired later gate was frozen or invalidated") | |
| @@ -260,9 +292,9 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi | |||
| 260 | 292 | complete.unfreeze() | |
| 261 | 293 | for _, token := range []attachmentConnectionToken{firstToken, blockedToken, laterToken} { | |
| 262 | 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 | 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 | 302 | d, sess, snatched, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 271 | 303 | snatchedTransport := newTeardownBlockingTransport() | |
| 272 | 304 | snatched.replaceTransport(snatchedTransport) | |
| 273 | - | d.attachCoordinator(sess, nil, snatched, true) | |
| 305 | + | d.attachCoordinator(sess, snatched, true) | |
| 274 | 306 | snatched.publishAttachmentCapability(sess.attachmentToken(snatched, snatchedTransport)) | |
| 275 | 307 | ||
| 276 | 308 | activeTransport := newTeardownBlockingTransport() | |
| @@ -290,12 +322,12 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) { | |||
| 290 | 322 | d.mu.Lock() | |
| 291 | 323 | d.sessions[unrelated.id] = unrelated | |
| 292 | 324 | d.mu.Unlock() | |
| 293 | - | d.attachCoordinator(unrelated, nil, unrelatedClient, true) | |
| 325 | + | d.attachCoordinator(unrelated, unrelatedClient, true) | |
| 294 | 326 | ||
| 295 | 327 | snatchedToken := sess.attachmentToken(snatched, snatchedTransport) | |
| 296 | 328 | 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) | |
| 299 | 331 | awaitTestCompletion(t, snatchedTransport.sendEntered, "snatched send did not block") | |
| 300 | 332 | awaitTestCompletion(t, activeTransport.sendEntered, "active send did not block") | |
| 301 | 333 | ||
| @@ -309,8 +341,8 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) { | |||
| 309 | 341 | _ = activeTransport.Close() | |
| 310 | 342 | t.Fatal("session kill remained pinned behind admitted participant sends") | |
| 311 | 343 | } | |
| 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") | |
| 314 | 346 | d.waitNotifies() | |
| 315 | 347 | ||
| 316 | 348 | require.True(t, snatchedTransport.Closed()) | |
| @@ -318,19 +350,19 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) { | |||
| 318 | 350 | require.False(t, unrelatedTransport.Closed(), "unrelated session transport was interrupted") | |
| 319 | 351 | require.Contains(t, unrelated.snapshotAttachments(), unrelatedClient) | |
| 320 | 352 | require.Same(t, unrelated, unrelatedClient.currentSession()) | |
| 321 | - | requireRoleGateRetired(t, snatched) | |
| 322 | - | requireRoleGateRetired(t, active) | |
| 353 | + | requireAttachmentEffectGateRetired(t, snatched) | |
| 354 | + | requireAttachmentEffectGateRetired(t, active) | |
| 323 | 355 | } | |
| 324 | 356 | ||
| 325 | 357 | func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *testing.T) { | |
| 326 | 358 | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 327 | 359 | stale := newTeardownBlockingTransport() | |
| 328 | 360 | ac.replaceTransport(stale) | |
| 329 | - | d.attachCoordinator(sess, nil, ac, true) | |
| 361 | + | d.attachCoordinator(sess, ac, true) | |
| 330 | 362 | ac.publishAttachmentCapability(sess.attachmentToken(ac, stale)) | |
| 331 | 363 | ||
| 332 | 364 | token := sess.attachmentToken(ac, stale) | |
| 333 | - | sendDone := startBlockedRoleSend(t, token) | |
| 365 | + | sendDone := startBlockedAttachmentSend(t, token) | |
| 334 | 366 | awaitTestCompletion(t, stale.sendEntered, "captured incarnation send did not block") | |
| 335 | 367 | fresh := &closeTrackingTransport{} | |
| 336 | 368 | d.afterAttachmentEffectParticipantsSnapshotted = func(string, []*attachedClient) { | |
| @@ -338,7 +370,7 @@ func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *test | |||
| 338 | 370 | } | |
| 339 | 371 | ||
| 340 | 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 | 374 | require.True(t, stale.Closed(), "exact captured in-flight transport was not interrupted") | |
| 343 | 375 | require.False(t, fresh.Closed(), "newer transport incarnation was interrupted") | |
| 344 | 376 | require.Same(t, fresh, ac.transport()) | |
| @@ -348,14 +380,14 @@ func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *test | |||
| 348 | 380 | sess.mu.Lock() | |
| 349 | 381 | require.Contains(t, sess.snapshotAttachmentsLocked(), ac) | |
| 350 | 382 | sess.mu.Unlock() | |
| 351 | - | requireRoleGateRetired(t, ac) | |
| 383 | + | requireAttachmentEffectGateRetired(t, ac) | |
| 352 | 384 | } | |
| 353 | 385 | ||
| 354 | 386 | func TestShutdownSignalsServeWhenParticipantGateAcquisitionTimesOut(t *testing.T) { | |
| 355 | 387 | d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 356 | 388 | transport := &closeTrackingTransport{} | |
| 357 | 389 | active.replaceTransport(transport) | |
| 358 | - | d.attachCoordinator(sess, nil, active, true) | |
| 390 | + | d.attachCoordinator(sess, active, true) | |
| 359 | 391 | active.publishAttachmentCapability(sess.attachmentToken(active, transport)) | |
| 360 | 392 | ||
| 361 | 393 | owner := freezeAttachmentEffectGates(active) | |
| @@ -386,11 +418,11 @@ func TestShutdownInterruptsBlockedParticipantSend(t *testing.T) { | |||
| 386 | 418 | d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 387 | 419 | blocked := newTeardownBlockingTransport() | |
| 388 | 420 | active.replaceTransport(blocked) | |
| 389 | - | d.attachCoordinator(sess, nil, active, true) | |
| 421 | + | d.attachCoordinator(sess, active, true) | |
| 390 | 422 | active.publishAttachmentCapability(sess.attachmentToken(active, blocked)) | |
| 391 | 423 | ||
| 392 | 424 | token := sess.attachmentToken(active, blocked) | |
| 393 | - | sendDone := startBlockedRoleSend(t, token) | |
| 425 | + | sendDone := startBlockedAttachmentSend(t, token) | |
| 394 | 426 | awaitTestCompletion(t, blocked.sendEntered, "active send did not block") | |
| 395 | 427 | ||
| 396 | 428 | shutdownDone := make(chan bool, 1) | |
| @@ -402,17 +434,17 @@ func TestShutdownInterruptsBlockedParticipantSend(t *testing.T) { | |||
| 402 | 434 | _ = blocked.Close() | |
| 403 | 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 | 438 | d.waitNotifies() | |
| 407 | 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 | 444 | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 413 | 445 | transport := &closeTrackingTransport{} | |
| 414 | 446 | ac.replaceTransport(transport) | |
| 415 | - | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 447 | + | rc := d.attachCoordinator(sess, ac, true) | |
| 416 | 448 | token := sess.attachmentToken(ac, transport) | |
| 417 | 449 | token.lease = rc.attachmentLease(ac) | |
| 418 | 450 | ac.publishAttachmentCapability(token) | |
| @@ -442,7 +474,7 @@ func TestAttachmentEffectGateReplacementInterruptsBlockedOldRenderBeforePublicat | |||
| 442 | 474 | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 443 | 475 | oldTransport := newBlockedRenderReplacementTransport(d, sess) | |
| 444 | 476 | old.replaceTransport(oldTransport) | |
| 445 | - | rc := d.attachCoordinator(sess, nil, old, true) | |
| 477 | + | rc := d.attachCoordinator(sess, old, true) | |
| 446 | 478 | lease := rc.attachmentLease(old) | |
| 447 | 479 | token := sess.attachmentToken(old, oldTransport) | |
| 448 | 480 | token.lease = lease | |
| @@ -497,7 +529,7 @@ func TestAttachmentEffectGateAdmittedEffectCompletesBeforeConflictingTransition( | |||
| 497 | 529 | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 498 | 530 | oldTransport := newDatagramTestTransport() | |
| 499 | 531 | old.replaceTransport(oldTransport) | |
| 500 | - | rc := d.attachCoordinator(sess, nil, old, true) | |
| 532 | + | rc := d.attachCoordinator(sess, old, true) | |
| 501 | 533 | token := sess.attachmentToken(old, oldTransport) | |
| 502 | 534 | token.lease = rc.attachmentLease(old) | |
| 503 | 535 | old.publishAttachmentCapability(token) | |
| @@ -598,7 +630,7 @@ func TestAttachmentEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *tes | |||
| 598 | 630 | oldTransport := newDatagramTestTransport() | |
| 599 | 631 | old.replaceTransport(oldTransport) | |
| 600 | 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 | 634 | token := sess.attachmentToken(old, oldTransport) | |
| 603 | 635 | token.lease = rc.attachmentLease(old) | |
| 604 | 636 | if tt.setup != nil { | |
| @@ -651,7 +683,7 @@ func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) { | |||
| 651 | 683 | target.tabs[1].attentionAt = time.Unix(1, 0) | |
| 652 | 684 | d.sessions[target.id] = target | |
| 653 | 685 | ||
| 654 | - | rc := d.attachCoordinator(source, nil, ac, true) | |
| 686 | + | rc := d.attachCoordinator(source, ac, true) | |
| 655 | 687 | token := source.attachmentToken(ac, ac.transport()) | |
| 656 | 688 | token.lease = rc.attachmentLease(ac) | |
| 657 | 689 | ac.publishAttachmentCapability(token) | |
| @@ -669,7 +701,7 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T) | |||
| 669 | 701 | d.enterPicker(sess, old) | |
| 670 | 702 | ||
| 671 | 703 | oldTransport := old.transport() | |
| 672 | - | rc := d.attachCoordinator(sess, nil, old, true) | |
| 704 | + | rc := d.attachCoordinator(sess, old, true) | |
| 673 | 705 | token := sess.attachmentToken(old, oldTransport) | |
| 674 | 706 | token.lease = rc.attachmentLease(old) | |
| 675 | 707 | old.publishAttachmentCapability(token) | |
| @@ -713,7 +745,7 @@ func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) { | |||
| 713 | 745 | defer release() | |
| 714 | 746 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 715 | 747 | d.enterPicker(sess, ac) | |
| 716 | - | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 748 | + | rc := d.attachCoordinator(sess, ac, true) | |
| 717 | 749 | token := sess.attachmentToken(ac, ac.transport()) | |
| 718 | 750 | token.lease = rc.attachmentLease(ac) | |
| 719 | 751 | ac.publishAttachmentCapability(token) | |
| @@ -730,7 +762,7 @@ func TestAttachmentEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *test | |||
| 730 | 762 | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 731 | 763 | oldTransport := newDatagramTestTransport() | |
| 732 | 764 | old.replaceTransport(oldTransport) | |
| 733 | - | rc := d.attachCoordinator(sess, nil, old, true) | |
| 765 | + | rc := d.attachCoordinator(sess, old, true) | |
| 734 | 766 | token := sess.attachmentToken(old, oldTransport) | |
| 735 | 767 | token.lease = rc.attachmentLease(old) | |
| 736 | 768 | ||
| @@ -764,7 +796,7 @@ func TestAttachmentEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testi | |||
| 764 | 796 | d, first, a, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 765 | 797 | aTransport := newDatagramTestTransport() | |
| 766 | 798 | a.replaceTransport(aTransport) | |
| 767 | - | d.attachCoordinator(first, nil, a, true) | |
| 799 | + | d.attachCoordinator(first, a, true) | |
| 768 | 800 | first.attachmentToken(a, aTransport) | |
| 769 | 801 | ||
| 770 | 802 | bTransport := newDatagramTestTransport() | |
| @@ -775,7 +807,7 @@ func TestAttachmentEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testi | |||
| 775 | 807 | d.mu.Lock() | |
| 776 | 808 | d.sessions[second.id] = second | |
| 777 | 809 | d.mu.Unlock() | |
| 778 | - | d.attachCoordinator(second, nil, b, true) | |
| 810 | + | d.attachCoordinator(second, b, true) | |
| 779 | 811 | second.attachmentToken(b, bTransport) | |
| 780 | 812 | ||
| 781 | 813 | start := make(chan struct{}) | |
| @@ -822,7 +854,7 @@ func TestAttachmentEffectGateFrozenConnectionRejectsLateEffect(t *testing.T) { | |||
| 822 | 854 | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 823 | 855 | transport := newDatagramTestTransport() | |
| 824 | 856 | ac.replaceTransport(transport) | |
| 825 | - | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 857 | + | rc := d.attachCoordinator(sess, ac, true) | |
| 826 | 858 | token := sess.attachmentToken(ac, transport) | |
| 827 | 859 | token.lease = rc.attachmentLease(ac) | |
| 828 | 860 | 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 done | 131 | 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.capability | 178 | 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] = unrelated | 323 | 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 = lease | 480 | 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] = target | 684 | 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] = second | 808 | 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 { | |||
| 22 | 22 | 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 | 25 | // viewInvalidation records the order in which attachment views observed one | |
| 33 | 26 | // shared session mutation. The order is stable client-ID order, not map | |
| 34 | 27 | // iteration or lock-acquisition order, which keeps concurrent attaches | |
| @@ -168,25 +161,6 @@ func (c *sessionCore) snapshotAttachmentsLocked() []*attachedClient { | |||
| 168 | 161 | 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 | 164 | func attachmentRegisteredLocked(entry attachmentSession, ac *attachedClient) bool { | |
| 191 | 165 | if entry == nil || ac == nil || entry.core() == nil { | |
| 192 | 166 | return false | |
| @@ -244,18 +218,6 @@ func (s *session) snapshotAttachments() []*attachedClient { | |||
| 244 | 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 | 221 | // repairAttachmentViewLocked validates stable targets and chooses the nearest | |
| 260 | 222 | // surviving target when a tab or pane was removed. Caller holds s.mu. | |
| 261 | 223 | func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachmentView) attachmentView { | |
| @@ -302,12 +264,10 @@ func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachment | |||
| 302 | 264 | } else { | |
| 303 | 265 | view.paneID = "" | |
| 304 | 266 | } | |
| 305 | - | targetTab.mu.Unlock() | |
| 306 | 267 | if view.windowRows <= 0 { | |
| 307 | - | targetTab.mu.Lock() | |
| 308 | 268 | view.windowRows = targetTab.size.Rows | |
| 309 | - | targetTab.mu.Unlock() | |
| 310 | 269 | } | |
| 270 | + | targetTab.mu.Unlock() | |
| 311 | 271 | if view.windowTop < 0 { | |
| 312 | 272 | view.windowTop = 0 | |
| 313 | 273 | } | |
| @@ -402,7 +362,6 @@ func (s *session) tabForAttachment(ac *attachedClient) *tab { | |||
| 402 | 362 | if s == nil || ac == nil { | |
| 403 | 363 | return nil | |
| 404 | 364 | } | |
| 405 | - | s.repairAttachmentView(ac) | |
| 406 | 365 | s.mu.Lock() | |
| 407 | 366 | defer s.mu.Unlock() | |
| 408 | 367 | if _, ok := s.attachments[ac]; !ok { | |
| @@ -618,8 +577,8 @@ func (s *session) repairAttachmentViews() []viewInvalidation { | |||
| 618 | 577 | } | |
| 619 | 578 | ||
| 620 | 579 | // 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. | |
| 623 | 582 | func (s *session) repairAttachmentViewsLocked() []viewInvalidation { | |
| 624 | 583 | if s == nil { | |
| 625 | 584 | return nil | |
| @@ -22,13 +22,6 @@ type attachmentView struct { | |||||
| 22 | revision uint64 | 22 | 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 one | 25 | // viewInvalidation records the order in which attachment views observed one | ||
| 33 | // shared session mutation. The order is stable client-ID order, not map | 26 | // shared session mutation. The order is stable client-ID order, not map | ||
| 34 | // iteration or lock-acquisition order, which keeps concurrent attaches | 27 | // iteration or lock-acquisition order, which keeps concurrent attaches | ||
| @@ -168,25 +161,6 @@ func (c *sessionCore) snapshotAttachmentsLocked() []*attachedClient { | |||||
| 168 | return out | 161 | 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 false | 166 | 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 nearest | 221 | // 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.Rows | 268 | 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 = 0 | 272 | 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 nil | 363 | 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 tab | 579 | // repairAttachmentViewsLocked repairs all live attachment targets after a tab | ||
| 621 | - | // or pane mutation. It is intended to run at the same mutation linearization | 580 | + | // 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 nil | 584 | return nil | ||
M internal/usecase/daemon/attachment_registry_test.go +1 -7
| @@ -123,16 +123,10 @@ func TestAttachmentRegistryConcurrentRegistrationSnapshotIsStable(t *testing.T) | |||
| 123 | 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 | 127 | for i, want := range []byte{1, 2, 3} { | |
| 128 | 128 | if invalidations[i].attachment.clientID[0] != want { | |
| 129 | 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 | - | } | |
| @@ -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) ( | |||
| 111 | 111 | ||
| 112 | 112 | // freezeAttachmentTransition drains the snapshotted connection without any | |
| 113 | 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 | 115 | if d.afterAttachmentEffectParticipantsSnapshotted != nil { | |
| 116 | 116 | d.afterAttachmentEffectParticipantsSnapshotted(req.action, participants.clients) | |
| 117 | 117 | } | |
| @@ -121,13 +121,12 @@ func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, par | |||
| 121 | 121 | } | |
| 122 | 122 | }}, participants.clients...) | |
| 123 | 123 | if !frozen.acquired || !frozen.drained { | |
| 124 | - | return frozen, false, errAttachmentTransition | |
| 124 | + | return frozen, errAttachmentTransition | |
| 125 | 125 | } | |
| 126 | - | interrupted := frozen.interrupted(nil, transportSnapshot{}) | |
| 127 | 126 | if d.afterAttachmentEffectsFrozen != nil { | |
| 128 | 127 | d.afterAttachmentEffectsFrozen() | |
| 129 | 128 | } | |
| 130 | - | return frozen, interrupted, nil | |
| 129 | + | return frozen, nil | |
| 131 | 130 | } | |
| 132 | 131 | ||
| 133 | 132 | // publishAttachmentTransition revalidates the frozen source, creates an | |
| @@ -159,7 +158,7 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme | |||
| 159 | 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 | 162 | defer frozen.unfreeze() | |
| 164 | 163 | if err != nil { | |
| 165 | 164 | return attachmentTransitionResult{}, err | |
| @@ -111,7 +111,7 @@ func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) ( | |||||
| 111 | 111 | ||||
| 112 | // freezeAttachmentTransition drains the snapshotted connection without any | 112 | // 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, errAttachmentTransition | 124 | + | 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, nil | 129 | + | return frozen, nil |
| 131 | } | 130 | } | ||
| 132 | 131 | ||||
| 133 | // publishAttachmentTransition revalidates the frozen source, creates an | 132 | // 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{}, err | 164 | return attachmentTransitionResult{}, err | ||
M internal/usecase/daemon/client.go +23 -20
| @@ -43,7 +43,7 @@ type attachedClient struct { | |||
| 43 | 43 | overlayOnce sync.Once | |
| 44 | 44 | clientID [16]byte | |
| 45 | 45 | // 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 | |
| 47 | 47 | // transition freezes and drains this gate before changing either generation | |
| 48 | 48 | // or the session/coordinator registries. | |
| 49 | 49 | connectionGeneration atomic.Uint64 | |
| @@ -583,13 +583,13 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size, | |||
| 583 | 583 | // attachCoordinator installs the coordinator lease for an attachment. Session | |
| 584 | 584 | // membership remains independent, so installing one attachment never displaces | |
| 585 | 585 | // 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) | |
| 588 | 588 | cleanup.finish() | |
| 589 | 589 | 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 | 593 | rc := d.ensureRenderCoordinator(sess) | |
| 594 | 594 | if current != nil { | |
| 595 | 595 | rc.attachWithReadiness(current, ready) | |
| @@ -631,7 +631,7 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess | |||
| 631 | 631 | // attachment's membership and transport fence after that snapshot, so | |
| 632 | 632 | // a detached peer cannot stop remaining attachments receiving output. | |
| 633 | 633 | attachments := snapshotAttachmentSession(entry) | |
| 634 | - | for index, attachment := range attachments { | |
| 634 | + | for _, attachment := range attachments { | |
| 635 | 635 | if !rc.wakeCurrent(w) { | |
| 636 | 636 | return | |
| 637 | 637 | } | |
| @@ -639,24 +639,27 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess | |||
| 639 | 639 | if lease == nil || !rc.leaseCurrent(lease, true) { | |
| 640 | 640 | continue | |
| 641 | 641 | } | |
| 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) | |
| 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 | 663 | installAttachmentRenderCoordinator(entry, rc) | |
| 661 | 664 | return rc | |
| 662 | 665 | } | |
| @@ -756,7 +759,7 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi | |||
| 756 | 759 | if lease == nil { | |
| 757 | 760 | d.invalidateRenderNow(sess, ac, true, "client.go") | |
| 758 | 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 | } | |
| @@ -43,7 +43,7 @@ type attachedClient struct { | |||||
| 43 | overlayOnce sync.Once | 43 | overlayOnce sync.Once | ||
| 44 | clientID [16]byte | 44 | clientID [16]byte | ||
| 45 | // connectionGeneration is the wire-facing capability generation. attachmentEffects is | 45 | // 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 |
| 47 | // transition freezes and drains this gate before changing either generation | 47 | // 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.Uint64 | 49 | 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. Session | 583 | // attachCoordinator installs the coordinator lease for an attachment. Session | ||
| 584 | // membership remains independent, so installing one attachment never displaces | 584 | // 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 rc | 589 | 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, so | 631 | // 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 | return | 636 | 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 | continue | 640 | continue | ||
| 641 | } | 641 | } | ||
| 642 | - | // Pane damage is session-shared; later attachments need a complete | 642 | + | // 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 rc | 664 | 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 | |||
| 218 | 218 | ||
| 219 | 219 | // The frame's attachment token is the target capability. No registry/name lookup is | |
| 220 | 220 | // 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) | |
| 225 | 223 | }) | |
| 226 | 224 | if err == nil { | |
| 227 | 225 | 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 is | 219 | // 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 error | 221 | + | 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 = true | 225 | 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 { | |||
| 203 | 203 | } | |
| 204 | 204 | ||
| 205 | 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 | 207 | token := sess.attachmentToken(ac, tr) | |
| 208 | 208 | token.lease = rc.attachmentLease(ac) | |
| 209 | 209 | 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 | |||
| 161 | 161 | if len(locals) != 1 { | |
| 162 | 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 { | |
| 165 | - | return locals[0], 0, "" | |
| 166 | - | } | |
| 167 | - | return nil, ports.ErrNoSuchTarget, "no live sessions" | |
| 164 | + | return locals[0], 0, "" | |
| 168 | 165 | } | |
| 169 | 166 | ||
| 170 | 167 | func (s *session) containsStableIDs(tabID, paneID string) bool { | |
| @@ -257,6 +254,7 @@ type daemonActionTarget struct { | |||
| 257 | 254 | type daemonActionRequest struct { | |
| 258 | 255 | kind daemonActionKind | |
| 259 | 256 | target daemonActionTarget | |
| 257 | + | effect *attachmentEffectTicket | |
| 260 | 258 | direction layout.Direction | |
| 261 | 259 | axis layout.Axis | |
| 262 | 260 | delta int | |
| @@ -300,7 +298,7 @@ func (a daemonActions) Run(request daemonActionRequest) error { | |||
| 300 | 298 | case daemonActionCreateTab: | |
| 301 | 299 | return a.d.createTabForAttachment(target.session, target.attachment, request.viewport) | |
| 302 | 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 | 302 | case daemonActionSplitPane: | |
| 305 | 303 | return a.d.splitPaneAt(target.session, target.tab, target.pane, request.direction) | |
| 306 | 304 | case daemonActionStackPane: | |
| @@ -314,7 +312,7 @@ func (a daemonActions) Run(request daemonActionRequest) error { | |||
| 314 | 312 | if !a.d.hasDaemonActionPaneTarget(target) { | |
| 315 | 313 | 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 | 316 | return err | |
| 319 | 317 | } | |
| 320 | 318 | if a.d.hasDaemonActionPaneTarget(target) { | |
| @@ -454,8 +452,17 @@ func (e controlExec) MovePane(destinationSession, destinationTabID string) error | |||
| 454 | 452 | if e.sess == nil || e.tab == nil || e.target.pane == nil { | |
| 455 | 453 | 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 | 463 | return e.d.movePane(movePaneRequest{ | |
| 458 | - | Attachment: e.target.attachment, | |
| 464 | + | Attachment: attachment, | |
| 465 | + | AttachmentToken: token, | |
| 459 | 466 | Source: sessionMoveLocator(e.sess), | |
| 460 | 467 | SourceTabID: domain.TabStableID(e.tab.stableID), | |
| 461 | 468 | SourcePaneID: domain.PaneStableID(e.target.pane.stableID), | |
| @@ -472,11 +479,20 @@ func (e controlExec) MoveTab(destinationSession string) error { | |||
| 472 | 479 | if e.sess == nil || e.tab == nil { | |
| 473 | 480 | 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 | 490 | 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), | |
| 480 | 496 | }) | |
| 481 | 497 | } | |
| 482 | 498 | ||
| @@ -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 daemonActionKind | 255 | kind daemonActionKind | ||
| 259 | target daemonActionTarget | 256 | target daemonActionTarget | ||
| 257 | + | effect *attachmentEffectTicket | |||
| 260 | direction layout.Direction | 258 | direction layout.Direction | ||
| 261 | axis layout.Axis | 259 | axis layout.Axis | ||
| 262 | delta int | 260 | 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.ErrNotFound | 313 | 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 err | 316 | 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 errMovePaneInvalid | 453 | 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 errMovePaneInvalid | 480 | 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) { | |||
| 814 | 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 | 838 | func TestHandleCommandMovePaneUsesActiveFocusedSourceWithSessionFlag(t *testing.T) { | |
| 818 | 839 | d := newTestDaemon(t, nil, stubClock{}) | |
| 819 | 840 | source := addControlSession(d, "work", "t_active", "p_active") | |
| @@ -889,28 +910,29 @@ func TestHandleCommandMovePaneSelfUsesStableSourceIDs(t *testing.T) { | |||
| 889 | 910 | func TestHandleCommandMoveTabUsesActiveTabWithoutSelf(t *testing.T) { | |
| 890 | 911 | d := newTestDaemon(t, nil, stubClock{}) | |
| 891 | 912 | source := addControlSession(d, "work", "t_first", "p_first") | |
| 913 | + | publishTiledPaneOwners(source, source.tabs[0]) | |
| 892 | 914 | second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 893 | 915 | second.ctx, second.cancel = context.WithCancel(d.serveCtx) | |
| 894 | 916 | publishTiledPaneOwners(source, second) | |
| 895 | 917 | source.mu.Lock() | |
| 896 | 918 | source.tabs = append(source.tabs, second) | |
| 897 | - | selectTestAttachmentTabLocked(source, 1) | |
| 919 | + | selectTestAttachmentTabLocked(source, 0) | |
| 898 | 920 | source.mu.Unlock() | |
| 899 | 921 | destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |
| 900 | 922 | ||
| 901 | 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 | 925 | TargetTab: "t_second", TargetPane: "p_second", | |
| 904 | 926 | }) | |
| 905 | 927 | require.True(t, result.OK, result.Text) | |
| 906 | 928 | ||
| 907 | 929 | source.mu.Lock() | |
| 908 | 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 | 932 | source.mu.Unlock() | |
| 911 | 933 | destination.mu.Lock() | |
| 912 | 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 | 936 | destination.mu.Unlock() | |
| 915 | 937 | } | |
| 916 | 938 | ||
| @@ -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 { | |||
| 163 | 163 | // afterAttachmentTransitionCoordinatorsLocked is a deterministic lock-order | |
| 164 | 164 | // seam used by transition validation tests. | |
| 165 | 165 | 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 | |
| 168 | 169 | // attachment snapshot respectively. beforeMovePaneCommit runs inside the | |
| 169 | 170 | // non-failing publication section. None is set in production, and none may | |
| 170 | 171 | // perform external work while locks are held. | |
| 172 | + | beforeMoveDispatch func() | |
| 171 | 173 | afterMoveLifecycleReserved func() | |
| 172 | 174 | afterMoveLifecycleGateBeforeTeardownLocks func() | |
| 173 | 175 | afterMovePaneSourceSnapshot func() | |
| @@ -163,11 +163,13 @@ type Daemon struct { | |||||
| 163 | // afterAttachmentTransitionCoordinatorsLocked is a deterministic lock-order | 163 | // 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-only | 166 | + | // beforeMoveDispatch, afterMoveLifecycleReserved, and |
| 167 | - | // seams. They run before role/fence work and after the pre-fence source | 167 | + | // 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 the | 169 | // attachment snapshot respectively. beforeMovePaneCommit runs inside the | ||
| 169 | // non-failing publication section. None is set in production, and none may | 170 | // 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 | |||
| 178 | 178 | if d == nil || sess == nil || tb == nil { | |
| 179 | 179 | return false | |
| 180 | 180 | } | |
| 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. | |
| 190 | 183 | if rc := sess.renderCoordinator(); rc != nil && rc.opts.onActivateTabAfterResize != nil { | |
| 191 | 184 | rc.opts.onActivateTabAfterResize(lease != nil) | |
| 192 | 185 | } | |
| @@ -196,11 +189,24 @@ func (d *Daemon) activateTabAfterResizeForLease(sess *session, tb *tab, outerRes | |||
| 196 | 189 | return false | |
| 197 | 190 | } | |
| 198 | 191 | } | |
| 199 | - | d.exitCopyMode(ac) | |
| 192 | + | d.ensureFloatingWarm(sess, tb) | |
| 200 | 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 | 207 | return false | |
| 202 | 208 | } | |
| 203 | - | d.ensureFloatingWarm(sess, tb) | |
| 209 | + | d.exitCopyMode(ac) | |
| 204 | 210 | if outerResizeAccepted { | |
| 205 | 211 | return false | |
| 206 | 212 | } | |
| @@ -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 false | 179 | return false | ||
| 180 | } | 180 | } | ||
| 181 | - | // A headless session has no actual terminal destination. Deferring warmup | 181 | + | // A headless session has no attachment-local copy mode to clear. Keep its |
| 182 | - | // until firstPaint keeps restored tabs cold and avoids launching children | 182 | + | // 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 false | 189 | 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 false | 207 | return false | ||
| 202 | } | 208 | } | ||
| 203 | - | d.ensureFloatingWarm(sess, tb) | 209 | + | d.exitCopyMode(ac) |
| 204 | if outerResizeAccepted { | 210 | if outerResizeAccepted { | ||
| 205 | return false | 211 | return false | ||
| 206 | } | 212 | } | ||
M internal/usecase/daemon/input.go +3 -2
| @@ -395,8 +395,8 @@ type daemonKeyHandler struct { | |||
| 395 | 395 | } | |
| 396 | 396 | ||
| 397 | 397 | // 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. | |
| 400 | 400 | func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) { | |
| 401 | 401 | if h.connectionToken.ac != nil { | |
| 402 | 402 | if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() { | |
| @@ -459,6 +459,7 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) { | |||
| 459 | 459 | defer effect.End() | |
| 460 | 460 | } | |
| 461 | 461 | runAction := func(request daemonActionRequest) { | |
| 462 | + | request.effect = effect | |
| 462 | 463 | runner := h.actions | |
| 463 | 464 | if runner == nil { | |
| 464 | 465 | 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 and | 397 | // 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.actions | 463 | 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 | |||
| 15 | 15 | ac := &attachedClient{tr: transport} | |
| 16 | 16 | ac.setSession(source) | |
| 17 | 17 | source.mu.Lock() | |
| 18 | - | require.True(t, source.registerAttachmentLocked(ac)) | |
| 18 | + | registered := source.registerAttachmentLocked(ac) | |
| 19 | 19 | source.mu.Unlock() | |
| 20 | + | require.True(t, registered) | |
| 20 | 21 | sourceCoordinator := newRenderCoordinator(renderCoordinatorOptions{}) | |
| 21 | 22 | source.installRenderCoordinator(sourceCoordinator) | |
| 22 | 23 | sourceCoordinator.attach(ac) | |
| @@ -26,10 +27,11 @@ func TestValidateAttachmentTransitionLocksSourceCoordinatorBeforeLeaseValidation | |||
| 26 | 27 | d.sessions[target.id] = target | |
| 27 | 28 | ||
| 28 | 29 | checked := false | |
| 30 | + | coordinatorUnlocked := false | |
| 29 | 31 | d.afterAttachmentTransitionCoordinatorsLocked = func() { | |
| 30 | - | if sourceCoordinator.mu.TryLock() { | |
| 32 | + | coordinatorUnlocked = sourceCoordinator.mu.TryLock() | |
| 33 | + | if coordinatorUnlocked { | |
| 31 | 34 | sourceCoordinator.mu.Unlock() | |
| 32 | - | t.Fatalf("source coordinator was not held during lease validation") | |
| 33 | 35 | } | |
| 34 | 36 | checked = true | |
| 35 | 37 | } | |
| @@ -45,10 +47,13 @@ func TestValidateAttachmentTransitionLocksSourceCoordinatorBeforeLeaseValidation | |||
| 45 | 47 | unlockSessions() | |
| 46 | 48 | d.notices.routingMu.Unlock() | |
| 47 | 49 | d.mu.Unlock() | |
| 50 | + | if publication != nil { | |
| 51 | + | publication.unlockCoordinators() | |
| 52 | + | } | |
| 48 | 53 | require.NoError(t, err) | |
| 54 | + | require.False(t, coordinatorUnlocked, "source coordinator was not held during lease validation") | |
| 49 | 55 | require.True(t, checked) | |
| 50 | 56 | require.NotNil(t, publication) | |
| 51 | - | publication.unlockCoordinators() | |
| 52 | 57 | } | |
| 53 | 58 | ||
| 54 | 59 | func TestAttachmentTokenRevalidatesTheCapturedTransportIncarnation(t *testing.T) { | |
| @@ -58,8 +63,9 @@ func TestAttachmentTokenRevalidatesTheCapturedTransportIncarnation(t *testing.T) | |||
| 58 | 63 | sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}} | |
| 59 | 64 | ac.setSession(sess) | |
| 60 | 65 | sess.mu.Lock() | |
| 61 | - | require.True(t, sess.registerAttachmentLocked(ac)) | |
| 66 | + | registered := sess.registerAttachmentLocked(ac) | |
| 62 | 67 | sess.mu.Unlock() | |
| 68 | + | require.True(t, registered) | |
| 63 | 69 | ||
| 64 | 70 | captured := make(chan struct{}) | |
| 65 | 71 | 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] = target | 27 | d.sessions[target.id] = target | ||
| 27 | 28 | ||||
| 28 | checked := false | 29 | 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 = true | 36 | 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) { | |||
| 46 | 46 | 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 | 63 | reservation, err := d.reserveMoveLifecycles(source, destination) | |
| 50 | 64 | if err != nil { | |
| 51 | 65 | return errMoveStaleTarget | |
| @@ -60,14 +74,6 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) { | |||
| 60 | 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 | 77 | // Snapshot the exact live objects and generations before waiting on resize | |
| 72 | 78 | // fences. The locked commit repeats every authority check before publication. | |
| 73 | 79 | admission, err := d.snapshotMovePaneAdmission(req, source, destination) | |
| @@ -46,6 +46,20 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) { | |||||
| 46 | return errMoveStaleTarget | 46 | 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 errMoveStaleTarget | 65 | 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 resize | 77 | // 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 | |||
| 29 | 29 | if sess == nil || len(sess.attachments) == 0 { | |
| 30 | 30 | return nil | |
| 31 | 31 | } | |
| 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 { | |
| 34 | 35 | retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: transports[ac]}) | |
| 35 | 36 | ac.connectionGeneration.Add(1) | |
| 36 | 37 | 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 nil | 30 | 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 ( | |||
| 9 | 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 | 29 | func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) { | |
| 13 | 30 | movedPTY, releaseMoved := newBlockingPTY(t) | |
| 14 | 31 | remainingPTY, releaseRemaining := newBlockingPTY(t) | |
| @@ -18,7 +35,7 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) { | |||
| 18 | 35 | defer releaseDestination() | |
| 19 | 36 | ||
| 20 | 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 | 39 | sourceTab := source.tabs[0] | |
| 23 | 40 | sourceTab.stableID = "source-tab" | |
| 24 | 41 | moved := sourceTab.focusedPane() | |
| @@ -100,7 +117,7 @@ func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) { | |||
| 100 | 117 | sourceTab.stableID = "source-tab" | |
| 101 | 118 | remainingTab.stableID = "remaining-tab" | |
| 102 | 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 | 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 | 123 | 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 | |||
| 19 | 19 | sourceTab := source.tabs[0] | |
| 20 | 20 | sourceTab.stableID = "source-tab" | |
| 21 | 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 | 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 | 25 | destinationTab := destination.tabs[0] | |
| @@ -111,6 +111,139 @@ func TestMovePaneGateReservationRaceKillWinsWithoutDeadlock(t *testing.T) { | |||
| 111 | 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 | 247 | func TestMovePaneGateReservationRaceMoveWinsWithoutDeadlock(t *testing.T) { | |
| 115 | 248 | d, source, sourceTab, moved, destination, destinationTab, releasePTYs := newMoveGateRaceFixture(t) | |
| 116 | 249 | 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) { | |||
| 50 | 50 | source := moveSessionForLocatorLocked(d, req.Source) | |
| 51 | 51 | destination := moveSessionForLocatorLocked(d, req.Destination) | |
| 52 | 52 | 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 { | |
| 57 | 54 | 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 | 73 | reservation, err := d.reserveMoveLifecycles(source, destination) | |
| 60 | 74 | if err != nil { | |
| 61 | 75 | return errMoveStaleTarget | |
| @@ -69,13 +83,6 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) { | |||
| 69 | 83 | if d.afterMoveLifecycleReserved != nil { | |
| 70 | 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 | 87 | admission, err := d.snapshotMoveTabAdmission(req, source, destination) | |
| 81 | 88 | 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 errMoveStaleTarget | 54 | 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 errMoveStaleTarget | 75 | 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 | |||
| 152 | 152 | d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 153 | 153 | moved := source.tabs[0] | |
| 154 | 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 | 156 | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 157 | 157 | floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8}) | |
| 158 | 158 | moved.mu.Lock() | |
| @@ -453,7 +453,7 @@ func (r *moveTabPurgeRepository) DeleteIncarnation(_ context.Context, id domain. | |||
| 453 | 453 | ||
| 454 | 454 | func TestMoveTabFinalNamedSourcePurgesAfterDestinationAdmissionAndFailureDoesNotRollback(t *testing.T) { | |
| 455 | 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 | 457 | source.incarnation = domain.IncarnationID{4} | |
| 458 | 458 | moved := source.tabs[0] | |
| 459 | 459 | 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) { | |||
| 84 | 84 | defer sess.mu.Unlock() | |
| 85 | 85 | tb := sess.tabs[0] | |
| 86 | 86 | 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 | + | } | |
| 90 | 92 | tb.mu.Unlock() | |
| 93 | + | if closeErr != nil { | |
| 94 | + | return closeErr | |
| 95 | + | } | |
| 91 | 96 | sess.repairAttachmentViewsLocked() | |
| 92 | 97 | return nil | |
| 93 | 98 | })) | |
| @@ -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 nil | 97 | return nil | ||
| 93 | })) | 98 | })) | ||
M internal/usecase/daemon/navigation_attachment_handoff_test.go +2 -2
| @@ -97,7 +97,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |||
| 97 | 97 | d.sessions[target.id] = target | |
| 98 | 98 | d.mu.Unlock() | |
| 99 | 99 | ||
| 100 | - | rc := d.attachCoordinator(source, nil, old, true) | |
| 100 | + | rc := d.attachCoordinator(source, old, true) | |
| 101 | 101 | token := source.attachmentToken(old, old.transport()) | |
| 102 | 102 | token.lease = rc.attachmentLease(old) | |
| 103 | 103 | old.publishAttachmentCapability(token) | |
| @@ -152,7 +152,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing. | |||
| 152 | 152 | d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 7, state: ports.SessionStopped} | |
| 153 | 153 | d.mu.Unlock() | |
| 154 | 154 | ||
| 155 | - | rc := d.attachCoordinator(source, nil, old, true) | |
| 155 | + | rc := d.attachCoordinator(source, old, true) | |
| 156 | 156 | token := source.attachmentToken(old, old.transport()) | |
| 157 | 157 | token.lease = rc.attachmentLease(old) | |
| 158 | 158 | old.publishAttachmentCapability(token) | |
| @@ -97,7 +97,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |||||
| 97 | d.sessions[target.id] = target | 97 | 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 | |||
| 47 | 47 | d.sessions[target.id] = target | |
| 48 | 48 | d.mu.Unlock() | |
| 49 | 49 | ||
| 50 | - | rc := d.attachCoordinator(source, nil, ac, true) | |
| 50 | + | rc := d.attachCoordinator(source, ac, true) | |
| 51 | 51 | token := source.attachmentToken(ac, ac.transport()) | |
| 52 | 52 | token.lease = rc.attachmentLease(ac) | |
| 53 | 53 | ac.publishAttachmentCapability(token) | |
| @@ -47,7 +47,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing | |||||
| 47 | d.sessions[target.id] = target | 47 | 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 | |||
| 143 | 143 | sess.mu.Unlock() | |
| 144 | 144 | return tabOverflowCandidate{}, false | |
| 145 | 145 | } | |
| 146 | - | sourceIndex := 0 | |
| 146 | + | sourceIndex := -1 | |
| 147 | 147 | for i, tb := range sess.tabs { | |
| 148 | 148 | if tb == expectedSource { | |
| 149 | 149 | sourceIndex = i | |
| 150 | 150 | break | |
| 151 | 151 | } | |
| 152 | 152 | } | |
| 153 | + | if sourceIndex < 0 { | |
| 154 | + | sess.mu.Unlock() | |
| 155 | + | return tabOverflowCandidate{}, false | |
| 156 | + | } | |
| 153 | 157 | targetIndex := sourceIndex + delta | |
| 154 | 158 | if targetIndex < 0 || targetIndex >= len(sess.tabs) { | |
| 155 | 159 | 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{}, false | 144 | return tabOverflowCandidate{}, false | ||
| 145 | } | 145 | } | ||
| 146 | - | sourceIndex := 0 | 146 | + | 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 = i | 149 | sourceIndex = i | ||
| 150 | break | 150 | break | ||
| 151 | } | 151 | } | ||
| 152 | } | 152 | } | ||
| 153 | + | if sourceIndex < 0 { | |||
| 154 | + | sess.mu.Unlock() | |||
| 155 | + | return tabOverflowCandidate{}, false | |||
| 156 | + | } | |||
| 153 | targetIndex := sourceIndex + delta | 157 | 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) { | |||
| 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 | 59 | func TestKeyboardHorizontalOverflowLandsOnFacingEdge(t *testing.T) { | |
| 46 | 60 | tests := []struct { | |
| 47 | 61 | 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 string | 61 | name string | ||
M internal/usecase/daemon/notify_test.go +1 -1
| @@ -57,7 +57,7 @@ func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing. | |||
| 57 | 57 | ||
| 58 | 58 | func TestMalformedClientNoticeIsIgnored(t *testing.T) { | |
| 59 | 59 | d, sess, ac, _ := newNoticeFixture(t, ¬iceClock{}) | |
| 60 | - | d.attachCoordinator(sess, nil, ac, true) | |
| 60 | + | d.attachCoordinator(sess, ac, true) | |
| 61 | 61 | tr, ok := ac.tr.(*portsmocks.MockTransport) | |
| 62 | 62 | require.True(t, ok, "attached client transport must be a MockTransport") | |
| 63 | 63 | 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, ¬iceClock{}) | 59 | d, sess, ac, _ := newNoticeFixture(t, ¬iceClock{}) | ||
| 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 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "sync/atomic" | |
| 5 | + | ||
| 4 | 6 | "github.com/bnema/vev/internal/ports" | |
| 5 | 7 | "github.com/bnema/vev/pkg/renderer" | |
| 6 | 8 | ) | |
| @@ -15,11 +17,13 @@ import ( | |||
| 15 | 17 | // Callers serialize access with attachedClient.sendMu. A prepared transaction | |
| 16 | 18 | // must be sent or committed as a no-op before the next transaction is prepared. | |
| 17 | 19 | 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 | |
| 23 | 27 | } | |
| 24 | 28 | ||
| 25 | 29 | func newOutputStateStream(windowSize ...uint8) *outputStateStream { | |
| @@ -27,10 +31,13 @@ func newOutputStateStream(windowSize ...uint8) *outputStateStream { | |||
| 27 | 31 | if len(windowSize) > 0 { | |
| 28 | 32 | window = normalizeOutputWindow(windowSize[0]) | |
| 29 | 33 | } | |
| 30 | - | return &outputStateStream{ | |
| 34 | + | stream := &outputStateStream{ | |
| 31 | 35 | renderer: renderer.New(renderer.Capabilities{}), | |
| 32 | 36 | maxOutstanding: uint64(window), | |
| 33 | 37 | } | |
| 38 | + | stream.maxOutstandingAtomic.Store(uint64(window)) | |
| 39 | + | stream.publishOutstanding() | |
| 40 | + | return stream | |
| 34 | 41 | } | |
| 35 | 42 | ||
| 36 | 43 | type preparedOutput struct { | |
| @@ -73,6 +80,7 @@ func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame | |||
| 73 | 80 | } | |
| 74 | 81 | p.commit() | |
| 75 | 82 | p.stream.next = p.next | |
| 83 | + | p.stream.publishOutstanding() | |
| 76 | 84 | return nil | |
| 77 | 85 | } | |
| 78 | 86 | ||
| @@ -98,16 +106,39 @@ func (s *outputStateStream) ack(state uint64) { | |||
| 98 | 106 | return | |
| 99 | 107 | } | |
| 100 | 108 | s.acked = state | |
| 109 | + | s.publishOutstanding() | |
| 101 | 110 | } | |
| 102 | 111 | ||
| 103 | 112 | func (s *outputStateStream) rebase() { | |
| 104 | 113 | s.acked = s.next | |
| 114 | + | s.publishOutstanding() | |
| 115 | + | s.maxOutstandingAtomic.Store(s.maxOutstanding) | |
| 105 | 116 | s.renderer.Reset() | |
| 106 | 117 | 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 | 137 | 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() | |
| 111 | 142 | } | |
| 112 | 143 | ||
| 113 | 144 | func (s *outputStateStream) outstanding() uint64 { return s.next - s.acked } | |
| @@ -1,6 +1,8 @@ | |||||
| 1 | package daemon | 1 | 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 transaction | 17 | // 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.Renderer | 20 | + | renderer *renderer.Renderer |
| 19 | - | next uint64 | 21 | + | next uint64 |
| 20 | - | acked uint64 | 22 | + | acked uint64 |
| 21 | - | maxOutstanding uint64 | 23 | + | maxOutstanding uint64 |
| 22 | - | forceSnapshot bool | 24 | + | 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.next | 82 | p.stream.next = p.next | ||
| 83 | + | p.stream.publishOutstanding() | |||
| 76 | return nil | 84 | return nil | ||
| 77 | } | 85 | } | ||
| 78 | 86 | ||||
| @@ -98,16 +106,39 @@ func (s *outputStateStream) ack(state uint64) { | |||||
| 98 | return | 106 | return | ||
| 99 | } | 107 | } | ||
| 100 | s.acked = state | 108 | s.acked = state | ||
| 109 | + | s.publishOutstanding() | |||
| 101 | } | 110 | } | ||
| 102 | 111 | ||||
| 103 | func (s *outputStateStream) rebase() { | 112 | func (s *outputStateStream) rebase() { | ||
| 104 | s.acked = s.next | 113 | s.acked = s.next | ||
| 114 | + | s.publishOutstanding() | |||
| 115 | + | s.maxOutstandingAtomic.Store(s.maxOutstanding) | |||
| 105 | s.renderer.Reset() | 116 | s.renderer.Reset() | ||
| 106 | s.forceSnapshot = true | 117 | 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.maxOutstanding | 138 | + | if s == nil { |
| 139 | + | return false | |||
| 140 | + | } | |||
| 141 | + | return s.outstandingAtomic.Load() >= s.maxOutstandingAtomic.Load() | |||
| 111 | } | 142 | } | ||
| 112 | 143 | ||||
| 113 | func (s *outputStateStream) outstanding() uint64 { return s.next - s.acked } | 144 | func (s *outputStateStream) outstanding() uint64 { return s.next - s.acked } | ||
M internal/usecase/daemon/output_state_stream_test.go +39 -0
| @@ -2,6 +2,7 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | + | "runtime" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/vev/internal/ports" | |
| @@ -35,6 +36,44 @@ func TestOutputStateStreamBuildsPipelinedDependencyChain(t *testing.T) { | |||
| 35 | 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 | 77 | func TestOutputStateStreamFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T) { | |
| 39 | 78 | stream := newOutputStateStream() | |
| 40 | 79 | 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 { | |||
| 500 | 500 | } | |
| 501 | 501 | ||
| 502 | 502 | func (e paletteExec) runAction(request daemonActionRequest) error { | |
| 503 | + | request.effect = e.effect | |
| 503 | 504 | if request.target.session == nil { | |
| 504 | 505 | request.target = resolveDaemonActionTargetForAttachment(e.sess, e.ac) | |
| 505 | 506 | } | |
| @@ -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 { | |||
| 241 | 241 | return true | |
| 242 | 242 | } | |
| 243 | 243 | 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 | 249 | sess.mu.Lock() | |
| 245 | 250 | ownedTab := slices.Contains(sess.tabs, tb) | |
| 246 | 251 | if p.owner.Load() != owner { | |
| @@ -285,11 +290,16 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool { | |||
| 285 | 290 | p.clearOwnerLocked() | |
| 286 | 291 | p.mu.Unlock() | |
| 287 | 292 | tb.mu.Unlock() | |
| 293 | + | sess.invalidateViewsLocked() | |
| 288 | 294 | sess.mu.Unlock() | |
| 289 | - | sess.repairAttachmentViews() | |
| 290 | 295 | ||
| 291 | 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 | 303 | return true | |
| 294 | 304 | } | |
| 295 | 305 | d.applyTabLayout(sess, tb) | |
| @@ -310,7 +320,25 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool { | |||
| 310 | 320 | 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 | 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 | 342 | if tb == nil { | |
| 315 | 343 | 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 | 359 | viewer.pruneCaptureFrames(p) | |
| 332 | 360 | } | |
| 333 | - | return d.closeTab(sess, tb, repaint) | |
| 361 | + | return d.closeTabLockedWithEffect(sess, tb, repaint, effect) | |
| 334 | 362 | } | |
| 335 | 363 | if err := tb.tree.Close(id); err != nil { | |
| 336 | 364 | tb.mu.Unlock() | |
| @@ -359,6 +387,10 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||
| 359 | 387 | if repaint { | |
| 360 | 388 | if ac != nil { | |
| 361 | 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 | 396 | return nil | |
| @@ -459,7 +491,7 @@ func (d *Daemon) finishPaneFocusForClient(sess *session, ac *attachedClient, tb | |||
| 459 | 491 | if moved { | |
| 460 | 492 | d.exitCopyMode(ac) | |
| 461 | 493 | if hasPlacement && pl.InStack { | |
| 462 | - | d.refreshPaneTitleOnFocus(sess, newFocus) | |
| 494 | + | d.refreshPaneTitleOnFocus(sess, newFocus, tb) | |
| 463 | 495 | } | |
| 464 | 496 | } | |
| 465 | 497 | d.invalidateRender(sess, ac, true, producer) | |
| @@ -241,6 +241,11 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool { | |||||
| 241 | return true | 241 | return true | ||
| 242 | } | 242 | } | ||
| 243 | sess, tb := owner.session, owner.tab | 243 | 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 true | 303 | 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 true | 320 | 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.ErrNotFound | 343 | 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 nil | 396 | 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) { | |||
| 635 | 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 | 748 | func TestCloseFocusedPaneRemovesPaneReflowsAndIsIdempotent(t *testing.T) { | |
| 639 | 749 | d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 640 | 750 | 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 { | |||
| 137 | 137 | return fallback | |
| 138 | 138 | } | |
| 139 | 139 | ||
| 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...) | |
| 142 | 142 | } | |
| 143 | 143 | ||
| 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...) | |
| 146 | 146 | } | |
| 147 | 147 | ||
| 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 { | |
| 150 | 152 | fallback := d.paneTitleFallback() | |
| 151 | 153 | if sess == nil { | |
| 152 | 154 | return fallback | |
| 153 | 155 | } | |
| 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() | |
| 157 | 161 | } | |
| 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) | |
| 163 | 175 | } | |
| 164 | - | p.setDisplayFallback(fallback) | |
| 165 | - | return d.refreshPaneDisplayTitle(sess, p, force) | |
| 176 | + | return title | |
| 166 | 177 | } | |
| 167 | 178 | ||
| 168 | 179 | // refreshPaneDisplayTitle refreshes only the process-name portion of p's | |
| @@ -137,32 +137,43 @@ func (d *Daemon) paneTitleFallback() string { | |||||
| 137 | return fallback | 137 | 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 fallback | 154 | 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 fallback | 158 | + | 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 fallback | 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) | |||
| 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's | 179 | // 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) { | |||
| 89 | 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 | 113 | func TestRefreshPaneTitleCachesByTTLAndRefreshesOnFocus(t *testing.T) { | |
| 93 | 114 | pty := portsmocks.NewMockPTY(t) | |
| 94 | 115 | 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 | |||
| 1456 | 1456 | compose: func() { fixture.renderCompositions++ }, | |
| 1457 | 1457 | emit: func() { fixture.renderEmissions++ }, | |
| 1458 | 1458 | } | |
| 1459 | - | d.attachCoordinator(sess, nil, ac, true) | |
| 1459 | + | d.attachCoordinator(sess, ac, true) | |
| 1460 | 1460 | ||
| 1461 | 1461 | // Prime the real renderer shadow before measurements. Subsequent paints use | |
| 1462 | 1462 | // 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 use | 1461 | // 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) { | |||
| 423 | 423 | if targetSess == ac.currentAttachmentSession() { | |
| 424 | 424 | return | |
| 425 | 425 | } | |
| 426 | - | rc := d.attachCoordinator(targetSess, nil, nil, false) | |
| 426 | + | rc := d.attachCoordinator(targetSess, nil, false) | |
| 427 | 427 | rc.subscribePreviewFor(ac, generation, func(renderWake) { | |
| 428 | 428 | if pickerPreviewCurrent(ac, targetSess, next, generation) { | |
| 429 | 429 | if owner := ac.currentAttachmentSession(); owner != nil { | |
| @@ -640,7 +640,7 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnecti | |||
| 640 | 640 | activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true, | |
| 641 | 641 | }) | |
| 642 | 642 | 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 | |
| 644 | 644 | // whichever attachment replaced the initiator. | |
| 645 | 645 | if !token.attachmentCurrent() { | |
| 646 | 646 | return nil | |
| @@ -692,8 +692,8 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta | |||
| 692 | 692 | } | |
| 693 | 693 | ||
| 694 | 694 | // 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 | |
| 697 | 697 | // before publishing ownership. | |
| 698 | 698 | func (d *Daemon) switchToRemoteTargetForAttachment(token attachmentConnectionToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, action string) error { | |
| 699 | 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 | 1086 | // stealClientForTarget is retained for direct-ID callers and tests. Named | |
| 1087 | 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 | 1089 | d.mu.Lock() | |
| 1090 | 1090 | _, transition, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{}, nil, "") | |
| 1091 | 1091 | d.mu.Unlock() | |
| 1092 | 1092 | if !switched { | |
| 1093 | - | return nil | |
| 1093 | + | return | |
| 1094 | 1094 | } | |
| 1095 | 1095 | d.deferAttachmentTransitionCleanups(transition) | |
| 1096 | - | return nil | |
| 1097 | 1096 | } | |
| 1098 | 1097 | ||
| 1099 | 1098 | // 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 | return | 424 | 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 for | 643 | + | // 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 nil | 646 | 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 has | 694 | // switchToRemoteTargetForAttachment performs dialing after the initiating effect has | ||
| 695 | - | // ended and without role or architecture locks. transitionAttachment then | 695 | + | // ended and without attachment-effect or architecture locks. transitionAttachment then |
| 696 | - | // freezes and revalidates the exact source role and exact structured-key proxy | 696 | + | // 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. Named | 1086 | // 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 nil | 1093 | + | 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 resolves | 1098 | // 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 | |||
| 1550 | 1550 | current.mu.Lock() | |
| 1551 | 1551 | current.registerAttachmentLocked(ac) | |
| 1552 | 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 | 1555 | d.enterPicker(current, ac) | |
| 1556 | 1556 | 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) { | |||
| 205 | 205 | func TestProxiedTransactionalResizeUsesReceivedContentGeometry(t *testing.T) { | |
| 206 | 206 | d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 207 | 207 | ac.proxied = true | |
| 208 | - | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 208 | + | rc := d.attachCoordinator(sess, ac, true) | |
| 209 | 209 | lease := rc.attachmentLease(ac) | |
| 210 | 210 | require.NotNil(t, lease) | |
| 211 | 211 | ||
| @@ -262,7 +262,7 @@ func TestProxiedModeCannotChangeAcrossResume(t *testing.T) { | |||
| 262 | 262 | func TestOutputResetRebasesFullWindowAndSchedulesBaseZeroPaint(t *testing.T) { | |
| 263 | 263 | d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 264 | 264 | ac.proxied = true | |
| 265 | - | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 265 | + | rc := d.attachCoordinator(sess, ac, true) | |
| 266 | 266 | lease := rc.attachmentLease(ac) | |
| 267 | 267 | require.NotNil(t, lease) | |
| 268 | 268 | ||
| @@ -304,7 +304,7 @@ func TestOutputResetRequiresProxiedActiveRoleAndStrictPayload(t *testing.T) { | |||
| 304 | 304 | t.Run(tt.name, func(t *testing.T) { | |
| 305 | 305 | d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 306 | 306 | ac.proxied = tt.proxied | |
| 307 | - | d.attachCoordinator(sess, nil, ac, true) | |
| 307 | + | d.attachCoordinator(sess, ac, true) | |
| 308 | 308 | ac.sendMu.Lock() | |
| 309 | 309 | ac.output.next, ac.output.acked = 4, 0 | |
| 310 | 310 | ac.sendMu.Unlock() | |
| @@ -325,7 +325,7 @@ func TestOutputResetRequiresProxiedActiveRoleAndStrictPayload(t *testing.T) { | |||
| 325 | 325 | func TestOutputResetRevalidatesTransportUnderSendLock(t *testing.T) { | |
| 326 | 326 | d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 327 | 327 | ac.proxied = true | |
| 328 | - | d.attachCoordinator(sess, nil, ac, true) | |
| 328 | + | d.attachCoordinator(sess, ac, true) | |
| 329 | 329 | ac.sendMu.Lock() | |
| 330 | 330 | ac.output.next, ac.output.acked = 4, 0 | |
| 331 | 331 | 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 = true | 207 | 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 = true | 264 | 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.proxied | 306 | 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, 0 | 309 | 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 = true | 327 | 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, 0 | 330 | 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 | |||
| 232 | 232 | if target.pane != nil { | |
| 233 | 233 | 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 | 236 | if err == nil { | |
| 237 | 237 | if ac != nil { | |
| 238 | 238 | 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.id | 233 | 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 | |||
| 516 | 516 | sess.tabs = append(sess.tabs, second) | |
| 517 | 517 | selectTestAttachmentTabLocked(sess, 0) | |
| 518 | 518 | sess.mu.Unlock() | |
| 519 | - | d.attachCoordinator(sess, nil, ac, true) | |
| 519 | + | d.attachCoordinator(sess, ac, true) | |
| 520 | 520 | ||
| 521 | 521 | send := func(request ports.CommandRequest) ports.CommandResult { | |
| 522 | 522 | payload, err := ports.MarshalCommandRequest(request) | |
| @@ -620,7 +620,7 @@ func TestProxyPickerSelectionDialsOutsideLocksAndRevalidatesExactRoleAndKey(t *t | |||
| 620 | 620 | dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once() | |
| 621 | 621 | d.remoteDialerFactory = factory | |
| 622 | 622 | d.remoteTransportMode = ports.RemoteTransportUDP | |
| 623 | - | d.attachCoordinator(source, nil, ac, true) | |
| 623 | + | d.attachCoordinator(source, ac, true) | |
| 624 | 624 | ||
| 625 | 625 | token := source.attachmentToken(ac, ac.transport()) | |
| 626 | 626 | effect, admitted := ac.beginAttachmentEffect(token) | |
| @@ -656,7 +656,7 @@ func TestProxyPickerSelectionDialsOutsideLocksAndRevalidatesExactRoleAndKey(t *t | |||
| 656 | 656 | ||
| 657 | 657 | func TestRemotePickerEnterRoutesStructuredKeyThroughProxyOwnership(t *testing.T) { | |
| 658 | 658 | d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 659 | - | d.attachCoordinator(local, nil, ac, true) | |
| 659 | + | d.attachCoordinator(local, ac, true) | |
| 660 | 660 | key := domain.RemoteSessionKey{Host: "arch", Name: "enter"} | |
| 661 | 661 | transport := newProxyTestTransport() | |
| 662 | 662 | transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | |
| @@ -803,7 +803,7 @@ func TestRemotePickerRejectsReplacedLocalTab(t *testing.T) { | |||
| 803 | 803 | ||
| 804 | 804 | func TestRemotePickerCanReselectWarmProxyAfterReturningLocal(t *testing.T) { | |
| 805 | 805 | d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 806 | - | d.attachCoordinator(local, nil, ac, true) | |
| 806 | + | d.attachCoordinator(local, ac, true) | |
| 807 | 807 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |
| 808 | 808 | remote := newProxyTestTransport() | |
| 809 | 809 | remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | |
| @@ -895,7 +895,7 @@ func TestConnectionLoopRetriesRoleResolutionAcrossHandoff(t *testing.T) { | |||
| 895 | 895 | d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 896 | 896 | client := newProxyTestTransport() | |
| 897 | 897 | ac.replaceTransport(client) | |
| 898 | - | d.attachCoordinator(local, nil, ac, true) | |
| 898 | + | d.attachCoordinator(local, ac, true) | |
| 899 | 899 | ||
| 900 | 900 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |
| 901 | 901 | remote := newProxyTestTransport() | |
| @@ -934,7 +934,7 @@ func TestConnectionLoopCleansCurrentProxyAfterHandoffReceiveError(t *testing.T) | |||
| 934 | 934 | d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 935 | 935 | client := newProxyTestTransport() | |
| 936 | 936 | ac.replaceTransport(client) | |
| 937 | - | d.attachCoordinator(local, nil, ac, true) | |
| 937 | + | d.attachCoordinator(local, ac, true) | |
| 938 | 938 | ||
| 939 | 939 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |
| 940 | 940 | remote := newProxyTestTransport() | |
| @@ -991,7 +991,7 @@ func TestConnectionLoopFollowsClientFromLocalToRemoteProxy(t *testing.T) { | |||
| 991 | 991 | d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 992 | 992 | client := newProxyTestTransport() | |
| 993 | 993 | ac.replaceTransport(client) | |
| 994 | - | d.attachCoordinator(local, nil, ac, true) | |
| 994 | + | d.attachCoordinator(local, ac, true) | |
| 995 | 995 | ||
| 996 | 996 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |
| 997 | 997 | 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 = factory | 621 | d.remoteDialerFactory = factory | ||
| 622 | d.remoteTransportMode = ports.RemoteTransportUDP | 622 | 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) { | |||
| 174 | 174 | clock := newProxyLifecycleClock() | |
| 175 | 175 | d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 176 | 176 | d.clock = clock | |
| 177 | - | localCoordinator := d.attachCoordinator(local, nil, ac, true) | |
| 177 | + | localCoordinator := d.attachCoordinator(local, ac, true) | |
| 178 | 178 | localToken := attachmentToken(local, ac, ac.transport()) | |
| 179 | 179 | localToken.lease = localCoordinator.attachmentLease(ac) | |
| 180 | 180 | ||
| @@ -466,7 +466,7 @@ func TestProxyLinkStateDisplayAndExactGeneration(t *testing.T) { | |||
| 466 | 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 | 470 | d := newTestDaemon(t, nil, newProxyLifecycleClock()) | |
| 471 | 471 | sess := &session{sessionCore: sessionCore{id: "work", name: "work"}} | |
| 472 | 472 | oldTransport := newProxyTestTransport() | |
| @@ -477,7 +477,7 @@ func TestIncomingDirectAttachTerminatesProxiedRemoteAttachment(t *testing.T) { | |||
| 477 | 477 | d.mu.Lock() | |
| 478 | 478 | require.True(t, d.registerSessionLocked(sess)) | |
| 479 | 479 | d.mu.Unlock() | |
| 480 | - | d.attachCoordinator(sess, nil, old, true) | |
| 480 | + | d.attachCoordinator(sess, old, true) | |
| 481 | 481 | ||
| 482 | 482 | newTransport := newProxyTestTransport() | |
| 483 | 483 | next := &attachedClient{tr: newTransport, output: newOutputStateStream()} | |
| @@ -535,7 +535,7 @@ func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T) | |||
| 535 | 535 | clock := newProxyLifecycleClock() | |
| 536 | 536 | d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 537 | 537 | d.clock = clock | |
| 538 | - | coordinator := d.attachCoordinator(local, nil, ac, true) | |
| 538 | + | coordinator := d.attachCoordinator(local, ac, true) | |
| 539 | 539 | localToken := attachmentToken(local, ac, ac.transport()) | |
| 540 | 540 | localToken.lease = coordinator.attachmentLease(ac) | |
| 541 | 541 | ||
| @@ -1271,18 +1271,34 @@ func TestOpenProxySessionSameKeyWaiterCanCancel(t *testing.T) { | |||
| 1271 | 1271 | ||
| 1272 | 1272 | type warmHookClock struct { | |
| 1273 | 1273 | timers chan *signalTimer | |
| 1274 | + | ||
| 1275 | + | mu sync.Mutex | |
| 1276 | + | hook func() | |
| 1274 | 1277 | } | |
| 1275 | 1278 | ||
| 1276 | 1279 | func (c *warmHookClock) Now() time.Time { return time.Time{} } | |
| 1277 | 1280 | ||
| 1278 | 1281 | func (c *warmHookClock) NewTimer(d time.Duration) ports.Timer { | |
| 1279 | 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 | 1290 | if c.timers != nil { | |
| 1281 | 1291 | c.timers <- timer | |
| 1282 | 1292 | } | |
| 1283 | 1293 | 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 | 1302 | func registerLifecycleProxy(t *testing.T, d *Daemon, key domain.RemoteSessionKey) *proxySession { | |
| 1287 | 1303 | t.Helper() | |
| 1288 | 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 | 1354 | // a re-arm that loses its revalidation must leave the incumbent timer both | |
| 1339 | 1355 | // installed and current, otherwise a registered proxy is left with no path out | |
| 1340 | 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 | 1417 | func TestDetachProxyIfCurrentTransportArmsExpiredProxy(t *testing.T) { | |
| 1343 | 1418 | d, p, clock := newProxyLifecycleFixture(t) | |
| @@ -1475,7 +1550,7 @@ func newProxyRoleDetachFixture(t *testing.T) (*Daemon, *proxySession, *attachedC | |||
| 1475 | 1550 | d.clock = clock | |
| 1476 | 1551 | clientTransport := &closeTrackingTransport{} | |
| 1477 | 1552 | ac.replaceTransport(clientTransport) | |
| 1478 | - | localCoordinator := d.attachCoordinator(local, nil, ac, true) | |
| 1553 | + | localCoordinator := d.attachCoordinator(local, ac, true) | |
| 1479 | 1554 | localToken := attachmentToken(local, ac, clientTransport) | |
| 1480 | 1555 | localToken.lease = localCoordinator.attachmentLease(ac) | |
| 1481 | 1556 | 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 = clock | 176 | 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 = clock | 537 | 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 *signalTimer | 1273 | 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 <- timer | 1291 | c.timers <- timer | ||
| 1282 | } | 1292 | } | ||
| 1283 | return timer | 1293 | 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 both | 1354 | // 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 out | 1355 | // 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 = clock | 1550 | 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 | |||
| 651 | 651 | } | |
| 652 | 652 | } | |
| 653 | 653 | ||
| 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. | |
| 658 | 657 | func (d *Daemon) handleProxySideEffect(p *proxySession, generation uint64, out ports.Output) error { | |
| 659 | 658 | if p == nil || !p.currentLinkGeneration(generation) { | |
| 660 | 659 | return nil | |
| 661 | 660 | } | |
| 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 | + | } | |
| 665 | 669 | } | |
| 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) { | |
| 668 | 675 | return nil | |
| 669 | 676 | } | |
| 670 | 677 | expected := ac.transportSnapshot() | |
| @@ -672,7 +679,7 @@ func (d *Daemon) handleProxySideEffect(p *proxySession, generation uint64, out p | |||
| 672 | 679 | return nil | |
| 673 | 680 | } | |
| 674 | 681 | token := attachmentToken(p, ac, expected.transport) | |
| 675 | - | if token.sess != p { | |
| 682 | + | if token.sess != p || !token.current() { | |
| 676 | 683 | return nil | |
| 677 | 684 | } | |
| 678 | 685 | ticket, admitted := ac.beginAttachmentEffect(token) | |
| @@ -680,10 +687,13 @@ func (d *Daemon) handleProxySideEffect(p *proxySession, generation uint64, out p | |||
| 680 | 687 | return nil | |
| 681 | 688 | } | |
| 682 | 689 | defer ticket.End() | |
| 683 | - | if !p.currentLinkGeneration(generation) { | |
| 690 | + | if !p.currentLinkGeneration(generation) || !token.current() { | |
| 684 | 691 | return nil | |
| 685 | 692 | } | |
| 686 | 693 | frame := ac.output.sideEffect(out.Data, ac.echoAck.Load()) | |
| 694 | + | if !p.currentLinkGeneration(generation) || !token.current() { | |
| 695 | + | return nil | |
| 696 | + | } | |
| 687 | 697 | if err := ac.sendExpectedTransportForAttachment(expected, frame, ticket); err != nil { | |
| 688 | 698 | if errors.Is(err, errAttachmentTransition) { | |
| 689 | 699 | 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 local | 654 | + | // handleProxySideEffect fans out a stateless remote effect to every attachment |
| 655 | - | // attachment that currently owns this proxy. Connection admission and transport | 655 | + | // currently owning this proxy. Each attachment gets an independent capability, |
| 656 | - | // incarnation checks make a stale handoff a harmless drop rather than a send | 656 | + | // 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 nil | 659 | 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 nil | 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 | + | } | |||
| 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 nil | 675 | 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 nil | 679 | 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 nil | 683 | 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 nil | 687 | 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 nil | 691 | 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 nil | 699 | return nil | ||
M internal/usecase/daemon/proxy_output_test.go +63 -0
| @@ -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 | 1053 | func TestProxySideEffectRejectsStateBearingAndMalformedMsgOutput(t *testing.T) { | |
| 991 | 1054 | tests := []struct { | |
| 992 | 1055 | 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 string | 1055 | name string | ||
M internal/usecase/daemon/proxy_render_test.go +1 -1
| @@ -101,7 +101,7 @@ func TestProxyRenderPaintsAfterLocalToRemoteHandoff(t *testing.T) { | |||
| 101 | 101 | ||
| 102 | 102 | func TestProxyRenderTransitionsPaintBothLocalAndRemoteSessions(t *testing.T) { | |
| 103 | 103 | d, local, ac, sent := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 104 | - | localCoordinator := d.attachCoordinator(local, nil, ac, true) | |
| 104 | + | localCoordinator := d.attachCoordinator(local, ac, true) | |
| 105 | 105 | localToken := attachmentToken(local, ac, ac.transport()) | |
| 106 | 106 | localToken.lease = localCoordinator.attachmentLease(ac) | |
| 107 | 107 | ||
| @@ -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, | |||
| 404 | 404 | hasFloating := tb.floating.state == floatingVisible && floating != nil | |
| 405 | 405 | tb.mu.Unlock() | |
| 406 | 406 | for _, id := range titleIDs { | |
| 407 | - | d.refreshPaneTitle(sess, id) | |
| 407 | + | d.refreshPaneTitle(sess, id, tb) | |
| 408 | 408 | } | |
| 409 | 409 | if hasFloating { | |
| 410 | 410 | 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 != nil | 404 | 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 { | |||
| 25 | 25 | // viewers may consume a coalesced target snapshot while output is | |
| 26 | 26 | // still ACK-blocked. | |
| 27 | 27 | 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 | |
| 31 | 45 | // ackBlocked retains the first rejected ACK-capacity probe until the | |
| 32 | 46 | // matching consume or lifecycle closure releases that exact interval. | |
| 33 | 47 | ackBlocked *ackBlockedSpan | |
| @@ -161,9 +175,14 @@ func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpoch(source *att | |||
| 161 | 175 | } | |
| 162 | 176 | reservation := &renderInvalidationReservation{coordinator: c, invalidation: inv, onInvalidate: c.opts.onInvalidate} | |
| 163 | 177 | c.metrics.invalidations.Add(1) | |
| 178 | + | c.pendingGeneration++ | |
| 179 | + | if c.pendingGeneration == 0 { | |
| 180 | + | c.pendingGeneration = 1 | |
| 181 | + | } | |
| 164 | 182 | wasPending, wasUrgent, wasPreviewPending := c.pending, c.pendingUrgent, c.pendingPreview | |
| 165 | 183 | if !wasPending { | |
| 166 | 184 | c.ackDeferred = false | |
| 185 | + | c.ackDeferredFor = nil | |
| 167 | 186 | c.deadlineDue = false | |
| 168 | 187 | if c.opts.observer != nil { | |
| 169 | 188 | c.queueMarked = true | |
| @@ -173,11 +192,26 @@ func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpoch(source *att | |||
| 173 | 192 | } | |
| 174 | 193 | } | |
| 175 | 194 | 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 | 210 | c.pendingReset = c.pendingReset || inv.reset | |
| 177 | 211 | c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent | |
| 178 | 212 | c.pendingPreview = c.pendingPreview || len(c.previewWakes) != 0 | |
| 179 | 213 | 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 | |
| 181 | 215 | if reservation.arm { | |
| 182 | 216 | _, reservation.old = c.normalLane.replaceLocked() | |
| 183 | 217 | } | |
| @@ -250,8 +284,8 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie | |||
| 250 | 284 | } | |
| 251 | 285 | ||
| 252 | 286 | // 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. | |
| 255 | 289 | func (c *renderCoordinator) notifyAck() { c.notifyAckForLease(nil) } | |
| 256 | 290 | ||
| 257 | 291 | // notifyAckForLease prevents an acknowledgement captured by an obsolete active | |
| @@ -259,11 +293,16 @@ func (c *renderCoordinator) notifyAck() { c.notifyAckForLease(nil) } | |||
| 259 | 293 | // reserved for coordinator-owned/direct test callers. | |
| 260 | 294 | func (c *renderCoordinator) notifyAckForLease(lease *attachmentLease) { | |
| 261 | 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 | 302 | c.mu.Unlock() | |
| 264 | 303 | return | |
| 265 | 304 | } | |
| 266 | - | if (!c.ackDeferred && !c.deadlineDue) || !c.pending { | |
| 305 | + | if !c.pending { | |
| 267 | 306 | c.mu.Unlock() | |
| 268 | 307 | return | |
| 269 | 308 | } | |
| @@ -541,6 +580,12 @@ func (c *renderCoordinator) installLeaseLocked(ac *attachedClient, ready bool) * | |||
| 541 | 580 | } | |
| 542 | 581 | lease := &attachmentLease{attachment: ac, ready: ready, active: true} | |
| 543 | 582 | c.leases[ac] = lease | |
| 583 | + | if c.delivered != nil { | |
| 584 | + | delete(c.delivered, ac) | |
| 585 | + | } | |
| 586 | + | if c.ackDeferredFor != nil { | |
| 587 | + | delete(c.ackDeferredFor, ac) | |
| 588 | + | } | |
| 544 | 589 | return lease | |
| 545 | 590 | } | |
| 546 | 591 | ||
| @@ -667,6 +712,8 @@ func (c *renderCoordinator) beginDetachLocked(ac *attachedClient) renderLifecycl | |||
| 667 | 712 | lease.active = false | |
| 668 | 713 | delete(c.leases, ac) | |
| 669 | 714 | } | |
| 715 | + | delete(c.delivered, ac) | |
| 716 | + | delete(c.ackDeferredFor, ac) | |
| 670 | 717 | // Shared pending output belongs to the session, not the detached | |
| 671 | 718 | // attachment. Keep it alive while another attachment remains registered. | |
| 672 | 719 | if len(c.attachments) != 0 { | |
| @@ -686,7 +733,11 @@ func (c *renderCoordinator) resetAttachmentLifecycleLocked() renderLifecycleClea | |||
| 686 | 733 | c.pendingReset = false | |
| 687 | 734 | c.pendingUrgent = false | |
| 688 | 735 | c.ackDeferred = false | |
| 736 | + | c.ackDeferredFor = nil | |
| 737 | + | c.delivered = nil | |
| 689 | 738 | c.pendingPreview = false | |
| 739 | + | c.pendingShared = false | |
| 740 | + | c.pendingTargets = nil | |
| 690 | 741 | c.coalesced = 0 | |
| 691 | 742 | _, timer := c.normalLane.replaceLocked() | |
| 692 | 743 | c.armed = false | |
| @@ -728,7 +779,11 @@ func (c *renderCoordinator) beginSessionTeardown() renderLifecycleCleanup { | |||
| 728 | 779 | _, resizeTimer := c.resizeLane.replaceLocked() | |
| 729 | 780 | _, retryTimer := c.retryLane.replaceLocked() | |
| 730 | 781 | c.ackDeferred = false | |
| 782 | + | c.ackDeferredFor = nil | |
| 783 | + | c.delivered = nil | |
| 731 | 784 | c.pendingPreview = false | |
| 785 | + | c.pendingShared = false | |
| 786 | + | c.pendingTargets = nil | |
| 732 | 787 | _, timer := c.normalLane.replaceLocked() | |
| 733 | 788 | c.armed = false | |
| 734 | 789 | workers := c.detachSyncTimersLocked() | |
| @@ -804,35 +859,73 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) { | |||
| 804 | 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 | 873 | // fireFromTimer consumes a normal deadline using its exact immutable token. | |
| 808 | 874 | func (c *renderCoordinator) fireFromTimer(token *timerToken, watchdog, deadline bool) { | |
| 809 | 875 | c.fireWithTimerToken(token, token.generation, watchdog, deadline) | |
| 810 | 876 | } | |
| 811 | 877 | ||
| 812 | 878 | 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. | |
| 815 | 895 | c.mu.Lock() | |
| 816 | 896 | if !c.fireValidLocked(token, gen, watchdog) { | |
| 817 | 897 | c.mu.Unlock() | |
| 818 | 898 | return | |
| 819 | 899 | } | |
| 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 | |
| 829 | 907 | } | |
| 830 | 908 | } | |
| 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 | |
| 834 | 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 | 930 | // The readiness callback can detach, replace, or publish a sync batch. | |
| 838 | 931 | // syncGateOpenLocked retries predicate evaluation on every registry change | |
| @@ -850,70 +943,171 @@ func (c *renderCoordinator) fireWithTimerToken(token *timerToken, gen uint64, wa | |||
| 850 | 943 | c.mu.Unlock() | |
| 851 | 944 | return | |
| 852 | 945 | } | |
| 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 | + | } | |
| 856 | 955 | 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 { | |
| 861 | 981 | 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 | 1016 | 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 | |
| 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 | 1036 | c.mu.Unlock() | |
| 1037 | + | stopDetachedTimer(worker) | |
| 880 | 1038 | if ackStart != nil { | |
| 881 | 1039 | ackStart.publishStart() | |
| 882 | 1040 | } | |
| 883 | - | c.notifyPreviews(w, preview, previews) | |
| 1041 | + | c.notifyPreviews(w, previews) | |
| 884 | 1042 | return | |
| 885 | 1043 | } | |
| 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-- | |
| 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 | 1090 | observer, queueCorrelation, queueMarked := c.opts.observer, c.queueCorrelation, c.queueMarked | |
| 902 | 1091 | ackBlocked := c.ackBlocked | |
| 903 | - | c.queueMarked, c.ackBlocked = false, nil | |
| 1092 | + | if complete { | |
| 1093 | + | c.queueMarked, c.ackBlocked = false, nil | |
| 1094 | + | } | |
| 904 | 1095 | wake := c.opts.wake | |
| 905 | 1096 | c.mu.Unlock() | |
| 906 | 1097 | stopDetachedTimer(worker) | |
| 907 | - | if queueMarked && observer != nil { | |
| 1098 | + | if ackStart != nil { | |
| 1099 | + | ackStart.publishStart() | |
| 1100 | + | } | |
| 1101 | + | if complete && queueMarked && observer != nil { | |
| 908 | 1102 | observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueDequeued, 0, true)) | |
| 909 | 1103 | } | |
| 910 | - | if ackBlocked != nil { | |
| 1104 | + | if complete && ackBlocked != nil { | |
| 911 | 1105 | ackBlocked.finish(true) | |
| 912 | 1106 | } | |
| 913 | - | if wake != nil { | |
| 1107 | + | if outputReady && wake != nil { | |
| 914 | 1108 | wake(w) | |
| 915 | 1109 | } | |
| 916 | - | c.notifyPreviews(w, preview, previews) | |
| 1110 | + | c.notifyPreviews(w, previews) | |
| 917 | 1111 | } | |
| 918 | 1112 | ||
| 919 | 1113 | func (c *renderCoordinator) fireValidLocked(token *timerToken, gen uint64, watchdog bool) bool { | |
| @@ -957,19 +1151,19 @@ func (c *renderCoordinator) syncGateOpenLocked() bool { | |||
| 957 | 1151 | ||
| 958 | 1152 | // takePendingPreviewsLocked snapshots and consumes picker preview delivery | |
| 959 | 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 | 1155 | if !c.pendingPreview { | |
| 962 | - | return nil, nil | |
| 1156 | + | return nil | |
| 963 | 1157 | } | |
| 964 | 1158 | c.pendingPreview = false | |
| 965 | 1159 | previews := make([]func(renderWake), 0, len(c.previewWakes)) | |
| 966 | 1160 | for _, subscription := range c.previewWakes { | |
| 967 | 1161 | previews = append(previews, subscription.fn) | |
| 968 | 1162 | } | |
| 969 | - | return nil, previews | |
| 1163 | + | 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 | 1167 | for _, fn := range previews { | |
| 974 | 1168 | fn(w) | |
| 975 | 1169 | } | |
| @@ -25,9 +25,23 @@ type renderCoordinator struct { | |||||
| 25 | // viewers may consume a coalesced target snapshot while output is | 25 | // viewers may consume a coalesced target snapshot while output is | ||
| 26 | // still ACK-blocked. | 26 | // still ACK-blocked. | ||
| 27 | pendingPreview bool | 27 | pendingPreview bool | ||
| 28 | - | // ackDeferred records that an expired deadline or watchdog was blocked by | 28 | + | // 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 bool | 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 | |||
| 31 | // ackBlocked retains the first rejected ACK-capacity probe until the | 45 | // 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 *ackBlockedSpan | 47 | 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.pendingPreview | 182 | wasPending, wasUrgent, wasPreviewPending := c.pending, c.pendingUrgent, c.pendingPreview | ||
| 165 | if !wasPending { | 183 | if !wasPending { | ||
| 166 | c.ackDeferred = false | 184 | c.ackDeferred = false | ||
| 185 | + | c.ackDeferredFor = nil | |||
| 167 | c.deadlineDue = false | 186 | c.deadlineDue = false | ||
| 168 | if c.opts.observer != nil { | 187 | if c.opts.observer != nil { | ||
| 169 | c.queueMarked = true | 188 | c.queueMarked = true | ||
| @@ -173,11 +192,26 @@ func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpoch(source *att | |||||
| 173 | } | 192 | } | ||
| 174 | } | 193 | } | ||
| 175 | c.pending = true | 194 | 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.reset | 210 | c.pendingReset = c.pendingReset || inv.reset | ||
| 177 | c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent | 211 | c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent | ||
| 178 | c.pendingPreview = c.pendingPreview || len(c.previewWakes) != 0 | 212 | 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.deadlineDue | 214 | + | 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, releasing | 286 | // notifyAck reports that the client acknowledged an output state, releasing | ||
| 253 | - | // at most one deadline/watchdog-deferred wake. It never bypasses an unexpired | 287 | + | // 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 active | 291 | // 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 | return | 303 | return | ||
| 265 | } | 304 | } | ||
| 266 | - | if (!c.ackDeferred && !c.deadlineDue) || !c.pending { | 305 | + | if !c.pending { |
| 267 | c.mu.Unlock() | 306 | c.mu.Unlock() | ||
| 268 | return | 307 | 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] = lease | 582 | c.leases[ac] = lease | ||
| 583 | + | if c.delivered != nil { | |||
| 584 | + | delete(c.delivered, ac) | |||
| 585 | + | } | |||
| 586 | + | if c.ackDeferredFor != nil { | |||
| 587 | + | delete(c.ackDeferredFor, ac) | |||
| 588 | + | } | |||
| 544 | return lease | 589 | return lease | ||
| 545 | } | 590 | } | ||
| 546 | 591 | ||||
| @@ -667,6 +712,8 @@ func (c *renderCoordinator) beginDetachLocked(ac *attachedClient) renderLifecycl | |||||
| 667 | lease.active = false | 712 | 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 detached | 717 | // 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 = false | 733 | c.pendingReset = false | ||
| 687 | c.pendingUrgent = false | 734 | c.pendingUrgent = false | ||
| 688 | c.ackDeferred = false | 735 | c.ackDeferred = false | ||
| 736 | + | c.ackDeferredFor = nil | |||
| 737 | + | c.delivered = nil | |||
| 689 | c.pendingPreview = false | 738 | c.pendingPreview = false | ||
| 739 | + | c.pendingShared = false | |||
| 740 | + | c.pendingTargets = nil | |||
| 690 | c.coalesced = 0 | 741 | c.coalesced = 0 | ||
| 691 | _, timer := c.normalLane.replaceLocked() | 742 | _, timer := c.normalLane.replaceLocked() | ||
| 692 | c.armed = false | 743 | 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 = false | 781 | c.ackDeferred = false | ||
| 782 | + | c.ackDeferredFor = nil | |||
| 783 | + | c.delivered = nil | |||
| 731 | c.pendingPreview = false | 784 | c.pendingPreview = false | ||
| 785 | + | c.pendingShared = false | |||
| 786 | + | c.pendingTargets = nil | |||
| 732 | _, timer := c.normalLane.replaceLocked() | 787 | _, timer := c.normalLane.replaceLocked() | ||
| 733 | c.armed = false | 788 | 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 read | 879 | + | 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 | return | 898 | 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 is | 901 | + | c.pendingGeneration = 1 |
| 822 | - | // ready: a deadline that expires during the handshake must complete and | 902 | + | } |
| 823 | - | // leave work pending, rather than wedging behind Welcome. | 903 | + | leases := make(map[*attachedClient]*attachmentLease, len(c.leases)) |
| 824 | - | hasLiveAttachment, allAttachmentsReady := false, true | 904 | + | 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 && !allAttachmentsReady | 909 | + | previewOnly := len(leases) == 0 && len(c.previewWakes) != 0 |
| 832 | - | previewOnly := !hasLiveAttachment && len(c.previewWakes) != 0 | 910 | + | 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 change | 931 | // 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 | return | 944 | 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 object | 947 | + | // 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]*attachmentLease | 955 | 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] = lease | 981 | 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 *ackBlockedSpan | 1018 | + | 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 | return | 1042 | 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 - 1 | 1049 | + | } |
| 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.queueMarked | 1090 | observer, queueCorrelation, queueMarked := c.opts.observer, c.queueCorrelation, c.queueMarked | ||
| 902 | ackBlocked := c.ackBlocked | 1091 | ackBlocked := c.ackBlocked | ||
| 903 | - | c.queueMarked, c.ackBlocked = false, nil | 1092 | + | if complete { |
| 1093 | + | c.queueMarked, c.ackBlocked = false, nil | |||
| 1094 | + | } | |||
| 904 | wake := c.opts.wake | 1095 | 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 delivery | 1152 | // 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, nil | 1156 | + | return nil |
| 963 | } | 1157 | } | ||
| 964 | c.pendingPreview = false | 1158 | 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, previews | 1163 | + | 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 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "io" | |
| 5 | + | "sync" | |
| 4 | 6 | "testing" | |
| 5 | 7 | "time" | |
| 6 | 8 | ||
| 7 | 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 | 53 | // Fresh producer state may arrive after route has published an attachment but | |
| 11 | 54 | // before the transport has accepted Welcome. It must remain pending until the | |
| 12 | 55 | // exact attachment incarnation completes that handshake. | |
| @@ -36,6 +79,93 @@ func TestRenderCoordinatorFreshWakeWaitsForWelcome(t *testing.T) { | |||
| 36 | 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 | 169 | // Parking and resuming the same client object is a new incarnation: a failed | |
| 40 | 170 | // Welcome on the replacement transport must not revive readiness from before | |
| 41 | 171 | // the park. | |
| @@ -1,12 +1,55 @@ | |||||
| 1 | package daemon | 1 | 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 but | 53 | // Fresh producer state may arrive after route has published an attachment but | ||
| 11 | // before the transport has accepted Welcome. It must remain pending until the | 54 | // 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 failed | 169 | // Parking and resuming the same client object is a new incarnation: a failed | ||
| 40 | // Welcome on the replacement transport must not revive readiness from before | 170 | // 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) { | |||
| 378 | 378 | ||
| 379 | 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 | 412 | func TestRenderCoordinatorAckReadinessReentersWithoutBlockingResize(t *testing.T) { | |
| 382 | 413 | owner := &attachedClient{} | |
| 383 | 414 | wakes := make(chan renderWake, 1) | |
| @@ -923,7 +954,6 @@ func TestRenderCoordinatorLifecycleDropsStaleWakes(t *testing.T) { | |||
| 923 | 954 | name string | |
| 924 | 955 | teardown func(rc *renderCoordinator, owner *attachedClient) | |
| 925 | 956 | }{ | |
| 926 | - | {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }}, | |
| 927 | 957 | {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }}, | |
| 928 | 958 | {"session teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.beginSessionTeardown().finish() }}, | |
| 929 | 959 | } | |
| @@ -1357,7 +1387,6 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) { | |||
| 1357 | 1387 | run func(*renderCoordinator, *attachedClient) | |
| 1358 | 1388 | }{ | |
| 1359 | 1389 | {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }}, | |
| 1360 | - | {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }}, | |
| 1361 | 1390 | } { | |
| 1362 | 1391 | t.Run(transition.name, func(t *testing.T) { | |
| 1363 | 1392 | 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 string | 954 | 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 | |||
| 107 | 107 | // Prepare failures now schedule recovery through the coordinator; | |
| 108 | 108 | // retain the failed transaction here until this test explicitly retries. | |
| 109 | 109 | d.clock = newNoticeClock() | |
| 110 | - | d.attachCoordinator(sess, nil, ac, true) | |
| 110 | + | d.attachCoordinator(sess, ac, true) | |
| 111 | 111 | healthy := ac.transport() | |
| 112 | 112 | p := sess.tabs[0].focusedPane() | |
| 113 | 113 | ||
| @@ -143,7 +143,7 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T | |||
| 143 | 143 | func TestPrepareFailureNotifiesAndSchedulesRecovery(t *testing.T) { | |
| 144 | 144 | clock := newNoticeClock() | |
| 145 | 145 | d, sess, ac, _ := newNoticeFixture(t, clock) | |
| 146 | - | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 146 | + | rc := d.attachCoordinator(sess, ac, true) | |
| 147 | 147 | var producers []string | |
| 148 | 148 | rc.opts.onInvalidate = func(inv renderInvalidation) { | |
| 149 | 149 | // 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 []string | 147 | 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 only | 149 | // 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 | |||
| 548 | 548 | sendErr = errors.New("client transport is nil") | |
| 549 | 549 | } | |
| 550 | 550 | 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 | + | } | |
| 554 | 557 | } | |
| 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 | + | } | |
| 560 | 568 | } | |
| 561 | - | if interruptible { | |
| 569 | + | if marks.attachmentEffect != nil && interruptible { | |
| 562 | 570 | if sendErr != nil { | |
| 563 | 571 | marks.attachmentEffect.reportTransportFailure(sendTransport) | |
| 564 | 572 | } | |
| @@ -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.Send | 551 | + | interruptible := false |
| 552 | - | if async, ok := sendTr.(ports.AsyncTransport); ok { | 552 | + | if marks.attachmentEffect != nil { |
| 553 | - | send = async.SendAsync | 553 | + | 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) { | |||
| 131 | 131 | ac.sendMu.Lock() | |
| 132 | 132 | ac.output.next = ac.output.maxOutstanding | |
| 133 | 133 | ac.output.acked = 0 | |
| 134 | + | ac.output.syncCapacityLocked() | |
| 134 | 135 | ac.sendMu.Unlock() | |
| 135 | 136 | p := sess.tabs[0].focusedPane() | |
| 136 | 137 | p.mu.Lock() | |
| @@ -674,3 +675,27 @@ func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) { | |||
| 674 | 675 | require.Equal(t, warn, got.BoxWarn) | |
| 675 | 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 | + | } | |
| @@ -131,6 +131,7 @@ func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) { | |||||
| 131 | ac.sendMu.Lock() | 131 | ac.sendMu.Lock() | ||
| 132 | ac.output.next = ac.output.maxOutstanding | 132 | ac.output.next = ac.output.maxOutstanding | ||
| 133 | ac.output.acked = 0 | 133 | 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) { | |||
| 313 | 313 | d, sess, ac, sends := newManualSessionWithPTYs(t, activePTY, inactivePTY) | |
| 314 | 314 | clock := newCoordinatorMockClock(t, 8) | |
| 315 | 315 | d.clock = clock.clock | |
| 316 | - | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 316 | + | rc := d.attachCoordinator(sess, ac, true) | |
| 317 | 317 | ||
| 318 | 318 | d.sessWg.Add(1) | |
| 319 | 319 | go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane()) | |
| @@ -349,7 +349,7 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) { | |||
| 349 | 349 | d, sess, ac, sends := newManualSessionWithPTYs(t, oldPTY, newPTY, parkedPTY) | |
| 350 | 350 | clock := newCoordinatorMockClock(t, 8) | |
| 351 | 351 | d.clock = clock.clock | |
| 352 | - | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 352 | + | rc := d.attachCoordinator(sess, ac, true) | |
| 353 | 353 | ||
| 354 | 354 | d.sessWg.Add(2) | |
| 355 | 355 | go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane()) | |
| @@ -385,7 +385,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t | |||
| 385 | 385 | d, target, targetClient, _ := newManualSessionWithPTYs(t, pty) | |
| 386 | 386 | clock := newCoordinatorMockClock(t, 8) | |
| 387 | 387 | d.clock = clock.clock | |
| 388 | - | rc := d.attachCoordinator(target, nil, targetClient, true) | |
| 388 | + | rc := d.attachCoordinator(target, targetClient, true) | |
| 389 | 389 | ||
| 390 | 390 | // A viewer in a different session is previewing the headless target. | |
| 391 | 391 | viewer := &attachedClient{} | |
| @@ -429,7 +429,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t | |||
| 429 | 429 | d, target, detached, _ := newManualSessionWithPTYs(t, pty) | |
| 430 | 430 | clock := newCoordinatorMockClock(t, 8) | |
| 431 | 431 | d.clock = clock.clock | |
| 432 | - | rc := d.attachCoordinator(target, nil, detached, true) | |
| 432 | + | rc := d.attachCoordinator(target, detached, true) | |
| 433 | 433 | ||
| 434 | 434 | secondTransport, secondSends := newCapturingTransport(t) | |
| 435 | 435 | remaining := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: detached.size} | |
| @@ -470,7 +470,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t | |||
| 470 | 470 | d, target, client, _ := newManualSessionWithPTYs(t, pty) | |
| 471 | 471 | clock := newCoordinatorMockClock(t, 8) | |
| 472 | 472 | d.clock = clock.clock | |
| 473 | - | rc := d.attachCoordinator(target, nil, client, true) | |
| 473 | + | rc := d.attachCoordinator(target, client, true) | |
| 474 | 474 | wakes := make(chan renderWake, 1) | |
| 475 | 475 | rc.opts.wake = func(w renderWake) { wakes <- w } | |
| 476 | 476 | 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.clock | 315 | 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.clock | 351 | 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.clock | 387 | 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.clock | 431 | 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.clock | 472 | 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 { | |||
| 24 | 24 | if p.attachment == nil { | |
| 25 | 25 | 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 | 28 | if p.connection.effect == nil { | |
| 29 | 29 | return false | |
| 30 | 30 | } | |
| @@ -24,7 +24,7 @@ func (p resizeCommitPublication) current() bool { | |||||
| 24 | if p.attachment == nil { | 24 | if p.attachment == nil { | ||
| 25 | return true | 25 | 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 false | 29 | 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 { | |||
| 252 | 252 | oldSame.closeDone() | |
| 253 | 253 | } | |
| 254 | 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 | 265 | select { | |
| 258 | 266 | case <-timer.C(): | |
| 259 | 267 | d.expireParked(token, parked) | |
| 260 | 268 | case <-parked.done: | |
| 261 | 269 | } | |
| 262 | - | }(token, parked) | |
| 263 | - | return true | |
| 270 | + | }() | |
| 264 | 271 | } | |
| 265 | 272 | ||
| 266 | 273 | func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) { | |
| @@ -309,9 +316,12 @@ func (d *Daemon) commitResumeClaim(ac *attachedClient) bool { | |||
| 309 | 316 | d.mu.Lock() | |
| 310 | 317 | defer d.mu.Unlock() | |
| 311 | 318 | token := ac.resumeClaimToken | |
| 319 | + | if token == 0 { | |
| 320 | + | return true | |
| 321 | + | } | |
| 312 | 322 | 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 | |
| 315 | 325 | } | |
| 316 | 326 | delete(d.parked, token) | |
| 317 | 327 | parked.claimed = false | |
| @@ -348,9 +358,25 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool { | |||
| 348 | 358 | sess.unregisterAttachmentLocked(ac) | |
| 349 | 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 | 376 | captured := ac.transportSnapshot().transport | |
| 352 | 377 | ac.setSession(nil) | |
| 353 | 378 | d.mu.Unlock() | |
| 379 | + | d.watchParkedTimer(token, rearmed) | |
| 354 | 380 | if captured != nil { | |
| 355 | 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 | 621 | // mandatory first paint cannot be blocked by ACKs that died with the link. | |
| 596 | 622 | ac.rebaseOutput() | |
| 597 | 623 | ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight)) | |
| 624 | + | ac.output.syncCapacityLocked() | |
| 598 | 625 | ac.replaceTransport(tr) | |
| 599 | 626 | ac.size = sz | |
| 600 | 627 | 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.resumeClaimToken | 318 | 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 == 0 | 324 | + | return false |
| 315 | } | 325 | } | ||
| 316 | delete(d.parked, token) | 326 | delete(d.parked, token) | ||
| 317 | parked.claimed = false | 327 | 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().transport | 376 | 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 = sz | 626 | 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 { | |||
| 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 | 147 | func TestNamedLinkLossParks(t *testing.T) { | |
| 101 | 148 | pty, release := newBlockingPTY(t) | |
| 102 | 149 | 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) { | |||
| 69 | 69 | ||
| 70 | 70 | func TestSameAttachmentRebindRejectsQueuedWakeAndFansOutFreshGeneration(t *testing.T) { | |
| 71 | 71 | d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 72 | - | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 72 | + | rc := d.attachCoordinator(sess, ac, true) | |
| 73 | 73 | oldLease := rc.attachmentLease(ac) | |
| 74 | 74 | require.NotNil(t, oldLease) | |
| 75 | 75 | ||
| @@ -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 { | |||
| 184 | 184 | } | |
| 185 | 185 | p.commit() | |
| 186 | 186 | p.stream.state.next = p.next | |
| 187 | + | p.stream.state.publishOutstanding() | |
| 187 | 188 | return nil | |
| 188 | 189 | } | |
| 189 | 190 | ||
| @@ -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.next | 186 | p.stream.state.next = p.next | ||
| 187 | + | p.stream.state.publishOutstanding() | |||
| 187 | return nil | 188 | 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 | |||
| 242 | 242 | } | |
| 243 | 243 | ||
| 244 | 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 | 247 | tbSize := contentSize(sz, proxied) | |
| 248 | 248 | var names []string | |
| @@ -523,7 +523,14 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, sz do | |||
| 523 | 523 | env := copyEnvironment(sess.env) | |
| 524 | 524 | attachments := sess.snapshotAttachmentsLocked() | |
| 525 | 525 | 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 | |
| 527 | 534 | tbSize := contentSize(sz, proxied) | |
| 528 | 535 | tabStableID, paneStableID, err := d.newTabPaneStableIDs() | |
| 529 | 536 | if err != nil { | |
| @@ -541,8 +548,8 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, sz do | |||
| 541 | 548 | return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err) | |
| 542 | 549 | } | |
| 543 | 550 | 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()) | |
| 546 | 553 | tb.mu.Lock() | |
| 547 | 554 | p := tb.focusedPane() | |
| 548 | 555 | tb.mu.Unlock() | |
| @@ -706,6 +713,7 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) { | |||
| 706 | 713 | return | |
| 707 | 714 | } | |
| 708 | 715 | if owner.session != sess || owner.tab != tb { | |
| 716 | + | d.log.Warn("pane owner mismatch", "pane", p.id) | |
| 709 | 717 | return | |
| 710 | 718 | } | |
| 711 | 719 | sess.mu.Lock() | |
| @@ -990,7 +998,24 @@ func (s *session) persistRecordLocked(updatedAt int64) domain.CatalogueRecord { | |||
| 990 | 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 | 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 | 1019 | if sess == nil || tb == nil { | |
| 995 | 1020 | return layout.ErrNotFound | |
| 996 | 1021 | } | |
| @@ -1016,7 +1041,14 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error { | |||
| 1016 | 1041 | name := sess.name | |
| 1017 | 1042 | sess.mu.Unlock() | |
| 1018 | 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 | 1052 | d.log.Warn("closing last tab failed", "session", name, "err", err) | |
| 1021 | 1053 | d.reportError(sess, domain.UserErr(domain.NoticeSnapshotSaturated, | |
| 1022 | 1054 | "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 []string | 248 | 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].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 | |||
| 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 | return | 713 | 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 | return | 717 | 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.ErrNotFound | 1020 | return layout.ErrNotFound | ||
| 996 | } | 1021 | } | ||
| @@ -1016,7 +1041,14 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error { | |||||
| 1016 | name := sess.name | 1041 | 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) { | |||
| 116 | 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 | 138 | func TestProxiedAttachedCommandValidationPreservesRequestID(t *testing.T) { | |
| 120 | 139 | tests := []struct { | |
| 121 | 140 | name string | |
| @@ -192,7 +211,7 @@ func TestProxiedAttachedCommandValidationPreservesRequestID(t *testing.T) { | |||
| 192 | 211 | t.Run(tt.name, func(t *testing.T) { | |
| 193 | 212 | d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 194 | 213 | ac.proxied = true | |
| 195 | - | d.attachCoordinator(sess, nil, ac, true) | |
| 214 | + | d.attachCoordinator(sess, ac, true) | |
| 196 | 215 | payload, err := ports.MarshalCommandRequest(tt.request) | |
| 197 | 216 | require.NoError(t, err) | |
| 198 | 217 | ||
| @@ -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 string | 140 | 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 = true | 213 | 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 ( | |||
| 20 | 20 | "github.com/bnema/vev/internal/ports" | |
| 21 | 21 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 22 | 22 | "github.com/bnema/vev/internal/usecase/picker" | |
| 23 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 23 | 24 | ) | |
| 24 | 25 | ||
| 25 | 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 | 202 | func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) { | |
| 155 | 203 | p1, release1 := newBlockingPTY(t) | |
| 156 | 204 | 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 { | |||
| 93 | 93 | view.tabs = append(view.tabs, entry) | |
| 94 | 94 | } | |
| 95 | 95 | s.mu.Unlock() | |
| 96 | - | if !view.attached { | |
| 97 | - | view.attached = len(s.snapshotAttachmentViews()) != 0 | |
| 98 | - | } | |
| 99 | 96 | return view | |
| 100 | 97 | } | |
| 101 | 98 | ||
| @@ -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 view | 96 | 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 | |||
| 852 | 852 | if ac == nil { | |
| 853 | 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 | 856 | return d.requestTransactionalResizeForLease(sess, ac, rc.attachmentLease(ac), size, immediate) | |
| 857 | 857 | } | |
| 858 | 858 | ||
| @@ -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) { | |||
| 388 | 388 | }{ | |
| 389 | 389 | {"detach and attach", func(rc *renderCoordinator, old, next *attachedClient) { rc.noteDetach(old); rc.attach(next) }}, | |
| 390 | 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 | 392 | t.Run(transition.name, func(t *testing.T) { | |
| 394 | 393 | rc := newRenderCoordinator(renderCoordinatorOptions{}) | |
| @@ -411,7 +410,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing | |||
| 411 | 410 | d, sess, ac, _ := newManualSessionWithPTYs(t, first, second) | |
| 412 | 411 | observer := &daemonRuntimeObserver{} | |
| 413 | 412 | d.runtimeObserver = observer | |
| 414 | - | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 413 | + | rc := d.attachCoordinator(sess, ac, true) | |
| 415 | 414 | lease := rc.attachmentLease(ac) | |
| 416 | 415 | var newer uint64 | |
| 417 | 416 | d.beforeSessionResizePublication = func() { | |
| @@ -472,7 +471,7 @@ func TestTransactionalResizeRechecksLeaseAtAttachmentPublication(t *testing.T) { | |||
| 472 | 471 | t.Run(tc.name, func(t *testing.T) { | |
| 473 | 472 | pty := &transactionalResizePTY{} | |
| 474 | 473 | d, sess, ac, _ := newManualSessionWithPTYs(t, pty) | |
| 475 | - | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 474 | + | rc := d.attachCoordinator(sess, ac, true) | |
| 476 | 475 | lease := rc.attachmentLease(ac) | |
| 477 | 476 | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease) | |
| 478 | 477 | require.NotZero(t, epoch) | |
| @@ -556,7 +555,7 @@ func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) { | |||
| 556 | 555 | tb.mu.Unlock() | |
| 557 | 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 | 559 | lease := rc.attachmentLease(ac) | |
| 561 | 560 | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease) | |
| 562 | 561 | require.True(t, d.runResizeTransaction(source, ac, lease, epoch)) | |
| @@ -781,7 +780,7 @@ func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) | |||
| 781 | 780 | tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7} | |
| 782 | 781 | tb.mu.Unlock() | |
| 783 | 782 | ||
| 784 | - | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 783 | + | rc := d.attachCoordinator(sess, ac, true) | |
| 785 | 784 | lease := rc.attachmentLease(ac) | |
| 786 | 785 | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease) | |
| 787 | 786 | 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 = observer | 412 | 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 uint64 | 415 | 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) { | |||
| 20 | 20 | // One in-flight state makes the second public resize take the ACK-blocked | |
| 21 | 21 | // path. The real coordinator owns both renders and the ACK wake. | |
| 22 | 22 | ac.output = newOutputStateStream(1) | |
| 23 | - | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 23 | + | rc := d.attachCoordinator(sess, ac, true) | |
| 24 | 24 | ||
| 25 | 25 | if !d.resizeForFirstPaint(sess, ac, domain.Size{Cols: 90, Rows: 25}) { | |
| 26 | 26 | 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-blocked | 20 | // 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") | ||