Merge pull request #173 from bnema/feat/multi-attachment-session-views
feat(session): support independent attachment views
158 files · +6831 -12512
A .coderabbit.yaml +11 -0
| @@ -0,0 +1,11 @@ | |||
| 1 | + | # Temporary PR #173 review scope; remove after the clean reviewed-head merge. | |
| 2 | + | reviews: | |
| 3 | + | path_filters: | |
| 4 | + | - "!internal/usecase/daemon/move_atomicity_test.go" | |
| 5 | + | - "!internal/usecase/daemon/move_final_source_attachment_race_test.go" | |
| 6 | + | - "!internal/usecase/daemon/pane_sync_owner_test.go" | |
| 7 | + | - "!internal/usecase/daemon/role_effect_gate_test.go" | |
| 8 | + | - "!internal/usecase/daemon/snatched_client_test.go" | |
| 9 | + | - "!internal/usecase/daemon/snatched_reclaim_test.go" | |
| 10 | + | - "!internal/usecase/daemon/snatched_render_test.go" | |
| 11 | + | - "!internal/usecase/daemon/stalled_attachment_test.go" | |
M internal/app/daemon_integration_test.go +42 -1
| @@ -261,6 +261,47 @@ func TestIntegration_AttachEnvironmentRefreshesFuturePTYChildren(t *testing.T) { | |||
| 261 | 261 | assertChildEnvironment(t, tr2, p2, sz, "second", secondShell, "/run/second", "wayland-second") | |
| 262 | 262 | } | |
| 263 | 263 | ||
| 264 | + | func TestIntegration_TwoAttachmentsReceiveSharedMutationPTYOutput(t *testing.T) { | |
| 265 | + | sz := domain.Size{Cols: 80, Rows: 24} | |
| 266 | + | dir, _ := startDaemon(t) | |
| 267 | + | firstShell := shellFixture(t, "first") | |
| 268 | + | const sharedOutput = "SECOND_SHARED_PTY_OUTPUT" | |
| 269 | + | secondShell := filepath.Join(t.TempDir(), "second-shell") | |
| 270 | + | require.NoError(t, os.WriteFile(secondShell, []byte("#!/bin/sh\nprintf 'SHELL_COMMAND=second\\n'\nIFS= read -r _\nprintf '"+sharedOutput+"\\n'\nexec /bin/sh\n"), 0o700)) | |
| 271 | + | firstEnv := []string{ | |
| 272 | + | "VEV_TEST_ENV=first", "SHELL=" + firstShell, "XDG_RUNTIME_DIR=/run/first", "WAYLAND_DISPLAY=wayland-first", | |
| 273 | + | "TERM=client", "COLORTERM=client", "TERM_PROGRAM=client", "VEV=client", | |
| 274 | + | } | |
| 275 | + | tr1, p1 := attachWithEnvironment(t, dir, ports.IntentNew, "environment", sz, firstEnv) | |
| 276 | + | defer func() { _ = tr1.Close() }() | |
| 277 | + | awaitText(t, p1, sz, "SHELL_COMMAND=first") | |
| 278 | + | assertChildEnvironment(t, tr1, p1, sz, "first", firstShell, "/run/first", "wayland-first") | |
| 279 | + | ||
| 280 | + | secondEnv := []string{ | |
| 281 | + | "VEV_TEST_ENV=second", "SHELL=" + secondShell, "XDG_RUNTIME_DIR=/run/second", "WAYLAND_DISPLAY=wayland-second", | |
| 282 | + | "TERM=client", "COLORTERM=client", "TERM_PROGRAM=client", "VEV=client", | |
| 283 | + | } | |
| 284 | + | tr2, p2 := attachWithEnvironment(t, dir, ports.IntentAttach, "environment", sz, secondEnv) | |
| 285 | + | defer func() { _ = tr2.Close() }() | |
| 286 | + | assertChildEnvironment(t, tr2, p2, sz, "first", firstShell, "/run/first", "wayland-first") | |
| 287 | + | ||
| 288 | + | // The second attachment mutates shared tab state. Its newly opened PTY must | |
| 289 | + | // also be rendered to that attachment, not only to the coordinator primary. | |
| 290 | + | require.NoError(t, tr2.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("\x1b ")})})) | |
| 291 | + | awaitText(t, p2, sz, "Commands") | |
| 292 | + | require.NoError(t, tr2.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("CNT\r")})})) | |
| 293 | + | awaitText(t, p2, sz, "SHELL_COMMAND=second") | |
| 294 | + | ||
| 295 | + | // Each attachment owns its selected tab. Move the first attachment to the | |
| 296 | + | // new PTY before releasing its gate, so the fresh PTY output must reach both | |
| 297 | + | // views rather than only being recovered by a later first paint. | |
| 298 | + | require.NoError(t, tr1.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("\x1b2")})})) | |
| 299 | + | awaitText(t, p1, sz, "SHELL_COMMAND=second") | |
| 300 | + | require.NoError(t, tr1.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("go\n")})})) | |
| 301 | + | awaitText(t, p2, sz, sharedOutput) | |
| 302 | + | awaitText(t, p1, sz, sharedOutput) | |
| 303 | + | } | |
| 304 | + | ||
| 264 | 305 | func TestIntegration_AttachFirstOutput(t *testing.T) { | |
| 265 | 306 | sz := domain.Size{Cols: 80, Rows: 24} | |
| 266 | 307 | dir, _ := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf HELLO; sleep 30"})) | |
| @@ -329,7 +370,7 @@ func TestIntegration_CommandPaletteRenamesEphemeralSession(t *testing.T) { | |||
| 329 | 370 | require.Equal(t, ports.MsgSessions, f.Type) | |
| 330 | 371 | sessions, err := ports.UnmarshalSessions(f.Payload) | |
| 331 | 372 | require.NoError(t, err) | |
| 332 | - | require.Len(t, sessions.Sessions, 1) | |
| 373 | + | require.NotEmpty(t, sessions.Sessions) | |
| 333 | 374 | require.Equal(t, "work", sessions.Sessions[0].Name) | |
| 334 | 375 | require.False(t, sessions.Sessions[0].Ephemeral) | |
| 335 | 376 | } | |
A internal/usecase/daemon/attachment_connection.go +156 -0
| @@ -0,0 +1,156 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/internal/ports" | |
| 4 | + | ||
| 5 | + | // attachmentSessionCore normalizes nil interfaces and implementations that | |
| 6 | + | // deliberately expose no attachment core. | |
| 7 | + | func attachmentSessionCore(entry attachmentSession) *sessionCore { | |
| 8 | + | if entry == nil { | |
| 9 | + | return nil | |
| 10 | + | } | |
| 11 | + | return entry.core() | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | // attachmentConnectionToken is the exact capability for one registered | |
| 15 | + | // attachment connection incarnation. It is invalid as soon as any of the | |
| 16 | + | // session, attachment membership, connection generation, transport link, or | |
| 17 | + | // coordinator lease changes. | |
| 18 | + | type attachmentConnectionToken struct { | |
| 19 | + | sess attachmentSession | |
| 20 | + | ac *attachedClient | |
| 21 | + | generation uint64 | |
| 22 | + | transport transportSnapshot | |
| 23 | + | lease *attachmentLease | |
| 24 | + | effect *attachmentEffectTicket | |
| 25 | + | // rebase marks a cross-session move whose first paint starts a dependency- | |
| 26 | + | // free output chain after publication releases architecture locks. | |
| 27 | + | rebase bool | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func attachmentToken(entry attachmentSession, ac *attachedClient, tr ports.Transport) attachmentConnectionToken { | |
| 31 | + | if entry == nil || entry.core() == nil || ac == nil || tr == nil { | |
| 32 | + | return attachmentConnectionToken{} | |
| 33 | + | } | |
| 34 | + | // Capture one concrete link incarnation before taking the session lock. The | |
| 35 | + | // same snapshot is revalidated below; do not take a second snapshot and | |
| 36 | + | // accidentally bind a replacement link. | |
| 37 | + | transport := ac.transportSnapshot() | |
| 38 | + | if transport.transport != tr { | |
| 39 | + | return attachmentConnectionToken{} | |
| 40 | + | } | |
| 41 | + | if ac.beforeAttachmentTokenValidation != nil { | |
| 42 | + | ac.beforeAttachmentTokenValidation() | |
| 43 | + | } | |
| 44 | + | core := entry.core() | |
| 45 | + | core.mu.Lock() | |
| 46 | + | if !attachmentRegisteredLocked(entry, ac) || !ac.transportSnapshotCurrent(transport) { | |
| 47 | + | core.mu.Unlock() | |
| 48 | + | return attachmentConnectionToken{} | |
| 49 | + | } | |
| 50 | + | generation := ac.connectionGeneration.Load() | |
| 51 | + | token := attachmentConnectionToken{ | |
| 52 | + | sess: entry, | |
| 53 | + | ac: ac, | |
| 54 | + | generation: generation, | |
| 55 | + | transport: transport, | |
| 56 | + | } | |
| 57 | + | if rc := core.coordinator.Load(); rc != nil { | |
| 58 | + | token.lease = rc.attachmentLease(ac) | |
| 59 | + | } | |
| 60 | + | core.mu.Unlock() | |
| 61 | + | if !token.current() { | |
| 62 | + | return attachmentConnectionToken{} | |
| 63 | + | } | |
| 64 | + | ac.bootstrapAttachmentCapability(token) | |
| 65 | + | return token | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentConnectionToken { | |
| 69 | + | return attachmentToken(s, ac, tr) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // current validates every immutable identity captured by the token. Membership | |
| 73 | + | // is the sole authority; there is no owner, replacement, or compatibility distinction. | |
| 74 | + | func (t attachmentConnectionToken) current() bool { | |
| 75 | + | return t.sess != nil && t.ac != nil && | |
| 76 | + | t.ac.connectionGeneration.Load() == t.generation && | |
| 77 | + | t.ac.currentAttachmentSession() == t.sess && | |
| 78 | + | t.ac.transportSnapshotCurrent(t.transport) && | |
| 79 | + | attachmentRegistered(t.sess, t.ac) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // attachmentCurrent additionally validates the optional coordinator lease used | |
| 83 | + | // by render and resize effects. Attachments without that optional lease still | |
| 84 | + | // own their independent connection lifecycle. | |
| 85 | + | func (t attachmentConnectionToken) attachmentCurrent() bool { | |
| 86 | + | if !t.current() || t.ac.currentAttachmentSession() != t.sess { | |
| 87 | + | return false | |
| 88 | + | } | |
| 89 | + | if t.lease == nil { | |
| 90 | + | return true | |
| 91 | + | } | |
| 92 | + | rc := t.sess.core().coordinator.Load() | |
| 93 | + | return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func (t attachmentConnectionToken) attachmentEffectCurrent() bool { | |
| 97 | + | if t.effect != nil && !t.effect.ended.Load() { | |
| 98 | + | return true | |
| 99 | + | } | |
| 100 | + | return t.attachmentCurrent() | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func beginAttachmentLeaseEffect(sess attachmentSession, ac *attachedClient, lease *attachmentLease) (*attachmentEffectTicket, bool) { | |
| 104 | + | if sess == nil || ac == nil || lease == nil || lease.attachment != ac { | |
| 105 | + | return nil, false | |
| 106 | + | } | |
| 107 | + | token := attachmentToken(sess, ac, ac.transport()) | |
| 108 | + | token.lease = lease | |
| 109 | + | return ac.beginAttachmentEffect(token) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func (t *attachmentConnectionToken) endAttachmentEffect() { | |
| 113 | + | if t == nil || t.effect == nil { | |
| 114 | + | return | |
| 115 | + | } | |
| 116 | + | t.effect.End() | |
| 117 | + | t.effect = nil | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // attachmentEffectCurrentSessionLocked is the same check at a session-owned | |
| 121 | + | // mutation boundary where sess.core().mu is already held. | |
| 122 | + | func (t attachmentConnectionToken) attachmentEffectCurrentSessionLocked() bool { | |
| 123 | + | if t.effect != nil && !t.effect.ended.Load() { | |
| 124 | + | return true | |
| 125 | + | } | |
| 126 | + | if t.sess == nil || t.ac == nil || | |
| 127 | + | t.ac.connectionGeneration.Load() != t.generation || | |
| 128 | + | !t.ac.transportSnapshotCurrent(t.transport) || | |
| 129 | + | t.ac.currentAttachmentSession() != t.sess || | |
| 130 | + | !attachmentRegisteredLocked(t.sess, t.ac) { | |
| 131 | + | return false | |
| 132 | + | } | |
| 133 | + | if t.lease == nil { | |
| 134 | + | return true | |
| 135 | + | } | |
| 136 | + | rc := t.sess.core().coordinator.Load() | |
| 137 | + | return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func (t attachmentConnectionToken) sendControl(frame ports.Frame) error { | |
| 141 | + | if t.ac == nil || t.transport.transport == nil { | |
| 142 | + | return errAttachmentTransition | |
| 143 | + | } | |
| 144 | + | t.ac.sendMu.Lock() | |
| 145 | + | defer t.ac.sendMu.Unlock() | |
| 146 | + | if t.effect == nil || t.effect.ended.Load() || !t.ac.transportSnapshotCurrent(t.transport) || | |
| 147 | + | !t.effect.beginTransportSend(t.transport) { | |
| 148 | + | return errAttachmentTransition | |
| 149 | + | } | |
| 150 | + | err := t.transport.transport.Send(frame) | |
| 151 | + | if err != nil { | |
| 152 | + | t.effect.reportTransportFailure(t.transport) | |
| 153 | + | } | |
| 154 | + | t.effect.endTransportSend() | |
| 155 | + | return err | |
| 156 | + | } | |
R internal/usecase/daemon/role_effect_freeze.go → internal/usecase/daemon/attachment_effect_freeze.go +50 -64 renamed, 55% similar
| @@ -8,7 +8,7 @@ import ( | |||
| 8 | 8 | "github.com/bnema/vev/internal/ports" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | - | type roleEffectDrainDeadline struct { | |
| 11 | + | type attachmentEffectDrainDeadline struct { | |
| 12 | 12 | clock ports.Clock | |
| 13 | 13 | done chan struct{} | |
| 14 | 14 | stopCh chan struct{} | |
| @@ -18,13 +18,13 @@ type roleEffectDrainDeadline struct { | |||
| 18 | 18 | started atomic.Bool | |
| 19 | 19 | } | |
| 20 | 20 | ||
| 21 | - | func newRoleEffectDrainDeadline(clock ports.Clock) *roleEffectDrainDeadline { | |
| 22 | - | return &roleEffectDrainDeadline{ | |
| 21 | + | func newAttachmentEffectDrainDeadline(clock ports.Clock) *attachmentEffectDrainDeadline { | |
| 22 | + | return &attachmentEffectDrainDeadline{ | |
| 23 | 23 | clock: clock, done: make(chan struct{}), stopCh: make(chan struct{}), finished: make(chan struct{}), | |
| 24 | 24 | } | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | - | func (d *roleEffectDrainDeadline) stop() { | |
| 27 | + | func (d *attachmentEffectDrainDeadline) stop() { | |
| 28 | 28 | if d == nil { | |
| 29 | 29 | return | |
| 30 | 30 | } | |
| @@ -40,7 +40,7 @@ func (d *roleEffectDrainDeadline) stop() { | |||
| 40 | 40 | }) | |
| 41 | 41 | } | |
| 42 | 42 | ||
| 43 | - | func (d *roleEffectDrainDeadline) Done() <-chan struct{} { | |
| 43 | + | func (d *attachmentEffectDrainDeadline) Done() <-chan struct{} { | |
| 44 | 44 | if d == nil { | |
| 45 | 45 | return nil | |
| 46 | 46 | } | |
| @@ -60,71 +60,57 @@ func (d *roleEffectDrainDeadline) Done() <-chan struct{} { | |||
| 60 | 60 | return d.done | |
| 61 | 61 | } | |
| 62 | 62 | ||
| 63 | - | type frozenRoleEffectGates struct { | |
| 63 | + | type frozenAttachmentEffectGates struct { | |
| 64 | 64 | clients []*attachedClient | |
| 65 | 65 | interruptions map[*attachedClient]transportSnapshot | |
| 66 | 66 | acquired bool | |
| 67 | 67 | drained bool | |
| 68 | 68 | } | |
| 69 | 69 | ||
| 70 | - | func (f frozenRoleEffectGates) interrupted(ac *attachedClient, expected transportSnapshot) bool { | |
| 71 | - | interrupted, ok := f.interruptions[ac] | |
| 72 | - | return ok && interrupted.transport == expected.transport && interrupted.incarnation == expected.incarnation | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | func (f frozenRoleEffectGates) contains(target *attachedClient) bool { | |
| 76 | - | for _, ac := range f.clients { | |
| 77 | - | if ac == target { | |
| 78 | - | return true | |
| 79 | - | } | |
| 80 | - | } | |
| 81 | - | return false | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | type roleTransportInterrupt struct { | |
| 70 | + | type attachmentTransportInterrupt struct { | |
| 85 | 71 | ac *attachedClient | |
| 86 | 72 | transport transportSnapshot | |
| 87 | 73 | } | |
| 88 | 74 | ||
| 89 | - | // roleEffectFreezeOptions controls one role-gate freeze operation. A freeze | |
| 75 | + | // attachmentEffectFreezeOptions controls one attachment-effect-gate freeze operation. A freeze | |
| 90 | 76 | // must be called without daemon, routing, session, coordinator, send, overlay, | |
| 91 | 77 | // router, or pane locks held. | |
| 92 | - | type roleEffectFreezeOptions struct { | |
| 93 | - | interrupts []roleTransportInterrupt | |
| 78 | + | type attachmentEffectFreezeOptions struct { | |
| 79 | + | interrupts []attachmentTransportInterrupt | |
| 94 | 80 | done func() <-chan struct{} | |
| 95 | 81 | afterFrozen func(*attachedClient) | |
| 96 | 82 | nonblocking bool | |
| 97 | 83 | } | |
| 98 | 84 | ||
| 99 | - | // freezeRoleEffectGates establishes transition priority in immutable attachment | |
| 85 | + | // freezeAttachmentEffectGates establishes transition priority in immutable attachment | |
| 100 | 86 | // order, then drains earlier admissions. | |
| 101 | - | func freezeRoleEffectGates(clients ...*attachedClient) frozenRoleEffectGates { | |
| 102 | - | return freezeRoleEffectGatesWith(roleEffectFreezeOptions{}, clients...) | |
| 87 | + | func freezeAttachmentEffectGates(clients ...*attachedClient) frozenAttachmentEffectGates { | |
| 88 | + | return freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{}, clients...) | |
| 103 | 89 | } | |
| 104 | 90 | ||
| 105 | - | // freezeRoleEffectGatesWith uses done as one overall bound for ordered | |
| 91 | + | // freezeAttachmentEffectGatesWith uses done as one overall bound for ordered | |
| 106 | 92 | // acquisition and drain. Acquisition is all-or-nothing: if the bound expires | |
| 107 | 93 | // behind another owner, only gates acquired by this call are rolled back, in | |
| 108 | 94 | // reverse order. nonblocking is used by move reservations to avoid inverting | |
| 109 | 95 | // the move/teardown protocols. | |
| 110 | - | func freezeRoleEffectGatesWith(options roleEffectFreezeOptions, clients ...*attachedClient) frozenRoleEffectGates { | |
| 111 | - | ordered := orderedRoleEffectClients(clients) | |
| 112 | - | frozen, acquired := acquireRoleEffectGates(ordered, options.done, options.afterFrozen, options.nonblocking) | |
| 96 | + | func freezeAttachmentEffectGatesWith(options attachmentEffectFreezeOptions, clients ...*attachedClient) frozenAttachmentEffectGates { | |
| 97 | + | ordered := orderedAttachmentEffectClients(clients) | |
| 98 | + | frozen, acquired := acquireAttachmentEffectGates(ordered, options.done, options.afterFrozen, options.nonblocking) | |
| 113 | 99 | if !acquired { | |
| 114 | - | return frozenRoleEffectGates{} | |
| 100 | + | return frozenAttachmentEffectGates{} | |
| 115 | 101 | } | |
| 116 | 102 | ||
| 117 | - | interruptByClient := roleTransportInterruptsByClient(options.interrupts) | |
| 103 | + | interruptByClient := attachmentTransportInterruptsByClient(options.interrupts) | |
| 118 | 104 | frozen.interruptions = make(map[*attachedClient]transportSnapshot, len(interruptByClient)) | |
| 119 | - | interruptFrozenRoleTransports(ordered, interruptByClient) | |
| 105 | + | interruptFrozenAttachmentTransports(ordered, interruptByClient) | |
| 120 | 106 | frozen.acquired = true | |
| 121 | - | frozen.drained = drainFrozenRoleEffects(ordered, interruptByClient, frozen.interruptions, options.done) | |
| 107 | + | frozen.drained = drainFrozenAttachmentEffects(ordered, interruptByClient, frozen.interruptions, options.done) | |
| 122 | 108 | return frozen | |
| 123 | 109 | } | |
| 124 | 110 | ||
| 125 | - | // orderedRoleEffectClients snapshots unique participants in immutable lock | |
| 111 | + | // orderedAttachmentEffectClients snapshots unique participants in immutable lock | |
| 126 | 112 | // order. Assigning a previously unused order does not freeze or wait on a gate. | |
| 127 | - | func orderedRoleEffectClients(clients []*attachedClient) []*attachedClient { | |
| 113 | + | func orderedAttachmentEffectClients(clients []*attachedClient) []*attachedClient { | |
| 128 | 114 | unique := make(map[*attachedClient]struct{}, len(clients)) | |
| 129 | 115 | ordered := make([]*attachedClient, 0, len(clients)) | |
| 130 | 116 | for _, ac := range clients { | |
| @@ -135,18 +121,18 @@ func orderedRoleEffectClients(clients []*attachedClient) []*attachedClient { | |||
| 135 | 121 | continue | |
| 136 | 122 | } | |
| 137 | 123 | unique[ac] = struct{}{} | |
| 138 | - | ac.roleEffects.immutableOrder() | |
| 124 | + | ac.attachmentEffects.immutableOrder() | |
| 139 | 125 | ordered = append(ordered, ac) | |
| 140 | 126 | } | |
| 141 | 127 | sort.Slice(ordered, func(i, j int) bool { | |
| 142 | - | return ordered[i].roleEffects.order.Load() < ordered[j].roleEffects.order.Load() | |
| 128 | + | return ordered[i].attachmentEffects.order.Load() < ordered[j].attachmentEffects.order.Load() | |
| 143 | 129 | }) | |
| 144 | 130 | return ordered | |
| 145 | 131 | } | |
| 146 | 132 | ||
| 147 | - | // waitForRoleEffectChangeLocked requires g.mu and returns with g.mu held. A | |
| 133 | + | // waitForAttachmentEffectChangeLocked requires g.mu and returns with g.mu held. A | |
| 148 | 134 | // true result means the overall deadline won and the caller must stop waiting. | |
| 149 | - | func waitForRoleEffectChangeLocked(g *roleEffectGate, done func() <-chan struct{}) bool { | |
| 135 | + | func waitForAttachmentEffectChangeLocked(g *attachmentEffectGate, done func() <-chan struct{}) bool { | |
| 150 | 136 | if done == nil { | |
| 151 | 137 | g.cond.Wait() | |
| 152 | 138 | return false | |
| @@ -167,28 +153,28 @@ func waitForRoleEffectChangeLocked(g *roleEffectGate, done func() <-chan struct{ | |||
| 167 | 153 | } | |
| 168 | 154 | } | |
| 169 | 155 | ||
| 170 | - | // acquireRoleEffectGates establishes exclusive transition ownership in order. | |
| 156 | + | // acquireAttachmentEffectGates establishes exclusive transition ownership in order. | |
| 171 | 157 | // It must run without architecture locks. Deadline rollback releases only this | |
| 172 | 158 | // call's partial acquisition and does so in reverse order. | |
| 173 | - | func acquireRoleEffectGates(ordered []*attachedClient, done func() <-chan struct{}, afterFrozen func(*attachedClient), nonblocking bool) (frozenRoleEffectGates, bool) { | |
| 174 | - | frozen := frozenRoleEffectGates{clients: make([]*attachedClient, 0, len(ordered))} | |
| 159 | + | func acquireAttachmentEffectGates(ordered []*attachedClient, done func() <-chan struct{}, afterFrozen func(*attachedClient), nonblocking bool) (frozenAttachmentEffectGates, bool) { | |
| 160 | + | frozen := frozenAttachmentEffectGates{clients: make([]*attachedClient, 0, len(ordered))} | |
| 175 | 161 | for _, ac := range ordered { | |
| 176 | - | g := &ac.roleEffects | |
| 162 | + | g := &ac.attachmentEffects | |
| 177 | 163 | g.mu.Lock() | |
| 178 | 164 | g.initLocked() | |
| 179 | - | for g.phase == roleEffectsFrozen { | |
| 165 | + | for g.phase == attachmentEffectsFrozen { | |
| 180 | 166 | if nonblocking { | |
| 181 | 167 | g.mu.Unlock() | |
| 182 | 168 | frozen.unfreeze() | |
| 183 | - | return frozenRoleEffectGates{}, false | |
| 169 | + | return frozenAttachmentEffectGates{}, false | |
| 184 | 170 | } | |
| 185 | - | if waitForRoleEffectChangeLocked(g, done) { | |
| 171 | + | if waitForAttachmentEffectChangeLocked(g, done) { | |
| 186 | 172 | g.mu.Unlock() | |
| 187 | 173 | frozen.unfreeze() | |
| 188 | - | return frozenRoleEffectGates{}, false | |
| 174 | + | return frozenAttachmentEffectGates{}, false | |
| 189 | 175 | } | |
| 190 | 176 | } | |
| 191 | - | g.phase = roleEffectsFrozen | |
| 177 | + | g.phase = attachmentEffectsFrozen | |
| 192 | 178 | g.mu.Unlock() | |
| 193 | 179 | frozen.clients = append(frozen.clients, ac) | |
| 194 | 180 | if afterFrozen != nil { | |
| @@ -198,7 +184,7 @@ func acquireRoleEffectGates(ordered []*attachedClient, done func() <-chan struct | |||
| 198 | 184 | return frozen, true | |
| 199 | 185 | } | |
| 200 | 186 | ||
| 201 | - | func roleTransportInterruptsByClient(interrupts []roleTransportInterrupt) map[*attachedClient]transportSnapshot { | |
| 187 | + | func attachmentTransportInterruptsByClient(interrupts []attachmentTransportInterrupt) map[*attachedClient]transportSnapshot { | |
| 202 | 188 | byClient := make(map[*attachedClient]transportSnapshot, len(interrupts)) | |
| 203 | 189 | for _, interrupt := range interrupts { | |
| 204 | 190 | if interrupt.ac != nil && interrupt.transport.transport != nil { | |
| @@ -208,20 +194,20 @@ func roleTransportInterruptsByClient(interrupts []roleTransportInterrupt) map[*a | |||
| 208 | 194 | return byClient | |
| 209 | 195 | } | |
| 210 | 196 | ||
| 211 | - | // interruptFrozenRoleTransports snapshots every exact in-flight link while all | |
| 197 | + | // interruptFrozenAttachmentTransports snapshots every exact in-flight link while all | |
| 212 | 198 | // gates are frozen, then closes the complete set before any drain wait. One | |
| 213 | 199 | // uncooperative Send cannot prevent another participant from being interrupted. | |
| 214 | - | func interruptFrozenRoleTransports(ordered []*attachedClient, interruptByClient map[*attachedClient]transportSnapshot) { | |
| 215 | - | toClose := make([]roleTransportInterrupt, 0, len(interruptByClient)) | |
| 200 | + | func interruptFrozenAttachmentTransports(ordered []*attachedClient, interruptByClient map[*attachedClient]transportSnapshot) { | |
| 201 | + | toClose := make([]attachmentTransportInterrupt, 0, len(interruptByClient)) | |
| 216 | 202 | for _, ac := range ordered { | |
| 217 | 203 | expected, mayInterrupt := interruptByClient[ac] | |
| 218 | 204 | if !mayInterrupt { | |
| 219 | 205 | continue | |
| 220 | 206 | } | |
| 221 | - | g := &ac.roleEffects | |
| 207 | + | g := &ac.attachmentEffects | |
| 222 | 208 | g.mu.Lock() | |
| 223 | 209 | if gateHasTransportEffectLocked(g, expected) { | |
| 224 | - | toClose = append(toClose, roleTransportInterrupt{ac: ac, transport: expected}) | |
| 210 | + | toClose = append(toClose, attachmentTransportInterrupt{ac: ac, transport: expected}) | |
| 225 | 211 | } | |
| 226 | 212 | g.mu.Unlock() | |
| 227 | 213 | } | |
| @@ -230,15 +216,15 @@ func interruptFrozenRoleTransports(ordered []*attachedClient, interruptByClient | |||
| 230 | 216 | } | |
| 231 | 217 | } | |
| 232 | 218 | ||
| 233 | - | // drainFrozenRoleEffects waits for admissions made before the freeze. A closed | |
| 219 | + | // drainFrozenAttachmentEffects waits for admissions made before the freeze. A closed | |
| 234 | 220 | // deadline permits terminal teardown to continue with the capability frozen; | |
| 235 | 221 | // late tickets can then retire without pinning the owner indefinitely. | |
| 236 | - | func drainFrozenRoleEffects(ordered []*attachedClient, interruptByClient, interruptions map[*attachedClient]transportSnapshot, done func() <-chan struct{}) bool { | |
| 222 | + | func drainFrozenAttachmentEffects(ordered []*attachedClient, interruptByClient, interruptions map[*attachedClient]transportSnapshot, done func() <-chan struct{}) bool { | |
| 237 | 223 | for _, ac := range ordered { | |
| 238 | - | g := &ac.roleEffects | |
| 224 | + | g := &ac.attachmentEffects | |
| 239 | 225 | g.mu.Lock() | |
| 240 | 226 | for g.inFlight != 0 { | |
| 241 | - | if waitForRoleEffectChangeLocked(g, done) { | |
| 227 | + | if waitForAttachmentEffectChangeLocked(g, done) { | |
| 242 | 228 | g.mu.Unlock() | |
| 243 | 229 | return false | |
| 244 | 230 | } | |
| @@ -252,7 +238,7 @@ func drainFrozenRoleEffects(ordered []*attachedClient, interruptByClient, interr | |||
| 252 | 238 | return true | |
| 253 | 239 | } | |
| 254 | 240 | ||
| 255 | - | func gateHasTransportEffectLocked(g *roleEffectGate, expected transportSnapshot) bool { | |
| 241 | + | func gateHasTransportEffectLocked(g *attachmentEffectGate, expected transportSnapshot) bool { | |
| 256 | 242 | for _, active := range g.transportEffects { | |
| 257 | 243 | if active.transport == expected.transport && active.incarnation == expected.incarnation { | |
| 258 | 244 | return true | |
| @@ -261,12 +247,12 @@ func gateHasTransportEffectLocked(g *roleEffectGate, expected transportSnapshot) | |||
| 261 | 247 | return false | |
| 262 | 248 | } | |
| 263 | 249 | ||
| 264 | - | func (f frozenRoleEffectGates) unfreeze() { | |
| 250 | + | func (f frozenAttachmentEffectGates) unfreeze() { | |
| 265 | 251 | for i := len(f.clients) - 1; i >= 0; i-- { | |
| 266 | - | g := &f.clients[i].roleEffects | |
| 252 | + | g := &f.clients[i].attachmentEffects | |
| 267 | 253 | g.mu.Lock() | |
| 268 | 254 | g.initLocked() | |
| 269 | - | g.phase = roleEffectsStable | |
| 255 | + | g.phase = attachmentEffectsStable | |
| 270 | 256 | g.signalLocked() | |
| 271 | 257 | g.mu.Unlock() | |
| 272 | 258 | } | |
A internal/usecase/daemon/attachment_effect_gate.go +311 -0
| @@ -0,0 +1,311 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | "sync/atomic" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | type attachmentEffectPhase uint8 | |
| 11 | + | ||
| 12 | + | const ( | |
| 13 | + | attachmentEffectsStable attachmentEffectPhase = iota | |
| 14 | + | attachmentEffectsFrozen | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // attachmentCapability is the single attachment-local publication consumed by | |
| 18 | + | // effect admission. It deliberately contains no independently mutable state: | |
| 19 | + | // a ticket is admitted only when every field matches the frame's token. | |
| 20 | + | type attachmentCapability struct { | |
| 21 | + | sess attachmentSession | |
| 22 | + | generation uint64 | |
| 23 | + | transport transportSnapshot | |
| 24 | + | lease *attachmentLease | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func capabilityFromToken(token attachmentConnectionToken) attachmentCapability { | |
| 28 | + | return attachmentCapability{ | |
| 29 | + | sess: token.sess, | |
| 30 | + | generation: token.generation, | |
| 31 | + | transport: token.transport, | |
| 32 | + | lease: token.lease, | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func (c attachmentCapability) matches(token attachmentConnectionToken) bool { | |
| 37 | + | return token.ac != nil && c.sess == token.sess && | |
| 38 | + | c.generation == token.generation && c.transport.transport == token.transport.transport && | |
| 39 | + | c.transport.incarnation == token.transport.incarnation && c.lease == token.lease | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type attachmentEffectGate struct { | |
| 43 | + | mu sync.Mutex | |
| 44 | + | cond *sync.Cond | |
| 45 | + | changed chan struct{} | |
| 46 | + | phase attachmentEffectPhase | |
| 47 | + | capability attachmentCapability | |
| 48 | + | inFlight uint64 | |
| 49 | + | transportEffects map[*attachmentEffectTicket]transportSnapshot | |
| 50 | + | failedTransport transportSnapshot | |
| 51 | + | order atomic.Uint64 | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | var nextAttachmentEffectGateOrder atomic.Uint64 | |
| 55 | + | ||
| 56 | + | func (g *attachmentEffectGate) initLocked() { | |
| 57 | + | if g.cond == nil { | |
| 58 | + | g.cond = sync.NewCond(&g.mu) | |
| 59 | + | } | |
| 60 | + | if g.changed == nil { | |
| 61 | + | g.changed = make(chan struct{}) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func (g *attachmentEffectGate) signalLocked() { | |
| 66 | + | g.initLocked() | |
| 67 | + | close(g.changed) | |
| 68 | + | g.changed = make(chan struct{}) | |
| 69 | + | g.cond.Broadcast() | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func (g *attachmentEffectGate) immutableOrder() uint64 { | |
| 73 | + | if order := g.order.Load(); order != 0 { | |
| 74 | + | return order | |
| 75 | + | } | |
| 76 | + | order := nextAttachmentEffectGateOrder.Add(1) | |
| 77 | + | if g.order.CompareAndSwap(0, order) { | |
| 78 | + | return order | |
| 79 | + | } | |
| 80 | + | return g.order.Load() | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func (g *attachmentEffectGate) inFlightCount() uint64 { | |
| 84 | + | if g == nil { | |
| 85 | + | return 0 | |
| 86 | + | } | |
| 87 | + | g.mu.Lock() | |
| 88 | + | defer g.mu.Unlock() | |
| 89 | + | return g.inFlight | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // attachmentEffectTicket reserves the exact published capability until End. End is | |
| 93 | + | // idempotent so error paths can safely defer it before doing any observable I/O. | |
| 94 | + | type attachmentEffectTicket struct { | |
| 95 | + | gate *attachmentEffectGate | |
| 96 | + | token attachmentConnectionToken | |
| 97 | + | actionDaemon *Daemon | |
| 98 | + | action string | |
| 99 | + | ended atomic.Bool | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func (t *attachmentEffectTicket) bindActionEnd(d *Daemon, action string) { | |
| 103 | + | if t == nil || t.ended.Load() { | |
| 104 | + | return | |
| 105 | + | } | |
| 106 | + | t.actionDaemon = d | |
| 107 | + | t.action = action | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func (t *attachmentEffectTicket) connectionToken() attachmentConnectionToken { | |
| 111 | + | if t == nil { | |
| 112 | + | return attachmentConnectionToken{} | |
| 113 | + | } | |
| 114 | + | token := t.token | |
| 115 | + | token.effect = t | |
| 116 | + | return token | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func (t *attachmentEffectTicket) End() { | |
| 120 | + | if t == nil || t.gate == nil || !t.ended.CompareAndSwap(false, true) { | |
| 121 | + | return | |
| 122 | + | } | |
| 123 | + | g := t.gate | |
| 124 | + | g.mu.Lock() | |
| 125 | + | g.initLocked() | |
| 126 | + | if g.inFlight == 0 { | |
| 127 | + | g.mu.Unlock() | |
| 128 | + | panic("daemon: attachment effect ticket ended without admission") | |
| 129 | + | } | |
| 130 | + | delete(g.transportEffects, t) | |
| 131 | + | g.inFlight-- | |
| 132 | + | g.signalLocked() | |
| 133 | + | g.mu.Unlock() | |
| 134 | + | if t.actionDaemon != nil && t.actionDaemon.afterActionAttachmentEffectEnded != nil { | |
| 135 | + | t.actionDaemon.afterActionAttachmentEffectEnded(t.action) | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func (d *Daemon) endActionAttachmentEffect(effect *attachmentEffectTicket, _ string) { | |
| 140 | + | if effect != nil { | |
| 141 | + | effect.End() | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // beginTransportSend marks only the interval spent in an external transport | |
| 146 | + | // send as interruptible. A replacement may close this exact published link to | |
| 147 | + | // release the send without retiring an idle healthy attachment. | |
| 148 | + | func (t *attachmentEffectTicket) beginTransportSend(expected transportSnapshot) bool { | |
| 149 | + | if t == nil || t.gate == nil || t.ended.Load() || expected.transport == nil { | |
| 150 | + | return false | |
| 151 | + | } | |
| 152 | + | g := t.gate | |
| 153 | + | g.mu.Lock() | |
| 154 | + | g.initLocked() | |
| 155 | + | if t.ended.Load() || g.capability.transport.transport != expected.transport || | |
| 156 | + | g.capability.transport.incarnation != expected.incarnation { | |
| 157 | + | g.mu.Unlock() | |
| 158 | + | return false | |
| 159 | + | } | |
| 160 | + | if g.transportEffects == nil { | |
| 161 | + | g.transportEffects = make(map[*attachmentEffectTicket]transportSnapshot) | |
| 162 | + | } | |
| 163 | + | g.transportEffects[t] = expected | |
| 164 | + | g.signalLocked() | |
| 165 | + | g.mu.Unlock() | |
| 166 | + | return true | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func (t *attachmentEffectTicket) reportTransportFailure(expected transportSnapshot) { | |
| 170 | + | if t == nil || t.gate == nil || expected.transport == nil { | |
| 171 | + | return | |
| 172 | + | } | |
| 173 | + | g := t.gate | |
| 174 | + | g.mu.Lock() | |
| 175 | + | if !t.ended.Load() && g.capability.transport.transport == expected.transport && | |
| 176 | + | g.capability.transport.incarnation == expected.incarnation { | |
| 177 | + | g.failedTransport = expected | |
| 178 | + | g.signalLocked() | |
| 179 | + | } | |
| 180 | + | g.mu.Unlock() | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func (t *attachmentEffectTicket) endTransportSend() { | |
| 184 | + | if t == nil || t.gate == nil { | |
| 185 | + | return | |
| 186 | + | } | |
| 187 | + | g := t.gate | |
| 188 | + | g.mu.Lock() | |
| 189 | + | if _, exists := g.transportEffects[t]; exists { | |
| 190 | + | delete(g.transportEffects, t) | |
| 191 | + | g.signalLocked() | |
| 192 | + | } | |
| 193 | + | g.mu.Unlock() | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // beginAttachmentEffect is the sole attachment-bound effect admission point. The gate is | |
| 197 | + | // held only long enough to reserve the capability; the effect itself must run | |
| 198 | + | // without the gate mutex. | |
| 199 | + | func (ac *attachedClient) beginAttachmentEffect(token attachmentConnectionToken) (*attachmentEffectTicket, bool) { | |
| 200 | + | if ac == nil || token.ac != ac { | |
| 201 | + | return nil, false | |
| 202 | + | } | |
| 203 | + | g := &ac.attachmentEffects | |
| 204 | + | g.mu.Lock() | |
| 205 | + | g.initLocked() | |
| 206 | + | if g.phase != attachmentEffectsStable || !g.capability.matches(token) { | |
| 207 | + | g.mu.Unlock() | |
| 208 | + | return nil, false | |
| 209 | + | } | |
| 210 | + | g.inFlight++ | |
| 211 | + | g.mu.Unlock() | |
| 212 | + | captured := token | |
| 213 | + | captured.effect = nil | |
| 214 | + | return &attachmentEffectTicket{gate: g, token: captured}, true | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | // beginCurrentAttachmentEffect waits out a transition that already froze ac, then | |
| 218 | + | // admits the capability derived from the session registry. Handshakes use it | |
| 219 | + | // after Welcome has completed so a replacement blocked behind that send can | |
| 220 | + | // publish before readiness or first paint. | |
| 221 | + | func (ac *attachedClient) beginCurrentAttachmentEffect(sess *session, tr ports.Transport) (attachmentConnectionToken, *attachmentEffectTicket, bool) { | |
| 222 | + | if ac == nil || sess == nil || tr == nil { | |
| 223 | + | return attachmentConnectionToken{}, nil, false | |
| 224 | + | } | |
| 225 | + | for { | |
| 226 | + | token := sess.attachmentToken(ac, tr) | |
| 227 | + | if token.ac == nil { | |
| 228 | + | return token, nil, false | |
| 229 | + | } | |
| 230 | + | if ticket, admitted := ac.beginAttachmentEffect(token); admitted { | |
| 231 | + | return token, ticket, true | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | g := &ac.attachmentEffects | |
| 235 | + | g.mu.Lock() | |
| 236 | + | g.initLocked() | |
| 237 | + | if g.phase == attachmentEffectsFrozen { | |
| 238 | + | changed := g.changed | |
| 239 | + | g.mu.Unlock() | |
| 240 | + | <-changed | |
| 241 | + | continue | |
| 242 | + | } | |
| 243 | + | g.mu.Unlock() | |
| 244 | + | // A stable mismatch is only recoverable when a publication raced token | |
| 245 | + | // capture; an already-stale token proves that happened. A current token | |
| 246 | + | // with a stable mismatch violates the publication invariant, so fail | |
| 247 | + | // closed instead of spinning forever. | |
| 248 | + | if token.current() { | |
| 249 | + | return token, nil, false | |
| 250 | + | } | |
| 251 | + | // Publication may have completed between token capture and admission. | |
| 252 | + | // Re-read the registry and exact generation instead of using stale authority. | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | // publishAttachmentCapability is used by direct/headless setup and by transition | |
| 257 | + | // publication while the gate is frozen. Production attachment changes publish only | |
| 258 | + | // through publishFrozenAttachmentCapability. | |
| 259 | + | func (ac *attachedClient) publishAttachmentCapability(token attachmentConnectionToken) { | |
| 260 | + | if ac == nil { | |
| 261 | + | return | |
| 262 | + | } | |
| 263 | + | g := &ac.attachmentEffects | |
| 264 | + | g.mu.Lock() | |
| 265 | + | g.initLocked() | |
| 266 | + | g.capability = capabilityFromToken(token) | |
| 267 | + | g.failedTransport = transportSnapshot{} | |
| 268 | + | g.mu.Unlock() | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | // bootstrapAttachmentCapability supports direct/headless session construction. It | |
| 272 | + | // never changes an existing or frozen production publication. | |
| 273 | + | func (ac *attachedClient) bootstrapAttachmentCapability(token attachmentConnectionToken) { | |
| 274 | + | if ac == nil || token.sess == nil || token.ac != ac { | |
| 275 | + | return | |
| 276 | + | } | |
| 277 | + | g := &ac.attachmentEffects | |
| 278 | + | g.mu.Lock() | |
| 279 | + | g.initLocked() | |
| 280 | + | if g.phase == attachmentEffectsStable && g.capability.sess == nil { | |
| 281 | + | g.capability = capabilityFromToken(token) | |
| 282 | + | g.failedTransport = transportSnapshot{} | |
| 283 | + | } | |
| 284 | + | g.mu.Unlock() | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | func (ac *attachedClient) publishFrozenAttachmentCapability(token attachmentConnectionToken) { | |
| 288 | + | g := &ac.attachmentEffects | |
| 289 | + | g.mu.Lock() | |
| 290 | + | g.initLocked() | |
| 291 | + | if g.phase != attachmentEffectsFrozen { | |
| 292 | + | g.mu.Unlock() | |
| 293 | + | panic("daemon: publishing attachment capability without transition freeze") | |
| 294 | + | } | |
| 295 | + | g.capability = capabilityFromToken(token) | |
| 296 | + | g.failedTransport = transportSnapshot{} | |
| 297 | + | g.mu.Unlock() | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | func (ac *attachedClient) invalidateFrozenAttachmentCapability() { | |
| 301 | + | g := &ac.attachmentEffects | |
| 302 | + | g.mu.Lock() | |
| 303 | + | g.initLocked() | |
| 304 | + | if g.phase != attachmentEffectsFrozen { | |
| 305 | + | g.mu.Unlock() | |
| 306 | + | panic("daemon: invalidating attachment capability without transition freeze") | |
| 307 | + | } | |
| 308 | + | g.capability = attachmentCapability{} | |
| 309 | + | g.failedTransport = transportSnapshot{} | |
| 310 | + | g.mu.Unlock() | |
| 311 | + | } | |
A internal/usecase/daemon/attachment_effect_gate_test.go +868 -0
| @@ -0,0 +1,868 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "io" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/domain" | |
| 13 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | + | "github.com/bnema/vev/internal/usecase/keys" | |
| 15 | + | "github.com/bnema/vev/pkg/renderer" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func releaseTestGate(t *testing.T, ch chan struct{}) func() { | |
| 19 | + | t.Helper() | |
| 20 | + | var once sync.Once | |
| 21 | + | release := func() { once.Do(func() { close(ch) }) } | |
| 22 | + | t.Cleanup(release) | |
| 23 | + | return release | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | type blockedRenderReplacementTransport struct { | |
| 27 | + | sendEntered chan struct{} | |
| 28 | + | closed chan struct{} | |
| 29 | + | sendOnce sync.Once | |
| 30 | + | closeOnce sync.Once | |
| 31 | + | locksFree chan bool | |
| 32 | + | d *Daemon | |
| 33 | + | sess *session | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func newBlockedRenderReplacementTransport(d *Daemon, sess *session) *blockedRenderReplacementTransport { | |
| 37 | + | return &blockedRenderReplacementTransport{ | |
| 38 | + | sendEntered: make(chan struct{}), | |
| 39 | + | closed: make(chan struct{}), | |
| 40 | + | locksFree: make(chan bool, 1), | |
| 41 | + | d: d, | |
| 42 | + | sess: sess, | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (t *blockedRenderReplacementTransport) Send(ports.Frame) error { | |
| 47 | + | t.sendOnce.Do(func() { close(t.sendEntered) }) | |
| 48 | + | <-t.closed | |
| 49 | + | return errors.New("transport closed") | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func (*blockedRenderReplacementTransport) Recv() (ports.Frame, error) { | |
| 53 | + | return ports.Frame{}, io.EOF | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func (t *blockedRenderReplacementTransport) Close() error { | |
| 57 | + | locksFree := t.d.mu.TryLock() | |
| 58 | + | if locksFree { | |
| 59 | + | t.d.mu.Unlock() | |
| 60 | + | } | |
| 61 | + | if locksFree { | |
| 62 | + | locksFree = t.d.notices.routingMu.TryLock() | |
| 63 | + | if locksFree { | |
| 64 | + | t.d.notices.routingMu.Unlock() | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | if locksFree { | |
| 68 | + | locksFree = t.sess.mu.TryLock() | |
| 69 | + | if locksFree { | |
| 70 | + | t.sess.mu.Unlock() | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | if locksFree { | |
| 74 | + | if rc := t.sess.renderCoordinator(); rc != nil { | |
| 75 | + | locksFree = rc.mu.TryLock() | |
| 76 | + | if locksFree { | |
| 77 | + | rc.mu.Unlock() | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | t.closeOnce.Do(func() { | |
| 82 | + | t.locksFree <- locksFree | |
| 83 | + | close(t.closed) | |
| 84 | + | }) | |
| 85 | + | return nil | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | type teardownBlockingTransport struct { | |
| 89 | + | sendEntered chan struct{} | |
| 90 | + | closed chan struct{} | |
| 91 | + | sendOnce sync.Once | |
| 92 | + | closeOnce sync.Once | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func newTeardownBlockingTransport() *teardownBlockingTransport { | |
| 96 | + | return &teardownBlockingTransport{sendEntered: make(chan struct{}), closed: make(chan struct{})} | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func (t *teardownBlockingTransport) Send(ports.Frame) error { | |
| 100 | + | t.sendOnce.Do(func() { close(t.sendEntered) }) | |
| 101 | + | <-t.closed | |
| 102 | + | return errors.New("transport closed") | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func (*teardownBlockingTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |
| 106 | + | ||
| 107 | + | func (t *teardownBlockingTransport) Close() error { | |
| 108 | + | t.closeOnce.Do(func() { close(t.closed) }) | |
| 109 | + | return nil | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func (t *teardownBlockingTransport) Closed() bool { | |
| 113 | + | select { | |
| 114 | + | case <-t.closed: | |
| 115 | + | return true | |
| 116 | + | default: | |
| 117 | + | return false | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func startBlockedAttachmentSend(t *testing.T, token attachmentConnectionToken) <-chan struct{} { | |
| 122 | + | t.Helper() | |
| 123 | + | ticket, admitted := token.ac.beginAttachmentEffect(token) | |
| 124 | + | require.True(t, admitted) | |
| 125 | + | done := make(chan struct{}) | |
| 126 | + | go func() { | |
| 127 | + | defer close(done) | |
| 128 | + | defer ticket.End() | |
| 129 | + | _ = token.ac.sendExpectedTransportForAttachment(token.transport, framePong(), ticket) | |
| 130 | + | }() | |
| 131 | + | return done | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func requireAttachmentEffectGateRetired(t *testing.T, ac *attachedClient) { | |
| 135 | + | t.Helper() | |
| 136 | + | ac.attachmentEffects.mu.Lock() | |
| 137 | + | defer ac.attachmentEffects.mu.Unlock() | |
| 138 | + | require.Equal(t, attachmentEffectsStable, ac.attachmentEffects.phase) | |
| 139 | + | require.Zero(t, ac.attachmentEffects.inFlight) | |
| 140 | + | require.Empty(t, ac.attachmentEffects.transportEffects) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func TestBeginCurrentAttachmentEffectFailsClosedOnStableCapabilityMismatch(t *testing.T) { | |
| 144 | + | _, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 145 | + | current := sess.attachmentToken(ac, ac.transport()) | |
| 146 | + | stale := current | |
| 147 | + | stale.generation++ | |
| 148 | + | ac.publishAttachmentCapability(stale) | |
| 149 | + | ||
| 150 | + | result := make(chan bool, 1) | |
| 151 | + | go func() { | |
| 152 | + | _, ticket, admitted := ac.beginCurrentAttachmentEffect(sess, ac.transport()) | |
| 153 | + | if ticket != nil { | |
| 154 | + | ticket.End() | |
| 155 | + | } | |
| 156 | + | result <- admitted | |
| 157 | + | }() | |
| 158 | + | select { | |
| 159 | + | case admitted := <-result: | |
| 160 | + | require.False(t, admitted) | |
| 161 | + | case <-time.After(time.Second): | |
| 162 | + | t.Fatal("stable capability mismatch spun instead of failing closed") | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func TestBeginAttachmentLeaseEffectRejectsForeignLease(t *testing.T) { | |
| 167 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 168 | + | rc := d.attachCoordinator(sess, ac, true) | |
| 169 | + | foreign := &attachmentLease{attachment: &attachedClient{}} | |
| 170 | + | _, admitted := beginAttachmentLeaseEffect(sess, ac, foreign) | |
| 171 | + | require.False(t, admitted) | |
| 172 | + | require.NotNil(t, rc.attachmentLease(ac)) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func attachmentEffectGatePublicationSnapshot(ac *attachedClient) (attachmentEffectPhase, attachmentCapability) { | |
| 176 | + | ac.attachmentEffects.mu.Lock() | |
| 177 | + | defer ac.attachmentEffects.mu.Unlock() | |
| 178 | + | return ac.attachmentEffects.phase, ac.attachmentEffects.capability | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testing.T) { | |
| 182 | + | d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 183 | + | firstTransport := &closeTrackingTransport{} | |
| 184 | + | first.replaceTransport(firstTransport) | |
| 185 | + | ||
| 186 | + | blockedTransport := &closeTrackingTransport{} | |
| 187 | + | blocked := &attachedClient{tr: blockedTransport, output: newOutputStateStream(), size: first.size} | |
| 188 | + | blocked.initOverlays() | |
| 189 | + | blocked.setSession(sess) | |
| 190 | + | laterTransport := &closeTrackingTransport{} | |
| 191 | + | later := &attachedClient{tr: laterTransport, output: newOutputStateStream(), size: first.size} | |
| 192 | + | later.initOverlays() | |
| 193 | + | later.setSession(sess) | |
| 194 | + | sess.mu.Lock() | |
| 195 | + | sess.registerAttachmentLocked(blocked) | |
| 196 | + | sess.registerAttachmentLocked(later) | |
| 197 | + | sess.mu.Unlock() | |
| 198 | + | ||
| 199 | + | rc := d.attachCoordinator(sess, first, true) | |
| 200 | + | firstToken := sess.attachmentToken(first, firstTransport) | |
| 201 | + | firstToken.lease = rc.attachmentLease(first) | |
| 202 | + | first.publishAttachmentCapability(firstToken) | |
| 203 | + | blockedToken := sess.attachmentToken(blocked, blockedTransport) | |
| 204 | + | blocked.publishAttachmentCapability(blockedToken) | |
| 205 | + | laterToken := sess.attachmentToken(later, laterTransport) | |
| 206 | + | later.publishAttachmentCapability(laterToken) | |
| 207 | + | ||
| 208 | + | // Fix the canonical order so teardown acquires first, waits behind the gate | |
| 209 | + | // owned by this test, and never reaches the still-stable later participant. | |
| 210 | + | first.attachmentEffects.immutableOrder() | |
| 211 | + | blocked.attachmentEffects.immutableOrder() | |
| 212 | + | later.attachmentEffects.immutableOrder() | |
| 213 | + | require.Less(t, first.attachmentEffects.order.Load(), blocked.attachmentEffects.order.Load()) | |
| 214 | + | require.Less(t, blocked.attachmentEffects.order.Load(), later.attachmentEffects.order.Load()) | |
| 215 | + | ||
| 216 | + | blockedOwner := freezeAttachmentEffectGates(blocked) | |
| 217 | + | blockedOwnerHeld := true | |
| 218 | + | t.Cleanup(func() { | |
| 219 | + | if blockedOwnerHeld { | |
| 220 | + | blockedOwner.unfreeze() | |
| 221 | + | } | |
| 222 | + | }) | |
| 223 | + | ||
| 224 | + | firstPhase, firstCapability := attachmentEffectGatePublicationSnapshot(first) | |
| 225 | + | blockedPhase, blockedCapability := attachmentEffectGatePublicationSnapshot(blocked) | |
| 226 | + | laterPhase, laterCapability := attachmentEffectGatePublicationSnapshot(later) | |
| 227 | + | require.Equal(t, attachmentEffectsStable, firstPhase) | |
| 228 | + | require.Equal(t, attachmentEffectsFrozen, blockedPhase) | |
| 229 | + | require.Equal(t, attachmentEffectsStable, laterPhase) | |
| 230 | + | firstGeneration := first.connectionGeneration.Load() | |
| 231 | + | blockedGeneration := blocked.connectionGeneration.Load() | |
| 232 | + | laterGeneration := later.connectionGeneration.Load() | |
| 233 | + | firstSnapshot := first.transportSnapshot() | |
| 234 | + | blockedSnapshot := blocked.transportSnapshot() | |
| 235 | + | laterSnapshot := later.transportSnapshot() | |
| 236 | + | ||
| 237 | + | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 238 | + | d.clock = clock | |
| 239 | + | frozenByKill := make(chan *attachedClient, 3) | |
| 240 | + | d.afterAttachmentEffectGateFrozen = func(_ string, ac *attachedClient) { frozenByKill <- ac } | |
| 241 | + | killDone := make(chan error, 1) | |
| 242 | + | go func() { killDone <- d.killSession(sess, ports.ReasonSessionKilled, false) }() | |
| 243 | + | ||
| 244 | + | require.Same(t, first, awaitTestValue(t, frozenByKill, "teardown did not acquire the first attachment effect gate")) | |
| 245 | + | deadline := awaitTestValue(t, clock.timers, "teardown did not arm the gate acquisition deadline") | |
| 246 | + | require.Equal(t, detachNotifyTimeout, deadline.duration) | |
| 247 | + | deadline.ch <- time.Now() | |
| 248 | + | select { | |
| 249 | + | case err := <-killDone: | |
| 250 | + | require.NoError(t, err) | |
| 251 | + | case <-time.After(time.Second): | |
| 252 | + | t.Fatal("teardown did not abort after partial gate acquisition timed out") | |
| 253 | + | } | |
| 254 | + | require.Empty(t, frozenByKill, "teardown acquired a participant after its deadline expired") | |
| 255 | + | ||
| 256 | + | d.mu.Lock() | |
| 257 | + | require.Same(t, sess, d.sessions[sess.id], "partial gate acquisition removed the session registry owner") | |
| 258 | + | d.mu.Unlock() | |
| 259 | + | sess.mu.Lock() | |
| 260 | + | require.Contains(t, sess.snapshotAttachmentsLocked(), first) | |
| 261 | + | require.Contains(t, sess.snapshotAttachmentsLocked(), blocked) | |
| 262 | + | require.Contains(t, sess.snapshotAttachmentsLocked(), later) | |
| 263 | + | sess.mu.Unlock() | |
| 264 | + | for _, ac := range []*attachedClient{first, blocked, later} { | |
| 265 | + | require.Same(t, sess, ac.currentSession(), "partial gate acquisition cleared attachment ownership") | |
| 266 | + | } | |
| 267 | + | require.Equal(t, firstGeneration, first.connectionGeneration.Load()) | |
| 268 | + | require.Equal(t, blockedGeneration, blocked.connectionGeneration.Load()) | |
| 269 | + | require.Equal(t, laterGeneration, later.connectionGeneration.Load()) | |
| 270 | + | require.True(t, first.transportSnapshotCurrent(firstSnapshot)) | |
| 271 | + | require.True(t, blocked.transportSnapshotCurrent(blockedSnapshot)) | |
| 272 | + | require.True(t, later.transportSnapshotCurrent(laterSnapshot)) | |
| 273 | + | require.False(t, firstTransport.Closed()) | |
| 274 | + | require.False(t, blockedTransport.Closed()) | |
| 275 | + | require.False(t, laterTransport.Closed()) | |
| 276 | + | ||
| 277 | + | firstPhase, gotFirstCapability := attachmentEffectGatePublicationSnapshot(first) | |
| 278 | + | blockedPhase, gotBlockedCapability := attachmentEffectGatePublicationSnapshot(blocked) | |
| 279 | + | laterPhase, gotLaterCapability := attachmentEffectGatePublicationSnapshot(later) | |
| 280 | + | require.Equal(t, attachmentEffectsStable, firstPhase, "partially acquired gate was not rolled back") | |
| 281 | + | require.Equal(t, attachmentEffectsFrozen, blockedPhase, "teardown released a gate owned by another transition") | |
| 282 | + | require.Equal(t, attachmentEffectsStable, laterPhase, "unacquired later gate was frozen or invalidated") | |
| 283 | + | require.Equal(t, firstCapability, gotFirstCapability) | |
| 284 | + | require.Equal(t, blockedCapability, gotBlockedCapability) | |
| 285 | + | require.Equal(t, laterCapability, gotLaterCapability) | |
| 286 | + | ||
| 287 | + | blockedOwner.unfreeze() | |
| 288 | + | blockedOwnerHeld = false | |
| 289 | + | complete := freezeAttachmentEffectGates(first, blocked, later) | |
| 290 | + | require.True(t, complete.acquired, "rolled-back gate set could not be acquired again") | |
| 291 | + | require.True(t, complete.drained) | |
| 292 | + | complete.unfreeze() | |
| 293 | + | for _, token := range []attachmentConnectionToken{firstToken, blockedToken, laterToken} { | |
| 294 | + | ticket, admitted := token.ac.beginAttachmentEffect(token) | |
| 295 | + | require.True(t, admitted, "pre-publication abort changed an attachment capability") | |
| 296 | + | ticket.End() | |
| 297 | + | requireAttachmentEffectGateRetired(t, token.ac) | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) { | |
| 302 | + | d, sess, snatched, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 303 | + | snatchedTransport := newTeardownBlockingTransport() | |
| 304 | + | snatched.replaceTransport(snatchedTransport) | |
| 305 | + | d.attachCoordinator(sess, snatched, true) | |
| 306 | + | snatched.publishAttachmentCapability(sess.attachmentToken(snatched, snatchedTransport)) | |
| 307 | + | ||
| 308 | + | activeTransport := newTeardownBlockingTransport() | |
| 309 | + | active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: snatched.size} | |
| 310 | + | active.initOverlays() | |
| 311 | + | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 312 | + | target: sess, next: active, expectedTransport: active.transportSnapshot(), ready: true, | |
| 313 | + | }) | |
| 314 | + | require.NoError(t, err) | |
| 315 | + | defer d.deferAttachmentTransitionCleanups(transition) | |
| 316 | + | ||
| 317 | + | unrelatedTransport := &closeTrackingTransport{} | |
| 318 | + | unrelatedClient := &attachedClient{tr: unrelatedTransport, output: newOutputStateStream(), size: active.size} | |
| 319 | + | unrelatedClient.initOverlays() | |
| 320 | + | unrelated := &session{sessionCore: sessionCore{id: "unrelated", name: "unrelated", attachments: map[*attachedClient]struct{}{unrelatedClient: {}}}, ctx: sess.ctx, cancel: func() {}} | |
| 321 | + | unrelatedClient.setSession(unrelated) | |
| 322 | + | d.mu.Lock() | |
| 323 | + | d.sessions[unrelated.id] = unrelated | |
| 324 | + | d.mu.Unlock() | |
| 325 | + | d.attachCoordinator(unrelated, unrelatedClient, true) | |
| 326 | + | ||
| 327 | + | snatchedToken := sess.attachmentToken(snatched, snatchedTransport) | |
| 328 | + | activeToken := sess.attachmentToken(active, activeTransport) | |
| 329 | + | snatchedDone := startBlockedAttachmentSend(t, snatchedToken) | |
| 330 | + | activeDone := startBlockedAttachmentSend(t, activeToken) | |
| 331 | + | awaitTestCompletion(t, snatchedTransport.sendEntered, "snatched send did not block") | |
| 332 | + | awaitTestCompletion(t, activeTransport.sendEntered, "active send did not block") | |
| 333 | + | ||
| 334 | + | killDone := make(chan error, 1) | |
| 335 | + | go func() { killDone <- d.killSession(sess, ports.ReasonSessionKilled, false) }() | |
| 336 | + | select { | |
| 337 | + | case err := <-killDone: | |
| 338 | + | require.NoError(t, err) | |
| 339 | + | case <-time.After(500 * time.Millisecond): | |
| 340 | + | _ = snatchedTransport.Close() | |
| 341 | + | _ = activeTransport.Close() | |
| 342 | + | t.Fatal("session kill remained pinned behind admitted participant sends") | |
| 343 | + | } | |
| 344 | + | awaitTestCompletion(t, snatchedDone, "snatched attachment effect ticket did not retire") | |
| 345 | + | awaitTestCompletion(t, activeDone, "active attachment effect ticket did not retire") | |
| 346 | + | d.waitNotifies() | |
| 347 | + | ||
| 348 | + | require.True(t, snatchedTransport.Closed()) | |
| 349 | + | require.True(t, activeTransport.Closed()) | |
| 350 | + | require.False(t, unrelatedTransport.Closed(), "unrelated session transport was interrupted") | |
| 351 | + | require.Contains(t, unrelated.snapshotAttachments(), unrelatedClient) | |
| 352 | + | require.Same(t, unrelated, unrelatedClient.currentSession()) | |
| 353 | + | requireAttachmentEffectGateRetired(t, snatched) | |
| 354 | + | requireAttachmentEffectGateRetired(t, active) | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *testing.T) { | |
| 358 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 359 | + | stale := newTeardownBlockingTransport() | |
| 360 | + | ac.replaceTransport(stale) | |
| 361 | + | d.attachCoordinator(sess, ac, true) | |
| 362 | + | ac.publishAttachmentCapability(sess.attachmentToken(ac, stale)) | |
| 363 | + | ||
| 364 | + | token := sess.attachmentToken(ac, stale) | |
| 365 | + | sendDone := startBlockedAttachmentSend(t, token) | |
| 366 | + | awaitTestCompletion(t, stale.sendEntered, "captured incarnation send did not block") | |
| 367 | + | fresh := &closeTrackingTransport{} | |
| 368 | + | d.afterAttachmentEffectParticipantsSnapshotted = func(string, []*attachedClient) { | |
| 369 | + | ac.replaceTransport(fresh) | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |
| 373 | + | awaitTestCompletion(t, sendDone, "captured incarnation attachment effect ticket did not retire") | |
| 374 | + | require.True(t, stale.Closed(), "exact captured in-flight transport was not interrupted") | |
| 375 | + | require.False(t, fresh.Closed(), "newer transport incarnation was interrupted") | |
| 376 | + | require.Same(t, fresh, ac.transport()) | |
| 377 | + | d.mu.Lock() | |
| 378 | + | require.Same(t, sess, d.sessions[sess.id], "transport incarnation change should abort stale teardown publication") | |
| 379 | + | d.mu.Unlock() | |
| 380 | + | sess.mu.Lock() | |
| 381 | + | require.Contains(t, sess.snapshotAttachmentsLocked(), ac) | |
| 382 | + | sess.mu.Unlock() | |
| 383 | + | requireAttachmentEffectGateRetired(t, ac) | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | func TestShutdownSignalsServeWhenParticipantGateAcquisitionTimesOut(t *testing.T) { | |
| 387 | + | d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 388 | + | transport := &closeTrackingTransport{} | |
| 389 | + | active.replaceTransport(transport) | |
| 390 | + | d.attachCoordinator(sess, active, true) | |
| 391 | + | active.publishAttachmentCapability(sess.attachmentToken(active, transport)) | |
| 392 | + | ||
| 393 | + | owner := freezeAttachmentEffectGates(active) | |
| 394 | + | require.True(t, owner.acquired) | |
| 395 | + | defer owner.unfreeze() | |
| 396 | + | ||
| 397 | + | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 398 | + | d.clock = clock | |
| 399 | + | shutdownDone := make(chan bool, 1) | |
| 400 | + | go func() { shutdownDone <- d.shutdownAll(ports.ReasonServerShutdown) }() | |
| 401 | + | ||
| 402 | + | deadline := awaitTestValue(t, clock.timers, "shutdown did not arm the gate acquisition deadline") | |
| 403 | + | deadline.ch <- clock.Now() | |
| 404 | + | require.False(t, awaitTestValue(t, shutdownDone, "shutdown did not return after its gate acquisition deadline")) | |
| 405 | + | ||
| 406 | + | d.mu.Lock() | |
| 407 | + | registered := d.sessions[sess.id] | |
| 408 | + | d.mu.Unlock() | |
| 409 | + | require.Same(t, sess, registered, "timed-out initial teardown should leave the session for Serve's shutdown pass") | |
| 410 | + | select { | |
| 411 | + | case <-d.done: | |
| 412 | + | case <-time.After(time.Second): | |
| 413 | + | t.Fatal("shutdown request did not signal Serve after the initial teardown pass timed out") | |
| 414 | + | } | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | func TestShutdownInterruptsBlockedParticipantSend(t *testing.T) { | |
| 418 | + | d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 419 | + | blocked := newTeardownBlockingTransport() | |
| 420 | + | active.replaceTransport(blocked) | |
| 421 | + | d.attachCoordinator(sess, active, true) | |
| 422 | + | active.publishAttachmentCapability(sess.attachmentToken(active, blocked)) | |
| 423 | + | ||
| 424 | + | token := sess.attachmentToken(active, blocked) | |
| 425 | + | sendDone := startBlockedAttachmentSend(t, token) | |
| 426 | + | awaitTestCompletion(t, blocked.sendEntered, "active send did not block") | |
| 427 | + | ||
| 428 | + | shutdownDone := make(chan bool, 1) | |
| 429 | + | go func() { shutdownDone <- d.shutdownAll(ports.ReasonServerShutdown) }() | |
| 430 | + | select { | |
| 431 | + | case incomplete := <-shutdownDone: | |
| 432 | + | require.False(t, incomplete) | |
| 433 | + | case <-time.After(500 * time.Millisecond): | |
| 434 | + | _ = blocked.Close() | |
| 435 | + | t.Fatal("shutdown remained pinned behind admitted participant send") | |
| 436 | + | } | |
| 437 | + | awaitTestCompletion(t, sendDone, "shutdown-interrupted attachment effect ticket did not retire") | |
| 438 | + | d.waitNotifies() | |
| 439 | + | require.True(t, blocked.Closed()) | |
| 440 | + | requireAttachmentEffectGateRetired(t, active) | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | func TestRenderSendFailureCleanupIsDeadlineBoundedWhileAttachmentEffectGateIsBusy(t *testing.T) { | |
| 444 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 445 | + | transport := &closeTrackingTransport{} | |
| 446 | + | ac.replaceTransport(transport) | |
| 447 | + | rc := d.attachCoordinator(sess, ac, true) | |
| 448 | + | token := sess.attachmentToken(ac, transport) | |
| 449 | + | token.lease = rc.attachmentLease(ac) | |
| 450 | + | ac.publishAttachmentCapability(token) | |
| 451 | + | ||
| 452 | + | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 453 | + | d.clock = clock | |
| 454 | + | frozen := freezeAttachmentEffectGates(ac) | |
| 455 | + | defer frozen.unfreeze() | |
| 456 | + | cleanupDone := make(chan struct{}) | |
| 457 | + | d.afterAttachmentSendErrorCleanup = func() { close(cleanupDone) } | |
| 458 | + | ||
| 459 | + | launchCleanup := d.reserveAttachmentSendErrorCleanup(token, transport) | |
| 460 | + | launchCleanup() | |
| 461 | + | deadline := awaitTestValue(t, clock.timers, "render cleanup did not arm its deadline") | |
| 462 | + | require.Equal(t, detachNotifyTimeout, deadline.duration) | |
| 463 | + | deadline.ch <- time.Now() | |
| 464 | + | awaitTestCompletion(t, cleanupDone, "deadline did not release blocked render cleanup") | |
| 465 | + | awaitTestCompletion(t, waitGroupDone(&d.attachmentCleanupWg), "bounded render cleanup leaked its tracked goroutine") | |
| 466 | + | ||
| 467 | + | sess.mu.Lock() | |
| 468 | + | require.Contains(t, sess.snapshotAttachmentsLocked(), ac) | |
| 469 | + | sess.mu.Unlock() | |
| 470 | + | require.False(t, transport.Closed()) | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | func TestAttachmentEffectGateReplacementInterruptsBlockedOldRenderBeforePublication(t *testing.T) { | |
| 474 | + | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 475 | + | oldTransport := newBlockedRenderReplacementTransport(d, sess) | |
| 476 | + | old.replaceTransport(oldTransport) | |
| 477 | + | rc := d.attachCoordinator(sess, old, true) | |
| 478 | + | lease := rc.attachmentLease(old) | |
| 479 | + | token := sess.attachmentToken(old, oldTransport) | |
| 480 | + | token.lease = lease | |
| 481 | + | old.publishAttachmentCapability(token) | |
| 482 | + | ||
| 483 | + | paintDone := make(chan struct{}) | |
| 484 | + | go func() { | |
| 485 | + | d.paint(sess, old, true, lease) | |
| 486 | + | close(paintDone) | |
| 487 | + | }() | |
| 488 | + | <-oldTransport.sendEntered | |
| 489 | + | ||
| 490 | + | newTransport := &closeTrackingTransport{} | |
| 491 | + | next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size} | |
| 492 | + | next.initOverlays() | |
| 493 | + | transitionDone := make(chan attachmentTransitionResult, 1) | |
| 494 | + | transitionErr := make(chan error, 1) | |
| 495 | + | go func() { | |
| 496 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 497 | + | target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true, | |
| 498 | + | }) | |
| 499 | + | transitionDone <- result | |
| 500 | + | transitionErr <- err | |
| 501 | + | }() | |
| 502 | + | ||
| 503 | + | var result attachmentTransitionResult | |
| 504 | + | select { | |
| 505 | + | case result = <-transitionDone: | |
| 506 | + | case <-time.After(time.Second): | |
| 507 | + | t.Fatal("replacement publication remained blocked behind the old render send") | |
| 508 | + | } | |
| 509 | + | require.NoError(t, <-transitionErr) | |
| 510 | + | require.Contains(t, sess.snapshotAttachments(), next) | |
| 511 | + | require.Contains(t, sess.snapshotAttachments(), old) | |
| 512 | + | require.False(t, newTransport.Closed(), "new attachment transport was affected by publication") | |
| 513 | + | ||
| 514 | + | // Generic attachment publication does not interrupt or retire another | |
| 515 | + | // registered connection. Release the test transport explicitly. | |
| 516 | + | require.NoError(t, oldTransport.Close()) | |
| 517 | + | select { | |
| 518 | + | case <-paintDone: | |
| 519 | + | case <-time.After(time.Second): | |
| 520 | + | t.Fatal("closing the test transport did not unblock its render") | |
| 521 | + | } | |
| 522 | + | d.deferAttachmentTransitionCleanups(result) | |
| 523 | + | d.attachmentCleanupWg.Wait() | |
| 524 | + | require.Same(t, oldTransport, old.transport(), "unrelated attachment link was retired") | |
| 525 | + | require.False(t, newTransport.Closed()) | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | func TestAttachmentEffectGateAdmittedEffectCompletesBeforeConflictingTransition(t *testing.T) { | |
| 529 | + | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 530 | + | oldTransport := newDatagramTestTransport() | |
| 531 | + | old.replaceTransport(oldTransport) | |
| 532 | + | rc := d.attachCoordinator(sess, old, true) | |
| 533 | + | token := sess.attachmentToken(old, oldTransport) | |
| 534 | + | token.lease = rc.attachmentLease(old) | |
| 535 | + | old.publishAttachmentCapability(token) | |
| 536 | + | ||
| 537 | + | ticket, ok := old.beginAttachmentEffect(token) | |
| 538 | + | require.True(t, ok) | |
| 539 | + | ||
| 540 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 541 | + | next.initOverlays() | |
| 542 | + | done := make(chan error, 1) | |
| 543 | + | go func() { | |
| 544 | + | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 545 | + | target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true, | |
| 546 | + | }) | |
| 547 | + | done <- err | |
| 548 | + | }() | |
| 549 | + | ||
| 550 | + | // Adding an independent attachment does not wait on another connection's | |
| 551 | + | // effect gate. | |
| 552 | + | require.NoError(t, <-done) | |
| 553 | + | ticket.End() | |
| 554 | + | } | |
| 555 | + | ||
| 556 | + | func TestAttachmentEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *testing.T) { | |
| 557 | + | tests := []struct { | |
| 558 | + | name string | |
| 559 | + | setup func(*Daemon, *session, *attachedClient, *transactionalResizePTY) | |
| 560 | + | frame ports.Frame | |
| 561 | + | assert func(*testing.T, *attachedClient, *transactionalResizePTY) | |
| 562 | + | }{ | |
| 563 | + | { | |
| 564 | + | name: "PTY input", | |
| 565 | + | frame: frameInput([]byte("reserved")), | |
| 566 | + | assert: func(t *testing.T, _ *attachedClient, pty *transactionalResizePTY) { | |
| 567 | + | require.Equal(t, [][]byte{[]byte("reserved")}, pty.writes()) | |
| 568 | + | }, | |
| 569 | + | }, | |
| 570 | + | { | |
| 571 | + | name: "mouse input", | |
| 572 | + | setup: func(_ *Daemon, sess *session, _ *attachedClient, _ *transactionalResizePTY) { | |
| 573 | + | p := testAttachmentTab(sess).focusedPane() | |
| 574 | + | p.mu.Lock() | |
| 575 | + | p.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |
| 576 | + | p.mu.Unlock() | |
| 577 | + | }, | |
| 578 | + | frame: frameInput([]byte("\x1b[<0;1;2M")), | |
| 579 | + | assert: func(t *testing.T, _ *attachedClient, pty *transactionalResizePTY) { | |
| 580 | + | require.NotEmpty(t, pty.writes()) | |
| 581 | + | }, | |
| 582 | + | }, | |
| 583 | + | { | |
| 584 | + | name: "key action", | |
| 585 | + | frame: frameInput([]byte("\x1b ")), | |
| 586 | + | assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) { | |
| 587 | + | require.True(t, ac.overlays.paletteActive()) | |
| 588 | + | }, | |
| 589 | + | }, | |
| 590 | + | { | |
| 591 | + | name: "overlay mutation", | |
| 592 | + | setup: func(d *Daemon, sess *session, ac *attachedClient, _ *transactionalResizePTY) { | |
| 593 | + | d.enterPrompt(sess, ac, "Rename", "", func(string) error { return nil }) | |
| 594 | + | }, | |
| 595 | + | frame: frameInput([]byte("x")), | |
| 596 | + | assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) { | |
| 597 | + | ac.overlays.promptMu.Lock() | |
| 598 | + | defer ac.overlays.promptMu.Unlock() | |
| 599 | + | require.Equal(t, "x", ac.overlays.prompt.Value()) | |
| 600 | + | }, | |
| 601 | + | }, | |
| 602 | + | { | |
| 603 | + | name: "theme", | |
| 604 | + | frame: ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(ports.Theme{ | |
| 605 | + | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 606 | + | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 607 | + | })}, | |
| 608 | + | assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) { | |
| 609 | + | require.True(t, ac.getClientTheme().HasFG) | |
| 610 | + | }, | |
| 611 | + | }, | |
| 612 | + | { | |
| 613 | + | name: "ACK", | |
| 614 | + | setup: func(_ *Daemon, _ *session, ac *attachedClient, _ *transactionalResizePTY) { | |
| 615 | + | ac.output.next = 3 | |
| 616 | + | }, | |
| 617 | + | frame: ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: 1})}, | |
| 618 | + | assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) { | |
| 619 | + | ac.sendMu.Lock() | |
| 620 | + | defer ac.sendMu.Unlock() | |
| 621 | + | require.Equal(t, uint64(1), ac.output.acked) | |
| 622 | + | }, | |
| 623 | + | }, | |
| 624 | + | } | |
| 625 | + | ||
| 626 | + | for _, tt := range tests { | |
| 627 | + | t.Run(tt.name, func(t *testing.T) { | |
| 628 | + | pty := &transactionalResizePTY{} | |
| 629 | + | d, sess, old, _ := newManualSessionWithPTYs(t, pty) | |
| 630 | + | oldTransport := newDatagramTestTransport() | |
| 631 | + | old.replaceTransport(oldTransport) | |
| 632 | + | old.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: old}, &d.bindings) | |
| 633 | + | rc := d.attachCoordinator(sess, old, true) | |
| 634 | + | token := sess.attachmentToken(old, oldTransport) | |
| 635 | + | token.lease = rc.attachmentLease(old) | |
| 636 | + | if tt.setup != nil { | |
| 637 | + | tt.setup(d, sess, old, pty) | |
| 638 | + | } | |
| 639 | + | ||
| 640 | + | admitted := make(chan struct{}) | |
| 641 | + | release := make(chan struct{}) | |
| 642 | + | d.afterAttachmentEffectAdmitted = func(attachmentConnectionToken) { | |
| 643 | + | close(admitted) | |
| 644 | + | <-release | |
| 645 | + | } | |
| 646 | + | effectDone := make(chan struct{}) | |
| 647 | + | go func() { | |
| 648 | + | d.handleAttachmentClientFrame(token, tt.frame) | |
| 649 | + | close(effectDone) | |
| 650 | + | }() | |
| 651 | + | <-admitted | |
| 652 | + | ||
| 653 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 654 | + | next.initOverlays() | |
| 655 | + | transitionDone := make(chan error, 1) | |
| 656 | + | go func() { | |
| 657 | + | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 658 | + | target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true, | |
| 659 | + | }) | |
| 660 | + | transitionDone <- err | |
| 661 | + | }() | |
| 662 | + | require.NoError(t, <-transitionDone) | |
| 663 | + | close(release) | |
| 664 | + | <-effectDone | |
| 665 | + | tt.assert(t, old, pty) | |
| 666 | + | }) | |
| 667 | + | } | |
| 668 | + | } | |
| 669 | + | ||
| 670 | + | func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) { | |
| 671 | + | p1, release1 := newBlockingPTY(t) | |
| 672 | + | p2, release2 := newBlockingPTY(t) | |
| 673 | + | p3, release3 := newBlockingPTY(t) | |
| 674 | + | defer release1() | |
| 675 | + | defer release2() | |
| 676 | + | defer release3() | |
| 677 | + | d, source, ac, _ := newManualSessionWithPTYs(t, p1) | |
| 678 | + | target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{ | |
| 679 | + | newTab(p2, domain.Size{Cols: 80, Rows: 23}), | |
| 680 | + | newTab(p3, domain.Size{Cols: 80, Rows: 23}), | |
| 681 | + | }} | |
| 682 | + | target.tabs[1].attention = true | |
| 683 | + | target.tabs[1].attentionAt = time.Unix(1, 0) | |
| 684 | + | d.sessions[target.id] = target | |
| 685 | + | ||
| 686 | + | rc := d.attachCoordinator(source, ac, true) | |
| 687 | + | token := source.attachmentToken(ac, ac.transport()) | |
| 688 | + | token.lease = rc.attachmentLease(ac) | |
| 689 | + | ac.publishAttachmentCapability(token) | |
| 690 | + | ||
| 691 | + | d.handleAttachmentClientFrame(token, frameInput([]byte("\x1ba"))) | |
| 692 | + | ||
| 693 | + | require.Same(t, target, ac.currentSession()) | |
| 694 | + | require.Equal(t, 1, testAttachmentTabIndex(target)) | |
| 695 | + | } | |
| 696 | + | ||
| 697 | + | func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T) { | |
| 698 | + | p, releasePTY := newBlockingPTY(t) | |
| 699 | + | defer releasePTY() | |
| 700 | + | d, sess, old, _ := newManualSessionWithPTYs(t, p) | |
| 701 | + | d.enterPicker(sess, old) | |
| 702 | + | ||
| 703 | + | oldTransport := old.transport() | |
| 704 | + | rc := d.attachCoordinator(sess, old, true) | |
| 705 | + | token := sess.attachmentToken(old, oldTransport) | |
| 706 | + | token.lease = rc.attachmentLease(old) | |
| 707 | + | old.publishAttachmentCapability(token) | |
| 708 | + | ||
| 709 | + | admissionEnded := make(chan struct{}) | |
| 710 | + | releaseAction := make(chan struct{}) | |
| 711 | + | release := releaseTestGate(t, releaseAction) | |
| 712 | + | var admissionEndedOnce sync.Once | |
| 713 | + | d.afterActionAttachmentEffectEnded = func(action string) { | |
| 714 | + | if action != "picker-delete" { | |
| 715 | + | return | |
| 716 | + | } | |
| 717 | + | admissionEndedOnce.Do(func() { close(admissionEnded) }) | |
| 718 | + | <-releaseAction | |
| 719 | + | } | |
| 720 | + | actionDone := make(chan struct{}) | |
| 721 | + | go func() { | |
| 722 | + | d.handleAttachmentClientFrame(token, frameInput([]byte("x"))) | |
| 723 | + | close(actionDone) | |
| 724 | + | }() | |
| 725 | + | <-admissionEnded | |
| 726 | + | ||
| 727 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 728 | + | next.initOverlays() | |
| 729 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 730 | + | target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true, | |
| 731 | + | }) | |
| 732 | + | require.NoError(t, err) | |
| 733 | + | release() | |
| 734 | + | <-actionDone | |
| 735 | + | ||
| 736 | + | d.mu.Lock() | |
| 737 | + | registered := d.sessions[sess.id] | |
| 738 | + | d.mu.Unlock() | |
| 739 | + | require.Same(t, sess, registered, "the replaced initiator deleted its former source session") | |
| 740 | + | d.deferAttachmentTransitionCleanups(result) | |
| 741 | + | } | |
| 742 | + | ||
| 743 | + | func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) { | |
| 744 | + | p, release := newBlockingPTY(t) | |
| 745 | + | defer release() | |
| 746 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 747 | + | d.enterPicker(sess, ac) | |
| 748 | + | rc := d.attachCoordinator(sess, ac, true) | |
| 749 | + | token := sess.attachmentToken(ac, ac.transport()) | |
| 750 | + | token.lease = rc.attachmentLease(ac) | |
| 751 | + | ac.publishAttachmentCapability(token) | |
| 752 | + | ||
| 753 | + | d.handleAttachmentClientFrame(token, frameInput([]byte("x"))) | |
| 754 | + | ||
| 755 | + | d.mu.Lock() | |
| 756 | + | _, registered := d.sessions[sess.id] | |
| 757 | + | d.mu.Unlock() | |
| 758 | + | require.False(t, registered) | |
| 759 | + | } | |
| 760 | + | ||
| 761 | + | func TestAttachmentEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *testing.T) { | |
| 762 | + | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 763 | + | oldTransport := newDatagramTestTransport() | |
| 764 | + | old.replaceTransport(oldTransport) | |
| 765 | + | rc := d.attachCoordinator(sess, old, true) | |
| 766 | + | token := sess.attachmentToken(old, oldTransport) | |
| 767 | + | token.lease = rc.attachmentLease(old) | |
| 768 | + | ||
| 769 | + | admitted := make(chan struct{}) | |
| 770 | + | release := make(chan struct{}) | |
| 771 | + | d.afterAttachmentEffectAdmitted = func(attachmentConnectionToken) { | |
| 772 | + | close(admitted) | |
| 773 | + | <-release | |
| 774 | + | } | |
| 775 | + | paintDone := make(chan bool, 1) | |
| 776 | + | go func() { paintDone <- d.firstPaintForTransition(token) }() | |
| 777 | + | <-admitted | |
| 778 | + | ||
| 779 | + | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 780 | + | next.initOverlays() | |
| 781 | + | transitionDone := make(chan error, 1) | |
| 782 | + | go func() { | |
| 783 | + | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 784 | + | target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true, | |
| 785 | + | }) | |
| 786 | + | transitionDone <- err | |
| 787 | + | }() | |
| 788 | + | // An independent attachment publication does not wait on this connection's | |
| 789 | + | // admitted first paint. | |
| 790 | + | require.NoError(t, <-transitionDone) | |
| 791 | + | close(release) | |
| 792 | + | require.True(t, <-paintDone) | |
| 793 | + | } | |
| 794 | + | ||
| 795 | + | func TestAttachmentEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testing.T) { | |
| 796 | + | d, first, a, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 797 | + | aTransport := newDatagramTestTransport() | |
| 798 | + | a.replaceTransport(aTransport) | |
| 799 | + | d.attachCoordinator(first, a, true) | |
| 800 | + | first.attachmentToken(a, aTransport) | |
| 801 | + | ||
| 802 | + | bTransport := newDatagramTestTransport() | |
| 803 | + | b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size} | |
| 804 | + | b.initOverlays() | |
| 805 | + | second := &session{sessionCore: sessionCore{id: "second", name: "second", attachments: map[*attachedClient]struct{}{b: {}}}} | |
| 806 | + | b.setSession(second) | |
| 807 | + | d.mu.Lock() | |
| 808 | + | d.sessions[second.id] = second | |
| 809 | + | d.mu.Unlock() | |
| 810 | + | d.attachCoordinator(second, b, true) | |
| 811 | + | second.attachmentToken(b, bTransport) | |
| 812 | + | ||
| 813 | + | start := make(chan struct{}) | |
| 814 | + | results := make(chan error, 2) | |
| 815 | + | go func() { | |
| 816 | + | <-start | |
| 817 | + | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 818 | + | source: first, target: second, next: a, | |
| 819 | + | ||
| 820 | + | expectedTransport: a.transportSnapshot(), ready: true, | |
| 821 | + | }) | |
| 822 | + | results <- err | |
| 823 | + | }() | |
| 824 | + | go func() { | |
| 825 | + | <-start | |
| 826 | + | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 827 | + | source: second, target: first, next: b, | |
| 828 | + | ||
| 829 | + | expectedTransport: b.transportSnapshot(), ready: true, | |
| 830 | + | }) | |
| 831 | + | results <- err | |
| 832 | + | }() | |
| 833 | + | close(start) | |
| 834 | + | ||
| 835 | + | errs := make([]error, 0, 2) | |
| 836 | + | for range 2 { | |
| 837 | + | select { | |
| 838 | + | case err := <-results: | |
| 839 | + | errs = append(errs, err) | |
| 840 | + | case <-time.After(time.Second): | |
| 841 | + | t.Fatal("reversed transitions deadlocked") | |
| 842 | + | } | |
| 843 | + | } | |
| 844 | + | successes := 0 | |
| 845 | + | for _, err := range errs { | |
| 846 | + | if err == nil { | |
| 847 | + | successes++ | |
| 848 | + | } | |
| 849 | + | } | |
| 850 | + | require.Equal(t, 2, successes) | |
| 851 | + | } | |
| 852 | + | ||
| 853 | + | func TestAttachmentEffectGateFrozenConnectionRejectsLateEffect(t *testing.T) { | |
| 854 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 855 | + | transport := newDatagramTestTransport() | |
| 856 | + | ac.replaceTransport(transport) | |
| 857 | + | rc := d.attachCoordinator(sess, ac, true) | |
| 858 | + | token := sess.attachmentToken(ac, transport) | |
| 859 | + | token.lease = rc.attachmentLease(ac) | |
| 860 | + | ac.publishAttachmentCapability(token) | |
| 861 | + | ||
| 862 | + | frozen := freezeAttachmentEffectGates(ac) | |
| 863 | + | require.True(t, frozen.acquired) | |
| 864 | + | require.True(t, frozen.drained) | |
| 865 | + | _, ok := ac.beginAttachmentEffect(token) | |
| 866 | + | require.False(t, ok, "a frozen capability must admit no new effect") | |
| 867 | + | frozen.unfreeze() | |
| 868 | + | } | |
A internal/usecase/daemon/attachment_parking_test.go +268 -0
| @@ -0,0 +1,268 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type resumeWelcomeFailureTransport struct { | |
| 13 | + | closeTrackingTransport | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func (*resumeWelcomeFailureTransport) Send(ports.Frame) error { return errWelcomeSendFailed } | |
| 17 | + | ||
| 18 | + | var errWelcomeSendFailed = errors.New("welcome send failed") | |
| 19 | + | ||
| 20 | + | func TestAttachmentLossParksOnlyThatAttachment(t *testing.T) { | |
| 21 | + | pty, release := newBlockingPTY(t) | |
| 22 | + | defer release() | |
| 23 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 24 | + | ||
| 25 | + | firstTransport := &closeTrackingTransport{} | |
| 26 | + | sess, first, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), firstTransport) | |
| 27 | + | require.NoError(t, err) | |
| 28 | + | secondTransport := &closeTrackingTransport{} | |
| 29 | + | _, second, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), secondTransport) | |
| 30 | + | require.NoError(t, err) | |
| 31 | + | ||
| 32 | + | d.clientGone(sess, first, firstTransport, false) | |
| 33 | + | ||
| 34 | + | d.mu.Lock() | |
| 35 | + | parked := d.parked[first.resumeToken] | |
| 36 | + | d.mu.Unlock() | |
| 37 | + | require.NotNil(t, parked) | |
| 38 | + | sess.mu.Lock() | |
| 39 | + | _, firstAttached := sess.attachments[first] | |
| 40 | + | _, secondAttached := sess.attachments[second] | |
| 41 | + | sess.mu.Unlock() | |
| 42 | + | require.False(t, firstAttached) | |
| 43 | + | require.True(t, secondAttached) | |
| 44 | + | require.Contains(t, sess.snapshotAttachments(), second) | |
| 45 | + | require.False(t, secondTransport.Closed()) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func TestTwoAttachmentsParkIndependently(t *testing.T) { | |
| 49 | + | pty, release := newBlockingPTY(t) | |
| 50 | + | defer release() | |
| 51 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 52 | + | firstTransport := &closeTrackingTransport{} | |
| 53 | + | sess, first, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), firstTransport) | |
| 54 | + | require.NoError(t, err) | |
| 55 | + | secondTransport := &closeTrackingTransport{} | |
| 56 | + | _, second, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), secondTransport) | |
| 57 | + | require.NoError(t, err) | |
| 58 | + | firstToken, secondToken := first.resumeToken, second.resumeToken | |
| 59 | + | ||
| 60 | + | d.clientGone(sess, first, firstTransport, false) | |
| 61 | + | d.clientGone(sess, second, secondTransport, false) | |
| 62 | + | d.mu.Lock() | |
| 63 | + | firstParked, secondParked := d.parked[firstToken], d.parked[secondToken] | |
| 64 | + | d.mu.Unlock() | |
| 65 | + | require.NotNil(t, firstParked) | |
| 66 | + | require.NotNil(t, secondParked) | |
| 67 | + | require.NotSame(t, firstParked, secondParked) | |
| 68 | + | sess.mu.Lock() | |
| 69 | + | require.Empty(t, sess.attachments) | |
| 70 | + | sess.mu.Unlock() | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestParkExpiryRemovesOnlyOneAttachment(t *testing.T) { | |
| 74 | + | pty, release := newBlockingPTY(t) | |
| 75 | + | defer release() | |
| 76 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 77 | + | firstTransport := &closeTrackingTransport{} | |
| 78 | + | sess, first, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), firstTransport) | |
| 79 | + | require.NoError(t, err) | |
| 80 | + | secondTransport := &closeTrackingTransport{} | |
| 81 | + | _, second, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), secondTransport) | |
| 82 | + | require.NoError(t, err) | |
| 83 | + | d.clientGone(sess, first, firstTransport, false) | |
| 84 | + | d.clientGone(sess, second, secondTransport, false) | |
| 85 | + | firstToken, secondToken := first.resumeToken, second.resumeToken | |
| 86 | + | d.mu.Lock() | |
| 87 | + | firstParked := d.parked[firstToken] | |
| 88 | + | d.mu.Unlock() | |
| 89 | + | d.expireParked(firstToken, firstParked) | |
| 90 | + | d.mu.Lock() | |
| 91 | + | _, firstRetained := d.parked[firstToken] | |
| 92 | + | _, secondRetained := d.parked[secondToken] | |
| 93 | + | d.mu.Unlock() | |
| 94 | + | require.False(t, firstRetained) | |
| 95 | + | require.True(t, secondRetained) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestResumeRotatesCredentialAndRejectsStaleTransport(t *testing.T) { | |
| 99 | + | pty, release := newBlockingPTY(t) | |
| 100 | + | defer release() | |
| 101 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 102 | + | oldTransport := &closeTrackingTransport{} | |
| 103 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 104 | + | require.NoError(t, err) | |
| 105 | + | oldToken := ac.resumeToken | |
| 106 | + | oldGeneration := ac.connectionGeneration.Load() | |
| 107 | + | d.clientGone(sess, ac, oldTransport, false) | |
| 108 | + | ||
| 109 | + | newTransport := &closeTrackingTransport{} | |
| 110 | + | resumed, same, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", oldToken), newTransport, defaultSize) | |
| 111 | + | require.NoError(t, err) | |
| 112 | + | require.True(t, ok) | |
| 113 | + | require.Same(t, sess, resumed) | |
| 114 | + | require.Same(t, ac, same) | |
| 115 | + | require.NotEqual(t, oldToken, ac.resumeToken) | |
| 116 | + | require.Greater(t, ac.connectionGeneration.Load(), oldGeneration) | |
| 117 | + | require.True(t, d.commitResumeClaim(ac)) | |
| 118 | + | d.mu.Lock() | |
| 119 | + | _, oldRetained := d.parked[oldToken] | |
| 120 | + | d.mu.Unlock() | |
| 121 | + | require.False(t, oldRetained) | |
| 122 | + | ||
| 123 | + | before := ac.resumeToken | |
| 124 | + | d.clientGone(sess, ac, oldTransport, false) | |
| 125 | + | require.Equal(t, before, ac.resumeToken) | |
| 126 | + | d.mu.Lock() | |
| 127 | + | _, staleParked := d.parked[oldToken] | |
| 128 | + | d.mu.Unlock() | |
| 129 | + | require.False(t, staleParked) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestSuccessfulResumeRejectsOldCredentialAfterWelcome(t *testing.T) { | |
| 133 | + | pty, release := newBlockingPTY(t) | |
| 134 | + | defer release() | |
| 135 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 136 | + | oldTransport := &closeTrackingTransport{} | |
| 137 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 138 | + | require.NoError(t, err) | |
| 139 | + | oldToken := ac.resumeToken | |
| 140 | + | d.clientGone(sess, ac, oldTransport, false) | |
| 141 | + | ||
| 142 | + | resumedTransport := &closeTrackingTransport{} | |
| 143 | + | d.handleHello(resumedTransport, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(helloResumeCapable(ports.IntentResume, "work", oldToken))}) | |
| 144 | + | newToken := ac.resumeToken | |
| 145 | + | require.NotEqual(t, oldToken, newToken) | |
| 146 | + | d.mu.Lock() | |
| 147 | + | _, oldRetained := d.parked[oldToken] | |
| 148 | + | newParked := d.parked[newToken] | |
| 149 | + | d.mu.Unlock() | |
| 150 | + | require.False(t, oldRetained) | |
| 151 | + | require.NotNil(t, newParked) | |
| 152 | + | require.NotEmpty(t, resumedTransport.Sends()) | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func TestFailedResumeHandshakeKeepsParkedCredential(t *testing.T) { | |
| 156 | + | pty, release := newBlockingPTY(t) | |
| 157 | + | defer release() | |
| 158 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 159 | + | oldTransport := &closeTrackingTransport{} | |
| 160 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 161 | + | require.NoError(t, err) | |
| 162 | + | token := ac.resumeToken | |
| 163 | + | d.clientGone(sess, ac, oldTransport, false) | |
| 164 | + | ||
| 165 | + | failed := &resumeWelcomeFailureTransport{} | |
| 166 | + | d.handleHello(failed, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(helloResumeCapable(ports.IntentResume, "work", token))}) | |
| 167 | + | require.Nil(t, ac.transport()) | |
| 168 | + | d.mu.Lock() | |
| 169 | + | parked := d.parked[token] | |
| 170 | + | d.mu.Unlock() | |
| 171 | + | require.NotNil(t, parked) | |
| 172 | + | require.False(t, parked.claimed) | |
| 173 | + | require.Equal(t, token, ac.resumeToken) | |
| 174 | + | ||
| 175 | + | _, resumed, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, defaultSize) | |
| 176 | + | require.NoError(t, err) | |
| 177 | + | require.True(t, ok) | |
| 178 | + | require.Same(t, ac, resumed) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func TestConcurrentResumeHasExactlyOneWinner(t *testing.T) { | |
| 182 | + | pty, release := newBlockingPTY(t) | |
| 183 | + | defer release() | |
| 184 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 185 | + | oldTransport := &closeTrackingTransport{} | |
| 186 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 187 | + | require.NoError(t, err) | |
| 188 | + | token := ac.resumeToken | |
| 189 | + | d.clientGone(sess, ac, oldTransport, false) | |
| 190 | + | ||
| 191 | + | type result struct { | |
| 192 | + | ok bool | |
| 193 | + | err error | |
| 194 | + | } | |
| 195 | + | results := make(chan result, 2) | |
| 196 | + | for range 2 { | |
| 197 | + | go func() { | |
| 198 | + | _, _, ok, resumeErr := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, defaultSize) | |
| 199 | + | results <- result{ok: ok, err: resumeErr} | |
| 200 | + | }() | |
| 201 | + | } | |
| 202 | + | var winners int | |
| 203 | + | for range 2 { | |
| 204 | + | got := <-results | |
| 205 | + | if got.ok { | |
| 206 | + | winners++ | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | require.Equal(t, 1, winners) | |
| 210 | + | require.NotEqual(t, token, ac.resumeToken) | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | func TestKillSessionClosesEveryAttachmentAndParkedCredential(t *testing.T) { | |
| 214 | + | pty, release := newBlockingPTY(t) | |
| 215 | + | defer release() | |
| 216 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 217 | + | firstTransport := &closeTrackingTransport{} | |
| 218 | + | sess, _, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), firstTransport) | |
| 219 | + | require.NoError(t, err) | |
| 220 | + | secondTransport := &closeTrackingTransport{} | |
| 221 | + | _, _, err = d.route(helloResumeCapable(ports.IntentAttach, "work", 0), secondTransport) | |
| 222 | + | require.NoError(t, err) | |
| 223 | + | parkedTransport := &closeTrackingTransport{} | |
| 224 | + | _, parkedAttachment, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), parkedTransport) | |
| 225 | + | require.NoError(t, err) | |
| 226 | + | d.clientGone(sess, parkedAttachment, parkedTransport, false) | |
| 227 | + | parkedToken := parkedAttachment.resumeToken | |
| 228 | + | ||
| 229 | + | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |
| 230 | + | d.waitNotifies() | |
| 231 | + | require.True(t, firstTransport.Closed()) | |
| 232 | + | require.True(t, secondTransport.Closed()) | |
| 233 | + | require.True(t, parkedTransport.Closed()) | |
| 234 | + | d.mu.Lock() | |
| 235 | + | _, retained := d.parked[parkedToken] | |
| 236 | + | d.mu.Unlock() | |
| 237 | + | require.False(t, retained) | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func TestResumeCredentialIsInvalidAfterDaemonRestart(t *testing.T) { | |
| 241 | + | pty, release := newBlockingPTY(t) | |
| 242 | + | defer release() | |
| 243 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 244 | + | transport := &closeTrackingTransport{} | |
| 245 | + | _, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), transport) | |
| 246 | + | require.NoError(t, err) | |
| 247 | + | token := ac.resumeToken | |
| 248 | + | restarted := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 249 | + | _, _, err = restarted.route(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}) | |
| 250 | + | var protocolErr *protoErr | |
| 251 | + | require.ErrorAs(t, err, &protocolErr) | |
| 252 | + | require.Equal(t, ports.ErrNoSuchSession, protocolErr.code) | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | func TestHandleHelloAllowsMultipleAttachments(t *testing.T) { | |
| 256 | + | pty, release := newBlockingPTY(t) | |
| 257 | + | defer release() | |
| 258 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 259 | + | firstTransport := &closeTrackingTransport{} | |
| 260 | + | sess, first, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), firstTransport) | |
| 261 | + | require.NoError(t, err) | |
| 262 | + | secondTransport := &closeTrackingTransport{} | |
| 263 | + | _, second, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), secondTransport) | |
| 264 | + | require.NoError(t, err) | |
| 265 | + | require.Equal(t, true, sess.attachmentRegistered(first)) | |
| 266 | + | require.Equal(t, true, sess.attachmentRegistered(second)) | |
| 267 | + | require.NotSame(t, first, second) | |
| 268 | + | } | |
A internal/usecase/daemon/attachment_registry.go +587 -0
| @@ -0,0 +1,587 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "cmp" | |
| 6 | + | "slices" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/pkg/vt" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // attachmentView is the mutable viewport owned by one attachment. Tab and | |
| 13 | + | // pane identity is stable across tab insertion, deletion, and reordering; | |
| 14 | + | // neither field is a session-local index or layout PaneID. | |
| 15 | + | type attachmentView struct { | |
| 16 | + | tabID domain.TabStableID | |
| 17 | + | paneID domain.PaneStableID | |
| 18 | + | windowTop int | |
| 19 | + | windowRows int | |
| 20 | + | bookmark vt.RowID | |
| 21 | + | liveBottom bool | |
| 22 | + | revision uint64 | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // viewInvalidation records the order in which attachment views observed one | |
| 26 | + | // shared session mutation. The order is stable client-ID order, not map | |
| 27 | + | // iteration or lock-acquisition order, which keeps concurrent attaches | |
| 28 | + | // deterministic. | |
| 29 | + | type viewInvalidation struct { | |
| 30 | + | attachment *attachedClient | |
| 31 | + | revision uint64 | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (ac *attachedClient) viewSnapshot() attachmentView { | |
| 35 | + | if ac == nil { | |
| 36 | + | return attachmentView{} | |
| 37 | + | } | |
| 38 | + | ac.viewMu.Lock() | |
| 39 | + | defer ac.viewMu.Unlock() | |
| 40 | + | return ac.view | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (ac *attachedClient) publishView(view attachmentView) { | |
| 44 | + | if ac == nil { | |
| 45 | + | return | |
| 46 | + | } | |
| 47 | + | ac.viewMu.Lock() | |
| 48 | + | ac.view = view | |
| 49 | + | ac.viewMu.Unlock() | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // registerAttachmentLocked admits ac exactly once. Caller holds s.mu and must | |
| 53 | + | // have acquired dispatchMu first when the operation came from a client. | |
| 54 | + | func (s *session) registerAttachmentLocked(ac *attachedClient) bool { | |
| 55 | + | if s == nil || !s.sessionCore.registerAttachmentLocked(ac) { | |
| 56 | + | return false | |
| 57 | + | } | |
| 58 | + | view := ac.viewSnapshot() | |
| 59 | + | if view.tabID == "" || view.paneID == "" { | |
| 60 | + | view = s.repairAttachmentViewLocked(ac, view) | |
| 61 | + | ac.publishView(view) | |
| 62 | + | } | |
| 63 | + | return true | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func (c *sessionCore) registerAttachmentLocked(ac *attachedClient) bool { | |
| 67 | + | if c == nil || ac == nil { | |
| 68 | + | return false | |
| 69 | + | } | |
| 70 | + | if c.attachments == nil { | |
| 71 | + | c.attachments = make(map[*attachedClient]struct{}) | |
| 72 | + | } | |
| 73 | + | if _, exists := c.attachments[ac]; exists { | |
| 74 | + | return false | |
| 75 | + | } | |
| 76 | + | if c.attachmentOrder == nil { | |
| 77 | + | c.attachmentOrder = make(map[*attachedClient]uint64) | |
| 78 | + | } | |
| 79 | + | c.nextAttachmentID++ | |
| 80 | + | c.attachments[ac] = struct{}{} | |
| 81 | + | c.attachmentOrder[ac] = c.nextAttachmentID | |
| 82 | + | return true | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func registerAttachmentSessionLocked(entry attachmentSession, ac *attachedClient) bool { | |
| 86 | + | if entry == nil || entry.core() == nil { | |
| 87 | + | return false | |
| 88 | + | } | |
| 89 | + | // Transition publication may already hold tab locks (move commit). Only | |
| 90 | + | // publish collection membership here; target repair runs at the next view | |
| 91 | + | // snapshot outside that critical section. | |
| 92 | + | return entry.core().registerAttachmentLocked(ac) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func (s *session) registerAttachment(ac *attachedClient) bool { | |
| 96 | + | if s == nil || ac == nil { | |
| 97 | + | return false | |
| 98 | + | } | |
| 99 | + | s.dispatchMu.Lock() | |
| 100 | + | defer s.dispatchMu.Unlock() | |
| 101 | + | s.mu.Lock() | |
| 102 | + | defer s.mu.Unlock() | |
| 103 | + | return s.registerAttachmentLocked(ac) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // unregisterAttachmentLocked removes only ac and leaves every other | |
| 107 | + | // attachment's view intact. Caller holds s.mu. | |
| 108 | + | func (s *session) unregisterAttachmentLocked(ac *attachedClient) bool { | |
| 109 | + | if s == nil { | |
| 110 | + | return false | |
| 111 | + | } | |
| 112 | + | return s.sessionCore.unregisterAttachmentLocked(ac) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func (c *sessionCore) unregisterAttachmentLocked(ac *attachedClient) bool { | |
| 116 | + | if c == nil || ac == nil || c.attachments == nil { | |
| 117 | + | return false | |
| 118 | + | } | |
| 119 | + | if _, exists := c.attachments[ac]; !exists { | |
| 120 | + | return false | |
| 121 | + | } | |
| 122 | + | delete(c.attachments, ac) | |
| 123 | + | delete(c.attachmentOrder, ac) | |
| 124 | + | return true | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func unregisterAttachmentSessionLocked(entry attachmentSession, ac *attachedClient) bool { | |
| 128 | + | if entry == nil || entry.core() == nil { | |
| 129 | + | return false | |
| 130 | + | } | |
| 131 | + | return entry.core().unregisterAttachmentLocked(ac) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func (s *session) unregisterAttachment(ac *attachedClient) bool { | |
| 135 | + | if s == nil || ac == nil { | |
| 136 | + | return false | |
| 137 | + | } | |
| 138 | + | s.dispatchMu.Lock() | |
| 139 | + | defer s.dispatchMu.Unlock() | |
| 140 | + | s.mu.Lock() | |
| 141 | + | defer s.mu.Unlock() | |
| 142 | + | return s.unregisterAttachmentLocked(ac) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // snapshotAttachmentsLocked returns a stable registration-ordered snapshot. | |
| 146 | + | // Callers hold s.mu; returned attachments may be used after unlocking. | |
| 147 | + | func (c *sessionCore) snapshotAttachmentsLocked() []*attachedClient { | |
| 148 | + | if c == nil || len(c.attachments) == 0 { | |
| 149 | + | return nil | |
| 150 | + | } | |
| 151 | + | out := make([]*attachedClient, 0, len(c.attachments)) | |
| 152 | + | for ac := range c.attachments { | |
| 153 | + | out = append(out, ac) | |
| 154 | + | } | |
| 155 | + | slices.SortStableFunc(out, func(a, b *attachedClient) int { | |
| 156 | + | if id := bytes.Compare(a.clientID[:], b.clientID[:]); id != 0 { | |
| 157 | + | return id | |
| 158 | + | } | |
| 159 | + | return cmp.Compare(c.attachmentOrder[a], c.attachmentOrder[b]) | |
| 160 | + | }) | |
| 161 | + | return out | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func attachmentRegisteredLocked(entry attachmentSession, ac *attachedClient) bool { | |
| 165 | + | if entry == nil || ac == nil || entry.core() == nil { | |
| 166 | + | return false | |
| 167 | + | } | |
| 168 | + | _, ok := entry.core().attachments[ac] | |
| 169 | + | return ok | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func attachmentRegistered(entry attachmentSession, ac *attachedClient) bool { | |
| 173 | + | if entry == nil || ac == nil || entry.core() == nil { | |
| 174 | + | return false | |
| 175 | + | } | |
| 176 | + | core := entry.core() | |
| 177 | + | core.mu.Lock() | |
| 178 | + | defer core.mu.Unlock() | |
| 179 | + | return attachmentRegisteredLocked(entry, ac) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func (s *session) attachmentRegistered(ac *attachedClient) bool { | |
| 183 | + | return attachmentRegistered(s, ac) | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func (s *session) attachmentRegisteredLocked(ac *attachedClient) bool { | |
| 187 | + | return attachmentRegisteredLocked(s, ac) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func (s *session) attachmentViewsTabLocked(tb *tab) bool { | |
| 191 | + | if s == nil || tb == nil { | |
| 192 | + | return false | |
| 193 | + | } | |
| 194 | + | for ac := range s.attachments { | |
| 195 | + | if ac.viewSnapshot().tabID == domain.TabStableID(tb.stableID) { | |
| 196 | + | return true | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | return false | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func snapshotAttachmentSession(entry attachmentSession) []*attachedClient { | |
| 203 | + | if entry == nil || entry.core() == nil { | |
| 204 | + | return nil | |
| 205 | + | } | |
| 206 | + | core := entry.core() | |
| 207 | + | core.mu.Lock() | |
| 208 | + | defer core.mu.Unlock() | |
| 209 | + | return core.snapshotAttachmentsLocked() | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func (s *session) snapshotAttachments() []*attachedClient { | |
| 213 | + | if s == nil { | |
| 214 | + | return nil | |
| 215 | + | } | |
| 216 | + | s.mu.Lock() | |
| 217 | + | defer s.mu.Unlock() | |
| 218 | + | return s.snapshotAttachmentsLocked() | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // repairAttachmentViewLocked validates stable targets and chooses the nearest | |
| 222 | + | // surviving target when a tab or pane was removed. Caller holds s.mu. | |
| 223 | + | func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachmentView) attachmentView { | |
| 224 | + | _ = ac | |
| 225 | + | if s == nil { | |
| 226 | + | return attachmentView{} | |
| 227 | + | } | |
| 228 | + | var targetTab *tab | |
| 229 | + | for _, tb := range s.tabs { | |
| 230 | + | if tb != nil && domain.TabStableID(tb.stableID) == view.tabID { | |
| 231 | + | targetTab = tb | |
| 232 | + | break | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | tabRepaired := targetTab == nil | |
| 236 | + | if targetTab == nil && len(s.tabs) != 0 { | |
| 237 | + | targetTab = s.tabs[0] | |
| 238 | + | } | |
| 239 | + | if targetTab == nil { | |
| 240 | + | view.tabID = "" | |
| 241 | + | view.paneID = "" | |
| 242 | + | view.windowTop = 0 | |
| 243 | + | view.windowRows = 0 | |
| 244 | + | view.bookmark = 0 | |
| 245 | + | view.liveBottom = true | |
| 246 | + | return view | |
| 247 | + | } | |
| 248 | + | view.tabID = domain.TabStableID(targetTab.stableID) | |
| 249 | + | ||
| 250 | + | targetPane := (*pane)(nil) | |
| 251 | + | targetTab.mu.Lock() | |
| 252 | + | for _, candidate := range targetTab.panes { | |
| 253 | + | if candidate != nil && domain.PaneStableID(candidate.stableID) == view.paneID { | |
| 254 | + | targetPane = candidate | |
| 255 | + | break | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | paneRepaired := targetPane == nil | |
| 259 | + | if targetPane == nil { | |
| 260 | + | targetPane = targetTab.focusedPane() | |
| 261 | + | } | |
| 262 | + | if targetPane != nil { | |
| 263 | + | view.paneID = domain.PaneStableID(targetPane.stableID) | |
| 264 | + | } else { | |
| 265 | + | view.paneID = "" | |
| 266 | + | } | |
| 267 | + | if view.windowRows <= 0 { | |
| 268 | + | view.windowRows = targetTab.size.Rows | |
| 269 | + | } | |
| 270 | + | targetTab.mu.Unlock() | |
| 271 | + | if view.windowTop < 0 { | |
| 272 | + | view.windowTop = 0 | |
| 273 | + | } | |
| 274 | + | if tabRepaired || paneRepaired || targetPane == nil { | |
| 275 | + | view.bookmark = 0 | |
| 276 | + | view.liveBottom = true | |
| 277 | + | } | |
| 278 | + | return view | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | // repairAttachmentView repairs ac and increments its revision only when the | |
| 282 | + | // stable target changed. It is deliberately separate from registration so | |
| 283 | + | // layout mutation code can validate every surviving attachment in one ordered | |
| 284 | + | // pass. | |
| 285 | + | func (s *session) repairAttachmentView(ac *attachedClient) bool { | |
| 286 | + | if s == nil || ac == nil { | |
| 287 | + | return false | |
| 288 | + | } | |
| 289 | + | s.mu.Lock() | |
| 290 | + | defer s.mu.Unlock() | |
| 291 | + | if _, ok := s.attachments[ac]; !ok { | |
| 292 | + | return false | |
| 293 | + | } | |
| 294 | + | before := ac.viewSnapshot() | |
| 295 | + | after := s.repairAttachmentViewLocked(ac, before) | |
| 296 | + | if before.tabID == after.tabID && before.paneID == after.paneID && before.windowTop == after.windowTop && before.windowRows == after.windowRows && before.bookmark == after.bookmark && before.liveBottom == after.liveBottom { | |
| 297 | + | return false | |
| 298 | + | } | |
| 299 | + | after.revision++ | |
| 300 | + | ac.publishView(after) | |
| 301 | + | return true | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | // prepareAttachmentViewsForRemovedTabLocked selects the nearest surviving tab | |
| 305 | + | // for attachments that were viewing removed. Caller holds s.mu before tabs is | |
| 306 | + | // mutated; the normal invalidation pass publishes the revised views. | |
| 307 | + | func (s *session) prepareAttachmentViewsForRemovedTabLocked(removed *tab, index int) { | |
| 308 | + | if s == nil || removed == nil || len(s.tabs) <= 1 || index < 0 || index >= len(s.tabs) { | |
| 309 | + | return | |
| 310 | + | } | |
| 311 | + | replacement := index + 1 | |
| 312 | + | if replacement >= len(s.tabs) { | |
| 313 | + | replacement = index - 1 | |
| 314 | + | } | |
| 315 | + | if replacement < 0 || replacement >= len(s.tabs) || s.tabs[replacement] == nil { | |
| 316 | + | return | |
| 317 | + | } | |
| 318 | + | tabID := domain.TabStableID(s.tabs[replacement].stableID) | |
| 319 | + | removedID := domain.TabStableID(removed.stableID) | |
| 320 | + | for ac := range s.attachments { | |
| 321 | + | view := ac.viewSnapshot() | |
| 322 | + | if view.tabID == removedID { | |
| 323 | + | view.tabID = tabID | |
| 324 | + | view.paneID = "" | |
| 325 | + | view.bookmark = 0 | |
| 326 | + | view.liveBottom = true | |
| 327 | + | ac.publishView(view) | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | // invalidateViewsLocked orders all per-attachment invalidations behind the | |
| 333 | + | // shared session mutation. Caller holds s.mu and dispatchMu. | |
| 334 | + | func (s *session) invalidateViewsLocked() []viewInvalidation { | |
| 335 | + | attachments := s.snapshotAttachmentsLocked() | |
| 336 | + | invalidations := make([]viewInvalidation, 0, len(attachments)) | |
| 337 | + | for _, ac := range attachments { | |
| 338 | + | view := ac.viewSnapshot() | |
| 339 | + | repaired := s.repairAttachmentViewLocked(ac, view) | |
| 340 | + | repaired.revision++ | |
| 341 | + | ac.publishView(repaired) | |
| 342 | + | invalidations = append(invalidations, viewInvalidation{attachment: ac, revision: repaired.revision}) | |
| 343 | + | } | |
| 344 | + | return invalidations | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | // runMutation is the one admission boundary for shared session mutations. | |
| 348 | + | // Callers perform their state mutation and view invalidation inside fn; the | |
| 349 | + | // dispatch lock is acquired before any session, tab, or daemon lock. | |
| 350 | + | func (s *session) runMutation(fn func() error) error { | |
| 351 | + | if s == nil || fn == nil { | |
| 352 | + | return nil | |
| 353 | + | } | |
| 354 | + | s.dispatchMu.Lock() | |
| 355 | + | defer s.dispatchMu.Unlock() | |
| 356 | + | return fn() | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | // tabForAttachment resolves the attachment's stable target after validating | |
| 360 | + | // it against current membership. It never changes another attachment's view. | |
| 361 | + | func (s *session) tabForAttachment(ac *attachedClient) *tab { | |
| 362 | + | if s == nil || ac == nil { | |
| 363 | + | return nil | |
| 364 | + | } | |
| 365 | + | s.mu.Lock() | |
| 366 | + | defer s.mu.Unlock() | |
| 367 | + | if _, ok := s.attachments[ac]; !ok { | |
| 368 | + | return nil | |
| 369 | + | } | |
| 370 | + | view := ac.viewSnapshot() | |
| 371 | + | repaired := s.repairAttachmentViewLocked(ac, view) | |
| 372 | + | if repaired.tabID != view.tabID || repaired.paneID != view.paneID || repaired.windowTop != view.windowTop || repaired.windowRows != view.windowRows || repaired.bookmark != view.bookmark || repaired.liveBottom != view.liveBottom { | |
| 373 | + | repaired.revision++ | |
| 374 | + | ac.publishView(repaired) | |
| 375 | + | } | |
| 376 | + | for _, tb := range s.tabs { | |
| 377 | + | if tb != nil && domain.TabStableID(tb.stableID) == repaired.tabID { | |
| 378 | + | return tb | |
| 379 | + | } | |
| 380 | + | } | |
| 381 | + | return nil | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | // paneForAttachment resolves both stable IDs and repairs a removed pane before | |
| 385 | + | // returning it. The tab remains shared; only the attachment's view moves. | |
| 386 | + | func (s *session) tabIndexForAttachment(ac *attachedClient) (int, int) { | |
| 387 | + | if s == nil { | |
| 388 | + | return -1, 0 | |
| 389 | + | } | |
| 390 | + | s.mu.Lock() | |
| 391 | + | tabs := append([]*tab(nil), s.tabs...) | |
| 392 | + | s.mu.Unlock() | |
| 393 | + | if len(tabs) == 0 { | |
| 394 | + | return -1, 0 | |
| 395 | + | } | |
| 396 | + | if ac == nil { | |
| 397 | + | return 0, len(tabs) | |
| 398 | + | } | |
| 399 | + | view := ac.viewSnapshot() | |
| 400 | + | for i, tb := range tabs { | |
| 401 | + | if tb != nil && domain.TabStableID(tb.stableID) == view.tabID { | |
| 402 | + | return i, len(tabs) | |
| 403 | + | } | |
| 404 | + | } | |
| 405 | + | return 0, len(tabs) | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | // firstTab returns the first tab in the session's ordered tab collection. | |
| 409 | + | // It is a deterministic session-level default for headless work, never an | |
| 410 | + | // interactive attachment selection. | |
| 411 | + | func (s *session) firstTab() *tab { | |
| 412 | + | if s == nil { | |
| 413 | + | return nil | |
| 414 | + | } | |
| 415 | + | s.mu.Lock() | |
| 416 | + | defer s.mu.Unlock() | |
| 417 | + | if len(s.tabs) == 0 { | |
| 418 | + | return nil | |
| 419 | + | } | |
| 420 | + | return s.tabs[0] | |
| 421 | + | } | |
| 422 | + | ||
| 423 | + | func (s *session) paneForAttachment(ac *attachedClient) (*tab, *pane) { | |
| 424 | + | tb := s.tabForAttachment(ac) | |
| 425 | + | if tb == nil { | |
| 426 | + | return nil, nil | |
| 427 | + | } | |
| 428 | + | view := ac.viewSnapshot() | |
| 429 | + | tb.mu.Lock() | |
| 430 | + | defer tb.mu.Unlock() | |
| 431 | + | for _, p := range tb.panes { | |
| 432 | + | if p != nil && domain.PaneStableID(p.stableID) == view.paneID { | |
| 433 | + | return tb, p | |
| 434 | + | } | |
| 435 | + | } | |
| 436 | + | return tb, tb.focusedPane() | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | // setAttachmentPaneLocked publishes the initiating attachment's pane target | |
| 440 | + | // after a shared focus mutation. Caller holds s.mu; the structural tree focus | |
| 441 | + | // is not used to resolve any other attachment's target. | |
| 442 | + | func (s *session) setAttachmentPaneLocked(ac *attachedClient, tb *tab, p *pane) bool { | |
| 443 | + | if s == nil || ac == nil || tb == nil || p == nil || !attachmentRegisteredLocked(s, ac) { | |
| 444 | + | return false | |
| 445 | + | } | |
| 446 | + | view := ac.viewSnapshot() | |
| 447 | + | before := view | |
| 448 | + | view.tabID = domain.TabStableID(tb.stableID) | |
| 449 | + | view.paneID = domain.PaneStableID(p.stableID) | |
| 450 | + | view = s.repairAttachmentViewLocked(ac, view) | |
| 451 | + | if view.tabID == before.tabID && view.paneID == before.paneID { | |
| 452 | + | return false | |
| 453 | + | } | |
| 454 | + | view.revision++ | |
| 455 | + | ac.publishView(view) | |
| 456 | + | return true | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | // updateAttachmentView applies one attachment-local view mutation at the | |
| 460 | + | // shared mutation boundary. The revision increments exactly once and target | |
| 461 | + | // IDs are repaired before publication. | |
| 462 | + | func (s *session) updateAttachmentView(ac *attachedClient, update func(*attachmentView)) bool { | |
| 463 | + | if s == nil || ac == nil || update == nil { | |
| 464 | + | return false | |
| 465 | + | } | |
| 466 | + | changed := false | |
| 467 | + | _ = s.runMutation(func() error { | |
| 468 | + | s.mu.Lock() | |
| 469 | + | defer s.mu.Unlock() | |
| 470 | + | if _, ok := s.attachments[ac]; !ok { | |
| 471 | + | return nil | |
| 472 | + | } | |
| 473 | + | before := ac.viewSnapshot() | |
| 474 | + | next := before | |
| 475 | + | update(&next) | |
| 476 | + | next = s.repairAttachmentViewLocked(ac, next) | |
| 477 | + | next.revision++ | |
| 478 | + | ac.publishView(next) | |
| 479 | + | changed = next.tabID != before.tabID || next.paneID != before.paneID || next.windowTop != before.windowTop || next.windowRows != before.windowRows || next.bookmark != before.bookmark || next.liveBottom != before.liveBottom | |
| 480 | + | return nil | |
| 481 | + | }) | |
| 482 | + | return changed | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | // selectAttachmentTab changes only one attachment's target. Shared tab order | |
| 486 | + | // and PTY ownership are untouched. | |
| 487 | + | func (s *session) activateAttachmentViewLocked(ac *attachedClient, idx int) bool { | |
| 488 | + | if s == nil || ac == nil || idx < 0 || idx >= len(s.tabs) || !attachmentRegisteredLocked(s, ac) { | |
| 489 | + | return false | |
| 490 | + | } | |
| 491 | + | view := ac.viewSnapshot() | |
| 492 | + | view.tabID = domain.TabStableID(s.tabs[idx].stableID) | |
| 493 | + | view.paneID = "" | |
| 494 | + | view = s.repairAttachmentViewLocked(ac, view) | |
| 495 | + | view.revision++ | |
| 496 | + | ac.publishView(view) | |
| 497 | + | return true | |
| 498 | + | } | |
| 499 | + | ||
| 500 | + | func (s *session) selectAttachmentTabLocked(ac *attachedClient, tabID domain.TabStableID) bool { | |
| 501 | + | if s == nil || ac == nil || !attachmentRegisteredLocked(s, ac) { | |
| 502 | + | return false | |
| 503 | + | } | |
| 504 | + | view := ac.viewSnapshot() | |
| 505 | + | view.tabID = tabID | |
| 506 | + | view.paneID = "" | |
| 507 | + | view = s.repairAttachmentViewLocked(ac, view) | |
| 508 | + | view.revision++ | |
| 509 | + | ac.publishView(view) | |
| 510 | + | s.invalidateViewsLocked() | |
| 511 | + | return view.tabID == tabID | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | func (s *session) selectAttachmentTab(ac *attachedClient, tabID domain.TabStableID) bool { | |
| 515 | + | if s == nil || ac == nil { | |
| 516 | + | return false | |
| 517 | + | } | |
| 518 | + | var changed bool | |
| 519 | + | _ = s.runMutation(func() error { | |
| 520 | + | s.mu.Lock() | |
| 521 | + | defer s.mu.Unlock() | |
| 522 | + | changed = s.selectAttachmentTabLocked(ac, tabID) | |
| 523 | + | return nil | |
| 524 | + | }) | |
| 525 | + | return changed | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | func (s *session) switchAttachmentTabForDispatch(ac *attachedClient, idx int) bool { | |
| 529 | + | if s == nil || ac == nil { | |
| 530 | + | return false | |
| 531 | + | } | |
| 532 | + | s.mu.Lock() | |
| 533 | + | defer s.mu.Unlock() | |
| 534 | + | if idx < 0 || idx >= len(s.tabs) { | |
| 535 | + | return false | |
| 536 | + | } | |
| 537 | + | return s.selectAttachmentTabLocked(ac, domain.TabStableID(s.tabs[idx].stableID)) | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | func (s *session) switchAttachmentTab(ac *attachedClient, idx int) bool { | |
| 541 | + | if s == nil || ac == nil { | |
| 542 | + | return false | |
| 543 | + | } | |
| 544 | + | s.mu.Lock() | |
| 545 | + | registered := attachmentRegisteredLocked(s, ac) | |
| 546 | + | if registered && idx >= 0 && idx < len(s.tabs) { | |
| 547 | + | tabID := domain.TabStableID(s.tabs[idx].stableID) | |
| 548 | + | s.mu.Unlock() | |
| 549 | + | return s.selectAttachmentTab(ac, tabID) | |
| 550 | + | } | |
| 551 | + | s.mu.Unlock() | |
| 552 | + | return false | |
| 553 | + | } | |
| 554 | + | ||
| 555 | + | func (s *session) switchAttachmentRelativeForDispatch(ac *attachedClient, delta int) bool { | |
| 556 | + | position, count := s.tabIndexForAttachment(ac) | |
| 557 | + | if count < 2 { | |
| 558 | + | return false | |
| 559 | + | } | |
| 560 | + | return s.switchAttachmentTabForDispatch(ac, (position+delta+count)%count) | |
| 561 | + | } | |
| 562 | + | ||
| 563 | + | // repairAttachmentViews repairs all live attachment targets after a shared | |
| 564 | + | // topology mutation. | |
| 565 | + | func (s *session) repairAttachmentViews() []viewInvalidation { | |
| 566 | + | if s == nil { | |
| 567 | + | return nil | |
| 568 | + | } | |
| 569 | + | var invalidations []viewInvalidation | |
| 570 | + | _ = s.runMutation(func() error { | |
| 571 | + | s.mu.Lock() | |
| 572 | + | invalidations = s.invalidateViewsLocked() | |
| 573 | + | s.mu.Unlock() | |
| 574 | + | return nil | |
| 575 | + | }) | |
| 576 | + | return invalidations | |
| 577 | + | } | |
| 578 | + | ||
| 579 | + | // repairAttachmentViewsLocked repairs all live attachment targets after a tab | |
| 580 | + | // or pane mutation. Caller holds dispatchMu and s.mu; it is intended to run at | |
| 581 | + | // the same mutation linearization point as the shared membership change. | |
| 582 | + | func (s *session) repairAttachmentViewsLocked() []viewInvalidation { | |
| 583 | + | if s == nil { | |
| 584 | + | return nil | |
| 585 | + | } | |
| 586 | + | return s.invalidateViewsLocked() | |
| 587 | + | } | |
A internal/usecase/daemon/attachment_registry_test.go +132 -0
| @@ -0,0 +1,132 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func registryTestSession() *session { | |
| 11 | + | sz := domain.Size{Cols: 80, Rows: 23} | |
| 12 | + | first := newTabWithStableID("tab-a", "pane-a", nil, sz) | |
| 13 | + | second := newTabWithStableID("tab-b", "pane-b", nil, sz) | |
| 14 | + | return &session{ | |
| 15 | + | sessionCore: sessionCore{id: "registry-test"}, | |
| 16 | + | tabs: []*tab{first, second}, | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func registryTestAttachment(id byte) *attachedClient { | |
| 21 | + | ac := &attachedClient{} | |
| 22 | + | ac.clientID[0] = id | |
| 23 | + | return ac | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func TestAttachmentRegistryPreservesOtherAttachmentsOnDetach(t *testing.T) { | |
| 27 | + | sess := registryTestSession() | |
| 28 | + | first, second, third := registryTestAttachment(1), registryTestAttachment(2), registryTestAttachment(3) | |
| 29 | + | ||
| 30 | + | for _, ac := range []*attachedClient{first, second, third} { | |
| 31 | + | if !sess.registerAttachment(ac) { | |
| 32 | + | t.Fatalf("register %v failed", ac.clientID[0]) | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | if got := sess.snapshotAttachments(); len(got) != 3 { | |
| 36 | + | t.Fatalf("attachment count = %d, want 3", len(got)) | |
| 37 | + | } | |
| 38 | + | if !sess.unregisterAttachment(second) { | |
| 39 | + | t.Fatal("unregister second failed") | |
| 40 | + | } | |
| 41 | + | got := sess.snapshotAttachments() | |
| 42 | + | if len(got) != 2 || got[0] != first || got[1] != third { | |
| 43 | + | t.Fatalf("remaining attachments = %v, want first and third", got) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestAttachmentRegistryRepairsRemovedStableTarget(t *testing.T) { | |
| 48 | + | sess := registryTestSession() | |
| 49 | + | ac := registryTestAttachment(1) | |
| 50 | + | if !sess.registerAttachment(ac) || !sess.selectAttachmentTab(ac, "tab-b") { | |
| 51 | + | t.Fatal("failed to select second tab") | |
| 52 | + | } | |
| 53 | + | before := ac.viewSnapshot() | |
| 54 | + | if before.tabID != "tab-b" || before.paneID != "pane-b" { | |
| 55 | + | t.Fatalf("selected view = %#v", before) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | if err := sess.runMutation(func() error { | |
| 59 | + | sess.mu.Lock() | |
| 60 | + | defer sess.mu.Unlock() | |
| 61 | + | sess.tabs = sess.tabs[:1] | |
| 62 | + | sess.repairAttachmentViewsLocked() | |
| 63 | + | return nil | |
| 64 | + | }); err != nil { | |
| 65 | + | t.Fatal(err) | |
| 66 | + | } | |
| 67 | + | after := ac.viewSnapshot() | |
| 68 | + | if after.tabID != "tab-a" || after.paneID != "pane-a" { | |
| 69 | + | t.Fatalf("repaired view = %#v, want tab-a/pane-a", after) | |
| 70 | + | } | |
| 71 | + | if after.revision <= before.revision || !after.liveBottom { | |
| 72 | + | t.Fatalf("repaired view revision/liveBottom = %d/%v, want revision increase and live bottom", after.revision, after.liveBottom) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestAttachmentRegistryViewMutationIsSerialized(t *testing.T) { | |
| 77 | + | sess := registryTestSession() | |
| 78 | + | ac := registryTestAttachment(1) | |
| 79 | + | if !sess.registerAttachment(ac) { | |
| 80 | + | t.Fatal("register failed") | |
| 81 | + | } | |
| 82 | + | before := ac.viewSnapshot() | |
| 83 | + | if !sess.updateAttachmentView(ac, func(view *attachmentView) { | |
| 84 | + | view.windowTop = 4 | |
| 85 | + | view.windowRows = 12 | |
| 86 | + | view.bookmark = 9 | |
| 87 | + | view.liveBottom = false | |
| 88 | + | }) { | |
| 89 | + | t.Fatal("view update was not published") | |
| 90 | + | } | |
| 91 | + | after := ac.viewSnapshot() | |
| 92 | + | if after.windowTop != 4 || after.windowRows != 12 || after.bookmark != 9 || after.liveBottom || after.revision != before.revision+1 { | |
| 93 | + | t.Fatalf("view after mutation = %#v, want updated fields and one revision", after) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestAttachmentRegistryConcurrentRegistrationSnapshotIsStable(t *testing.T) { | |
| 98 | + | sess := registryTestSession() | |
| 99 | + | attachments := []*attachedClient{ | |
| 100 | + | registryTestAttachment(3), | |
| 101 | + | registryTestAttachment(1), | |
| 102 | + | registryTestAttachment(2), | |
| 103 | + | } | |
| 104 | + | start := make(chan struct{}) | |
| 105 | + | var wg sync.WaitGroup | |
| 106 | + | for _, ac := range attachments { | |
| 107 | + | wg.Add(1) | |
| 108 | + | go func(ac *attachedClient) { | |
| 109 | + | defer wg.Done() | |
| 110 | + | <-start | |
| 111 | + | sess.registerAttachment(ac) | |
| 112 | + | }(ac) | |
| 113 | + | } | |
| 114 | + | close(start) | |
| 115 | + | wg.Wait() | |
| 116 | + | ||
| 117 | + | got := sess.snapshotAttachments() | |
| 118 | + | if len(got) != len(attachments) { | |
| 119 | + | t.Fatalf("attachment count = %d, want %d", len(got), len(attachments)) | |
| 120 | + | } | |
| 121 | + | for i, want := range []byte{1, 2, 3} { | |
| 122 | + | if got[i].clientID[0] != want { | |
| 123 | + | t.Fatalf("snapshot[%d] id = %d, want %d", i, got[i].clientID[0], want) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | invalidations := sess.repairAttachmentViews() | |
| 127 | + | for i, want := range []byte{1, 2, 3} { | |
| 128 | + | if invalidations[i].attachment.clientID[0] != want { | |
| 129 | + | t.Fatalf("invalidation[%d] id = %d, want %d", i, invalidations[i].attachment.clientID[0], want) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | } | |
D internal/usecase/daemon/attachment_role.go +0 -224
| @@ -1,224 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import "github.com/bnema/vev/internal/ports" | |
| 4 | - | ||
| 5 | - | type attachmentRole uint8 | |
| 6 | - | ||
| 7 | - | // attachmentSessionCore normalizes nil interfaces, typed-nil implementations, | |
| 8 | - | // and implementations that deliberately expose no attachment core. | |
| 9 | - | func attachmentSessionCore(entry attachmentSession) *sessionCore { | |
| 10 | - | if entry == nil { | |
| 11 | - | return nil | |
| 12 | - | } | |
| 13 | - | return entry.core() | |
| 14 | - | } | |
| 15 | - | ||
| 16 | - | const ( | |
| 17 | - | attachmentDetached attachmentRole = iota | |
| 18 | - | attachmentActive | |
| 19 | - | attachmentSnatched | |
| 20 | - | ) | |
| 21 | - | ||
| 22 | - | // attachmentRole derives the attachment's role from the session-owned | |
| 23 | - | // registries. There is deliberately no second role field that could drift. | |
| 24 | - | func attachmentSessionRole(entry attachmentSession, ac *attachedClient) attachmentRole { | |
| 25 | - | if entry == nil || ac == nil || entry.core() == nil { | |
| 26 | - | return attachmentDetached | |
| 27 | - | } | |
| 28 | - | core := entry.core() | |
| 29 | - | core.mu.Lock() | |
| 30 | - | defer core.mu.Unlock() | |
| 31 | - | return attachmentSessionRoleLocked(entry, ac) | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | // attachmentSessionRoleLocked requires entry.core().mu. Active membership wins | |
| 35 | - | // if an invalid intermediate state contains the same attachment in both | |
| 36 | - | // registries. | |
| 37 | - | func attachmentSessionRoleLocked(entry attachmentSession, ac *attachedClient) attachmentRole { | |
| 38 | - | if entry == nil || ac == nil || entry.core() == nil { | |
| 39 | - | return attachmentDetached | |
| 40 | - | } | |
| 41 | - | core := entry.core() | |
| 42 | - | if core.client == ac { | |
| 43 | - | return attachmentActive | |
| 44 | - | } | |
| 45 | - | if _, ok := core.snatched[ac]; ok { | |
| 46 | - | return attachmentSnatched | |
| 47 | - | } | |
| 48 | - | return attachmentDetached | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | func addSnatchedLocked(entry attachmentSession, ac *attachedClient) { | |
| 52 | - | if entry == nil || ac == nil || entry.core() == nil { | |
| 53 | - | return | |
| 54 | - | } | |
| 55 | - | core := entry.core() | |
| 56 | - | if core.snatched == nil { | |
| 57 | - | core.snatched = make(map[*attachedClient]struct{}) | |
| 58 | - | } | |
| 59 | - | core.snatched[ac] = struct{}{} | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | func (s *session) attachmentRole(ac *attachedClient) attachmentRole { | |
| 63 | - | return attachmentSessionRole(s, ac) | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | func (s *session) attachmentRoleLocked(ac *attachedClient) attachmentRole { | |
| 67 | - | return attachmentSessionRoleLocked(s, ac) | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | func (s *session) addSnatchedLocked(ac *attachedClient) { addSnatchedLocked(s, ac) } | |
| 71 | - | ||
| 72 | - | type attachmentRoleToken struct { | |
| 73 | - | sess attachmentSession | |
| 74 | - | ac *attachedClient | |
| 75 | - | role attachmentRole | |
| 76 | - | generation uint64 | |
| 77 | - | transport transportSnapshot | |
| 78 | - | lease *attachmentLease | |
| 79 | - | effect *roleEffectTicket | |
| 80 | - | // rebase marks a cross-session ownership move whose first paint must start a | |
| 81 | - | // dependency-free output chain. The rebase is deliberately deferred until | |
| 82 | - | // after transition publication releases all architecture locks. | |
| 83 | - | rebase bool | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | // attachmentTokenLocked captures the exact role, transport incarnation, and | |
| 87 | - | // active coordinator lease at an architecture commit point. The caller must | |
| 88 | - | // hold s.mu; coordinator acquisition follows the canonical session -> | |
| 89 | - | // coordinator order. | |
| 90 | - | func attachmentTokenLocked(entry attachmentSession, ac *attachedClient) attachmentRoleToken { | |
| 91 | - | if entry == nil || entry.core() == nil || ac == nil { | |
| 92 | - | return attachmentRoleToken{} | |
| 93 | - | } | |
| 94 | - | transport := ac.transportSnapshot() | |
| 95 | - | if transport.transport == nil { | |
| 96 | - | return attachmentRoleToken{} | |
| 97 | - | } | |
| 98 | - | token := attachmentRoleToken{ | |
| 99 | - | sess: entry, | |
| 100 | - | ac: ac, | |
| 101 | - | role: attachmentSessionRoleLocked(entry, ac), | |
| 102 | - | generation: ac.roleGeneration.Load(), | |
| 103 | - | transport: transport, | |
| 104 | - | } | |
| 105 | - | if token.role == attachmentActive { | |
| 106 | - | if rc := entry.core().coordinator.Load(); rc != nil { | |
| 107 | - | token.lease = rc.attachmentLease(ac) | |
| 108 | - | } | |
| 109 | - | } | |
| 110 | - | return token | |
| 111 | - | } | |
| 112 | - | ||
| 113 | - | func (s *session) attachmentTokenLocked(ac *attachedClient) attachmentRoleToken { | |
| 114 | - | return attachmentTokenLocked(s, ac) | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | func attachmentToken(entry attachmentSession, ac *attachedClient, tr ports.Transport) attachmentRoleToken { | |
| 118 | - | if entry == nil || entry.core() == nil || ac == nil || tr == nil { | |
| 119 | - | return attachmentRoleToken{} | |
| 120 | - | } | |
| 121 | - | core := entry.core() | |
| 122 | - | core.mu.Lock() | |
| 123 | - | transport := ac.transportSnapshot() | |
| 124 | - | if transport.transport != tr { | |
| 125 | - | core.mu.Unlock() | |
| 126 | - | return attachmentRoleToken{} | |
| 127 | - | } | |
| 128 | - | token := attachmentRoleToken{ | |
| 129 | - | sess: entry, | |
| 130 | - | ac: ac, | |
| 131 | - | role: attachmentSessionRoleLocked(entry, ac), | |
| 132 | - | generation: ac.roleGeneration.Load(), | |
| 133 | - | transport: transport, | |
| 134 | - | } | |
| 135 | - | core.mu.Unlock() | |
| 136 | - | if token.role == attachmentActive { | |
| 137 | - | if rc := core.coordinator.Load(); rc != nil { | |
| 138 | - | token.lease = rc.attachmentLease(ac) | |
| 139 | - | } | |
| 140 | - | } | |
| 141 | - | ac.bootstrapRoleCapability(token) | |
| 142 | - | return token | |
| 143 | - | } | |
| 144 | - | ||
| 145 | - | func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentRoleToken { | |
| 146 | - | return attachmentToken(s, ac, tr) | |
| 147 | - | } | |
| 148 | - | ||
| 149 | - | func (t attachmentRoleToken) current() bool { | |
| 150 | - | return t.sess != nil && t.ac != nil && | |
| 151 | - | t.ac.roleGeneration.Load() == t.generation && | |
| 152 | - | t.ac.transportSnapshotCurrent(t.transport) && | |
| 153 | - | attachmentSessionRole(t.sess, t.ac) == t.role | |
| 154 | - | } | |
| 155 | - | ||
| 156 | - | // activeCurrent admits post-transition effects only for the exact published | |
| 157 | - | // role, transport incarnation, current-session link, and coordinator lease. | |
| 158 | - | func (t attachmentRoleToken) activeCurrent() bool { | |
| 159 | - | if t.role != attachmentActive || t.lease == nil || !t.current() || t.ac.currentAttachmentSession() != t.sess { | |
| 160 | - | return false | |
| 161 | - | } | |
| 162 | - | rc := t.sess.core().coordinator.Load() | |
| 163 | - | return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true) | |
| 164 | - | } | |
| 165 | - | ||
| 166 | - | // activeEffect is the canonical admission check for a client-frame mutation. | |
| 167 | - | // Tokens bind role generation, transport incarnation, session ownership, and | |
| 168 | - | // coordinator lease, so handlers never rediscover authority from mutable state. | |
| 169 | - | func (t attachmentRoleToken) activeEffect() bool { | |
| 170 | - | if t.effect != nil && !t.effect.ended.Load() { | |
| 171 | - | return true | |
| 172 | - | } | |
| 173 | - | return t.activeCurrent() | |
| 174 | - | } | |
| 175 | - | ||
| 176 | - | func beginActiveLeaseEffect(sess attachmentSession, ac *attachedClient, lease *attachmentLease) (*roleEffectTicket, bool) { | |
| 177 | - | if sess == nil || ac == nil || lease == nil { | |
| 178 | - | return nil, false | |
| 179 | - | } | |
| 180 | - | token := attachmentToken(sess, ac, ac.transport()) | |
| 181 | - | token.lease = lease | |
| 182 | - | return ac.beginRoleEffect(token) | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | func (t *attachmentRoleToken) endRoleEffect() { | |
| 186 | - | if t == nil || t.effect == nil { | |
| 187 | - | return | |
| 188 | - | } | |
| 189 | - | t.effect.End() | |
| 190 | - | t.effect = nil | |
| 191 | - | } | |
| 192 | - | ||
| 193 | - | // activeEffectSessionLocked is the same admission check while t.sess.mu is | |
| 194 | - | // already held at a session-owned mutation boundary. | |
| 195 | - | func (t attachmentRoleToken) activeEffectSessionLocked() bool { | |
| 196 | - | if t.effect != nil && !t.effect.ended.Load() { | |
| 197 | - | return true | |
| 198 | - | } | |
| 199 | - | if t.role != attachmentActive || t.lease == nil || t.sess == nil || t.ac == nil || | |
| 200 | - | t.ac.roleGeneration.Load() != t.generation || !t.ac.transportSnapshotCurrent(t.transport) || | |
| 201 | - | t.ac.currentAttachmentSession() != t.sess || attachmentSessionRoleLocked(t.sess, t.ac) != attachmentActive { | |
| 202 | - | return false | |
| 203 | - | } | |
| 204 | - | rc := t.sess.core().coordinator.Load() | |
| 205 | - | return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true) | |
| 206 | - | } | |
| 207 | - | ||
| 208 | - | func (t attachmentRoleToken) sendActiveControl(frame ports.Frame) error { | |
| 209 | - | if t.ac == nil { | |
| 210 | - | return errAttachmentTransition | |
| 211 | - | } | |
| 212 | - | t.ac.sendMu.Lock() | |
| 213 | - | defer t.ac.sendMu.Unlock() | |
| 214 | - | if t.effect == nil || t.effect.ended.Load() || !t.ac.transportSnapshotCurrent(t.transport) || | |
| 215 | - | !t.effect.beginTransportSend(t.transport) { | |
| 216 | - | return errAttachmentTransition | |
| 217 | - | } | |
| 218 | - | err := t.transport.transport.Send(frame) | |
| 219 | - | if err != nil { | |
| 220 | - | t.effect.reportTransportFailure(t.transport) | |
| 221 | - | } | |
| 222 | - | t.effect.endTransportSend() | |
| 223 | - | return err | |
| 224 | - | } | |
M internal/usecase/daemon/attachment_transition.go +58 -135
| @@ -23,76 +23,65 @@ type attachmentTransitionRequest struct { | |||
| 23 | 23 | source attachmentSession | |
| 24 | 24 | target attachmentSession | |
| 25 | 25 | next *attachedClient | |
| 26 | - | expectedRole attachmentRole | |
| 27 | - | targetRole attachmentRole | |
| 28 | 26 | expectedTransport transportSnapshot | |
| 29 | - | sourceToken *attachmentRoleToken | |
| 27 | + | sourceToken *attachmentConnectionToken | |
| 30 | 28 | action string | |
| 31 | 29 | activateTargetTab bool | |
| 32 | 30 | targetTabIndex int | |
| 33 | 31 | copySourceEnvironment bool | |
| 34 | - | // preserveRole commits navigation state without replacing attachment | |
| 35 | - | // ownership. It is used for same-session picker selections after the exact | |
| 36 | - | // initiating capability has been frozen and revalidated. | |
| 37 | - | preserveRole bool | |
| 38 | - | expectedSourceTab *tab | |
| 32 | + | // preserveAttachment commits attachment-local navigation state without | |
| 33 | + | // changing session membership. The initiating connection token remains the | |
| 34 | + | // exact authority for this mutation. | |
| 35 | + | preserveAttachment bool | |
| 36 | + | expectedSourceTab *tab | |
| 39 | 37 | // transferExpectedSourceTab permits an installed visible floating pane only | |
| 40 | - | // when the caller atomically transfers this exact tab under its own tab and | |
| 41 | - | // floating-generation fences. Ordinary navigation still rejects visibility. | |
| 42 | - | transferExpectedSourceTab bool | |
| 43 | - | createTargetLocked func() (*session, error) | |
| 44 | - | ready bool | |
| 45 | - | expectedTargetCurrent *attachedClient | |
| 46 | - | expectedTargetTransport transportSnapshot | |
| 47 | - | expectedTargetLifecycle *attachmentLifecycleFence | |
| 48 | - | preflighted bool | |
| 49 | - | roleEffectsFrozen bool | |
| 50 | - | expectedTargetTransportInterrupted bool | |
| 51 | - | // activationBarrier is used when promoting an already connected snatched | |
| 52 | - | // attachment. Its role gate is deadline-drained before sendMu is acquired, | |
| 53 | - | // preserving the gate -> sendMu -> daemon/routing/session publication order. | |
| 54 | - | activationBarrier bool | |
| 38 | + | // when the caller atomically transfers this exact tab under its fences. | |
| 39 | + | transferExpectedSourceTab bool | |
| 40 | + | createTargetLocked func() (*session, error) | |
| 41 | + | ready bool | |
| 42 | + | expectedTargetLifecycle *attachmentLifecycleFence | |
| 43 | + | preflighted bool | |
| 44 | + | attachmentEffectsFrozen bool | |
| 55 | 45 | } | |
| 56 | 46 | ||
| 57 | - | func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source attachmentSession, req attachmentTransitionRequest) bool { | |
| 58 | - | return token.sess == source && token.ac == req.next && token.role == req.expectedRole && | |
| 47 | + | func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source attachmentSession, req attachmentTransitionRequest) bool { | |
| 48 | + | return token.sess == source && token.ac == req.next && | |
| 49 | + | token.generation == req.next.connectionGeneration.Load() && | |
| 59 | 50 | token.transport.transport == req.expectedTransport.transport && | |
| 60 | 51 | token.transport.incarnation == req.expectedTransport.incarnation | |
| 61 | 52 | } | |
| 62 | 53 | ||
| 63 | - | // transitionSourceTokenCurrentLocked requires source.mu and, for an active | |
| 64 | - | // source, sourceCoordinator.mu. This is the canonical handoff preflight: every | |
| 65 | - | // client-originated navigation, delete, or create intent is bound to one exact | |
| 66 | - | // role generation, transport incarnation, and coordinator lease. | |
| 67 | - | func transitionSourceTokenCurrentLocked(token attachmentRoleToken, source attachmentSession, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool { | |
| 68 | - | if token.sess != source || token.ac != req.next || token.role != req.expectedRole || | |
| 69 | - | token.generation != req.next.roleGeneration.Load() || attachmentSessionRoleLocked(source, req.next) != token.role || | |
| 70 | - | req.next.currentAttachmentSession() != source || !req.next.transportSnapshotCurrent(token.transport) { | |
| 54 | + | // transitionSourceTokenCurrentLocked requires source.core().mu and, when a | |
| 55 | + | // lease exists, sourceCoordinator.mu. It is the canonical exact-connection | |
| 56 | + | // handoff check for client-originated navigation and lifecycle mutations. | |
| 57 | + | func transitionSourceTokenCurrentLocked(token attachmentConnectionToken, source attachmentSession, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool { | |
| 58 | + | if token.sess != source || token.ac != req.next || | |
| 59 | + | token.generation != req.next.connectionGeneration.Load() || | |
| 60 | + | token.sess == nil || token.ac.currentAttachmentSession() != source || | |
| 61 | + | !attachmentRegisteredLocked(source, req.next) || | |
| 62 | + | !req.next.transportSnapshotCurrent(token.transport) { | |
| 71 | 63 | return false | |
| 72 | 64 | } | |
| 73 | - | if token.role != attachmentActive { | |
| 65 | + | if token.lease == nil { | |
| 74 | 66 | return true | |
| 75 | 67 | } | |
| 76 | - | return sourceCoordinator != nil && token.lease != nil && token.lease.attachment == req.next && | |
| 68 | + | return sourceCoordinator != nil && token.lease.attachment == req.next && | |
| 77 | 69 | sourceCoordinator.leaseCurrentLocked(token.lease, true) | |
| 78 | 70 | } | |
| 79 | 71 | ||
| 80 | 72 | type attachmentTransitionResult struct { | |
| 81 | - | published attachmentRoleToken | |
| 82 | - | displaced attachmentRoleToken | |
| 83 | - | terminalDisplaced detachedAttachmentSnapshot | |
| 84 | - | displacedInterrupted bool | |
| 85 | - | cleanups []renderLifecycleCleanup | |
| 73 | + | published attachmentConnectionToken | |
| 74 | + | cleanups []renderLifecycleCleanup | |
| 86 | 75 | } | |
| 87 | 76 | ||
| 88 | 77 | type attachmentTransitionParticipants struct { | |
| 89 | 78 | clients []*attachedClient | |
| 90 | - | interrupts []roleTransportInterrupt | |
| 79 | + | interrupts []attachmentTransportInterrupt | |
| 91 | 80 | } | |
| 92 | 81 | ||
| 93 | - | // snapshotAttachmentTransition discovers the exact participants while d.mu is | |
| 94 | - | // held. It releases every architecture lock before any role effect is ended or | |
| 95 | - | // any gate is frozen and drained. | |
| 82 | + | // snapshotAttachmentTransition discovers the exact connection participant | |
| 83 | + | // while d.mu is held, then releases architecture locks before effect gates are | |
| 84 | + | // frozen and drained. Existing target attachments are never displaced. | |
| 96 | 85 | func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionRequest, attachmentTransitionParticipants, error) { | |
| 97 | 86 | d.mu.Lock() | |
| 98 | 87 | defer d.mu.Unlock() | |
| @@ -110,73 +99,43 @@ func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) ( | |||
| 110 | 99 | targetCore = req.target.core() | |
| 111 | 100 | } | |
| 112 | 101 | if sourceCore == nil || req.target != nil && targetCore == nil || req.next == nil || d.closing || | |
| 113 | - | d.sessions[sourceCore.id] != source || req.target != nil && d.sessions[targetCore.id] != req.target || | |
| 114 | - | req.activationBarrier && (req.sourceToken == nil || source != req.target || | |
| 115 | - | req.expectedRole != attachmentSnatched || req.targetRole != attachmentActive) { | |
| 102 | + | d.sessions[sourceCore.id] != source || req.target != nil && d.sessions[targetCore.id] != req.target { | |
| 116 | 103 | return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition | |
| 117 | 104 | } | |
| 118 | - | if req.target != nil { | |
| 119 | - | targetCore.mu.Lock() | |
| 120 | - | req.expectedTargetCurrent = targetCore.client | |
| 121 | - | if req.expectedTargetCurrent != nil && req.expectedTargetCurrent != req.next && req.targetRole == attachmentActive { | |
| 122 | - | req.expectedTargetTransport = req.expectedTargetCurrent.transportSnapshot() | |
| 123 | - | } | |
| 124 | - | targetCore.mu.Unlock() | |
| 125 | - | } | |
| 126 | 105 | if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) { | |
| 127 | 106 | return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition | |
| 128 | 107 | } | |
| 129 | 108 | req.preflighted = true | |
| 130 | - | return req, attachmentTransitionParticipants{ | |
| 131 | - | clients: []*attachedClient{req.next, req.expectedTargetCurrent}, | |
| 132 | - | interrupts: []roleTransportInterrupt{{ | |
| 133 | - | ac: req.expectedTargetCurrent, transport: req.expectedTargetTransport, | |
| 134 | - | }}, | |
| 135 | - | }, nil | |
| 109 | + | return req, attachmentTransitionParticipants{clients: []*attachedClient{req.next}}, nil | |
| 136 | 110 | } | |
| 137 | 111 | ||
| 138 | - | // freezeAttachmentTransition drains the snapshotted participants without any | |
| 139 | - | // architecture lock held. The returned guard remains frozen through atomic | |
| 140 | - | // publication and must be released by the caller. | |
| 141 | - | func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, participants attachmentTransitionParticipants) (frozenRoleEffectGates, bool, error) { | |
| 142 | - | if d.afterRoleEffectParticipantsSnapshotted != nil { | |
| 143 | - | d.afterRoleEffectParticipantsSnapshotted(req.action, participants.clients) | |
| 112 | + | // freezeAttachmentTransition drains the snapshotted connection without any | |
| 113 | + | // architecture lock held. The returned guard remains frozen through publication. | |
| 114 | + | func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, participants attachmentTransitionParticipants) (frozenAttachmentEffectGates, error) { | |
| 115 | + | if d.afterAttachmentEffectParticipantsSnapshotted != nil { | |
| 116 | + | d.afterAttachmentEffectParticipantsSnapshotted(req.action, participants.clients) | |
| 144 | 117 | } | |
| 145 | - | var drainDeadline *roleEffectDrainDeadline | |
| 146 | - | var drainDone func() <-chan struct{} | |
| 147 | - | if req.activationBarrier { | |
| 148 | - | drainDeadline = newRoleEffectDrainDeadline(d.clock) | |
| 149 | - | drainDone = drainDeadline.Done | |
| 150 | - | } | |
| 151 | - | frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, done: drainDone, afterFrozen: func(ac *attachedClient) { | |
| 152 | - | if d.afterRoleEffectGateFrozen != nil { | |
| 153 | - | d.afterRoleEffectGateFrozen(req.action, ac) | |
| 118 | + | frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: participants.interrupts, afterFrozen: func(ac *attachedClient) { | |
| 119 | + | if d.afterAttachmentEffectGateFrozen != nil { | |
| 120 | + | d.afterAttachmentEffectGateFrozen(req.action, ac) | |
| 154 | 121 | } | |
| 155 | 122 | }}, participants.clients...) | |
| 156 | - | if drainDeadline != nil { | |
| 157 | - | drainDeadline.stop() | |
| 158 | - | } | |
| 159 | 123 | if !frozen.acquired || !frozen.drained { | |
| 160 | - | if req.activationBarrier { | |
| 161 | - | _ = req.next.closeCapturedTransport(req.expectedTransport.transport) | |
| 162 | - | return frozen, false, errSendTimedOut | |
| 163 | - | } | |
| 164 | - | return frozen, false, errAttachmentTransition | |
| 124 | + | return frozen, errAttachmentTransition | |
| 165 | 125 | } | |
| 166 | - | interrupted := frozen.interrupted(req.expectedTargetCurrent, req.expectedTargetTransport) | |
| 167 | - | if d.afterRoleEffectsFrozen != nil { | |
| 168 | - | d.afterRoleEffectsFrozen() | |
| 126 | + | if d.afterAttachmentEffectsFrozen != nil { | |
| 127 | + | d.afterAttachmentEffectsFrozen() | |
| 169 | 128 | } | |
| 170 | - | return frozen, interrupted, nil | |
| 129 | + | return frozen, nil | |
| 171 | 130 | } | |
| 172 | 131 | ||
| 173 | 132 | // publishAttachmentTransition revalidates the frozen source, creates an | |
| 174 | - | // optional target, and performs publication while d.mu remains held. | |
| 133 | + | // optional target, and performs exact membership publication under d.mu. | |
| 175 | 134 | func (d *Daemon) publishAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | |
| 176 | 135 | d.mu.Lock() | |
| 177 | 136 | defer d.mu.Unlock() | |
| 178 | 137 | ||
| 179 | - | req.roleEffectsFrozen = true | |
| 138 | + | req.attachmentEffectsFrozen = true | |
| 180 | 139 | if req.target == nil { | |
| 181 | 140 | if !d.transitionSourcePreflightLocked(req) { | |
| 182 | 141 | return attachmentTransitionResult{}, errAttachmentTransition | |
| @@ -186,71 +145,35 @@ func (d *Daemon) publishAttachmentTransition(req attachmentTransitionRequest) (a | |||
| 186 | 145 | if err != nil { | |
| 187 | 146 | return attachmentTransitionResult{}, err | |
| 188 | 147 | } | |
| 189 | - | req.expectedTargetCurrent = nil | |
| 190 | 148 | } | |
| 191 | 149 | return d.transitionAttachmentLocked(req) | |
| 192 | 150 | } | |
| 193 | 151 | ||
| 194 | - | // prepareActivatedAttachment rebases the first active paint while the caller | |
| 195 | - | // still owns the activation barrier (and therefore sendMu). | |
| 196 | - | func (d *Daemon) prepareActivatedAttachment(result *attachmentTransitionResult) { | |
| 197 | - | ac := result.published.ac | |
| 198 | - | ac.rebaseOutput() | |
| 199 | - | ac.captureFrames = nil | |
| 200 | - | if sess, ok := localSession(result.published.sess); ok { | |
| 201 | - | d.applyHostThemeSendLocked(sess, ac, ac.getClientTheme(), false) | |
| 202 | - | } | |
| 203 | - | // The first paint still requests a reset, but the old panel dependency chain | |
| 204 | - | // has already been rebased atomically under the activation barrier. | |
| 205 | - | result.published.rebase = false | |
| 206 | - | } | |
| 207 | - | ||
| 208 | 152 | func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | |
| 209 | 153 | req, participants, err := d.snapshotAttachmentTransition(req) | |
| 210 | 154 | if err != nil { | |
| 211 | 155 | return attachmentTransitionResult{}, err | |
| 212 | 156 | } | |
| 213 | 157 | if req.sourceToken != nil { | |
| 214 | - | d.endActionRoleEffect(req.sourceToken.effect, req.action) | |
| 158 | + | d.endActionAttachmentEffect(req.sourceToken.effect, req.action) | |
| 215 | 159 | } | |
| 216 | 160 | ||
| 217 | - | frozen, interrupted, err := d.freezeAttachmentTransition(req, participants) | |
| 161 | + | frozen, err := d.freezeAttachmentTransition(req, participants) | |
| 218 | 162 | defer frozen.unfreeze() | |
| 219 | 163 | if err != nil { | |
| 220 | 164 | return attachmentTransitionResult{}, err | |
| 221 | 165 | } | |
| 222 | - | req.expectedTargetTransportInterrupted = interrupted | |
| 223 | - | ||
| 224 | - | var releaseActivation func() | |
| 225 | - | if req.activationBarrier { | |
| 226 | - | releaseActivation, err = d.acquireActivationBarrier(*req.sourceToken) | |
| 227 | - | if err != nil { | |
| 228 | - | return attachmentTransitionResult{}, err | |
| 229 | - | } | |
| 230 | - | defer func() { | |
| 231 | - | if releaseActivation != nil { | |
| 232 | - | releaseActivation() | |
| 233 | - | } | |
| 234 | - | }() | |
| 235 | - | } | |
| 236 | 166 | ||
| 237 | 167 | result, err := d.publishAttachmentTransition(req) | |
| 238 | 168 | if err != nil { | |
| 239 | 169 | return result, err | |
| 240 | 170 | } | |
| 241 | - | if req.activationBarrier && result.published.role == attachmentActive { | |
| 242 | - | d.prepareActivatedAttachment(&result) | |
| 243 | - | } | |
| 244 | - | if releaseActivation != nil { | |
| 245 | - | releaseActivation() | |
| 246 | - | releaseActivation = nil | |
| 247 | - | } | |
| 248 | - | if result.published.role == attachmentActive { | |
| 249 | - | result.published.ac.clearSnatchedInput() | |
| 250 | - | } | |
| 251 | - | // Warm-proxy timers are external clock operations. Apply them only after | |
| 252 | - | // frozen ownership publication and every architecture lock have completed; | |
| 253 | - | // the lifecycle helper revalidates exact registry pointers and clients. | |
| 254 | - | d.proxyAttachmentTransitionCommitted(req.source, req.target, req.next, req.preserveRole) | |
| 171 | + | d.proxyAttachmentTransitionCommitted(req.source, req.target, req.next, req.preserveAttachment) | |
| 255 | 172 | return result, nil | |
| 256 | 173 | } | |
| 174 | + | ||
| 175 | + | func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionResult) { | |
| 176 | + | for _, cleanup := range result.cleanups { | |
| 177 | + | d.attachmentCleanupWg.Go(cleanup.finish) | |
| 178 | + | } | |
| 179 | + | } | |
M internal/usecase/daemon/attachment_transition_prelocked_test.go +20 -129
| @@ -2,152 +2,43 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | - | "time" | |
| 6 | 5 | ||
| 7 | 6 | "github.com/stretchr/testify/require" | |
| 8 | 7 | ||
| 9 | 8 | "github.com/bnema/vev/internal/domain" | |
| 10 | 9 | ) | |
| 11 | 10 | ||
| 12 | - | func TestAttachmentTransitionPrelockedMatchesWrapper(t *testing.T) { | |
| 13 | - | t.Parallel() | |
| 14 | - | ||
| 15 | - | type transitionObservation struct { | |
| 16 | - | result attachmentTransitionResult | |
| 17 | - | activeRole attachmentRole | |
| 18 | - | displacedRole attachmentRole | |
| 19 | - | activeGeneration uint64 | |
| 20 | - | displacedGeneration uint64 | |
| 21 | - | activeSession *session | |
| 22 | - | displacedSession *session | |
| 23 | - | } | |
| 24 | - | ||
| 25 | - | setup := func(t *testing.T) (*Daemon, *session, *attachedClient, *attachedClient) { | |
| 26 | - | t.Helper() | |
| 27 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 28 | - | sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}} | |
| 29 | - | old := &attachedClient{tr: &closeTrackingTransport{}} | |
| 30 | - | old.setSession(sess) | |
| 31 | - | sess.client = old | |
| 32 | - | d.sessions[sess.id] = sess | |
| 33 | - | next := &attachedClient{tr: &closeTrackingTransport{}} | |
| 34 | - | return d, sess, old, next | |
| 35 | - | } | |
| 36 | - | observe := func(sess *session, old, next *attachedClient, result attachmentTransitionResult) transitionObservation { | |
| 37 | - | return transitionObservation{ | |
| 38 | - | result: result, | |
| 39 | - | activeRole: sess.attachmentRole(next), | |
| 40 | - | displacedRole: sess.attachmentRole(old), | |
| 41 | - | activeGeneration: next.roleGeneration.Load(), | |
| 42 | - | displacedGeneration: old.roleGeneration.Load(), | |
| 43 | - | activeSession: next.currentSession(), | |
| 44 | - | displacedSession: old.currentSession(), | |
| 45 | - | } | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | wrapperDaemon, wrapperSession, wrapperOld, wrapperNext := setup(t) | |
| 49 | - | wrapperResult, err := wrapperDaemon.transitionAttachment(attachmentTransitionRequest{ | |
| 50 | - | target: wrapperSession, | |
| 51 | - | next: wrapperNext, | |
| 52 | - | expectedRole: attachmentDetached, | |
| 53 | - | targetRole: attachmentActive, | |
| 54 | - | expectedTransport: wrapperNext.transportSnapshot(), | |
| 55 | - | ready: true, | |
| 56 | - | }) | |
| 57 | - | require.NoError(t, err) | |
| 58 | - | wrapper := observe(wrapperSession, wrapperOld, wrapperNext, wrapperResult) | |
| 59 | - | ||
| 60 | - | prelockedDaemon, prelockedSession, prelockedOld, prelockedNext := setup(t) | |
| 61 | - | frozen := freezeRoleEffectGates(prelockedNext, prelockedOld) | |
| 62 | - | require.True(t, frozen.acquired) | |
| 63 | - | require.True(t, frozen.drained) | |
| 64 | - | defer frozen.unfreeze() | |
| 65 | - | ||
| 66 | - | req := attachmentTransitionRequest{ | |
| 67 | - | target: prelockedSession, | |
| 68 | - | next: prelockedNext, | |
| 69 | - | expectedRole: attachmentDetached, | |
| 70 | - | targetRole: attachmentActive, | |
| 71 | - | expectedTransport: prelockedNext.transportSnapshot(), | |
| 72 | - | expectedTargetCurrent: prelockedOld, | |
| 73 | - | preflighted: true, | |
| 74 | - | roleEffectsFrozen: true, | |
| 75 | - | ready: true, | |
| 76 | - | } | |
| 77 | - | type prelockedOutcome struct { | |
| 78 | - | result attachmentTransitionResult | |
| 79 | - | err error | |
| 80 | - | } | |
| 81 | - | outcome := make(chan prelockedOutcome, 1) | |
| 82 | - | go func() { | |
| 83 | - | prelockedDaemon.mu.Lock() | |
| 84 | - | prelockedDaemon.notices.routingMu.Lock() | |
| 85 | - | unlockSessions := lockAttachmentSessions(prelockedSession, prelockedSession) | |
| 86 | - | publication, validateErr := prelockedDaemon.validateAttachmentTransitionPrelocked(req) | |
| 87 | - | var result attachmentTransitionResult | |
| 88 | - | if validateErr == nil { | |
| 89 | - | result = prelockedDaemon.publishAttachmentTransitionPrelocked(publication) | |
| 90 | - | publication.unlockCoordinators() | |
| 91 | - | } | |
| 92 | - | unlockSessions() | |
| 93 | - | prelockedDaemon.notices.routingMu.Unlock() | |
| 94 | - | prelockedDaemon.mu.Unlock() | |
| 95 | - | outcome <- prelockedOutcome{result: result, err: validateErr} | |
| 96 | - | }() | |
| 97 | - | ||
| 98 | - | var prelockedResult attachmentTransitionResult | |
| 99 | - | select { | |
| 100 | - | case got := <-outcome: | |
| 101 | - | require.NoError(t, got.err) | |
| 102 | - | prelockedResult = got.result | |
| 103 | - | case <-time.After(time.Second): | |
| 104 | - | t.Fatal("prelocked attachment transition recursively acquired an already-held lock") | |
| 105 | - | } | |
| 106 | - | prelocked := observe(prelockedSession, prelockedOld, prelockedNext, prelockedResult) | |
| 107 | - | ||
| 108 | - | require.Equal(t, wrapper.activeRole, prelocked.activeRole) | |
| 109 | - | require.Equal(t, wrapper.displacedRole, prelocked.displacedRole) | |
| 110 | - | require.Equal(t, wrapper.activeGeneration, prelocked.activeGeneration) | |
| 111 | - | require.Equal(t, wrapper.displacedGeneration, prelocked.displacedGeneration) | |
| 112 | - | require.Same(t, prelockedSession, prelocked.activeSession) | |
| 113 | - | require.Same(t, prelockedSession, prelocked.displacedSession) | |
| 114 | - | require.Equal(t, wrapper.result.published.role, prelocked.result.published.role) | |
| 115 | - | require.Equal(t, wrapper.result.displaced.role, prelocked.result.displaced.role) | |
| 116 | - | require.NotNil(t, prelocked.result.published.lease) | |
| 117 | - | } | |
| 118 | - | ||
| 119 | 11 | func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure(t *testing.T) { | |
| 120 | 12 | t.Parallel() | |
| 121 | 13 | ||
| 122 | 14 | d := newTestDaemon(t, nil, stubClock{}) | |
| 123 | 15 | source := &session{sessionCore: sessionCore{id: domain.SessionID("source")}} | |
| 124 | - | target := &session{sessionCore: sessionCore{id: domain.SessionID("target")}, active: 0} | |
| 16 | + | target := &session{sessionCore: sessionCore{id: domain.SessionID("target")}} | |
| 125 | 17 | next := &attachedClient{tr: &closeTrackingTransport{}} | |
| 126 | 18 | next.setSession(source) | |
| 127 | - | source.addSnatchedLocked(next) | |
| 19 | + | source.registerAttachmentLocked(next) | |
| 128 | 20 | old := &attachedClient{tr: &closeTrackingTransport{}} | |
| 129 | 21 | old.setSession(target) | |
| 130 | - | target.client = old | |
| 22 | + | target.registerAttachment(old) | |
| 131 | 23 | d.sessions[source.id] = source | |
| 132 | 24 | d.sessions[target.id] = target | |
| 133 | 25 | ||
| 134 | - | frozen := freezeRoleEffectGates(next, old) | |
| 26 | + | frozen := freezeAttachmentEffectGates(next, old) | |
| 135 | 27 | require.True(t, frozen.acquired) | |
| 136 | 28 | require.True(t, frozen.drained) | |
| 137 | 29 | defer frozen.unfreeze() | |
| 138 | 30 | ||
| 139 | 31 | req := attachmentTransitionRequest{ | |
| 140 | - | source: source, | |
| 141 | - | target: target, | |
| 142 | - | next: next, | |
| 143 | - | expectedRole: attachmentSnatched, | |
| 144 | - | targetRole: attachmentActive, | |
| 145 | - | expectedTransport: next.transportSnapshot(), | |
| 146 | - | expectedTargetCurrent: old, | |
| 147 | - | preflighted: true, | |
| 148 | - | roleEffectsFrozen: true, | |
| 149 | - | activateTargetTab: true, | |
| 150 | - | targetTabIndex: 1, | |
| 32 | + | source: source, | |
| 33 | + | target: target, | |
| 34 | + | next: next, | |
| 35 | + | ||
| 36 | + | expectedTransport: next.transportSnapshot(), | |
| 37 | + | ||
| 38 | + | preflighted: true, | |
| 39 | + | attachmentEffectsFrozen: true, | |
| 40 | + | activateTargetTab: true, | |
| 41 | + | targetTabIndex: 1, | |
| 151 | 42 | } | |
| 152 | 43 | ||
| 153 | 44 | d.mu.Lock() | |
| @@ -160,11 +51,11 @@ func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure | |||
| 160 | 51 | ||
| 161 | 52 | require.ErrorIs(t, err, errAttachmentTransition) | |
| 162 | 53 | require.Nil(t, publication) | |
| 163 | - | require.Equal(t, attachmentSnatched, source.attachmentRole(next)) | |
| 164 | - | require.Equal(t, attachmentDetached, target.attachmentRole(next)) | |
| 165 | - | require.Equal(t, attachmentActive, target.attachmentRole(old)) | |
| 54 | + | require.Equal(t, true, source.attachmentRegistered(next)) | |
| 55 | + | require.Equal(t, false, target.attachmentRegistered(next)) | |
| 56 | + | require.Equal(t, true, target.attachmentRegistered(old)) | |
| 166 | 57 | require.Same(t, source, next.currentSession()) | |
| 167 | - | require.Equal(t, uint64(0), next.roleGeneration.Load()) | |
| 168 | - | require.Equal(t, uint64(0), old.roleGeneration.Load()) | |
| 169 | - | require.Equal(t, 0, target.active) | |
| 58 | + | require.Equal(t, uint64(0), next.connectionGeneration.Load()) | |
| 59 | + | require.Equal(t, uint64(0), old.connectionGeneration.Load()) | |
| 60 | + | require.Equal(t, 0, testAttachmentTabIndex(target)) | |
| 170 | 61 | } | |
M internal/usecase/daemon/attachment_transition_publish.go +87 -152
| @@ -1,10 +1,14 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/internal/domain" | |
| 3 | + | import ( | |
| 4 | + | "slices" | |
| 4 | 5 | ||
| 5 | - | // transitionSourcePreflightLocked validates an initiating role while its gate | |
| 6 | - | // is frozen and d.mu is held. No caller may create or delete a target before | |
| 7 | - | // this succeeds. | |
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // transitionSourcePreflightLocked validates an initiating connection while its | |
| 10 | + | // effect gate is frozen and d.mu is held. No target is created before this | |
| 11 | + | // succeeds. | |
| 8 | 12 | func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest) bool { | |
| 9 | 13 | if req.sourceToken == nil || req.source == nil || req.source.core() == nil || d.sessions[req.source.core().id] != req.source { | |
| 10 | 14 | return false | |
| @@ -14,19 +18,18 @@ func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest | |||
| 14 | 18 | req.source.core().mu.Lock() | |
| 15 | 19 | defer req.source.core().mu.Unlock() | |
| 16 | 20 | coordinator := req.source.core().coordinator.Load() | |
| 17 | - | if coordinator == nil { | |
| 18 | - | return false | |
| 21 | + | if coordinator != nil { | |
| 22 | + | coordinator.mu.Lock() | |
| 23 | + | defer coordinator.mu.Unlock() | |
| 19 | 24 | } | |
| 20 | - | coordinator.mu.Lock() | |
| 21 | - | defer coordinator.mu.Unlock() | |
| 22 | 25 | if !transitionSourceTokenCurrentLocked(*req.sourceToken, req.source, coordinator, req) { | |
| 23 | 26 | return false | |
| 24 | 27 | } | |
| 25 | 28 | return transitionSourceTabCurrentLocked(req.source, req.expectedSourceTab) | |
| 26 | 29 | } | |
| 27 | 30 | ||
| 28 | - | func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool { | |
| 29 | - | if token.sess == nil || token.ac == nil || token.role != attachmentActive { | |
| 31 | + | func (d *Daemon) sourceAttachmentTokenCurrentFrozen(token attachmentConnectionToken) bool { | |
| 32 | + | if token.sess == nil || token.ac == nil { | |
| 30 | 33 | return false | |
| 31 | 34 | } | |
| 32 | 35 | d.mu.Lock() | |
| @@ -39,15 +42,11 @@ func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool { | |||
| 39 | 42 | token.sess.core().mu.Lock() | |
| 40 | 43 | defer token.sess.core().mu.Unlock() | |
| 41 | 44 | coordinator := token.sess.core().coordinator.Load() | |
| 42 | - | if coordinator == nil { | |
| 43 | - | return false | |
| 44 | - | } | |
| 45 | - | coordinator.mu.Lock() | |
| 46 | - | defer coordinator.mu.Unlock() | |
| 47 | - | req := attachmentTransitionRequest{ | |
| 48 | - | source: token.sess, next: token.ac, expectedRole: token.role, | |
| 49 | - | expectedTransport: token.transport, | |
| 45 | + | if coordinator != nil { | |
| 46 | + | coordinator.mu.Lock() | |
| 47 | + | defer coordinator.mu.Unlock() | |
| 50 | 48 | } | |
| 49 | + | req := attachmentTransitionRequest{source: token.sess, next: token.ac, expectedTransport: token.transport} | |
| 51 | 50 | return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req) | |
| 52 | 51 | } | |
| 53 | 52 | ||
| @@ -60,7 +59,7 @@ func transitionSourceTabCurrentForRequestLocked(source attachmentSession, expect | |||
| 60 | 59 | return true | |
| 61 | 60 | } | |
| 62 | 61 | local, ok := localSession(source) | |
| 63 | - | if !ok || local.active < 0 || local.active >= len(local.tabs) || local.tabs[local.active] != expected { | |
| 62 | + | if !ok || !containsTab(local.tabs, expected) { | |
| 64 | 63 | return false | |
| 65 | 64 | } | |
| 66 | 65 | expected.mu.Lock() | |
| @@ -68,28 +67,25 @@ func transitionSourceTabCurrentForRequestLocked(source attachmentSession, expect | |||
| 68 | 67 | return transferExpected || expected.floating.state != floatingVisible | |
| 69 | 68 | } | |
| 70 | 69 | ||
| 70 | + | func containsTab(tabs []*tab, want *tab) bool { | |
| 71 | + | return slices.Contains(tabs, want) | |
| 72 | + | } | |
| 73 | + | ||
| 71 | 74 | // transitionAttachmentLocked is the publication half of transitionAttachment. | |
| 72 | - | // Caller holds d.mu after the lock-free gate freeze/drain has completed. | |
| 75 | + | // Caller holds d.mu after the lock-free effect freeze/drain. | |
| 73 | 76 | func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | |
| 74 | 77 | d.notices.routingMu.Lock() | |
| 75 | 78 | defer d.notices.routingMu.Unlock() | |
| 76 | 79 | return d.transitionAttachmentRoutedLocked(req) | |
| 77 | 80 | } | |
| 78 | 81 | ||
| 79 | - | // attachmentPublication is the validated, coordinator-locked input to the | |
| 80 | - | // non-failing ownership and lease publication step. Its caller continues to | |
| 81 | - | // own d.mu, notices.routingMu, and the ordered source/target session locks. | |
| 82 | 82 | type attachmentPublication struct { | |
| 83 | 83 | req attachmentTransitionRequest | |
| 84 | 84 | source attachmentSession | |
| 85 | - | old *attachedClient | |
| 86 | - | sourceCoordinator *renderCoordinator | |
| 87 | 85 | targetCoordinator *renderCoordinator | |
| 86 | + | sourceCoordinator *renderCoordinator | |
| 88 | 87 | releaseCoordinators func() | |
| 89 | - | displacedTransport transportSnapshot | |
| 90 | - | terminalDisplaced bool | |
| 91 | 88 | nextGeneration uint64 | |
| 92 | - | displacedGeneration uint64 | |
| 93 | 89 | } | |
| 94 | 90 | ||
| 95 | 91 | func (p *attachmentPublication) unlockCoordinators() { | |
| @@ -114,11 +110,9 @@ func attachmentLifecycleCurrentLocked(entry attachmentSession, fence *attachment | |||
| 114 | 110 | domain.TabStableID(target.tabs[fence.tabIndex].stableID) == fence.tabID | |
| 115 | 111 | } | |
| 116 | 112 | ||
| 117 | - | // validateAttachmentTransitionPrelocked performs every fallible transition | |
| 118 | - | // check without acquiring d.mu, notices.routingMu, or either session lock. | |
| 119 | - | // The caller holds those locks in that order, with session locks ordered by ID. | |
| 120 | - | // On success the returned validation retains the ordered coordinator locks | |
| 121 | - | // through publishAttachmentTransitionPrelocked. | |
| 113 | + | // validateAttachmentTransitionPrelocked performs every fallible check before | |
| 114 | + | // the non-failing membership and lease publication. Caller holds d.mu, | |
| 115 | + | // notices.routingMu, and ordered source/target session locks. | |
| 122 | 116 | func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionRequest) (*attachmentPublication, error) { | |
| 123 | 117 | source := req.source | |
| 124 | 118 | if source == nil { | |
| @@ -129,56 +123,60 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR | |||
| 129 | 123 | } | |
| 130 | 124 | sourceCore := source.core() | |
| 131 | 125 | targetCore := req.target.core() | |
| 132 | - | if sourceCore == nil || targetCore == nil || | |
| 133 | - | !req.preflighted || !req.roleEffectsFrozen || | |
| 134 | - | !validAttachmentTransitionRole(req.expectedRole, true) || !validAttachmentTransitionRole(req.targetRole, false) || | |
| 135 | - | req.next == nil || d.closing || | |
| 136 | - | d.sessions[sourceCore.id] != source || d.sessions[targetCore.id] != req.target || | |
| 126 | + | if sourceCore == nil || targetCore == nil || !req.preflighted || !req.attachmentEffectsFrozen || | |
| 127 | + | req.next == nil || d.closing || d.sessions[sourceCore.id] != source || d.sessions[targetCore.id] != req.target || | |
| 137 | 128 | req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) || | |
| 138 | - | req.preserveRole && req.sourceToken == nil { | |
| 129 | + | req.preserveAttachment && req.sourceToken == nil { | |
| 139 | 130 | return nil, errAttachmentTransition | |
| 140 | 131 | } | |
| 141 | 132 | ||
| 142 | - | var targetCoordinator *renderCoordinator | |
| 143 | - | if req.targetRole == attachmentActive { | |
| 144 | - | targetCoordinator = d.ensureAttachmentRenderCoordinatorPrelocked(req.target) | |
| 145 | - | } | |
| 146 | 133 | sourceCoordinator := sourceCore.coordinator.Load() | |
| 147 | - | if attachmentSessionRoleLocked(source, req.next) != req.expectedRole || | |
| 148 | - | !req.next.transportSnapshotCurrent(req.expectedTransport) { | |
| 134 | + | targetCoordinator := d.ensureAttachmentRenderCoordinatorPrelocked(req.target) | |
| 135 | + | // transitionSourceTokenCurrentLocked also reads the source lease. Hold the | |
| 136 | + | // source coordinator lock before calling it, following session -> coordinator | |
| 137 | + | // ordering and preventing a lease rebind from racing validation. | |
| 138 | + | releaseCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator) | |
| 139 | + | if d.afterAttachmentTransitionCoordinatorsLocked != nil { | |
| 140 | + | d.afterAttachmentTransitionCoordinatorsLocked() | |
| 141 | + | } | |
| 142 | + | if req.sourceToken != nil && !transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) { | |
| 143 | + | releaseCoordinators() | |
| 149 | 144 | return nil, errAttachmentTransition | |
| 150 | 145 | } | |
| 151 | - | ||
| 152 | - | old := targetCore.client | |
| 153 | - | if old != req.expectedTargetCurrent || source != req.target && old == req.next { | |
| 146 | + | if source != req.target && !attachmentRegisteredLocked(source, req.next) { | |
| 147 | + | releaseCoordinators() | |
| 148 | + | return nil, errAttachmentTransition | |
| 149 | + | } | |
| 150 | + | if !req.preserveAttachment && source != req.target && attachmentRegisteredLocked(req.target, req.next) { | |
| 151 | + | releaseCoordinators() | |
| 152 | + | return nil, errAttachmentTransition | |
| 153 | + | } | |
| 154 | + | if req.preserveAttachment && !attachmentRegisteredLocked(req.target, req.next) { | |
| 155 | + | releaseCoordinators() | |
| 154 | 156 | return nil, errAttachmentTransition | |
| 155 | 157 | } | |
| 158 | + | if !attachmentLifecycleCurrentLocked(req.target, req.expectedTargetLifecycle) || | |
| 159 | + | req.activateTargetTab && !req.target.validTargetTabLocked(req.targetTabIndex) || | |
| 160 | + | req.sourceToken != nil && !transitionSourceTabCurrentForRequestLocked(source, req.expectedSourceTab, req.transferExpectedSourceTab) { | |
| 161 | + | releaseCoordinators() | |
| 162 | + | return nil, errAttachmentTransition | |
| 163 | + | } | |
| 164 | + | ||
| 156 | 165 | publication := &attachmentPublication{ | |
| 157 | - | req: req, source: source, old: old, | |
| 166 | + | req: req, source: source, | |
| 158 | 167 | sourceCoordinator: sourceCoordinator, targetCoordinator: targetCoordinator, | |
| 159 | - | releaseCoordinators: lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator), | |
| 160 | - | } | |
| 161 | - | invalid := req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) || | |
| 162 | - | !transitionSourceTabCurrentForRequestLocked(source, req.expectedSourceTab, req.transferExpectedSourceTab)) | |
| 163 | - | invalid = invalid || targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next) | |
| 164 | - | invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil && | |
| 165 | - | (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next) | |
| 166 | - | invalid = invalid || !attachmentLifecycleCurrentLocked(req.target, req.expectedTargetLifecycle) | |
| 167 | - | invalid = invalid || req.activateTargetTab && !req.target.activateTargetLocked(req.targetTabIndex) | |
| 168 | - | if invalid { | |
| 169 | - | publication.unlockCoordinators() | |
| 170 | - | return nil, errAttachmentTransition | |
| 168 | + | releaseCoordinators: releaseCoordinators, | |
| 171 | 169 | } | |
| 172 | 170 | return publication, nil | |
| 173 | 171 | } | |
| 174 | 172 | ||
| 175 | - | // applyTargetStateLocked commits navigation metadata before role ownership, as | |
| 176 | - | // part of the same locked publication. It reports role-preserving transitions. | |
| 173 | + | // applyTargetStateLocked commits attachment-local navigation metadata and | |
| 174 | + | // shared environment state as part of the same publication. | |
| 177 | 175 | func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool { | |
| 178 | 176 | req := publication.req | |
| 179 | - | if req.targetRole == attachmentActive { | |
| 177 | + | if req.activateTargetTab { | |
| 180 | 178 | if target, ok := localSession(req.target); ok { | |
| 181 | - | d.demoteParkedActiveForSessionLocked(target) | |
| 179 | + | target.activateAttachmentViewLocked(req.next, req.targetTabIndex) | |
| 182 | 180 | } | |
| 183 | 181 | } | |
| 184 | 182 | if req.copySourceEnvironment { | |
| @@ -189,113 +187,58 @@ func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool | |||
| 189 | 187 | target.env = copyEnvironment(source.env) | |
| 190 | 188 | } | |
| 191 | 189 | } | |
| 192 | - | return req.preserveRole | |
| 190 | + | return req.preserveAttachment | |
| 193 | 191 | } | |
| 194 | 192 | ||
| 195 | - | // publishAttachmentOwnershipLocked atomically changes session membership, | |
| 196 | - | // generations, and currentSession while all role gates remain frozen. | |
| 193 | + | // publishAttachmentOwnershipLocked adds/removes only the exact attachment. | |
| 194 | + | // Existing target attachments and their independent views/transports remain | |
| 195 | + | // untouched. | |
| 197 | 196 | func publishAttachmentOwnershipLocked(publication *attachmentPublication) { | |
| 198 | 197 | req := publication.req | |
| 199 | - | if req.targetRole == attachmentActive && publication.old != nil && publication.old != req.next { | |
| 200 | - | publication.displacedTransport = publication.old.transportSnapshot() | |
| 201 | - | // A proxied daemon attachment has no local snatched UI and must never | |
| 202 | - | // linger in that role. Replacing it is terminal so its owning local proxy | |
| 203 | - | // receives ReasonReplaced and cannot kick the direct user back later. | |
| 204 | - | publication.terminalDisplaced = publication.old.proxied | |
| 205 | - | } | |
| 206 | 198 | if publication.source != req.target { | |
| 207 | - | if publication.source.core().client == req.next { | |
| 208 | - | publication.source.core().client = nil | |
| 209 | - | } | |
| 210 | - | delete(publication.source.core().snatched, req.next) | |
| 211 | - | } | |
| 212 | - | if req.targetRole == attachmentActive { | |
| 213 | - | delete(req.target.core().snatched, req.next) | |
| 214 | - | if publication.old != nil && publication.old != req.next { | |
| 215 | - | if publication.terminalDisplaced { | |
| 216 | - | delete(req.target.core().snatched, publication.old) | |
| 217 | - | } else { | |
| 218 | - | addSnatchedLocked(req.target, publication.old) | |
| 219 | - | } | |
| 220 | - | } | |
| 221 | - | req.target.core().client = req.next | |
| 222 | - | } else { | |
| 223 | - | addSnatchedLocked(req.target, req.next) | |
| 224 | - | } | |
| 225 | - | publication.nextGeneration = req.next.roleGeneration.Add(1) | |
| 226 | - | if publication.old != nil && publication.old != req.next && req.targetRole == attachmentActive { | |
| 227 | - | publication.displacedGeneration = publication.old.roleGeneration.Add(1) | |
| 228 | - | if publication.terminalDisplaced { | |
| 229 | - | publication.old.setSession(nil) | |
| 230 | - | publication.old.invalidateFrozenRoleCapability() | |
| 231 | - | } | |
| 199 | + | unregisterAttachmentSessionLocked(publication.source, req.next) | |
| 232 | 200 | } | |
| 201 | + | registerAttachmentSessionLocked(req.target, req.next) | |
| 202 | + | publication.nextGeneration = req.next.connectionGeneration.Add(1) | |
| 233 | 203 | req.next.setSession(req.target) | |
| 234 | 204 | } | |
| 235 | 205 | ||
| 236 | - | // buildAttachmentPostcommitPlanLocked binds coordinator lifecycle cleanup and | |
| 237 | - | // constructs the exact published tokens after ownership has committed. | |
| 238 | 206 | func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) attachmentTransitionResult { | |
| 239 | 207 | req := publication.req | |
| 240 | 208 | result := attachmentTransitionResult{} | |
| 241 | - | if publication.source != req.target && req.expectedRole == attachmentActive && publication.sourceCoordinator != nil { | |
| 209 | + | if publication.source != req.target && publication.sourceCoordinator != nil { | |
| 242 | 210 | result.cleanups = append(result.cleanups, publication.sourceCoordinator.beginDetachLocked(req.next)) | |
| 243 | 211 | } | |
| 244 | 212 | var lease *attachmentLease | |
| 245 | - | if publication.targetCoordinator != nil { | |
| 246 | - | var cleanup renderLifecycleCleanup | |
| 247 | - | cleanup, lease = publication.targetCoordinator.beginReplaceLocked(publication.old, req.next, req.ready) | |
| 248 | - | result.cleanups = append(result.cleanups, cleanup) | |
| 249 | - | } | |
| 250 | - | result.published = attachmentRoleToken{ | |
| 251 | - | sess: req.target, | |
| 252 | - | ac: req.next, | |
| 253 | - | role: req.targetRole, | |
| 254 | - | generation: publication.nextGeneration, | |
| 255 | - | transport: req.expectedTransport, | |
| 256 | - | lease: lease, | |
| 257 | - | rebase: publication.source != req.target && req.expectedRole == attachmentActive, | |
| 258 | - | } | |
| 259 | - | if publication.displacedGeneration != 0 { | |
| 260 | - | if publication.terminalDisplaced { | |
| 261 | - | result.terminalDisplaced = detachedAttachmentSnapshot{ac: publication.old, transport: publication.displacedTransport} | |
| 213 | + | if publication.targetCoordinator != nil && !req.preserveAttachment { | |
| 214 | + | if publication.source == req.target { | |
| 215 | + | lease = publication.targetCoordinator.rebindAttachmentWithReadinessLocked(req.next, req.ready) | |
| 262 | 216 | } else { | |
| 263 | - | result.displaced = attachmentRoleToken{ | |
| 264 | - | sess: req.target, | |
| 265 | - | ac: publication.old, | |
| 266 | - | role: attachmentSnatched, | |
| 267 | - | generation: publication.displacedGeneration, | |
| 268 | - | transport: publication.displacedTransport, | |
| 269 | - | } | |
| 270 | - | result.displacedInterrupted = req.expectedTargetTransportInterrupted | |
| 217 | + | lease = publication.targetCoordinator.attachWithReadinessLocked(req.next, req.ready) | |
| 271 | 218 | } | |
| 272 | 219 | } | |
| 273 | - | // Membership, currentSession, lease, generation, and both exact admission | |
| 274 | - | // capabilities become visible as one frozen publication. The gates remain | |
| 275 | - | // frozen until every architecture lock has been released. | |
| 276 | - | if req.roleEffectsFrozen { | |
| 277 | - | req.next.publishFrozenRoleCapability(result.published) | |
| 278 | - | if result.displaced.ac != nil { | |
| 279 | - | result.displaced.ac.publishFrozenRoleCapability(result.displaced) | |
| 280 | - | } | |
| 220 | + | result.published = attachmentConnectionToken{ | |
| 221 | + | sess: req.target, ac: req.next, | |
| 222 | + | generation: publication.nextGeneration, | |
| 223 | + | transport: req.expectedTransport, lease: lease, | |
| 224 | + | rebase: publication.source != req.target, | |
| 225 | + | } | |
| 226 | + | if req.attachmentEffectsFrozen { | |
| 227 | + | req.next.publishFrozenAttachmentCapability(result.published) | |
| 281 | 228 | } | |
| 282 | 229 | return result | |
| 283 | 230 | } | |
| 284 | 231 | ||
| 285 | - | // publishAttachmentTransitionPrelocked performs only non-failing metadata, | |
| 286 | - | // ownership, generation, and coordinator-lease publication. The caller retains | |
| 287 | - | // every lock required by validateAttachmentTransitionPrelocked. | |
| 288 | 232 | func (d *Daemon) publishAttachmentTransitionPrelocked(publication *attachmentPublication) attachmentTransitionResult { | |
| 289 | - | if d.applyTargetStateLocked(publication) { | |
| 233 | + | if publication.req.preserveAttachment { | |
| 234 | + | d.applyTargetStateLocked(publication) | |
| 290 | 235 | return attachmentTransitionResult{published: *publication.req.sourceToken} | |
| 291 | 236 | } | |
| 292 | 237 | publishAttachmentOwnershipLocked(publication) | |
| 238 | + | d.applyTargetStateLocked(publication) | |
| 293 | 239 | return buildAttachmentPostcommitPlanLocked(publication) | |
| 294 | 240 | } | |
| 295 | 241 | ||
| 296 | - | // transitionAttachmentRoutedLocked is the ordinary lock-acquiring wrapper for | |
| 297 | - | // the composable prelocked validation/publication seam. Caller holds d.mu and | |
| 298 | - | // notices.routingMu; this function acquires ordered source/target session locks. | |
| 299 | 242 | func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | |
| 300 | 243 | source := req.source | |
| 301 | 244 | if source == nil { | |
| @@ -315,15 +258,7 @@ func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionReques | |||
| 315 | 258 | return d.publishAttachmentTransitionPrelocked(publication), nil | |
| 316 | 259 | } | |
| 317 | 260 | ||
| 318 | - | func validAttachmentTransitionRole(role attachmentRole, expected bool) bool { | |
| 319 | - | if role == attachmentActive || role == attachmentSnatched { | |
| 320 | - | return true | |
| 321 | - | } | |
| 322 | - | return expected && role == attachmentDetached | |
| 323 | - | } | |
| 324 | - | ||
| 325 | 261 | // lockAttachmentSessions gives every two-session transition one stable order. | |
| 326 | - | // Session IDs are immutable and unique while their lifecycles are registered. | |
| 327 | 262 | func lockAttachmentSessions(a, b attachmentSession) func() { | |
| 328 | 263 | aCore := attachmentSessionCore(a) | |
| 329 | 264 | bCore := attachmentSessionCore(b) | |
| @@ -347,7 +282,7 @@ func lockAttachmentSessions(a, b attachmentSession) func() { | |||
| 347 | 282 | } | |
| 348 | 283 | ||
| 349 | 284 | // lockAttachmentCoordinators follows the same immutable session-ID order as | |
| 350 | - | // lockAttachmentSessions. Callers already hold the ordered session locks. | |
| 285 | + | // lockAttachmentSessions. Callers already hold ordered session locks. | |
| 351 | 286 | func lockAttachmentCoordinators(a attachmentSession, aCoordinator *renderCoordinator, b attachmentSession, bCoordinator *renderCoordinator) func() { | |
| 352 | 287 | if aCoordinator == nil && bCoordinator == nil { | |
| 353 | 288 | return func() {} | |
M internal/usecase/daemon/attention.go +16 -15
| @@ -23,13 +23,16 @@ func (d *Daemon) noteAttention(sess *session, tb *tab) { | |||
| 23 | 23 | } | |
| 24 | 24 | ||
| 25 | 25 | sess.mu.Lock() | |
| 26 | - | visible := sess.client != nil && sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb | |
| 27 | 26 | if !tb.attention { | |
| 28 | 27 | tb.attentionAt = now | |
| 29 | 28 | } | |
| 30 | 29 | tb.attention = true | |
| 31 | - | if visible { | |
| 32 | - | tb.attentionVisiblePaint = true | |
| 30 | + | for _, attachment := range sess.snapshotAttachmentsLocked() { | |
| 31 | + | view := attachment.viewSnapshot() | |
| 32 | + | if domain.TabStableID(tb.stableID) == view.tabID { | |
| 33 | + | tb.attentionVisiblePaint = true | |
| 34 | + | break | |
| 35 | + | } | |
| 33 | 36 | } | |
| 34 | 37 | sess.mu.Unlock() | |
| 35 | 38 | ||
| @@ -47,16 +50,16 @@ func (d *Daemon) noteAttention(sess *session, tb *tab) { | |||
| 47 | 50 | // happened — it also covers "no target exists", which is routine and not an | |
| 48 | 51 | // error. Only a failure to reach a target that does exist is a genuine error. | |
| 49 | 52 | func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error { | |
| 50 | - | return d.jumpAttentionForRole(sess, ac, attachmentRoleToken{}) | |
| 53 | + | return d.jumpAttentionForAttachment(sess, ac, attachmentConnectionToken{}) | |
| 51 | 54 | } | |
| 52 | 55 | ||
| 53 | - | func (d *Daemon) jumpAttentionForRole(sess *session, ac *attachedClient, token attachmentRoleToken) error { | |
| 56 | + | func (d *Daemon) jumpAttentionForAttachment(sess *session, ac *attachedClient, token attachmentConnectionToken) error { | |
| 54 | 57 | if sess == nil || ac == nil { | |
| 55 | 58 | return nil | |
| 56 | 59 | } | |
| 57 | 60 | if idx, ok := oldestAttentionTab(sess); ok { | |
| 58 | - | if sess.switchTab(idx) { | |
| 59 | - | d.activateTab(sess, sess.activeTab()) | |
| 61 | + | if sess.switchAttachmentTab(ac, idx) { | |
| 62 | + | d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(ac), false, ac, token.lease) | |
| 60 | 63 | d.invalidateRender(sess, ac, true, "attention.go") | |
| 61 | 64 | } | |
| 62 | 65 | return nil | |
| @@ -70,7 +73,7 @@ func (d *Daemon) jumpAttentionForRole(sess *session, ac *attachedClient, token a | |||
| 70 | 73 | if token.ac == nil { | |
| 71 | 74 | return d.switchToTarget(sess, ac, pickerTarget) | |
| 72 | 75 | } | |
| 73 | - | return d.switchActiveTargetForRole(token, pickerTarget) | |
| 76 | + | return d.switchActiveTargetForAttachment(token, pickerTarget) | |
| 74 | 77 | } | |
| 75 | 78 | ||
| 76 | 79 | func oldestAttentionTab(sess *session) (int, bool) { | |
| @@ -127,10 +130,7 @@ func oldestAttentionTabWithTime(sess *session) (int, time.Time, bool) { | |||
| 127 | 130 | } | |
| 128 | 131 | ||
| 129 | 132 | func (d *Daemon) repaintAttachedClients(sess *session) { | |
| 130 | - | sess.mu.Lock() | |
| 131 | - | ac := sess.client | |
| 132 | - | sess.mu.Unlock() | |
| 133 | - | if ac != nil { | |
| 133 | + | for _, ac := range sess.snapshotAttachments() { | |
| 134 | 134 | d.invalidateRender(sess, ac, false, "attention.go") | |
| 135 | 135 | } | |
| 136 | 136 | } | |
| @@ -145,13 +145,14 @@ func (d *Daemon) repaintAllAttachedClients() { | |||
| 145 | 145 | } | |
| 146 | 146 | } | |
| 147 | 147 | ||
| 148 | - | func (s *session) ackAttention(tb *tab, visible bool) bool { | |
| 149 | - | if tb == nil { | |
| 148 | + | func (s *session) ackAttention(tb *tab, ac *attachedClient, visible bool) bool { | |
| 149 | + | if tb == nil || ac == nil { | |
| 150 | 150 | return false | |
| 151 | 151 | } | |
| 152 | 152 | s.mu.Lock() | |
| 153 | 153 | defer s.mu.Unlock() | |
| 154 | - | if s.active < 0 || s.active >= len(s.tabs) || s.tabs[s.active] != tb || !tb.attention { | |
| 154 | + | view := ac.viewSnapshot() | |
| 155 | + | if domain.TabStableID(tb.stableID) != view.tabID || !tb.attention { | |
| 155 | 156 | return false | |
| 156 | 157 | } | |
| 157 | 158 | if tb.attentionVisiblePaint && !visible { | |
M internal/usecase/daemon/attention_test.go +27 -27
| @@ -23,7 +23,7 @@ func TestNoteAttentionFlagsBackgroundTab(t *testing.T) { | |||
| 23 | 23 | d, sess, _, _, releases := newManualTabSession(t, 2) | |
| 24 | 24 | defer releases[0]() | |
| 25 | 25 | defer releases[1]() | |
| 26 | - | sess.active = 0 | |
| 26 | + | selectTestAttachmentTab(sess, 0) | |
| 27 | 27 | ||
| 28 | 28 | d.noteAttention(sess, sess.tabs[1]) | |
| 29 | 29 | ||
| @@ -37,7 +37,7 @@ func TestNoteAttentionFlagsBackgroundTab(t *testing.T) { | |||
| 37 | 37 | func TestNoteAttentionFlagsVisibleTabUntilPainted(t *testing.T) { | |
| 38 | 38 | d, sess, _, _, releases := newManualTabSession(t, 1) | |
| 39 | 39 | defer releases[0]() | |
| 40 | - | sess.active = 0 | |
| 40 | + | selectTestAttachmentTab(sess, 0) | |
| 41 | 41 | ||
| 42 | 42 | d.noteAttention(sess, sess.tabs[0]) | |
| 43 | 43 | ||
| @@ -50,7 +50,7 @@ func TestNoteAttentionFlagsVisibleTabUntilPainted(t *testing.T) { | |||
| 50 | 50 | func TestPaintDoesNotAckActiveAttentionOnBlankPulseFrame(t *testing.T) { | |
| 51 | 51 | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |
| 52 | 52 | defer releases[0]() | |
| 53 | - | sess.active = 0 | |
| 53 | + | selectTestAttachmentTab(sess, 0) | |
| 54 | 54 | d.setAttentionFrame(0) | |
| 55 | 55 | d.noteAttention(sess, sess.tabs[0]) | |
| 56 | 56 | ||
| @@ -86,7 +86,7 @@ func TestPTYReaderActiveVisibleAgentNotificationRendersBellBeforeAck(t *testing. | |||
| 86 | 86 | t.Run(tt.name, func(t *testing.T) { | |
| 87 | 87 | pty := newScriptPTY([][]byte{tt.data}) | |
| 88 | 88 | d, sess, ac, sends := newManualSessionWithPTYs(t, pty) | |
| 89 | - | sess.active = 0 | |
| 89 | + | selectTestAttachmentTab(sess, 0) | |
| 90 | 90 | d.setAttentionFrame(1) | |
| 91 | 91 | ||
| 92 | 92 | d.sessWg.Add(1) | |
| @@ -119,7 +119,7 @@ func TestPTYReaderActiveVisibleAgentNotificationRendersBellBeforeAck(t *testing. | |||
| 119 | 119 | func TestNoteAttentionFlagsDetachedActiveTab(t *testing.T) { | |
| 120 | 120 | d, sess, ac, _, releases := newManualTabSession(t, 1) | |
| 121 | 121 | defer releases[0]() | |
| 122 | - | sess.active = 0 | |
| 122 | + | selectTestAttachmentTab(sess, 0) | |
| 123 | 123 | require.True(t, sess.detachIfCurrent(ac)) | |
| 124 | 124 | ||
| 125 | 125 | d.noteAttention(sess, sess.tabs[0]) | |
| @@ -135,7 +135,7 @@ func TestPTYReaderBellMarksBackgroundTab(t *testing.T) { | |||
| 135 | 135 | p2 := newScriptPTY([][]byte{[]byte("\a")}) | |
| 136 | 136 | d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2) | |
| 137 | 137 | defer releasePTY1() | |
| 138 | - | sess.active = 0 | |
| 138 | + | selectTestAttachmentTab(sess, 0) | |
| 139 | 139 | ||
| 140 | 140 | d.sessWg.Add(1) | |
| 141 | 141 | go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane()) | |
| @@ -161,7 +161,7 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) { | |||
| 161 | 161 | d, sess, _, _, releases := newManualTabSession(t, 2) | |
| 162 | 162 | defer releases[0]() | |
| 163 | 163 | defer releases[1]() | |
| 164 | - | sess.active = 0 | |
| 164 | + | selectTestAttachmentTab(sess, 0) | |
| 165 | 165 | ||
| 166 | 166 | trW := portsmocks.NewMockTransport(t) | |
| 167 | 167 | block := make(chan struct{}) | |
| @@ -176,7 +176,7 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) { | |||
| 176 | 176 | sctxW, cancelW := context.WithCancel(d.serveCtx) | |
| 177 | 177 | t.Cleanup(cancelW) | |
| 178 | 178 | tabW := newTestTabWithContext(newScriptPTY(nil), sctxW, cancelW) | |
| 179 | - | sessW := &session{sessionCore: sessionCore{id: "wedged", name: "wedged", client: acW}, ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}} | |
| 179 | + | sessW := &session{sessionCore: sessionCore{id: "wedged", name: "wedged", attachments: map[*attachedClient]struct{}{acW: {}}}, ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}} | |
| 180 | 180 | acW.setSession(sessW) | |
| 181 | 181 | acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW}, nil) | |
| 182 | 182 | d.sessions[sessW.id] = sessW | |
| @@ -235,7 +235,7 @@ func TestAckAttentionClearsOnlyPaintedVisibleTab(t *testing.T) { | |||
| 235 | 235 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |
| 236 | 236 | defer releases[0]() | |
| 237 | 237 | defer releases[1]() | |
| 238 | - | sess.active = 0 | |
| 238 | + | selectTestAttachmentTab(sess, 0) | |
| 239 | 239 | sess.mu.Lock() | |
| 240 | 240 | sess.tabs[0].attention = true | |
| 241 | 241 | sess.tabs[0].attentionAt = time.Unix(1, 0) | |
| @@ -266,7 +266,7 @@ func TestPaintPreservesBackgroundAttention(t *testing.T) { | |||
| 266 | 266 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |
| 267 | 267 | defer releases[0]() | |
| 268 | 268 | defer releases[1]() | |
| 269 | - | sess.active = 0 | |
| 269 | + | selectTestAttachmentTab(sess, 0) | |
| 270 | 270 | sess.mu.Lock() | |
| 271 | 271 | sess.tabs[1].attention = true | |
| 272 | 272 | sess.tabs[1].attentionAt = time.Unix(2, 0) | |
| @@ -286,13 +286,13 @@ func TestSwitchTabClearsAttentionEndToEnd(t *testing.T) { | |||
| 286 | 286 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |
| 287 | 287 | defer releases[0]() | |
| 288 | 288 | defer releases[1]() | |
| 289 | - | sess.active = 0 | |
| 289 | + | selectTestAttachmentTab(sess, 0) | |
| 290 | 290 | sess.mu.Lock() | |
| 291 | 291 | sess.tabs[1].attention = true | |
| 292 | 292 | sess.tabs[1].attentionAt = time.Unix(2, 0) | |
| 293 | 293 | sess.mu.Unlock() | |
| 294 | 294 | ||
| 295 | - | require.True(t, sess.switchTab(1)) | |
| 295 | + | require.True(t, selectTestAttachmentTab(sess, 1)) | |
| 296 | 296 | d.paint(sess, ac, true, nil) | |
| 297 | 297 | data := mustOutputData(t, sends) | |
| 298 | 298 | // As above: the deferred all-clients repaint has nothing new to say about | |
| @@ -314,7 +314,7 @@ func TestCloseRingingTabClearsClientBar(t *testing.T) { | |||
| 314 | 314 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |
| 315 | 315 | defer releases[0]() | |
| 316 | 316 | defer releases[1]() | |
| 317 | - | sess.active = 0 | |
| 317 | + | selectTestAttachmentTab(sess, 0) | |
| 318 | 318 | sess.mu.Lock() | |
| 319 | 319 | ringing := sess.tabs[1] | |
| 320 | 320 | ringing.attention = true | |
| @@ -340,7 +340,7 @@ func TestAnimationRepaintConfinedToBarRows(t *testing.T) { | |||
| 340 | 340 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |
| 341 | 341 | defer releases[0]() | |
| 342 | 342 | defer releases[1]() | |
| 343 | - | sess.active = 0 | |
| 343 | + | selectTestAttachmentTab(sess, 0) | |
| 344 | 344 | sess.tabs[0].focusedPane().screen.Write([]byte("MIDSCREENMARKER")) | |
| 345 | 345 | sess.mu.Lock() | |
| 346 | 346 | sess.tabs[1].attention = true | |
| @@ -374,7 +374,7 @@ func TestComposeFrameAttentionFrameChangeDamagesOnlyBars(t *testing.T) { | |||
| 374 | 374 | d, sess, _, _, releases := newManualTabSession(t, 2) | |
| 375 | 375 | defer releases[0]() | |
| 376 | 376 | defer releases[1]() | |
| 377 | - | sess.active = 0 | |
| 377 | + | selectTestAttachmentTab(sess, 0) | |
| 378 | 378 | sess.mu.Lock() | |
| 379 | 379 | sess.tabs[1].attention = true | |
| 380 | 380 | sess.tabs[1].attentionAt = time.Unix(1, 0) | |
| @@ -446,7 +446,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) { | |||
| 446 | 446 | sctxB, cancelB := context.WithCancel(d.serveCtx) | |
| 447 | 447 | t.Cleanup(cancelB) | |
| 448 | 448 | tbB := newTestTabWithContext(pB, sctxB, cancelB) | |
| 449 | - | sessB := &session{sessionCore: sessionCore{id: "sessB", name: "other", client: acB}, ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}} | |
| 449 | + | sessB := &session{sessionCore: sessionCore{id: "sessB", name: "other", attachments: map[*attachedClient]struct{}{acB: {}}}, ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}} | |
| 450 | 450 | acB.setSession(sessB) | |
| 451 | 451 | acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil) | |
| 452 | 452 | d.sessions[sessB.id] = sessB | |
| @@ -465,13 +465,13 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) { | |||
| 465 | 465 | func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) { | |
| 466 | 466 | d, sess, ac, sends, releases := newManualTabSession(t, 3) | |
| 467 | 467 | sess.mu.Lock() | |
| 468 | - | sess.client = ac | |
| 468 | + | sess.registerAttachmentLocked(ac) | |
| 469 | 469 | sess.mu.Unlock() | |
| 470 | 470 | d.ptys = newBlockingOpenFactory(t, d) | |
| 471 | 471 | defer releases[0]() | |
| 472 | 472 | defer releases[1]() | |
| 473 | 473 | defer releases[2]() | |
| 474 | - | sess.active = 0 | |
| 474 | + | selectTestAttachmentTab(sess, 0) | |
| 475 | 475 | sess.mu.Lock() | |
| 476 | 476 | sess.tabs[1].attention = true | |
| 477 | 477 | sess.tabs[1].attentionAt = time.Unix(20, 0) | |
| @@ -481,8 +481,8 @@ func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) { | |||
| 481 | 481 | ||
| 482 | 482 | d.handleInput(sess, ac, []byte("\x1ba")) | |
| 483 | 483 | ||
| 484 | - | require.Equal(t, 2, activeTabIndex(sess)) | |
| 485 | - | requireFloatingInitialized(t, sess.activeTab()) | |
| 484 | + | require.Equal(t, 2, testAttachmentTabIndex(sess)) | |
| 485 | + | requireFloatingInitialized(t, testAttachmentTab(sess)) | |
| 486 | 486 | _ = mustOutputData(t, sends) | |
| 487 | 487 | } | |
| 488 | 488 | ||
| @@ -512,9 +512,9 @@ func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) { | |||
| 512 | 512 | require.NoError(t, d.jumpAttention(sess1, ac)) | |
| 513 | 513 | ||
| 514 | 514 | require.Same(t, sess2, ac.currentSession()) | |
| 515 | - | require.Same(t, ac, sess2.client) | |
| 516 | - | require.Nil(t, sess1.client) | |
| 517 | - | require.Equal(t, 1, activeTabIndex(sess2)) | |
| 515 | + | require.Contains(t, sess2.snapshotAttachments(), ac) | |
| 516 | + | require.Empty(t, sess1.snapshotAttachments()) | |
| 517 | + | require.Equal(t, 1, testAttachmentTabIndex(sess2)) | |
| 518 | 518 | _ = mustOutputData(t, sends) | |
| 519 | 519 | } | |
| 520 | 520 | ||
| @@ -522,12 +522,12 @@ func TestJumpAttentionNoopsWithNoBells(t *testing.T) { | |||
| 522 | 522 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |
| 523 | 523 | defer releases[0]() | |
| 524 | 524 | defer releases[1]() | |
| 525 | - | sess.active = 0 | |
| 525 | + | selectTestAttachmentTab(sess, 0) | |
| 526 | 526 | ||
| 527 | 527 | require.NoError(t, d.jumpAttention(sess, ac), "no target to jump to is not a failure") | |
| 528 | 528 | ||
| 529 | 529 | require.Same(t, sess, ac.currentSession()) | |
| 530 | - | require.Equal(t, 0, activeTabIndex(sess)) | |
| 530 | + | require.Equal(t, 0, testAttachmentTabIndex(sess)) | |
| 531 | 531 | select { | |
| 532 | 532 | case f := <-sends: | |
| 533 | 533 | t.Fatalf("unexpected frame on no-op jump: %v", f.Type) | |
| @@ -556,7 +556,7 @@ func TestJumpAttentionSwitchFailureReportsNotice(t *testing.T) { | |||
| 556 | 556 | d.sessions[sess2.id] = sess2 | |
| 557 | 557 | ||
| 558 | 558 | sess1.mu.Lock() | |
| 559 | - | sess1.client = nil | |
| 559 | + | clearAttachmentsForTestLocked(sess1) | |
| 560 | 560 | sess1.mu.Unlock() | |
| 561 | 561 | ||
| 562 | 562 | err := d.jumpAttention(sess1, ac) | |
| @@ -576,7 +576,7 @@ func TestAttentionAnimatorParksAdvancesAndResets(t *testing.T) { | |||
| 576 | 576 | defer releases[1]() | |
| 577 | 577 | clk := newManualAttentionClock() | |
| 578 | 578 | d.clock = clk | |
| 579 | - | sess.active = 0 | |
| 579 | + | selectTestAttachmentTab(sess, 0) | |
| 580 | 580 | ||
| 581 | 581 | ctx, cancel := context.WithCancel(context.Background()) | |
| 582 | 582 | done := make(chan struct{}) | |
M internal/usecase/daemon/bar_refresh.go +13 -11
| @@ -122,15 +122,11 @@ func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScrip | |||
| 122 | 122 | // slice under sess.mu (daemon.go, resume.go), and nothing mutates the | |
| 123 | 123 | // backing array in place, so capturing the header here is safe. | |
| 124 | 124 | env := sess.env | |
| 125 | - | active := sess.active | |
| 126 | - | var tb *tab | |
| 127 | - | if active >= 0 && active < len(sess.tabs) { | |
| 128 | - | tb = sess.tabs[active] | |
| 129 | - | } | |
| 130 | - | ac := sess.client | |
| 125 | + | attachments := sess.snapshotAttachmentsLocked() | |
| 131 | 126 | ctx.PaneCWD = sess.cwd | |
| 132 | 127 | sess.mu.Unlock() | |
| 133 | - | if ac == nil || tb == nil { | |
| 128 | + | tb := sess.firstTab() | |
| 129 | + | if len(attachments) == 0 || tb == nil { | |
| 134 | 130 | return ctx, env, false | |
| 135 | 131 | } | |
| 136 | 132 | tb.mu.Lock() | |
| @@ -413,11 +409,17 @@ func (d *Daemon) pokeSessionRender(sess *session) { | |||
| 413 | 409 | if sess == nil { | |
| 414 | 410 | return | |
| 415 | 411 | } | |
| 416 | - | sess.mu.Lock() | |
| 417 | - | ac := sess.client | |
| 418 | - | sess.mu.Unlock() | |
| 419 | 412 | // A headless session can still supply a picker preview to an attachment | |
| 420 | 413 | // owned by another session. Its coordinator fans this wake out to those | |
| 421 | 414 | // preview subscribers even though no primary attachment is bound. | |
| 422 | - | d.invalidateRender(sess, ac, false, "bar_refresh.go") | |
| 415 | + | attachments := sess.snapshotAttachments() | |
| 416 | + | if len(attachments) == 0 { | |
| 417 | + | if rc := sess.renderCoordinator(); rc != nil { | |
| 418 | + | rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "bar_refresh.go"}) | |
| 419 | + | } | |
| 420 | + | return | |
| 421 | + | } | |
| 422 | + | for _, ac := range attachments { | |
| 423 | + | d.invalidateRender(sess, ac, false, "bar_refresh.go") | |
| 424 | + | } | |
| 423 | 425 | } | |
M internal/usecase/daemon/bar_refresh_test.go +21 -22
| @@ -64,7 +64,7 @@ func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T) | |||
| 64 | 64 | r := &fakeBarRunner{outs: []string{"top1", "bot1", "top2", "bot2"}} | |
| 65 | 65 | d := newBarRefreshTestDaemon(r, 100*time.Millisecond) | |
| 66 | 66 | sess := newBarRefreshTestSession() | |
| 67 | - | sess.client = &attachedClient{size: domain.Size{Cols: 120, Rows: 24}} | |
| 67 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 120, Rows: 24}}) | |
| 68 | 68 | ||
| 69 | 69 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false)) | |
| 70 | 70 | waitBarRefreshIdle(t, d) | |
| @@ -85,17 +85,16 @@ func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T) | |||
| 85 | 85 | require.Equal(t, "bot2", state.bottomRight) | |
| 86 | 86 | } | |
| 87 | 87 | ||
| 88 | - | func TestBarScriptContextUsesActivePaneSessionAndClientCols(t *testing.T) { | |
| 88 | + | func TestBarScriptContextUsesFirstOrderedPaneAndClientCols(t *testing.T) { | |
| 89 | 89 | r := &fakeBarRunner{outs: []string{"top", "bottom"}} | |
| 90 | 90 | d := newBarRefreshTestDaemon(r, time.Second) | |
| 91 | 91 | sess := newBarRefreshTestSession() | |
| 92 | 92 | sess.name = "work" | |
| 93 | 93 | sess.cwd = "/repo" | |
| 94 | - | sess.client = &attachedClient{size: domain.Size{Cols: 132, Rows: 40}} | |
| 95 | - | sess.active = 1 | |
| 96 | - | sess.tabs[1].size = domain.Size{Cols: 132, Rows: 38} | |
| 97 | - | active := sess.tabs[1].focusedPane() | |
| 98 | - | active.stableID = "pane-active" | |
| 94 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 132, Rows: 40}}) | |
| 95 | + | sess.tabs[0].size = domain.Size{Cols: 132, Rows: 38} | |
| 96 | + | active := sess.tabs[0].focusedPane() | |
| 97 | + | active.stableID = "pane-default" | |
| 99 | 98 | active.pty = newScriptPTY(nil) | |
| 100 | 99 | d.procCwd = func(pid int) (string, error) { | |
| 101 | 100 | require.Equal(t, 4242, pid) | |
| @@ -107,8 +106,8 @@ func TestBarScriptContextUsesActivePaneSessionAndClientCols(t *testing.T) { | |||
| 107 | 106 | require.Len(t, r.calls, 2) | |
| 108 | 107 | for _, call := range r.calls { | |
| 109 | 108 | require.Equal(t, "work", call.Session) | |
| 110 | - | require.Equal(t, "tab-active", call.Tab) | |
| 111 | - | require.Equal(t, "pane-active", call.Pane) | |
| 109 | + | require.Equal(t, "tab-inactive", call.Tab) | |
| 110 | + | require.Equal(t, "pane-default", call.Pane) | |
| 112 | 111 | require.Equal(t, "/pane-repo", call.PaneCWD) | |
| 113 | 112 | require.Equal(t, 132, call.Cols) | |
| 114 | 113 | } | |
| @@ -131,7 +130,7 @@ func TestBarScriptRefreshSkipsRunnerWhenCommandsDisabled(t *testing.T) { | |||
| 131 | 130 | d.barScripts.cfg.topRight = "" | |
| 132 | 131 | d.barScripts.cfg.bottomRight = "" | |
| 133 | 132 | sess := newBarRefreshTestSession() | |
| 134 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 133 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) | |
| 135 | 134 | d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "old", bottomRight: "old"} | |
| 136 | 135 | ||
| 137 | 136 | require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true)) | |
| @@ -146,7 +145,7 @@ func TestDefaultBarConfigDoesNotInvokeRunner(t *testing.T) { | |||
| 146 | 145 | d := newBarRefreshTestDaemon(r, time.Second) | |
| 147 | 146 | d.barScripts.cfg = barConfigFromDomain(domain.Defaults().Bar) | |
| 148 | 147 | sess := newBarRefreshTestSession() | |
| 149 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 148 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) | |
| 150 | 149 | ||
| 151 | 150 | require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true)) | |
| 152 | 151 | require.Empty(t, r.calls) | |
| @@ -156,7 +155,7 @@ func TestBarScriptForcedRefreshRespectsMinimumInterval(t *testing.T) { | |||
| 156 | 155 | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | |
| 157 | 156 | d := newBarRefreshTestDaemon(r, 5*time.Second) | |
| 158 | 157 | sess := newBarRefreshTestSession() | |
| 159 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 158 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) | |
| 160 | 159 | ||
| 161 | 160 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true)) | |
| 162 | 161 | waitBarRefreshIdle(t, d) | |
| @@ -173,7 +172,7 @@ func TestBarScriptContextChangeDebouncesUntilMinimumInterval(t *testing.T) { | |||
| 173 | 172 | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | |
| 174 | 173 | d := newBarRefreshTestDaemon(r, 5*time.Second) | |
| 175 | 174 | sess := newBarRefreshTestSession() | |
| 176 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 175 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) | |
| 177 | 176 | ||
| 178 | 177 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Now(), false)) | |
| 179 | 178 | waitBarRefreshIdle(t, d) | |
| @@ -191,7 +190,7 @@ func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) { | |||
| 191 | 190 | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | |
| 192 | 191 | d := newBarRefreshTestDaemon(r, 5*time.Second) | |
| 193 | 192 | sess := newBarRefreshTestSession() | |
| 194 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 193 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) | |
| 195 | 194 | ||
| 196 | 195 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false)) | |
| 197 | 196 | waitBarRefreshIdle(t, d) | |
| @@ -219,7 +218,7 @@ func TestRefreshBarScriptsAllSessionsForcesRun(t *testing.T) { | |||
| 219 | 218 | r := &fakeBarRunner{outs: []string{"top1", "bottom1"}} | |
| 220 | 219 | d := newBarRefreshTestDaemon(r, 60*time.Second) | |
| 221 | 220 | sess := newBarRefreshTestSession() | |
| 222 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 221 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) | |
| 223 | 222 | d.sessions = map[domain.SessionID]attachmentSession{sess.id: sess} | |
| 224 | 223 | ||
| 225 | 224 | // Verifies refreshBarScriptsAllSessions iterates every live session and | |
| @@ -252,7 +251,7 @@ func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) { | |||
| 252 | 251 | d := newBarRefreshTestDaemon(r, time.Second) | |
| 253 | 252 | d.log = slog.New(slog.NewTextHandler(&logs, nil)) | |
| 254 | 253 | sess := newBarRefreshTestSession() | |
| 255 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 254 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) | |
| 256 | 255 | ||
| 257 | 256 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true)) | |
| 258 | 257 | waitBarRefreshIdle(t, d) | |
| @@ -287,7 +286,7 @@ func TestBarScriptRunConsumesPendingAfterRunningClears(t *testing.T) { | |||
| 287 | 286 | r := newBlockingBarRunner([]string{"top1", "bottom1", "top2", "bottom2"}) | |
| 288 | 287 | d := newBarRefreshTestDaemon(r, time.Second) | |
| 289 | 288 | sess := newBarRefreshTestSession() | |
| 290 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 289 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) | |
| 291 | 290 | ||
| 292 | 291 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true)) | |
| 293 | 292 | select { | |
| @@ -337,7 +336,7 @@ func TestBarScriptFailureLogsOnceUntilSignatureChanges(t *testing.T) { | |||
| 337 | 336 | d.log = logger | |
| 338 | 337 | d.baseEnv = []string{"PATH=/usr/bin"} | |
| 339 | 338 | sess := newBarRefreshTestSession() | |
| 340 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 339 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) | |
| 341 | 340 | ||
| 342 | 341 | readBuf := func() string { | |
| 343 | 342 | mu.Lock() | |
| @@ -420,11 +419,11 @@ func TestBarScriptRefreshIsPerSession(t *testing.T) { | |||
| 420 | 419 | a := newBarRefreshTestSession() | |
| 421 | 420 | a.id = "a" | |
| 422 | 421 | a.name = "a" | |
| 423 | - | a.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 422 | + | a.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) | |
| 424 | 423 | b := newBarRefreshTestSession() | |
| 425 | 424 | b.id = "b" | |
| 426 | 425 | b.name = "b" | |
| 427 | - | b.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 426 | + | b.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) | |
| 428 | 427 | ||
| 429 | 428 | require.True(t, d.refreshBarScriptsIfDue(a, time.Unix(0, 0), false)) | |
| 430 | 429 | waitBarRefreshIdle(t, d) | |
| @@ -587,7 +586,7 @@ func newBarRefreshTestSession() *session { | |||
| 587 | 586 | inactive := newTabWithStableID("tab-inactive", "pane-inactive", nil, domain.Size{Cols: 80, Rows: 22}) | |
| 588 | 587 | active := newTabWithStableID("tab-active", "pane-active", nil, domain.Size{Cols: 80, Rows: 22}) | |
| 589 | 588 | active.tree.Focus = layout.PaneID("pane-1") | |
| 590 | - | return &session{sessionCore: sessionCore{id: "s", name: "s"}, cwd: "/tmp", tabs: []*tab{inactive, active}, active: 0, ctx: context.Background()} | |
| 589 | + | return &session{sessionCore: sessionCore{id: "s", name: "s"}, cwd: "/tmp", tabs: []*tab{inactive, active}, ctx: context.Background()} | |
| 591 | 590 | } | |
| 592 | 591 | ||
| 593 | 592 | func waitBarRefreshIdle(t *testing.T, d *Daemon) { | |
| @@ -645,7 +644,7 @@ func TestBarScriptsUseSessionEnvNotDaemonBaseEnv(t *testing.T) { | |||
| 645 | 644 | d.baseEnv = tc.baseEnv | |
| 646 | 645 | sess := newBarRefreshTestSession() | |
| 647 | 646 | sess.env = tc.sessEnv | |
| 648 | - | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 647 | + | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) | |
| 649 | 648 | ||
| 650 | 649 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true)) | |
| 651 | 650 | waitBarRefreshIdle(t, d) | |
M internal/usecase/daemon/client.go +109 -98
| @@ -42,15 +42,19 @@ type attachedClient struct { | |||
| 42 | 42 | overlays *overlayRuntime | |
| 43 | 43 | overlayOnce sync.Once | |
| 44 | 44 | clientID [16]byte | |
| 45 | - | // roleGeneration is the wire-facing capability generation. roleEffects is | |
| 46 | - | // the linearization gate for every role-bound observable operation. A role | |
| 45 | + | // connectionGeneration is the wire-facing capability generation. attachmentEffects is | |
| 46 | + | // the linearization gate for every attachment-bound observable operation. An attachment | |
| 47 | 47 | // transition freezes and drains this gate before changing either generation | |
| 48 | 48 | // or the session/coordinator registries. | |
| 49 | - | roleGeneration atomic.Uint64 | |
| 50 | - | roleEffects roleEffectGate | |
| 51 | - | resumeCapable bool | |
| 52 | - | resumeToken uint64 | |
| 53 | - | parked bool | |
| 49 | + | connectionGeneration atomic.Uint64 | |
| 50 | + | attachmentEffects attachmentEffectGate | |
| 51 | + | resumeCapable bool | |
| 52 | + | resumeToken uint64 | |
| 53 | + | // resumeClaimToken is non-zero only between a parked resume claim and its | |
| 54 | + | // successful Welcome. It lets a failed pre-claim handshake restore the old | |
| 55 | + | // credential instead of consuming it. | |
| 56 | + | resumeClaimToken uint64 | |
| 57 | + | parked bool | |
| 54 | 58 | // proxied is negotiated once by Hello and remains immutable for this | |
| 55 | 59 | // attachment, including across transport resume. | |
| 56 | 60 | proxied bool | |
| @@ -75,33 +79,28 @@ type attachedClient struct { | |||
| 75 | 79 | // captureFrames is keyed by pane ownership, not the tab-local PaneID, so | |
| 76 | 80 | // snapshots cannot leak when an attachment switches tabs or sessions. | |
| 77 | 81 | captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held | |
| 78 | - | // initialSnatchedMu elects exactly one reset-panel sender per role generation. | |
| 79 | - | // A Welcome handshake and displaced-client cleanup may both discover the | |
| 80 | - | // same post-transition snatched role; the loser waits for the elected send. | |
| 81 | - | initialSnatchedMu sync.Mutex | |
| 82 | - | initialSnatchedGeneration uint64 | |
| 83 | - | initialSnatchedAttempt *initialSnatchedPanelAttempt | |
| 84 | - | size domain.Size | |
| 85 | - | keys *keys.Router | |
| 86 | - | sess Guarded[attachmentSession] | |
| 87 | - | mouseScan mouse.Scanner | |
| 88 | - | themeMu sync.Mutex | |
| 89 | - | clientTheme themeui.Theme | |
| 90 | - | appliedTheme appliedTheme | |
| 91 | - | lastCursor cursorOut | |
| 92 | - | renderStages renderStageHooks // optional render and handoff observability hooks | |
| 82 | + | size domain.Size | |
| 83 | + | keys *keys.Router | |
| 84 | + | // view is attachment-local navigation state. It is never inferred from a | |
| 85 | + | // session-wide active tab, so multiple attachments can observe different | |
| 86 | + | // tabs and panes without changing shared session ownership. | |
| 87 | + | viewMu sync.Mutex | |
| 88 | + | view attachmentView | |
| 89 | + | sess Guarded[attachmentSession] | |
| 90 | + | mouseScan mouse.Scanner | |
| 91 | + | themeMu sync.Mutex | |
| 92 | + | clientTheme themeui.Theme | |
| 93 | + | appliedTheme appliedTheme | |
| 94 | + | lastCursor cursorOut | |
| 95 | + | renderStages renderStageHooks // optional render and handoff observability hooks | |
| 93 | 96 | // previousSession is guarded independently. It is retained through temporary | |
| 94 | 97 | // setSession(nil) hand-offs and cleared only on terminal teardown. | |
| 95 | 98 | previousSession Guarded[attachmentSession] | |
| 96 | - | // snatchedInputMu serializes the restricted input parser with its delayed | |
| 97 | - | // standalone-ESC callback. It is independent of routing and transport locks; | |
| 98 | - | // role activation and terminal close clear it only after releasing those locks. | |
| 99 | - | snatchedInputMu sync.Mutex | |
| 100 | - | snatchedInputPending []byte | |
| 101 | - | snatchedInputDrain bool | |
| 102 | - | snatchedInputESC pendingByteTimer | |
| 103 | - | linkMu sync.Mutex | |
| 104 | - | sendMu sync.Mutex | |
| 99 | + | linkMu sync.Mutex | |
| 100 | + | sendMu sync.Mutex | |
| 101 | + | // beforeAttachmentTokenValidation is a deterministic lifecycle-race seam | |
| 102 | + | // used only by package tests. | |
| 103 | + | beforeAttachmentTokenValidation func() | |
| 105 | 104 | } | |
| 106 | 105 | ||
| 107 | 106 | type cursorOut struct { | |
| @@ -243,7 +242,7 @@ func (ac *attachedClient) replaceTransport(tr ports.Transport) { | |||
| 243 | 242 | ||
| 244 | 243 | // revokeTransport removes tr only if it is still this attachment's current | |
| 245 | 244 | // link. It returns the revoked transport, so asynchronous retirement can | |
| 246 | - | // close exactly that link without touching a later resume/replacement. | |
| 245 | + | // close exactly that link without touching a later connection incarnation. | |
| 247 | 246 | func (ac *attachedClient) revokeTransport(tr ports.Transport) ports.Transport { | |
| 248 | 247 | if tr == nil { | |
| 249 | 248 | return nil | |
| @@ -256,7 +255,6 @@ func (ac *attachedClient) revokeTransport(tr ports.Transport) ports.Transport { | |||
| 256 | 255 | ac.tr = nil | |
| 257 | 256 | ac.transportIncarnation++ | |
| 258 | 257 | ac.linkMu.Unlock() | |
| 259 | - | ac.clearSnatchedInput() | |
| 260 | 258 | return tr | |
| 261 | 259 | } | |
| 262 | 260 | ||
| @@ -357,7 +355,7 @@ func (ac *attachedClient) sendExpectedTransport(expected transportSnapshot, f po | |||
| 357 | 355 | // attachment's current transport incarnation and admits ticket's interruptible | |
| 358 | 356 | // transport send. The caller must already hold ac.sendMu and, on success, owns | |
| 359 | 357 | // both the send and its matching ticket.endTransportSend. | |
| 360 | - | func (ac *attachedClient) beginExpectedTransportSendLocked(expected transportSnapshot, ticket *roleEffectTicket) error { | |
| 358 | + | func (ac *attachedClient) beginExpectedTransportSendLocked(expected transportSnapshot, ticket *attachmentEffectTicket) error { | |
| 361 | 359 | if expected.transport == nil || !ac.transportSnapshotCurrent(expected) { | |
| 362 | 360 | return errTransportReplaced | |
| 363 | 361 | } | |
| @@ -367,10 +365,10 @@ func (ac *attachedClient) beginExpectedTransportSendLocked(expected transportSna | |||
| 367 | 365 | return nil | |
| 368 | 366 | } | |
| 369 | 367 | ||
| 370 | - | func (ac *attachedClient) sendExpectedTransportForRole(expected transportSnapshot, f ports.Frame, ticket *roleEffectTicket) error { | |
| 368 | + | func (ac *attachedClient) sendExpectedTransportForAttachment(expected transportSnapshot, f ports.Frame, ticket *attachmentEffectTicket) error { | |
| 371 | 369 | ac.sendMu.Lock() | |
| 372 | 370 | defer ac.sendMu.Unlock() | |
| 373 | - | // A role-bound send requires a live ticket, and reports every rejection, | |
| 371 | + | // A attachment-bound send requires a live ticket, and reports every rejection, | |
| 374 | 372 | // including a replaced transport, as a lost attachment transition. | |
| 375 | 373 | if ticket == nil { | |
| 376 | 374 | return errAttachmentTransition | |
| @@ -528,15 +526,14 @@ type attachClientOptions struct { | |||
| 528 | 526 | proxied bool | |
| 529 | 527 | } | |
| 530 | 528 | ||
| 531 | - | func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient, error) { | |
| 529 | + | func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, error) { | |
| 532 | 530 | d.mu.Lock() | |
| 533 | 531 | ac := d.prepareAttachedClientLocked(tr, sz, opts) | |
| 534 | 532 | d.mu.Unlock() | |
| 535 | 533 | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 536 | - | target: sess, | |
| 537 | - | next: ac, | |
| 538 | - | expectedRole: attachmentDetached, | |
| 539 | - | targetRole: attachmentActive, | |
| 534 | + | target: sess, | |
| 535 | + | next: ac, | |
| 536 | + | ||
| 540 | 537 | expectedTransport: ac.transportSnapshot(), | |
| 541 | 538 | ready: false, | |
| 542 | 539 | }) | |
| @@ -544,11 +541,11 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, | |||
| 544 | 541 | if tr != nil { | |
| 545 | 542 | _ = tr.Close() | |
| 546 | 543 | } | |
| 547 | - | return nil, nil, err | |
| 544 | + | return nil, err | |
| 548 | 545 | } | |
| 549 | 546 | d.finishAttachedClient(sess, ac, opts) | |
| 550 | 547 | d.deferAttachmentTransitionCleanups(result) | |
| 551 | - | return ac, result.displaced.ac, nil | |
| 548 | + | return ac, nil | |
| 552 | 549 | } | |
| 553 | 550 | ||
| 554 | 551 | func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) { | |
| @@ -558,7 +555,7 @@ func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts at | |||
| 558 | 555 | } | |
| 559 | 556 | ||
| 560 | 557 | // prepareAttachedClientLocked allocates one detached attachment. Caller holds | |
| 561 | - | // d.mu only to allocate its resume token; role publication happens later after | |
| 558 | + | // d.mu only to allocate its resume token; attachment publication happens later after | |
| 562 | 559 | // the caller releases every architecture lock. | |
| 563 | 560 | func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size, opts attachClientOptions) *attachedClient { | |
| 564 | 561 | resumeToken := uint64(0) | |
| @@ -583,24 +580,21 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size, | |||
| 583 | 580 | return ac | |
| 584 | 581 | } | |
| 585 | 582 | ||
| 586 | - | // attachCoordinator is the sole direct attachment handoff. It creates at | |
| 587 | - | // most one coordinator for sess and changes its identity before any caller | |
| 588 | - | // can publish resize or render state for the new client. | |
| 589 | - | func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient, ready bool) *renderCoordinator { | |
| 590 | - | rc, cleanup := d.attachCoordinatorDeferred(sess, old, current, ready) | |
| 583 | + | // attachCoordinator installs the coordinator lease for an attachment. Session | |
| 584 | + | // membership remains independent, so installing one attachment never displaces | |
| 585 | + | // another. | |
| 586 | + | func (d *Daemon) attachCoordinator(sess *session, current *attachedClient, ready bool) *renderCoordinator { | |
| 587 | + | rc, cleanup := d.attachCoordinatorDeferred(sess, current, ready) | |
| 591 | 588 | cleanup.finish() | |
| 592 | 589 | return rc | |
| 593 | 590 | } | |
| 594 | 591 | ||
| 595 | - | func (d *Daemon) attachCoordinatorDeferred(sess *session, old, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) { | |
| 592 | + | func (d *Daemon) attachCoordinatorDeferred(sess *session, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) { | |
| 596 | 593 | rc := d.ensureRenderCoordinator(sess) | |
| 597 | - | var cleanup renderLifecycleCleanup | |
| 598 | - | if old != nil { | |
| 599 | - | cleanup = rc.beginReplace(old, current, ready) | |
| 600 | - | } else if current != nil { | |
| 594 | + | if current != nil { | |
| 601 | 595 | rc.attachWithReadiness(current, ready) | |
| 602 | 596 | } | |
| 603 | - | return rc, cleanup | |
| 597 | + | return rc, renderLifecycleCleanup{} | |
| 604 | 598 | } | |
| 605 | 599 | ||
| 606 | 600 | // ensureRenderCoordinator publishes the session's single coordinator without | |
| @@ -630,25 +624,42 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess | |||
| 630 | 624 | clock: d.clock, | |
| 631 | 625 | observer: d.runtimeObserver, | |
| 632 | 626 | wake: func(w renderWake) { | |
| 633 | - | // The wake retains the exact attachment lease. paint repeats this | |
| 634 | - | // identity check under sendMu before composing any snapshot. | |
| 635 | - | if w.lease != nil && rc.wakeCurrent(w) { | |
| 636 | - | d.paint(entry, w.lease.attachment, w.reset, w.lease) | |
| 627 | + | if !rc.wakeCurrent(w) { | |
| 628 | + | return | |
| 637 | 629 | } | |
| 638 | - | }, | |
| 639 | - | ackReady: func() bool { | |
| 640 | - | entry.core().mu.Lock() | |
| 641 | - | attached := entry.core().client | |
| 642 | - | entry.core().mu.Unlock() | |
| 643 | - | if attached == nil { | |
| 644 | - | return false | |
| 630 | + | // The session snapshot is deterministic. Each paint revalidates the | |
| 631 | + | // attachment's membership and transport fence after that snapshot, so | |
| 632 | + | // a detached peer cannot stop remaining attachments receiving output. | |
| 633 | + | attachments := snapshotAttachmentSession(entry) | |
| 634 | + | for _, attachment := range attachments { | |
| 635 | + | if !rc.wakeCurrent(w) { | |
| 636 | + | return | |
| 637 | + | } | |
| 638 | + | lease := w.attachmentLeases[attachment] | |
| 639 | + | if lease == nil || !rc.leaseCurrent(lease, true) { | |
| 640 | + | continue | |
| 641 | + | } | |
| 642 | + | // Pane damage is session-shared; every fan-out attachment needs a | |
| 643 | + | // complete frame because any peer may capture it first. A handshake | |
| 644 | + | // target is the one synchronous exception: its first paint must finish | |
| 645 | + | // before the handshake releases its lifecycle gate. | |
| 646 | + | fanoutReset := w.reset || len(attachments) > 1 | |
| 647 | + | if w.lease != nil && w.lease.attachment == attachment { | |
| 648 | + | d.paint(entry, attachment, fanoutReset, lease) | |
| 649 | + | } else if len(attachments) > 1 { | |
| 650 | + | go d.paint(entry, attachment, fanoutReset, lease) | |
| 651 | + | } else { | |
| 652 | + | d.paint(entry, attachment, fanoutReset, lease) | |
| 653 | + | } | |
| 645 | 654 | } | |
| 646 | - | attached.sendMu.Lock() | |
| 647 | - | ready := attached.output == nil || !attached.output.atCapacity() | |
| 648 | - | attached.sendMu.Unlock() | |
| 649 | - | return ready | |
| 650 | 655 | }, | |
| 651 | 656 | }) | |
| 657 | + | rc.ackReadyFor = func(attached *attachedClient) bool { | |
| 658 | + | // outputStateStream publishes capacity atomically. Do not take | |
| 659 | + | // attached.sendMu here: a slow transport may be holding it for an | |
| 660 | + | // in-flight Send, and that peer must not gate healthy attachments. | |
| 661 | + | return attached == nil || attached.output == nil || !attached.output.atCapacity() | |
| 662 | + | } | |
| 652 | 663 | installAttachmentRenderCoordinator(entry, rc) | |
| 653 | 664 | return rc | |
| 654 | 665 | } | |
| @@ -660,29 +671,29 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess | |||
| 660 | 671 | // next client (with a different terminal theme) may never have reported. | |
| 661 | 672 | // Mirrors attachClient's reset loop: snapshot sess.tabs under sess.mu, | |
| 662 | 673 | // release it, then take each tb.mu in turn — never holding sess.mu and | |
| 663 | - | // tb.mu together. Guarded against a race with a newer attach: if sess.client | |
| 664 | - | // is non-nil by the time sess.mu is taken, a new client has already attached | |
| 665 | - | // (and run its own attach-time reset), so this call must leave the tabs | |
| 666 | - | // alone rather than clobbering that client's freshly applied colors. | |
| 674 | + | // tb.mu together. Guarded against a race with a newer attachment: if the | |
| 675 | + | // session has gained an attachment by the time sess.mu is taken, that | |
| 676 | + | // attachment has already run its attach-time reset, so this call must leave | |
| 677 | + | // the tabs alone rather than clobbering freshly applied colors. | |
| 667 | 678 | func (d *Daemon) resetScreenDefaultColors(sess *session) { | |
| 668 | 679 | d.applyHostTheme(sess, nil, themeui.Theme{}, true) | |
| 669 | 680 | } | |
| 670 | 681 | ||
| 671 | 682 | // firstPaintForTransition is the only active post-transition paint entry | |
| 672 | - | // point. It rejects a stale role capability before rebasing output and carries | |
| 683 | + | // point. It rejects a stale attachment capability before rebasing output and carries | |
| 673 | 684 | // the exact coordinator lease through every resize and render effect. | |
| 674 | - | func (d *Daemon) firstPaintForTransition(token attachmentRoleToken) bool { | |
| 675 | - | if !token.activeCurrent() { | |
| 685 | + | func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool { | |
| 686 | + | if !token.attachmentCurrent() { | |
| 676 | 687 | return false | |
| 677 | 688 | } | |
| 678 | - | ticket, admitted := token.ac.beginRoleEffect(token) | |
| 689 | + | ticket, admitted := token.ac.beginAttachmentEffect(token) | |
| 679 | 690 | if !admitted { | |
| 680 | 691 | return false | |
| 681 | 692 | } | |
| 682 | 693 | token.effect = ticket | |
| 683 | - | defer token.endRoleEffect() | |
| 684 | - | if d.afterRoleEffectAdmitted != nil { | |
| 685 | - | d.afterRoleEffectAdmitted(token) | |
| 694 | + | defer token.endAttachmentEffect() | |
| 695 | + | if d.afterAttachmentEffectAdmitted != nil { | |
| 696 | + | d.afterAttachmentEffectAdmitted(token) | |
| 686 | 697 | } | |
| 687 | 698 | if token.rebase { | |
| 688 | 699 | if d.beforeFirstPaintSendWait != nil { | |
| @@ -713,7 +724,7 @@ func (d *Daemon) firstPaintForTransition(token attachmentRoleToken) bool { | |||
| 713 | 724 | // firstPaint guarantees the freshly attached client sees the full screen: if | |
| 714 | 725 | // the tab size differs from the client's it resizes first, then immediately | |
| 715 | 726 | // emits a full redraw. It is retained for direct test/headless setup; active | |
| 716 | - | // role transitions use firstPaintForTransition with their exact lease. | |
| 727 | + | // attachment transitions use firstPaintForTransition with their exact lease. | |
| 717 | 728 | func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) { | |
| 718 | 729 | d.firstPaintWithLease(sess, ac, clientSize, nil) | |
| 719 | 730 | } | |
| @@ -723,7 +734,7 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi | |||
| 723 | 734 | // Drained before the early return below so a session without an active tab | |
| 724 | 735 | // cannot swallow the queue. | |
| 725 | 736 | d.drainPendingForFirstPaint(sess, ac) | |
| 726 | - | tb := sess.activeTab() | |
| 737 | + | tb := sess.tabForAttachment(ac) | |
| 727 | 738 | if tb == nil { | |
| 728 | 739 | return | |
| 729 | 740 | } | |
| @@ -748,7 +759,7 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi | |||
| 748 | 759 | if lease == nil { | |
| 749 | 760 | d.invalidateRenderNow(sess, ac, true, "client.go") | |
| 750 | 761 | } else if rc := sess.renderCoordinator(); rc != nil && rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"}) { | |
| 751 | - | rc.fireCurrent(false) | |
| 762 | + | rc.fireCurrentForLease(lease) | |
| 752 | 763 | } | |
| 753 | 764 | } | |
| 754 | 765 | } | |
| @@ -780,17 +791,17 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) | |||
| 780 | 791 | d.invalidateRender(sess, ac, true, "client.go") | |
| 781 | 792 | } | |
| 782 | 793 | ||
| 783 | - | func (d *Daemon) applyThemeForRole(token attachmentRoleToken, msg ports.Theme) bool { | |
| 784 | - | if !token.activeEffect() { | |
| 794 | + | func (d *Daemon) applyThemeForAttachment(token attachmentConnectionToken, msg ports.Theme) bool { | |
| 795 | + | if !token.attachmentEffectCurrent() { | |
| 785 | 796 | return false | |
| 786 | 797 | } | |
| 787 | 798 | clientTheme := themeFromMessage(msg) | |
| 788 | 799 | token.ac.setClientTheme(clientTheme) | |
| 789 | 800 | sess, ok := localSession(token.sess) | |
| 790 | - | if !ok || !token.activeEffect() || !d.applyHostTheme(sess, token.ac, clientTheme, false) { | |
| 801 | + | if !ok || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) { | |
| 791 | 802 | return false | |
| 792 | 803 | } | |
| 793 | - | if !token.activeEffect() { | |
| 804 | + | if !token.attachmentEffectCurrent() { | |
| 794 | 805 | return false | |
| 795 | 806 | } | |
| 796 | 807 | if rc := token.sess.core().coordinator.Load(); rc != nil { | |
| @@ -805,8 +816,8 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||
| 805 | 816 | ||
| 806 | 817 | // handleClientNotice maps the closed client-event enum to daemon-owned notice | |
| 807 | 818 | // content. routingMu makes ownership validation and toast mutation one atomic | |
| 808 | - | // attachment-routing operation: replacement also takes routingMu before | |
| 809 | - | // publishing sess.client. Never retain sess.mu while touching notice or overlay | |
| 819 | + | // attachment-routing operation: attachment publication also takes routingMu | |
| 820 | + | // before changing membership. Never retain sess.mu while touching notice or overlay | |
| 810 | 821 | // state, and retain the routingMu -> sess.mu order used by attachment paths. | |
| 811 | 822 | func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) { | |
| 812 | 823 | if sess == nil || ac == nil { | |
| @@ -816,23 +827,23 @@ func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice po | |||
| 816 | 827 | token := sess.attachmentToken(ac, tr) | |
| 817 | 828 | if token.lease == nil { | |
| 818 | 829 | // Direct test/headless callers retain pointer-based routing. Production | |
| 819 | - | // client frames always carry the exact lease through the role path below. | |
| 830 | + | // client frames always carry the exact lease through the attachment path below. | |
| 820 | 831 | d.handleClientNoticeDirect(sess, ac, notice) | |
| 821 | 832 | return | |
| 822 | 833 | } | |
| 823 | - | d.handleClientNoticeForRole(token, notice) | |
| 834 | + | d.handleClientNoticeForAttachment(token, notice) | |
| 824 | 835 | } | |
| 825 | 836 | ||
| 826 | - | func (d *Daemon) handleClientNoticeForRole(token attachmentRoleToken, notice ports.ClientNotice) { | |
| 837 | + | func (d *Daemon) handleClientNoticeForAttachment(token attachmentConnectionToken, notice ports.ClientNotice) { | |
| 827 | 838 | d.notices.routingMu.Lock() | |
| 828 | - | if !token.activeEffect() { | |
| 839 | + | if !token.attachmentEffectCurrent() { | |
| 829 | 840 | d.notices.routingMu.Unlock() | |
| 830 | 841 | return | |
| 831 | 842 | } | |
| 832 | 843 | if d.notices.beforeClientNoticeMutation != nil { | |
| 833 | 844 | d.notices.beforeClientNoticeMutation() | |
| 834 | 845 | } | |
| 835 | - | if !token.activeEffect() { | |
| 846 | + | if !token.attachmentEffectCurrent() { | |
| 836 | 847 | d.notices.routingMu.Unlock() | |
| 837 | 848 | return | |
| 838 | 849 | } | |
| @@ -843,7 +854,7 @@ func (d *Daemon) handleClientNoticeForRole(token attachmentRoleToken, notice por | |||
| 843 | 854 | } | |
| 844 | 855 | repaint := d.mutateClientNotice(sess, token.ac, notice) | |
| 845 | 856 | d.notices.routingMu.Unlock() | |
| 846 | - | if repaint && token.activeEffect() { | |
| 857 | + | if repaint && token.attachmentEffectCurrent() { | |
| 847 | 858 | d.repaintForNotice(token.ac) | |
| 848 | 859 | } | |
| 849 | 860 | } | |
| @@ -851,7 +862,7 @@ func (d *Daemon) handleClientNoticeForRole(token attachmentRoleToken, notice por | |||
| 851 | 862 | func (d *Daemon) handleClientNoticeDirect(sess *session, ac *attachedClient, notice ports.ClientNotice) { | |
| 852 | 863 | d.notices.routingMu.Lock() | |
| 853 | 864 | sess.mu.Lock() | |
| 854 | - | current := sess.client == ac | |
| 865 | + | _, current := sess.attachments[ac] | |
| 855 | 866 | sess.mu.Unlock() | |
| 856 | 867 | if !current { | |
| 857 | 868 | d.notices.routingMu.Unlock() | |
| @@ -883,7 +894,7 @@ func (d *Daemon) mutateClientNotice(sess *session, ac *attachedClient, notice po | |||
| 883 | 894 | } | |
| 884 | 895 | ||
| 885 | 896 | // recordClientNotice mutates notice history and the selected attachment while | |
| 886 | - | // routingMu keeps replacement from changing that selection. Rendering is left | |
| 897 | + | // routingMu keeps another connection from changing that selection. Rendering is left | |
| 887 | 898 | // to the caller after it releases routingMu. | |
| 888 | 899 | func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, message string) bool { | |
| 889 | 900 | n := d.notices.record(domain.Notification{ | |
M internal/usecase/daemon/client_frame_routing.go +50 -134
| @@ -8,37 +8,36 @@ import ( | |||
| 8 | 8 | "github.com/bnema/vev/internal/usecase/command" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | - | const connectionRoleAttempts = 8 | |
| 11 | + | const connectionSnapshotAttempts = 8 | |
| 12 | 12 | ||
| 13 | - | // currentConnectionRole retries the lock-free session-to-role snapshot when a | |
| 14 | - | // handoff lands between those reads. The role gate still performs the final | |
| 13 | + | // currentAttachmentConnection retries the lock-free session-to-role snapshot when a | |
| 14 | + | // handoff lands between those reads. The attachment gate still performs the final | |
| 15 | 15 | // mutation admission after this function returns. | |
| 16 | - | func (d *Daemon) currentConnectionRole(ac *attachedClient, tr ports.Transport) (attachmentSession, attachmentRoleToken, bool) { | |
| 17 | - | for range connectionRoleAttempts { | |
| 16 | + | func (d *Daemon) currentAttachmentConnection(ac *attachedClient, tr ports.Transport) (attachmentSession, attachmentConnectionToken, bool) { | |
| 17 | + | for range connectionSnapshotAttempts { | |
| 18 | 18 | if !ac.currentTransportIs(tr) { | |
| 19 | 19 | break | |
| 20 | 20 | } | |
| 21 | 21 | sess := ac.currentAttachmentSession() | |
| 22 | 22 | if sess == nil { | |
| 23 | - | return nil, attachmentRoleToken{}, false | |
| 23 | + | return nil, attachmentConnectionToken{}, false | |
| 24 | 24 | } | |
| 25 | 25 | if d.afterConnectionSessionSnapshot != nil { | |
| 26 | 26 | d.afterConnectionSessionSnapshot(sess) | |
| 27 | 27 | } | |
| 28 | 28 | token := attachmentToken(sess, ac, tr) | |
| 29 | - | if token.role == attachmentDetached || ac.currentAttachmentSession() != sess { | |
| 29 | + | if token.ac == nil || ac.currentAttachmentSession() != sess { | |
| 30 | 30 | runtime.Gosched() | |
| 31 | 31 | continue | |
| 32 | 32 | } | |
| 33 | 33 | return sess, token, true | |
| 34 | 34 | } | |
| 35 | - | return nil, attachmentRoleToken{}, false | |
| 35 | + | return nil, attachmentConnectionToken{}, false | |
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | 38 | // runConnLoop is the per-connection input router: it pumps client messages | |
| 39 | - | // until detach, EOF, or a transport error. Role is resolved after every Recv so | |
| 40 | - | // a transport displaced while blocked in Recv immediately adopts its restricted | |
| 41 | - | // snatched routing rather than executing one stale active frame. | |
| 39 | + | // until detach, EOF, or a transport error. The attachment generation is | |
| 40 | + | // resolved after every Recv so a stale transport cannot execute a new frame. | |
| 42 | 41 | func (d *Daemon) runConnLoop(ac *attachedClient) { | |
| 43 | 42 | tr := ac.transport() | |
| 44 | 43 | if tr == nil { | |
| @@ -50,14 +49,12 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||
| 50 | 49 | } | |
| 51 | 50 | f, err := tr.Recv() | |
| 52 | 51 | if err != nil { | |
| 53 | - | for range connectionRoleAttempts { | |
| 54 | - | sess, token, ok := d.currentConnectionRole(ac, tr) | |
| 52 | + | for range connectionSnapshotAttempts { | |
| 53 | + | sess, _, ok := d.currentAttachmentConnection(ac, tr) | |
| 55 | 54 | if !ok { | |
| 56 | 55 | return | |
| 57 | 56 | } | |
| 58 | - | if token.role == attachmentSnatched { | |
| 59 | - | d.parkOrDropSnatchedAttachment(token) | |
| 60 | - | } else if proxy, ok := sess.(*proxySession); ok { | |
| 57 | + | if proxy, ok := sess.(*proxySession); ok { | |
| 61 | 58 | d.detachProxyOnSendError(proxy, ac, tr) | |
| 62 | 59 | } else if local, ok := localSession(sess); ok { | |
| 63 | 60 | d.clientGone(local, ac, tr, false) | |
| @@ -71,48 +68,40 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||
| 71 | 68 | } | |
| 72 | 69 | return | |
| 73 | 70 | } | |
| 74 | - | _, token, ok := d.currentConnectionRole(ac, tr) | |
| 71 | + | _, token, ok := d.currentAttachmentConnection(ac, tr) | |
| 75 | 72 | if !ok { | |
| 76 | 73 | return | |
| 77 | 74 | } | |
| 78 | - | switch token.role { | |
| 79 | - | case attachmentActive: | |
| 80 | - | if d.handleActiveClientFrame(token, f) { | |
| 81 | - | return | |
| 82 | - | } | |
| 83 | - | case attachmentSnatched: | |
| 84 | - | if d.handleSnatchedClientFrame(token, f) { | |
| 85 | - | return | |
| 86 | - | } | |
| 75 | + | if d.handleAttachmentClientFrame(token, f) { | |
| 76 | + | return | |
| 87 | 77 | } | |
| 88 | 78 | } | |
| 89 | 79 | } | |
| 90 | 80 | ||
| 91 | - | // handleActiveClientFrame owns the ordinary attached-client protocol. Keeping | |
| 92 | - | // it separate makes the restricted snatched protocol an explicit allow-list. | |
| 93 | - | func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Frame) bool { | |
| 94 | - | if !token.activeEffect() { | |
| 81 | + | // handleAttachmentClientFrame owns the attached-client protocol. | |
| 82 | + | func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f ports.Frame) bool { | |
| 83 | + | if !token.attachmentEffectCurrent() { | |
| 95 | 84 | return false | |
| 96 | 85 | } | |
| 97 | - | if d.afterActiveFrameDispatch != nil { | |
| 98 | - | d.afterActiveFrameDispatch(token) | |
| 86 | + | if d.afterAttachmentFrameDispatch != nil { | |
| 87 | + | d.afterAttachmentFrameDispatch(token) | |
| 99 | 88 | } | |
| 100 | - | ticket, admitted := token.ac.beginRoleEffect(token) | |
| 89 | + | ticket, admitted := token.ac.beginAttachmentEffect(token) | |
| 101 | 90 | if !admitted { | |
| 102 | 91 | return false | |
| 103 | 92 | } | |
| 104 | 93 | token.effect = ticket | |
| 105 | - | defer token.endRoleEffect() | |
| 106 | - | if d.afterRoleEffectAdmitted != nil { | |
| 107 | - | d.afterRoleEffectAdmitted(token) | |
| 94 | + | defer token.endAttachmentEffect() | |
| 95 | + | if d.afterAttachmentEffectAdmitted != nil { | |
| 96 | + | d.afterAttachmentEffectAdmitted(token) | |
| 108 | 97 | } | |
| 109 | 98 | switch f.Type { | |
| 110 | 99 | case ports.MsgInput: | |
| 111 | 100 | if in, derr := ports.UnmarshalInput(f.Payload); derr == nil { | |
| 112 | - | d.handleSequencedInputForRole(token, in.InputSeq, in.Data) | |
| 101 | + | d.handleSequencedInputForAttachment(token, in.InputSeq, in.Data) | |
| 113 | 102 | } | |
| 114 | 103 | case ports.MsgResize: | |
| 115 | - | if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.activeEffect() { | |
| 104 | + | if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.attachmentEffectCurrent() { | |
| 116 | 105 | if sess, ok := localSession(token.sess); ok { | |
| 117 | 106 | d.requestTransactionalResizeForLease(sess, token.ac, token.lease, rz.Size, false) | |
| 118 | 107 | } else if proxy, ok := token.sess.(*proxySession); ok { | |
| @@ -121,30 +110,30 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram | |||
| 121 | 110 | } | |
| 122 | 111 | case ports.MsgTheme: | |
| 123 | 112 | if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil { | |
| 124 | - | d.applyThemeForRole(token, th) | |
| 113 | + | d.applyThemeForAttachment(token, th) | |
| 125 | 114 | } | |
| 126 | 115 | case ports.MsgImagePush: | |
| 127 | 116 | if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil { | |
| 128 | - | d.handleSequencedImagePushForRole(token, ip.InputSeq, ip) | |
| 117 | + | d.handleSequencedImagePushForAttachment(token, ip.InputSeq, ip) | |
| 129 | 118 | } | |
| 130 | 119 | case ports.MsgClientNotice: | |
| 131 | 120 | if notice, derr := ports.UnmarshalClientNotice(f.Payload); derr == nil { | |
| 132 | - | d.handleClientNoticeForRole(token, notice) | |
| 121 | + | d.handleClientNoticeForAttachment(token, notice) | |
| 133 | 122 | } else { | |
| 134 | 123 | d.log.Warn("malformed client notice", "err", derr) | |
| 135 | 124 | } | |
| 136 | 125 | case ports.MsgDetach: | |
| 137 | - | if token.activeEffect() { | |
| 138 | - | d.clientGoneForRole(token, true) | |
| 126 | + | if token.attachmentEffectCurrent() { | |
| 127 | + | d.clientGoneForAttachment(token, true) | |
| 139 | 128 | return true | |
| 140 | 129 | } | |
| 141 | 130 | case ports.MsgAck: | |
| 142 | 131 | if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil { | |
| 143 | - | d.ackActiveOutput(token, ack.AckedStateNum) | |
| 132 | + | d.ackOutput(token, ack.AckedStateNum) | |
| 144 | 133 | } | |
| 145 | 134 | case ports.MsgOutputResetRequest: | |
| 146 | 135 | if _, derr := ports.UnmarshalOutputResetRequest(f.Payload); derr == nil { | |
| 147 | - | d.resetActiveOutput(token) | |
| 136 | + | d.resetOutput(token) | |
| 148 | 137 | } | |
| 149 | 138 | case ports.MsgCommand: | |
| 150 | 139 | if token.ac.proxied { | |
| @@ -153,16 +142,16 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram | |||
| 153 | 142 | return false | |
| 154 | 143 | } | |
| 155 | 144 | result := d.executeAttachedCommand(token, request) | |
| 156 | - | if err := token.sendActiveControl(frameCommandResult(result)); err != nil { | |
| 157 | - | token.endRoleEffect() | |
| 158 | - | d.detachOnRoleSendError(token, token.transport.transport) | |
| 145 | + | if err := token.sendControl(frameCommandResult(result)); err != nil { | |
| 146 | + | token.endAttachmentEffect() | |
| 147 | + | d.detachOnAttachmentSendError(token, token.transport.transport) | |
| 159 | 148 | return true | |
| 160 | 149 | } | |
| 161 | 150 | } | |
| 162 | 151 | case ports.MsgPing: | |
| 163 | - | if err := token.sendActiveControl(framePong()); err != nil { | |
| 164 | - | token.endRoleEffect() | |
| 165 | - | d.detachOnRoleSendError(token, token.transport.transport) | |
| 152 | + | if err := token.sendControl(framePong()); err != nil { | |
| 153 | + | token.endAttachmentEffect() | |
| 154 | + | d.detachOnAttachmentSendError(token, token.transport.transport) | |
| 166 | 155 | return true | |
| 167 | 156 | } | |
| 168 | 157 | default: | |
| @@ -172,7 +161,7 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram | |||
| 172 | 161 | return false | |
| 173 | 162 | } | |
| 174 | 163 | ||
| 175 | - | func (d *Daemon) executeAttachedCommand(token attachmentRoleToken, request ports.CommandRequest) ports.CommandResult { | |
| 164 | + | func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request ports.CommandRequest) ports.CommandResult { | |
| 176 | 165 | result := ports.CommandResult{RequestID: request.RequestID} | |
| 177 | 166 | if request.Version != ports.ProtocolVersion { | |
| 178 | 167 | result.Code = ports.ErrInvalidCommandArgs | |
| @@ -200,7 +189,7 @@ func (d *Daemon) executeAttachedCommand(token attachmentRoleToken, request ports | |||
| 200 | 189 | return result | |
| 201 | 190 | } | |
| 202 | 191 | sess, ok := localSession(token.sess) | |
| 203 | - | if !ok || !token.activeEffect() { | |
| 192 | + | if !ok || !token.attachmentEffectCurrent() { | |
| 204 | 193 | result.Code = ports.ErrNoSuchTarget | |
| 205 | 194 | result.Text = "attached session is no longer active" | |
| 206 | 195 | return result | |
| @@ -227,11 +216,11 @@ func (d *Daemon) executeAttachedCommand(token attachmentRoleToken, request ports | |||
| 227 | 216 | return result | |
| 228 | 217 | } | |
| 229 | 218 | ||
| 230 | - | // The frame's role token is the target capability. No registry/name lookup is | |
| 219 | + | // The frame's attachment token is the target capability. No registry/name lookup is | |
| 231 | 220 | // performed, so the command cannot escape to another remote session. | |
| 232 | - | sess.dispatchMu.Lock() | |
| 233 | - | err := cmd.Run(paletteExec{d: d, sess: sess, ac: token.ac, effect: token.effect}, request.Args) | |
| 234 | - | sess.dispatchMu.Unlock() | |
| 221 | + | err := sess.runMutation(func() error { | |
| 222 | + | return cmd.Run(paletteExec{d: d, sess: sess, ac: token.ac, effect: token.effect}, request.Args) | |
| 223 | + | }) | |
| 235 | 224 | if err == nil { | |
| 236 | 225 | result.OK = true | |
| 237 | 226 | return result | |
| @@ -252,16 +241,16 @@ func proxyAttachedCommandOwnedRemotely(slug string) bool { | |||
| 252 | 241 | return proxyPaletteCommandOwnership(slug) == proxyPaletteRemote | |
| 253 | 242 | } | |
| 254 | 243 | ||
| 255 | - | // resetActiveOutput rebases only the exact proxied role and transport admitted | |
| 244 | + | // resetOutput rebases only the exact proxied attachment and transport admitted | |
| 256 | 245 | // by the frame router. sendMu serializes the rebase with every output send; | |
| 257 | 246 | // transport revalidation under that lock rejects a link replaced while waiting. | |
| 258 | - | func (d *Daemon) resetActiveOutput(token attachmentRoleToken) bool { | |
| 247 | + | func (d *Daemon) resetOutput(token attachmentConnectionToken) bool { | |
| 259 | 248 | ac := token.ac | |
| 260 | 249 | if ac == nil || !ac.proxied || token.effect == nil || token.effect.ended.Load() { | |
| 261 | 250 | return false | |
| 262 | 251 | } | |
| 263 | 252 | ac.sendMu.Lock() | |
| 264 | - | if token.effect.ended.Load() || !ac.proxied || !token.activeCurrent() || ac.output == nil { | |
| 253 | + | if token.effect.ended.Load() || !ac.proxied || !token.attachmentCurrent() || ac.output == nil { | |
| 265 | 254 | ac.sendMu.Unlock() | |
| 266 | 255 | return false | |
| 267 | 256 | } | |
| @@ -274,7 +263,7 @@ func (d *Daemon) resetActiveOutput(token attachmentRoleToken) bool { | |||
| 274 | 263 | }) | |
| 275 | 264 | } | |
| 276 | 265 | ||
| 277 | - | func (d *Daemon) ackActiveOutput(token attachmentRoleToken, state uint64) bool { | |
| 266 | + | func (d *Daemon) ackOutput(token attachmentConnectionToken, state uint64) bool { | |
| 278 | 267 | ac := token.ac | |
| 279 | 268 | if token.effect == nil || token.effect.ended.Load() { | |
| 280 | 269 | return false | |
| @@ -287,76 +276,3 @@ func (d *Daemon) ackActiveOutput(token attachmentRoleToken, state uint64) bool { | |||
| 287 | 276 | } | |
| 288 | 277 | return true | |
| 289 | 278 | } | |
| 290 | - | ||
| 291 | - | // handleSnatchedClientFrame is intentionally an allow-list. Strict resume and | |
| 292 | - | // quit actions are introduced separately; until then ordinary input is ignored. | |
| 293 | - | func (d *Daemon) handleSnatchedClientFrame(token attachmentRoleToken, f ports.Frame) bool { | |
| 294 | - | if !token.current() { | |
| 295 | - | return false | |
| 296 | - | } | |
| 297 | - | ticket, admitted := token.ac.beginRoleEffect(token) | |
| 298 | - | if !admitted { | |
| 299 | - | return false | |
| 300 | - | } | |
| 301 | - | token.effect = ticket | |
| 302 | - | defer token.endRoleEffect() | |
| 303 | - | if d.afterRoleEffectAdmitted != nil { | |
| 304 | - | d.afterRoleEffectAdmitted(token) | |
| 305 | - | } | |
| 306 | - | switch f.Type { | |
| 307 | - | case ports.MsgInput: | |
| 308 | - | if in, err := ports.UnmarshalInput(f.Payload); err == nil && d.handleSnatchedInput(token, in) { | |
| 309 | - | return true | |
| 310 | - | } | |
| 311 | - | case ports.MsgResize: | |
| 312 | - | if resize, err := ports.UnmarshalResize(f.Payload); err == nil && resize.Size.Valid() { | |
| 313 | - | ac := token.ac | |
| 314 | - | ac.sendMu.Lock() | |
| 315 | - | if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) { | |
| 316 | - | ac.sendMu.Unlock() | |
| 317 | - | return false | |
| 318 | - | } | |
| 319 | - | ac.size = resize.Size | |
| 320 | - | ac.sendMu.Unlock() | |
| 321 | - | if err := d.sendSnatchedPanel(ac, token.transport, token.generation, "", token.effect); err != nil { | |
| 322 | - | token.endRoleEffect() | |
| 323 | - | d.parkOrDropSnatchedAttachment(token) | |
| 324 | - | return true | |
| 325 | - | } | |
| 326 | - | } | |
| 327 | - | case ports.MsgTheme: | |
| 328 | - | if theme, err := ports.UnmarshalTheme(f.Payload); err == nil { | |
| 329 | - | ac := token.ac | |
| 330 | - | ac.sendMu.Lock() | |
| 331 | - | if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) { | |
| 332 | - | ac.sendMu.Unlock() | |
| 333 | - | return false | |
| 334 | - | } | |
| 335 | - | clientTheme := themeFromMessage(theme) | |
| 336 | - | ac.setClientTheme(clientTheme) | |
| 337 | - | ac.setAppliedTheme(d.resolveAppliedTheme(clientTheme)) | |
| 338 | - | ac.sendMu.Unlock() | |
| 339 | - | if err := d.sendSnatchedPanel(ac, token.transport, token.generation, "", token.effect); err != nil { | |
| 340 | - | token.endRoleEffect() | |
| 341 | - | d.parkOrDropSnatchedAttachment(token) | |
| 342 | - | return true | |
| 343 | - | } | |
| 344 | - | } | |
| 345 | - | case ports.MsgAck: | |
| 346 | - | if ack, err := ports.UnmarshalAck(f.Payload); err == nil { | |
| 347 | - | token.ac.ackOutputState(ack.AckedStateNum) | |
| 348 | - | } | |
| 349 | - | case ports.MsgPing: | |
| 350 | - | if err := d.sendSnatchedControl(token, framePong()); err != nil { | |
| 351 | - | token.endRoleEffect() | |
| 352 | - | d.parkOrDropSnatchedAttachment(token) | |
| 353 | - | return true | |
| 354 | - | } | |
| 355 | - | case ports.MsgDetach: | |
| 356 | - | _ = d.sendSnatchedControl(token, frameDetached(ports.ReasonDetach)) | |
| 357 | - | token.endRoleEffect() | |
| 358 | - | d.dropSnatchedAttachment(token) | |
| 359 | - | return true | |
| 360 | - | } | |
| 361 | - | return false | |
| 362 | - | } | |
M internal/usecase/daemon/client_transport_errors.go +26 -20
| @@ -5,6 +5,9 @@ import "github.com/bnema/vev/internal/ports" | |||
| 5 | 5 | // clientGone detaches ac if it is still the session's current client. The | |
| 6 | 6 | // session remains registered and headless after the client is gone. | |
| 7 | 7 | func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) { | |
| 8 | + | if sess == nil || ac == nil { | |
| 9 | + | return | |
| 10 | + | } | |
| 8 | 11 | expected := transportSnapshot{} | |
| 9 | 12 | if failed != nil { | |
| 10 | 13 | expected = ac.transportSnapshot() | |
| @@ -28,14 +31,14 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran | |||
| 28 | 31 | d.finishClientGone(sess, ac, failed, explicit) | |
| 29 | 32 | } | |
| 30 | 33 | ||
| 31 | - | func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) bool { | |
| 34 | + | func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explicit bool) bool { | |
| 32 | 35 | if token.effect == nil { | |
| 33 | 36 | return false | |
| 34 | 37 | } | |
| 35 | 38 | token.effect.bindActionEnd(d, "detach") | |
| 36 | 39 | token.effect.End() | |
| 37 | 40 | if proxy, ok := token.sess.(*proxySession); ok { | |
| 38 | - | if !d.detachIfRoleCurrent(token) { | |
| 41 | + | if !d.detachIfAttachmentCurrent(token) { | |
| 39 | 42 | return false | |
| 40 | 43 | } | |
| 41 | 44 | d.finishProxyClientGone(proxy, token.ac, token.transport.transport, explicit) | |
| @@ -50,7 +53,7 @@ func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) boo | |||
| 50 | 53 | if !explicit { | |
| 51 | 54 | parkingToken = d.markParkingInFlight(sess, token.ac) | |
| 52 | 55 | } | |
| 53 | - | if !d.detachIfRoleCurrent(token) { | |
| 56 | + | if !d.detachIfAttachmentCurrent(token) { | |
| 54 | 57 | d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken) | |
| 55 | 58 | return false | |
| 56 | 59 | } | |
| @@ -59,6 +62,9 @@ func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) boo | |||
| 59 | 62 | } | |
| 60 | 63 | ||
| 61 | 64 | func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) { | |
| 65 | + | if sess == nil || ac == nil { | |
| 66 | + | return | |
| 67 | + | } | |
| 62 | 68 | if d.afterClientGoneDetach != nil { | |
| 63 | 69 | d.afterClientGoneDetach() | |
| 64 | 70 | } | |
| @@ -143,8 +149,8 @@ func (d *Daemon) detachProxyOnSendError(p *proxySession, ac *attachedClient, fai | |||
| 143 | 149 | } | |
| 144 | 150 | ||
| 145 | 151 | // finishProxyClientGone retires external client ownership after the exact proxy | |
| 146 | - | // role has been unpublished. Warm-timer publication remains with the caller so | |
| 147 | - | // each detach path can arm once, after p.client is nil. | |
| 152 | + | // attachment has been unpublished. Warm-timer publication remains with the caller so | |
| 153 | + | // each detach path can arm once, after membership is empty. | |
| 148 | 154 | func (d *Daemon) finishProxyClientGone(p *proxySession, ac *attachedClient, failed ports.Transport, explicit bool) { | |
| 149 | 155 | if rc := p.coordinator.Load(); rc != nil { | |
| 150 | 156 | rc.noteDetach(ac) | |
| @@ -160,18 +166,18 @@ func (d *Daemon) finishProxyClientGone(p *proxySession, ac *attachedClient, fail | |||
| 160 | 166 | d.log.Warn("detached proxy client", "host", p.key.Host, "session", p.key.Name, "explicit", explicit) | |
| 161 | 167 | } | |
| 162 | 168 | ||
| 163 | - | func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.Transport) { | |
| 164 | - | d.detachOnRoleSendErrorUntil(token, failed, nil) | |
| 169 | + | func (d *Daemon) detachOnAttachmentSendError(token attachmentConnectionToken, failed ports.Transport) { | |
| 170 | + | d.detachOnAttachmentSendErrorUntil(token, failed, nil) | |
| 165 | 171 | } | |
| 166 | 172 | ||
| 167 | - | func (d *Daemon) detachOnRoleSendErrorUntil(token attachmentRoleToken, failed ports.Transport, done func() <-chan struct{}) { | |
| 173 | + | func (d *Daemon) detachOnAttachmentSendErrorUntil(token attachmentConnectionToken, failed ports.Transport, done func() <-chan struct{}) { | |
| 168 | 174 | // A delayed sender may report after the client has rebound. Only the exact | |
| 169 | - | // transport captured by this role is allowed to detach either lifecycle. | |
| 175 | + | // transport captured by this attachment is allowed to detach either lifecycle. | |
| 170 | 176 | if failed == nil || failed != token.transport.transport { | |
| 171 | 177 | return | |
| 172 | 178 | } | |
| 173 | 179 | if proxy, ok := token.sess.(*proxySession); ok { | |
| 174 | - | if d.detachIfRoleCurrentUntil(token, done) { | |
| 180 | + | if d.detachIfAttachmentCurrentUntil(token, done) { | |
| 175 | 181 | d.finishProxyClientGone(proxy, token.ac, failed, false) | |
| 176 | 182 | d.armProxyWarm(proxy) | |
| 177 | 183 | } | |
| @@ -182,30 +188,30 @@ func (d *Daemon) detachOnRoleSendErrorUntil(token attachmentRoleToken, failed po | |||
| 182 | 188 | return | |
| 183 | 189 | } | |
| 184 | 190 | parkingToken := d.markParkingInFlight(sess, token.ac) | |
| 185 | - | if d.detachIfRoleCurrentUntil(token, done) { | |
| 191 | + | if d.detachIfAttachmentCurrentUntil(token, done) { | |
| 186 | 192 | d.finishSendErrorDetach(sess, token.ac, failed) | |
| 187 | 193 | return | |
| 188 | 194 | } | |
| 189 | 195 | d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken) | |
| 190 | 196 | } | |
| 191 | 197 | ||
| 192 | - | // reserveRoleSendErrorCleanup accounts for cleanup before End releases the | |
| 193 | - | // role gate. This closes the WaitGroup Add/Wait race with terminal teardown; | |
| 198 | + | // reserveAttachmentSendErrorCleanup accounts for cleanup before End releases the | |
| 199 | + | // attachment gate. This closes the WaitGroup Add/Wait race with terminal teardown; | |
| 194 | 200 | // the returned launch function must be invoked immediately after ticket End. | |
| 195 | - | func (d *Daemon) reserveRoleSendErrorCleanup(token attachmentRoleToken, failed ports.Transport) func() { | |
| 201 | + | func (d *Daemon) reserveAttachmentSendErrorCleanup(token attachmentConnectionToken, failed ports.Transport) func() { | |
| 196 | 202 | d.attachmentCleanupWg.Add(1) | |
| 197 | 203 | return func() { | |
| 198 | 204 | go func() { | |
| 199 | 205 | defer d.attachmentCleanupWg.Done() | |
| 200 | - | if d.afterRoleSendErrorCleanup != nil { | |
| 201 | - | defer d.afterRoleSendErrorCleanup() | |
| 206 | + | if d.afterAttachmentSendErrorCleanup != nil { | |
| 207 | + | defer d.afterAttachmentSendErrorCleanup() | |
| 202 | 208 | } | |
| 203 | - | if d.beforeRoleSendErrorCleanup != nil { | |
| 204 | - | d.beforeRoleSendErrorCleanup(token) | |
| 209 | + | if d.beforeAttachmentSendErrorCleanup != nil { | |
| 210 | + | d.beforeAttachmentSendErrorCleanup(token) | |
| 205 | 211 | } | |
| 206 | - | deadline := newRoleEffectDrainDeadline(d.clock) | |
| 212 | + | deadline := newAttachmentEffectDrainDeadline(d.clock) | |
| 207 | 213 | defer deadline.stop() | |
| 208 | - | d.detachOnRoleSendErrorUntil(token, failed, deadline.Done) | |
| 214 | + | d.detachOnAttachmentSendErrorUntil(token, failed, deadline.Done) | |
| 209 | 215 | }() | |
| 210 | 216 | } | |
| 211 | 217 | } | |
M internal/usecase/daemon/clipboard.go +39 -40
| @@ -26,27 +26,27 @@ var ( | |||
| 26 | 26 | clipPasteCloseMarker = []byte("\x1b[201~") | |
| 27 | 27 | ) | |
| 28 | 28 | ||
| 29 | - | func (d *Daemon) handleSequencedImagePushForRole(token attachmentRoleToken, _ uint64, ip ports.ImagePush) { | |
| 30 | - | if !token.activeEffect() { | |
| 29 | + | func (d *Daemon) handleSequencedImagePushForAttachment(token attachmentConnectionToken, _ uint64, ip ports.ImagePush) { | |
| 30 | + | if !token.attachmentEffectCurrent() { | |
| 31 | 31 | return | |
| 32 | 32 | } | |
| 33 | - | d.handleImagePushForRole(token, ip) | |
| 33 | + | d.handleImagePushForAttachment(token, ip) | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | - | func (d *Daemon) handleImagePushForRole(token attachmentRoleToken, ip ports.ImagePush) { | |
| 37 | - | if len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !token.activeEffect() { | |
| 36 | + | func (d *Daemon) handleImagePushForAttachment(token attachmentConnectionToken, ip ports.ImagePush) { | |
| 37 | + | if len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !token.attachmentEffectCurrent() { | |
| 38 | 38 | return | |
| 39 | 39 | } | |
| 40 | - | path, err := d.writeClipboardImageForRole(token, ip) | |
| 40 | + | path, err := d.writeClipboardImageForAttachment(token, ip) | |
| 41 | 41 | if err != nil { | |
| 42 | 42 | d.log.Error("writing clipboard image failed", "err", err) | |
| 43 | 43 | return | |
| 44 | 44 | } | |
| 45 | - | if !token.activeEffect() { | |
| 45 | + | if !token.attachmentEffectCurrent() { | |
| 46 | 46 | _ = os.Remove(path) | |
| 47 | 47 | return | |
| 48 | 48 | } | |
| 49 | - | d.injectClipboardPathForRole(token, path) | |
| 49 | + | d.injectClipboardPathForAttachment(token, path) | |
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | 52 | // handleImagePush writes an ImagePush's bytes to a temp file and injects the | |
| @@ -84,7 +84,7 @@ func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string, | |||
| 84 | 84 | return path, nil | |
| 85 | 85 | } | |
| 86 | 86 | ||
| 87 | - | func (d *Daemon) writeClipboardImageForRole(token attachmentRoleToken, ip ports.ImagePush) (string, error) { | |
| 87 | + | func (d *Daemon) writeClipboardImageForAttachment(token attachmentConnectionToken, ip ports.ImagePush) (string, error) { | |
| 88 | 88 | path, err := d.createClipboardImage(ip) | |
| 89 | 89 | if err != nil { | |
| 90 | 90 | return "", err | |
| @@ -95,7 +95,7 @@ func (d *Daemon) writeClipboardImageForRole(token attachmentRoleToken, ip ports. | |||
| 95 | 95 | return "", errAttachmentTransition | |
| 96 | 96 | } | |
| 97 | 97 | sess.mu.Lock() | |
| 98 | - | if !token.activeEffectSessionLocked() { | |
| 98 | + | if !token.attachmentEffectCurrentSessionLocked() { | |
| 99 | 99 | sess.mu.Unlock() | |
| 100 | 100 | _ = os.Remove(path) | |
| 101 | 101 | return "", errAttachmentTransition | |
| @@ -134,16 +134,25 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) { | |||
| 134 | 134 | d.injectClipboardPathToTarget(sess, path, nil) | |
| 135 | 135 | } | |
| 136 | 136 | ||
| 137 | - | func (d *Daemon) injectClipboardPathForRole(token attachmentRoleToken, path string) { | |
| 137 | + | func (d *Daemon) injectClipboardPathForAttachment(token attachmentConnectionToken, path string) { | |
| 138 | 138 | sess, ok := localSession(token.sess) | |
| 139 | - | if !ok || !token.activeEffect() { | |
| 139 | + | if !ok || !token.attachmentEffectCurrent() { | |
| 140 | 140 | return | |
| 141 | 141 | } | |
| 142 | 142 | d.injectClipboardPathToTarget(sess, path, &token) | |
| 143 | 143 | } | |
| 144 | 144 | ||
| 145 | - | func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *attachmentRoleToken) { | |
| 146 | - | tb := sess.activeTab() | |
| 145 | + | func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *attachmentConnectionToken) { | |
| 146 | + | var ac *attachedClient | |
| 147 | + | if token != nil { | |
| 148 | + | ac = token.ac | |
| 149 | + | } | |
| 150 | + | var tb *tab | |
| 151 | + | if ac != nil { | |
| 152 | + | tb = sess.tabForAttachment(ac) | |
| 153 | + | } else { | |
| 154 | + | tb = sess.firstTab() | |
| 155 | + | } | |
| 147 | 156 | if tb == nil { | |
| 148 | 157 | return | |
| 149 | 158 | } | |
| @@ -166,7 +175,7 @@ func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token * | |||
| 166 | 175 | wrapped = append(wrapped, clipPasteCloseMarker...) | |
| 167 | 176 | data = wrapped | |
| 168 | 177 | } | |
| 169 | - | if token != nil && !token.activeEffect() { | |
| 178 | + | if token != nil && !token.attachmentEffectCurrent() { | |
| 170 | 179 | return | |
| 171 | 180 | } | |
| 172 | 181 | d.writeToPane(sess, p, data) | |
| @@ -193,7 +202,7 @@ func clipboardExt(mime string) string { | |||
| 193 | 202 | ||
| 194 | 203 | type clipboardForward struct { | |
| 195 | 204 | owner paneEffectLease | |
| 196 | - | token attachmentRoleToken | |
| 205 | + | token attachmentConnectionToken | |
| 197 | 206 | seq []byte | |
| 198 | 207 | } | |
| 199 | 208 | ||
| @@ -222,32 +231,22 @@ func (d *Daemon) forwardClipboardAsync(owner paneEffectLease, b64 string) { | |||
| 222 | 231 | default: | |
| 223 | 232 | } | |
| 224 | 233 | } | |
| 225 | - | ac := sess.client | |
| 234 | + | attachments := sess.snapshotAttachmentsLocked() | |
| 226 | 235 | sess.mu.Unlock() | |
| 227 | - | if ac == nil { | |
| 228 | - | return | |
| 229 | - | } | |
| 230 | - | transport := ac.transport() | |
| 231 | - | token := sess.attachmentToken(ac, transport) | |
| 232 | - | if !token.activeCurrent() { | |
| 233 | - | return | |
| 234 | - | } | |
| 235 | 236 | ||
| 236 | - | sess.mu.Lock() | |
| 237 | - | if sess.ctx != nil { | |
| 238 | - | select { | |
| 239 | - | case <-sess.ctx.Done(): | |
| 240 | - | sess.mu.Unlock() | |
| 241 | - | return | |
| 242 | - | default: | |
| 237 | + | items := make([]clipboardForward, 0, len(attachments)) | |
| 238 | + | for _, ac := range attachments { | |
| 239 | + | transport := ac.transport() | |
| 240 | + | token := sess.attachmentToken(ac, transport) | |
| 241 | + | if token.attachmentCurrent() { | |
| 242 | + | items = append(items, clipboardForward{owner: owner, token: token, seq: seq}) | |
| 243 | 243 | } | |
| 244 | 244 | } | |
| 245 | - | if sess.client != ac || ac.roleGeneration.Load() != token.generation || | |
| 246 | - | !ac.transportSnapshotCurrent(token.transport) || ac.currentSession() != sess { | |
| 247 | - | sess.mu.Unlock() | |
| 245 | + | if len(items) == 0 { | |
| 248 | 246 | return | |
| 249 | 247 | } | |
| 250 | - | sess.clipboardQueue = append(sess.clipboardQueue, clipboardForward{owner: owner, token: token, seq: seq}) | |
| 248 | + | sess.mu.Lock() | |
| 249 | + | sess.clipboardQueue = append(sess.clipboardQueue, items...) | |
| 251 | 250 | if !sess.clipboardWorkerRunning { | |
| 252 | 251 | sess.clipboardWorkerRunning = true | |
| 253 | 252 | go d.clipboardWorker(sess) | |
| @@ -255,7 +254,7 @@ func (d *Daemon) forwardClipboardAsync(owner paneEffectLease, b64 string) { | |||
| 255 | 254 | sess.mu.Unlock() | |
| 256 | 255 | } | |
| 257 | 256 | ||
| 258 | - | func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *roleEffectTicket) (ports.Transport, error) { | |
| 257 | + | func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *attachmentEffectTicket) (ports.Transport, error) { | |
| 259 | 258 | token := item.token | |
| 260 | 259 | if token.ac == nil || ticket == nil || ticket.ended.Load() || token.transport.transport == nil { | |
| 261 | 260 | return token.transport.transport, errAttachmentTransition | |
| @@ -295,7 +294,7 @@ func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *role | |||
| 295 | 294 | // admission and marks the transport interval while ownership publication is | |
| 296 | 295 | // excluded. Publication therefore orders either before this check (the send is | |
| 297 | 296 | // dropped) or after transport admission (the send belongs to the old owner). | |
| 298 | - | func beginClipboardOwnerSend(lease paneEffectLease, ticket *roleEffectTicket, expected transportSnapshot) bool { | |
| 297 | + | func beginClipboardOwnerSend(lease paneEffectLease, ticket *attachmentEffectTicket, expected transportSnapshot) bool { | |
| 299 | 298 | if lease.pane == nil || lease.owner == nil || lease.owner.session == nil || lease.owner.tab == nil { | |
| 300 | 299 | return false | |
| 301 | 300 | } | |
| @@ -326,7 +325,7 @@ func (d *Daemon) clipboardWorker(sess *session) { | |||
| 326 | 325 | if !item.owner.Current() { | |
| 327 | 326 | continue | |
| 328 | 327 | } | |
| 329 | - | ticket, admitted := item.token.ac.beginRoleEffect(item.token) | |
| 328 | + | ticket, admitted := item.token.ac.beginAttachmentEffect(item.token) | |
| 330 | 329 | if !admitted { | |
| 331 | 330 | continue | |
| 332 | 331 | } | |
| @@ -336,7 +335,7 @@ func (d *Daemon) clipboardWorker(sess *session) { | |||
| 336 | 335 | } | |
| 337 | 336 | ticket.End() | |
| 338 | 337 | if err != nil && item.owner.Current() { | |
| 339 | - | d.detachOnRoleSendError(item.token, failed) | |
| 338 | + | d.detachOnAttachmentSendError(item.token, failed) | |
| 340 | 339 | } | |
| 341 | 340 | } | |
| 342 | 341 | } | |
M internal/usecase/daemon/clipboard_test.go +14 -44
| @@ -31,7 +31,7 @@ func TestInjectClipboardPathTargetsVisibleFloatingPane(t *testing.T) { | |||
| 31 | 31 | d, sess, _, _ := newManualSessionWithPTYs(t, normal) | |
| 32 | 32 | floating := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 5}) | |
| 33 | 33 | floating.screen.Write([]byte("\x1b[?2004h")) | |
| 34 | - | installTestFloating(sess.activeTab(), floating, true) | |
| 34 | + | installTestFloating(testAttachmentTab(sess), floating, true) | |
| 35 | 35 | ||
| 36 | 36 | d.injectClipboardPath(sess, "/tmp/image.png") | |
| 37 | 37 | ||
| @@ -203,15 +203,15 @@ func chunkReadPTY(t *testing.T, chunks ...[]byte) *portsmocks.MockPTY { | |||
| 203 | 203 | } | |
| 204 | 204 | ||
| 205 | 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 | - | ac.publishRoleCapability(token) | |
| 209 | + | ac.publishAttachmentCapability(token) | |
| 210 | 210 | } | |
| 211 | 211 | ||
| 212 | 212 | func clipboardOwnerLease(t *testing.T, sess *session) paneEffectLease { | |
| 213 | 213 | t.Helper() | |
| 214 | - | tb := sess.activeTab() | |
| 214 | + | tb := testAttachmentTab(sess) | |
| 215 | 215 | tb.mu.Lock() | |
| 216 | 216 | p := tb.terminalTargetLocked() | |
| 217 | 217 | tb.mu.Unlock() | |
| @@ -231,7 +231,7 @@ func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) { | |||
| 231 | 231 | win := newTestTabWithContext(p, sctx, cancel) | |
| 232 | 232 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 233 | 233 | ac.initOverlays() | |
| 234 | - | sess := &session{sessionCore: sessionCore{id: "clip", name: "clip", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 234 | + | sess := &session{sessionCore: sessionCore{id: "clip", name: "clip", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 235 | 235 | publishTiledPaneOwners(sess, win) | |
| 236 | 236 | ac.setSession(sess) | |
| 237 | 237 | d.sessions[sess.id] = sess | |
| @@ -265,7 +265,7 @@ func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) { | |||
| 265 | 265 | win := newTestTabWithContext(p, sctx, cancel) | |
| 266 | 266 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 267 | 267 | ac.initOverlays() | |
| 268 | - | sess := &session{sessionCore: sessionCore{id: "clip-big", name: "clip-big", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 268 | + | sess := &session{sessionCore: sessionCore{id: "clip-big", name: "clip-big", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 269 | 269 | publishTiledPaneOwners(sess, win) | |
| 270 | 270 | ac.setSession(sess) | |
| 271 | 271 | d.sessions[sess.id] = sess | |
| @@ -290,7 +290,7 @@ func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) { | |||
| 290 | 290 | win := newTestTabWithContext(p, sctx, cancel) | |
| 291 | 291 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 292 | 292 | ac.initOverlays() | |
| 293 | - | sess := &session{sessionCore: sessionCore{id: "clip-bad", name: "clip-bad", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 293 | + | sess := &session{sessionCore: sessionCore{id: "clip-bad", name: "clip-bad", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 294 | 294 | publishTiledPaneOwners(sess, win) | |
| 295 | 295 | ac.setSession(sess) | |
| 296 | 296 | d.sessions[sess.id] = sess | |
| @@ -354,7 +354,7 @@ func TestQueuedClipboardAfterPaneMoveDoesNotSendToFormerOwner(t *testing.T) { | |||
| 354 | 354 | sess.mu.Unlock() | |
| 355 | 355 | d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued"))) | |
| 356 | 356 | ||
| 357 | - | sourceTab := sess.activeTab() | |
| 357 | + | sourceTab := testAttachmentTab(sess) | |
| 358 | 358 | p := sourceTab.focusedPane() | |
| 359 | 359 | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}} | |
| 360 | 360 | publishPaneOwner(p, destination, &tab{}, 0) | |
| @@ -363,7 +363,7 @@ func TestQueuedClipboardAfterPaneMoveDoesNotSendToFormerOwner(t *testing.T) { | |||
| 363 | 363 | d.clipboardWorker(sess) | |
| 364 | 364 | ||
| 365 | 365 | require.Zero(t, oldTransport.sendCount(), "clipboard queued by the former pane owner reached its client") | |
| 366 | - | require.Same(t, ac, sess.client, "stale clipboard send handling detached the former owner's client") | |
| 366 | + | require.Contains(t, sess.snapshotAttachments(), ac, "stale clipboard send handling detached the former owner's client") | |
| 367 | 367 | } | |
| 368 | 368 | ||
| 369 | 369 | type movingClipboardErrorTransport struct { | |
| @@ -419,10 +419,10 @@ func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing | |||
| 419 | 419 | close(workerDone) | |
| 420 | 420 | }() | |
| 421 | 421 | require.Eventually(t, func() bool { | |
| 422 | - | return ac.roleEffects.inFlightCount() == 1 | |
| 422 | + | return ac.attachmentEffects.inFlightCount() == 1 | |
| 423 | 423 | }, time.Second, time.Millisecond, "clipboard send was not admitted before waiting on sendMu") | |
| 424 | 424 | ||
| 425 | - | sourceTab := sess.activeTab() | |
| 425 | + | sourceTab := testAttachmentTab(sess) | |
| 426 | 426 | p := sourceTab.focusedPane() | |
| 427 | 427 | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}} | |
| 428 | 428 | publishPaneOwner(p, destination, &tab{}, 0) | |
| @@ -432,7 +432,7 @@ func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing | |||
| 432 | 432 | awaitTestCompletion(t, workerDone, "clipboard worker did not finish") | |
| 433 | 433 | ||
| 434 | 434 | require.Zero(t, oldTransport.sendCount(), "clipboard send was not revalidated immediately before transport I/O") | |
| 435 | - | require.Same(t, ac, sess.client) | |
| 435 | + | require.Contains(t, sess.snapshotAttachments(), ac) | |
| 436 | 436 | } | |
| 437 | 437 | ||
| 438 | 438 | func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) { | |
| @@ -453,7 +453,7 @@ func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) { | |||
| 453 | 453 | }() | |
| 454 | 454 | awaitTestCompletion(t, oldTransport.started, "clipboard send did not start") | |
| 455 | 455 | ||
| 456 | - | sourceTab := sess.activeTab() | |
| 456 | + | sourceTab := testAttachmentTab(sess) | |
| 457 | 457 | p := sourceTab.focusedPane() | |
| 458 | 458 | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}} | |
| 459 | 459 | publishPaneOwner(p, destination, &tab{}, 0) | |
| @@ -461,7 +461,7 @@ func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) { | |||
| 461 | 461 | close(oldTransport.release) | |
| 462 | 462 | awaitTestCompletion(t, workerDone, "clipboard worker did not finish") | |
| 463 | 463 | ||
| 464 | - | require.Same(t, ac, sess.client, "a send error from the pane's retired owner detached its client") | |
| 464 | + | require.Contains(t, sess.snapshotAttachments(), ac, "a send error from the pane's retired owner detached its client") | |
| 465 | 465 | select { | |
| 466 | 466 | case <-oldTransport.closed: | |
| 467 | 467 | t.Fatal("a send error from the pane's retired owner closed its client transport") | |
| @@ -469,36 +469,6 @@ func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) { | |||
| 469 | 469 | } | |
| 470 | 470 | } | |
| 471 | 471 | ||
| 472 | - | func TestQueuedClipboardBeforeSnatchDropsExactStaleCapability(t *testing.T) { | |
| 473 | - | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 474 | - | oldTransport := &staleClipboardErrorTransport{} | |
| 475 | - | old.replaceTransport(oldTransport) | |
| 476 | - | rc := d.attachCoordinator(sess, nil, old, true) | |
| 477 | - | token := sess.attachmentToken(old, oldTransport) | |
| 478 | - | token.lease = rc.attachmentLease(old) | |
| 479 | - | old.publishRoleCapability(token) | |
| 480 | - | ||
| 481 | - | sess.mu.Lock() | |
| 482 | - | sess.clipboardWorkerRunning = true | |
| 483 | - | sess.mu.Unlock() | |
| 484 | - | d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued"))) | |
| 485 | - | ||
| 486 | - | newTransport := &closeTrackingTransport{} | |
| 487 | - | next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size} | |
| 488 | - | next.initOverlays() | |
| 489 | - | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 490 | - | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 491 | - | expectedTransport: next.transportSnapshot(), ready: true, | |
| 492 | - | }) | |
| 493 | - | require.NoError(t, err) | |
| 494 | - | ||
| 495 | - | d.clipboardWorker(sess) | |
| 496 | - | require.Zero(t, oldTransport.sendCount(), "stale queued work reached its captured failing transport") | |
| 497 | - | require.Empty(t, newTransport.Sends(), "stale queued work was redirected to the replacement") | |
| 498 | - | require.Same(t, next, sess.client, "stale clipboard send handling detached the current client") | |
| 499 | - | require.False(t, newTransport.Closed()) | |
| 500 | - | } | |
| 501 | - | ||
| 502 | 472 | func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) { | |
| 503 | 473 | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 504 | 474 | tr := newBlockingClipboardTransport() | |
M internal/usecase/daemon/config.go +9 -9
| @@ -345,16 +345,16 @@ func (d *Daemon) reapplyThemeAllSessions() { | |||
| 345 | 345 | } | |
| 346 | 346 | ||
| 347 | 347 | func (d *Daemon) reapplyThemeSession(sess *session) { | |
| 348 | - | sess.mu.Lock() | |
| 349 | - | ac := sess.client | |
| 350 | - | sess.mu.Unlock() | |
| 351 | - | if ac == nil { | |
| 348 | + | attachments := sess.snapshotAttachments() | |
| 349 | + | if len(attachments) == 0 { | |
| 352 | 350 | d.applyHostTheme(sess, nil, theme.Theme{}, true) | |
| 353 | 351 | return | |
| 354 | 352 | } | |
| 355 | - | ac.sendMu.Lock() | |
| 356 | - | defer ac.sendMu.Unlock() | |
| 357 | - | sess.themeMu.Lock() | |
| 358 | - | defer sess.themeMu.Unlock() | |
| 359 | - | d.applyHostThemeLocked(sess, ac, ac.getClientTheme(), false) | |
| 353 | + | for _, ac := range attachments { | |
| 354 | + | ac.sendMu.Lock() | |
| 355 | + | sess.themeMu.Lock() | |
| 356 | + | d.applyHostThemeLocked(sess, ac, ac.getClientTheme(), false) | |
| 357 | + | sess.themeMu.Unlock() | |
| 358 | + | ac.sendMu.Unlock() | |
| 359 | + | } | |
| 360 | 360 | } | |
M internal/usecase/daemon/control.go +72 -33
| @@ -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 | - | for _, sess := range locals { | |
| 165 | - | return sess, 0, "" | |
| 166 | - | } | |
| 167 | - | panic("unreachable") | |
| 164 | + | return locals[0], 0, "" | |
| 168 | 165 | } | |
| 169 | 166 | ||
| 170 | 167 | func (s *session) containsStableIDs(tabID, paneID string) bool { | |
| @@ -186,7 +183,6 @@ func (s *session) containsStableIDs(tabID, paneID string) bool { | |||
| 186 | 183 | func resolveControlTarget(sess *session, kind command.TargetKind, request ports.CommandRequest) (*tab, *pane, uint16, string) { | |
| 187 | 184 | sess.mu.Lock() | |
| 188 | 185 | tabs := append([]*tab(nil), sess.tabs...) | |
| 189 | - | active := sess.active | |
| 190 | 186 | sess.mu.Unlock() | |
| 191 | 187 | if request.Self { | |
| 192 | 188 | for _, tb := range tabs { | |
| @@ -204,10 +200,10 @@ func resolveControlTarget(sess *session, kind command.TargetKind, request ports. | |||
| 204 | 200 | } | |
| 205 | 201 | return nil, nil, ports.ErrNoSuchTarget, "no such target tab" | |
| 206 | 202 | } | |
| 207 | - | if len(tabs) == 0 || active < 0 || active >= len(tabs) { | |
| 208 | - | return nil, nil, ports.ErrNoSuchTarget, "target session has no active tab" | |
| 203 | + | if len(tabs) == 0 { | |
| 204 | + | return nil, nil, ports.ErrNoSuchTarget, "target session has no tabs" | |
| 209 | 205 | } | |
| 210 | - | tb := tabs[active] | |
| 206 | + | tb := tabs[0] | |
| 211 | 207 | if kind != command.TargetPane { | |
| 212 | 208 | return tb, nil, 0, "" | |
| 213 | 209 | } | |
| @@ -249,14 +245,16 @@ const ( | |||
| 249 | 245 | ) | |
| 250 | 246 | ||
| 251 | 247 | type daemonActionTarget struct { | |
| 252 | - | session *session | |
| 253 | - | tab *tab | |
| 254 | - | pane *pane | |
| 248 | + | session *session | |
| 249 | + | attachment *attachedClient | |
| 250 | + | tab *tab | |
| 251 | + | pane *pane | |
| 255 | 252 | } | |
| 256 | 253 | ||
| 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 | |
| @@ -268,11 +266,18 @@ type daemonActionRunner interface { | |||
| 268 | 266 | Run(daemonActionRequest) error | |
| 269 | 267 | } | |
| 270 | 268 | ||
| 271 | - | func resolveDaemonActionTarget(sess *session) daemonActionTarget { | |
| 269 | + | func resolveDaemonActionTargetForAttachment(sess *session, ac *attachedClient) daemonActionTarget { | |
| 272 | 270 | if sess == nil { | |
| 273 | 271 | return daemonActionTarget{} | |
| 274 | 272 | } | |
| 275 | - | tb := sess.activeTab() | |
| 273 | + | if ac != nil { | |
| 274 | + | tb, pane := sess.paneForAttachment(ac) | |
| 275 | + | if tb == nil { | |
| 276 | + | return daemonActionTarget{session: sess, attachment: ac} | |
| 277 | + | } | |
| 278 | + | return daemonActionTarget{session: sess, attachment: ac, tab: tb, pane: pane} | |
| 279 | + | } | |
| 280 | + | tb := sess.firstTab() | |
| 276 | 281 | if tb == nil { | |
| 277 | 282 | return daemonActionTarget{session: sess} | |
| 278 | 283 | } | |
| @@ -291,9 +296,9 @@ func (a daemonActions) Run(request daemonActionRequest) error { | |||
| 291 | 296 | target := request.target | |
| 292 | 297 | switch request.kind { | |
| 293 | 298 | case daemonActionCreateTab: | |
| 294 | - | return a.d.createTab(target.session, request.viewport) | |
| 299 | + | return a.d.createTabForAttachment(target.session, target.attachment, request.viewport) | |
| 295 | 300 | case daemonActionCloseTab: | |
| 296 | - | return a.d.closeTab(target.session, target.tab, true) | |
| 301 | + | return a.d.closeTabLockedWithEffect(target.session, target.tab, true, request.effect) | |
| 297 | 302 | case daemonActionSplitPane: | |
| 298 | 303 | return a.d.splitPaneAt(target.session, target.tab, target.pane, request.direction) | |
| 299 | 304 | case daemonActionStackPane: | |
| @@ -307,7 +312,7 @@ func (a daemonActions) Run(request daemonActionRequest) error { | |||
| 307 | 312 | if !a.d.hasDaemonActionPaneTarget(target) { | |
| 308 | 313 | return layout.ErrNotFound | |
| 309 | 314 | } | |
| 310 | - | if err := a.d.closePane(target.session, target.tab, target.pane.id, nil, true); err != nil { | |
| 315 | + | if err := a.d.closePaneLockedWithEffect(target.session, target.tab, target.pane.id, nil, true, request.effect); err != nil { | |
| 311 | 316 | return err | |
| 312 | 317 | } | |
| 313 | 318 | if a.d.hasDaemonActionPaneTarget(target) { | |
| @@ -315,12 +320,12 @@ func (a daemonActions) Run(request daemonActionRequest) error { | |||
| 315 | 320 | } | |
| 316 | 321 | return nil | |
| 317 | 322 | case daemonActionFocusPane: | |
| 318 | - | _, err := a.d.focusDirAt(target.session, target.tab, target.pane, request.direction) | |
| 323 | + | _, err := a.d.focusDirAt(target.session, target.tab, target.pane, request.direction, target.attachment) | |
| 319 | 324 | return err | |
| 320 | 325 | case daemonActionNextTab: | |
| 321 | - | return a.switchRelative(target.session, 1) | |
| 326 | + | return a.switchRelative(target.session, target.attachment, 1) | |
| 322 | 327 | case daemonActionPreviousTab: | |
| 323 | - | return a.switchRelative(target.session, -1) | |
| 328 | + | return a.switchRelative(target.session, target.attachment, -1) | |
| 324 | 329 | case daemonActionRenameSession: | |
| 325 | 330 | return a.d.renameSession(target.session, request.name) | |
| 326 | 331 | case daemonActionRenameTab: | |
| @@ -359,9 +364,9 @@ func (d *Daemon) hasDaemonActionPaneTarget(target daemonActionTarget) bool { | |||
| 359 | 364 | target.tab.tree != nil && layout.ContainsLeaf(target.tab.tree.Root, target.pane.id) | |
| 360 | 365 | } | |
| 361 | 366 | ||
| 362 | - | func (a daemonActions) switchRelative(sess *session, delta int) error { | |
| 363 | - | if sess.switchRelative(delta) { | |
| 364 | - | a.d.activateTab(sess, sess.activeTab()) | |
| 367 | + | func (a daemonActions) switchRelative(sess *session, ac *attachedClient, delta int) error { | |
| 368 | + | if sess.switchAttachmentRelativeForDispatch(ac, delta) { | |
| 369 | + | a.d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(ac), false, ac, nil) | |
| 365 | 370 | } | |
| 366 | 371 | return nil | |
| 367 | 372 | } | |
| @@ -394,10 +399,7 @@ func (e controlExec) runAction(request daemonActionRequest) error { | |||
| 394 | 399 | return err | |
| 395 | 400 | } | |
| 396 | 401 | if e.actions == nil { | |
| 397 | - | e.sess.mu.Lock() | |
| 398 | - | ac := e.sess.client | |
| 399 | - | e.sess.mu.Unlock() | |
| 400 | - | finishDaemonActionForClient(e.d, request, ac, "control.go") | |
| 402 | + | finishDaemonActionForClient(e.d, request, request.target.attachment, "control.go") | |
| 401 | 403 | } | |
| 402 | 404 | return nil | |
| 403 | 405 | } | |
| @@ -450,7 +452,17 @@ func (e controlExec) MovePane(destinationSession, destinationTabID string) error | |||
| 450 | 452 | if e.sess == nil || e.tab == nil || e.target.pane == nil { | |
| 451 | 453 | return errMovePaneInvalid | |
| 452 | 454 | } | |
| 455 | + | attachment := e.target.attachment | |
| 456 | + | token := attachmentConnectionToken{} | |
| 457 | + | if attachment != nil { | |
| 458 | + | token = e.sess.attachmentToken(attachment, attachment.transport()) | |
| 459 | + | if token.ac == nil { | |
| 460 | + | return errMoveStaleTarget | |
| 461 | + | } | |
| 462 | + | } | |
| 453 | 463 | return e.d.movePane(movePaneRequest{ | |
| 464 | + | Attachment: attachment, | |
| 465 | + | AttachmentToken: token, | |
| 454 | 466 | Source: sessionMoveLocator(e.sess), | |
| 455 | 467 | SourceTabID: domain.TabStableID(e.tab.stableID), | |
| 456 | 468 | SourcePaneID: domain.PaneStableID(e.target.pane.stableID), | |
| @@ -467,10 +479,20 @@ func (e controlExec) MoveTab(destinationSession string) error { | |||
| 467 | 479 | if e.sess == nil || e.tab == nil { | |
| 468 | 480 | return errMovePaneInvalid | |
| 469 | 481 | } | |
| 482 | + | attachment := e.target.attachment | |
| 483 | + | token := attachmentConnectionToken{} | |
| 484 | + | if attachment != nil { | |
| 485 | + | token = e.sess.attachmentToken(attachment, attachment.transport()) | |
| 486 | + | if token.ac == nil { | |
| 487 | + | return errMoveStaleTarget | |
| 488 | + | } | |
| 489 | + | } | |
| 470 | 490 | return e.d.moveTab(moveTabRequest{ | |
| 471 | - | Source: sessionMoveLocator(e.sess), | |
| 472 | - | SourceTabID: domain.TabStableID(e.tab.stableID), | |
| 473 | - | Destination: sessionMoveLocator(destination), | |
| 491 | + | Attachment: attachment, | |
| 492 | + | AttachmentToken: token, | |
| 493 | + | Source: sessionMoveLocator(e.sess), | |
| 494 | + | SourceTabID: domain.TabStableID(e.tab.stableID), | |
| 495 | + | Destination: sessionMoveLocator(destination), | |
| 474 | 496 | }) | |
| 475 | 497 | } | |
| 476 | 498 | ||
| @@ -657,8 +679,8 @@ func (e controlExec) ListTabs(asJSON bool) (string, error) { | |||
| 657 | 679 | } | |
| 658 | 680 | e.sess.mu.Lock() | |
| 659 | 681 | tabs := append([]*tab(nil), e.sess.tabs...) | |
| 660 | - | active := e.sess.active | |
| 661 | 682 | e.sess.mu.Unlock() | |
| 683 | + | active, _ := e.sess.tabIndexForAttachment(e.target.attachment) | |
| 662 | 684 | rows := make([]row, 0, len(tabs)) | |
| 663 | 685 | for i, tb := range tabs { | |
| 664 | 686 | tb.mu.Lock() | |
| @@ -686,7 +708,11 @@ func (e controlExec) ListPanes(asJSON bool) (string, error) { | |||
| 686 | 708 | } | |
| 687 | 709 | tb := e.tab | |
| 688 | 710 | if tb == nil { | |
| 689 | - | tb = e.sess.activeTab() | |
| 711 | + | if e.target.attachment == nil { | |
| 712 | + | tb = e.sess.firstTab() | |
| 713 | + | } else { | |
| 714 | + | tb = e.sess.tabForAttachment(e.target.attachment) | |
| 715 | + | } | |
| 690 | 716 | } | |
| 691 | 717 | if tb == nil { | |
| 692 | 718 | return "", errors.New("target session has no active tab") | |
| @@ -694,8 +720,17 @@ func (e controlExec) ListPanes(asJSON bool) (string, error) { | |||
| 694 | 720 | e.sess.mu.Lock() | |
| 695 | 721 | fallbackCWD := e.sess.cwd | |
| 696 | 722 | e.sess.mu.Unlock() | |
| 723 | + | var focus layout.PaneID | |
| 724 | + | var focusStableID domain.PaneStableID | |
| 725 | + | if e.target.attachment != nil { | |
| 726 | + | focusStableID = e.target.attachment.viewSnapshot().paneID | |
| 727 | + | } | |
| 697 | 728 | tb.mu.Lock() | |
| 698 | - | focus := tb.tree.Focus | |
| 729 | + | if e.target.attachment == nil { | |
| 730 | + | if focused := tb.focusedPane(); focused != nil { | |
| 731 | + | focus = focused.id | |
| 732 | + | } | |
| 733 | + | } | |
| 699 | 734 | panes := tb.panesSnapshot() | |
| 700 | 735 | tb.mu.Unlock() | |
| 701 | 736 | rows := make([]row, 0, len(panes)) | |
| @@ -713,7 +748,11 @@ func (e controlExec) ListPanes(asJSON bool) (string, error) { | |||
| 713 | 748 | cwd = live | |
| 714 | 749 | } | |
| 715 | 750 | } | |
| 716 | - | rows = append(rows, row{ID: pane.stableID, Pane: string(pane.id), Size: fmt.Sprintf("%dx%d", size.Cols, size.Rows), CWD: cwd, Focused: pane.id == focus}) | |
| 751 | + | focused := pane.id == focus | |
| 752 | + | if e.target.attachment != nil { | |
| 753 | + | focused = domain.PaneStableID(pane.stableID) == focusStableID | |
| 754 | + | } | |
| 755 | + | rows = append(rows, row{ID: pane.stableID, Pane: string(pane.id), Size: fmt.Sprintf("%dx%d", size.Cols, size.Rows), CWD: cwd, Focused: focused}) | |
| 717 | 756 | } | |
| 718 | 757 | sort.Slice(rows, func(i, j int) bool { return rows[i].Pane < rows[j].Pane }) | |
| 719 | 758 | if asJSON { | |
M internal/usecase/daemon/control_test.go +59 -38
| @@ -37,7 +37,7 @@ func TestPaletteAndControlShareExplicitDaemonActionTarget(t *testing.T) { | |||
| 37 | 37 | t.Run(tt.name, func(t *testing.T) { | |
| 38 | 38 | d := newTestDaemon(t, nil, stubClock{}) | |
| 39 | 39 | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 40 | - | tb := sess.activeTab() | |
| 40 | + | tb := testAttachmentTab(sess) | |
| 41 | 41 | tb.mu.Lock() | |
| 42 | 42 | pane := tb.focusedPane() | |
| 43 | 43 | tb.mu.Unlock() | |
| @@ -78,9 +78,7 @@ func TestConsumeOrExpelControlSelfTargetsNonFocusedPane(t *testing.T) { | |||
| 78 | 78 | ||
| 79 | 79 | require.True(t, result.OK, result.Text) | |
| 80 | 80 | require.Empty(t, result.Output) | |
| 81 | - | h.session.mu.Lock() | |
| 82 | - | require.Zero(t, h.session.active, "--self must not change the active tab") | |
| 83 | - | h.session.mu.Unlock() | |
| 81 | + | require.Zero(t, testAttachmentTabIndex(h.session), "--self must not change the attachment tab") | |
| 84 | 82 | h.tab.mu.Lock() | |
| 85 | 83 | require.Equal(t, layout.PaneID("pane-3"), h.tab.tree.Focus, "a moved explicit target becomes focused") | |
| 86 | 84 | require.Equal(t, beforeGeneration+1, h.tab.layoutGeneration) | |
| @@ -93,7 +91,7 @@ func TestConsumeOrExpelControlEdgeNoopReturnsOKAndPreservesFocus(t *testing.T) { | |||
| 93 | 91 | ac := &attachedClient{} | |
| 94 | 92 | ac.setSession(h.session) | |
| 95 | 93 | h.session.mu.Lock() | |
| 96 | - | h.session.client = ac | |
| 94 | + | h.session.registerAttachmentLocked(ac) | |
| 97 | 95 | h.session.mu.Unlock() | |
| 98 | 96 | invalidations := make(chan renderInvalidation, 1) | |
| 99 | 97 | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |
| @@ -240,7 +238,7 @@ func TestHandleCommandStableIDsResolveSessionWithoutSelectingTab(t *testing.T) { | |||
| 240 | 238 | second.ctx, second.cancel = context.WithCancel(d.serveCtx) | |
| 241 | 239 | sess.mu.Lock() | |
| 242 | 240 | sess.tabs = append(sess.tabs, second) | |
| 243 | - | sess.active = 0 | |
| 241 | + | selectTestAttachmentTabLocked(sess, 0) | |
| 244 | 242 | sess.mu.Unlock() | |
| 245 | 243 | ||
| 246 | 244 | result := sendCommand(t, d, ports.CommandRequest{ | |
| @@ -256,7 +254,7 @@ func TestHandleCommandStableIDsResolveSessionWithoutSelectingTab(t *testing.T) { | |||
| 256 | 254 | require.Empty(t, second.name) | |
| 257 | 255 | second.mu.Unlock() | |
| 258 | 256 | sess.mu.Lock() | |
| 259 | - | require.Zero(t, sess.active) | |
| 257 | + | require.Zero(t, testAttachmentTabIndexLocked(sess)) | |
| 260 | 258 | sess.mu.Unlock() | |
| 261 | 259 | } | |
| 262 | 260 | ||
| @@ -272,7 +270,7 @@ func TestHandleCommandStableIDsDoNotRedirectSplitFromCurrentFocus(t *testing.T) | |||
| 272 | 270 | second.ctx, second.cancel = context.WithCancel(d.serveCtx) | |
| 273 | 271 | sess.mu.Lock() | |
| 274 | 272 | sess.tabs = append(sess.tabs, second) | |
| 275 | - | sess.active = 0 | |
| 273 | + | selectTestAttachmentTabLocked(sess, 0) | |
| 276 | 274 | sess.mu.Unlock() | |
| 277 | 275 | ||
| 278 | 276 | result := sendCommand(t, d, ports.CommandRequest{ | |
| @@ -282,7 +280,7 @@ func TestHandleCommandStableIDsDoNotRedirectSplitFromCurrentFocus(t *testing.T) | |||
| 282 | 280 | ||
| 283 | 281 | require.True(t, result.OK, result.Text) | |
| 284 | 282 | sess.mu.Lock() | |
| 285 | - | activeIndex := sess.active | |
| 283 | + | activeIndex := testAttachmentTabIndexLocked(sess) | |
| 286 | 284 | sess.mu.Unlock() | |
| 287 | 285 | require.Zero(t, activeIndex) | |
| 288 | 286 | active.mu.Lock() | |
| @@ -309,7 +307,7 @@ func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) { | |||
| 309 | 307 | invoking.ctx, invoking.cancel = context.WithCancel(d.serveCtx) | |
| 310 | 308 | sess.mu.Lock() | |
| 311 | 309 | sess.tabs = append(sess.tabs, invoking) | |
| 312 | - | sess.active = 0 | |
| 310 | + | selectTestAttachmentTabLocked(sess, 0) | |
| 313 | 311 | sess.mu.Unlock() | |
| 314 | 312 | ||
| 315 | 313 | rename := sendCommand(t, d, ports.CommandRequest{ | |
| @@ -383,7 +381,7 @@ func TestResizeControlOneShotsTargetDetachedSessions(t *testing.T) { | |||
| 383 | 381 | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 384 | 382 | require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK) | |
| 385 | 383 | require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-down", TargetSession: "work"}).OK) | |
| 386 | - | tb := sess.activeTab() | |
| 384 | + | tb := testAttachmentTab(sess) | |
| 387 | 385 | tb.mu.Lock() | |
| 388 | 386 | generation := tb.layoutGeneration | |
| 389 | 387 | tb.mu.Unlock() | |
| @@ -403,7 +401,7 @@ func TestResizeControlOneShotsTargetDetachedSessions(t *testing.T) { | |||
| 403 | 401 | require.Equal(t, snapshotGeneration+1, sess.snapshotGeneration, "one accepted action has one snapshot dirty boundary") | |
| 404 | 402 | sess.snapshotMu.Unlock() | |
| 405 | 403 | sess.mu.Lock() | |
| 406 | - | require.Nil(t, sess.client, "one-shots must work headless") | |
| 404 | + | require.Empty(t, sess.snapshotAttachmentsLocked(), "one-shots must work headless") | |
| 407 | 405 | sess.mu.Unlock() | |
| 408 | 406 | }) | |
| 409 | 407 | } | |
| @@ -422,7 +420,7 @@ func TestResizeControlNonSelfVEVLocatorUsesActiveTarget(t *testing.T) { | |||
| 422 | 420 | locator.ctx, locator.cancel = context.WithCancel(d.serveCtx) | |
| 423 | 421 | sess.mu.Lock() | |
| 424 | 422 | sess.tabs = append(sess.tabs, locator) | |
| 425 | - | sess.active = 0 | |
| 423 | + | selectTestAttachmentTabLocked(sess, 0) | |
| 426 | 424 | sess.mu.Unlock() | |
| 427 | 425 | active.mu.Lock() | |
| 428 | 426 | before := active.layoutGeneration | |
| @@ -439,7 +437,7 @@ func TestResizeControlNonSelfVEVLocatorUsesActiveTarget(t *testing.T) { | |||
| 439 | 437 | require.Zero(t, locator.layoutGeneration) | |
| 440 | 438 | locator.mu.Unlock() | |
| 441 | 439 | sess.mu.Lock() | |
| 442 | - | require.Zero(t, sess.active) | |
| 440 | + | require.Zero(t, testAttachmentTabIndexLocked(sess)) | |
| 443 | 441 | sess.mu.Unlock() | |
| 444 | 442 | } | |
| 445 | 443 | ||
| @@ -449,7 +447,7 @@ func TestResizeControlTooSmallUsesStableFailure(t *testing.T) { | |||
| 449 | 447 | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |
| 450 | 448 | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 451 | 449 | require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK) | |
| 452 | - | tb := sess.activeTab() | |
| 450 | + | tb := testAttachmentTab(sess) | |
| 453 | 451 | tb.mu.Lock() | |
| 454 | 452 | tb.size.Cols = 41 | |
| 455 | 453 | tb.bumpLayoutGenerationLocked() | |
| @@ -468,7 +466,7 @@ func TestHandleCommandSelfListPanesUsesInvokingTab(t *testing.T) { | |||
| 468 | 466 | invoking.ctx, invoking.cancel = context.WithCancel(d.serveCtx) | |
| 469 | 467 | sess.mu.Lock() | |
| 470 | 468 | sess.tabs = append(sess.tabs, invoking) | |
| 471 | - | sess.active = 0 | |
| 469 | + | selectTestAttachmentTabLocked(sess, 0) | |
| 472 | 470 | sess.mu.Unlock() | |
| 473 | 471 | ||
| 474 | 472 | result := sendCommand(t, d, ports.CommandRequest{ | |
| @@ -539,7 +537,7 @@ func TestCloseCommandsReportMutationOutcome(t *testing.T) { | |||
| 539 | 537 | second.ctx, second.cancel = context.WithCancel(d.serveCtx) | |
| 540 | 538 | sess.mu.Lock() | |
| 541 | 539 | sess.tabs = append(sess.tabs, second) | |
| 542 | - | sess.active = 1 | |
| 540 | + | selectTestAttachmentTabLocked(sess, 1) | |
| 543 | 541 | sess.mu.Unlock() | |
| 544 | 542 | ||
| 545 | 543 | result := sendCommand(t, d, ports.CommandRequest{Slug: "close-tab", TargetSession: "work"}) | |
| @@ -547,7 +545,7 @@ func TestCloseCommandsReportMutationOutcome(t *testing.T) { | |||
| 547 | 545 | require.True(t, result.OK, result.Text) | |
| 548 | 546 | sess.mu.Lock() | |
| 549 | 547 | require.Len(t, sess.tabs, 1) | |
| 550 | - | require.Equal(t, "t_first", sess.tabs[0].stableID) | |
| 548 | + | require.Equal(t, "t_second", sess.tabs[0].stableID) | |
| 551 | 549 | sess.mu.Unlock() | |
| 552 | 550 | }) | |
| 553 | 551 | } | |
| @@ -560,7 +558,7 @@ func TestHandleCommandHeadlessMutations(t *testing.T) { | |||
| 560 | 558 | verify func(*testing.T, *Daemon, *session) | |
| 561 | 559 | }{ | |
| 562 | 560 | {name: "split focused pane", slug: "split-right", verify: func(t *testing.T, _ *Daemon, sess *session) { | |
| 563 | - | tb := sess.activeTab() | |
| 561 | + | tb := testAttachmentTab(sess) | |
| 564 | 562 | tb.mu.Lock() | |
| 565 | 563 | defer tb.mu.Unlock() | |
| 566 | 564 | require.Len(t, tb.panes, 2) | |
| @@ -569,10 +567,10 @@ func TestHandleCommandHeadlessMutations(t *testing.T) { | |||
| 569 | 567 | sess.mu.Lock() | |
| 570 | 568 | defer sess.mu.Unlock() | |
| 571 | 569 | require.Len(t, sess.tabs, 2) | |
| 572 | - | require.Equal(t, 1, sess.active) | |
| 570 | + | require.Equal(t, 0, testAttachmentTabIndexLocked(sess)) | |
| 573 | 571 | }}, | |
| 574 | 572 | {name: "rename tab", slug: "rename-tab", args: []string{"logs"}, verify: func(t *testing.T, _ *Daemon, sess *session) { | |
| 575 | - | tb := sess.activeTab() | |
| 573 | + | tb := testAttachmentTab(sess) | |
| 576 | 574 | tb.mu.Lock() | |
| 577 | 575 | defer tb.mu.Unlock() | |
| 578 | 576 | require.Equal(t, "logs", tb.name) | |
| @@ -613,7 +611,7 @@ func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T | |||
| 613 | 611 | d.mu.Unlock() | |
| 614 | 612 | require.NotNil(t, created) | |
| 615 | 613 | created.mu.Lock() | |
| 616 | - | require.Nil(t, created.client) | |
| 614 | + | require.Empty(t, created.snapshotAttachmentsLocked()) | |
| 617 | 615 | require.Equal(t, "/tmp/work", created.cwd) | |
| 618 | 616 | require.Equal(t, []string{"INHERITED=yes"}, created.env) | |
| 619 | 617 | require.True(t, created.terminal.TrueColor) | |
| @@ -622,7 +620,7 @@ func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T | |||
| 622 | 620 | tb.mu.Lock() | |
| 623 | 621 | require.Equal(t, domain.Size{Cols: 118, Rows: 38}, tb.size) | |
| 624 | 622 | tb.mu.Unlock() | |
| 625 | - | require.Nil(t, source.client) | |
| 623 | + | require.Empty(t, source.snapshotAttachments()) | |
| 626 | 624 | ||
| 627 | 625 | taken := sendCommand(t, d, ports.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"}) | |
| 628 | 626 | require.False(t, taken.OK) | |
| @@ -674,7 +672,7 @@ func TestRemoteCatalogJSONOutput(t *testing.T) { | |||
| 674 | 672 | d := newTestDaemon(t, nil, stubClock{}) | |
| 675 | 673 | work := addControlSession(d, "work", "t_work", "p_work") | |
| 676 | 674 | work.ephemeral = false | |
| 677 | - | work.client = &attachedClient{} | |
| 675 | + | work.registerAttachment(&attachedClient{}) | |
| 678 | 676 | work.mruAt.Store(2) | |
| 679 | 677 | tb := newTabWithStableID("t_work_2", "p_work_2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 680 | 678 | tb.ctx, tb.cancel = context.WithCancel(d.serveCtx) | |
| @@ -762,7 +760,7 @@ func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) { | |||
| 762 | 760 | target.ctx, target.cancel = context.WithCancel(d.serveCtx) | |
| 763 | 761 | sess.mu.Lock() | |
| 764 | 762 | sess.tabs = append(sess.tabs, target) | |
| 765 | - | sess.active = 0 | |
| 763 | + | selectTestAttachmentTabLocked(sess, 0) | |
| 766 | 764 | sess.mu.Unlock() | |
| 767 | 765 | original := target.tree.Focus | |
| 768 | 766 | ||
| @@ -808,7 +806,7 @@ func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) { | |||
| 808 | 806 | require.True(t, awaitCommandResult(t, secondSends).OK) | |
| 809 | 807 | ||
| 810 | 808 | sess.mu.Lock() | |
| 811 | - | require.Zero(t, sess.active, "self commands must not select the invoking tab") | |
| 809 | + | require.Zero(t, testAttachmentTabIndexLocked(sess), "self commands must not select the invoking tab") | |
| 812 | 810 | sess.mu.Unlock() | |
| 813 | 811 | target.mu.Lock() | |
| 814 | 812 | defer target.mu.Unlock() | |
| @@ -816,6 +814,27 @@ func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) { | |||
| 816 | 814 | require.NotEqual(t, original, target.tree.Focus, "second command must execute after the split creates its right neighbor") | |
| 817 | 815 | } | |
| 818 | 816 | ||
| 817 | + | func TestControlMoveRejectsMissingAttachmentCapability(t *testing.T) { | |
| 818 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 819 | + | source := addControlSession(d, "work", "t_work", "p_work") | |
| 820 | + | destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |
| 821 | + | tb := source.tabs[0] | |
| 822 | + | tb.mu.Lock() | |
| 823 | + | pane := tb.focusedPane() | |
| 824 | + | tb.mu.Unlock() | |
| 825 | + | ac := &attachedClient{tr: &closeTrackingTransport{}} | |
| 826 | + | ac.setSession(source) | |
| 827 | + | ||
| 828 | + | source.mu.Lock() | |
| 829 | + | require.True(t, source.registerAttachmentLocked(ac)) | |
| 830 | + | target := daemonActionTarget{session: source, attachment: ac, tab: tb, pane: pane} | |
| 831 | + | require.True(t, source.unregisterAttachmentLocked(ac)) | |
| 832 | + | source.mu.Unlock() | |
| 833 | + | ||
| 834 | + | err := (controlExec{d: d, sess: source, tab: tb, target: target}).MovePane(destination.name, destination.tabs[0].stableID) | |
| 835 | + | require.ErrorIs(t, err, errMoveStaleTarget) | |
| 836 | + | } | |
| 837 | + | ||
| 819 | 838 | func TestHandleCommandMovePaneUsesActiveFocusedSourceWithSessionFlag(t *testing.T) { | |
| 820 | 839 | d := newTestDaemon(t, nil, stubClock{}) | |
| 821 | 840 | source := addControlSession(d, "work", "t_active", "p_active") | |
| @@ -824,7 +843,7 @@ func TestHandleCommandMovePaneUsesActiveFocusedSourceWithSessionFlag(t *testing. | |||
| 824 | 843 | publishTiledPaneOwners(source, inactive) | |
| 825 | 844 | source.mu.Lock() | |
| 826 | 845 | source.tabs = append(source.tabs, inactive) | |
| 827 | - | source.active = 0 | |
| 846 | + | selectTestAttachmentTabLocked(source, 0) | |
| 828 | 847 | source.mu.Unlock() | |
| 829 | 848 | destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |
| 830 | 849 | ||
| @@ -859,7 +878,7 @@ func TestHandleCommandMovePaneSelfUsesStableSourceIDs(t *testing.T) { | |||
| 859 | 878 | publishTiledPaneOwners(source, inactive) | |
| 860 | 879 | source.mu.Lock() | |
| 861 | 880 | source.tabs = append(source.tabs, inactive) | |
| 862 | - | source.active = 0 | |
| 881 | + | selectTestAttachmentTabLocked(source, 0) | |
| 863 | 882 | source.mu.Unlock() | |
| 864 | 883 | destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |
| 865 | 884 | ||
| @@ -891,27 +910,29 @@ func TestHandleCommandMovePaneSelfUsesStableSourceIDs(t *testing.T) { | |||
| 891 | 910 | func TestHandleCommandMoveTabUsesActiveTabWithoutSelf(t *testing.T) { | |
| 892 | 911 | d := newTestDaemon(t, nil, stubClock{}) | |
| 893 | 912 | source := addControlSession(d, "work", "t_first", "p_first") | |
| 913 | + | publishTiledPaneOwners(source, source.tabs[0]) | |
| 894 | 914 | second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 895 | 915 | second.ctx, second.cancel = context.WithCancel(d.serveCtx) | |
| 896 | 916 | publishTiledPaneOwners(source, second) | |
| 897 | 917 | source.mu.Lock() | |
| 898 | 918 | source.tabs = append(source.tabs, second) | |
| 899 | - | source.active = 1 | |
| 919 | + | selectTestAttachmentTabLocked(source, 0) | |
| 900 | 920 | source.mu.Unlock() | |
| 901 | 921 | destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |
| 902 | 922 | ||
| 903 | 923 | result := sendCommand(t, d, ports.CommandRequest{ | |
| 904 | - | Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work", | |
| 924 | + | Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work", Self: false, | |
| 925 | + | TargetTab: "t_second", TargetPane: "p_second", | |
| 905 | 926 | }) | |
| 906 | 927 | require.True(t, result.OK, result.Text) | |
| 907 | 928 | ||
| 908 | 929 | source.mu.Lock() | |
| 909 | 930 | require.Len(t, source.tabs, 1) | |
| 910 | - | require.Equal(t, "t_first", source.tabs[0].stableID) | |
| 931 | + | require.Equal(t, "t_second", source.tabs[0].stableID) | |
| 911 | 932 | source.mu.Unlock() | |
| 912 | 933 | destination.mu.Lock() | |
| 913 | 934 | require.Len(t, destination.tabs, 2) | |
| 914 | - | require.Equal(t, "t_second", destination.tabs[1].stableID) | |
| 935 | + | require.Equal(t, "t_first", destination.tabs[1].stableID) | |
| 915 | 936 | destination.mu.Unlock() | |
| 916 | 937 | } | |
| 917 | 938 | ||
| @@ -923,7 +944,7 @@ func TestHandleCommandMoveTabSelfUsesStableTab(t *testing.T) { | |||
| 923 | 944 | publishTiledPaneOwners(source, second) | |
| 924 | 945 | source.mu.Lock() | |
| 925 | 946 | source.tabs = append(source.tabs, second) | |
| 926 | - | source.active = 0 | |
| 947 | + | selectTestAttachmentTabLocked(source, 0) | |
| 927 | 948 | source.mu.Unlock() | |
| 928 | 949 | destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |
| 929 | 950 | ||
| @@ -1093,12 +1114,12 @@ func TestHandleCommandMovePaneStableIDsLocateSessionWithoutSelfRedirect(t *testi | |||
| 1093 | 1114 | publishTiledPaneOwners(source, inactive) | |
| 1094 | 1115 | source.mu.Lock() | |
| 1095 | 1116 | source.tabs = append(source.tabs, inactive) | |
| 1096 | - | source.active = 1 | |
| 1117 | + | selectTestAttachmentTabLocked(source, 1) | |
| 1097 | 1118 | source.mu.Unlock() | |
| 1098 | 1119 | destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |
| 1099 | 1120 | ||
| 1100 | 1121 | result := sendCommand(t, d, ports.CommandRequest{ | |
| 1101 | - | Slug: "move-pane", Args: []string{"dest", "t_dest"}, | |
| 1122 | + | Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true, | |
| 1102 | 1123 | TargetSession: "old-name", TargetTab: "t_inactive", TargetPane: "p_inactive", | |
| 1103 | 1124 | }) | |
| 1104 | 1125 | require.True(t, result.OK, result.Text) | |
| @@ -1126,7 +1147,7 @@ func TestHandleCommandOppositeMoveCommandsDoNotDeadlock(t *testing.T) { | |||
| 1126 | 1147 | left.mu.Lock() | |
| 1127 | 1148 | left.name = "left" | |
| 1128 | 1149 | left.tabs[0].stableID = "left-moved" | |
| 1129 | - | left.active = 0 | |
| 1150 | + | selectTestAttachmentTabLocked(left, 0) | |
| 1130 | 1151 | left.mu.Unlock() | |
| 1131 | 1152 | ||
| 1132 | 1153 | right := addMoveTabTestSession(d, "right", "right-stays") | |
| @@ -1135,7 +1156,7 @@ func TestHandleCommandOppositeMoveCommandsDoNotDeadlock(t *testing.T) { | |||
| 1135 | 1156 | publishTiledPaneOwners(right, rightMoved) | |
| 1136 | 1157 | right.mu.Lock() | |
| 1137 | 1158 | right.tabs = append(right.tabs, rightMoved) | |
| 1138 | - | right.active = 1 | |
| 1159 | + | selectTestAttachmentTabLocked(right, 1) | |
| 1139 | 1160 | right.mu.Unlock() | |
| 1140 | 1161 | ||
| 1141 | 1162 | leftFrame := commandFrame(t, ports.CommandRequest{ | |
| @@ -1179,7 +1200,7 @@ func addNamedMoveDestination(d *Daemon, name, tabID, paneID string) *session { | |||
| 1179 | 1200 | tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 23}) | |
| 1180 | 1201 | tb.ctx, tb.cancel = context.WithCancel(ctx) | |
| 1181 | 1202 | sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, incarnation: domain.IncarnationID{5}, | |
| 1182 | - | ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0, | |
| 1203 | + | ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, | |
| 1183 | 1204 | } | |
| 1184 | 1205 | publishTiledPaneOwners(sess, tb) | |
| 1185 | 1206 | d.mu.Lock() | |
M internal/usecase/daemon/copy_enter_allocation_budget_race_test.go +1 -1
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | 3 | package daemon | |
| 4 | 4 | ||
| 5 | - | // Race instrumentation allocations are not part of the parent benchmark's | |
| 5 | + | // Race instrumentation allocations are not part of the non-race benchmark's | |
| 6 | 6 | // allocation baseline. TestCopyEnterAllocationBudget still exercises copy | |
| 7 | 7 | // entry and validates its result under -race. | |
| 8 | 8 | const copyEnterAllocationBudgetEnabled = false | |
M internal/usecase/daemon/copy_enter_allocation_budget_test.go +1 -1
| @@ -3,5 +3,5 @@ | |||
| 3 | 3 | package daemon | |
| 4 | 4 | ||
| 5 | 5 | // copyEnterAllocationBudgetEnabled keeps the allocation gate aligned with the | |
| 6 | - | // non-race parent benchmark, whose allocation baseline defines the budget. | |
| 6 | + | // non-race benchmark, whose allocation baseline defines the budget. | |
| 7 | 7 | const copyEnterAllocationBudgetEnabled = true | |
M internal/usecase/daemon/copy_mouse_input.go +1 -1
| @@ -179,7 +179,7 @@ func (d *Daemon) handleFreshCopyPointer(sess *session, ac *attachedClient, ev mo | |||
| 179 | 179 | if !ok { | |
| 180 | 180 | return true | |
| 181 | 181 | } | |
| 182 | - | if !d.publishCopyMode(sess, ac, sess.activeTab(), pointer.pane, pointer.document, func(mode *scopy.Mode) { | |
| 182 | + | if !d.publishCopyMode(sess, ac, sess.tabForAttachment(ac), pointer.pane, pointer.document, func(mode *scopy.Mode) { | |
| 183 | 183 | mode.StartCharacterSelection(pointer.press) | |
| 184 | 184 | mode.ExtendCharacterSelection(mapped.pos) | |
| 185 | 185 | }, func(runtime *overlayRuntime, mode *scopy.Mode) { | |
M internal/usecase/daemon/copy_mouse_map_test.go +1 -1
| @@ -71,7 +71,7 @@ func TestCopyMouseGeometryUsesClientFrameExactlyOnce(t *testing.T) { | |||
| 71 | 71 | p, release := newBlockingPTY(t) | |
| 72 | 72 | defer release() | |
| 73 | 73 | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 74 | - | tb := sess.activeTab() | |
| 74 | + | tb := testAttachmentTab(sess) | |
| 75 | 75 | ||
| 76 | 76 | tb.mu.Lock() | |
| 77 | 77 | geometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), 0, 1) | |
M internal/usecase/daemon/copymode.go +2 -2
| @@ -63,7 +63,7 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | |||
| 63 | 63 | d.invalidateRender(sess, ac, true, "copymode.go") | |
| 64 | 64 | } | |
| 65 | 65 | func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |
| 66 | - | tb := sess.activeTab() | |
| 66 | + | tb := sess.tabForAttachment(ac) | |
| 67 | 67 | if tb == nil { | |
| 68 | 68 | return | |
| 69 | 69 | } | |
| @@ -114,7 +114,7 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p * | |||
| 114 | 114 | if d.beforeCopyModeRevalidate != nil { | |
| 115 | 115 | d.beforeCopyModeRevalidate() | |
| 116 | 116 | } | |
| 117 | - | valid := sess.activeTab() == tb | |
| 117 | + | valid := sess.tabForAttachment(ac) == tb | |
| 118 | 118 | if valid { | |
| 119 | 119 | tb.mu.Lock() | |
| 120 | 120 | valid = tb.panes[p.id] == p || (tb.floating.state == floatingVisible && tb.floating.pane == p) | |
M internal/usecase/daemon/copymode_lifecycle_test.go +12 -12
| @@ -45,8 +45,8 @@ func TestCopyModeLifecycleActivateTabClearsRuntime(t *testing.T) { | |||
| 45 | 45 | seedCopyInteractionLocked(ac.overlays, ac.overlays.copyPane, ac.overlays.copyDocument) | |
| 46 | 46 | ac.overlays.copyMu.Unlock() | |
| 47 | 47 | ||
| 48 | - | require.True(t, sess.switchTab(1)) | |
| 49 | - | d.activateTab(sess, sess.activeTab()) | |
| 48 | + | require.True(t, selectTestAttachmentTab(sess, 1)) | |
| 49 | + | d.activateTab(sess, testAttachmentTab(sess)) | |
| 50 | 50 | ||
| 51 | 51 | require.False(t, ac.overlays.copyActive()) | |
| 52 | 52 | ac.overlays.copyMu.Lock() | |
| @@ -80,7 +80,7 @@ func TestActivateTabAfterResizePathAndCopyModeLifecycle(t *testing.T) { | |||
| 80 | 80 | ||
| 81 | 81 | d.enterCopyMode(sess, ac) | |
| 82 | 82 | require.True(t, ac.overlays.copyActive()) | |
| 83 | - | d.activateTabAfterResizeForLease(sess, sess.activeTab(), true, ac, lease) | |
| 83 | + | d.activateTabAfterResizeForLease(sess, testAttachmentTab(sess), true, ac, lease) | |
| 84 | 84 | ||
| 85 | 85 | require.False(t, ac.overlays.copyActive()) | |
| 86 | 86 | require.Equal(t, tt.leaseBacked, <-path) | |
| @@ -90,7 +90,7 @@ func TestActivateTabAfterResizePathAndCopyModeLifecycle(t *testing.T) { | |||
| 90 | 90 | ||
| 91 | 91 | func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) { | |
| 92 | 92 | d, sess, _, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 93 | - | tb := sess.activeTab() | |
| 93 | + | tb := testAttachmentTab(sess) | |
| 94 | 94 | closingPTY := portsmocks.NewMockPTY(t) | |
| 95 | 95 | closingPTY.EXPECT().Close().Return(nil).Once() | |
| 96 | 96 | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"} | |
| @@ -98,7 +98,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) { | |||
| 98 | 98 | tb.panes["pane-2"] = closing | |
| 99 | 99 | ac := &attachedClient{output: newOutputStateStream()} | |
| 100 | 100 | ac.initOverlays() | |
| 101 | - | sess.client = ac | |
| 101 | + | sess.registerAttachment(ac) | |
| 102 | 102 | ac.setSession(sess) | |
| 103 | 103 | closing.mu.Lock() | |
| 104 | 104 | document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame, closing.screen.LineBounds(), nil), domain.DefaultWordSeparators) | |
| @@ -126,7 +126,7 @@ func TestCopyModeLifecycleCloseOnlyPaneInTabClearsRecoveredClientState(t *testin | |||
| 126 | 126 | release() | |
| 127 | 127 | } | |
| 128 | 128 | }() | |
| 129 | - | tb := sess.activeTab() | |
| 129 | + | tb := testAttachmentTab(sess) | |
| 130 | 130 | p := tb.focusedPane() | |
| 131 | 131 | d.enterCopyMode(sess, ac) | |
| 132 | 132 | require.True(t, ac.overlays.copyActive()) | |
| @@ -143,7 +143,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) { | |||
| 143 | 143 | release() | |
| 144 | 144 | } | |
| 145 | 145 | }() | |
| 146 | - | tb := sess.activeTab() | |
| 146 | + | tb := testAttachmentTab(sess) | |
| 147 | 147 | p := tb.focusedPane() | |
| 148 | 148 | p.mu.Lock() | |
| 149 | 149 | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators) | |
| @@ -185,7 +185,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) { | |||
| 185 | 185 | renderSnapshot := ac.overlays.SnapshotForRender() | |
| 186 | 186 | candidateWasRenderable := renderSnapshot.copyActive | |
| 187 | 187 | renderSnapshot.Unlock() | |
| 188 | - | require.True(t, sess.switchTab(1)) | |
| 188 | + | require.True(t, selectTestAttachmentTab(sess, 1)) | |
| 189 | 189 | close(resume) | |
| 190 | 190 | resumed = true | |
| 191 | 191 | ||
| @@ -225,7 +225,7 @@ func TestCopyModeLifecyclePublicationEpochRejectsReleaseAndPaneClose(t *testing. | |||
| 225 | 225 | release() | |
| 226 | 226 | } | |
| 227 | 227 | }() | |
| 228 | - | tb := sess.activeTab() | |
| 228 | + | tb := testAttachmentTab(sess) | |
| 229 | 229 | p := tb.focusedPane() | |
| 230 | 230 | ||
| 231 | 231 | // A real press is required: the motion publication must inherit this | |
| @@ -285,7 +285,7 @@ func TestCopyModeLifecycleFloatingCloseDuringPublicationDoesNotResurrect(t *test | |||
| 285 | 285 | release() | |
| 286 | 286 | } | |
| 287 | 287 | }() | |
| 288 | - | tb := sess.activeTab() | |
| 288 | + | tb := testAttachmentTab(sess) | |
| 289 | 289 | floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}) | |
| 290 | 290 | installTestFloating(tb, floating, true) | |
| 291 | 291 | tb.mu.Lock() | |
| @@ -356,7 +356,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing. | |||
| 356 | 356 | { | |
| 357 | 357 | name: "inactive tab", | |
| 358 | 358 | invalidate: func(sess *session, _ *tab, _ *pane) { | |
| 359 | - | require.True(t, sess.switchTab(1)) | |
| 359 | + | require.True(t, selectTestAttachmentTab(sess, 1)) | |
| 360 | 360 | }, | |
| 361 | 361 | }, | |
| 362 | 362 | } { | |
| @@ -367,7 +367,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing. | |||
| 367 | 367 | release() | |
| 368 | 368 | } | |
| 369 | 369 | }() | |
| 370 | - | tb := sess.activeTab() | |
| 370 | + | tb := testAttachmentTab(sess) | |
| 371 | 371 | p := tb.focusedPane() | |
| 372 | 372 | p.mu.Lock() | |
| 373 | 373 | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators) | |
M internal/usecase/daemon/copymode_mouse_test.go +5 -5
| @@ -26,7 +26,7 @@ func mouseCopyHarness(t *testing.T, rows ...string) (*Daemon, *session, *attache | |||
| 26 | 26 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 27 | 27 | clock := &manualMouseClock{now: time.Unix(1, 0)} | |
| 28 | 28 | d.clock = clock | |
| 29 | - | pane := sess.activeTab().focusedPane() | |
| 29 | + | pane := testAttachmentTab(sess).focusedPane() | |
| 30 | 30 | for i, row := range rows { | |
| 31 | 31 | copy(pane.screen.Frame.Row(i), testRow(row)) | |
| 32 | 32 | } | |
| @@ -41,7 +41,7 @@ func copyMouseInput(d *Daemon, sess *session, ac *attachedClient, raw string) { | |||
| 41 | 41 | func TestCopyDoubleClick(t *testing.T) { | |
| 42 | 42 | t.Run("same normalized position within interval selects word", func(t *testing.T) { | |
| 43 | 43 | d, sess, ac, clock := mouseCopyHarness(t, "a界 beta") | |
| 44 | - | pane := sess.activeTab().focusedPane() | |
| 44 | + | pane := testAttachmentTab(sess).focusedPane() | |
| 45 | 45 | d.exitCopyMode(ac) | |
| 46 | 46 | pane.mu.Lock() | |
| 47 | 47 | row := pane.screen.Frame.Row(0) | |
| @@ -324,7 +324,7 @@ func TestCopyWordSelectionYanksExactOSC52(t *testing.T) { | |||
| 324 | 324 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 325 | 325 | clock := &manualMouseClock{now: time.Unix(1, 0)} | |
| 326 | 326 | d.clock = clock | |
| 327 | - | copy(sess.activeTab().focusedPane().screen.Frame.Row(0), testRow("alpha beta")) | |
| 327 | + | copy(testAttachmentTab(sess).focusedPane().screen.Frame.Row(0), testRow("alpha beta")) | |
| 328 | 328 | d.enterCopyMode(sess, ac) | |
| 329 | 329 | mustOutputData(t, sends) | |
| 330 | 330 | ||
| @@ -348,11 +348,11 @@ func TestCopyPointerAndClickResetOnCopyExitAndReplacement(t *testing.T) { | |||
| 348 | 348 | {name: "escape", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("\x1bx")) }}, | |
| 349 | 349 | {name: "yank", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("y")) }}, | |
| 350 | 350 | {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) { | |
| 351 | - | p := sess.activeTab().focusedPane() | |
| 351 | + | p := testAttachmentTab(sess).focusedPane() | |
| 352 | 352 | p.mu.Lock() | |
| 353 | 353 | doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators) | |
| 354 | 354 | p.mu.Unlock() | |
| 355 | - | require.True(t, d.publishCopyMode(sess, ac, sess.activeTab(), p, doc, nil, nil)) | |
| 355 | + | require.True(t, d.publishCopyMode(sess, ac, testAttachmentTab(sess), p, doc, nil, nil)) | |
| 356 | 356 | }}, | |
| 357 | 357 | } { | |
| 358 | 358 | t.Run(tc.name, func(t *testing.T) { | |
M internal/usecase/daemon/copymode_test.go +11 -11
| @@ -48,7 +48,7 @@ func TestOwnedSynchronousSendReturnsCapturedTransportAcrossReplacement(t *testin | |||
| 48 | 48 | ac := &attachedClient{output: newOutputStateStream()} | |
| 49 | 49 | failed := &ownedSwapErrorTransport{ac: ac, replacement: replacement, err: sendErr, sent: make(chan ports.Frame, 1)} | |
| 50 | 50 | ac.replaceTransport(failed) | |
| 51 | - | sess := &session{sessionCore: sessionCore{name: "work", client: ac}} | |
| 51 | + | sess := &session{sessionCore: sessionCore{name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}} | |
| 52 | 52 | ac.setSession(sess) | |
| 53 | 53 | ||
| 54 | 54 | used, err := d.boundedSendOutputErrTransport(ac, []byte("copy")) | |
| @@ -60,7 +60,7 @@ func TestOwnedSynchronousSendReturnsCapturedTransportAcrossReplacement(t *testin | |||
| 60 | 60 | require.NoError(t, decodeErr) | |
| 61 | 61 | require.Equal(t, []byte("copy"), out.Data) | |
| 62 | 62 | d.detachOnSendError(sess, ac, used) | |
| 63 | - | require.Same(t, ac, sess.client) | |
| 63 | + | require.Contains(t, sess.snapshotAttachments(), ac) | |
| 64 | 64 | require.False(t, replacement.Closed()) | |
| 65 | 65 | } | |
| 66 | 66 | ||
| @@ -108,7 +108,7 @@ func TestCopySearchModalGeometry(t *testing.T) { | |||
| 108 | 108 | func TestCopyModeDocumentCarriesPaneRowIDs(t *testing.T) { | |
| 109 | 109 | p, _ := newBlockingPTY(t) | |
| 110 | 110 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 111 | - | pane := sess.activeTab().focusedPane() | |
| 111 | + | pane := testAttachmentTab(sess).focusedPane() | |
| 112 | 112 | appendHistoryRow(t, pane.history, testRow("history")) | |
| 113 | 113 | pane.screen.Write([]byte("live")) | |
| 114 | 114 | historyID := pane.history.View().RowID(0) | |
| @@ -128,7 +128,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) { | |||
| 128 | 128 | p, release := newBlockingPTY(t) | |
| 129 | 129 | defer release() | |
| 130 | 130 | _, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 131 | - | tb := sess.activeTab() | |
| 131 | + | tb := testAttachmentTab(sess) | |
| 132 | 132 | pane := tb.focusedPane() | |
| 133 | 133 | pane.mu.Lock() | |
| 134 | 134 | snap := scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds(), nil) | |
| @@ -162,7 +162,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) { | |||
| 162 | 162 | _, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 163 | 163 | defer release() | |
| 164 | 164 | sess.name = "work" | |
| 165 | - | tb := sess.activeTab() | |
| 165 | + | tb := testAttachmentTab(sess) | |
| 166 | 166 | tb.focusedPane().screen = vt.NewScreen(12, 3) | |
| 167 | 167 | tb.focusedPane().screen.Write([]byte("live")) | |
| 168 | 168 | snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame, tb.focusedPane().screen.LineBounds(), nil) | |
| @@ -441,7 +441,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||
| 441 | 441 | if sess == nil { | |
| 442 | 442 | return false | |
| 443 | 443 | } | |
| 444 | - | win := sess.activeTab() | |
| 444 | + | win := testAttachmentTab(sess) | |
| 445 | 445 | if win == nil { | |
| 446 | 446 | return false | |
| 447 | 447 | } | |
| @@ -458,7 +458,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||
| 458 | 458 | ||
| 459 | 459 | sess := firstSession(d) | |
| 460 | 460 | require.NotNil(t, sess) | |
| 461 | - | ac := sess.client | |
| 461 | + | ac := sess.snapshotAttachments()[0] | |
| 462 | 462 | require.NotNil(t, ac) | |
| 463 | 463 | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 464 | 464 | awaitCoordinatorOutput(t, sends, clk.timers, "while advancing render clock", "controllable timers did not produce an output frame") | |
| @@ -662,7 +662,7 @@ func TestHandleCopyInputUsesImmutableSnapshotWithoutPaneLock(t *testing.T) { | |||
| 662 | 662 | p, release := newBlockingPTY(t) | |
| 663 | 663 | defer release() | |
| 664 | 664 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 665 | - | pane := sess.activeTab().focusedPane() | |
| 665 | + | pane := testAttachmentTab(sess).focusedPane() | |
| 666 | 666 | pane.screen.Write([]byte("live")) | |
| 667 | 667 | d.enterCopyMode(sess, ac) | |
| 668 | 668 | awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -729,7 +729,7 @@ func installFloatingCopyFixture(t *testing.T, sess *session, size domain.Size) * | |||
| 729 | 729 | floatingPTY, releaseFloating := newBlockingPTY(t) | |
| 730 | 730 | t.Cleanup(releaseFloating) | |
| 731 | 731 | fp := newPane("floating", floatingPTY, size) | |
| 732 | - | installTestFloating(sess.activeTab(), fp, true) | |
| 732 | + | installTestFloating(testAttachmentTab(sess), fp, true) | |
| 733 | 733 | return fp | |
| 734 | 734 | } | |
| 735 | 735 | ||
| @@ -748,7 +748,7 @@ func TestCopyModeCapturesSourceAndRetainsItAcrossFocusMove(t *testing.T) { | |||
| 748 | 748 | normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites) | |
| 749 | 749 | defer releaseNormal() | |
| 750 | 750 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | |
| 751 | - | tb := sess.activeTab() | |
| 751 | + | tb := testAttachmentTab(sess) | |
| 752 | 752 | second := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 5}) | |
| 753 | 753 | tb.mu.Lock() | |
| 754 | 754 | tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}} | |
| @@ -856,7 +856,7 @@ func TestFloatingExitClearsCopyModeBeforeRepaint(t *testing.T) { | |||
| 856 | 856 | normal, releaseNormal := newBlockingPTY(t) | |
| 857 | 857 | defer releaseNormal() | |
| 858 | 858 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | |
| 859 | - | tb := sess.activeTab() | |
| 859 | + | tb := testAttachmentTab(sess) | |
| 860 | 860 | fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | |
| 861 | 861 | fp.screen.Write([]byte("flt-live")) | |
| 862 | 862 | ||
M internal/usecase/daemon/daemon.go +64 -90
| @@ -144,45 +144,41 @@ type Daemon struct { | |||
| 144 | 144 | // after publishResizeCommit acquires attachment sendMu and before it acquires | |
| 145 | 145 | // owner fences. Tests must not perform external work from this callback. | |
| 146 | 146 | afterResizeCommitSendLocked func() | |
| 147 | - | // afterSnatchOverlayFamily is a deterministic structural lock seam. It runs | |
| 148 | - | // after one overlay-family mutex is released and before the next is taken. | |
| 149 | - | afterSnatchOverlayFamily func(string) | |
| 150 | - | // afterSnatchedEscapeAccepted observes a standalone ESC after parser state | |
| 151 | - | // is consumed and before its captured role and transport are revalidated. | |
| 152 | - | afterSnatchedEscapeAccepted func() | |
| 153 | - | // afterSnatchedEscapeAttempt reports whether that callback acquired its exact | |
| 154 | - | // role capability after revalidation. | |
| 155 | - | afterSnatchedEscapeAttempt func(bool) | |
| 156 | 147 | // afterConnectionSessionSnapshot is a deterministic test seam after the | |
| 157 | 148 | // connection loop reads its current session and before it captures that role. | |
| 158 | 149 | afterConnectionSessionSnapshot func(attachmentSession) | |
| 159 | - | // afterActiveFrameDispatch is a deterministic test seam after the connection | |
| 160 | - | // loop's first role check and before a decoded active frame takes effect. | |
| 161 | - | afterActiveFrameDispatch func(attachmentRoleToken) | |
| 162 | - | // afterRoleEffectParticipantsSnapshotted observes immutable role-gate | |
| 150 | + | // afterAttachmentFrameDispatch is a deterministic test seam after the | |
| 151 | + | // connection loop snapshots a token and before a decoded frame takes effect. | |
| 152 | + | afterAttachmentFrameDispatch func(attachmentConnectionToken) | |
| 153 | + | // afterAttachmentEffectParticipantsSnapshotted observes immutable role-gate | |
| 163 | 154 | // participants after architecture preflight locks are released and before | |
| 164 | 155 | // the globally ordered freeze begins. | |
| 165 | - | afterRoleEffectParticipantsSnapshotted func(string, []*attachedClient) | |
| 166 | - | // afterRoleEffectGateFrozen observes each participant after it is frozen in | |
| 156 | + | afterAttachmentEffectParticipantsSnapshotted func(string, []*attachedClient) | |
| 157 | + | // afterAttachmentEffectGateFrozen observes each participant after it is frozen in | |
| 167 | 158 | // immutable identity order and before drain/publication continues. | |
| 168 | - | afterRoleEffectGateFrozen func(string, *attachedClient) | |
| 169 | - | // afterRoleEffectsFrozen observes the lock-free boundary after all affected | |
| 159 | + | afterAttachmentEffectGateFrozen func(string, *attachedClient) | |
| 160 | + | // afterAttachmentEffectsFrozen observes the lock-free boundary after all affected | |
| 170 | 161 | // attachment gates are frozen and drained, before architecture publication. | |
| 171 | - | afterRoleEffectsFrozen func() | |
| 172 | - | // afterMoveLifecycleReserved and afterMovePaneSourceSnapshot are test-only | |
| 173 | - | // seams. They run before role/fence work and after the pre-fence source | |
| 162 | + | afterAttachmentEffectsFrozen func() | |
| 163 | + | // afterAttachmentTransitionCoordinatorsLocked is a deterministic lock-order | |
| 164 | + | // seam used by transition validation tests. | |
| 165 | + | afterAttachmentTransitionCoordinatorsLocked func() | |
| 166 | + | // beforeMoveDispatch, afterMoveLifecycleReserved, and | |
| 167 | + | // afterMovePaneSourceSnapshot are test-only seams. They run before dispatch | |
| 168 | + | // admission, after lifecycle reservation, and after the pre-fence source | |
| 174 | 169 | // attachment snapshot respectively. beforeMovePaneCommit runs inside the | |
| 175 | 170 | // non-failing publication section. None is set in production, and none may | |
| 176 | 171 | // perform external work while locks are held. | |
| 172 | + | beforeMoveDispatch func() | |
| 177 | 173 | afterMoveLifecycleReserved func() | |
| 178 | 174 | afterMoveLifecycleGateBeforeTeardownLocks func() | |
| 179 | 175 | afterMovePaneSourceSnapshot func() | |
| 180 | 176 | afterMoveTabSourceSnapshot func() | |
| 181 | 177 | beforeMovePaneCommit func() | |
| 182 | 178 | beforeMoveTabCommit func() | |
| 183 | - | // afterDetachRoleEffectsFrozen observes terminal detach after it wins the | |
| 179 | + | // afterDetachAttachmentEffectsFrozen observes terminal detach after it wins the | |
| 184 | 180 | // attachment gate but before it checks session ownership. | |
| 185 | - | afterDetachRoleEffectsFrozen func() | |
| 181 | + | afterDetachAttachmentEffectsFrozen func() | |
| 186 | 182 | // beforeClientGoneDetach pauses clientGone after the stale-transport | |
| 187 | 183 | // precheck and before exact transport/incarnation detach validation. | |
| 188 | 184 | beforeClientGoneDetach func() | |
| @@ -200,49 +196,38 @@ type Daemon struct { | |||
| 200 | 196 | // afterParkingWaitArmed observes waitParkingInFlight after it has resolved a | |
| 201 | 197 | // matching in-flight entry and before it blocks on done. | |
| 202 | 198 | afterParkingWaitArmed func() | |
| 203 | - | // afterSnatchedUnrouteBeforePark pauses parkSnatchedAttachment and | |
| 204 | - | // cleanupInterruptedSnatchedAttachment after exact snatched ownership is | |
| 205 | - | // removed and before parkAttachmentAs. The parking-in-flight marker must | |
| 206 | - | // already be published so a concurrent same-token resume can wait the gap. | |
| 207 | - | afterSnatchedUnrouteBeforePark func() | |
| 208 | 199 | // beforeResumeParkedSendMu pauses resumeParked after the initial parked | |
| 209 | 200 | // lookup and before the attachment send lock, so tests can consume or | |
| 210 | 201 | // replace the credential while the handshake waits. | |
| 211 | 202 | beforeResumeParkedSendMu func() | |
| 212 | - | // afterDisplacedCleanupStarted observes deferred displaced cleanup before it | |
| 213 | - | // synchronizes with the attachment gate. | |
| 214 | - | afterDisplacedCleanupStarted func() | |
| 215 | - | // beforeReclaimFirstPaint observes a committed reclaim after displaced cleanup | |
| 216 | - | // is queued and before its generation-bound reset first paint is admitted. | |
| 217 | - | beforeReclaimFirstPaint func(attachmentRoleToken) | |
| 218 | - | // afterRoleEffectAdmitted is a deterministic test seam after a frame/paint | |
| 203 | + | // afterAttachmentEffectAdmitted is a deterministic test seam after a frame/paint | |
| 219 | 204 | // reserves its exact capability and before its first observable mutation. | |
| 220 | - | afterRoleEffectAdmitted func(attachmentRoleToken) | |
| 205 | + | afterAttachmentEffectAdmitted func(attachmentConnectionToken) | |
| 221 | 206 | // beforeFirstPaintSendWait is a test-only seam immediately before a | |
| 222 | 207 | // transition first paint waits for the attachment send lock. | |
| 223 | - | beforeFirstPaintSendWait func(attachmentRoleToken) | |
| 208 | + | beforeFirstPaintSendWait func(attachmentConnectionToken) | |
| 224 | 209 | // afterDelayedKeyEffectAttempt observes whether a timer callback acquired a | |
| 225 | 210 | // fresh exact capability before producing PTY, action, or overlay effects. | |
| 226 | 211 | afterDelayedKeyEffectAttempt func(bool) | |
| 227 | - | // afterActionRoleEffectEnded observes action-specific admission release. It | |
| 212 | + | // afterActionAttachmentEffectEnded observes action-specific admission release. It | |
| 228 | 213 | // is a deterministic seam for proving no role-bound mutation follows release. | |
| 229 | - | afterActionRoleEffectEnded func(string) | |
| 230 | - | // beforeRoleSendErrorCleanup pauses asynchronous render-failure retirement | |
| 214 | + | afterActionAttachmentEffectEnded func(string) | |
| 215 | + | // beforeAttachmentSendErrorCleanup pauses asynchronous render-failure retirement | |
| 231 | 216 | // after the render ticket ends and before exact lifecycle validation. | |
| 232 | - | beforeRoleSendErrorCleanup func(attachmentRoleToken) | |
| 233 | - | afterRoleSendErrorCleanup func() | |
| 234 | - | ptys ports.PTYFactory | |
| 235 | - | clock ports.Clock | |
| 236 | - | log *slog.Logger | |
| 237 | - | runtimeObserver ports.RuntimeObserver | |
| 238 | - | baseEnv []string | |
| 239 | - | shell string | |
| 240 | - | shellArgs []string | |
| 241 | - | shellOverride bool | |
| 242 | - | persistEnabled bool | |
| 243 | - | catalogue ports.Catalogue | |
| 244 | - | catalogueRecords []domain.CatalogueRecord | |
| 245 | - | catalogueRecordsProvided bool | |
| 217 | + | beforeAttachmentSendErrorCleanup func(attachmentConnectionToken) | |
| 218 | + | afterAttachmentSendErrorCleanup func() | |
| 219 | + | ptys ports.PTYFactory | |
| 220 | + | clock ports.Clock | |
| 221 | + | log *slog.Logger | |
| 222 | + | runtimeObserver ports.RuntimeObserver | |
| 223 | + | baseEnv []string | |
| 224 | + | shell string | |
| 225 | + | shellArgs []string | |
| 226 | + | shellOverride bool | |
| 227 | + | persistEnabled bool | |
| 228 | + | catalogue ports.Catalogue | |
| 229 | + | catalogueRecords []domain.CatalogueRecord | |
| 230 | + | catalogueRecordsProvided bool | |
| 246 | 231 | // snapshotRepository is the sole checkpoint storage contract. | |
| 247 | 232 | snapshotRepository ports.SnapshotRepository | |
| 248 | 233 | recovery *recoveryusecase.Coordinator | |
| @@ -339,9 +324,9 @@ type Daemon struct { | |||
| 339 | 324 | type parkedAttachment struct { | |
| 340 | 325 | sess *session | |
| 341 | 326 | ac *attachedClient | |
| 342 | - | role attachmentRole | |
| 343 | 327 | pickerGeneration uint64 | |
| 344 | 328 | timer ports.Timer | |
| 329 | + | claimed bool | |
| 345 | 330 | done chan struct{} | |
| 346 | 331 | doneOnce sync.Once | |
| 347 | 332 | } | |
| @@ -1012,7 +997,7 @@ func (d *Daemon) handleList(tr ports.Transport) { | |||
| 1012 | 997 | State: ports.SessionRunning, | |
| 1013 | 998 | Ephemeral: s.ephemeral, | |
| 1014 | 999 | Tabs: uint16(len(s.tabs)), | |
| 1015 | - | Attached: s.client != nil, | |
| 1000 | + | Attached: len(s.attachments) != 0, | |
| 1016 | 1001 | } | |
| 1017 | 1002 | liveNames[s.name] = struct{}{} | |
| 1018 | 1003 | s.mu.Unlock() | |
| @@ -1119,68 +1104,58 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) { | |||
| 1119 | 1104 | expected := ac.transportSnapshot() | |
| 1120 | 1105 | welcomeToken := sess.attachmentToken(ac, tr) | |
| 1121 | 1106 | welcomeToken.lease = lease | |
| 1122 | - | welcomeTicket, admitted := ac.beginRoleEffect(welcomeToken) | |
| 1123 | - | validRole := welcomeToken.role == attachmentSnatched || welcomeToken.role == attachmentActive && lease != nil | |
| 1124 | - | if expected.transport != tr || !admitted || !validRole { | |
| 1107 | + | welcomeTicket, admitted := ac.beginAttachmentEffect(welcomeToken) | |
| 1108 | + | if expected.transport != tr || !admitted || welcomeToken.ac == nil { | |
| 1125 | 1109 | if admitted { | |
| 1126 | 1110 | welcomeTicket.End() | |
| 1127 | 1111 | } | |
| 1128 | - | if welcomeToken.role == attachmentSnatched { | |
| 1129 | - | d.parkOrDropSnatchedAttachment(welcomeToken) | |
| 1130 | - | } else { | |
| 1112 | + | if !d.abortResumeClaim(ac) { | |
| 1131 | 1113 | d.clientGone(sess, ac, tr, false) | |
| 1132 | 1114 | } | |
| 1133 | 1115 | return | |
| 1134 | 1116 | } | |
| 1135 | - | if err := ac.sendExpectedTransportForRole(expected, frameWelcome(sess, ac), welcomeTicket); err != nil { | |
| 1117 | + | if err := ac.sendExpectedTransportForAttachment(expected, frameWelcome(sess, ac), welcomeTicket); err != nil { | |
| 1136 | 1118 | welcomeTicket.End() | |
| 1137 | - | if welcomeToken.role == attachmentSnatched { | |
| 1138 | - | d.parkOrDropSnatchedAttachment(welcomeToken) | |
| 1139 | - | } else { | |
| 1119 | + | if !d.abortResumeClaim(ac) { | |
| 1120 | + | d.clientGone(sess, ac, tr, false) | |
| 1121 | + | } | |
| 1122 | + | return | |
| 1123 | + | } | |
| 1124 | + | if !d.commitResumeClaim(ac) { | |
| 1125 | + | welcomeTicket.End() | |
| 1126 | + | if !d.abortResumeClaim(ac) { | |
| 1140 | 1127 | d.clientGone(sess, ac, tr, false) | |
| 1141 | 1128 | } | |
| 1142 | 1129 | return | |
| 1143 | 1130 | } | |
| 1144 | - | // Welcome is the only effect allowed to retain the route-time role. Release | |
| 1145 | - | // it before discovering post-handshake authority so a replacement blocked | |
| 1146 | - | // behind the send can publish its generation and lease first. | |
| 1131 | + | // Release Welcome's effect before discovering post-handshake authority so a | |
| 1132 | + | // replacement blocked behind the send can publish its generation and lease. | |
| 1147 | 1133 | welcomeTicket.End() | |
| 1148 | - | postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentRoleEffect(sess, tr) | |
| 1134 | + | postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentAttachmentEffect(sess, tr) | |
| 1149 | 1135 | if !admitted { | |
| 1150 | 1136 | d.clientGone(sess, ac, tr, false) | |
| 1151 | 1137 | return | |
| 1152 | 1138 | } | |
| 1153 | - | if postWelcomeToken.role == attachmentSnatched { | |
| 1154 | - | if err := d.sendInitialSnatchedPanel(postWelcomeToken, postWelcomeTicket); err != nil { | |
| 1155 | - | postWelcomeTicket.End() | |
| 1156 | - | d.parkOrDropSnatchedAttachment(postWelcomeToken) | |
| 1157 | - | return | |
| 1158 | - | } | |
| 1159 | - | postWelcomeTicket.End() | |
| 1160 | - | d.runConnLoop(ac) | |
| 1161 | - | _ = tr.Close() | |
| 1162 | - | return | |
| 1163 | - | } | |
| 1164 | 1139 | postWelcomeLease := postWelcomeToken.lease | |
| 1165 | - | if postWelcomeToken.role != attachmentActive || rc == nil || postWelcomeLease == nil { | |
| 1140 | + | if postWelcomeToken.ac == nil { | |
| 1166 | 1141 | postWelcomeTicket.End() | |
| 1167 | 1142 | d.clientGone(sess, ac, tr, false) | |
| 1168 | 1143 | return | |
| 1169 | 1144 | } | |
| 1170 | 1145 | if ac.proxied { | |
| 1171 | - | meta, ok := sess.sessionMetaSnapshot() | |
| 1146 | + | meta, ok := sess.sessionMetaSnapshotFor(ac) | |
| 1172 | 1147 | metaFrame, metaErr := frameSessionMeta(meta) | |
| 1173 | - | if !ok || metaErr != nil || ac.sendExpectedTransportForRole(postWelcomeToken.transport, metaFrame, postWelcomeTicket) != nil { | |
| 1148 | + | if !ok || metaErr != nil || ac.sendExpectedTransportForAttachment(postWelcomeToken.transport, metaFrame, postWelcomeTicket) != nil { | |
| 1174 | 1149 | postWelcomeTicket.End() | |
| 1175 | 1150 | d.clientGone(sess, ac, tr, false) | |
| 1176 | 1151 | return | |
| 1177 | 1152 | } | |
| 1178 | 1153 | ac.markSessionMetaSent(meta) | |
| 1179 | 1154 | } | |
| 1180 | - | if !rc.markAttachmentReady(postWelcomeLease) { | |
| 1155 | + | if postWelcomeLease != nil && (rc == nil || !rc.markAttachmentReady(postWelcomeLease)) { | |
| 1181 | 1156 | postWelcomeTicket.End() | |
| 1182 | - | // The attachment was displaced or detached while Welcome was in flight; | |
| 1183 | - | // never let this stale handshake emit an Output frame. | |
| 1157 | + | // The attachment was detached while Welcome was in flight; never let | |
| 1158 | + | // this stale handshake emit an Output frame. | |
| 1184 | 1159 | d.clientGone(sess, ac, tr, false) | |
| 1185 | 1160 | return | |
| 1186 | 1161 | } | |
| @@ -1223,10 +1198,9 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, | |||
| 1223 | 1198 | ac := d.prepareAttachedClientLocked(tr, sz, opts) | |
| 1224 | 1199 | d.mu.Unlock() | |
| 1225 | 1200 | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1226 | - | target: sess, | |
| 1227 | - | next: ac, | |
| 1228 | - | expectedRole: attachmentDetached, | |
| 1229 | - | targetRole: attachmentActive, | |
| 1201 | + | target: sess, | |
| 1202 | + | next: ac, | |
| 1203 | + | ||
| 1230 | 1204 | expectedTransport: ac.transportSnapshot(), | |
| 1231 | 1205 | ready: false, | |
| 1232 | 1206 | }) | |
M internal/usecase/daemon/daemon_test.go +95 -4
| @@ -413,7 +413,7 @@ func newManualSessionWithPTYsClockCleanup(t testing.TB, clock ports.Clock, regis | |||
| 413 | 413 | } | |
| 414 | 414 | tabs = append(tabs, tb) | |
| 415 | 415 | } | |
| 416 | - | sess := &session{sessionCore: sessionCore{id: "manual", name: "work", client: ac}, ctx: sctx, cancel: cancel, tabs: tabs} | |
| 416 | + | sess := &session{sessionCore: sessionCore{id: "manual", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx, cancel: cancel, tabs: tabs} | |
| 417 | 417 | for _, tb := range tabs { | |
| 418 | 418 | publishTiledPaneOwners(sess, tb) | |
| 419 | 419 | } | |
| @@ -439,10 +439,101 @@ func newManualTabSession(t *testing.T, n int) (*Daemon, *session, *attachedClien | |||
| 439 | 439 | return d, sess, ac, sends, releases | |
| 440 | 440 | } | |
| 441 | 441 | ||
| 442 | - | func activeTabIndex(sess *session) int { | |
| 442 | + | func testAttachment(sess *session) *attachedClient { | |
| 443 | + | if sess == nil { | |
| 444 | + | return nil | |
| 445 | + | } | |
| 446 | + | attachments := sess.snapshotAttachments() | |
| 447 | + | if len(attachments) == 0 { | |
| 448 | + | return nil | |
| 449 | + | } | |
| 450 | + | return attachments[0] | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | func testAttachmentTabLocked(sess *session) *tab { | |
| 454 | + | if sess == nil { | |
| 455 | + | return nil | |
| 456 | + | } | |
| 457 | + | var ac *attachedClient | |
| 458 | + | for candidate := range sess.attachments { | |
| 459 | + | ac = candidate | |
| 460 | + | break | |
| 461 | + | } | |
| 462 | + | if ac == nil { | |
| 463 | + | if len(sess.tabs) == 0 { | |
| 464 | + | return nil | |
| 465 | + | } | |
| 466 | + | return sess.tabs[0] | |
| 467 | + | } | |
| 468 | + | view := ac.viewSnapshot() | |
| 469 | + | for _, tb := range sess.tabs { | |
| 470 | + | if domain.TabStableID(tb.stableID) == view.tabID { | |
| 471 | + | return tb | |
| 472 | + | } | |
| 473 | + | } | |
| 474 | + | return nil | |
| 475 | + | } | |
| 476 | + | ||
| 477 | + | func testAttachmentTab(sess *session) *tab { | |
| 478 | + | if ac := testAttachment(sess); ac != nil { | |
| 479 | + | return sess.tabForAttachment(ac) | |
| 480 | + | } | |
| 481 | + | return sess.firstTab() | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | func selectTestAttachmentTab(sess *session, idx int) bool { | |
| 485 | + | ac := testAttachment(sess) | |
| 486 | + | if ac == nil { | |
| 487 | + | return idx == 0 && testAttachmentTab(sess) != nil | |
| 488 | + | } | |
| 489 | + | return sess.switchAttachmentTab(ac, idx) | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | // selectTestAttachmentTabLocked is used only by fixtures that already hold | |
| 493 | + | // sess.mu; production navigation always goes through selectAttachmentTab. | |
| 494 | + | func selectTestAttachmentTabLocked(sess *session, idx int) bool { | |
| 495 | + | if sess == nil { | |
| 496 | + | return false | |
| 497 | + | } | |
| 498 | + | var ac *attachedClient | |
| 499 | + | for candidate := range sess.attachments { | |
| 500 | + | ac = candidate | |
| 501 | + | break | |
| 502 | + | } | |
| 503 | + | if ac == nil { | |
| 504 | + | return idx == 0 && len(sess.tabs) > 0 | |
| 505 | + | } | |
| 506 | + | return sess.activateAttachmentViewLocked(ac, idx) | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | func testAttachmentTabIndexLocked(sess *session) int { | |
| 510 | + | if sess == nil { | |
| 511 | + | return 0 | |
| 512 | + | } | |
| 513 | + | var ac *attachedClient | |
| 514 | + | for candidate := range sess.attachments { | |
| 515 | + | ac = candidate | |
| 516 | + | break | |
| 517 | + | } | |
| 518 | + | if ac == nil { | |
| 519 | + | return 0 | |
| 520 | + | } | |
| 521 | + | view := ac.viewSnapshot() | |
| 522 | + | for i, tb := range sess.tabs { | |
| 523 | + | if domain.TabStableID(tb.stableID) == view.tabID { | |
| 524 | + | return i | |
| 525 | + | } | |
| 526 | + | } | |
| 527 | + | return 0 | |
| 528 | + | } | |
| 529 | + | ||
| 530 | + | func testAttachmentTabIndex(sess *session) int { | |
| 531 | + | if sess == nil { | |
| 532 | + | return 0 | |
| 533 | + | } | |
| 443 | 534 | sess.mu.Lock() | |
| 444 | 535 | defer sess.mu.Unlock() | |
| 445 | - | return sess.active | |
| 536 | + | return testAttachmentTabIndexLocked(sess) | |
| 446 | 537 | } | |
| 447 | 538 | ||
| 448 | 539 | func testRow(text string) []renderer.Cell { | |
| @@ -537,7 +628,7 @@ func TestHandleHelloDefersFreshOutputUntilWelcome(t *testing.T) { | |||
| 537 | 628 | require.Equal(t, ports.MsgWelcome, welcome.Type) | |
| 538 | 629 | sess := firstSession(d) | |
| 539 | 630 | sess.mu.Lock() | |
| 540 | - | ac := sess.client | |
| 631 | + | ac := sess.snapshotAttachmentsLocked()[0] | |
| 541 | 632 | sess.mu.Unlock() | |
| 542 | 633 | require.NotNil(t, ac, "route must publish the attachment before Welcome returns") | |
| 543 | 634 | ||
M internal/usecase/daemon/floating.go +38 -23
| @@ -178,14 +178,8 @@ func (d *Daemon) activateTabAfterResizeForLease(sess *session, tb *tab, outerRes | |||
| 178 | 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 | - | sess.mu.Lock() | |
| 186 | - | ac = sess.client | |
| 187 | - | sess.mu.Unlock() | |
| 188 | - | } | |
| 181 | + | // A headless session has no attachment-local copy mode to clear. Keep its | |
| 182 | + | // geometry work session-wide instead of choosing an arbitrary attachment. | |
| 189 | 183 | if rc := sess.renderCoordinator(); rc != nil && rc.opts.onActivateTabAfterResize != nil { | |
| 190 | 184 | rc.opts.onActivateTabAfterResize(lease != nil) | |
| 191 | 185 | } | |
| @@ -195,11 +189,24 @@ func (d *Daemon) activateTabAfterResizeForLease(sess *session, tb *tab, outerRes | |||
| 195 | 189 | return false | |
| 196 | 190 | } | |
| 197 | 191 | } | |
| 198 | - | d.exitCopyMode(ac) | |
| 192 | + | d.ensureFloatingWarm(sess, tb) | |
| 199 | 193 | if ac == nil { | |
| 194 | + | for _, attachment := range sess.snapshotAttachments() { | |
| 195 | + | d.exitCopyMode(attachment) | |
| 196 | + | } | |
| 197 | + | if outerResizeAccepted { | |
| 198 | + | return false | |
| 199 | + | } | |
| 200 | + | tb.mu.Lock() | |
| 201 | + | hasFloating := tb.floating.pane != nil | |
| 202 | + | size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2} | |
| 203 | + | tb.mu.Unlock() | |
| 204 | + | if hasFloating { | |
| 205 | + | return d.requestTransactionalResize(sess, nil, size, true) | |
| 206 | + | } | |
| 200 | 207 | return false | |
| 201 | 208 | } | |
| 202 | - | d.ensureFloatingWarm(sess, tb) | |
| 209 | + | d.exitCopyMode(ac) | |
| 203 | 210 | if outerResizeAccepted { | |
| 204 | 211 | return false | |
| 205 | 212 | } | |
| @@ -222,7 +229,10 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error { | |||
| 222 | 229 | if d == nil || sess == nil { | |
| 223 | 230 | return domain.UserErr(domain.NoticeFloatingSpawn, "couldn't open floating pane: no active session", nil) | |
| 224 | 231 | } | |
| 225 | - | tb := sess.activeTab() | |
| 232 | + | tb := sess.tabForAttachment(ac) | |
| 233 | + | if ac == nil { | |
| 234 | + | tb = sess.firstTab() | |
| 235 | + | } | |
| 226 | 236 | if tb == nil { | |
| 227 | 237 | return domain.UserErr(domain.NoticeFloatingSpawn, "couldn't open floating pane: no active tab", layout.ErrNotFound) | |
| 228 | 238 | } | |
| @@ -478,13 +488,19 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin | |||
| 478 | 488 | d.reapplyThemeSession(sess) | |
| 479 | 489 | d.startPaneGoroutines(sess, tb, p) | |
| 480 | 490 | if visible { | |
| 481 | - | sess.mu.Lock() | |
| 482 | - | ac := sess.client | |
| 483 | - | sess.mu.Unlock() | |
| 491 | + | attachments := sess.snapshotAttachments() | |
| 492 | + | if len(attachments) == 0 { | |
| 493 | + | // A headless resize may commit while Open is warming. Reconcile the | |
| 494 | + | // visible floating geometry even without an attachment callback. | |
| 495 | + | d.applyVisibleFloatingLayout(sess, tb, nil) | |
| 496 | + | return | |
| 497 | + | } | |
| 484 | 498 | tb.mu.Lock() | |
| 485 | 499 | size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2} | |
| 486 | 500 | tb.mu.Unlock() | |
| 487 | - | d.requestTransactionalResize(sess, ac, size, true) | |
| 501 | + | for _, ac := range attachments { | |
| 502 | + | d.requestTransactionalResize(sess, ac, size, true) | |
| 503 | + | } | |
| 488 | 504 | } | |
| 489 | 505 | } | |
| 490 | 506 | ||
| @@ -515,15 +531,14 @@ func (d *Daemon) reapInstalledFloating(p *pane) { | |||
| 515 | 531 | return | |
| 516 | 532 | } | |
| 517 | 533 | closeFloatingPane(p) | |
| 518 | - | sess.mu.Lock() | |
| 519 | - | ac := sess.client | |
| 520 | - | sess.mu.Unlock() | |
| 521 | - | if ac != nil { | |
| 534 | + | attachments := sess.snapshotAttachments() | |
| 535 | + | copyCleared := false | |
| 536 | + | for _, ac := range attachments { | |
| 522 | 537 | ac.pruneCaptureFrames(p) | |
| 523 | - | } | |
| 524 | - | copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p) | |
| 525 | - | if ac != nil && (visible || copyCleared) { | |
| 526 | - | d.invalidateRender(sess, ac, true, "floating.go") | |
| 538 | + | copyCleared = ac.overlays.clearCopyModeForPane(p) || copyCleared | |
| 539 | + | if visible || copyCleared { | |
| 540 | + | d.invalidateRender(sess, ac, true, "floating.go") | |
| 541 | + | } | |
| 527 | 542 | } | |
| 528 | 543 | return | |
| 529 | 544 | } | |
M internal/usecase/daemon/floating_open_test.go +3 -3
| @@ -130,11 +130,11 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) { | |||
| 130 | 130 | tb.mu.Unlock() | |
| 131 | 131 | require.NotNil(t, floating) | |
| 132 | 132 | sess.mu.Lock() | |
| 133 | - | sess.client = &attachedClient{} | |
| 133 | + | sess.registerAttachmentLocked(&attachedClient{}) | |
| 134 | 134 | sess.mu.Unlock() | |
| 135 | 135 | require.Equal(t, tt.desiredVisible, d.paneRenderable(sess, tb, floating), "installed popup renderability must follow its retained visibility") | |
| 136 | 136 | sess.mu.Lock() | |
| 137 | - | sess.client = nil | |
| 137 | + | clearAttachmentsForTestLocked(sess) | |
| 138 | 138 | sess.mu.Unlock() | |
| 139 | 139 | select { | |
| 140 | 140 | case <-pty.readerStarted: | |
| @@ -228,7 +228,7 @@ func TestFloatingOpenErrorReleasesReturnedPTYBeforePublishingFailure(t *testing. | |||
| 228 | 228 | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 229 | 229 | ac.initOverlays() | |
| 230 | 230 | sessCtx, cancelSession := context.WithCancel(t.Context()) | |
| 231 | - | sess := &session{sessionCore: sessionCore{id: "floating-open-error", name: "work", client: ac}, tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession} | |
| 231 | + | sess := &session{sessionCore: sessionCore{id: "floating-open-error", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession} | |
| 232 | 232 | ac.setSession(sess) | |
| 233 | 233 | ||
| 234 | 234 | // Close must be safe to run before failure publication and without | |
M internal/usecase/daemon/floating_render_test.go +2 -2
| @@ -595,7 +595,7 @@ func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) { | |||
| 595 | 595 | installTestFloating(tb, p, true) | |
| 596 | 596 | ac := &attachedClient{} | |
| 597 | 597 | ac.initOverlays() | |
| 598 | - | sess := &session{sessionCore: sessionCore{client: ac}, tabs: []*tab{tb}} | |
| 598 | + | sess := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}} | |
| 599 | 599 | base := barState{} | |
| 600 | 600 | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |
| 601 | 601 | ||
| @@ -617,7 +617,7 @@ func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) { | |||
| 617 | 617 | cache := composeCacheInput{} | |
| 618 | 618 | for range 500 { | |
| 619 | 619 | ac.sendMu.Lock() | |
| 620 | - | state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{bars: base, floatingCfg: cfg}) | |
| 620 | + | state, ok := captureRenderState(sess, ac, renderCaptureRequest{bars: base, floatingCfg: cfg}) | |
| 621 | 621 | ac.sendMu.Unlock() | |
| 622 | 622 | require.True(t, ok) | |
| 623 | 623 | out := composeFrame(*state, cache) | |
M internal/usecase/daemon/floating_test.go +4 -4
| @@ -406,8 +406,8 @@ func TestFloatingExitUsesCurrentOwnerAfterTabTransfer(t *testing.T) { | |||
| 406 | 406 | sourceClient.initOverlays() | |
| 407 | 407 | destinationClient := &attachedClient{captureFrames: map[*pane]capturedPaneRenderState{floating: {}}} | |
| 408 | 408 | destinationClient.initOverlays() | |
| 409 | - | source.client = sourceClient | |
| 410 | - | destination.client = destinationClient | |
| 409 | + | source.registerAttachment(sourceClient) | |
| 410 | + | destination.registerAttachment(destinationClient) | |
| 411 | 411 | ||
| 412 | 412 | floating.onExit() | |
| 413 | 413 | d.sessWg.Wait() | |
| @@ -485,7 +485,7 @@ func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) { | |||
| 485 | 485 | t.Helper() | |
| 486 | 486 | normalPTY, releaseNormal := newBlockingPTY(t) | |
| 487 | 487 | d, sess, ac, sends := newManualSessionWithPTYs(t, normalPTY) | |
| 488 | - | tb := sess.activeTab() | |
| 488 | + | tb := testAttachmentTab(sess) | |
| 489 | 489 | require.NotNil(t, tb) | |
| 490 | 490 | tb.mu.Lock() | |
| 491 | 491 | normal := tb.focusedPane() | |
| @@ -770,7 +770,7 @@ func TestFloatingAsyncSpawnFailureToastsOnlyForUserOpen(t *testing.T) { | |||
| 770 | 770 | tr, _ := newCapturingTransport(t) | |
| 771 | 771 | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 772 | 772 | ac.initOverlays() | |
| 773 | - | sess := &session{sessionCore: sessionCore{id: "floating-spawn", name: "work", client: ac}, tabs: []*tab{tb}, ctx: t.Context()} | |
| 773 | + | sess := &session{sessionCore: sessionCore{id: "floating-spawn", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}, ctx: t.Context()} | |
| 774 | 774 | ac.setSession(sess) | |
| 775 | 775 | ||
| 776 | 776 | d.startFloating(sess, tb, tc.userOpen) | |
M internal/usecase/daemon/input.go +199 -114
| @@ -20,11 +20,11 @@ func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint6 | |||
| 20 | 20 | d.handleInput(sess, ac, data) | |
| 21 | 21 | } | |
| 22 | 22 | ||
| 23 | - | func (d *Daemon) handleSequencedInputForRole(token attachmentRoleToken, _ uint64, data []byte) { | |
| 24 | - | if !token.activeEffect() { | |
| 23 | + | func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionToken, _ uint64, data []byte) { | |
| 24 | + | if !token.attachmentEffectCurrent() { | |
| 25 | 25 | return | |
| 26 | 26 | } | |
| 27 | - | d.handleInputForRole(token, data) | |
| 27 | + | d.handleInputForAttachment(token, data) | |
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | 30 | func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) { | |
| @@ -40,19 +40,19 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) { | |||
| 40 | 40 | ) | |
| 41 | 41 | } | |
| 42 | 42 | ||
| 43 | - | func (d *Daemon) handleInputForRole(token attachmentRoleToken, data []byte) { | |
| 43 | + | func (d *Daemon) handleInputForAttachment(token attachmentConnectionToken, data []byte) { | |
| 44 | 44 | ac := token.ac | |
| 45 | 45 | ac.initOverlays() | |
| 46 | 46 | if proxy, ok := token.sess.(*proxySession); ok { | |
| 47 | - | handler := proxyKeyHandler{d: d, proxy: proxy, ac: ac, roleToken: token} | |
| 47 | + | handler := proxyKeyHandler{d: d, proxy: proxy, ac: ac, connectionToken: token} | |
| 48 | 48 | ac.mouseScan.Scan(data, | |
| 49 | 49 | func(ev mouse.Event) { | |
| 50 | - | if token.activeEffect() && !proxyMouseOwnedLocally(ac.overlays) { | |
| 50 | + | if token.attachmentEffectCurrent() && !proxyMouseOwnedLocally(ac.overlays) { | |
| 51 | 51 | handler.Mouse(ev) | |
| 52 | 52 | } | |
| 53 | 53 | }, | |
| 54 | 54 | func(b []byte) { | |
| 55 | - | if !token.activeEffect() || ac.overlays.HandleInput(d, b, token.effect) || !token.activeEffect() { | |
| 55 | + | if !token.attachmentEffectCurrent() || ac.overlays.HandleInput(d, b, token.effect) || !token.attachmentEffectCurrent() { | |
| 56 | 56 | return | |
| 57 | 57 | } | |
| 58 | 58 | ac.keys.RouteWithHandler(b, handler) | |
| @@ -62,26 +62,30 @@ func (d *Daemon) handleInputForRole(token attachmentRoleToken, data []byte) { | |||
| 62 | 62 | } | |
| 63 | 63 | ac.mouseScan.Scan(data, | |
| 64 | 64 | func(ev mouse.Event) { | |
| 65 | - | if token.activeEffect() { | |
| 65 | + | if token.attachmentEffectCurrent() { | |
| 66 | 66 | d.handleMouse(ac, ev) | |
| 67 | 67 | } | |
| 68 | 68 | }, | |
| 69 | 69 | func(b []byte) { | |
| 70 | - | if !token.activeEffect() { | |
| 70 | + | if !token.attachmentEffectCurrent() { | |
| 71 | 71 | return | |
| 72 | 72 | } | |
| 73 | 73 | if ac.overlays.HandleInput(d, b, token.effect) { | |
| 74 | 74 | return | |
| 75 | 75 | } | |
| 76 | - | if !token.activeEffect() { | |
| 76 | + | if !token.attachmentEffectCurrent() { | |
| 77 | 77 | return | |
| 78 | 78 | } | |
| 79 | - | ac.keys.RouteWithHandler(b, daemonKeyHandler{d: d, ac: ac, roleToken: token}) | |
| 79 | + | ac.keys.RouteWithHandler(b, daemonKeyHandler{d: d, ac: ac, connectionToken: token}) | |
| 80 | 80 | }, | |
| 81 | 81 | ) | |
| 82 | 82 | } | |
| 83 | 83 | ||
| 84 | 84 | func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |
| 85 | + | d.handleMouseMutation(ac, ev) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (d *Daemon) handleMouseMutation(ac *attachedClient, ev mouse.Event) { | |
| 85 | 89 | frameEvent := ev | |
| 86 | 90 | ac.initOverlays() | |
| 87 | 91 | rt := ac.overlays | |
| @@ -93,7 +97,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 93 | 97 | invalidateRejectedLeftPointer(rt, ev) | |
| 94 | 98 | return | |
| 95 | 99 | } | |
| 96 | - | tb := sess.activeTab() | |
| 100 | + | tb := sess.tabForAttachment(ac) | |
| 97 | 101 | if tb == nil { | |
| 98 | 102 | invalidateRejectedLeftPointer(rt, ev) | |
| 99 | 103 | return | |
| @@ -108,97 +112,157 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 108 | 112 | return | |
| 109 | 113 | } | |
| 110 | 114 | ||
| 111 | - | tb.mu.Lock() | |
| 115 | + | // Copy-mode publication deliberately revalidates outside the dispatch lock: | |
| 116 | + | // a concurrent release or pane close must be able to invalidate its candidate. | |
| 117 | + | var routed mouseRoute | |
| 118 | + | _ = sess.runMutation(func() error { | |
| 119 | + | routed = d.routeMouseMutation(sess, ac, ev) | |
| 120 | + | return nil | |
| 121 | + | }) | |
| 122 | + | if routed.handled { | |
| 123 | + | return | |
| 124 | + | } | |
| 125 | + | if routed.p == nil { | |
| 126 | + | invalidateRejectedLeftPointer(rt, ev) | |
| 127 | + | return | |
| 128 | + | } | |
| 129 | + | if ev.Button == mouse.Left && ev.Type == mouse.Press && d.handleFreshCopyPress(sess, ac, routed.tb, routed.p, frameEvent) { | |
| 130 | + | return | |
| 131 | + | } | |
| 132 | + | d.handleTerminalMouse(sess, ac, routed.p, routed.event, routed.translated, routed.hoveredFocused) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | type mouseRoute struct { | |
| 136 | + | tb *tab | |
| 137 | + | p *pane | |
| 138 | + | event mouse.Event | |
| 139 | + | translated bool | |
| 140 | + | hoveredFocused bool | |
| 141 | + | handled bool | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func (d *Daemon) routeMouseMutation(sess *session, ac *attachedClient, ev mouse.Event) mouseRoute { | |
| 145 | + | route := mouseRoute{event: ev, hoveredFocused: true} | |
| 146 | + | tb := sess.tabForAttachment(ac) | |
| 147 | + | if tb == nil { | |
| 148 | + | route.handled = true | |
| 149 | + | return route | |
| 150 | + | } | |
| 151 | + | route.tb = tb | |
| 112 | 152 | contentRow := ev.Row - clientTopBarRows | |
| 153 | + | tb.mu.Lock() | |
| 113 | 154 | floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig()) | |
| 114 | 155 | if floatingVisible { | |
| 115 | 156 | if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) { | |
| 116 | 157 | tb.mu.Unlock() | |
| 117 | - | invalidateRejectedLeftPointer(rt, ev) | |
| 118 | - | return | |
| 158 | + | route.handled = true | |
| 159 | + | return route | |
| 119 | 160 | } | |
| 120 | 161 | tb.mu.Unlock() | |
| 121 | - | if ev.Button == mouse.Left && ev.Type == mouse.Press && d.handleFreshCopyPress(sess, ac, tb, floating, frameEvent) { | |
| 122 | - | return | |
| 123 | - | } | |
| 124 | - | ev = translateMouseEvent(ev, floatingGeometry.Inner.X, floatingGeometry.Inner.Y) | |
| 125 | - | d.handleTerminalMouse(sess, ac, floating, ev, true, true) | |
| 126 | - | return | |
| 162 | + | route.p = floating | |
| 163 | + | route.event = translateMouseEvent(ev, floatingGeometry.Inner.X, floatingGeometry.Inner.Y) | |
| 164 | + | route.translated = true | |
| 165 | + | return route | |
| 127 | 166 | } | |
| 128 | 167 | pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow) | |
| 129 | 168 | multi := len(tb.panes) > 1 | |
| 130 | - | focusedID := layout.PaneID("") | |
| 131 | - | if tb.tree != nil { | |
| 132 | - | focusedID = tb.tree.Focus | |
| 133 | - | } | |
| 169 | + | focusedID := mouseFocusedPaneIDLocked(ac, tb) | |
| 134 | 170 | if hit && pointInRect(ev.Col, contentRow, pl.TitleBar) { | |
| 135 | 171 | if !isMouseFocusPress(ev) { | |
| 136 | 172 | tb.mu.Unlock() | |
| 137 | - | invalidateRejectedLeftPointer(rt, ev) | |
| 138 | - | return | |
| 173 | + | route.handled = true | |
| 174 | + | return route | |
| 139 | 175 | } | |
| 140 | - | oldFocus := focusedID | |
| 141 | - | layoutChanged := focusPlacementLocked(tb, pl.ID) | |
| 142 | 176 | tb.mu.Unlock() | |
| 143 | - | if layoutChanged { | |
| 144 | - | d.applyTabLayout(sess, tb) | |
| 177 | + | _, focused := d.focusMousePane(sess, ac, tb, pl.ID) | |
| 178 | + | if focused == nil { | |
| 179 | + | route.handled = true | |
| 180 | + | return route | |
| 145 | 181 | } | |
| 146 | - | // A title bar never routes to terminal content. Clear any pre-existing | |
| 147 | - | // left-button candidate before handling the focus result, including when | |
| 148 | - | // this press leaves the same pane focused. | |
| 149 | - | invalidateRejectedLeftPointer(rt, ev) | |
| 150 | - | if pl.ID != oldFocus { | |
| 182 | + | invalidateRejectedLeftPointer(ac.overlays, ev) | |
| 183 | + | if pl.ID != focusedID { | |
| 151 | 184 | d.exitCopyMode(ac) | |
| 152 | 185 | d.refreshPaneTitleOnFocus(sess, pl.ID) | |
| 153 | 186 | } | |
| 154 | 187 | d.invalidateRender(sess, ac, true, "input.go") | |
| 155 | - | return | |
| 188 | + | route.handled = true | |
| 189 | + | return route | |
| 156 | 190 | } | |
| 157 | - | var p *pane | |
| 158 | - | translated := false | |
| 159 | - | hoveredFocused := true | |
| 160 | 191 | if hit && !pl.Collapsed && pointInRect(ev.Col, contentRow, pl.Content) { | |
| 161 | 192 | oldFocus := focusedID | |
| 162 | - | layoutChanged := false | |
| 163 | - | if isMouseFocusPress(ev) { | |
| 164 | - | layoutChanged = focusPlacementLocked(tb, pl.ID) | |
| 165 | - | } | |
| 166 | - | p = tb.panes[pl.ID] | |
| 167 | - | hoveredFocused = pl.ID == oldFocus | |
| 193 | + | shouldFocus := isMouseFocusPress(ev) | |
| 194 | + | p := tb.panes[pl.ID] | |
| 195 | + | hoveredFocused := pl.ID == oldFocus | |
| 168 | 196 | tb.mu.Unlock() | |
| 169 | - | if layoutChanged { | |
| 170 | - | d.applyTabLayout(sess, tb) | |
| 197 | + | if shouldFocus { | |
| 198 | + | _, p = d.focusMousePane(sess, ac, tb, pl.ID) | |
| 171 | 199 | } | |
| 172 | 200 | if p == nil { | |
| 173 | - | invalidateRejectedLeftPointer(rt, ev) | |
| 174 | - | return | |
| 201 | + | route.handled = true | |
| 202 | + | return route | |
| 175 | 203 | } | |
| 176 | - | if isMouseFocusPress(ev) && pl.ID != oldFocus { | |
| 204 | + | if shouldFocus && pl.ID != oldFocus { | |
| 177 | 205 | d.exitCopyMode(ac) | |
| 178 | 206 | d.refreshPaneTitleOnFocus(sess, pl.ID) | |
| 179 | 207 | d.invalidateRender(sess, ac, true, "input.go") | |
| 180 | 208 | } | |
| 209 | + | route.p = p | |
| 210 | + | route.hoveredFocused = hoveredFocused | |
| 181 | 211 | if multi { | |
| 182 | - | ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y) | |
| 183 | - | translated = true | |
| 184 | - | } | |
| 185 | - | } else { | |
| 186 | - | if multi { | |
| 187 | - | tb.mu.Unlock() | |
| 188 | - | invalidateRejectedLeftPointer(rt, ev) | |
| 189 | - | return | |
| 212 | + | route.event = translateMouseEvent(ev, pl.Content.X, pl.Content.Y) | |
| 213 | + | route.translated = true | |
| 190 | 214 | } | |
| 191 | - | p = tb.focusedPane() | |
| 215 | + | return route | |
| 216 | + | } | |
| 217 | + | if multi { | |
| 192 | 218 | tb.mu.Unlock() | |
| 193 | - | if p == nil { | |
| 194 | - | invalidateRejectedLeftPointer(rt, ev) | |
| 195 | - | return | |
| 219 | + | route.handled = true | |
| 220 | + | return route | |
| 221 | + | } | |
| 222 | + | tb.mu.Unlock() | |
| 223 | + | _, route.p = sess.paneForAttachment(ac) | |
| 224 | + | return route | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func mouseFocusedPaneIDLocked(ac *attachedClient, tb *tab) layout.PaneID { | |
| 228 | + | if ac != nil && tb != nil { | |
| 229 | + | view := ac.viewSnapshot() | |
| 230 | + | if domain.TabStableID(tb.stableID) == view.tabID { | |
| 231 | + | for _, p := range tb.panes { | |
| 232 | + | if p != nil && domain.PaneStableID(p.stableID) == view.paneID { | |
| 233 | + | return p.id | |
| 234 | + | } | |
| 235 | + | } | |
| 196 | 236 | } | |
| 197 | 237 | } | |
| 198 | - | if ev.Button == mouse.Left && ev.Type == mouse.Press && d.handleFreshCopyPress(sess, ac, tb, p, frameEvent) { | |
| 199 | - | return | |
| 238 | + | if tb == nil || tb.tree == nil { | |
| 239 | + | return "" | |
| 240 | + | } | |
| 241 | + | return tb.tree.Focus | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // focusMousePane runs inside the caller's session mutation boundary. | |
| 245 | + | func (d *Daemon) focusMousePane(sess *session, ac *attachedClient, tb *tab, id layout.PaneID) (bool, *pane) { | |
| 246 | + | var changed bool | |
| 247 | + | var focused *pane | |
| 248 | + | tb.mu.Lock() | |
| 249 | + | if tb.tree != nil { | |
| 250 | + | focused = tb.panes[id] | |
| 251 | + | if focused != nil { | |
| 252 | + | changed = focusPlacementLocked(tb, id) | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | tb.mu.Unlock() | |
| 256 | + | if focused == nil { | |
| 257 | + | return false, nil | |
| 200 | 258 | } | |
| 201 | - | d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused) | |
| 259 | + | sess.mu.Lock() | |
| 260 | + | sess.setAttachmentPaneLocked(ac, tb, focused) | |
| 261 | + | sess.mu.Unlock() | |
| 262 | + | if changed { | |
| 263 | + | d.applyTabLayout(sess, tb) | |
| 264 | + | } | |
| 265 | + | return changed, focused | |
| 202 | 266 | } | |
| 203 | 267 | ||
| 204 | 268 | func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) { | |
| @@ -215,18 +279,24 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||
| 215 | 279 | if !mouseSGR || ev.Row == 0 || ev.Row > childRows { | |
| 216 | 280 | return | |
| 217 | 281 | } | |
| 218 | - | if translated { | |
| 219 | - | d.writeToPane(sess, p, ev.Raw) | |
| 220 | - | } else { | |
| 221 | - | d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -clientTopBarRows)) | |
| 222 | - | } | |
| 282 | + | _ = sess.runMutation(func() error { | |
| 283 | + | if translated { | |
| 284 | + | d.writeToPane(sess, p, ev.Raw) | |
| 285 | + | } else { | |
| 286 | + | d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -clientTopBarRows)) | |
| 287 | + | } | |
| 288 | + | return nil | |
| 289 | + | }) | |
| 223 | 290 | return | |
| 224 | 291 | } | |
| 225 | 292 | ||
| 226 | 293 | switch ev.Button { | |
| 227 | 294 | case mouse.WheelUp: | |
| 228 | 295 | if altScreen { | |
| 229 | - | d.writeToPane(sess, p, []byte("\x1b[A\x1b[A\x1b[A")) | |
| 296 | + | _ = sess.runMutation(func() error { | |
| 297 | + | d.writeToPane(sess, p, []byte("\x1b[A\x1b[A\x1b[A")) | |
| 298 | + | return nil | |
| 299 | + | }) | |
| 230 | 300 | return | |
| 231 | 301 | } | |
| 232 | 302 | if !hoveredFocused { | |
| @@ -236,7 +306,10 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||
| 236 | 306 | d.copyWheel(sess, ac, -3) | |
| 237 | 307 | case mouse.WheelDown: | |
| 238 | 308 | if altScreen { | |
| 239 | - | d.writeToPane(sess, p, []byte("\x1b[B\x1b[B\x1b[B")) | |
| 309 | + | _ = sess.runMutation(func() error { | |
| 310 | + | d.writeToPane(sess, p, []byte("\x1b[B\x1b[B\x1b[B")) | |
| 311 | + | return nil | |
| 312 | + | }) | |
| 240 | 313 | } | |
| 241 | 314 | } | |
| 242 | 315 | } | |
| @@ -315,34 +388,34 @@ func sgrOffset(raw []byte, colDelta, rowDelta int) []byte { | |||
| 315 | 388 | } | |
| 316 | 389 | ||
| 317 | 390 | type daemonKeyHandler struct { | |
| 318 | - | d *Daemon | |
| 319 | - | ac *attachedClient | |
| 320 | - | actions daemonActionRunner | |
| 321 | - | roleToken attachmentRoleToken | |
| 391 | + | d *Daemon | |
| 392 | + | ac *attachedClient | |
| 393 | + | actions daemonActionRunner | |
| 394 | + | connectionToken attachmentConnectionToken | |
| 322 | 395 | } | |
| 323 | 396 | ||
| 324 | - | // acquireRoleEffect preserves a synchronous frame's existing admission and | |
| 325 | - | // gives delayed router callbacks a fresh ticket for the exact captured role. | |
| 326 | - | // Direct/headless callers without a role token retain their existing behavior. | |
| 327 | - | func (h daemonKeyHandler) acquireRoleEffect() (*session, *roleEffectTicket, bool) { | |
| 328 | - | if h.roleToken.ac != nil { | |
| 329 | - | if effect := h.roleToken.effect; effect != nil && !effect.ended.Load() { | |
| 330 | - | sess, _ := localSession(h.roleToken.sess) | |
| 397 | + | // acquireAttachmentEffect preserves a synchronous frame's existing admission and | |
| 398 | + | // gives delayed router callbacks a fresh ticket for the exact captured attachment. | |
| 399 | + | // Direct/headless callers without an attachment token retain their existing behavior. | |
| 400 | + | func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) { | |
| 401 | + | if h.connectionToken.ac != nil { | |
| 402 | + | if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() { | |
| 403 | + | sess, _ := localSession(h.connectionToken.sess) | |
| 331 | 404 | return sess, effect, false | |
| 332 | 405 | } | |
| 333 | - | effect, admitted := h.roleToken.ac.beginRoleEffect(h.roleToken) | |
| 406 | + | effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken) | |
| 334 | 407 | if h.d.afterDelayedKeyEffectAttempt != nil { | |
| 335 | 408 | h.d.afterDelayedKeyEffectAttempt(admitted) | |
| 336 | 409 | } | |
| 337 | 410 | if !admitted { | |
| 338 | 411 | return nil, nil, false | |
| 339 | 412 | } | |
| 340 | - | if h.d.afterRoleEffectAdmitted != nil { | |
| 341 | - | token := h.roleToken | |
| 413 | + | if h.d.afterAttachmentEffectAdmitted != nil { | |
| 414 | + | token := h.connectionToken | |
| 342 | 415 | token.effect = effect | |
| 343 | - | h.d.afterRoleEffectAdmitted(token) | |
| 416 | + | h.d.afterAttachmentEffectAdmitted(token) | |
| 344 | 417 | } | |
| 345 | - | sess, ok := localSession(h.roleToken.sess) | |
| 418 | + | sess, ok := localSession(h.connectionToken.sess) | |
| 346 | 419 | if !ok { | |
| 347 | 420 | effect.End() | |
| 348 | 421 | return nil, nil, false | |
| @@ -350,32 +423,35 @@ func (h daemonKeyHandler) acquireRoleEffect() (*session, *roleEffectTicket, bool | |||
| 350 | 423 | return sess, effect, true | |
| 351 | 424 | } | |
| 352 | 425 | sess := h.ac.currentSession() | |
| 353 | - | if sess == nil || sess.attachmentRole(h.ac) == attachmentSnatched { | |
| 426 | + | if sess == nil { | |
| 354 | 427 | return nil, nil, false | |
| 355 | 428 | } | |
| 356 | 429 | return sess, nil, false | |
| 357 | 430 | } | |
| 358 | 431 | ||
| 359 | 432 | func (h daemonKeyHandler) Forward(data []byte) { | |
| 360 | - | sess, effect, owned := h.acquireRoleEffect() | |
| 433 | + | sess, effect, owned := h.acquireAttachmentEffect() | |
| 361 | 434 | if sess == nil { | |
| 362 | 435 | return | |
| 363 | 436 | } | |
| 364 | 437 | if owned { | |
| 365 | 438 | defer effect.End() | |
| 366 | 439 | } | |
| 367 | - | tb := sess.activeTab() | |
| 368 | - | if tb == nil { | |
| 369 | - | return | |
| 370 | - | } | |
| 371 | - | tb.mu.Lock() | |
| 372 | - | p := tb.terminalTargetLocked() | |
| 373 | - | tb.mu.Unlock() | |
| 374 | - | h.d.writeToPane(sess, p, data) | |
| 440 | + | _ = sess.runMutation(func() error { | |
| 441 | + | tb := sess.tabForAttachment(h.ac) | |
| 442 | + | if tb == nil { | |
| 443 | + | return nil | |
| 444 | + | } | |
| 445 | + | tb.mu.Lock() | |
| 446 | + | p := tb.terminalTargetLocked() | |
| 447 | + | tb.mu.Unlock() | |
| 448 | + | h.d.writeToPane(sess, p, data) | |
| 449 | + | return nil | |
| 450 | + | }) | |
| 375 | 451 | } | |
| 376 | 452 | ||
| 377 | 453 | func (h daemonKeyHandler) Action(action keys.Action, _ []byte) { | |
| 378 | - | sess, effect, owned := h.acquireRoleEffect() | |
| 454 | + | sess, effect, owned := h.acquireAttachmentEffect() | |
| 379 | 455 | if sess == nil { | |
| 380 | 456 | return | |
| 381 | 457 | } | |
| @@ -383,12 +459,15 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) { | |||
| 383 | 459 | defer effect.End() | |
| 384 | 460 | } | |
| 385 | 461 | runAction := func(request daemonActionRequest) { | |
| 386 | - | request.target = resolveDaemonActionTarget(sess) | |
| 462 | + | request.effect = effect | |
| 387 | 463 | runner := h.actions | |
| 388 | 464 | if runner == nil { | |
| 389 | 465 | runner = daemonActions{d: h.d} | |
| 390 | 466 | } | |
| 391 | - | if err := runner.Run(request); err != nil { | |
| 467 | + | if err := sess.runMutation(func() error { | |
| 468 | + | request.target = resolveDaemonActionTargetForAttachment(sess, h.ac) | |
| 469 | + | return runner.Run(request) | |
| 470 | + | }); err != nil { | |
| 392 | 471 | if errors.Is(err, errDaemonActionNoChange) { | |
| 393 | 472 | return | |
| 394 | 473 | } | |
| @@ -408,10 +487,13 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) { | |||
| 408 | 487 | h.d.enterPalette(sess, h.ac) | |
| 409 | 488 | case keys.ActionJumpAttention: | |
| 410 | 489 | if !proxiedJumpSearchesOtherSessions(h.ac.proxied) { | |
| 411 | - | if idx, ok := oldestAttentionTab(sess); ok && sess.switchTab(idx) { | |
| 412 | - | h.d.activateTab(sess, sess.activeTab()) | |
| 413 | - | h.d.invalidateRender(sess, h.ac, true, "input.go") | |
| 414 | - | } | |
| 490 | + | _ = sess.runMutation(func() error { | |
| 491 | + | if idx, ok := oldestAttentionTab(sess); ok && sess.switchAttachmentTabForDispatch(h.ac, idx) { | |
| 492 | + | h.d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(h.ac), false, h.ac, nil) | |
| 493 | + | h.d.invalidateRender(sess, h.ac, true, "input.go") | |
| 494 | + | } | |
| 495 | + | return nil | |
| 496 | + | }) | |
| 415 | 497 | return | |
| 416 | 498 | } | |
| 417 | 499 | if effect == nil { | |
| @@ -421,13 +503,13 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) { | |||
| 421 | 503 | return | |
| 422 | 504 | } | |
| 423 | 505 | effect.bindActionEnd(h.d, "jump-attention") | |
| 424 | - | token := h.roleToken | |
| 506 | + | token := h.connectionToken | |
| 425 | 507 | token.effect = effect | |
| 426 | - | if err := h.d.jumpAttentionForRole(sess, h.ac, token); err != nil { | |
| 508 | + | if err := h.d.jumpAttentionForAttachment(sess, h.ac, token); err != nil { | |
| 427 | 509 | h.d.reportError(sess, err) | |
| 428 | 510 | } | |
| 429 | 511 | case keys.ActionToggleFloatingPane: | |
| 430 | - | if err := h.d.toggleFloating(sess, h.ac); err != nil { | |
| 512 | + | if err := sess.runMutation(func() error { return h.d.toggleFloating(sess, h.ac) }); err != nil { | |
| 431 | 513 | h.d.log.Warn("toggle floating pane failed", "err", err) | |
| 432 | 514 | h.d.reportError(sess, err) | |
| 433 | 515 | } | |
| @@ -457,14 +539,17 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) { | |||
| 457 | 539 | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, | |
| 458 | 540 | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: | |
| 459 | 541 | idx := int(action - keys.ActionSwitchTab1) | |
| 460 | - | if sess.switchTab(idx) { | |
| 461 | - | h.d.activateTab(sess, sess.activeTab()) | |
| 462 | - | h.d.invalidateRender(sess, h.ac, true, "input.go") | |
| 463 | - | } | |
| 542 | + | _ = sess.runMutation(func() error { | |
| 543 | + | if sess.switchAttachmentTabForDispatch(h.ac, idx) { | |
| 544 | + | h.d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(h.ac), false, h.ac, nil) | |
| 545 | + | h.d.invalidateRender(sess, h.ac, true, "input.go") | |
| 546 | + | } | |
| 547 | + | return nil | |
| 548 | + | }) | |
| 464 | 549 | } | |
| 465 | 550 | } | |
| 466 | 551 | ||
| 467 | - | func (h daemonKeyHandler) focus(sess *session, dir layout.Direction, effect *roleEffectTicket) { | |
| 552 | + | func (h daemonKeyHandler) focus(sess *session, dir layout.Direction, effect *attachmentEffectTicket) { | |
| 468 | 553 | var err error | |
| 469 | 554 | if h.ac.proxied { | |
| 470 | 555 | err = h.d.focusDirProxied(sess, h.ac, dir) | |
M internal/usecase/daemon/input_test.go +96 -288
| @@ -27,6 +27,59 @@ import ( | |||
| 27 | 27 | ||
| 28 | 28 | // --- test doubles ----------------------------------------------------------- | |
| 29 | 29 | ||
| 30 | + | type blockingInputActionRunner struct { | |
| 31 | + | admitted chan<- struct{} | |
| 32 | + | release <-chan struct{} | |
| 33 | + | mu *sync.Mutex | |
| 34 | + | order *[]string | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (r *blockingInputActionRunner) Run(daemonActionRequest) error { | |
| 38 | + | close(r.admitted) | |
| 39 | + | <-r.release | |
| 40 | + | r.mu.Lock() | |
| 41 | + | *r.order = append(*r.order, "keyboard") | |
| 42 | + | r.mu.Unlock() | |
| 43 | + | return nil | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestAttachedKeyboardMutationSharesDispatchBoundaryWithCommand(t *testing.T) { | |
| 47 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 48 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 49 | + | ac := &attachedClient{} | |
| 50 | + | ac.setSession(sess) | |
| 51 | + | ||
| 52 | + | var orderMu sync.Mutex | |
| 53 | + | var order []string | |
| 54 | + | admitted := make(chan struct{}) | |
| 55 | + | release := make(chan struct{}) | |
| 56 | + | runner := &blockingInputActionRunner{admitted: admitted, release: release, mu: &orderMu, order: &order} | |
| 57 | + | handler := daemonKeyHandler{d: d, ac: ac, actions: runner} | |
| 58 | + | keyboardDone := make(chan struct{}) | |
| 59 | + | go func() { | |
| 60 | + | handler.Action(keys.ActionGrowPaneWidth, nil) | |
| 61 | + | close(keyboardDone) | |
| 62 | + | }() | |
| 63 | + | <-admitted | |
| 64 | + | require.False(t, sess.dispatchMu.TryLock(), "keyboard mutation must hold the session dispatch boundary") | |
| 65 | + | ||
| 66 | + | commandDone := make(chan struct{}) | |
| 67 | + | go func() { | |
| 68 | + | _ = sess.runMutation(func() error { | |
| 69 | + | orderMu.Lock() | |
| 70 | + | order = append(order, "command") | |
| 71 | + | orderMu.Unlock() | |
| 72 | + | return nil | |
| 73 | + | }) | |
| 74 | + | close(commandDone) | |
| 75 | + | }() | |
| 76 | + | close(release) | |
| 77 | + | <-keyboardDone | |
| 78 | + | <-commandDone | |
| 79 | + | ||
| 80 | + | require.Equal(t, []string{"keyboard", "command"}, order) | |
| 81 | + | } | |
| 82 | + | ||
| 30 | 83 | func TestConfiguredConsumeOrExpelActionsRouteThroughDaemonInput(t *testing.T) { | |
| 31 | 84 | tests := []struct { | |
| 32 | 85 | name string | |
| @@ -53,7 +106,7 @@ func TestConfiguredConsumeOrExpelActionsRouteThroughDaemonInput(t *testing.T) { | |||
| 53 | 106 | ac.setSession(h.session) | |
| 54 | 107 | ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings) | |
| 55 | 108 | h.session.mu.Lock() | |
| 56 | - | h.session.client = ac | |
| 109 | + | h.session.registerAttachmentLocked(ac) | |
| 57 | 110 | h.session.mu.Unlock() | |
| 58 | 111 | invalidations := make(chan renderInvalidation, 1) | |
| 59 | 112 | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |
| @@ -82,7 +135,7 @@ func TestConfiguredConsumeOrExpelEdgeActionIsSilent(t *testing.T) { | |||
| 82 | 135 | ac.setSession(h.session) | |
| 83 | 136 | ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings) | |
| 84 | 137 | h.session.mu.Lock() | |
| 85 | - | h.session.client = ac | |
| 138 | + | h.session.registerAttachmentLocked(ac) | |
| 86 | 139 | h.session.mu.Unlock() | |
| 87 | 140 | invalidations := make(chan renderInvalidation, 1) | |
| 88 | 141 | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |
| @@ -129,254 +182,9 @@ func TestConsumeOrExpelKeyActionPreservesRearrangementWarning(t *testing.T) { | |||
| 129 | 182 | } | |
| 130 | 183 | } | |
| 131 | 184 | ||
| 132 | - | func TestResizeActionAdaptersSubmitEquivalentRequests(t *testing.T) { | |
| 133 | - | tests := []struct { | |
| 134 | - | name string | |
| 135 | - | action keys.Action | |
| 136 | - | palette func(paletteExec) error | |
| 137 | - | control func(controlExec) error | |
| 138 | - | }{ | |
| 139 | - | {"grow width", keys.ActionGrowPaneWidth, func(e paletteExec) error { return e.GrowPaneWidth() }, func(e controlExec) error { return e.GrowPaneWidth() }}, | |
| 140 | - | {"shrink width", keys.ActionShrinkPaneWidth, func(e paletteExec) error { return e.ShrinkPaneWidth() }, func(e controlExec) error { return e.ShrinkPaneWidth() }}, | |
| 141 | - | {"grow height", keys.ActionGrowPaneHeight, func(e paletteExec) error { return e.GrowPaneHeight() }, func(e controlExec) error { return e.GrowPaneHeight() }}, | |
| 142 | - | {"shrink height", keys.ActionShrinkPaneHeight, func(e paletteExec) error { return e.ShrinkPaneHeight() }, func(e controlExec) error { return e.ShrinkPaneHeight() }}, | |
| 143 | - | {"equalize", keys.ActionEqualizePanes, func(e paletteExec) error { return e.EqualizePanes() }, func(e controlExec) error { return e.EqualizePanes() }}, | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | for _, tt := range tests { | |
| 147 | - | t.Run(tt.name, func(t *testing.T) { | |
| 148 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 149 | - | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 150 | - | ac := &attachedClient{} | |
| 151 | - | ac.setSession(sess) | |
| 152 | - | target := resolveDaemonActionTarget(sess) | |
| 153 | - | keySpy := &actionRunnerSpy{} | |
| 154 | - | paletteSpy := &actionRunnerSpy{} | |
| 155 | - | controlSpy := &actionRunnerSpy{} | |
| 156 | - | ||
| 157 | - | daemonKeyHandler{d: d, ac: ac, actions: keySpy}.Action(tt.action, nil) | |
| 158 | - | require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, actions: paletteSpy})) | |
| 159 | - | require.NoError(t, tt.control(controlExec{d: d, sess: sess, target: target, actions: controlSpy})) | |
| 160 | - | ||
| 161 | - | require.Len(t, keySpy.requests, 1) | |
| 162 | - | require.Equal(t, keySpy.requests, paletteSpy.requests) | |
| 163 | - | require.Equal(t, keySpy.requests, controlSpy.requests) | |
| 164 | - | require.Same(t, sess, keySpy.requests[0].target.session) | |
| 165 | - | require.Same(t, target.tab, keySpy.requests[0].target.tab) | |
| 166 | - | require.Same(t, target.pane, keySpy.requests[0].target.pane) | |
| 167 | - | }) | |
| 168 | - | } | |
| 169 | - | } | |
| 170 | - | ||
| 171 | - | func TestResizeActionAdaptersProduceEquivalentGeometry(t *testing.T) { | |
| 172 | - | type adapterState struct { | |
| 173 | - | tree *layout.Tree | |
| 174 | - | placements []layout.Placement | |
| 175 | - | ptySizes map[layout.PaneID][]domain.Size | |
| 176 | - | generationDelta uint64 | |
| 177 | - | snapshotDirtyDelta uint64 | |
| 178 | - | } | |
| 179 | - | tests := []struct { | |
| 180 | - | name string | |
| 181 | - | action keys.Action | |
| 182 | - | palette func(paletteExec) error | |
| 183 | - | control func(controlExec) error | |
| 184 | - | }{ | |
| 185 | - | {"grow width", keys.ActionGrowPaneWidth, func(e paletteExec) error { return e.GrowPaneWidth() }, func(e controlExec) error { return e.GrowPaneWidth() }}, | |
| 186 | - | {"shrink width", keys.ActionShrinkPaneWidth, func(e paletteExec) error { return e.ShrinkPaneWidth() }, func(e controlExec) error { return e.ShrinkPaneWidth() }}, | |
| 187 | - | {"grow height", keys.ActionGrowPaneHeight, func(e paletteExec) error { return e.GrowPaneHeight() }, func(e controlExec) error { return e.GrowPaneHeight() }}, | |
| 188 | - | {"shrink height", keys.ActionShrinkPaneHeight, func(e paletteExec) error { return e.ShrinkPaneHeight() }, func(e controlExec) error { return e.ShrinkPaneHeight() }}, | |
| 189 | - | {"equalize", keys.ActionEqualizePanes, func(e paletteExec) error { return e.EqualizePanes() }, func(e controlExec) error { return e.EqualizePanes() }}, | |
| 190 | - | } | |
| 191 | - | ||
| 192 | - | for _, tt := range tests { | |
| 193 | - | t.Run(tt.name, func(t *testing.T) { | |
| 194 | - | run := func(t *testing.T, adapter string, attached bool) adapterState { | |
| 195 | - | t.Helper() | |
| 196 | - | ptys := map[layout.PaneID]*resizeActionPTY{ | |
| 197 | - | "pane-1": {}, "pane-2": {}, "pane-3": {}, | |
| 198 | - | } | |
| 199 | - | d, sess, ac, _ := newManualSessionWithPTYs(t, ptys["pane-1"]) | |
| 200 | - | tb := sess.activeTab() | |
| 201 | - | p2 := newPane("pane-2", ptys["pane-2"], tb.size) | |
| 202 | - | p3 := newPane("pane-3", ptys["pane-3"], tb.size) | |
| 203 | - | tb.mu.Lock() | |
| 204 | - | tb.panes[p2.id] = p2 | |
| 205 | - | tb.panes[p3.id] = p3 | |
| 206 | - | tb.tree = &layout.Tree{ | |
| 207 | - | Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |
| 208 | - | layout.NewLeaf("pane-1"), | |
| 209 | - | {Kind: layout.Split, Dir: layout.Vertical, Weight: 2, Children: []*layout.Node{ | |
| 210 | - | layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3"), | |
| 211 | - | }}, | |
| 212 | - | }}, | |
| 213 | - | Focus: "pane-2", | |
| 214 | - | } | |
| 215 | - | tb.tree.Root.Children[0].Weight = 1 | |
| 216 | - | tb.tree.Root.Children[1].Children[0].Weight = 1 | |
| 217 | - | tb.tree.Root.Children[1].Children[1].Weight = 2 | |
| 218 | - | generation := tb.layoutGeneration | |
| 219 | - | tb.mu.Unlock() | |
| 220 | - | sess.snapEligible.Store(true) | |
| 221 | - | sess.snapshotMu.Lock() | |
| 222 | - | dirty := sess.snapshotGeneration | |
| 223 | - | sess.snapshotMu.Unlock() | |
| 224 | - | ||
| 225 | - | invalidations := make(chan renderInvalidation, 2) | |
| 226 | - | rc := newRenderCoordinator(renderCoordinatorOptions{ | |
| 227 | - | wake: func(renderWake) {}, | |
| 228 | - | onInvalidate: func(inv renderInvalidation) { invalidations <- inv }, | |
| 229 | - | }) | |
| 230 | - | sess.installRenderCoordinator(rc) | |
| 231 | - | if attached { | |
| 232 | - | rc.attach(ac) | |
| 233 | - | } else { | |
| 234 | - | sess.mu.Lock() | |
| 235 | - | sess.client = nil | |
| 236 | - | sess.mu.Unlock() | |
| 237 | - | } | |
| 238 | - | ||
| 239 | - | switch adapter { | |
| 240 | - | case "key": | |
| 241 | - | daemonKeyHandler{d: d, ac: ac}.Action(tt.action, nil) | |
| 242 | - | case "palette": | |
| 243 | - | require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, ac: ac})) | |
| 244 | - | case "control": | |
| 245 | - | target := resolveDaemonActionTarget(sess) | |
| 246 | - | require.NoError(t, tt.control(controlExec{d: d, sess: sess, target: target})) | |
| 247 | - | default: | |
| 248 | - | t.Fatalf("unknown adapter %q", adapter) | |
| 249 | - | } | |
| 250 | - | ||
| 251 | - | tb.mu.Lock() | |
| 252 | - | tree := tb.tree.Clone() | |
| 253 | - | gotGeneration := tb.layoutGeneration | |
| 254 | - | placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |
| 255 | - | tb.mu.Unlock() | |
| 256 | - | require.True(t, ok) | |
| 257 | - | sess.snapshotMu.Lock() | |
| 258 | - | gotDirty := sess.snapshotGeneration | |
| 259 | - | sess.snapshotMu.Unlock() | |
| 260 | - | ||
| 261 | - | gotPTY := make(map[layout.PaneID][]domain.Size, len(ptys)) | |
| 262 | - | for id, pty := range ptys { | |
| 263 | - | gotPTY[id] = pty.Sizes() | |
| 264 | - | } | |
| 265 | - | for _, placement := range placements { | |
| 266 | - | require.Equal(t, []domain.Size{{Cols: placement.Content.Width, Rows: placement.Content.Height}}, gotPTY[placement.ID], "canonical PTY resize for %s", placement.ID) | |
| 267 | - | } | |
| 268 | - | if attached { | |
| 269 | - | awaitInvalidation(t, invalidations) | |
| 270 | - | requireNoInvalidation(t, invalidations) | |
| 271 | - | } else { | |
| 272 | - | requireNoInvalidation(t, invalidations) | |
| 273 | - | } | |
| 274 | - | return adapterState{tree: tree, placements: placements, ptySizes: gotPTY, generationDelta: gotGeneration - generation, snapshotDirtyDelta: gotDirty - dirty} | |
| 275 | - | } | |
| 276 | - | ||
| 277 | - | keyState := run(t, "key", true) | |
| 278 | - | require.Equal(t, uint64(1), keyState.generationDelta) | |
| 279 | - | require.Equal(t, uint64(1), keyState.snapshotDirtyDelta) | |
| 280 | - | require.Equal(t, keyState, run(t, "palette", true)) | |
| 281 | - | require.Equal(t, keyState, run(t, "control", true)) | |
| 282 | - | headless := run(t, "control", false) | |
| 283 | - | require.Equal(t, uint64(1), headless.generationDelta) | |
| 284 | - | require.Equal(t, uint64(1), headless.snapshotDirtyDelta) | |
| 285 | - | require.Equal(t, keyState, headless) | |
| 286 | - | }) | |
| 287 | - | } | |
| 288 | - | } | |
| 289 | - | ||
| 290 | - | type resizeActionPTY struct { | |
| 291 | - | mu sync.Mutex | |
| 292 | - | sizes []domain.Size | |
| 293 | - | } | |
| 294 | - | ||
| 295 | - | func (*resizeActionPTY) Read([]byte) (int, error) { return 0, nil } | |
| 296 | - | func (*resizeActionPTY) Write(b []byte) (int, error) { return len(b), nil } | |
| 297 | - | func (*resizeActionPTY) Close() error { return nil } | |
| 298 | - | func (*resizeActionPTY) Pid() int { return 0 } | |
| 299 | - | func (*resizeActionPTY) ForegroundPgid() (int, error) { return 0, nil } | |
| 300 | - | func (p *resizeActionPTY) Resize(size domain.Size) error { | |
| 301 | - | p.mu.Lock() | |
| 302 | - | p.sizes = append(p.sizes, size) | |
| 303 | - | p.mu.Unlock() | |
| 304 | - | return nil | |
| 305 | - | } | |
| 306 | - | ||
| 307 | - | func (p *resizeActionPTY) Sizes() []domain.Size { | |
| 308 | - | p.mu.Lock() | |
| 309 | - | defer p.mu.Unlock() | |
| 310 | - | return append([]domain.Size(nil), p.sizes...) | |
| 311 | - | } | |
| 312 | - | ||
| 313 | 185 | // stubClock returns timers whose channel never fires, so a scheduler under it | |
| 314 | 186 | // blocks in its debounce loop until the session context is cancelled. Used by | |
| 315 | 187 | ||
| 316 | - | func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) { | |
| 317 | - | writes1 := make(chan []byte, 1) | |
| 318 | - | writes2 := make(chan []byte, 1) | |
| 319 | - | writes3 := make(chan []byte, 1) | |
| 320 | - | p1, releasePTY1 := newBlockingPTYWithWrites(t, writes1) | |
| 321 | - | p2, releasePTY2 := newBlockingPTYWithWrites(t, writes2) | |
| 322 | - | p3, releasePTY3 := newBlockingPTYWithWrites(t, writes3) | |
| 323 | - | d := newTestDaemon(t, newFactorySeq(t, p1, p2, p3), stubClock{}) | |
| 324 | - | tr, sends, releaseConn := newConn(t, | |
| 325 | - | mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}), | |
| 326 | - | frameInput([]byte("\x1b ")), | |
| 327 | - | frameInput([]byte("CNT\r")), | |
| 328 | - | frameInput([]byte("\x1b ")), | |
| 329 | - | frameInput([]byte("CNT\r")), | |
| 330 | - | frameInput([]byte("\x1b1")), | |
| 331 | - | frameInput([]byte("A")), | |
| 332 | - | frameInput([]byte("\x1b2")), | |
| 333 | - | frameInput([]byte("B")), | |
| 334 | - | frameInput([]byte("\x1b3")), | |
| 335 | - | frameInput([]byte("C")), | |
| 336 | - | ) | |
| 337 | - | ||
| 338 | - | var hg sync.WaitGroup | |
| 339 | - | hg.Go(func() { d.handleConn(tr) }) | |
| 340 | - | awaitFrame(t, sends, ports.MsgWelcome) | |
| 341 | - | awaitFrame(t, sends, ports.MsgOutput) | |
| 342 | - | ||
| 343 | - | require.Eventually(t, func() bool { | |
| 344 | - | sessions := listSessions(t, d) | |
| 345 | - | return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 3 | |
| 346 | - | }, 2*time.Second, 5*time.Millisecond) | |
| 347 | - | require.Eventually(t, func() bool { return len(writes1) == 1 }, 2*time.Second, 5*time.Millisecond) | |
| 348 | - | requirePTYWrite(t, writes1, []byte("A")) | |
| 349 | - | require.Eventually(t, func() bool { return len(writes2) == 1 }, 2*time.Second, 5*time.Millisecond) | |
| 350 | - | requirePTYWrite(t, writes2, []byte("B")) | |
| 351 | - | require.Eventually(t, func() bool { return len(writes3) == 1 }, 2*time.Second, 5*time.Millisecond) | |
| 352 | - | requirePTYWrite(t, writes3, []byte("C")) | |
| 353 | - | ||
| 354 | - | d.mu.Lock() | |
| 355 | - | var sess *session | |
| 356 | - | for _, candidate := range d.sessions { | |
| 357 | - | if local, ok := localSession(candidate); ok { | |
| 358 | - | sess = local | |
| 359 | - | break | |
| 360 | - | } | |
| 361 | - | } | |
| 362 | - | d.mu.Unlock() | |
| 363 | - | require.NotNil(t, sess) | |
| 364 | - | sess.mu.Lock() | |
| 365 | - | tabs := append([]*tab(nil), sess.tabs...) | |
| 366 | - | sess.mu.Unlock() | |
| 367 | - | require.Len(t, tabs, 3) | |
| 368 | - | for _, tb := range tabs { | |
| 369 | - | requireFloatingInitialized(t, tb) | |
| 370 | - | } | |
| 371 | - | ||
| 372 | - | releaseConn() | |
| 373 | - | releasePTY1() | |
| 374 | - | releasePTY2() | |
| 375 | - | releasePTY3() | |
| 376 | - | hg.Wait() | |
| 377 | - | d.sessWg.Wait() | |
| 378 | - | } | |
| 379 | - | ||
| 380 | 188 | func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) { | |
| 381 | 189 | d, sess, ac, sends := newManualSessionWithPTYs(t, nil, nil) | |
| 382 | 190 | source := sess.tabs[0].focusedPane() | |
| @@ -401,7 +209,7 @@ func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) { | |||
| 401 | 209 | // Use the real key action: it requests the mandatory complete repaint for | |
| 402 | 210 | // the first target-tab frame. | |
| 403 | 211 | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2, nil) | |
| 404 | - | require.Equal(t, 1, activeTabIndex(sess)) | |
| 212 | + | require.Equal(t, 1, testAttachmentTabIndex(sess)) | |
| 405 | 213 | ||
| 406 | 214 | second := awaitFrame(t, sends, ports.MsgOutput) | |
| 407 | 215 | secondOutput, err := ports.UnmarshalOutput(second.Payload) | |
| @@ -496,7 +304,7 @@ func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) { | |||
| 496 | 304 | floating := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 5}) | |
| 497 | 305 | floating.ctx = floatingCtx | |
| 498 | 306 | floating.screen.Write([]byte("popup-content")) | |
| 499 | - | installTestFloating(sess.activeTab(), floating, false) | |
| 307 | + | installTestFloating(testAttachmentTab(sess), floating, false) | |
| 500 | 308 | ||
| 501 | 309 | // Establish the client shadow while the retained popup is hidden. | |
| 502 | 310 | d.paint(sess, ac, true, nil) | |
| @@ -518,7 +326,7 @@ func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) { | |||
| 518 | 326 | d.handleInput(sess, ac, []byte("\x1bf")) | |
| 519 | 327 | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |
| 520 | 328 | require.NotContains(t, strings.Join(frameRows(client.Frame), "\n"), "popup-content") | |
| 521 | - | tb := sess.activeTab() | |
| 329 | + | tb := testAttachmentTab(sess) | |
| 522 | 330 | tb.mu.Lock() | |
| 523 | 331 | require.Equal(t, floatingHidden, tb.floating.state) | |
| 524 | 332 | require.Same(t, floating, tb.floating.pane) | |
| @@ -586,7 +394,7 @@ func TestFloatingKeyboardRoutesVisibleAndHiddenInput(t *testing.T) { | |||
| 586 | 394 | defer releaseNormal() | |
| 587 | 395 | defer releaseFloating() | |
| 588 | 396 | d, sess, ac, _ := newManualSessionWithPTYs(t, normal) | |
| 589 | - | installTestFloating(sess.activeTab(), newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), tc.visible) | |
| 397 | + | installTestFloating(testAttachmentTab(sess), newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), tc.visible) | |
| 590 | 398 | ||
| 591 | 399 | daemonKeyHandler{d: d, ac: ac}.Forward(tc.input) | |
| 592 | 400 | if tc.visible { | |
| @@ -608,7 +416,7 @@ func TestFloatingBracketedPasteAndGlobalActions(t *testing.T) { | |||
| 608 | 416 | defer releaseNormal() | |
| 609 | 417 | defer releaseFloating() | |
| 610 | 418 | d, sess, ac, _ := newManualSessionWithPTYs(t, normal) | |
| 611 | - | installTestFloating(sess.activeTab(), newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), true) | |
| 419 | + | installTestFloating(testAttachmentTab(sess), newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), true) | |
| 612 | 420 | ||
| 613 | 421 | paste := []byte("\x1b[200~paste\x1bf\x1b[201~") | |
| 614 | 422 | d.handleInput(sess, ac, paste) | |
| @@ -626,7 +434,7 @@ func TestFloatingStaysTerminalTargetAfterDirectionalFocus(t *testing.T) { | |||
| 626 | 434 | defer releaseNormal() | |
| 627 | 435 | defer releaseFloating() | |
| 628 | 436 | d, sess, ac, _ := newManualSessionWithPTYs(t, normal) | |
| 629 | - | tb := sess.activeTab() | |
| 437 | + | tb := testAttachmentTab(sess) | |
| 630 | 438 | second := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 5}) | |
| 631 | 439 | tb.mu.Lock() | |
| 632 | 440 | tb.size = domain.Size{Cols: 81, Rows: 5} | |
| @@ -658,7 +466,7 @@ func TestActionFocusPaneAtEdgeStaysSilent(t *testing.T) { | |||
| 658 | 466 | // the user as a notice instead of being silently discarded. | |
| 659 | 467 | func TestActionFocusPaneGenuineErrorReportsNoticeInternal(t *testing.T) { | |
| 660 | 468 | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 661 | - | tb := sess.activeTab() | |
| 469 | + | tb := testAttachmentTab(sess) | |
| 662 | 470 | tb.mu.Lock() | |
| 663 | 471 | tb.tree = nil | |
| 664 | 472 | tb.mu.Unlock() | |
| @@ -675,7 +483,7 @@ func TestActionFocusPaneGenuineErrorReportsNoticeInternal(t *testing.T) { | |||
| 675 | 483 | ||
| 676 | 484 | func TestFloatingVisibilityRemainsIndependentAcrossTabSwitches(t *testing.T) { | |
| 677 | 485 | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 678 | - | first := sess.activeTab() | |
| 486 | + | first := testAttachmentTab(sess) | |
| 679 | 487 | second := newTab(nil, first.size) | |
| 680 | 488 | installTestFloating(first, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true) | |
| 681 | 489 | installTestFloating(second, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), false) | |
| @@ -699,7 +507,7 @@ func TestFloatingMouseTranslatesSGRToInnerCoordinates(t *testing.T) { | |||
| 699 | 507 | floatingPTY := portsmocks.NewMockPTY(t) | |
| 700 | 508 | floatingPTY.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once() | |
| 701 | 509 | d, sess, ac, _ := newManualSessionWithPTYs(t, normal) | |
| 702 | - | tb := sess.activeTab() | |
| 510 | + | tb := testAttachmentTab(sess) | |
| 703 | 511 | tb.mu.Lock() | |
| 704 | 512 | tb.size = domain.Size{Cols: 100, Rows: 20} | |
| 705 | 513 | tb.mu.Unlock() | |
| @@ -716,7 +524,7 @@ func TestFloatingMouseIgnoresBorderAndOutside(t *testing.T) { | |||
| 716 | 524 | normal := portsmocks.NewMockPTY(t) | |
| 717 | 525 | floatingPTY := portsmocks.NewMockPTY(t) | |
| 718 | 526 | d, sess, ac, _ := newManualSessionWithPTYs(t, normal) | |
| 719 | - | tb := sess.activeTab() | |
| 527 | + | tb := testAttachmentTab(sess) | |
| 720 | 528 | tb.mu.Lock() | |
| 721 | 529 | tb.size = domain.Size{Cols: 100, Rows: 20} | |
| 722 | 530 | tb.mu.Unlock() | |
| @@ -736,7 +544,7 @@ func TestResponsiveDrawerFloatingMouseRoutesOnlyInnerClientCells(t *testing.T) { | |||
| 736 | 544 | floatingPTY := portsmocks.NewMockPTY(t) | |
| 737 | 545 | floatingPTY.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once() | |
| 738 | 546 | d, sess, ac, _ := newManualSessionWithPTYs(t, normal) | |
| 739 | - | tb := sess.activeTab() | |
| 547 | + | tb := testAttachmentTab(sess) | |
| 740 | 548 | complete := domain.Size{Cols: 79, Rows: 25} | |
| 741 | 549 | content := tabSize(complete) | |
| 742 | 550 | geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig()) | |
| @@ -760,7 +568,7 @@ func TestResponsiveDrawerCopyMouseMapsInnerAndPinsDragAwayFromChrome(t *testing. | |||
| 760 | 568 | p, release := newBlockingPTY(t) | |
| 761 | 569 | defer release() | |
| 762 | 570 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 763 | - | tb := sess.activeTab() | |
| 571 | + | tb := testAttachmentTab(sess) | |
| 764 | 572 | complete := domain.Size{Cols: 79, Rows: 25} | |
| 765 | 573 | content := tabSize(complete) | |
| 766 | 574 | geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig()) | |
| @@ -960,12 +768,12 @@ func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) { | |||
| 960 | 768 | release() | |
| 961 | 769 | } | |
| 962 | 770 | }() | |
| 963 | - | sess.active = tc.start | |
| 771 | + | selectTestAttachmentTab(sess, tc.start) | |
| 964 | 772 | ||
| 965 | 773 | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 966 | 774 | d.handleInput(sess, ac, tc.query) | |
| 967 | 775 | ||
| 968 | - | require.Equal(t, tc.wantIndex, activeTabIndex(sess)) | |
| 776 | + | require.Equal(t, tc.wantIndex, testAttachmentTabIndex(sess)) | |
| 969 | 777 | }) | |
| 970 | 778 | } | |
| 971 | 779 | } | |
| @@ -1015,7 +823,7 @@ func TestBackSessionReportsStaleHandoffOnce(t *testing.T) { | |||
| 1015 | 823 | // A displaced attachment makes the previously valid handoff stale by the | |
| 1016 | 824 | // time switchToTarget commits it. | |
| 1017 | 825 | current.mu.Lock() | |
| 1018 | - | current.client = nil | |
| 826 | + | clearAttachmentsForTestLocked(current) | |
| 1019 | 827 | current.mu.Unlock() | |
| 1020 | 828 | ||
| 1021 | 829 | d.backSession(current, ac) | |
| @@ -1200,14 +1008,14 @@ func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) { | |||
| 1200 | 1008 | p2, releasePTY2 := newBlockingPTY(t) | |
| 1201 | 1009 | p3, releasePTY3 := newBlockingPTYWithWrites(t, writes) | |
| 1202 | 1010 | d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2, p3) | |
| 1203 | - | sess.active = 1 | |
| 1011 | + | selectTestAttachmentTab(sess, 1) | |
| 1204 | 1012 | ||
| 1205 | 1013 | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 1206 | 1014 | d.handleInput(sess, ac, []byte("CLT\r")) | |
| 1207 | 1015 | ||
| 1208 | 1016 | require.Equal(t, 1, sessionCount(d)) | |
| 1209 | 1017 | require.Len(t, sess.tabs, 2) | |
| 1210 | - | require.Equal(t, 1, activeTabIndex(sess), "closing middle tab selects the next remaining tab") | |
| 1018 | + | require.Equal(t, 1, testAttachmentTabIndex(sess), "closing middle tab selects the next remaining tab") | |
| 1211 | 1019 | d.handleInput(sess, ac, []byte("Z")) | |
| 1212 | 1020 | require.Eventually(t, func() bool { return len(writes) == 1 }, 2*time.Second, 5*time.Millisecond) | |
| 1213 | 1021 | requirePTYWrite(t, writes, []byte("Z")) | |
| @@ -1224,7 +1032,7 @@ func TestAltDDetachesCurrentClient(t *testing.T) { | |||
| 1224 | 1032 | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 1225 | 1033 | d.handleInput(sess, ac, []byte("DET\r")) | |
| 1226 | 1034 | ||
| 1227 | - | require.Nil(t, sess.client) | |
| 1035 | + | require.Empty(t, sess.snapshotAttachments()) | |
| 1228 | 1036 | f := awaitFrame(t, sends, ports.MsgDetached) | |
| 1229 | 1037 | det, err := ports.UnmarshalDetached(f.Payload) | |
| 1230 | 1038 | require.NoError(t, err) | |
| @@ -1258,7 +1066,7 @@ func TestRNTOpensPromptAndRenamesActiveTab(t *testing.T) { | |||
| 1258 | 1066 | release() | |
| 1259 | 1067 | } | |
| 1260 | 1068 | }() | |
| 1261 | - | sess.active = 1 | |
| 1069 | + | selectTestAttachmentTab(sess, 1) | |
| 1262 | 1070 | ||
| 1263 | 1071 | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 1264 | 1072 | awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -1639,7 +1447,7 @@ func TestMouseWheelOverUnfocusedPaneDoesNotFocusAndForwardsChildMouse(t *testing | |||
| 1639 | 1447 | p2.EXPECT().Write([]byte("\x1b[<64;1;1M")).Return(len("\x1b[<64;1;1M"), nil).Once() | |
| 1640 | 1448 | d, sess, ac, _ := newManualSessionWithPTYs(t, p1) | |
| 1641 | 1449 | d.procComm = nil | |
| 1642 | - | tb := sess.activeTab() | |
| 1450 | + | tb := testAttachmentTab(sess) | |
| 1643 | 1451 | p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5}) | |
| 1644 | 1452 | p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |
| 1645 | 1453 | tb.mu.Lock() | |
| @@ -1663,7 +1471,7 @@ func TestRejectedLeftReleaseInvalidatesFreshPointerBeforeMotion(t *testing.T) { | |||
| 1663 | 1471 | { | |
| 1664 | 1472 | name: "split divider", | |
| 1665 | 1473 | setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, _ *[]byte) []byte { | |
| 1666 | - | tb := sess.activeTab() | |
| 1474 | + | tb := testAttachmentTab(sess) | |
| 1667 | 1475 | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |
| 1668 | 1476 | tb.mu.Lock() | |
| 1669 | 1477 | tb.size = domain.Size{Cols: 41, Rows: 10} | |
| @@ -1676,7 +1484,7 @@ func TestRejectedLeftReleaseInvalidatesFreshPointerBeforeMotion(t *testing.T) { | |||
| 1676 | 1484 | { | |
| 1677 | 1485 | name: "split title bar", | |
| 1678 | 1486 | setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, _ *[]byte) []byte { | |
| 1679 | - | tb := sess.activeTab() | |
| 1487 | + | tb := testAttachmentTab(sess) | |
| 1680 | 1488 | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |
| 1681 | 1489 | tb.mu.Lock() | |
| 1682 | 1490 | tb.size = domain.Size{Cols: 41, Rows: 10} | |
| @@ -1689,7 +1497,7 @@ func TestRejectedLeftReleaseInvalidatesFreshPointerBeforeMotion(t *testing.T) { | |||
| 1689 | 1497 | { | |
| 1690 | 1498 | name: "floating exterior", | |
| 1691 | 1499 | setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, press *[]byte) []byte { | |
| 1692 | - | tb := sess.activeTab() | |
| 1500 | + | tb := testAttachmentTab(sess) | |
| 1693 | 1501 | floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}) | |
| 1694 | 1502 | installTestFloating(tb, floating, true) | |
| 1695 | 1503 | tb.mu.Lock() | |
| @@ -1749,7 +1557,7 @@ func TestPressAtFormerStackTitleRowIsTreatedAsExpandedContent(t *testing.T) { | |||
| 1749 | 1557 | p, release := newBlockingPTY(t) | |
| 1750 | 1558 | defer release() | |
| 1751 | 1559 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 1752 | - | tb := sess.activeTab() | |
| 1560 | + | tb := testAttachmentTab(sess) | |
| 1753 | 1561 | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 9}) | |
| 1754 | 1562 | p3 := newPane("pane-3", nil, domain.Size{Cols: 20, Rows: 1}) | |
| 1755 | 1563 | tb.mu.Lock() | |
| @@ -1821,7 +1629,7 @@ func TestMouseDividerAndTitleBarDoNotForwardBogusCoordinates(t *testing.T) { | |||
| 1821 | 1629 | p2.EXPECT().Resize(mock.Anything).Return(nil).Maybe() | |
| 1822 | 1630 | d, sess, ac, _ := newManualSessionWithPTYs(t, p1) | |
| 1823 | 1631 | d.procComm = nil | |
| 1824 | - | tb := sess.activeTab() | |
| 1632 | + | tb := testAttachmentTab(sess) | |
| 1825 | 1633 | tb.focusedPane().screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |
| 1826 | 1634 | tb.mu.Lock() | |
| 1827 | 1635 | tb.size = tc.size | |
| @@ -1844,7 +1652,7 @@ func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) { | |||
| 1844 | 1652 | p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Maybe() | |
| 1845 | 1653 | d, sess, ac, sends := newManualSessionWithPTYs(t, p1) | |
| 1846 | 1654 | d.procComm = nil | |
| 1847 | - | tb := sess.activeTab() | |
| 1655 | + | tb := testAttachmentTab(sess) | |
| 1848 | 1656 | for i := range 10 { | |
| 1849 | 1657 | copy(tb.focusedPane().screen.Frame.Row(i), testRow(string(rune('a'+i)))) | |
| 1850 | 1658 | } | |
| @@ -1878,7 +1686,7 @@ func TestMouseHitTestFocusesPaneAndTranslatesSGRColumns(t *testing.T) { | |||
| 1878 | 1686 | p2.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once() | |
| 1879 | 1687 | d, sess, ac, _ := newManualSessionWithPTYs(t, p1) | |
| 1880 | 1688 | d.procComm = nil | |
| 1881 | - | tb := sess.activeTab() | |
| 1689 | + | tb := testAttachmentTab(sess) | |
| 1882 | 1690 | p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5}) | |
| 1883 | 1691 | p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |
| 1884 | 1692 | tb.mu.Lock() | |
| @@ -1905,7 +1713,7 @@ func TestMouseGatedWhileNoticesOverlayActive(t *testing.T) { | |||
| 1905 | 1713 | p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe() | |
| 1906 | 1714 | d, sess, ac, sends := newManualSessionWithPTYs(t, p1) | |
| 1907 | 1715 | d.procComm = nil | |
| 1908 | - | tb := sess.activeTab() | |
| 1716 | + | tb := testAttachmentTab(sess) | |
| 1909 | 1717 | p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5}) | |
| 1910 | 1718 | p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |
| 1911 | 1719 | tb.mu.Lock() | |
| @@ -1935,7 +1743,7 @@ func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) { | |||
| 1935 | 1743 | p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 4}).Return(nil).Maybe() | |
| 1936 | 1744 | d, sess, ac, _ := newManualSessionWithPTYs(t, p1) | |
| 1937 | 1745 | d.procComm = nil | |
| 1938 | - | tb := sess.activeTab() | |
| 1746 | + | tb := testAttachmentTab(sess) | |
| 1939 | 1747 | p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 3}) | |
| 1940 | 1748 | tb.mu.Lock() | |
| 1941 | 1749 | tb.size = domain.Size{Cols: 20, Rows: 5} | |
| @@ -1954,7 +1762,7 @@ func TestActiveCopyPressOnOtherPaneFocusesAndExitsBeforeFutureInput(t *testing.T | |||
| 1954 | 1762 | p1, release := newBlockingPTY(t) | |
| 1955 | 1763 | defer release() | |
| 1956 | 1764 | d, sess, ac, _ := newManualSessionWithPTYs(t, p1) | |
| 1957 | - | tb := sess.activeTab() | |
| 1765 | + | tb := testAttachmentTab(sess) | |
| 1958 | 1766 | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |
| 1959 | 1767 | tb.mu.Lock() | |
| 1960 | 1768 | tb.size = domain.Size{Cols: 41, Rows: 10} | |
| @@ -2053,8 +1861,8 @@ func TestActiveCopyMouseIgnoresStalePointerResets(t *testing.T) { | |||
| 2053 | 1861 | name: "press-owned release rejects mapping", | |
| 2054 | 1862 | prepare: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) mouse.Event { | |
| 2055 | 1863 | t.Helper() | |
| 2056 | - | p := sess.activeTab().focusedPane() | |
| 2057 | - | tb := sess.activeTab() | |
| 1864 | + | p := testAttachmentTab(sess).focusedPane() | |
| 1865 | + | tb := testAttachmentTab(sess) | |
| 2058 | 1866 | tb.mu.Lock() | |
| 2059 | 1867 | geometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), 1, 2) | |
| 2060 | 1868 | tb.mu.Unlock() | |
| @@ -2069,7 +1877,7 @@ func TestActiveCopyMouseIgnoresStalePointerResets(t *testing.T) { | |||
| 2069 | 1877 | name: "current geometry disappears", | |
| 2070 | 1878 | prepare: func(t *testing.T, _ *Daemon, sess *session, _ *attachedClient) mouse.Event { | |
| 2071 | 1879 | t.Helper() | |
| 2072 | - | tb := sess.activeTab() | |
| 1880 | + | tb := testAttachmentTab(sess) | |
| 2073 | 1881 | tb.mu.Lock() | |
| 2074 | 1882 | tb.tree = nil | |
| 2075 | 1883 | tb.mu.Unlock() | |
| @@ -2080,7 +1888,7 @@ func TestActiveCopyMouseIgnoresStalePointerResets(t *testing.T) { | |||
| 2080 | 1888 | name: "fresh press rejects document mapping", | |
| 2081 | 1889 | prepare: func(t *testing.T, _ *Daemon, sess *session, _ *attachedClient) mouse.Event { | |
| 2082 | 1890 | t.Helper() | |
| 2083 | - | tb := sess.activeTab() | |
| 1891 | + | tb := testAttachmentTab(sess) | |
| 2084 | 1892 | tb.mu.Lock() | |
| 2085 | 1893 | tb.size.Rows = 40 // valid layout coordinate, outside the old document. | |
| 2086 | 1894 | tb.mu.Unlock() | |
| @@ -2109,7 +1917,7 @@ func TestActiveCopyMouseIgnoresStalePointerResets(t *testing.T) { | |||
| 2109 | 1917 | } | |
| 2110 | 1918 | defer func() { d.beforeCopyMouseMap = nil }() | |
| 2111 | 1919 | ||
| 2112 | - | d.handleActiveCopyMouse(sess, ac, sess.activeTab(), ev) | |
| 1920 | + | d.handleActiveCopyMouse(sess, ac, testAttachmentTab(sess), ev) | |
| 2113 | 1921 | ||
| 2114 | 1922 | ac.overlays.copyMu.Lock() | |
| 2115 | 1923 | defer ac.overlays.copyMu.Unlock() | |
| @@ -2128,7 +1936,7 @@ func TestActiveCopyMouseRejectsViewportChangeAfterMappingSnapshot(t *testing.T) | |||
| 2128 | 1936 | p, release := newBlockingPTY(t) | |
| 2129 | 1937 | defer release() | |
| 2130 | 1938 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 2131 | - | pane := sess.activeTab().focusedPane() | |
| 1939 | + | pane := testAttachmentTab(sess).focusedPane() | |
| 2132 | 1940 | for range 8 { | |
| 2133 | 1941 | appendHistoryRow(t, pane.history, testRow("history")) | |
| 2134 | 1942 | } | |
| @@ -2162,13 +1970,13 @@ func TestFreshCopyPressUsesFrameAbsoluteHitTestGeometry(t *testing.T) { | |||
| 2162 | 1970 | { | |
| 2163 | 1971 | name: "mono", | |
| 2164 | 1972 | setup: func(_ *testing.T, _ *Daemon, sess *session) (*pane, mouse.Event) { | |
| 2165 | - | return sess.activeTab().focusedPane(), mouse.Event{Button: mouse.Left, Type: mouse.Press, Col: 1, Row: 1} | |
| 1973 | + | return testAttachmentTab(sess).focusedPane(), mouse.Event{Button: mouse.Left, Type: mouse.Press, Col: 1, Row: 1} | |
| 2166 | 1974 | }, | |
| 2167 | 1975 | }, | |
| 2168 | 1976 | { | |
| 2169 | 1977 | name: "split", | |
| 2170 | 1978 | setup: func(_ *testing.T, _ *Daemon, sess *session) (*pane, mouse.Event) { | |
| 2171 | - | tb := sess.activeTab() | |
| 1979 | + | tb := testAttachmentTab(sess) | |
| 2172 | 1980 | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |
| 2173 | 1981 | tb.mu.Lock() | |
| 2174 | 1982 | tb.size = domain.Size{Cols: 41, Rows: 10} | |
| @@ -2182,8 +1990,8 @@ func TestFreshCopyPressUsesFrameAbsoluteHitTestGeometry(t *testing.T) { | |||
| 2182 | 1990 | name: "floating", | |
| 2183 | 1991 | setup: func(t *testing.T, d *Daemon, sess *session) (*pane, mouse.Event) { | |
| 2184 | 1992 | p := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}) | |
| 2185 | - | installTestFloating(sess.activeTab(), p, true) | |
| 2186 | - | tb := sess.activeTab() | |
| 1993 | + | installTestFloating(testAttachmentTab(sess), p, true) | |
| 1994 | + | tb := testAttachmentTab(sess) | |
| 2187 | 1995 | tb.mu.Lock() | |
| 2188 | 1996 | _, geometry, visible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig()) | |
| 2189 | 1997 | tb.mu.Unlock() | |
| @@ -2197,7 +2005,7 @@ func TestFreshCopyPressUsesFrameAbsoluteHitTestGeometry(t *testing.T) { | |||
| 2197 | 2005 | defer release() | |
| 2198 | 2006 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 2199 | 2007 | wantPane, ev := tc.setup(t, d, sess) | |
| 2200 | - | tb := sess.activeTab() | |
| 2008 | + | tb := testAttachmentTab(sess) | |
| 2201 | 2009 | tb.mu.Lock() | |
| 2202 | 2010 | wantGeometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), ev.Col, ev.Row) | |
| 2203 | 2011 | tb.mu.Unlock() | |
M internal/usecase/daemon/lease_callback_guard_test.go +1 -1
| @@ -54,7 +54,7 @@ func TestResizeAndRetryCallbacksRejectSameObjectLeaseReplacement(t *testing.T) { | |||
| 54 | 54 | done := tc.done(t, rc) | |
| 55 | 55 | require.NotNil(t, done) | |
| 56 | 56 | ||
| 57 | - | rc.notePark(ac) | |
| 57 | + | rc.noteDetach(ac) | |
| 58 | 58 | rc.attach(ac) // resume the exact same object under a new incarnation | |
| 59 | 59 | timer.ch <- time.Time{} | |
| 60 | 60 | awaitTestCompletion(t, done, "stale callback did not complete") | |
A internal/usecase/daemon/lifecycle_fence_test.go +86 -0
| @@ -0,0 +1,86 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestValidateAttachmentTransitionLocksSourceCoordinatorBeforeLeaseValidation(t *testing.T) { | |
| 11 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 12 | + | transport := &closeTrackingTransport{} | |
| 13 | + | source := &session{sessionCore: sessionCore{id: domain.SessionID("source")}} | |
| 14 | + | target := &session{sessionCore: sessionCore{id: domain.SessionID("target")}} | |
| 15 | + | ac := &attachedClient{tr: transport} | |
| 16 | + | ac.setSession(source) | |
| 17 | + | source.mu.Lock() | |
| 18 | + | registered := source.registerAttachmentLocked(ac) | |
| 19 | + | source.mu.Unlock() | |
| 20 | + | require.True(t, registered) | |
| 21 | + | sourceCoordinator := newRenderCoordinator(renderCoordinatorOptions{}) | |
| 22 | + | source.installRenderCoordinator(sourceCoordinator) | |
| 23 | + | sourceCoordinator.attach(ac) | |
| 24 | + | token := source.attachmentToken(ac, transport) | |
| 25 | + | require.NotNil(t, token.lease) | |
| 26 | + | d.sessions[source.id] = source | |
| 27 | + | d.sessions[target.id] = target | |
| 28 | + | ||
| 29 | + | checked := false | |
| 30 | + | coordinatorUnlocked := false | |
| 31 | + | d.afterAttachmentTransitionCoordinatorsLocked = func() { | |
| 32 | + | coordinatorUnlocked = sourceCoordinator.mu.TryLock() | |
| 33 | + | if coordinatorUnlocked { | |
| 34 | + | sourceCoordinator.mu.Unlock() | |
| 35 | + | } | |
| 36 | + | checked = true | |
| 37 | + | } | |
| 38 | + | req := attachmentTransitionRequest{ | |
| 39 | + | source: source, target: target, next: ac, | |
| 40 | + | expectedTransport: ac.transportSnapshot(), sourceToken: &token, | |
| 41 | + | preflighted: true, attachmentEffectsFrozen: true, | |
| 42 | + | } | |
| 43 | + | d.mu.Lock() | |
| 44 | + | d.notices.routingMu.Lock() | |
| 45 | + | unlockSessions := lockAttachmentSessions(source, target) | |
| 46 | + | publication, err := d.validateAttachmentTransitionPrelocked(req) | |
| 47 | + | unlockSessions() | |
| 48 | + | d.notices.routingMu.Unlock() | |
| 49 | + | d.mu.Unlock() | |
| 50 | + | if publication != nil { | |
| 51 | + | publication.unlockCoordinators() | |
| 52 | + | } | |
| 53 | + | require.NoError(t, err) | |
| 54 | + | require.False(t, coordinatorUnlocked, "source coordinator was not held during lease validation") | |
| 55 | + | require.True(t, checked) | |
| 56 | + | require.NotNil(t, publication) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestAttachmentTokenRevalidatesTheCapturedTransportIncarnation(t *testing.T) { | |
| 60 | + | first := &closeTrackingTransport{} | |
| 61 | + | second := &closeTrackingTransport{} | |
| 62 | + | ac := &attachedClient{tr: first} | |
| 63 | + | sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}} | |
| 64 | + | ac.setSession(sess) | |
| 65 | + | sess.mu.Lock() | |
| 66 | + | registered := sess.registerAttachmentLocked(ac) | |
| 67 | + | sess.mu.Unlock() | |
| 68 | + | require.True(t, registered) | |
| 69 | + | ||
| 70 | + | captured := make(chan struct{}) | |
| 71 | + | release := make(chan struct{}) | |
| 72 | + | ac.beforeAttachmentTokenValidation = func() { | |
| 73 | + | close(captured) | |
| 74 | + | <-release | |
| 75 | + | } | |
| 76 | + | result := make(chan attachmentConnectionToken, 1) | |
| 77 | + | go func() { result <- sess.attachmentToken(ac, first) }() | |
| 78 | + | <-captured | |
| 79 | + | ac.replaceTransport(second) | |
| 80 | + | close(release) | |
| 81 | + | ||
| 82 | + | token := <-result | |
| 83 | + | require.Nil(t, token.ac, "a token must not bind a replacement transport") | |
| 84 | + | require.Equal(t, uint64(1), ac.transportSnapshot().incarnation) | |
| 85 | + | require.Same(t, second, ac.transport()) | |
| 86 | + | } | |
M internal/usecase/daemon/mouse_panes.go +0 -16
| @@ -10,22 +10,6 @@ import ( | |||
| 10 | 10 | "github.com/bnema/vev/internal/usecase/mouse" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | - | func focusedPlacementLocked(tb *tab) (layout.Placement, bool) { | |
| 14 | - | if tb == nil || tb.tree == nil { | |
| 15 | - | return layout.Placement{}, false | |
| 16 | - | } | |
| 17 | - | placements, ok := solvedPlacementsLocked(tb) | |
| 18 | - | if !ok { | |
| 19 | - | return layout.Placement{}, false | |
| 20 | - | } | |
| 21 | - | for _, pl := range placements { | |
| 22 | - | if pl.ID == tb.tree.Focus { | |
| 23 | - | return pl, true | |
| 24 | - | } | |
| 25 | - | } | |
| 26 | - | return layout.Placement{}, false | |
| 27 | - | } | |
| 28 | - | ||
| 29 | 13 | func hitTestPlacementLocked(tb *tab, col, row int) (layout.Placement, bool) { | |
| 30 | 14 | if tb == nil || tb.tree == nil || tb.tree.Root == nil || !tb.size.Valid() { | |
| 31 | 15 | return layout.Placement{}, false | |
M internal/usecase/daemon/move.go +45 -112
| @@ -10,6 +10,8 @@ import ( | |||
| 10 | 10 | // immutable identity. Session names are advisory; the ID and incarnation are | |
| 11 | 11 | // the commit-time authority. | |
| 12 | 12 | type movePaneRequest struct { | |
| 13 | + | Attachment *attachedClient | |
| 14 | + | AttachmentToken attachmentConnectionToken | |
| 13 | 15 | Source moveSessionLocator | |
| 14 | 16 | SourceTabID domain.TabStableID | |
| 15 | 17 | SourcePaneID domain.PaneStableID | |
| @@ -18,9 +20,11 @@ type movePaneRequest struct { | |||
| 18 | 20 | } | |
| 19 | 21 | ||
| 20 | 22 | type moveTabRequest struct { | |
| 21 | - | Source moveSessionLocator | |
| 22 | - | SourceTabID domain.TabStableID | |
| 23 | - | Destination moveSessionLocator | |
| 23 | + | Attachment *attachedClient | |
| 24 | + | AttachmentToken attachmentConnectionToken | |
| 25 | + | Source moveSessionLocator | |
| 26 | + | SourceTabID domain.TabStableID | |
| 27 | + | Destination moveSessionLocator | |
| 24 | 28 | } | |
| 25 | 29 | ||
| 26 | 30 | // movePane resolves, reserves, validates, and commits one pane relocation. All | |
| @@ -42,6 +46,20 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) { | |||
| 42 | 46 | return errMoveStaleTarget | |
| 43 | 47 | } | |
| 44 | 48 | ||
| 49 | + | // Dispatch admission must precede lifecycle reservation. A final close holds | |
| 50 | + | // dispatchMu while it waits for teardown ownership; reserving first would let | |
| 51 | + | // that close wait on a move which is itself blocked on dispatchMu. | |
| 52 | + | if d.beforeMoveDispatch != nil { | |
| 53 | + | d.beforeMoveDispatch() | |
| 54 | + | } | |
| 55 | + | unlockDispatch := lockMoveDispatch(source, destination) | |
| 56 | + | dispatchHeld := true | |
| 57 | + | defer func() { | |
| 58 | + | if dispatchHeld { | |
| 59 | + | unlockDispatch() | |
| 60 | + | } | |
| 61 | + | }() | |
| 62 | + | ||
| 45 | 63 | reservation, err := d.reserveMoveLifecycles(source, destination) | |
| 46 | 64 | if err != nil { | |
| 47 | 65 | return errMoveStaleTarget | |
| @@ -56,14 +74,6 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) { | |||
| 56 | 74 | d.afterMoveLifecycleReserved() | |
| 57 | 75 | } | |
| 58 | 76 | ||
| 59 | - | unlockDispatch := lockMoveDispatch(source, destination) | |
| 60 | - | dispatchHeld := true | |
| 61 | - | defer func() { | |
| 62 | - | if dispatchHeld { | |
| 63 | - | unlockDispatch() | |
| 64 | - | } | |
| 65 | - | }() | |
| 66 | - | ||
| 67 | 77 | // Snapshot the exact live objects and generations before waiting on resize | |
| 68 | 78 | // fences. The locked commit repeats every authority check before publication. | |
| 69 | 79 | admission, err := d.snapshotMovePaneAdmission(req, source, destination) | |
| @@ -73,10 +83,6 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) { | |||
| 73 | 83 | sourceTab := admission.sourceTab | |
| 74 | 84 | destinationTab := admission.destinationTab | |
| 75 | 85 | movedPane := admission.movedPane | |
| 76 | - | sourceClient := admission.sourceClient | |
| 77 | - | destinationClient := admission.destinationClient | |
| 78 | - | sourceSnatched := admission.sourceSnatched | |
| 79 | - | sourceTabWasActive := admission.sourceTabWasActive | |
| 80 | 86 | sourceTabInitialGeneration := admission.sourceGeneration | |
| 81 | 87 | destinationTabInitialGeneration := admission.destinationGeneration | |
| 82 | 88 | finalSourceTab := admission.finalSourceTab | |
| @@ -84,90 +90,32 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) { | |||
| 84 | 90 | d.afterMovePaneSourceSnapshot() | |
| 85 | 91 | } | |
| 86 | 92 | ||
| 87 | - | // A final source tab with an active client needs the centralized attachment | |
| 88 | - | // transition. Freeze that exact role set before entering the fence callback; | |
| 89 | - | // the callback then validates the handoff before changing any topology. | |
| 90 | - | var handoffReq attachmentTransitionRequest | |
| 91 | - | var handoffParticipants attachmentTransitionParticipants | |
| 92 | - | var frozen frozenRoleEffectGates | |
| 93 | - | var sourceRolesFrozen bool | |
| 94 | - | var handoffFrozen bool | |
| 95 | - | ||
| 96 | - | if finalSourceTab && source != destination && sourceClient != nil { | |
| 97 | - | transport := sourceClient.transportSnapshot() | |
| 98 | - | sourceToken := source.attachmentToken(sourceClient, transport.transport) | |
| 99 | - | if sourceToken.role != attachmentActive || sourceToken.transport.transport == nil { | |
| 100 | - | return errMovePaneInvalid | |
| 101 | - | } | |
| 102 | - | destinationTabIndex := moveTabIndex(destination, destinationTab) | |
| 103 | - | if destinationTabIndex < 0 { | |
| 104 | - | return errMovePaneInvalid | |
| 105 | - | } | |
| 106 | - | handoffReq = attachmentTransitionRequest{ | |
| 107 | - | source: source, target: destination, next: sourceClient, | |
| 108 | - | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 109 | - | expectedTransport: sourceToken.transport, sourceToken: &sourceToken, | |
| 110 | - | expectedSourceTab: sourceTab, activateTargetTab: true, | |
| 111 | - | targetTabIndex: destinationTabIndex, ready: true, | |
| 112 | - | } | |
| 113 | - | var err error | |
| 114 | - | handoffReq, handoffParticipants, err = d.snapshotAttachmentTransition(handoffReq) | |
| 115 | - | if err != nil { | |
| 116 | - | return err | |
| 117 | - | } | |
| 118 | - | // Retiring source snatched clients is part of the same role gate set as | |
| 119 | - | // the follower. Their exact membership is checked again in the commit. | |
| 120 | - | d.mu.Lock() | |
| 121 | - | source.mu.Lock() | |
| 122 | - | for _, ac := range sourceSnatched { | |
| 123 | - | handoffParticipants.clients = append(handoffParticipants.clients, ac) | |
| 124 | - | tr := ac.transportSnapshot() | |
| 125 | - | if tr.transport != nil { | |
| 126 | - | handoffParticipants.interrupts = append(handoffParticipants.interrupts, roleTransportInterrupt{ac: ac, transport: tr}) | |
| 93 | + | // A final source tab is retired without transferring any attachment to the | |
| 94 | + | // destination. Freeze every affected source connection before publication so | |
| 95 | + | // teardown invalidates exact generations rather than racing input or render. | |
| 96 | + | var frozen frozenAttachmentEffectGates | |
| 97 | + | var sourceEffectsFrozen bool | |
| 98 | + | if finalSourceTab && source != destination && len(admission.sourceAttachments) != 0 { | |
| 99 | + | interrupts := make([]attachmentTransportInterrupt, 0, len(admission.sourceAttachments)) | |
| 100 | + | for _, ac := range admission.sourceAttachments { | |
| 101 | + | if transport := admission.sourceTransports[ac]; transport.transport != nil { | |
| 102 | + | interrupts = append(interrupts, attachmentTransportInterrupt{ac: ac, transport: transport}) | |
| 127 | 103 | } | |
| 128 | 104 | } | |
| 129 | - | source.mu.Unlock() | |
| 130 | - | d.mu.Unlock() | |
| 131 | - | frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: handoffParticipants.interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) { | |
| 132 | - | if d.afterRoleEffectGateFrozen != nil { | |
| 133 | - | d.afterRoleEffectGateFrozen("move-pane", ac) | |
| 105 | + | frozen = freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) { | |
| 106 | + | if d.afterAttachmentEffectGateFrozen != nil { | |
| 107 | + | d.afterAttachmentEffectGateFrozen("move-pane", ac) | |
| 134 | 108 | } | |
| 135 | - | }}, handoffParticipants.clients...) | |
| 109 | + | }}, admission.sourceAttachments...) | |
| 136 | 110 | if !frozen.acquired || !frozen.drained { | |
| 137 | 111 | frozen.unfreeze() | |
| 138 | 112 | return errMovePaneInvalid | |
| 139 | 113 | } | |
| 140 | - | handoffFrozen = true | |
| 141 | - | sourceRolesFrozen = true | |
| 142 | - | handoffReq.roleEffectsFrozen = true | |
| 143 | - | } else if finalSourceTab && source != destination && len(sourceSnatched) > 0 { | |
| 144 | - | // A headless source may still have persistent snatched clients. Freeze | |
| 145 | - | // their exact role gates before retiring the source registry. | |
| 146 | - | d.mu.Lock() | |
| 147 | - | source.mu.Lock() | |
| 148 | - | participants := append([]*attachedClient(nil), sourceSnatched...) | |
| 149 | - | interrupts := make([]roleTransportInterrupt, 0, len(sourceSnatched)) | |
| 150 | - | for _, ac := range sourceSnatched { | |
| 151 | - | if transport := ac.transportSnapshot(); transport.transport != nil { | |
| 152 | - | interrupts = append(interrupts, roleTransportInterrupt{ac: ac, transport: transport}) | |
| 153 | - | } | |
| 154 | - | } | |
| 155 | - | source.mu.Unlock() | |
| 156 | - | d.mu.Unlock() | |
| 157 | - | frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) { | |
| 158 | - | if d.afterRoleEffectGateFrozen != nil { | |
| 159 | - | d.afterRoleEffectGateFrozen("move-pane", ac) | |
| 160 | - | } | |
| 161 | - | }}, participants...) | |
| 162 | - | if !frozen.acquired || !frozen.drained { | |
| 163 | - | frozen.unfreeze() | |
| 164 | - | return errMovePaneInvalid | |
| 165 | - | } | |
| 166 | - | sourceRolesFrozen = true | |
| 114 | + | sourceEffectsFrozen = true | |
| 167 | 115 | } | |
| 168 | - | if sourceRolesFrozen { | |
| 116 | + | if sourceEffectsFrozen { | |
| 169 | 117 | defer func() { | |
| 170 | - | if sourceRolesFrozen { | |
| 118 | + | if sourceEffectsFrozen { | |
| 171 | 119 | frozen.unfreeze() | |
| 172 | 120 | } | |
| 173 | 121 | }() | |
| @@ -180,17 +128,14 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) { | |||
| 180 | 128 | sourceTab: sourceTab, | |
| 181 | 129 | destinationTab: destinationTab, | |
| 182 | 130 | movedPane: movedPane, | |
| 183 | - | sourceClient: sourceClient, | |
| 184 | - | sourceSnatched: sourceSnatched, | |
| 131 | + | sourceAttachments: admission.sourceAttachments, | |
| 132 | + | sourceTransports: admission.sourceTransports, | |
| 185 | 133 | sourceGeneration: sourceTabInitialGeneration, | |
| 186 | 134 | destinationGeneration: destinationTabInitialGeneration, | |
| 187 | - | handoffFrozen: handoffFrozen, | |
| 188 | - | frozenRoles: frozen, | |
| 189 | - | handoffReq: handoffReq, | |
| 135 | + | frozenEffects: frozen, | |
| 190 | 136 | } | |
| 191 | 137 | fences := newMovePaneResizeFences(source, destination, sourceTab, destinationTab, movedPane) | |
| 192 | 138 | if !fences.acquire(func() bool { return commit.publishLocked(d) }) { | |
| 193 | - | commit.releasePublication() | |
| 194 | 139 | if commit.err != nil { | |
| 195 | 140 | return commit.err | |
| 196 | 141 | } | |
| @@ -205,15 +150,12 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) { | |||
| 205 | 150 | sourceTab: sourceTab, | |
| 206 | 151 | destinationTab: destinationTab, | |
| 207 | 152 | movedPane: movedPane, | |
| 208 | - | destinationClient: destinationClient, | |
| 209 | - | sourceCleanupToken: commit.sourceCleanupToken, | |
| 210 | - | handoffResult: commit.handoffResult, | |
| 153 | + | sourceAttachments: admission.sourceAttachments, | |
| 211 | 154 | syncCleanup: commit.syncCleanup, | |
| 212 | - | frozenRoles: frozen, | |
| 213 | - | rolesFrozen: sourceRolesFrozen, | |
| 155 | + | frozenEffects: frozen, | |
| 156 | + | effectsFrozen: sourceEffectsFrozen, | |
| 214 | 157 | unlockDispatch: unlockDispatch, | |
| 215 | 158 | reservation: reservation, | |
| 216 | - | sourceTabWasActive: sourceTabWasActive, | |
| 217 | 159 | sourceTabRemoved: commit.sourceTabRemoved, | |
| 218 | 160 | sourceEmpty: commit.sourceEmpty, | |
| 219 | 161 | retiredParked: commit.retiredParked, | |
| @@ -225,7 +167,7 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) { | |||
| 225 | 167 | } | |
| 226 | 168 | // Transfer cleanup ownership away from the admission defers only after the | |
| 227 | 169 | // complete postcommit plan exists. | |
| 228 | - | sourceRolesFrozen = false | |
| 170 | + | sourceEffectsFrozen = false | |
| 229 | 171 | dispatchHeld = false | |
| 230 | 172 | reservationHeld = false | |
| 231 | 173 | postcommit.execute(d) | |
| @@ -261,15 +203,6 @@ func findMoveTabLocked(sess *session, stableID domain.TabStableID) *tab { | |||
| 261 | 203 | return nil | |
| 262 | 204 | } | |
| 263 | 205 | ||
| 264 | - | func moveTabIndex(sess *session, target *tab) int { | |
| 265 | - | if sess == nil || target == nil { | |
| 266 | - | return -1 | |
| 267 | - | } | |
| 268 | - | sess.mu.Lock() | |
| 269 | - | defer sess.mu.Unlock() | |
| 270 | - | return indexMoveTabLocked(sess, target) | |
| 271 | - | } | |
| 272 | - | ||
| 273 | 206 | func indexMoveTabLocked(sess *session, target *tab) int { | |
| 274 | 207 | for i, tb := range sess.tabs { | |
| 275 | 208 | if tb == target { | |
M internal/usecase/daemon/move_acceptance_test.go +4 -4
| @@ -89,7 +89,7 @@ func TestMovePaneAcceptanceRetainsSourceFocusAndDestinationActivity(t *testing.T | |||
| 89 | 89 | ||
| 90 | 90 | destinationActive := newTabWithStableID("destination-active", "active-pane", activePTY, domain.Size{Cols: 80, Rows: 23}) | |
| 91 | 91 | destinationTab := newTabWithStableID("destination-target", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23}) | |
| 92 | - | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{destinationActive, destinationTab}, active: 0} | |
| 92 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{destinationActive, destinationTab}} | |
| 93 | 93 | publishTiledPaneOwners(destination, destinationActive) | |
| 94 | 94 | publishTiledPaneOwners(destination, destinationTab) | |
| 95 | 95 | d.mu.Lock() | |
| @@ -140,7 +140,7 @@ func TestMovePaneAcceptanceRetainsSourceFocusAndDestinationActivity(t *testing.T | |||
| 140 | 140 | require.Same(t, remaining, remainingPane) | |
| 141 | 141 | require.Equal(t, layout.PaneID("pane-2"), sourceFocus, "source focus must use the valid fallback leaf") | |
| 142 | 142 | destination.mu.Lock() | |
| 143 | - | destinationActiveTab := destination.tabs[destination.active] | |
| 143 | + | destinationActiveTab := destination.tabs[testAttachmentTabIndexLocked(destination)] | |
| 144 | 144 | destination.mu.Unlock() | |
| 145 | 145 | require.Same(t, destinationActive, destinationActiveTab, "destination active tab must remain unchanged") | |
| 146 | 146 | destinationTab.mu.Lock() | |
| @@ -182,7 +182,7 @@ func TestMovePaneRejectsStaleIncarnationWithoutMutation(t *testing.T) { | |||
| 182 | 182 | sourceTab := source.tabs[0] | |
| 183 | 183 | sourceTab.stableID = "source-tab" | |
| 184 | 184 | moved := sourceTab.focusedPane() | |
| 185 | - | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0} | |
| 185 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}} | |
| 186 | 186 | destinationTab := destination.tabs[0] | |
| 187 | 187 | publishTiledPaneOwners(destination, destinationTab) | |
| 188 | 188 | d.mu.Lock() | |
| @@ -231,7 +231,7 @@ func TestMovePaneRejectsStaleLayoutGenerationBeforeMutation(t *testing.T) { | |||
| 231 | 231 | sourceTab := source.tabs[0] | |
| 232 | 232 | sourceTab.stableID = "source-tab" | |
| 233 | 233 | moved := sourceTab.focusedPane() | |
| 234 | - | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0} | |
| 234 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}} | |
| 235 | 235 | destinationTab := destination.tabs[0] | |
| 236 | 236 | publishTiledPaneOwners(destination, destinationTab) | |
| 237 | 237 | d.mu.Lock() | |
D internal/usecase/daemon/move_atomicity_test.go +0 -183
| @@ -1,183 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "testing" | |
| 5 | - | ||
| 6 | - | "github.com/stretchr/testify/require" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/vev/internal/domain" | |
| 9 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | type moveCommitObservation struct { | |
| 13 | - | sourceRegistered bool | |
| 14 | - | sourceTabMember bool | |
| 15 | - | sourcePaneMember bool | |
| 16 | - | destinationTabMember bool | |
| 17 | - | destinationPaneMember bool | |
| 18 | - | sourceActive *tab | |
| 19 | - | destinationActive *tab | |
| 20 | - | sourceFocus layout.PaneID | |
| 21 | - | destinationFocus layout.PaneID | |
| 22 | - | ownerSession *session | |
| 23 | - | ownerTab *tab | |
| 24 | - | sourceRole attachmentRole | |
| 25 | - | destinationRole attachmentRole | |
| 26 | - | sourceClientSession *session | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | // readMoveCommitObservation follows the same architecture lock order as the | |
| 30 | - | // move commit. Every field is therefore one supported reader's coherent view, | |
| 31 | - | // rather than a collection of lock-free observations that could straddle a | |
| 32 | - | // publication boundary. | |
| 33 | - | func readMoveCommitObservation(d *Daemon, source, destination *session, sourceTab, destinationTab *tab, movedPane *pane, sourceClient *attachedClient) moveCommitObservation { | |
| 34 | - | d.mu.Lock() | |
| 35 | - | d.notices.routingMu.Lock() | |
| 36 | - | unlockSessions := lockAttachmentSessions(source, destination) | |
| 37 | - | unlockTabs := lockMoveTabs(sourceTab, destinationTab) | |
| 38 | - | movedPane.mu.Lock() | |
| 39 | - | ||
| 40 | - | owner := movedPane.ownerSnapshot() | |
| 41 | - | observation := moveCommitObservation{ | |
| 42 | - | sourceRegistered: d.sessions[source.id] == source, | |
| 43 | - | sourceTabMember: moveTabMemberLocked(source, sourceTab), | |
| 44 | - | sourcePaneMember: sourceTab.panes[movedPane.id] == movedPane, | |
| 45 | - | destinationTabMember: moveTabMemberLocked(destination, destinationTab), | |
| 46 | - | destinationPaneMember: destinationTab.panes[movedPane.id] == movedPane, | |
| 47 | - | sourceActive: activeMoveTabLocked(source), | |
| 48 | - | destinationActive: activeMoveTabLocked(destination), | |
| 49 | - | sourceFocus: sourceTab.tree.Focus, | |
| 50 | - | destinationFocus: destinationTab.tree.Focus, | |
| 51 | - | ownerSession: nil, | |
| 52 | - | ownerTab: nil, | |
| 53 | - | sourceRole: source.attachmentRoleLocked(sourceClient), | |
| 54 | - | destinationRole: destination.attachmentRoleLocked(sourceClient), | |
| 55 | - | sourceClientSession: sourceClient.currentSession(), | |
| 56 | - | } | |
| 57 | - | if owner != nil { | |
| 58 | - | observation.ownerSession = owner.session | |
| 59 | - | observation.ownerTab = owner.tab | |
| 60 | - | } | |
| 61 | - | movedPane.mu.Unlock() | |
| 62 | - | unlockTabs() | |
| 63 | - | unlockSessions() | |
| 64 | - | d.notices.routingMu.Unlock() | |
| 65 | - | d.mu.Unlock() | |
| 66 | - | return observation | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | func activeMoveTabLocked(sess *session) *tab { | |
| 70 | - | if sess == nil || sess.active < 0 || sess.active >= len(sess.tabs) { | |
| 71 | - | return nil | |
| 72 | - | } | |
| 73 | - | return sess.tabs[sess.active] | |
| 74 | - | } | |
| 75 | - | ||
| 76 | - | func TestMovePaneCommitPointHidesPartialPublication(t *testing.T) { | |
| 77 | - | p1, release1 := newBlockingPTY(t) | |
| 78 | - | p2, release2 := newBlockingPTY(t) | |
| 79 | - | defer release1() | |
| 80 | - | defer release2() | |
| 81 | - | ||
| 82 | - | d, source, sourceClient, _ := newManualSessionWithPTYs(t, p1) | |
| 83 | - | sourceTab := source.tabs[0] | |
| 84 | - | sourceTab.stableID = "source-tab" | |
| 85 | - | movedPane := sourceTab.focusedPane() | |
| 86 | - | ||
| 87 | - | destination := &session{sessionCore: sessionCore{id: "destination", | |
| 88 | - | name: "destination", | |
| 89 | - | ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})}, | |
| 90 | - | active: 0, | |
| 91 | - | } | |
| 92 | - | destinationTab := destination.tabs[0] | |
| 93 | - | publishTiledPaneOwners(destination, destinationTab) | |
| 94 | - | d.mu.Lock() | |
| 95 | - | d.sessions[destination.id] = destination | |
| 96 | - | d.mu.Unlock() | |
| 97 | - | require.NotNil(t, d.attachCoordinator(source, nil, sourceClient, true)) | |
| 98 | - | ||
| 99 | - | commitEntered := make(chan struct{}) | |
| 100 | - | releaseCommit := make(chan struct{}) | |
| 101 | - | d.beforeMovePaneCommit = func() { | |
| 102 | - | close(commitEntered) | |
| 103 | - | <-releaseCommit | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | moveDone := make(chan error, 1) | |
| 107 | - | go func() { | |
| 108 | - | moveDone <- d.movePane(movePaneRequest{ | |
| 109 | - | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 110 | - | SourceTabID: domain.TabStableID(sourceTab.stableID), | |
| 111 | - | SourcePaneID: domain.PaneStableID(movedPane.stableID), | |
| 112 | - | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 113 | - | DestinationTabID: domain.TabStableID(destinationTab.stableID), | |
| 114 | - | }) | |
| 115 | - | }() | |
| 116 | - | <-commitEntered | |
| 117 | - | ||
| 118 | - | type reader struct { | |
| 119 | - | name string | |
| 120 | - | read func(moveCommitObservation) bool | |
| 121 | - | } | |
| 122 | - | readers := []reader{ | |
| 123 | - | {name: "source membership", read: func(o moveCommitObservation) bool { | |
| 124 | - | return o.sourceRegistered || o.sourceTabMember || o.sourcePaneMember | |
| 125 | - | }}, | |
| 126 | - | {name: "destination membership", read: func(o moveCommitObservation) bool { return o.destinationTabMember && !o.destinationPaneMember }}, | |
| 127 | - | {name: "owner", read: func(o moveCommitObservation) bool { return o.ownerSession == source && o.ownerTab == sourceTab }}, | |
| 128 | - | {name: "active tab and focus", read: func(o moveCommitObservation) bool { | |
| 129 | - | return o.sourceActive == sourceTab || o.sourceFocus != layout.PaneID("pane-1") || o.destinationActive != destinationTab || o.destinationFocus != layout.PaneID("pane-2") | |
| 130 | - | }}, | |
| 131 | - | {name: "attachment roles", read: func(o moveCommitObservation) bool { | |
| 132 | - | return o.sourceRole == attachmentActive && o.destinationRole == attachmentDetached && o.sourceClientSession == source | |
| 133 | - | }}, | |
| 134 | - | } | |
| 135 | - | started := make(chan string, len(readers)) | |
| 136 | - | done := make(chan struct { | |
| 137 | - | name string | |
| 138 | - | observation moveCommitObservation | |
| 139 | - | }, len(readers)) | |
| 140 | - | for _, current := range readers { | |
| 141 | - | go func(current reader) { | |
| 142 | - | started <- current.name | |
| 143 | - | observation := readMoveCommitObservation(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient) | |
| 144 | - | done <- struct { | |
| 145 | - | name string | |
| 146 | - | observation moveCommitObservation | |
| 147 | - | }{name: current.name, observation: observation} | |
| 148 | - | }(current) | |
| 149 | - | } | |
| 150 | - | for range readers { | |
| 151 | - | <-started | |
| 152 | - | } | |
| 153 | - | for range readers { | |
| 154 | - | select { | |
| 155 | - | case result := <-done: | |
| 156 | - | t.Fatalf("%s reader completed while the move commit was paused: %#v", result.name, result.observation) | |
| 157 | - | default: | |
| 158 | - | } | |
| 159 | - | } | |
| 160 | - | ||
| 161 | - | close(releaseCommit) | |
| 162 | - | require.NoError(t, <-moveDone) | |
| 163 | - | for _, current := range readers { | |
| 164 | - | result := <-done | |
| 165 | - | require.False(t, current.read(result.observation), "%s observed partial move state: %#v", current.name, result.observation) | |
| 166 | - | } | |
| 167 | - | ||
| 168 | - | observation := readMoveCommitObservation(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient) | |
| 169 | - | require.False(t, observation.sourceRegistered) | |
| 170 | - | require.False(t, observation.sourceTabMember) | |
| 171 | - | require.False(t, observation.sourcePaneMember) | |
| 172 | - | require.True(t, observation.destinationTabMember) | |
| 173 | - | require.True(t, observation.destinationPaneMember) | |
| 174 | - | require.Nil(t, observation.sourceActive) | |
| 175 | - | require.Equal(t, layout.PaneID("pane-1"), observation.sourceFocus) | |
| 176 | - | require.Same(t, destinationTab, observation.destinationActive) | |
| 177 | - | require.Equal(t, layout.PaneID("pane-2"), observation.destinationFocus) | |
| 178 | - | require.Same(t, destination, observation.ownerSession) | |
| 179 | - | require.Same(t, destinationTab, observation.ownerTab) | |
| 180 | - | require.Equal(t, attachmentDetached, observation.sourceRole) | |
| 181 | - | require.Equal(t, attachmentActive, observation.destinationRole) | |
| 182 | - | require.Same(t, destination, observation.sourceClientSession) | |
| 183 | - | } | |
M internal/usecase/daemon/move_attachments.go +26 -30
| @@ -1,46 +1,42 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | - | // frozenMoveAttachmentRetirement is proof that every source snatched client | |
| 4 | - | // retired by a final move has had its exact role-effect gate frozen and drained. | |
| 5 | - | // It is prepared while the source session lock is held, before topology writes. | |
| 6 | - | type frozenMoveAttachmentRetirement struct { | |
| 7 | - | clients []*attachedClient | |
| 3 | + | func moveAttachmentTokenCurrentLocked(token attachmentConnectionToken, sess *session) bool { | |
| 4 | + | return token.sess == sess && token.ac != nil && token.generation == token.ac.connectionGeneration.Load() && | |
| 5 | + | token.ac.currentAttachmentSession() == sess && token.ac.transportSnapshotCurrent(token.transport) && | |
| 6 | + | attachmentRegisteredLocked(sess, token.ac) | |
| 8 | 7 | } | |
| 9 | 8 | ||
| 10 | - | func snapshotMoveSnatchedLocked(sess *session) []*attachedClient { | |
| 11 | - | clients := make([]*attachedClient, 0, len(sess.snatched)) | |
| 12 | - | for ac := range sess.snatched { | |
| 13 | - | clients = append(clients, ac) | |
| 14 | - | } | |
| 15 | - | return clients | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | func sameMoveSnatchedLocked(sess *session, admitted []*attachedClient) bool { | |
| 19 | - | if sess == nil || len(sess.snatched) != len(admitted) { | |
| 9 | + | // sameMoveAttachmentsLocked reports whether the source membership is unchanged | |
| 10 | + | // since admission. Caller holds the source session lock. | |
| 11 | + | func sameMoveAttachmentsLocked(sess *session, admitted []*attachedClient) bool { | |
| 12 | + | if sess == nil || len(sess.attachments) != len(admitted) { | |
| 20 | 13 | return false | |
| 21 | 14 | } | |
| 22 | 15 | for _, ac := range admitted { | |
| 23 | - | if _, ok := sess.snatched[ac]; !ok { | |
| 16 | + | if !attachmentRegisteredLocked(sess, ac) { | |
| 24 | 17 | return false | |
| 25 | 18 | } | |
| 26 | 19 | } | |
| 27 | 20 | return true | |
| 28 | 21 | } | |
| 29 | 22 | ||
| 30 | - | func prepareFrozenMoveAttachmentRetirementLocked(sess *session, admitted []*attachedClient, frozen frozenRoleEffectGates) (frozenMoveAttachmentRetirement, bool) { | |
| 31 | - | if !sameMoveSnatchedLocked(sess, admitted) { | |
| 32 | - | return frozenMoveAttachmentRetirement{}, false | |
| 33 | - | } | |
| 34 | - | if len(admitted) == 0 { | |
| 35 | - | return frozenMoveAttachmentRetirement{}, true | |
| 23 | + | // detachMoveAttachmentsLocked invalidates every attachment of a source session | |
| 24 | + | // that became empty. Moves never transfer attachment ownership to the target; | |
| 25 | + | // each connection is retired independently after the shared content move. | |
| 26 | + | // Caller holds the source session lock and the affected attachment gates are | |
| 27 | + | // frozen. | |
| 28 | + | func detachMoveAttachmentsLocked(sess *session, transports map[*attachedClient]transportSnapshot) []detachedAttachmentSnapshot { | |
| 29 | + | if sess == nil || len(sess.attachments) == 0 { | |
| 30 | + | return nil | |
| 36 | 31 | } | |
| 37 | - | if !frozen.acquired || !frozen.drained { | |
| 38 | - | return frozenMoveAttachmentRetirement{}, false | |
| 39 | - | } | |
| 40 | - | for _, ac := range admitted { | |
| 41 | - | if !frozen.contains(ac) { | |
| 42 | - | return frozenMoveAttachmentRetirement{}, false | |
| 43 | - | } | |
| 32 | + | attachments := sess.snapshotAttachmentsLocked() | |
| 33 | + | retired := make([]detachedAttachmentSnapshot, 0, len(attachments)) | |
| 34 | + | for _, ac := range attachments { | |
| 35 | + | retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: transports[ac]}) | |
| 36 | + | ac.connectionGeneration.Add(1) | |
| 37 | + | ac.setSession(nil) | |
| 38 | + | ac.invalidateFrozenAttachmentCapability() | |
| 44 | 39 | } | |
| 45 | - | return frozenMoveAttachmentRetirement{clients: append([]*attachedClient(nil), admitted...)}, true | |
| 40 | + | clear(sess.attachments) | |
| 41 | + | return retired | |
| 46 | 42 | } | |
M internal/usecase/daemon/move_cleanup_test.go +32 -19
| @@ -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() | |
| @@ -26,9 +43,7 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) { | |||
| 26 | 43 | client.captureFrames = map[*pane]capturedPaneRenderState{moved: {}} | |
| 27 | 44 | client.sendMu.Unlock() | |
| 28 | 45 | ||
| 29 | - | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, | |
| 30 | - | active: 0, | |
| 31 | - | } | |
| 46 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}} | |
| 32 | 47 | destinationTab := destination.tabs[0] | |
| 33 | 48 | publishTiledPaneOwners(destination, destinationTab) | |
| 34 | 49 | d.mu.Lock() | |
| @@ -43,7 +58,7 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) { | |||
| 43 | 58 | require.NotNil(t, rc) | |
| 44 | 59 | lease := rc.attachmentLease(client) | |
| 45 | 60 | epoch := rc.recordResizeRequestForLease(client.size, client, lease) | |
| 46 | - | ticket, admitted := beginActiveLeaseEffect(source, client, lease) | |
| 61 | + | ticket, admitted := beginAttachmentLeaseEffect(source, client, lease) | |
| 47 | 62 | require.True(t, admitted) | |
| 48 | 63 | defer ticket.End() | |
| 49 | 64 | ||
| @@ -102,20 +117,18 @@ func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) { | |||
| 102 | 117 | sourceTab.stableID = "source-tab" | |
| 103 | 118 | remainingTab.stableID = "remaining-tab" | |
| 104 | 119 | moved := sourceTab.focusedPane() | |
| 105 | - | require.NotNil(t, d.attachCoordinator(source, nil, displaced, true)) | |
| 120 | + | require.NotNil(t, d.attachCoordinator(source, displaced, true)) | |
| 106 | 121 | ||
| 107 | - | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, | |
| 108 | - | active: 0, | |
| 109 | - | } | |
| 122 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}} | |
| 110 | 123 | destinationTab := destination.tabs[0] | |
| 111 | 124 | publishTiledPaneOwners(destination, destinationTab) | |
| 112 | 125 | d.mu.Lock() | |
| 113 | 126 | d.sessions[destination.id] = destination | |
| 114 | 127 | d.mu.Unlock() | |
| 115 | 128 | ||
| 116 | - | // Both clients carry state tied specifically to the pane. The displaced | |
| 117 | - | // client was captured before the fence wait; the replacement is the only | |
| 118 | - | // attachment that may be cleaned after commit. | |
| 129 | + | // Both clients carry state tied specifically to the pane. The existing | |
| 130 | + | // attachment and the concurrently registered attachment remain independent; | |
| 131 | + | // moving one exact attachment must not clean the other. | |
| 119 | 132 | displaced.overlays.copyMu.Lock() | |
| 120 | 133 | displaced.overlays.copyPane = moved | |
| 121 | 134 | displaced.overlays.copyMode = &scopy.Mode{} | |
| @@ -163,27 +176,27 @@ func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) { | |||
| 163 | 176 | awaitTestCompletion(t, snapshot, "move did not capture its pre-fence source attachment") | |
| 164 | 177 | ||
| 165 | 178 | source.mu.Lock() | |
| 166 | - | source.client = replacement | |
| 179 | + | source.registerAttachmentLocked(replacement) | |
| 167 | 180 | source.mu.Unlock() | |
| 168 | 181 | close(releaseSnapshot) | |
| 169 | 182 | source.layoutApplyMu.Unlock() | |
| 170 | 183 | require.NoError(t, awaitTestValue(t, moveDone, "move did not finish after source fence release")) | |
| 171 | 184 | ||
| 172 | 185 | displaced.overlays.copyMu.Lock() | |
| 173 | - | require.NotNil(t, displaced.overlays.copyMode, "displaced source client was incorrectly cleaned") | |
| 174 | - | require.Same(t, moved, displaced.overlays.copyPane) | |
| 186 | + | require.Nil(t, displaced.overlays.copyMode, "moved attachment copy mode was not cleaned") | |
| 187 | + | require.Nil(t, displaced.overlays.copyPane) | |
| 175 | 188 | displaced.overlays.copyMu.Unlock() | |
| 176 | 189 | displaced.sendMu.Lock() | |
| 177 | 190 | _, displacedCaptured := displaced.captureFrames[moved] | |
| 178 | 191 | displaced.sendMu.Unlock() | |
| 179 | - | require.True(t, displacedCaptured, "displaced source capture was incorrectly pruned") | |
| 192 | + | require.False(t, displacedCaptured, "moved attachment capture was not pruned") | |
| 180 | 193 | ||
| 181 | 194 | replacement.overlays.copyMu.Lock() | |
| 182 | - | require.Nil(t, replacement.overlays.copyMode, "current source client copy mode was not cleaned") | |
| 183 | - | require.Nil(t, replacement.overlays.copyPane) | |
| 195 | + | require.NotNil(t, replacement.overlays.copyMode, "independent attachment copy mode was cleaned") | |
| 196 | + | require.Same(t, moved, replacement.overlays.copyPane) | |
| 184 | 197 | replacement.overlays.copyMu.Unlock() | |
| 185 | 198 | replacement.sendMu.Lock() | |
| 186 | 199 | _, replacementCaptured := replacement.captureFrames[moved] | |
| 187 | 200 | replacement.sendMu.Unlock() | |
| 188 | - | require.False(t, replacementCaptured, "current source capture was not pruned") | |
| 201 | + | require.True(t, replacementCaptured, "independent attachment capture was pruned") | |
| 189 | 202 | } | |
D internal/usecase/daemon/move_final_source_attachment_race_test.go +0 -211
| @@ -1,211 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "testing" | |
| 5 | - | ||
| 6 | - | "github.com/stretchr/testify/require" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/vev/internal/domain" | |
| 9 | - | ) | |
| 10 | - | ||
| 11 | - | type finalMoveAttachmentMutation int | |
| 12 | - | ||
| 13 | - | const ( | |
| 14 | - | installActiveAfterFinalMoveAdmission finalMoveAttachmentMutation = iota | |
| 15 | - | installSnatchedAfterFinalMoveAdmission | |
| 16 | - | replaceSnatchedAfterFinalMoveAdmission | |
| 17 | - | ) | |
| 18 | - | ||
| 19 | - | func newFinalMoveRaceClient(sess *session) *attachedClient { | |
| 20 | - | ac := &attachedClient{ | |
| 21 | - | tr: &closeTrackingTransport{}, | |
| 22 | - | output: newOutputStateStream(), | |
| 23 | - | size: domain.Size{Cols: 80, Rows: 24}, | |
| 24 | - | } | |
| 25 | - | ac.initOverlays() | |
| 26 | - | ac.setSession(sess) | |
| 27 | - | return ac | |
| 28 | - | } | |
| 29 | - | ||
| 30 | - | func makeFinalMoveSourceHeadless(source *session, original *attachedClient) { | |
| 31 | - | source.mu.Lock() | |
| 32 | - | source.client = nil | |
| 33 | - | source.mu.Unlock() | |
| 34 | - | original.setSession(nil) | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | func mutateFinalMoveAttachments(source *session, mutation finalMoveAttachmentMutation) (current, replaced *attachedClient) { | |
| 38 | - | current = newFinalMoveRaceClient(source) | |
| 39 | - | source.mu.Lock() | |
| 40 | - | switch mutation { | |
| 41 | - | case installActiveAfterFinalMoveAdmission: | |
| 42 | - | source.client = current | |
| 43 | - | case installSnatchedAfterFinalMoveAdmission: | |
| 44 | - | source.addSnatchedLocked(current) | |
| 45 | - | case replaceSnatchedAfterFinalMoveAdmission: | |
| 46 | - | for ac := range source.snatched { | |
| 47 | - | replaced = ac | |
| 48 | - | delete(source.snatched, ac) | |
| 49 | - | ac.setSession(nil) | |
| 50 | - | break | |
| 51 | - | } | |
| 52 | - | source.addSnatchedLocked(current) | |
| 53 | - | } | |
| 54 | - | source.mu.Unlock() | |
| 55 | - | return current, replaced | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | func assertRejectedFinalMoveAttachmentPreserved(t *testing.T, source *session, current, replaced *attachedClient, mutation finalMoveAttachmentMutation) { | |
| 59 | - | t.Helper() | |
| 60 | - | require.Same(t, source, current.currentSession(), "the attachment admitted during the race must not be orphaned") | |
| 61 | - | switch mutation { | |
| 62 | - | case installActiveAfterFinalMoveAdmission: | |
| 63 | - | require.Equal(t, attachmentActive, source.attachmentRole(current)) | |
| 64 | - | case installSnatchedAfterFinalMoveAdmission, replaceSnatchedAfterFinalMoveAdmission: | |
| 65 | - | require.Equal(t, attachmentSnatched, source.attachmentRole(current)) | |
| 66 | - | } | |
| 67 | - | if replaced != nil { | |
| 68 | - | require.Nil(t, replaced.currentSession()) | |
| 69 | - | require.Equal(t, attachmentDetached, source.attachmentRole(replaced)) | |
| 70 | - | } | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | func TestMovePaneFinalHeadlessRejectsAttachmentChangesAfterAdmissionAtomically(t *testing.T) { | |
| 74 | - | for _, tc := range []struct { | |
| 75 | - | name string | |
| 76 | - | mutation finalMoveAttachmentMutation | |
| 77 | - | existing bool | |
| 78 | - | }{ | |
| 79 | - | {name: "new active", mutation: installActiveAfterFinalMoveAdmission}, | |
| 80 | - | {name: "new snatched", mutation: installSnatchedAfterFinalMoveAdmission}, | |
| 81 | - | {name: "count preserving snatched replacement", mutation: replaceSnatchedAfterFinalMoveAdmission, existing: true}, | |
| 82 | - | } { | |
| 83 | - | t.Run(tc.name, func(t *testing.T) { | |
| 84 | - | d, source, sourceTab, moved, destination, destinationTab, release := newMoveGateRaceFixture(t) | |
| 85 | - | defer release() | |
| 86 | - | original := source.client | |
| 87 | - | makeFinalMoveSourceHeadless(source, original) | |
| 88 | - | if tc.existing { | |
| 89 | - | existing := newFinalMoveRaceClient(source) | |
| 90 | - | source.mu.Lock() | |
| 91 | - | source.addSnatchedLocked(existing) | |
| 92 | - | source.mu.Unlock() | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | sourceGeneration := sourceTab.layoutGeneration | |
| 96 | - | destinationGeneration := destinationTab.layoutGeneration | |
| 97 | - | destinationPaneAtMovedID := destinationTab.panes[moved.id] | |
| 98 | - | owner := moved.ownerSnapshot() | |
| 99 | - | barrier := make(chan struct{}) | |
| 100 | - | releaseMove := make(chan struct{}) | |
| 101 | - | d.afterMovePaneSourceSnapshot = func() { | |
| 102 | - | close(barrier) | |
| 103 | - | <-releaseMove | |
| 104 | - | } | |
| 105 | - | defer func() { d.afterMovePaneSourceSnapshot = nil }() | |
| 106 | - | ||
| 107 | - | moveDone := make(chan error, 1) | |
| 108 | - | go func() { | |
| 109 | - | moveDone <- d.movePane(moveGateRaceRequest(source, sourceTab, moved, destination, destinationTab)) | |
| 110 | - | }() | |
| 111 | - | waitMoveRace(t, barrier, "move pane admission") | |
| 112 | - | current, replaced := mutateFinalMoveAttachments(source, tc.mutation) | |
| 113 | - | currentGeneration := current.roleGeneration.Load() | |
| 114 | - | close(releaseMove) | |
| 115 | - | err := waitMoveRace(t, moveDone, "move pane rejection") | |
| 116 | - | ||
| 117 | - | require.ErrorIs(t, err, errMoveStaleTarget) | |
| 118 | - | d.mu.Lock() | |
| 119 | - | require.Same(t, source, d.sessions[source.id]) | |
| 120 | - | require.Same(t, destination, d.sessions[destination.id]) | |
| 121 | - | d.mu.Unlock() | |
| 122 | - | source.mu.Lock() | |
| 123 | - | require.Equal(t, []*tab{sourceTab}, source.tabs) | |
| 124 | - | source.mu.Unlock() | |
| 125 | - | sourceTab.mu.Lock() | |
| 126 | - | require.Same(t, moved, sourceTab.panes[moved.id]) | |
| 127 | - | require.Equal(t, sourceGeneration, sourceTab.layoutGeneration) | |
| 128 | - | sourceTab.mu.Unlock() | |
| 129 | - | destinationTab.mu.Lock() | |
| 130 | - | require.Same(t, destinationPaneAtMovedID, destinationTab.panes[moved.id]) | |
| 131 | - | require.Equal(t, destinationGeneration, destinationTab.layoutGeneration) | |
| 132 | - | destinationTab.mu.Unlock() | |
| 133 | - | require.Equal(t, owner, moved.ownerSnapshot()) | |
| 134 | - | require.Equal(t, currentGeneration, current.roleGeneration.Load()) | |
| 135 | - | assertRejectedFinalMoveAttachmentPreserved(t, source, current, replaced, tc.mutation) | |
| 136 | - | }) | |
| 137 | - | } | |
| 138 | - | } | |
| 139 | - | ||
| 140 | - | func TestMoveTabFinalHeadlessRejectsAttachmentChangesAfterAdmissionAtomically(t *testing.T) { | |
| 141 | - | for _, tc := range []struct { | |
| 142 | - | name string | |
| 143 | - | mutation finalMoveAttachmentMutation | |
| 144 | - | existing bool | |
| 145 | - | }{ | |
| 146 | - | {name: "new active", mutation: installActiveAfterFinalMoveAdmission}, | |
| 147 | - | {name: "new snatched", mutation: installSnatchedAfterFinalMoveAdmission}, | |
| 148 | - | {name: "count preserving snatched replacement", mutation: replaceSnatchedAfterFinalMoveAdmission, existing: true}, | |
| 149 | - | } { | |
| 150 | - | t.Run(tc.name, func(t *testing.T) { | |
| 151 | - | d, source, original, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 152 | - | moved := source.tabs[0] | |
| 153 | - | moved.stableID = "moved-tab" | |
| 154 | - | makeFinalMoveSourceHeadless(source, original) | |
| 155 | - | if tc.existing { | |
| 156 | - | existing := newFinalMoveRaceClient(source) | |
| 157 | - | source.mu.Lock() | |
| 158 | - | source.addSnatchedLocked(existing) | |
| 159 | - | source.mu.Unlock() | |
| 160 | - | } | |
| 161 | - | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 162 | - | destinationTabs := append([]*tab(nil), destination.tabs...) | |
| 163 | - | sourceGeneration := moved.layoutGeneration | |
| 164 | - | owners := make(map[*pane]*paneOwner, len(moved.panes)) | |
| 165 | - | for _, p := range moved.panes { | |
| 166 | - | owners[p] = p.ownerSnapshot() | |
| 167 | - | } | |
| 168 | - | ||
| 169 | - | barrier := make(chan struct{}) | |
| 170 | - | releaseMove := make(chan struct{}) | |
| 171 | - | d.afterMoveTabSourceSnapshot = func() { | |
| 172 | - | close(barrier) | |
| 173 | - | <-releaseMove | |
| 174 | - | } | |
| 175 | - | defer func() { d.afterMoveTabSourceSnapshot = nil }() | |
| 176 | - | moveDone := make(chan error, 1) | |
| 177 | - | go func() { | |
| 178 | - | moveDone <- d.moveTab(moveTabRequest{ | |
| 179 | - | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 180 | - | SourceTabID: domain.TabStableID(moved.stableID), | |
| 181 | - | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 182 | - | }) | |
| 183 | - | }() | |
| 184 | - | waitMoveRace(t, barrier, "move tab admission") | |
| 185 | - | current, replaced := mutateFinalMoveAttachments(source, tc.mutation) | |
| 186 | - | currentGeneration := current.roleGeneration.Load() | |
| 187 | - | close(releaseMove) | |
| 188 | - | err := waitMoveRace(t, moveDone, "move tab rejection") | |
| 189 | - | ||
| 190 | - | require.ErrorIs(t, err, errMoveStaleTarget) | |
| 191 | - | d.mu.Lock() | |
| 192 | - | require.Same(t, source, d.sessions[source.id]) | |
| 193 | - | require.Same(t, destination, d.sessions[destination.id]) | |
| 194 | - | d.mu.Unlock() | |
| 195 | - | source.mu.Lock() | |
| 196 | - | require.Equal(t, []*tab{moved}, source.tabs) | |
| 197 | - | source.mu.Unlock() | |
| 198 | - | destination.mu.Lock() | |
| 199 | - | require.Equal(t, destinationTabs, destination.tabs) | |
| 200 | - | destination.mu.Unlock() | |
| 201 | - | moved.mu.Lock() | |
| 202 | - | require.Equal(t, sourceGeneration, moved.layoutGeneration) | |
| 203 | - | moved.mu.Unlock() | |
| 204 | - | for p, owner := range owners { | |
| 205 | - | require.Equal(t, owner, p.ownerSnapshot()) | |
| 206 | - | } | |
| 207 | - | require.Equal(t, currentGeneration, current.roleGeneration.Load()) | |
| 208 | - | assertRejectedFinalMoveAttachmentPreserved(t, source, current, replaced, tc.mutation) | |
| 209 | - | }) | |
| 210 | - | } | |
| 211 | - | } | |
M internal/usecase/daemon/move_gate_race_test.go +141 -10
| @@ -19,11 +19,9 @@ func newMoveGateRaceFixture(t *testing.T) (*Daemon, *session, *tab, *pane, *sess | |||
| 19 | 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 | - | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, | |
| 25 | - | active: 0, | |
| 26 | - | } | |
| 24 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}} | |
| 27 | 25 | destinationTab := destination.tabs[0] | |
| 28 | 26 | publishTiledPaneOwners(destination, destinationTab) | |
| 29 | 27 | d.mu.Lock() | |
| @@ -76,7 +74,7 @@ func TestMovePaneGateReservationRaceKillWinsWithoutDeadlock(t *testing.T) { | |||
| 76 | 74 | killFrozen := make(chan struct{}) | |
| 77 | 75 | releaseKill := make(chan struct{}) | |
| 78 | 76 | var killFrozenOnce sync.Once | |
| 79 | - | d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) { | |
| 77 | + | d.afterAttachmentEffectGateFrozen = func(action string, _ *attachedClient) { | |
| 80 | 78 | if action != "" { | |
| 81 | 79 | return | |
| 82 | 80 | } | |
| @@ -85,7 +83,7 @@ func TestMovePaneGateReservationRaceKillWinsWithoutDeadlock(t *testing.T) { | |||
| 85 | 83 | } | |
| 86 | 84 | defer func() { | |
| 87 | 85 | d.afterMoveLifecycleReserved = nil | |
| 88 | - | d.afterRoleEffectGateFrozen = nil | |
| 86 | + | d.afterAttachmentEffectGateFrozen = nil | |
| 89 | 87 | }() | |
| 90 | 88 | ||
| 91 | 89 | moveDone := make(chan error, 1) | |
| @@ -113,6 +111,139 @@ func TestMovePaneGateReservationRaceKillWinsWithoutDeadlock(t *testing.T) { | |||
| 113 | 111 | require.NotSame(t, moved, destination.tabs[0].panes[moved.id], "kill winner must not publish a partial destination move") | |
| 114 | 112 | } | |
| 115 | 113 | ||
| 114 | + | func TestFinalCloseDoesNotDeadlockWithMoveDispatchAdmission(t *testing.T) { | |
| 115 | + | tests := []struct { | |
| 116 | + | name string | |
| 117 | + | close func(*Daemon, *session, *tab, *pane) error | |
| 118 | + | move func(*Daemon, *session, *tab, *pane, *session, *tab) error | |
| 119 | + | }{ | |
| 120 | + | {name: "pane", close: func(d *Daemon, source *session, sourceTab *tab, _ *pane) error { | |
| 121 | + | return d.closeTab(source, sourceTab, true) | |
| 122 | + | }, move: func(d *Daemon, source *session, sourceTab *tab, moved *pane, destination *session, destinationTab *tab) error { | |
| 123 | + | return d.movePane(moveGateRaceRequest(source, sourceTab, moved, destination, destinationTab)) | |
| 124 | + | }}, | |
| 125 | + | {name: "pane-reap", close: func(d *Daemon, _ *session, _ *tab, moved *pane) error { | |
| 126 | + | d.reapPaneOwner(moved) | |
| 127 | + | return nil | |
| 128 | + | }, move: func(d *Daemon, source *session, sourceTab *tab, moved *pane, destination *session, destinationTab *tab) error { | |
| 129 | + | return d.movePane(moveGateRaceRequest(source, sourceTab, moved, destination, destinationTab)) | |
| 130 | + | }}, | |
| 131 | + | {name: "tab", close: func(d *Daemon, source *session, sourceTab *tab, _ *pane) error { | |
| 132 | + | return d.closeTab(source, sourceTab, true) | |
| 133 | + | }, move: func(d *Daemon, source *session, sourceTab *tab, _ *pane, destination *session, _ *tab) error { | |
| 134 | + | return d.moveTab(moveTabRequest{ | |
| 135 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(sourceTab.stableID), | |
| 136 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 137 | + | }) | |
| 138 | + | }}, | |
| 139 | + | } | |
| 140 | + | for _, test := range tests { | |
| 141 | + | t.Run(test.name, func(t *testing.T) { | |
| 142 | + | d, source, sourceTab, moved, destination, destinationTab, releasePTYs := newMoveGateRaceFixture(t) | |
| 143 | + | defer releasePTYs() | |
| 144 | + | ||
| 145 | + | closeReady := make(chan struct{}) | |
| 146 | + | releaseClose := make(chan struct{}) | |
| 147 | + | d.afterAttachmentEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) { | |
| 148 | + | if action != "" { | |
| 149 | + | return | |
| 150 | + | } | |
| 151 | + | close(closeReady) | |
| 152 | + | <-releaseClose | |
| 153 | + | } | |
| 154 | + | moveDispatchReady := make(chan struct{}) | |
| 155 | + | d.beforeMoveDispatch = func() { close(moveDispatchReady) } | |
| 156 | + | defer func() { | |
| 157 | + | d.afterAttachmentEffectParticipantsSnapshotted = nil | |
| 158 | + | d.beforeMoveDispatch = nil | |
| 159 | + | }() | |
| 160 | + | ||
| 161 | + | closeDone := make(chan error, 1) | |
| 162 | + | go func() { closeDone <- test.close(d, source, sourceTab, moved) }() | |
| 163 | + | waitMoveRace(t, closeReady, "final close participant snapshot") | |
| 164 | + | ||
| 165 | + | moveDone := make(chan error, 1) | |
| 166 | + | go func() { moveDone <- test.move(d, source, sourceTab, moved, destination, destinationTab) }() | |
| 167 | + | waitMoveRace(t, moveDispatchReady, "move dispatch admission") | |
| 168 | + | close(releaseClose) | |
| 169 | + | ||
| 170 | + | require.NoError(t, waitMoveRace(t, closeDone, "final close")) | |
| 171 | + | require.ErrorIs(t, waitMoveRace(t, moveDone, "move after final close"), errMoveStaleTarget) | |
| 172 | + | d.mu.Lock() | |
| 173 | + | _, sourceLive := d.sessions[source.id] | |
| 174 | + | _, destinationLive := d.sessions[destination.id] | |
| 175 | + | d.mu.Unlock() | |
| 176 | + | require.False(t, sourceLive, "final close must retire the source") | |
| 177 | + | require.True(t, destinationLive) | |
| 178 | + | if test.name == "pane" || test.name == "pane-reap" { | |
| 179 | + | require.NotSame(t, moved, destinationTab.panes[moved.id], "a rejected move must not publish into the destination") | |
| 180 | + | } | |
| 181 | + | }) | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func TestPaletteFinalCloseEndsCurrentEffectBeforeDrainingPeers(t *testing.T) { | |
| 186 | + | d, sess, current, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 187 | + | d.attachCoordinator(sess, current, true) | |
| 188 | + | otherTransport := &closeTrackingTransport{} | |
| 189 | + | other := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: current.size} | |
| 190 | + | other.initOverlays() | |
| 191 | + | other.setSession(sess) | |
| 192 | + | sess.mu.Lock() | |
| 193 | + | require.True(t, sess.registerAttachmentLocked(other)) | |
| 194 | + | sess.mu.Unlock() | |
| 195 | + | d.attachCoordinator(sess, other, true) | |
| 196 | + | ||
| 197 | + | currentToken := sess.attachmentToken(current, current.transport()) | |
| 198 | + | currentEffect, admitted := current.beginAttachmentEffect(currentToken) | |
| 199 | + | require.True(t, admitted) | |
| 200 | + | t.Cleanup(currentEffect.End) | |
| 201 | + | otherToken := sess.attachmentToken(other, otherTransport) | |
| 202 | + | otherEffect, admitted := other.beginAttachmentEffect(otherToken) | |
| 203 | + | require.True(t, admitted) | |
| 204 | + | t.Cleanup(otherEffect.End) | |
| 205 | + | ||
| 206 | + | currentEnded := make(chan struct{}) | |
| 207 | + | currentReleasedBeforePeerFreeze := make(chan struct{}) | |
| 208 | + | otherFrozen := make(chan struct{}) | |
| 209 | + | d.afterActionAttachmentEffectEnded = func(action string) { | |
| 210 | + | if action == "close-tab" { | |
| 211 | + | close(currentEnded) | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | d.afterAttachmentEffectGateFrozen = func(action string, ac *attachedClient) { | |
| 215 | + | if action == "close-tab" && ac == other { | |
| 216 | + | if currentEffect.ended.Load() { | |
| 217 | + | close(currentReleasedBeforePeerFreeze) | |
| 218 | + | } | |
| 219 | + | close(otherFrozen) | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | defer func() { | |
| 223 | + | d.afterActionAttachmentEffectEnded = nil | |
| 224 | + | d.afterAttachmentEffectGateFrozen = nil | |
| 225 | + | }() | |
| 226 | + | ||
| 227 | + | closeDone := make(chan error, 1) | |
| 228 | + | go func() { | |
| 229 | + | sess.dispatchMu.Lock() | |
| 230 | + | defer sess.dispatchMu.Unlock() | |
| 231 | + | closeDone <- (paletteExec{d: d, sess: sess, ac: current, effect: currentEffect}).CloseTab() | |
| 232 | + | }() | |
| 233 | + | waitMoveRace(t, otherFrozen, "peer effect gate freeze") | |
| 234 | + | waitMoveRace(t, currentReleasedBeforePeerFreeze, "current effect release before peer drain") | |
| 235 | + | waitMoveRace(t, currentEnded, "current palette effect completion") | |
| 236 | + | select { | |
| 237 | + | case err := <-closeDone: | |
| 238 | + | t.Fatalf("final close drained its own peer before release: %v", err) | |
| 239 | + | default: | |
| 240 | + | } | |
| 241 | + | otherEffect.End() | |
| 242 | + | require.NoError(t, waitMoveRace(t, closeDone, "palette final close")) | |
| 243 | + | requireAttachmentEffectGateRetired(t, current) | |
| 244 | + | requireAttachmentEffectGateRetired(t, other) | |
| 245 | + | } | |
| 246 | + | ||
| 116 | 247 | func TestMovePaneGateReservationRaceMoveWinsWithoutDeadlock(t *testing.T) { | |
| 117 | 248 | d, source, sourceTab, moved, destination, destinationTab, releasePTYs := newMoveGateRaceFixture(t) | |
| 118 | 249 | defer releasePTYs() | |
| @@ -120,7 +251,7 @@ func TestMovePaneGateReservationRaceMoveWinsWithoutDeadlock(t *testing.T) { | |||
| 120 | 251 | moveGateFrozen := make(chan struct{}) | |
| 121 | 252 | releaseMove := make(chan struct{}) | |
| 122 | 253 | var moveGateFrozenOnce sync.Once | |
| 123 | - | d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) { | |
| 254 | + | d.afterAttachmentEffectGateFrozen = func(action string, _ *attachedClient) { | |
| 124 | 255 | if action != "move-pane" { | |
| 125 | 256 | return | |
| 126 | 257 | } | |
| @@ -129,14 +260,14 @@ func TestMovePaneGateReservationRaceMoveWinsWithoutDeadlock(t *testing.T) { | |||
| 129 | 260 | } | |
| 130 | 261 | killSnapshotted := make(chan struct{}) | |
| 131 | 262 | var killSnapshottedOnce sync.Once | |
| 132 | - | d.afterRoleEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) { | |
| 263 | + | d.afterAttachmentEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) { | |
| 133 | 264 | if action == "" { | |
| 134 | 265 | killSnapshottedOnce.Do(func() { close(killSnapshotted) }) | |
| 135 | 266 | } | |
| 136 | 267 | } | |
| 137 | 268 | defer func() { | |
| 138 | - | d.afterRoleEffectGateFrozen = nil | |
| 139 | - | d.afterRoleEffectParticipantsSnapshotted = nil | |
| 269 | + | d.afterAttachmentEffectGateFrozen = nil | |
| 270 | + | d.afterAttachmentEffectParticipantsSnapshotted = nil | |
| 140 | 271 | }() | |
| 141 | 272 | ||
| 142 | 273 | moveDone := make(chan error, 1) | |
D internal/usecase/daemon/move_handoff_failure_test.go +0 -336
| @@ -1,336 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "maps" | |
| 6 | - | "testing" | |
| 7 | - | ||
| 8 | - | "github.com/stretchr/testify/require" | |
| 9 | - | ||
| 10 | - | "github.com/bnema/vev/internal/domain" | |
| 11 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | - | ) | |
| 13 | - | ||
| 14 | - | type moveTabState struct { | |
| 15 | - | ptr *tab | |
| 16 | - | stableID string | |
| 17 | - | tree *layout.Tree | |
| 18 | - | panes map[layout.PaneID]*pane | |
| 19 | - | nextPaneID int | |
| 20 | - | layoutGeneration uint64 | |
| 21 | - | floatingState floatingState | |
| 22 | - | floatingPane *pane | |
| 23 | - | floatingVisible bool | |
| 24 | - | floatingGeneration uint64 | |
| 25 | - | ctxDone bool | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | type moveSessionState struct { | |
| 29 | - | ptr *session | |
| 30 | - | id domain.SessionID | |
| 31 | - | name string | |
| 32 | - | incarnation domain.IncarnationID | |
| 33 | - | tabs []*tab | |
| 34 | - | active int | |
| 35 | - | client *attachedClient | |
| 36 | - | snatched map[*attachedClient]struct{} | |
| 37 | - | moveReservations uint | |
| 38 | - | snapshotGeneration uint64 | |
| 39 | - | snapshotPublished uint64 | |
| 40 | - | snapshotDirty bool | |
| 41 | - | snapshotEligible bool | |
| 42 | - | ctxDone bool | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | type moveLeaseState struct { | |
| 46 | - | lease *attachmentLease | |
| 47 | - | attachment *attachedClient | |
| 48 | - | ready bool | |
| 49 | - | active bool | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | type moveClientState struct { | |
| 53 | - | transport transportSnapshot | |
| 54 | - | roleGeneration uint64 | |
| 55 | - | currentSession *session | |
| 56 | - | sourceRole attachmentRole | |
| 57 | - | destinationRole attachmentRole | |
| 58 | - | sourceLease moveLeaseState | |
| 59 | - | destinationLease moveLeaseState | |
| 60 | - | roleEffectPhase roleEffectPhase | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | type movePaneState struct { | |
| 64 | - | id layout.PaneID | |
| 65 | - | stableID string | |
| 66 | - | owner *paneOwner | |
| 67 | - | ownerGeneration uint64 | |
| 68 | - | ctxDone bool | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | type moveState struct { | |
| 72 | - | closing bool | |
| 73 | - | registry map[domain.SessionID]attachmentSession | |
| 74 | - | source moveSessionState | |
| 75 | - | destination moveSessionState | |
| 76 | - | sourceTab moveTabState | |
| 77 | - | destinationTab moveTabState | |
| 78 | - | movedPane movePaneState | |
| 79 | - | client moveClientState | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | func captureMoveState(d *Daemon, source, destination *session, sourceTab, destinationTab *tab, movedPane *pane, client *attachedClient) moveState { | |
| 83 | - | clientState := captureMoveClientState(source, destination, client) | |
| 84 | - | d.mu.Lock() | |
| 85 | - | d.notices.routingMu.Lock() | |
| 86 | - | unlockSessions := lockAttachmentSessions(source, destination) | |
| 87 | - | unlockTabs := lockMoveTabs(sourceTab, destinationTab) | |
| 88 | - | movedPane.mu.Lock() | |
| 89 | - | ||
| 90 | - | registry := make(map[domain.SessionID]attachmentSession, len(d.sessions)) | |
| 91 | - | maps.Copy(registry, d.sessions) | |
| 92 | - | state := moveState{ | |
| 93 | - | closing: d.closing, | |
| 94 | - | registry: registry, | |
| 95 | - | source: captureMoveSessionStateLocked(source), | |
| 96 | - | destination: captureMoveSessionStateLocked(destination), | |
| 97 | - | sourceTab: captureMoveTabStateLocked(sourceTab), | |
| 98 | - | destinationTab: captureMoveTabStateLocked(destinationTab), | |
| 99 | - | movedPane: movePaneState{ | |
| 100 | - | id: movedPane.id, | |
| 101 | - | stableID: movedPane.stableID, | |
| 102 | - | owner: movedPane.ownerSnapshot(), | |
| 103 | - | ownerGeneration: movedPane.ownerGeneration, | |
| 104 | - | ctxDone: moveContextDone(movedPane.ctx), | |
| 105 | - | }, | |
| 106 | - | client: clientState, | |
| 107 | - | } | |
| 108 | - | ||
| 109 | - | movedPane.mu.Unlock() | |
| 110 | - | unlockTabs() | |
| 111 | - | unlockSessions() | |
| 112 | - | d.notices.routingMu.Unlock() | |
| 113 | - | d.mu.Unlock() | |
| 114 | - | return state | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | func captureMoveSessionStateLocked(sess *session) moveSessionState { | |
| 118 | - | snatched := make(map[*attachedClient]struct{}, len(sess.snatched)) | |
| 119 | - | maps.Copy(snatched, sess.snatched) | |
| 120 | - | return moveSessionState{ | |
| 121 | - | ptr: sess, | |
| 122 | - | id: sess.id, | |
| 123 | - | name: sess.name, | |
| 124 | - | incarnation: sess.incarnation, | |
| 125 | - | tabs: append([]*tab(nil), sess.tabs...), | |
| 126 | - | active: sess.active, | |
| 127 | - | client: sess.client, | |
| 128 | - | snatched: snatched, | |
| 129 | - | moveReservations: sess.moveReservations, | |
| 130 | - | snapshotGeneration: sess.snapshotGeneration, | |
| 131 | - | snapshotPublished: sess.snapshotPublishedGeneration, | |
| 132 | - | snapshotDirty: sess.snapDirty.Load(), | |
| 133 | - | snapshotEligible: sess.snapEligible.Load(), | |
| 134 | - | ctxDone: moveContextDone(sess.ctx), | |
| 135 | - | } | |
| 136 | - | } | |
| 137 | - | ||
| 138 | - | func captureMoveTabStateLocked(tb *tab) moveTabState { | |
| 139 | - | panes := make(map[layout.PaneID]*pane, len(tb.panes)) | |
| 140 | - | maps.Copy(panes, tb.panes) | |
| 141 | - | return moveTabState{ | |
| 142 | - | ptr: tb, | |
| 143 | - | stableID: tb.stableID, | |
| 144 | - | tree: tb.tree.Clone(), | |
| 145 | - | panes: panes, | |
| 146 | - | nextPaneID: tb.nextPaneID, | |
| 147 | - | layoutGeneration: tb.layoutGeneration, | |
| 148 | - | floatingState: tb.floating.state, | |
| 149 | - | floatingPane: tb.floating.pane, | |
| 150 | - | floatingVisible: tb.floating.desiredVisible, | |
| 151 | - | floatingGeneration: tb.floating.generation, | |
| 152 | - | ctxDone: moveContextDone(tb.ctx), | |
| 153 | - | } | |
| 154 | - | } | |
| 155 | - | ||
| 156 | - | // captureMoveClientState snapshots coordinator and role-effect state before | |
| 157 | - | // callers acquire daemon/session/tab move-state locks. This keeps coordinator | |
| 158 | - | // and role-gate locks above, never beneath, the outer move fixture locks. | |
| 159 | - | func captureMoveClientState(source, destination *session, client *attachedClient) moveClientState { | |
| 160 | - | sourceLease := captureMoveLeaseState(source, client) | |
| 161 | - | destinationLease := captureMoveLeaseState(destination, client) | |
| 162 | - | roleEffectPhase := captureMoveRoleEffectPhase(client) | |
| 163 | - | return moveClientState{ | |
| 164 | - | transport: client.transportSnapshot(), | |
| 165 | - | roleGeneration: client.roleGeneration.Load(), | |
| 166 | - | currentSession: client.currentSession(), | |
| 167 | - | sourceRole: source.attachmentRole(client), | |
| 168 | - | destinationRole: destination.attachmentRole(client), | |
| 169 | - | sourceLease: sourceLease, | |
| 170 | - | destinationLease: destinationLease, | |
| 171 | - | roleEffectPhase: roleEffectPhase, | |
| 172 | - | } | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | func captureMoveLeaseState(sess *session, client *attachedClient) moveLeaseState { | |
| 176 | - | coordinator := sess.renderCoordinator() | |
| 177 | - | if coordinator == nil { | |
| 178 | - | return moveLeaseState{} | |
| 179 | - | } | |
| 180 | - | coordinator.mu.Lock() | |
| 181 | - | defer coordinator.mu.Unlock() | |
| 182 | - | lease := coordinator.lease | |
| 183 | - | if lease == nil || lease.attachment != client { | |
| 184 | - | return moveLeaseState{lease: lease} | |
| 185 | - | } | |
| 186 | - | return moveLeaseState{lease: lease, attachment: lease.attachment, ready: lease.ready, active: lease.active} | |
| 187 | - | } | |
| 188 | - | ||
| 189 | - | func captureMoveRoleEffectPhase(client *attachedClient) roleEffectPhase { | |
| 190 | - | client.roleEffects.mu.Lock() | |
| 191 | - | defer client.roleEffects.mu.Unlock() | |
| 192 | - | return client.roleEffects.phase | |
| 193 | - | } | |
| 194 | - | ||
| 195 | - | func moveContextDone(ctx context.Context) bool { | |
| 196 | - | return ctx != nil && ctx.Err() != nil | |
| 197 | - | } | |
| 198 | - | ||
| 199 | - | func TestMovePaneFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) { | |
| 200 | - | p1, release1 := newBlockingPTY(t) | |
| 201 | - | p2, release2 := newBlockingPTY(t) | |
| 202 | - | defer release1() | |
| 203 | - | defer release2() | |
| 204 | - | ||
| 205 | - | d, source, sourceClient, _ := newManualSessionWithPTYs(t, p1) | |
| 206 | - | sourceTab := source.tabs[0] | |
| 207 | - | sourceTab.stableID = "source-tab" | |
| 208 | - | movedPane := sourceTab.focusedPane() | |
| 209 | - | ||
| 210 | - | destination := &session{sessionCore: sessionCore{id: "destination", | |
| 211 | - | name: "destination", | |
| 212 | - | ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})}, | |
| 213 | - | active: 0, | |
| 214 | - | } | |
| 215 | - | destinationTab := destination.tabs[0] | |
| 216 | - | publishTiledPaneOwners(destination, destinationTab) | |
| 217 | - | d.mu.Lock() | |
| 218 | - | d.sessions[destination.id] = destination | |
| 219 | - | d.mu.Unlock() | |
| 220 | - | require.NotNil(t, d.attachCoordinator(source, nil, sourceClient, true)) | |
| 221 | - | ||
| 222 | - | before := captureMoveState(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient) | |
| 223 | - | originalTransport := before.client.transport | |
| 224 | - | staleTransport := &closeTrackingTransport{} | |
| 225 | - | staleInjected := make(chan struct{}) | |
| 226 | - | d.afterRoleEffectGateFrozen = func(action string, client *attachedClient) { | |
| 227 | - | if action != "move-pane" || client != sourceClient { | |
| 228 | - | return | |
| 229 | - | } | |
| 230 | - | client.replaceTransport(staleTransport) | |
| 231 | - | close(staleInjected) | |
| 232 | - | } | |
| 233 | - | ||
| 234 | - | moveDone := make(chan error, 1) | |
| 235 | - | go func() { | |
| 236 | - | moveDone <- d.movePane(movePaneRequest{ | |
| 237 | - | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 238 | - | SourceTabID: domain.TabStableID(sourceTab.stableID), | |
| 239 | - | SourcePaneID: domain.PaneStableID(movedPane.stableID), | |
| 240 | - | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 241 | - | DestinationTabID: domain.TabStableID(destinationTab.stableID), | |
| 242 | - | }) | |
| 243 | - | }() | |
| 244 | - | <-staleInjected | |
| 245 | - | err := <-moveDone | |
| 246 | - | ||
| 247 | - | // Restore the exact test-injected transport incarnation before comparing the | |
| 248 | - | // complete state. The move itself must not be able to alter this state. | |
| 249 | - | sourceClient.linkMu.Lock() | |
| 250 | - | sourceClient.tr = originalTransport.transport | |
| 251 | - | sourceClient.transportIncarnation = originalTransport.incarnation | |
| 252 | - | sourceClient.linkMu.Unlock() | |
| 253 | - | d.afterRoleEffectGateFrozen = nil | |
| 254 | - | ||
| 255 | - | require.ErrorIs(t, err, errAttachmentTransition) | |
| 256 | - | after := captureMoveState(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient) | |
| 257 | - | require.Equal(t, before, after, "stale handoff validation must reject before any move publication") | |
| 258 | - | } | |
| 259 | - | ||
| 260 | - | type moveTabHandoffState struct { | |
| 261 | - | closing bool | |
| 262 | - | registry map[domain.SessionID]attachmentSession | |
| 263 | - | source moveSessionState | |
| 264 | - | destination moveSessionState | |
| 265 | - | movedTab moveTabState | |
| 266 | - | follower moveClientState | |
| 267 | - | } | |
| 268 | - | ||
| 269 | - | func captureMoveTabHandoffState(d *Daemon, source, destination *session, moved *tab, follower *attachedClient) moveTabHandoffState { | |
| 270 | - | followerState := captureMoveClientState(source, destination, follower) | |
| 271 | - | d.mu.Lock() | |
| 272 | - | d.notices.routingMu.Lock() | |
| 273 | - | unlockSessions := lockAttachmentSessions(source, destination) | |
| 274 | - | moved.mu.Lock() | |
| 275 | - | ||
| 276 | - | registry := make(map[domain.SessionID]attachmentSession, len(d.sessions)) | |
| 277 | - | maps.Copy(registry, d.sessions) | |
| 278 | - | state := moveTabHandoffState{ | |
| 279 | - | closing: d.closing, | |
| 280 | - | registry: registry, | |
| 281 | - | source: captureMoveSessionStateLocked(source), | |
| 282 | - | destination: captureMoveSessionStateLocked(destination), | |
| 283 | - | movedTab: captureMoveTabStateLocked(moved), | |
| 284 | - | follower: followerState, | |
| 285 | - | } | |
| 286 | - | ||
| 287 | - | moved.mu.Unlock() | |
| 288 | - | unlockSessions() | |
| 289 | - | d.notices.routingMu.Unlock() | |
| 290 | - | d.mu.Unlock() | |
| 291 | - | return state | |
| 292 | - | } | |
| 293 | - | ||
| 294 | - | func TestMoveTabFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) { | |
| 295 | - | d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 296 | - | floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8}) | |
| 297 | - | floating.ctx, floating.cancel = context.WithCancel(d.paneProcessCtx) | |
| 298 | - | t.Cleanup(floating.cancel) | |
| 299 | - | moved.mu.Lock() | |
| 300 | - | moved.floating = floatingSlot{state: floatingVisible, pane: floating, desiredVisible: true, generation: 9} | |
| 301 | - | moved.mu.Unlock() | |
| 302 | - | publishPaneOwner(floating, source, moved, 9) | |
| 303 | - | ||
| 304 | - | before := captureMoveTabHandoffState(d, source, destination, moved, follower) | |
| 305 | - | originalTransport := before.follower.transport | |
| 306 | - | staleTransport := &closeTrackingTransport{} | |
| 307 | - | staleInjected := make(chan struct{}) | |
| 308 | - | d.afterRoleEffectGateFrozen = func(action string, client *attachedClient) { | |
| 309 | - | if action != "move-tab" || client != follower { | |
| 310 | - | return | |
| 311 | - | } | |
| 312 | - | client.replaceTransport(staleTransport) | |
| 313 | - | close(staleInjected) | |
| 314 | - | } | |
| 315 | - | ||
| 316 | - | moveDone := make(chan error, 1) | |
| 317 | - | go func() { | |
| 318 | - | moveDone <- d.moveTab(moveTabRequest{ | |
| 319 | - | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 320 | - | SourceTabID: domain.TabStableID(moved.stableID), | |
| 321 | - | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 322 | - | }) | |
| 323 | - | }() | |
| 324 | - | <-staleInjected | |
| 325 | - | err := <-moveDone | |
| 326 | - | ||
| 327 | - | follower.linkMu.Lock() | |
| 328 | - | follower.tr = originalTransport.transport | |
| 329 | - | follower.transportIncarnation = originalTransport.incarnation | |
| 330 | - | follower.linkMu.Unlock() | |
| 331 | - | d.afterRoleEffectGateFrozen = nil | |
| 332 | - | ||
| 333 | - | require.ErrorIs(t, err, errAttachmentTransition) | |
| 334 | - | after := captureMoveTabHandoffState(d, source, destination, moved, follower) | |
| 335 | - | require.Equal(t, before, after, "stale move-tab handoff validation must reject before any move publication") | |
| 336 | - | } | |
M internal/usecase/daemon/move_pane_admission.go +17 -18
| @@ -1,8 +1,6 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | - | import ( | |
| 4 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 5 | - | ) | |
| 3 | + | import "github.com/bnema/vev/internal/usecase/layout" | |
| 6 | 4 | ||
| 7 | 5 | // movePaneAdmission is an immutable snapshot of the exact live objects and | |
| 8 | 6 | // generations a move may later commit. Resize fences are acquired only after | |
| @@ -13,13 +11,11 @@ type movePaneAdmission struct { | |||
| 13 | 11 | sourceTab *tab | |
| 14 | 12 | destinationTab *tab | |
| 15 | 13 | movedPane *pane | |
| 16 | - | sourceClient *attachedClient | |
| 17 | - | destinationClient *attachedClient | |
| 18 | - | sourceSnatched []*attachedClient | |
| 14 | + | sourceAttachments []*attachedClient | |
| 15 | + | sourceTransports map[*attachedClient]transportSnapshot | |
| 19 | 16 | sourceGeneration uint64 | |
| 20 | 17 | destinationGeneration uint64 | |
| 21 | 18 | finalSourceTab bool | |
| 22 | - | sourceTabWasActive bool | |
| 23 | 19 | } | |
| 24 | 20 | ||
| 25 | 21 | func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destination *session) (*movePaneAdmission, error) { | |
| @@ -33,6 +29,12 @@ func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destinat | |||
| 33 | 29 | if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) { | |
| 34 | 30 | return nil, errMoveStaleTarget | |
| 35 | 31 | } | |
| 32 | + | if req.Attachment != nil && !attachmentRegisteredLocked(source, req.Attachment) { | |
| 33 | + | return nil, errMoveStaleTarget | |
| 34 | + | } | |
| 35 | + | if req.AttachmentToken.ac != nil && (req.AttachmentToken.ac != req.Attachment || !moveAttachmentTokenCurrentLocked(req.AttachmentToken, source)) { | |
| 36 | + | return nil, errMoveStaleTarget | |
| 37 | + | } | |
| 36 | 38 | ||
| 37 | 39 | sourceTab := findMoveTabLocked(source, req.SourceTabID) | |
| 38 | 40 | destinationTab := findMoveTabLocked(destination, req.DestinationTabID) | |
| @@ -56,19 +58,16 @@ func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destinat | |||
| 56 | 58 | return nil, errMoveStaleTarget | |
| 57 | 59 | } | |
| 58 | 60 | ||
| 61 | + | attachments := source.snapshotAttachmentsLocked() | |
| 62 | + | transports := make(map[*attachedClient]transportSnapshot, len(attachments)) | |
| 63 | + | for _, ac := range attachments { | |
| 64 | + | transports[ac] = ac.transportSnapshot() | |
| 65 | + | } | |
| 59 | 66 | return &movePaneAdmission{ | |
| 60 | - | source: source, | |
| 61 | - | destination: destination, | |
| 62 | - | sourceTab: sourceTab, | |
| 63 | - | destinationTab: destinationTab, | |
| 64 | - | movedPane: movedPane, | |
| 65 | - | sourceClient: source.client, | |
| 66 | - | destinationClient: destination.client, | |
| 67 | - | sourceSnatched: snapshotMoveSnatchedLocked(source), | |
| 68 | - | sourceGeneration: sourceTab.layoutGeneration, | |
| 69 | - | destinationGeneration: destinationTab.layoutGeneration, | |
| 67 | + | source: source, destination: destination, sourceTab: sourceTab, destinationTab: destinationTab, | |
| 68 | + | movedPane: movedPane, sourceAttachments: attachments, sourceTransports: transports, | |
| 69 | + | sourceGeneration: sourceTab.layoutGeneration, destinationGeneration: destinationTab.layoutGeneration, | |
| 70 | 70 | finalSourceTab: len(source.tabs) == 1 && source.tabs[0] == sourceTab && | |
| 71 | 71 | sourceTab.tree != nil && sourceTab.tree.Root != nil && len(layout.LeafIDs(sourceTab.tree.Root)) == 1, | |
| 72 | - | sourceTabWasActive: source.active >= 0 && source.active < len(source.tabs) && source.tabs[source.active] == sourceTab, | |
| 73 | 72 | }, nil | |
| 74 | 73 | } | |
M internal/usecase/daemon/move_pane_commit.go +18 -59
| @@ -13,19 +13,14 @@ type movePaneCommit struct { | |||
| 13 | 13 | source, destination *session | |
| 14 | 14 | sourceTab, destinationTab *tab | |
| 15 | 15 | movedPane *pane | |
| 16 | - | sourceClient *attachedClient | |
| 17 | - | sourceSnatched []*attachedClient | |
| 16 | + | sourceAttachments []*attachedClient | |
| 17 | + | sourceTransports map[*attachedClient]transportSnapshot | |
| 18 | 18 | sourceGeneration uint64 | |
| 19 | 19 | destinationGeneration uint64 | |
| 20 | - | handoffFrozen bool | |
| 21 | - | frozenRoles frozenRoleEffectGates | |
| 22 | - | handoffReq attachmentTransitionRequest | |
| 23 | - | handoffPublication *attachmentPublication | |
| 20 | + | frozenEffects frozenAttachmentEffectGates | |
| 24 | 21 | err error | |
| 25 | 22 | ||
| 26 | - | handoffResult attachmentTransitionResult | |
| 27 | 23 | syncCleanup syncTimerCleanup | |
| 28 | - | sourceCleanupToken attachmentRoleToken | |
| 29 | 24 | sourceEmpty bool | |
| 30 | 25 | sourceTabRemoved bool | |
| 31 | 26 | retiredParked []parkedAttachmentRetirement | |
| @@ -36,16 +31,8 @@ type movePaneCommit struct { | |||
| 36 | 31 | destinationMetadataValid bool | |
| 37 | 32 | } | |
| 38 | 33 | ||
| 39 | - | func (c *movePaneCommit) releasePublication() { | |
| 40 | - | if c != nil && c.handoffPublication != nil { | |
| 41 | - | c.handoffPublication.unlockCoordinators() | |
| 42 | - | c.handoffPublication = nil | |
| 43 | - | } | |
| 44 | - | } | |
| 45 | - | ||
| 46 | 34 | func (c *movePaneCommit) publishLocked(d *Daemon) bool { | |
| 47 | 35 | var candidate *movePaneCandidate | |
| 48 | - | var retirement frozenMoveAttachmentRetirement | |
| 49 | 36 | var err error | |
| 50 | 37 | d.mu.Lock() | |
| 51 | 38 | if d.closing || d.sessions[c.source.id] != c.source || d.sessions[c.destination.id] != c.destination { | |
| @@ -61,18 +48,11 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool { | |||
| 61 | 48 | if !moveSessionLocatorCurrentLocked(c.source, c.req.Source) || !moveSessionLocatorCurrentLocked(c.destination, c.req.Destination) { | |
| 62 | 49 | return false | |
| 63 | 50 | } | |
| 64 | - | if c.handoffFrozen { | |
| 65 | - | if c.source.client != c.sourceClient || !sameMoveSnatchedLocked(c.source, c.sourceSnatched) || | |
| 66 | - | c.handoffReq.targetTabIndex < 0 || c.handoffReq.targetTabIndex >= len(c.destination.tabs) || | |
| 67 | - | c.destination.tabs[c.handoffReq.targetTabIndex] != c.destinationTab { | |
| 68 | - | return false | |
| 69 | - | } | |
| 70 | - | c.handoffPublication, err = d.validateAttachmentTransitionPrelocked(c.handoffReq) | |
| 71 | - | if err != nil { | |
| 72 | - | c.err = err | |
| 73 | - | return false | |
| 74 | - | } | |
| 75 | - | defer c.releasePublication() | |
| 51 | + | if c.req.Attachment != nil && !attachmentRegisteredLocked(c.source, c.req.Attachment) { | |
| 52 | + | return false | |
| 53 | + | } | |
| 54 | + | if c.req.AttachmentToken.ac != nil && (c.req.AttachmentToken.ac != c.req.Attachment || !moveAttachmentTokenCurrentLocked(c.req.AttachmentToken, c.source)) { | |
| 55 | + | return false | |
| 76 | 56 | } | |
| 77 | 57 | ||
| 78 | 58 | unlockTabs := lockMoveTabs(c.sourceTab, c.destinationTab) | |
| @@ -101,34 +81,22 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool { | |||
| 101 | 81 | } | |
| 102 | 82 | ||
| 103 | 83 | sourceWillEmpty := candidate.removeSourceTab && len(c.source.tabs) == 1 | |
| 104 | - | if sourceWillEmpty && c.source != c.destination { | |
| 105 | - | if c.source.client != c.sourceClient || !sameMoveSnatchedLocked(c.source, c.sourceSnatched) || | |
| 106 | - | (c.sourceClient != nil) != c.handoffFrozen { | |
| 107 | - | return false | |
| 108 | - | } | |
| 109 | - | var retirementOK bool | |
| 110 | - | retirement, retirementOK = prepareFrozenMoveAttachmentRetirementLocked(c.source, c.sourceSnatched, c.frozenRoles) | |
| 111 | - | if !retirementOK { | |
| 112 | - | return false | |
| 113 | - | } | |
| 84 | + | if sourceWillEmpty && c.source != c.destination && !sameMoveAttachmentsLocked(c.source, c.sourceAttachments) { | |
| 85 | + | return false | |
| 114 | 86 | } | |
| 115 | 87 | ||
| 116 | - | // Everything above is fallible. Topology, owner, lifecycle registry, and | |
| 117 | - | // optional attachment roles now become visible under one lock section. | |
| 88 | + | // Everything above is fallible. Shared topology and owner publication are | |
| 89 | + | // committed once; attachment views are repaired after these locks release. | |
| 118 | 90 | if candidate.removeSourceTab { | |
| 119 | 91 | idx := indexMoveTabLocked(c.source, c.sourceTab) | |
| 120 | 92 | if idx < 0 { | |
| 121 | 93 | return false | |
| 122 | 94 | } | |
| 95 | + | if len(c.source.tabs) > 1 { | |
| 96 | + | c.source.prepareAttachmentViewsForRemovedTabLocked(c.sourceTab, idx) | |
| 97 | + | } | |
| 123 | 98 | c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...) | |
| 124 | 99 | c.sourceTabRemoved = true | |
| 125 | - | if len(c.source.tabs) == 0 { | |
| 126 | - | c.source.active = -1 | |
| 127 | - | } else if c.source.active > idx { | |
| 128 | - | c.source.active-- | |
| 129 | - | } else if c.source.active >= len(c.source.tabs) { | |
| 130 | - | c.source.active = len(c.source.tabs) - 1 | |
| 131 | - | } | |
| 132 | 100 | } else { | |
| 133 | 101 | c.sourceTab.tree = candidate.sourceTree | |
| 134 | 102 | c.sourceTab.bumpLayoutGenerationLocked() | |
| @@ -143,21 +111,12 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool { | |||
| 143 | 111 | c.destinationTab.bumpLayoutGenerationLocked() | |
| 144 | 112 | c.movedPane.id = candidate.destinationID | |
| 145 | 113 | oldOwner, newOwner := c.movedPane.publishOwnerLocked(c.destination, c.destinationTab, 0) | |
| 146 | - | if c.handoffPublication != nil { | |
| 147 | - | c.handoffResult = d.publishAttachmentTransitionPrelocked(c.handoffPublication) | |
| 148 | - | c.handoffPublication.unlockCoordinators() | |
| 149 | - | c.handoffPublication = nil | |
| 150 | - | } | |
| 151 | 114 | c.syncCleanup.append(d.migratePaneSyncOwnerLocked(c.movedPane, oldOwner, newOwner)) | |
| 152 | - | if c.handoffResult.published.ac != nil { | |
| 153 | - | c.sourceCleanupToken = c.handoffResult.published | |
| 154 | - | } else if c.source.client != nil { | |
| 155 | - | c.sourceCleanupToken = c.source.attachmentTokenLocked(c.source.client) | |
| 156 | - | } | |
| 157 | 115 | ||
| 158 | - | if candidate.removeSourceTab && len(c.source.tabs) == 0 { | |
| 116 | + | if sourceWillEmpty { | |
| 159 | 117 | d.unregisterSessionLocked(c.source) | |
| 160 | - | c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement) | |
| 118 | + | c.retiredAttachments = detachMoveAttachmentsLocked(c.source, c.sourceTransports) | |
| 119 | + | c.source.tabs = nil | |
| 161 | 120 | d.purgeParkingForSessionLocked(c.source) | |
| 162 | 121 | c.retiredParked = d.purgeParkedForSessionLocked(c.source) | |
| 163 | 122 | } | |
M internal/usecase/daemon/move_pane_postcommit.go +35 -78
| @@ -20,25 +20,22 @@ func firstMovePane(panes []*pane) *pane { | |||
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | 22 | type movePanePostcommitPlan struct { | |
| 23 | - | source *session | |
| 24 | - | destination *session | |
| 25 | - | sourceTab *tab | |
| 26 | - | destinationTab *tab | |
| 27 | - | movedPane *pane | |
| 28 | - | movedPanes []*pane | |
| 29 | - | destinationClient *attachedClient | |
| 30 | - | sourceCleanupToken attachmentRoleToken | |
| 31 | - | handoffResult attachmentTransitionResult | |
| 32 | - | syncCleanup syncTimerCleanup | |
| 33 | - | frozenRoles frozenRoleEffectGates | |
| 34 | - | rolesFrozen bool | |
| 35 | - | unlockDispatch func() | |
| 36 | - | reservation *moveLifecycleReservation | |
| 37 | - | oldTabCancel context.CancelFunc | |
| 23 | + | source *session | |
| 24 | + | destination *session | |
| 25 | + | sourceTab *tab | |
| 26 | + | destinationTab *tab | |
| 27 | + | movedPane *pane | |
| 28 | + | movedPanes []*pane | |
| 29 | + | sourceAttachments []*attachedClient | |
| 30 | + | syncCleanup syncTimerCleanup | |
| 31 | + | frozenEffects frozenAttachmentEffectGates | |
| 32 | + | effectsFrozen bool | |
| 33 | + | unlockDispatch func() | |
| 34 | + | reservation *moveLifecycleReservation | |
| 35 | + | oldTabCancel context.CancelFunc | |
| 38 | 36 | ||
| 39 | - | sourceTabWasActive bool | |
| 40 | - | sourceTabRemoved bool | |
| 41 | - | sourceEmpty bool | |
| 37 | + | sourceTabRemoved bool | |
| 38 | + | sourceEmpty bool | |
| 42 | 39 | ||
| 43 | 40 | retiredParked []parkedAttachmentRetirement | |
| 44 | 41 | retiredAttachments []detachedAttachmentSnapshot | |
| @@ -57,12 +54,11 @@ func (p movePanePostcommitPlan) execute(d *Daemon) { | |||
| 57 | 54 | } else if p.sourceTabRemoved && p.sourceTab != nil && p.sourceTab.cancel != nil { | |
| 58 | 55 | p.sourceTab.cancel() | |
| 59 | 56 | } | |
| 60 | - | if p.sourceCleanupToken.current() { | |
| 61 | - | cleanupClient := p.sourceCleanupToken.ac | |
| 62 | - | panes := p.movedPanes | |
| 63 | - | if len(panes) == 0 && p.movedPane != nil { | |
| 64 | - | panes = []*pane{p.movedPane} | |
| 65 | - | } | |
| 57 | + | panes := p.movedPanes | |
| 58 | + | if len(panes) == 0 && p.movedPane != nil { | |
| 59 | + | panes = []*pane{p.movedPane} | |
| 60 | + | } | |
| 61 | + | for _, cleanupClient := range p.sourceAttachments { | |
| 66 | 62 | for _, movedPane := range panes { | |
| 67 | 63 | if cleanupClient.overlays != nil { | |
| 68 | 64 | cleanupClient.overlays.clearCopyModeForPane(movedPane) | |
| @@ -78,19 +74,23 @@ func (p movePanePostcommitPlan) execute(d *Daemon) { | |||
| 78 | 74 | markSnapshotDirty(p.source) | |
| 79 | 75 | } | |
| 80 | 76 | p.syncCleanup.finish() | |
| 81 | - | if p.rolesFrozen { | |
| 82 | - | p.frozenRoles.unfreeze() | |
| 77 | + | if p.effectsFrozen { | |
| 78 | + | p.frozenEffects.unfreeze() | |
| 83 | 79 | } | |
| 84 | 80 | p.unlockDispatch() | |
| 85 | 81 | p.reservation.Release() | |
| 86 | 82 | ||
| 87 | - | if p.sourceTabWasActive && !moveSessionRetired(d, p.source) { | |
| 88 | - | d.activateTab(p.source, p.source.activeTab()) | |
| 83 | + | // Repair attachment-local stable targets after shared topology publication | |
| 84 | + | // and after the move dispatch locks have been released. | |
| 85 | + | p.source.repairAttachmentViews() | |
| 86 | + | if p.destination != p.source { | |
| 87 | + | p.destination.repairAttachmentViews() | |
| 89 | 88 | } | |
| 89 | + | ||
| 90 | 90 | if !moveSessionRetired(d, p.source) { | |
| 91 | 91 | sourceLayoutTab := p.sourceTab | |
| 92 | 92 | if p.sourceTabRemoved { | |
| 93 | - | sourceLayoutTab = p.source.activeTab() | |
| 93 | + | sourceLayoutTab = p.source.firstTab() | |
| 94 | 94 | } | |
| 95 | 95 | if sourceLayoutTab != nil && sourceLayoutTab != p.destinationTab { | |
| 96 | 96 | d.applyTabLayout(p.source, sourceLayoutTab) | |
| @@ -103,18 +103,13 @@ func (p movePanePostcommitPlan) execute(d *Daemon) { | |||
| 103 | 103 | if p.destinationMetadataValid { | |
| 104 | 104 | d.markCatalogueDirty(p.destinationMetadata) | |
| 105 | 105 | } | |
| 106 | - | if p.destinationClient != nil && sessionClientIs(p.destination, p.destinationClient) && destinationTabIsActive(p.destination, p.destinationTab) { | |
| 107 | - | d.invalidateRender(p.destination, p.destinationClient, true, "move.go") | |
| 106 | + | for _, ac := range p.destination.snapshotAttachments() { | |
| 107 | + | d.invalidateRender(p.destination, ac, true, "move.go") | |
| 108 | 108 | } | |
| 109 | - | if p.sourceCleanupToken.ac != nil && !p.sourceEmpty && p.sourceTabWasActive && sessionClientIs(p.source, p.sourceCleanupToken.ac) { | |
| 110 | - | d.invalidateRender(p.source, p.sourceCleanupToken.ac, true, "move.go") | |
| 111 | - | } | |
| 112 | - | if p.handoffResult.published.ac != nil { | |
| 113 | - | follower := p.handoffResult.published.ac | |
| 114 | - | d.applyHostTheme(p.destination, follower, follower.getClientTheme(), false) | |
| 115 | - | follower.recordPreviousSession(p.source) | |
| 116 | - | d.deferAttachmentTransitionCleanups(p.handoffResult) | |
| 117 | - | d.firstPaintForTransition(p.handoffResult.published) | |
| 109 | + | if !p.sourceEmpty && p.source != p.destination { | |
| 110 | + | for _, ac := range p.source.snapshotAttachments() { | |
| 111 | + | d.invalidateRender(p.source, ac, true, "move.go") | |
| 112 | + | } | |
| 118 | 113 | } | |
| 119 | 114 | if p.sourceEmpty { | |
| 120 | 115 | retireEmptySessionAfterMove(d, p.source) | |
| @@ -142,26 +137,6 @@ func (p movePanePostcommitPlan) execute(d *Daemon) { | |||
| 142 | 137 | } | |
| 143 | 138 | } | |
| 144 | 139 | ||
| 145 | - | // retireEmptyMoveSessionLocked removes all source-owned attachment roles that | |
| 146 | - | // were not transferred. It runs inside the move commit, after the moved pane | |
| 147 | - | // has already acquired its destination owner, and performs no external work. | |
| 148 | - | func retireEmptyMoveSessionLocked(sess *session, retirement frozenMoveAttachmentRetirement) []detachedAttachmentSnapshot { | |
| 149 | - | if sess == nil || sess.client != nil { | |
| 150 | - | return nil | |
| 151 | - | } | |
| 152 | - | retired := make([]detachedAttachmentSnapshot, 0, len(retirement.clients)) | |
| 153 | - | for _, ac := range retirement.clients { | |
| 154 | - | retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: ac.transportSnapshot()}) | |
| 155 | - | delete(sess.snatched, ac) | |
| 156 | - | ac.roleGeneration.Add(1) | |
| 157 | - | ac.setSession(nil) | |
| 158 | - | ac.invalidateFrozenRoleCapability() | |
| 159 | - | } | |
| 160 | - | sess.tabs = nil | |
| 161 | - | sess.active = -1 | |
| 162 | - | return retired | |
| 163 | - | } | |
| 164 | - | ||
| 165 | 140 | func moveSessionRetired(d *Daemon, sess *session) bool { | |
| 166 | 141 | if d == nil || sess == nil { | |
| 167 | 142 | return true | |
| @@ -172,24 +147,6 @@ func moveSessionRetired(d *Daemon, sess *session) bool { | |||
| 172 | 147 | return !live | |
| 173 | 148 | } | |
| 174 | 149 | ||
| 175 | - | func destinationTabIsActive(sess *session, target *tab) bool { | |
| 176 | - | if sess == nil || target == nil { | |
| 177 | - | return false | |
| 178 | - | } | |
| 179 | - | sess.mu.Lock() | |
| 180 | - | defer sess.mu.Unlock() | |
| 181 | - | return sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == target | |
| 182 | - | } | |
| 183 | - | ||
| 184 | - | func sessionClientIs(sess *session, client *attachedClient) bool { | |
| 185 | - | if sess == nil || client == nil { | |
| 186 | - | return false | |
| 187 | - | } | |
| 188 | - | sess.mu.Lock() | |
| 189 | - | defer sess.mu.Unlock() | |
| 190 | - | return sess.client == client | |
| 191 | - | } | |
| 192 | - | ||
| 193 | 150 | // retireEmptySessionAfterMove completes only source-owned lifecycle work. The | |
| 194 | 151 | // moved pane has already been removed from source.tabs, so stopInMemoryLifecycle | |
| 195 | 152 | // cannot cancel or close the transferred PTY. | |
M internal/usecase/daemon/move_persistence_test.go +1 -1
| @@ -177,7 +177,7 @@ func TestMoveTabPersistenceMatrix(t *testing.T) { | |||
| 177 | 177 | } | |
| 178 | 178 | d, source, client, _ := newManualSessionWithPTYs(t, ptys...) | |
| 179 | 179 | if tt.finalSource { | |
| 180 | - | source.client = nil | |
| 180 | + | clearAttachmentsForTest(source) | |
| 181 | 181 | client.setSession(nil) | |
| 182 | 182 | } | |
| 183 | 183 | source.incarnation = domain.IncarnationID{1} | |
M internal/usecase/daemon/move_picker.go +24 -13
| @@ -16,8 +16,11 @@ func (d *Daemon) movePickerSourceError(source moveSourceLocator) error { | |||
| 16 | 16 | } | |
| 17 | 17 | sess.mu.Lock() | |
| 18 | 18 | defer sess.mu.Unlock() | |
| 19 | - | if source.Client != nil && sess.client != source.Client { | |
| 20 | - | return domain.UserWarn(domain.NoticeSessionUnavailable, "Source client is no longer active.", errMovePaneInvalid) | |
| 19 | + | if source.AttachmentToken.ac != nil && !moveAttachmentTokenCurrentLocked(source.AttachmentToken, sess) { | |
| 20 | + | return domain.UserWarn(domain.NoticeSessionUnavailable, "Source attachment is no longer active.", errMovePaneInvalid) | |
| 21 | + | } | |
| 22 | + | if source.Attachment != nil && !attachmentRegisteredLocked(sess, source.Attachment) { | |
| 23 | + | return domain.UserWarn(domain.NoticeSessionUnavailable, "Source attachment is no longer active.", errMovePaneInvalid) | |
| 21 | 24 | } | |
| 22 | 25 | tb := findMoveTabLocked(sess, source.TabID) | |
| 23 | 26 | if tb == nil { | |
| @@ -49,10 +52,11 @@ type moveSessionLocator struct { | |||
| 49 | 52 | } | |
| 50 | 53 | ||
| 51 | 54 | type moveSourceLocator struct { | |
| 52 | - | Session moveSessionLocator | |
| 53 | - | TabID domain.TabStableID | |
| 54 | - | PaneID domain.PaneStableID | |
| 55 | - | Client *attachedClient | |
| 55 | + | Session moveSessionLocator | |
| 56 | + | TabID domain.TabStableID | |
| 57 | + | PaneID domain.PaneStableID | |
| 58 | + | Attachment *attachedClient | |
| 59 | + | AttachmentToken attachmentConnectionToken | |
| 56 | 60 | } | |
| 57 | 61 | ||
| 58 | 62 | // enterPickerForIntent returns errNoMoveDestination only for move intents. | |
| @@ -60,7 +64,10 @@ func (d *Daemon) enterPickerForIntent(sess *session, ac *attachedClient, intent | |||
| 60 | 64 | if intent != pickerNavigate && !sess.capabilities().yieldsMoves() { | |
| 61 | 65 | return errSessionCannotYieldMoves | |
| 62 | 66 | } | |
| 63 | - | model := d.newPickerModel(sess, intent, source, picker.SourceFilter{}) | |
| 67 | + | if source.Attachment != nil && source.AttachmentToken.ac == nil { | |
| 68 | + | source.AttachmentToken = sess.attachmentToken(source.Attachment, source.Attachment.transport()) | |
| 69 | + | } | |
| 70 | + | model := d.newPickerModel(sess, ac, intent, source, picker.SourceFilter{}) | |
| 64 | 71 | if intent != pickerNavigate { | |
| 65 | 72 | if _, ok := model.Selected(); !ok { | |
| 66 | 73 | return errNoMoveDestination | |
| @@ -90,6 +97,8 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc | |||
| 90 | 97 | return errMovePaneInvalid | |
| 91 | 98 | } | |
| 92 | 99 | return d.movePane(movePaneRequest{ | |
| 100 | + | Attachment: source.Attachment, | |
| 101 | + | AttachmentToken: source.AttachmentToken, | |
| 93 | 102 | Source: source.Session, | |
| 94 | 103 | SourceTabID: source.TabID, | |
| 95 | 104 | SourcePaneID: source.PaneID, | |
| @@ -98,9 +107,11 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc | |||
| 98 | 107 | }) | |
| 99 | 108 | case pickerMoveTab: | |
| 100 | 109 | return d.moveTab(moveTabRequest{ | |
| 101 | - | Source: source.Session, | |
| 102 | - | SourceTabID: source.TabID, | |
| 103 | - | Destination: destination, | |
| 110 | + | Attachment: source.Attachment, | |
| 111 | + | AttachmentToken: source.AttachmentToken, | |
| 112 | + | Source: source.Session, | |
| 113 | + | SourceTabID: source.TabID, | |
| 114 | + | Destination: destination, | |
| 104 | 115 | }) | |
| 105 | 116 | default: | |
| 106 | 117 | return errMovePaneInvalid | |
| @@ -121,10 +132,10 @@ func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*sess | |||
| 121 | 132 | return nil, nil | |
| 122 | 133 | } | |
| 123 | 134 | if intent == pickerMoveTab { | |
| 124 | - | if sess.active < 0 || sess.active >= len(sess.tabs) { | |
| 135 | + | if len(sess.tabs) == 0 { | |
| 125 | 136 | return nil, nil | |
| 126 | 137 | } | |
| 127 | - | return sess, sess.tabs[sess.active] | |
| 138 | + | return sess, sess.tabs[0] | |
| 128 | 139 | } | |
| 129 | 140 | if target.TabID != "" { | |
| 130 | 141 | for _, tb := range sess.tabs { | |
| @@ -170,7 +181,7 @@ func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions | |||
| 170 | 181 | } | |
| 171 | 182 | } | |
| 172 | 183 | rt.pickerMu.Unlock() | |
| 173 | - | model := d.newPickerModel(sess, intent, source, current) | |
| 184 | + | model := d.newPickerModel(sess, ac, intent, source, current) | |
| 174 | 185 | if rt.afterPickerRefreshBuild != nil { | |
| 175 | 186 | rt.afterPickerRefreshBuild(model) | |
| 176 | 187 | } | |
M internal/usecase/daemon/move_picker_test.go +7 -66
| @@ -1,7 +1,6 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "sync/atomic" | |
| 5 | 4 | "testing" | |
| 6 | 5 | "time" | |
| 7 | 6 | ||
| @@ -36,7 +35,7 @@ func setupMovePickerSessionsWithClock(t *testing.T, clock ports.Clock, extraDest | |||
| 36 | 35 | releases = append(releases, releaseDest) | |
| 37 | 36 | destinationTab := newTabWithStableID("destination-tab", "destination-pane", destPTY, domain.Size{Cols: 80, Rows: 23}) | |
| 38 | 37 | publishTiledPaneOwners(source, sourceTab) | |
| 39 | - | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{destinationTab}, active: 0} | |
| 38 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{destinationTab}} | |
| 40 | 39 | publishTiledPaneOwners(destination, destinationTab) | |
| 41 | 40 | for range extraDestinationTabs { | |
| 42 | 41 | extraPTY, releaseExtra := newBlockingPTY(t) | |
| @@ -68,7 +67,7 @@ func TestPaletteMovePaneCapturesSourceAndOpensPicker(t *testing.T) { | |||
| 68 | 67 | require.Equal(t, moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, captured.Session) | |
| 69 | 68 | require.Equal(t, domain.TabStableID("source-tab"), captured.TabID) | |
| 70 | 69 | require.Equal(t, domain.PaneStableID("source-pane"), captured.PaneID) | |
| 71 | - | require.Same(t, ac, captured.Client) | |
| 70 | + | require.Same(t, ac, captured.Attachment) | |
| 72 | 71 | } | |
| 73 | 72 | ||
| 74 | 73 | func TestPaletteMoveTabCapturesActiveTabAndOpensPicker(t *testing.T) { | |
| @@ -87,7 +86,7 @@ func TestPaletteMoveTabCapturesActiveTabAndOpensPicker(t *testing.T) { | |||
| 87 | 86 | require.Equal(t, pickerMoveTab, intent) | |
| 88 | 87 | require.Equal(t, domain.TabStableID("active-tab"), captured.TabID) | |
| 89 | 88 | require.Equal(t, domain.PaneStableID(""), captured.PaneID) | |
| 90 | - | require.Same(t, ac, captured.Client) | |
| 89 | + | require.Same(t, ac, captured.Attachment) | |
| 91 | 90 | } | |
| 92 | 91 | ||
| 93 | 92 | func TestPaletteMoveWithoutDestinationShowsToastAndNoPicker(t *testing.T) { | |
| @@ -110,7 +109,7 @@ func TestMovePickerEnterCommitsMovePane(t *testing.T) { | |||
| 110 | 109 | d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0) | |
| 111 | 110 | defer releaseAll(releases) | |
| 112 | 111 | source.mu.Lock() | |
| 113 | - | source.client = nil | |
| 112 | + | clearAttachmentsForTestLocked(source) | |
| 114 | 113 | source.mu.Unlock() | |
| 115 | 114 | sourceTab := source.tabs[0] | |
| 116 | 115 | moved := sourceTab.focusedPane() | |
| @@ -131,7 +130,7 @@ func TestMovePickerEnterCommitsMovePaneViaSharedAPI(t *testing.T) { | |||
| 131 | 130 | d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0) | |
| 132 | 131 | defer releaseAll(releases) | |
| 133 | 132 | source.mu.Lock() | |
| 134 | - | source.client = nil | |
| 133 | + | clearAttachmentsForTestLocked(source) | |
| 135 | 134 | source.mu.Unlock() | |
| 136 | 135 | sourceTab := source.tabs[0] | |
| 137 | 136 | moved := sourceTab.focusedPane() | |
| @@ -158,7 +157,7 @@ func TestMovePickerEnterCommitsMoveTab(t *testing.T) { | |||
| 158 | 157 | d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0) | |
| 159 | 158 | defer releaseAll(releases) | |
| 160 | 159 | source.mu.Lock() | |
| 161 | - | source.client = nil | |
| 160 | + | clearAttachmentsForTestLocked(source) | |
| 162 | 161 | source.mu.Unlock() | |
| 163 | 162 | movedTab := source.tabs[0] | |
| 164 | 163 | movedTab.stableID = "moved-tab" | |
| @@ -257,64 +256,6 @@ func TestMovePickerStaleSourceTabReportsPreciseFeedback(t *testing.T) { | |||
| 257 | 256 | require.Equal(t, "Tab no longer exists.", history[0].Message) | |
| 258 | 257 | } | |
| 259 | 258 | ||
| 260 | - | func TestMovePickerSourceClientReplacementInvalidatesAction(t *testing.T) { | |
| 261 | - | d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0) | |
| 262 | - | defer releaseAll(releases) | |
| 263 | - | ||
| 264 | - | require.NoError(t, paletteExec{d: d, sess: source, ac: ac}.OpenMovePanePicker()) | |
| 265 | - | replacement := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: ac.size} | |
| 266 | - | replacement.initOverlays() | |
| 267 | - | replacement.setSession(source) | |
| 268 | - | source.mu.Lock() | |
| 269 | - | source.client = replacement | |
| 270 | - | source.mu.Unlock() | |
| 271 | - | ||
| 272 | - | d.handlePickerInput(ac, []byte("\r")) | |
| 273 | - | ||
| 274 | - | require.True(t, ac.overlays.pickerActive()) | |
| 275 | - | require.Len(t, source.tabs, 1) | |
| 276 | - | require.Len(t, destination.tabs, 1) | |
| 277 | - | history := d.notices.history() | |
| 278 | - | require.NotEmpty(t, history) | |
| 279 | - | require.Equal(t, "Source client is no longer active.", history[0].Message) | |
| 280 | - | } | |
| 281 | - | ||
| 282 | - | func TestMovePickerFinalSourceClosesBeforeSnatchFinalization(t *testing.T) { | |
| 283 | - | d, source, follower, destination, _, releases := setupMovePickerSessions(t, 0) | |
| 284 | - | defer releaseAll(releases) | |
| 285 | - | follower.replaceTransport(&closeTrackingTransport{}) | |
| 286 | - | require.NotNil(t, d.attachCoordinator(source, nil, follower, true)) | |
| 287 | - | ||
| 288 | - | displacedTransport := &closeTrackingTransport{} | |
| 289 | - | displaced := &attachedClient{tr: displacedTransport, output: newOutputStateStream(), size: follower.size} | |
| 290 | - | displaced.initOverlays() | |
| 291 | - | displaced.setSession(destination) | |
| 292 | - | destination.mu.Lock() | |
| 293 | - | destination.client = displaced | |
| 294 | - | destination.mu.Unlock() | |
| 295 | - | require.NotNil(t, d.attachCoordinator(destination, nil, displaced, true)) | |
| 296 | - | ||
| 297 | - | var pickerClosed atomic.Bool | |
| 298 | - | d.afterDisplacedCleanupStarted = func() { | |
| 299 | - | require.False(t, follower.overlays.pickerActive(), "picker must close before snatch finalization") | |
| 300 | - | pickerClosed.Store(true) | |
| 301 | - | } | |
| 302 | - | defer func() { d.afterDisplacedCleanupStarted = nil }() | |
| 303 | - | ||
| 304 | - | require.NoError(t, d.enterPickerForIntent(source, follower, pickerMovePane, moveSourceLocator{ | |
| 305 | - | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 306 | - | TabID: "source-tab", PaneID: "source-pane", | |
| 307 | - | })) | |
| 308 | - | d.handlePickerInput(follower, []byte("\r")) | |
| 309 | - | d.attachmentCleanupWg.Wait() | |
| 310 | - | ||
| 311 | - | require.True(t, pickerClosed.Load()) | |
| 312 | - | require.False(t, follower.overlays.pickerActive()) | |
| 313 | - | frames := displacedTransport.Sends() | |
| 314 | - | require.NotEmpty(t, frames) | |
| 315 | - | require.Equal(t, ports.MsgOutput, frames[0].Type) | |
| 316 | - | } | |
| 317 | - | ||
| 318 | 259 | func TestMovePickerRefreshCloseKeepsReplacementPicker(t *testing.T) { | |
| 319 | 260 | d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0) | |
| 320 | 261 | defer releaseAll(releases) | |
| @@ -343,7 +284,7 @@ func TestMovePickerRefreshCloseKeepsReplacementPicker(t *testing.T) { | |||
| 343 | 284 | t.Fatal("timed out waiting for move-picker refresh rebuild") | |
| 344 | 285 | } | |
| 345 | 286 | ||
| 346 | - | replacement := d.newPickerModel(source, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | |
| 287 | + | replacement := d.newPickerModel(source, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | |
| 347 | 288 | d.publishPicker(source, ac, replacement, pickerNavigate, moveSourceLocator{}) | |
| 348 | 289 | close(allowClose) | |
| 349 | 290 | select { | |
M internal/usecase/daemon/move_stale_destination_test.go +1 -1
| @@ -18,7 +18,7 @@ func TestMovePaneRejectsStaleDestinationIncarnationWithoutMutation(t *testing.T) | |||
| 18 | 18 | sourceTab := source.tabs[0] | |
| 19 | 19 | sourceTab.stableID = "source-tab" | |
| 20 | 20 | moved := sourceTab.focusedPane() | |
| 21 | - | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0} | |
| 21 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}} | |
| 22 | 22 | destinationTab := destination.tabs[0] | |
| 23 | 23 | publishTiledPaneOwners(destination, destinationTab) | |
| 24 | 24 | d.mu.Lock() | |
M internal/usecase/daemon/move_tab.go +65 -145
| @@ -10,8 +10,8 @@ import ( | |||
| 10 | 10 | type moveTabAdmission struct { | |
| 11 | 11 | tab *tab | |
| 12 | 12 | sourceIndex int | |
| 13 | - | sourceClient *attachedClient | |
| 14 | - | sourceSnatched []*attachedClient | |
| 13 | + | sourceAttachments []*attachedClient | |
| 14 | + | sourceTransports map[*attachedClient]transportSnapshot | |
| 15 | 15 | layoutGeneration uint64 | |
| 16 | 16 | floatingState floatingState | |
| 17 | 17 | floatingPane *pane | |
| @@ -27,14 +27,9 @@ type moveTabCommit struct { | |||
| 27 | 27 | source *session | |
| 28 | 28 | destination *session | |
| 29 | 29 | admission moveTabAdmission | |
| 30 | - | handoffFrozen bool | |
| 31 | - | sourceRolesFrozen bool | |
| 32 | - | frozenRoles frozenRoleEffectGates | |
| 33 | - | handoffReq attachmentTransitionRequest | |
| 34 | - | handoffPublication *attachmentPublication | |
| 35 | - | handoffResult attachmentTransitionResult | |
| 30 | + | sourceEffectsFrozen bool | |
| 31 | + | frozenEffects frozenAttachmentEffectGates | |
| 36 | 32 | syncCleanup syncTimerCleanup | |
| 37 | - | sourceCleanupToken attachmentRoleToken | |
| 38 | 33 | oldTabCancel context.CancelFunc | |
| 39 | 34 | sourceEmpty bool | |
| 40 | 35 | retiredParked []parkedAttachmentRetirement | |
| @@ -46,27 +41,6 @@ type moveTabCommit struct { | |||
| 46 | 41 | err error | |
| 47 | 42 | } | |
| 48 | 43 | ||
| 49 | - | func (d *Daemon) snapshotSnatchedRoleParticipants(sess *session, admitted []*attachedClient) ([]*attachedClient, []roleTransportInterrupt) { | |
| 50 | - | if sess == nil || len(admitted) == 0 { | |
| 51 | - | return nil, nil | |
| 52 | - | } | |
| 53 | - | d.mu.Lock() | |
| 54 | - | defer d.mu.Unlock() | |
| 55 | - | sess.mu.Lock() | |
| 56 | - | defer sess.mu.Unlock() | |
| 57 | - | clients := append([]*attachedClient(nil), admitted...) | |
| 58 | - | interrupts := make([]roleTransportInterrupt, 0, len(clients)) | |
| 59 | - | for _, ac := range clients { | |
| 60 | - | if ac == nil { | |
| 61 | - | continue | |
| 62 | - | } | |
| 63 | - | if transport := ac.transportSnapshot(); transport.transport != nil { | |
| 64 | - | interrupts = append(interrupts, roleTransportInterrupt{ac: ac, transport: transport}) | |
| 65 | - | } | |
| 66 | - | } | |
| 67 | - | return clients, interrupts | |
| 68 | - | } | |
| 69 | - | ||
| 70 | 44 | func (d *Daemon) moveTab(req moveTabRequest) (result error) { | |
| 71 | 45 | defer func() { result = normalizeMoveRejection(result) }() | |
| 72 | 46 | if d == nil || req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID { | |
| @@ -82,6 +56,20 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) { | |||
| 82 | 56 | if source == destination { | |
| 83 | 57 | return errMovePaneInvalid | |
| 84 | 58 | } | |
| 59 | + | // Dispatch admission must precede lifecycle reservation. A final close holds | |
| 60 | + | // dispatchMu while it waits for teardown ownership; reserving first would let | |
| 61 | + | // that close wait on a move which is itself blocked on dispatchMu. | |
| 62 | + | if d.beforeMoveDispatch != nil { | |
| 63 | + | d.beforeMoveDispatch() | |
| 64 | + | } | |
| 65 | + | unlockDispatch := lockMoveDispatch(source, destination) | |
| 66 | + | dispatchHeld := true | |
| 67 | + | defer func() { | |
| 68 | + | if dispatchHeld { | |
| 69 | + | unlockDispatch() | |
| 70 | + | } | |
| 71 | + | }() | |
| 72 | + | ||
| 85 | 73 | reservation, err := d.reserveMoveLifecycles(source, destination) | |
| 86 | 74 | if err != nil { | |
| 87 | 75 | return errMoveStaleTarget | |
| @@ -95,13 +83,6 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) { | |||
| 95 | 83 | if d.afterMoveLifecycleReserved != nil { | |
| 96 | 84 | d.afterMoveLifecycleReserved() | |
| 97 | 85 | } | |
| 98 | - | unlockDispatch := lockMoveDispatch(source, destination) | |
| 99 | - | dispatchHeld := true | |
| 100 | - | defer func() { | |
| 101 | - | if dispatchHeld { | |
| 102 | - | unlockDispatch() | |
| 103 | - | } | |
| 104 | - | }() | |
| 105 | 86 | ||
| 106 | 87 | admission, err := d.snapshotMoveTabAdmission(req, source, destination) | |
| 107 | 88 | if err != nil { | |
| @@ -111,59 +92,35 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) { | |||
| 111 | 92 | d.afterMoveTabSourceSnapshot() | |
| 112 | 93 | } | |
| 113 | 94 | commit := moveTabCommit{req: req, source: source, destination: destination, admission: *admission} | |
| 114 | - | var frozen frozenRoleEffectGates | |
| 115 | - | if admission.finalSource && admission.sourceClient != nil { | |
| 116 | - | transport := admission.sourceClient.transportSnapshot() | |
| 117 | - | token := source.attachmentToken(admission.sourceClient, transport.transport) | |
| 118 | - | if token.role != attachmentActive || token.transport.transport == nil { | |
| 119 | - | return errMovePaneInvalid | |
| 120 | - | } | |
| 121 | - | commit.handoffReq = attachmentTransitionRequest{ | |
| 122 | - | source: source, target: destination, next: admission.sourceClient, | |
| 123 | - | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 124 | - | expectedTransport: token.transport, sourceToken: &token, | |
| 125 | - | expectedSourceTab: admission.tab, transferExpectedSourceTab: true, ready: true, action: "move-tab", | |
| 126 | - | } | |
| 127 | - | var participants attachmentTransitionParticipants | |
| 128 | - | commit.handoffReq, participants, err = d.snapshotAttachmentTransition(commit.handoffReq) | |
| 129 | - | if err != nil { | |
| 130 | - | return err | |
| 131 | - | } | |
| 132 | - | snatchedClients, snatchedInterrupts := d.snapshotSnatchedRoleParticipants(source, admission.sourceSnatched) | |
| 133 | - | participants.clients = append(participants.clients, snatchedClients...) | |
| 134 | - | participants.interrupts = append(participants.interrupts, snatchedInterrupts...) | |
| 135 | - | frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) { | |
| 136 | - | if d.afterRoleEffectGateFrozen != nil { | |
| 137 | - | d.afterRoleEffectGateFrozen("move-tab", ac) | |
| 95 | + | var frozen frozenAttachmentEffectGates | |
| 96 | + | if admission.finalSource && len(admission.sourceAttachments) != 0 { | |
| 97 | + | interrupts := make([]attachmentTransportInterrupt, 0, len(admission.sourceAttachments)) | |
| 98 | + | for _, ac := range admission.sourceAttachments { | |
| 99 | + | if transport := admission.sourceTransports[ac]; transport.transport != nil { | |
| 100 | + | interrupts = append(interrupts, attachmentTransportInterrupt{ac: ac, transport: transport}) | |
| 138 | 101 | } | |
| 139 | - | }}, participants.clients...) | |
| 140 | - | if !frozen.acquired || !frozen.drained { | |
| 141 | - | frozen.unfreeze() | |
| 142 | - | return errMovePaneInvalid | |
| 143 | 102 | } | |
| 144 | - | commit.handoffFrozen = true | |
| 145 | - | commit.sourceRolesFrozen = true | |
| 146 | - | commit.handoffReq.roleEffectsFrozen = true | |
| 147 | - | } else if admission.finalSource && len(admission.sourceSnatched) > 0 { | |
| 148 | - | clients, interrupts := d.snapshotSnatchedRoleParticipants(source, admission.sourceSnatched) | |
| 149 | - | frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: interrupts, nonblocking: true}, clients...) | |
| 103 | + | frozen = freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) { | |
| 104 | + | if d.afterAttachmentEffectGateFrozen != nil { | |
| 105 | + | d.afterAttachmentEffectGateFrozen("move-tab", ac) | |
| 106 | + | } | |
| 107 | + | }}, admission.sourceAttachments...) | |
| 150 | 108 | if !frozen.acquired || !frozen.drained { | |
| 151 | 109 | frozen.unfreeze() | |
| 152 | 110 | return errMovePaneInvalid | |
| 153 | 111 | } | |
| 154 | - | commit.sourceRolesFrozen = true | |
| 112 | + | commit.sourceEffectsFrozen = true | |
| 155 | 113 | } | |
| 156 | - | commit.frozenRoles = frozen | |
| 157 | - | rolesFrozen := commit.sourceRolesFrozen | |
| 114 | + | commit.frozenEffects = frozen | |
| 115 | + | effectsFrozen := commit.sourceEffectsFrozen | |
| 158 | 116 | defer func() { | |
| 159 | - | if rolesFrozen { | |
| 117 | + | if effectsFrozen { | |
| 160 | 118 | frozen.unfreeze() | |
| 161 | 119 | } | |
| 162 | 120 | }() | |
| 163 | 121 | ||
| 164 | 122 | fences := newMoveTabResizeFences(source, destination, admission.tab) | |
| 165 | 123 | if !fences.acquire(func() bool { return commit.publishLocked(d, fences.panes) }) { | |
| 166 | - | commit.releasePublication() | |
| 167 | 124 | if commit.err != nil { | |
| 168 | 125 | return commit.err | |
| 169 | 126 | } | |
| @@ -177,15 +134,13 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) { | |||
| 177 | 134 | destinationTab: admission.tab, | |
| 178 | 135 | movedPane: firstMovePane(admission.panes), | |
| 179 | 136 | movedPanes: admission.panes, | |
| 180 | - | sourceCleanupToken: commit.sourceCleanupToken, | |
| 181 | - | handoffResult: commit.handoffResult, | |
| 137 | + | sourceAttachments: admission.sourceAttachments, | |
| 182 | 138 | syncCleanup: commit.syncCleanup, | |
| 183 | - | frozenRoles: frozen, | |
| 184 | - | rolesFrozen: rolesFrozen, | |
| 139 | + | frozenEffects: frozen, | |
| 140 | + | effectsFrozen: effectsFrozen, | |
| 185 | 141 | unlockDispatch: unlockDispatch, | |
| 186 | 142 | reservation: reservation, | |
| 187 | 143 | oldTabCancel: commit.oldTabCancel, | |
| 188 | - | sourceTabWasActive: !commit.sourceEmpty, | |
| 189 | 144 | sourceTabRemoved: true, | |
| 190 | 145 | sourceEmpty: commit.sourceEmpty, | |
| 191 | 146 | retiredParked: commit.retiredParked, | |
| @@ -195,7 +150,7 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) { | |||
| 195 | 150 | destinationMetadata: commit.destMetadata, | |
| 196 | 151 | destinationMetadataValid: commit.destMetadataValid, | |
| 197 | 152 | } | |
| 198 | - | rolesFrozen = false | |
| 153 | + | effectsFrozen = false | |
| 199 | 154 | dispatchHeld = false | |
| 200 | 155 | reservationHeld = false | |
| 201 | 156 | postcommit.execute(d) | |
| @@ -213,11 +168,17 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio | |||
| 213 | 168 | if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) { | |
| 214 | 169 | return nil, errMoveStaleTarget | |
| 215 | 170 | } | |
| 171 | + | if req.Attachment != nil && !attachmentRegisteredLocked(source, req.Attachment) { | |
| 172 | + | return nil, errMoveStaleTarget | |
| 173 | + | } | |
| 174 | + | if req.AttachmentToken.ac != nil && (req.AttachmentToken.ac != req.Attachment || !moveAttachmentTokenCurrentLocked(req.AttachmentToken, source)) { | |
| 175 | + | return nil, errMoveStaleTarget | |
| 176 | + | } | |
| 216 | 177 | moved := findMoveTabLocked(source, req.SourceTabID) | |
| 217 | - | if moved == nil || destination.active < 0 || destination.active >= len(destination.tabs) { | |
| 178 | + | if moved == nil || len(destination.tabs) == 0 { | |
| 218 | 179 | return nil, errMoveStaleTarget | |
| 219 | 180 | } | |
| 220 | - | destinationActive := destination.tabs[destination.active] | |
| 181 | + | destinationActive := destination.tabs[0] | |
| 221 | 182 | destinationActive.mu.Lock() | |
| 222 | 183 | destinationSize := destinationActive.size | |
| 223 | 184 | destinationActive.mu.Unlock() | |
| @@ -240,9 +201,13 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio | |||
| 240 | 201 | panes = append(panes, moved.floating.pane) | |
| 241 | 202 | } | |
| 242 | 203 | idx := indexMoveTabLocked(source, moved) | |
| 204 | + | attachments := source.snapshotAttachmentsLocked() | |
| 205 | + | transports := make(map[*attachedClient]transportSnapshot, len(attachments)) | |
| 206 | + | for _, ac := range attachments { | |
| 207 | + | transports[ac] = ac.transportSnapshot() | |
| 208 | + | } | |
| 243 | 209 | return &moveTabAdmission{ | |
| 244 | - | tab: moved, sourceIndex: idx, | |
| 245 | - | sourceClient: source.client, sourceSnatched: snapshotMoveSnatchedLocked(source), | |
| 210 | + | tab: moved, sourceIndex: idx, sourceAttachments: attachments, sourceTransports: transports, | |
| 246 | 211 | layoutGeneration: moved.layoutGeneration, floatingState: moved.floating.state, | |
| 247 | 212 | floatingPane: moved.floating.pane, floatingGeneration: moved.floating.generation, | |
| 248 | 213 | panes: panes, destinationActive: destinationActive, destinationSize: destinationSize, | |
| @@ -250,13 +215,6 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio | |||
| 250 | 215 | }, nil | |
| 251 | 216 | } | |
| 252 | 217 | ||
| 253 | - | func (c *moveTabCommit) releasePublication() { | |
| 254 | - | if c.handoffPublication != nil { | |
| 255 | - | c.handoffPublication.unlockCoordinators() | |
| 256 | - | c.handoffPublication = nil | |
| 257 | - | } | |
| 258 | - | } | |
| 259 | - | ||
| 260 | 218 | func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool { | |
| 261 | 219 | d.mu.Lock() | |
| 262 | 220 | if d.closing || d.sessions[c.source.id] != c.source || d.sessions[c.destination.id] != c.destination { | |
| @@ -271,39 +229,21 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool { | |||
| 271 | 229 | if !moveSessionLocatorCurrentLocked(c.source, c.req.Source) || !moveSessionLocatorCurrentLocked(c.destination, c.req.Destination) { | |
| 272 | 230 | return false | |
| 273 | 231 | } | |
| 232 | + | if c.req.Attachment != nil && !attachmentRegisteredLocked(c.source, c.req.Attachment) { | |
| 233 | + | return false | |
| 234 | + | } | |
| 235 | + | if c.req.AttachmentToken.ac != nil && (c.req.AttachmentToken.ac != c.req.Attachment || !moveAttachmentTokenCurrentLocked(c.req.AttachmentToken, c.source)) { | |
| 236 | + | return false | |
| 237 | + | } | |
| 274 | 238 | moved := c.admission.tab | |
| 275 | 239 | if indexMoveTabLocked(c.source, moved) != c.admission.sourceIndex || findMoveTabLocked(c.destination, c.req.SourceTabID) != nil || | |
| 276 | 240 | len(c.source.tabs) == 1 != c.admission.finalSource { | |
| 277 | 241 | return false | |
| 278 | 242 | } | |
| 279 | - | // Attachment validation inspects the expected source tab and therefore must | |
| 280 | - | // precede moved.mu. The resize fences and ordered session locks keep topology | |
| 281 | - | // stable until the tab/pane checks below complete. | |
| 282 | - | if c.handoffFrozen { | |
| 283 | - | if c.source.client != c.admission.sourceClient || !sameMoveSnatchedLocked(c.source, c.admission.sourceSnatched) { | |
| 284 | - | return false | |
| 285 | - | } | |
| 286 | - | publication, err := d.validateAttachmentTransitionPrelocked(c.handoffReq) | |
| 287 | - | if err != nil { | |
| 288 | - | c.err = err | |
| 289 | - | return false | |
| 290 | - | } | |
| 291 | - | c.handoffPublication = publication | |
| 292 | - | defer c.releasePublication() | |
| 293 | - | } | |
| 294 | - | var retirement frozenMoveAttachmentRetirement | |
| 295 | - | if c.admission.finalSource { | |
| 296 | - | if c.source.client != c.admission.sourceClient || !sameMoveSnatchedLocked(c.source, c.admission.sourceSnatched) || | |
| 297 | - | (c.admission.sourceClient != nil) != c.handoffFrozen { | |
| 298 | - | return false | |
| 299 | - | } | |
| 300 | - | var retirementOK bool | |
| 301 | - | retirement, retirementOK = prepareFrozenMoveAttachmentRetirementLocked(c.source, c.admission.sourceSnatched, c.frozenRoles) | |
| 302 | - | if !retirementOK { | |
| 303 | - | return false | |
| 304 | - | } | |
| 243 | + | if c.admission.finalSource && !sameMoveAttachmentsLocked(c.source, c.admission.sourceAttachments) { | |
| 244 | + | return false | |
| 305 | 245 | } | |
| 306 | - | if c.destination.active < 0 || c.destination.active >= len(c.destination.tabs) || c.destination.tabs[c.destination.active] != c.admission.destinationActive { | |
| 246 | + | if len(c.destination.tabs) == 0 || c.destination.tabs[0] != c.admission.destinationActive { | |
| 307 | 247 | return false | |
| 308 | 248 | } | |
| 309 | 249 | c.admission.destinationActive.mu.Lock() | |
| @@ -329,17 +269,10 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool { | |||
| 329 | 269 | } | |
| 330 | 270 | ||
| 331 | 271 | idx := c.admission.sourceIndex | |
| 332 | - | c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...) | |
| 333 | - | if len(c.source.tabs) == 0 { | |
| 334 | - | c.source.active = -1 | |
| 335 | - | } else if c.source.active > idx { | |
| 336 | - | c.source.active-- | |
| 337 | - | } else if c.source.active == idx { | |
| 338 | - | if idx >= len(c.source.tabs) { | |
| 339 | - | idx = len(c.source.tabs) - 1 | |
| 340 | - | } | |
| 341 | - | c.source.active = idx | |
| 272 | + | if len(c.source.tabs) > 1 { | |
| 273 | + | c.source.prepareAttachmentViewsForRemovedTabLocked(moved, idx) | |
| 342 | 274 | } | |
| 275 | + | c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...) | |
| 343 | 276 | c.destination.tabs = append(c.destination.tabs, moved) | |
| 344 | 277 | moved.size = c.admission.destinationSize | |
| 345 | 278 | moved.bumpLayoutGenerationLocked() | |
| @@ -366,26 +299,13 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool { | |||
| 366 | 299 | if d.beforeMoveTabCommit != nil { | |
| 367 | 300 | d.beforeMoveTabCommit() | |
| 368 | 301 | } | |
| 369 | - | if c.handoffPublication != nil { | |
| 370 | - | c.destination.active = len(c.destination.tabs) - 1 | |
| 371 | - | c.handoffResult = d.publishAttachmentTransitionPrelocked(c.handoffPublication) | |
| 372 | - | c.handoffPublication.unlockCoordinators() | |
| 373 | - | c.handoffPublication = nil | |
| 374 | - | } | |
| 375 | - | // Attachment publication retains source/destination coordinator locks. | |
| 376 | - | // Release those first, then migrate synchronized-output batches exactly as | |
| 377 | - | // Move pane does, while pane parsing remains fenced. | |
| 378 | 302 | for _, change := range ownerChanges { | |
| 379 | 303 | c.syncCleanup.append(d.migratePaneSyncOwnerLocked(change.pane, change.oldOwner, change.newOwner)) | |
| 380 | 304 | } | |
| 381 | - | if c.handoffResult.published.ac != nil { | |
| 382 | - | c.sourceCleanupToken = c.handoffResult.published | |
| 383 | - | } else if c.source.client != nil { | |
| 384 | - | c.sourceCleanupToken = c.source.attachmentTokenLocked(c.source.client) | |
| 385 | - | } | |
| 386 | 305 | if len(c.source.tabs) == 0 { | |
| 387 | 306 | d.unregisterSessionLocked(c.source) | |
| 388 | - | c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement) | |
| 307 | + | c.retiredAttachments = detachMoveAttachmentsLocked(c.source, c.admission.sourceTransports) | |
| 308 | + | c.source.tabs = nil | |
| 389 | 309 | d.purgeParkingForSessionLocked(c.source) | |
| 390 | 310 | c.retiredParked = d.purgeParkedForSessionLocked(c.source) | |
| 391 | 311 | } | |
D internal/usecase/daemon/move_tab_handoff_test.go +0 -339
| @@ -1,339 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "testing" | |
| 5 | - | ||
| 6 | - | "github.com/stretchr/testify/require" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/vev/internal/domain" | |
| 9 | - | "github.com/bnema/vev/internal/ports" | |
| 10 | - | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 11 | - | ) | |
| 12 | - | ||
| 13 | - | func setupMoveTabFinalSourceHandoff(t *testing.T) (*Daemon, *session, *attachedClient, *session, *attachedClient, *closeTrackingTransport, *tab) { | |
| 14 | - | t.Helper() | |
| 15 | - | d, source, follower, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 16 | - | follower.replaceTransport(&closeTrackingTransport{}) | |
| 17 | - | moved := source.tabs[0] | |
| 18 | - | moved.stableID = "moved-tab" | |
| 19 | - | require.NotNil(t, d.attachCoordinator(source, nil, follower, true)) | |
| 20 | - | ||
| 21 | - | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 22 | - | displacedTransport := &closeTrackingTransport{} | |
| 23 | - | displaced := &attachedClient{ | |
| 24 | - | tr: displacedTransport, output: newOutputStateStream(), size: follower.size, | |
| 25 | - | } | |
| 26 | - | displaced.initOverlays() | |
| 27 | - | displaced.setSession(destination) | |
| 28 | - | destination.mu.Lock() | |
| 29 | - | destination.client = displaced | |
| 30 | - | destination.mu.Unlock() | |
| 31 | - | require.NotNil(t, d.attachCoordinator(destination, nil, displaced, true)) | |
| 32 | - | ||
| 33 | - | return d, source, follower, destination, displaced, displacedTransport, moved | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | func moveTabFinalSource(t *testing.T, d *Daemon, source, destination *session, moved *tab) { | |
| 37 | - | t.Helper() | |
| 38 | - | require.NoError(t, d.moveTab(moveTabRequest{ | |
| 39 | - | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 40 | - | SourceTabID: domain.TabStableID(moved.stableID), | |
| 41 | - | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 42 | - | })) | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | func detachedWithReason(frames []ports.Frame, reason uint8) bool { | |
| 46 | - | for _, frame := range frames { | |
| 47 | - | if frame.Type != ports.MsgDetached { | |
| 48 | - | continue | |
| 49 | - | } | |
| 50 | - | detached, err := ports.UnmarshalDetached(frame.Payload) | |
| 51 | - | if err == nil && detached.Reason == reason { | |
| 52 | - | return true | |
| 53 | - | } | |
| 54 | - | } | |
| 55 | - | return false | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | func TestMoveTabFinalSourceSnatchesDestinationActiveClient(t *testing.T) { | |
| 59 | - | d, source, follower, destination, displaced, displacedTransport, moved := setupMoveTabFinalSourceHandoff(t) | |
| 60 | - | ||
| 61 | - | moveTabFinalSource(t, d, source, destination, moved) | |
| 62 | - | d.attachmentCleanupWg.Wait() | |
| 63 | - | ||
| 64 | - | destination.mu.Lock() | |
| 65 | - | require.Same(t, follower, destination.client) | |
| 66 | - | require.Contains(t, destination.snatched, displaced) | |
| 67 | - | require.Same(t, moved, destination.tabs[destination.active]) | |
| 68 | - | destination.mu.Unlock() | |
| 69 | - | require.Same(t, destination, follower.currentSession()) | |
| 70 | - | require.Equal(t, attachmentActive, destination.attachmentRole(follower)) | |
| 71 | - | require.Equal(t, attachmentSnatched, destination.attachmentRole(displaced)) | |
| 72 | - | ||
| 73 | - | frames := displacedTransport.Sends() | |
| 74 | - | require.Len(t, frames, 1) | |
| 75 | - | require.Equal(t, ports.MsgOutput, frames[0].Type) | |
| 76 | - | output, err := ports.UnmarshalOutput(frames[0].Payload) | |
| 77 | - | require.NoError(t, err) | |
| 78 | - | require.Contains(t, string(output.Data), "Session snatched") | |
| 79 | - | require.False(t, detachedWithReason(frames, ports.ReasonReplaced)) | |
| 80 | - | require.False(t, displacedTransport.Closed()) | |
| 81 | - | } | |
| 82 | - | ||
| 83 | - | func TestMoveTabFinalSourcePreservesDestinationSnatchedWaiters(t *testing.T) { | |
| 84 | - | d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 85 | - | ||
| 86 | - | waitingTransport := &closeTrackingTransport{} | |
| 87 | - | waiting := &attachedClient{tr: waitingTransport, output: newOutputStateStream(), size: follower.size} | |
| 88 | - | waiting.initOverlays() | |
| 89 | - | waiting.setSession(destination) | |
| 90 | - | destination.mu.Lock() | |
| 91 | - | destination.addSnatchedLocked(waiting) | |
| 92 | - | destination.mu.Unlock() | |
| 93 | - | ||
| 94 | - | moveTabFinalSource(t, d, source, destination, moved) | |
| 95 | - | d.attachmentCleanupWg.Wait() | |
| 96 | - | ||
| 97 | - | destination.mu.Lock() | |
| 98 | - | require.Contains(t, destination.snatched, displaced) | |
| 99 | - | require.Contains(t, destination.snatched, waiting) | |
| 100 | - | require.Len(t, destination.snatched, 2) | |
| 101 | - | destination.mu.Unlock() | |
| 102 | - | requireSingleOwnerAndLease(t, destination, follower, displaced, waiting) | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | func TestMoveTabFinalSourceRetiresSourceParkedAndSnatchedClients(t *testing.T) { | |
| 106 | - | d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 107 | - | ||
| 108 | - | parkedTransport := &closeTrackingTransport{} | |
| 109 | - | parked := &attachedClient{tr: parkedTransport, output: newOutputStateStream(), size: follower.size, resumeCapable: true} | |
| 110 | - | parked.initOverlays() | |
| 111 | - | require.True(t, d.parkAttachment(source, parked)) | |
| 112 | - | ||
| 113 | - | sourceSnatchedTransport := &closeTrackingTransport{} | |
| 114 | - | sourceSnatched := &attachedClient{tr: sourceSnatchedTransport, output: newOutputStateStream(), size: follower.size} | |
| 115 | - | sourceSnatched.initOverlays() | |
| 116 | - | sourceSnatched.setSession(source) | |
| 117 | - | source.mu.Lock() | |
| 118 | - | source.addSnatchedLocked(sourceSnatched) | |
| 119 | - | source.mu.Unlock() | |
| 120 | - | ||
| 121 | - | moveTabFinalSource(t, d, source, destination, moved) | |
| 122 | - | d.attachmentCleanupWg.Wait() | |
| 123 | - | d.waitNotifies() | |
| 124 | - | ||
| 125 | - | d.mu.Lock() | |
| 126 | - | require.Empty(t, d.parked) | |
| 127 | - | d.mu.Unlock() | |
| 128 | - | require.True(t, parkedTransport.Closed()) | |
| 129 | - | require.True(t, sourceSnatchedTransport.Closed()) | |
| 130 | - | require.Nil(t, sourceSnatched.currentSession()) | |
| 131 | - | } | |
| 132 | - | ||
| 133 | - | func TestMoveTabFinalSourceClearsFollowerCopyCapturePreservesDestinationWaiterOverlays(t *testing.T) { | |
| 134 | - | d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 135 | - | movedPane := moved.focusedPane() | |
| 136 | - | ||
| 137 | - | follower.overlays.copyMu.Lock() | |
| 138 | - | follower.overlays.copyPane = movedPane | |
| 139 | - | follower.overlays.copyMode = &scopy.Mode{} | |
| 140 | - | follower.overlays.copyMu.Unlock() | |
| 141 | - | follower.sendMu.Lock() | |
| 142 | - | follower.captureFrames = map[*pane]capturedPaneRenderState{movedPane: {title: "stale-source-capture"}} | |
| 143 | - | follower.sendMu.Unlock() | |
| 144 | - | ||
| 145 | - | otherPane := destination.tabs[0].focusedPane() | |
| 146 | - | waiting := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: follower.size} | |
| 147 | - | waiting.initOverlays() | |
| 148 | - | waiting.setSession(destination) | |
| 149 | - | waiting.overlays.copyMu.Lock() | |
| 150 | - | waiting.overlays.copyPane = otherPane | |
| 151 | - | waiting.overlays.copyMode = &scopy.Mode{} | |
| 152 | - | waiting.overlays.copyMu.Unlock() | |
| 153 | - | waiting.sendMu.Lock() | |
| 154 | - | waiting.captureFrames = map[*pane]capturedPaneRenderState{otherPane: {}} | |
| 155 | - | waiting.sendMu.Unlock() | |
| 156 | - | destination.mu.Lock() | |
| 157 | - | destination.addSnatchedLocked(waiting) | |
| 158 | - | destination.mu.Unlock() | |
| 159 | - | ||
| 160 | - | moveTabFinalSource(t, d, source, destination, moved) | |
| 161 | - | ||
| 162 | - | follower.overlays.copyMu.Lock() | |
| 163 | - | require.Nil(t, follower.overlays.copyMode) | |
| 164 | - | require.Nil(t, follower.overlays.copyPane) | |
| 165 | - | follower.overlays.copyMu.Unlock() | |
| 166 | - | follower.sendMu.Lock() | |
| 167 | - | followerCapture, followerCaptured := follower.captureFrames[movedPane] | |
| 168 | - | follower.sendMu.Unlock() | |
| 169 | - | if followerCaptured { | |
| 170 | - | require.NotEqual(t, "stale-source-capture", followerCapture.title, "source capture survived destination first paint") | |
| 171 | - | } | |
| 172 | - | ||
| 173 | - | waiting.overlays.copyMu.Lock() | |
| 174 | - | require.NotNil(t, waiting.overlays.copyMode) | |
| 175 | - | require.Same(t, otherPane, waiting.overlays.copyPane) | |
| 176 | - | waiting.overlays.copyMu.Unlock() | |
| 177 | - | waiting.sendMu.Lock() | |
| 178 | - | _, waitingCaptured := waiting.captureFrames[otherPane] | |
| 179 | - | waiting.sendMu.Unlock() | |
| 180 | - | require.True(t, waitingCaptured) | |
| 181 | - | } | |
| 182 | - | ||
| 183 | - | func TestMoveTabFinalSourceFirstPaintNotBlockedByDisplacedPanelCleanup(t *testing.T) { | |
| 184 | - | d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 185 | - | followerRebased := make(chan struct{}) | |
| 186 | - | follower.renderStages.handoffRebase = func() { close(followerRebased) } | |
| 187 | - | ||
| 188 | - | displaced.sendMu.Lock() | |
| 189 | - | panelCleanupStarted := make(chan struct{}) | |
| 190 | - | d.afterDisplacedCleanupStarted = func() { close(panelCleanupStarted) } | |
| 191 | - | defer func() { d.afterDisplacedCleanupStarted = nil }() | |
| 192 | - | ||
| 193 | - | moveDone := make(chan struct{}) | |
| 194 | - | go func() { | |
| 195 | - | moveTabFinalSource(t, d, source, destination, moved) | |
| 196 | - | close(moveDone) | |
| 197 | - | }() | |
| 198 | - | ||
| 199 | - | awaitTestCompletion(t, panelCleanupStarted, "displaced panel cleanup did not start") | |
| 200 | - | awaitTestCompletion(t, followerRebased, "follower reset first paint waited for displaced panel cleanup") | |
| 201 | - | awaitTestCompletion(t, moveDone, "move-tab handoff did not finish after follower first paint") | |
| 202 | - | ||
| 203 | - | displaced.sendMu.Unlock() | |
| 204 | - | d.attachmentCleanupWg.Wait() | |
| 205 | - | } | |
| 206 | - | ||
| 207 | - | func TestMoveTabFinalSourceConcurrentReclaimKeepsNewestOwnerAndLease(t *testing.T) { | |
| 208 | - | d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 209 | - | ||
| 210 | - | paintAdmitted := make(chan struct{}) | |
| 211 | - | releasePaint := make(chan struct{}) | |
| 212 | - | d.afterRoleEffectAdmitted = func(token attachmentRoleToken) { | |
| 213 | - | if token.ac == follower && token.role == attachmentActive { | |
| 214 | - | close(paintAdmitted) | |
| 215 | - | <-releasePaint | |
| 216 | - | } | |
| 217 | - | } | |
| 218 | - | defer func() { d.afterRoleEffectAdmitted = nil }() | |
| 219 | - | ||
| 220 | - | moveDone := make(chan error, 1) | |
| 221 | - | go func() { | |
| 222 | - | moveDone <- d.moveTab(moveTabRequest{ | |
| 223 | - | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 224 | - | SourceTabID: domain.TabStableID(moved.stableID), | |
| 225 | - | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 226 | - | }) | |
| 227 | - | }() | |
| 228 | - | awaitTestCompletion(t, paintAdmitted, "move-tab follower first paint was not admitted") | |
| 229 | - | ||
| 230 | - | displacedToken := destination.attachmentToken(displaced, displaced.transportSnapshot().transport) | |
| 231 | - | reclaimDone := make(chan struct{}) | |
| 232 | - | go func() { | |
| 233 | - | d.handleSnatchedClientFrame(displacedToken, ports.Frame{ | |
| 234 | - | Type: ports.MsgInput, | |
| 235 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 236 | - | }) | |
| 237 | - | close(reclaimDone) | |
| 238 | - | }() | |
| 239 | - | ||
| 240 | - | close(releasePaint) | |
| 241 | - | require.NoError(t, <-moveDone) | |
| 242 | - | awaitTestCompletion(t, reclaimDone, "concurrent destination reclaim did not finish") | |
| 243 | - | d.attachmentCleanupWg.Wait() | |
| 244 | - | ||
| 245 | - | requireSingleOwnerAndLease(t, destination, follower, displaced) | |
| 246 | - | destination.mu.Lock() | |
| 247 | - | require.Same(t, displaced, destination.client) | |
| 248 | - | destination.mu.Unlock() | |
| 249 | - | require.Equal(t, attachmentSnatched, destination.attachmentRole(follower)) | |
| 250 | - | require.Equal(t, attachmentActive, destination.attachmentRole(displaced)) | |
| 251 | - | } | |
| 252 | - | ||
| 253 | - | func TestMoveTabFinalSourcePostCommitPaintFailureDoesNotRollback(t *testing.T) { | |
| 254 | - | d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 255 | - | ||
| 256 | - | d.afterRoleEffectAdmitted = func(token attachmentRoleToken) { | |
| 257 | - | if token.ac == follower { | |
| 258 | - | token.ac.roleGeneration.Add(1) | |
| 259 | - | } | |
| 260 | - | } | |
| 261 | - | defer func() { d.afterRoleEffectAdmitted = nil }() | |
| 262 | - | ||
| 263 | - | beforeTabs := append([]*tab(nil), destination.tabs...) | |
| 264 | - | moveTabFinalSource(t, d, source, destination, moved) | |
| 265 | - | ||
| 266 | - | require.Nil(t, source.tabs) | |
| 267 | - | require.Same(t, destination, follower.currentSession()) | |
| 268 | - | require.Same(t, follower, destination.client) | |
| 269 | - | require.Equal(t, append(beforeTabs, moved), destination.tabs) | |
| 270 | - | require.Same(t, moved, destination.tabs[destination.active]) | |
| 271 | - | } | |
| 272 | - | ||
| 273 | - | func TestMoveTabFinalSourceHandoffCommitHidesPartialPublication(t *testing.T) { | |
| 274 | - | d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 275 | - | ||
| 276 | - | commitEntered := make(chan struct{}) | |
| 277 | - | releaseCommit := make(chan struct{}) | |
| 278 | - | d.beforeMoveTabCommit = func() { | |
| 279 | - | close(commitEntered) | |
| 280 | - | <-releaseCommit | |
| 281 | - | } | |
| 282 | - | defer func() { d.beforeMoveTabCommit = nil }() | |
| 283 | - | ||
| 284 | - | moveDone := make(chan error, 1) | |
| 285 | - | go func() { | |
| 286 | - | moveDone <- d.moveTab(moveTabRequest{ | |
| 287 | - | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 288 | - | SourceTabID: domain.TabStableID(moved.stableID), | |
| 289 | - | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 290 | - | }) | |
| 291 | - | }() | |
| 292 | - | <-commitEntered | |
| 293 | - | ||
| 294 | - | type observation struct { | |
| 295 | - | sourceRegistered bool | |
| 296 | - | movedInSource bool | |
| 297 | - | movedInDestination bool | |
| 298 | - | followerOnDestination bool | |
| 299 | - | displacedSnatched bool | |
| 300 | - | movedActive bool | |
| 301 | - | } | |
| 302 | - | read := func() observation { | |
| 303 | - | d.mu.Lock() | |
| 304 | - | _, sourceLive := d.sessions[source.id] | |
| 305 | - | d.mu.Unlock() | |
| 306 | - | source.mu.Lock() | |
| 307 | - | movedInSource := indexMoveTabLocked(source, moved) >= 0 | |
| 308 | - | source.mu.Unlock() | |
| 309 | - | destination.mu.Lock() | |
| 310 | - | obs := observation{ | |
| 311 | - | sourceRegistered: sourceLive, | |
| 312 | - | movedInSource: movedInSource, | |
| 313 | - | movedInDestination: indexMoveTabLocked(destination, moved) >= 0, | |
| 314 | - | followerOnDestination: destination.client == follower, | |
| 315 | - | displacedSnatched: false, | |
| 316 | - | movedActive: destination.active >= 0 && destination.tabs[destination.active] == moved, | |
| 317 | - | } | |
| 318 | - | if displaced != nil { | |
| 319 | - | _, obs.displacedSnatched = destination.snatched[displaced] | |
| 320 | - | } | |
| 321 | - | destination.mu.Unlock() | |
| 322 | - | return obs | |
| 323 | - | } | |
| 324 | - | ||
| 325 | - | require.False(t, d.mu.TryLock(), "daemon observers must be excluded from partial move-tab publication") | |
| 326 | - | require.False(t, source.mu.TryLock(), "source observers must be excluded from partial move-tab publication") | |
| 327 | - | require.False(t, destination.mu.TryLock(), "destination observers must be excluded from partial move-tab publication") | |
| 328 | - | ||
| 329 | - | close(releaseCommit) | |
| 330 | - | require.NoError(t, <-moveDone) | |
| 331 | - | ||
| 332 | - | final := read() | |
| 333 | - | require.False(t, final.sourceRegistered) | |
| 334 | - | require.False(t, final.movedInSource) | |
| 335 | - | require.True(t, final.movedInDestination) | |
| 336 | - | require.True(t, final.followerOnDestination) | |
| 337 | - | require.True(t, final.displacedSnatched) | |
| 338 | - | require.True(t, final.movedActive) | |
| 339 | - | } | |
M internal/usecase/daemon/move_tab_test.go +14 -93
| @@ -27,13 +27,13 @@ func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) { | |||
| 27 | 27 | moved.name = "preserved-name" | |
| 28 | 28 | remaining := source.tabs[1] | |
| 29 | 29 | remaining.stableID = "remaining-tab" | |
| 30 | - | source.active = 0 | |
| 30 | + | selectTestAttachmentTab(source, 0) | |
| 31 | 31 | ||
| 32 | 32 | destinationCtx, destinationCancel := context.WithCancel(d.serveCtx) | |
| 33 | 33 | t.Cleanup(destinationCancel) | |
| 34 | 34 | destinationTab := newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 100, Rows: 30}) | |
| 35 | 35 | destinationTab.ctx, destinationTab.cancel = context.WithCancel(destinationCtx) | |
| 36 | - | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{9}, ephemeral: destinationEphemeral}, ctx: destinationCtx, cancel: destinationCancel, tabs: []*tab{destinationTab}, active: 0} | |
| 36 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{9}, ephemeral: destinationEphemeral}, ctx: destinationCtx, cancel: destinationCancel, tabs: []*tab{destinationTab}} | |
| 37 | 37 | publishTiledPaneOwners(destination, destinationTab) | |
| 38 | 38 | ||
| 39 | 39 | floating := newPaneWithStableID("floating", "floating-stable", floatingPTY, domain.Size{Cols: 20, Rows: 8}) | |
| @@ -53,7 +53,7 @@ func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) { | |||
| 53 | 53 | oldWorkerCtx := moved.ctx | |
| 54 | 54 | oldTree := moved.tree | |
| 55 | 55 | oldPanes := moved.panes | |
| 56 | - | beforeDestinationActive := destination.tabs[destination.active] | |
| 56 | + | beforeDestinationActive := destination.tabs[testAttachmentTabIndex(destination)] | |
| 57 | 57 | ||
| 58 | 58 | err := d.moveTab(moveTabRequest{ | |
| 59 | 59 | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| @@ -63,10 +63,10 @@ func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) { | |||
| 63 | 63 | require.NoError(t, err) | |
| 64 | 64 | ||
| 65 | 65 | require.Equal(t, []*tab{remaining}, source.tabs) | |
| 66 | - | require.Zero(t, source.active) | |
| 67 | - | require.Same(t, remaining, source.tabs[source.active]) | |
| 66 | + | require.Zero(t, testAttachmentTabIndex(source)) | |
| 67 | + | require.Same(t, remaining, source.tabs[testAttachmentTabIndex(source)]) | |
| 68 | 68 | require.Equal(t, []*tab{destinationTab, moved}, destination.tabs) | |
| 69 | - | require.Same(t, beforeDestinationActive, destination.tabs[destination.active], "moved tab stays in the background") | |
| 69 | + | require.Same(t, beforeDestinationActive, destination.tabs[testAttachmentTabIndex(destination)], "moved tab stays in the background") | |
| 70 | 70 | require.Equal(t, destinationTab.size, moved.size, "destination geometry becomes authoritative") | |
| 71 | 71 | require.Equal(t, "preserved-name", moved.name) | |
| 72 | 72 | require.Same(t, oldTree, moved.tree) | |
| @@ -87,85 +87,6 @@ func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) { | |||
| 87 | 87 | } | |
| 88 | 88 | } | |
| 89 | 89 | ||
| 90 | - | func TestMoveTabFinalSourceVisibleFloatingPreservesRuntimeAndFollowsClient(t *testing.T) { | |
| 91 | - | d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 92 | - | moved := source.tabs[0] | |
| 93 | - | moved.stableID = "moved-tab" | |
| 94 | - | require.NotNil(t, d.attachCoordinator(source, nil, client, true)) | |
| 95 | - | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 96 | - | ||
| 97 | - | floatingPTY := newQuietPTY() | |
| 98 | - | floating := newPaneWithStableID("floating", "floating-stable", floatingPTY, domain.Size{Cols: 20, Rows: 8}) | |
| 99 | - | floatingCtx, floatingCancel := context.WithCancel(d.paneProcessCtx) | |
| 100 | - | t.Cleanup(floatingCancel) | |
| 101 | - | floating.ctx, floating.cancel = floatingCtx, floatingCancel | |
| 102 | - | moved.mu.Lock() | |
| 103 | - | moved.floating = floatingSlot{state: floatingVisible, pane: floating, desiredVisible: true, generation: 7} | |
| 104 | - | moved.mu.Unlock() | |
| 105 | - | publishPaneOwner(floating, source, moved, 7) | |
| 106 | - | d.applyTabLayout(source, moved) | |
| 107 | - | ||
| 108 | - | beforeScreen := floating.screen | |
| 109 | - | beforeHistory := floating.history | |
| 110 | - | beforeContext := floating.ctx | |
| 111 | - | beforeOwnerGeneration := floating.ownerGeneration | |
| 112 | - | ||
| 113 | - | require.NoError(t, d.moveTab(moveTabRequest{ | |
| 114 | - | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID), | |
| 115 | - | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 116 | - | })) | |
| 117 | - | ||
| 118 | - | require.Same(t, moved, destination.tabs[destination.active]) | |
| 119 | - | require.Same(t, client, destination.client) | |
| 120 | - | require.Same(t, destination, client.currentSession()) | |
| 121 | - | moved.mu.Lock() | |
| 122 | - | require.Equal(t, floatingVisible, moved.floating.state) | |
| 123 | - | require.True(t, moved.floating.desiredVisible) | |
| 124 | - | require.Equal(t, uint64(7), moved.floating.generation) | |
| 125 | - | require.Same(t, floating, moved.floating.pane) | |
| 126 | - | moved.mu.Unlock() | |
| 127 | - | expectedGeometry := calculateContentFloatingGeometry(moved.size, d.currentFloatingConfig()) | |
| 128 | - | floating.mu.Lock() | |
| 129 | - | require.Same(t, floatingPTY, floating.pty) | |
| 130 | - | require.Same(t, beforeScreen, floating.screen) | |
| 131 | - | require.Same(t, beforeHistory, floating.history) | |
| 132 | - | require.Equal(t, beforeContext, floating.ctx) | |
| 133 | - | require.Equal(t, expectedGeometry, floating.popupGeometry) | |
| 134 | - | require.Equal(t, expectedGeometry.Inner, floating.rect) | |
| 135 | - | require.Equal(t, beforeOwnerGeneration+1, floating.ownerGeneration) | |
| 136 | - | floating.mu.Unlock() | |
| 137 | - | owner := floating.ownerSnapshot() | |
| 138 | - | require.Same(t, destination, owner.session) | |
| 139 | - | require.Same(t, moved, owner.tab) | |
| 140 | - | require.Equal(t, floating.ownerGeneration, owner.generation) | |
| 141 | - | require.Equal(t, uint64(7), owner.floatingSlotGeneration) | |
| 142 | - | require.NoError(t, floating.ctx.Err(), "floating process context remains live") | |
| 143 | - | } | |
| 144 | - | ||
| 145 | - | func TestMoveTabFinalSourceClientFollowsAndActivatesMovedTab(t *testing.T) { | |
| 146 | - | d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 147 | - | moved := source.tabs[0] | |
| 148 | - | moved.stableID = "moved-tab" | |
| 149 | - | require.NotNil(t, d.attachCoordinator(source, nil, client, true)) | |
| 150 | - | rebased := false | |
| 151 | - | client.renderStages.handoffRebase = func() { rebased = true } | |
| 152 | - | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 153 | - | oldDestinationActive := destination.tabs[0] | |
| 154 | - | ||
| 155 | - | require.NoError(t, d.moveTab(moveTabRequest{ | |
| 156 | - | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID), | |
| 157 | - | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 158 | - | })) | |
| 159 | - | ||
| 160 | - | require.Nil(t, source.tabs) | |
| 161 | - | require.Same(t, destination, client.currentSession()) | |
| 162 | - | require.Same(t, client, destination.client) | |
| 163 | - | require.Equal(t, attachmentActive, destination.attachmentRole(client)) | |
| 164 | - | require.Equal(t, []*tab{oldDestinationActive, moved}, destination.tabs) | |
| 165 | - | require.Same(t, moved, destination.tabs[destination.active], "final-source follower activates the moved tab") | |
| 166 | - | require.True(t, rebased) | |
| 167 | - | } | |
| 168 | - | ||
| 169 | 90 | func TestMoveTabRejectsSameSessionStaleMembershipAndWarming(t *testing.T) { | |
| 170 | 91 | tests := []struct { | |
| 171 | 92 | name string | |
| @@ -231,7 +152,7 @@ func TestMoveTabFinalSourceVisibleFloatingRejectsStaleSlotTransitions(t *testing | |||
| 231 | 152 | d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 232 | 153 | moved := source.tabs[0] | |
| 233 | 154 | moved.stableID = "moved-tab" | |
| 234 | - | require.NotNil(t, d.attachCoordinator(source, nil, client, true)) | |
| 155 | + | require.NotNil(t, d.attachCoordinator(source, client, true)) | |
| 235 | 156 | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 236 | 157 | floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8}) | |
| 237 | 158 | moved.mu.Lock() | |
| @@ -241,7 +162,7 @@ func TestMoveTabFinalSourceVisibleFloatingRejectsStaleSlotTransitions(t *testing | |||
| 241 | 162 | ||
| 242 | 163 | source.layoutApplyMu.Lock() | |
| 243 | 164 | admitted := make(chan struct{}) | |
| 244 | - | d.afterRoleEffectGateFrozen = func(action string, ac *attachedClient) { | |
| 165 | + | d.afterAttachmentEffectGateFrozen = func(action string, ac *attachedClient) { | |
| 245 | 166 | if action == "move-tab" && ac == client { | |
| 246 | 167 | close(admitted) | |
| 247 | 168 | } | |
| @@ -262,7 +183,7 @@ func TestMoveTabFinalSourceVisibleFloatingRejectsStaleSlotTransitions(t *testing | |||
| 262 | 183 | require.Error(t, awaitTestValue(t, moveDone, "stale floating transfer did not return")) | |
| 263 | 184 | require.NotContains(t, destination.tabs, moved) | |
| 264 | 185 | require.Same(t, source, floating.ownerSnapshot().session) | |
| 265 | - | d.afterRoleEffectGateFrozen = nil | |
| 186 | + | d.afterAttachmentEffectGateFrozen = nil | |
| 266 | 187 | }) | |
| 267 | 188 | } | |
| 268 | 189 | } | |
| @@ -487,12 +408,12 @@ func (r *moveTabPurgeRepository) DeleteIncarnation(_ context.Context, id domain. | |||
| 487 | 408 | sourceFenceUnlocked := r.source.layoutApplyMu.TryLock() | |
| 488 | 409 | destinationFenceUnlocked := r.destination.layoutApplyMu.TryLock() | |
| 489 | 410 | tabFenceUnlocked := r.moved.layoutApplyMu.TryLock() | |
| 490 | - | roleUnlocked := r.client.roleEffects.mu.TryLock() | |
| 491 | - | roleStable := roleUnlocked && r.client.roleEffects.phase == roleEffectsStable | |
| 411 | + | roleUnlocked := r.client.attachmentEffects.mu.TryLock() | |
| 412 | + | roleStable := roleUnlocked && r.client.attachmentEffects.phase == attachmentEffectsStable | |
| 492 | 413 | r.outside = daemonUnlocked && routingUnlocked && sourceUnlocked && destinationUnlocked && tabUnlocked && paneUnlocked && | |
| 493 | 414 | sourceFenceUnlocked && destinationFenceUnlocked && tabFenceUnlocked && roleStable | |
| 494 | 415 | if roleUnlocked { | |
| 495 | - | r.client.roleEffects.mu.Unlock() | |
| 416 | + | r.client.attachmentEffects.mu.Unlock() | |
| 496 | 417 | } | |
| 497 | 418 | if tabFenceUnlocked { | |
| 498 | 419 | r.moved.layoutApplyMu.Unlock() | |
| @@ -532,7 +453,7 @@ func (r *moveTabPurgeRepository) DeleteIncarnation(_ context.Context, id domain. | |||
| 532 | 453 | ||
| 533 | 454 | func TestMoveTabFinalNamedSourcePurgesAfterDestinationAdmissionAndFailureDoesNotRollback(t *testing.T) { | |
| 534 | 455 | d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 535 | - | require.NotNil(t, d.attachCoordinator(source, nil, client, true)) | |
| 456 | + | require.NotNil(t, d.attachCoordinator(source, client, true)) | |
| 536 | 457 | source.incarnation = domain.IncarnationID{4} | |
| 537 | 458 | moved := source.tabs[0] | |
| 538 | 459 | moved.stableID = "moved-tab" | |
| @@ -572,7 +493,7 @@ func addMoveTabTestSession(d *Daemon, id domain.SessionID, tabID string) *sessio | |||
| 572 | 493 | ctx, cancel := context.WithCancel(d.serveCtx) | |
| 573 | 494 | tb := newTabWithStableID(tabID, tabID+"-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23}) | |
| 574 | 495 | tb.ctx, tb.cancel = context.WithCancel(ctx) | |
| 575 | - | sess := &session{sessionCore: sessionCore{id: id, name: string(id), incarnation: domain.IncarnationID{5}, ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0} | |
| 496 | + | sess := &session{sessionCore: sessionCore{id: id, name: string(id), incarnation: domain.IncarnationID{5}, ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{tb}} | |
| 576 | 497 | publishTiledPaneOwners(sess, tb) | |
| 577 | 498 | d.mu.Lock() | |
| 578 | 499 | d.sessions[sess.id] = sess | |
M internal/usecase/daemon/move_test.go +6 -52
| @@ -3,7 +3,6 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "os" | |
| 6 | - | "sync/atomic" | |
| 7 | 6 | "testing" | |
| 8 | 7 | ||
| 9 | 8 | "github.com/stretchr/testify/require" | |
| @@ -49,7 +48,7 @@ func TestMovePaneMutationCrossSession(t *testing.T) { | |||
| 49 | 48 | defer release2() | |
| 50 | 49 | d, source, _, _ := newManualSessionWithPTYs(t, p1) | |
| 51 | 50 | source.mu.Lock() | |
| 52 | - | source.client = nil | |
| 51 | + | clearAttachmentsForTestLocked(source) | |
| 53 | 52 | source.mu.Unlock() | |
| 54 | 53 | sourceTab := source.tabs[0] | |
| 55 | 54 | sourceTab.stableID = "source-tab" | |
| @@ -57,7 +56,7 @@ func TestMovePaneMutationCrossSession(t *testing.T) { | |||
| 57 | 56 | movedCtx, movedCancel := context.WithCancel(d.paneProcessCtx) | |
| 58 | 57 | moved.ctx, moved.cancel = movedCtx, movedCancel | |
| 59 | 58 | ||
| 60 | - | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0} | |
| 59 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}} | |
| 61 | 60 | destinationTab := destination.tabs[0] | |
| 62 | 61 | destinationTab.stableID = "destination-tab" | |
| 63 | 62 | publishTiledPaneOwners(destination, destinationTab) | |
| @@ -81,47 +80,6 @@ func TestMovePaneMutationCrossSession(t *testing.T) { | |||
| 81 | 80 | require.NoError(t, moved.ctx.Err(), "retiring the source must not cancel the transferred pane process") | |
| 82 | 81 | } | |
| 83 | 82 | ||
| 84 | - | func TestMovePaneFinalSourceClientFollowsDestination(t *testing.T) { | |
| 85 | - | p1, release1 := newBlockingPTY(t) | |
| 86 | - | p2, release2 := newBlockingPTY(t) | |
| 87 | - | defer release1() | |
| 88 | - | defer release2() | |
| 89 | - | d, source, client, _ := newManualSessionWithPTYs(t, p1) | |
| 90 | - | sourceTab := source.tabs[0] | |
| 91 | - | sourceTab.stableID = "source-tab" | |
| 92 | - | moved := sourceTab.focusedPane() | |
| 93 | - | movedTabCtx, movedTabCancel := context.WithCancel(d.paneProcessCtx) | |
| 94 | - | moved.ctx, moved.cancel = movedTabCtx, movedTabCancel | |
| 95 | - | ||
| 96 | - | rc := d.attachCoordinator(source, nil, client, true) | |
| 97 | - | require.NotNil(t, rc) | |
| 98 | - | var rebased atomic.Bool | |
| 99 | - | client.renderStages.handoffRebase = func() { rebased.Store(true) } | |
| 100 | - | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0} | |
| 101 | - | destinationTab := destination.tabs[0] | |
| 102 | - | destinationTab.stableID = "destination-tab" | |
| 103 | - | publishTiledPaneOwners(destination, destinationTab) | |
| 104 | - | d.mu.Lock() | |
| 105 | - | d.sessions[destination.id] = destination | |
| 106 | - | d.mu.Unlock() | |
| 107 | - | ||
| 108 | - | err := d.movePane(movePaneRequest{ | |
| 109 | - | Source: moveSessionLocator{ID: source.id}, | |
| 110 | - | SourceTabID: domain.TabStableID(sourceTab.stableID), | |
| 111 | - | SourcePaneID: domain.PaneStableID(moved.stableID), | |
| 112 | - | Destination: moveSessionLocator{ID: destination.id}, | |
| 113 | - | DestinationTabID: domain.TabStableID(destinationTab.stableID), | |
| 114 | - | }) | |
| 115 | - | require.NoError(t, err) | |
| 116 | - | require.Nil(t, source.tabs) | |
| 117 | - | require.Same(t, destination, client.currentSession()) | |
| 118 | - | require.Same(t, client, destination.client) | |
| 119 | - | require.Equal(t, attachmentActive, destination.attachmentRole(client)) | |
| 120 | - | require.Same(t, destinationTab, destination.tabs[destination.active]) | |
| 121 | - | require.True(t, rebased.Load(), "final-source follower must rebase output before its first paint") | |
| 122 | - | require.NoError(t, moved.ctx.Err(), "transferred pane process must remain alive") | |
| 123 | - | } | |
| 124 | - | ||
| 125 | 83 | func TestMovePaneRetiresEmptySourceWithoutClosingTransferredResources(t *testing.T) { | |
| 126 | 84 | movedPTY := newQuietPTY() | |
| 127 | 85 | d, source, active, _ := newManualSessionWithPTYs(t, movedPTY) | |
| @@ -129,7 +87,7 @@ func TestMovePaneRetiresEmptySourceWithoutClosingTransferredResources(t *testing | |||
| 129 | 87 | require.NoError(t, err) | |
| 130 | 88 | require.NoError(t, clip.Close()) | |
| 131 | 89 | source.mu.Lock() | |
| 132 | - | source.client = nil | |
| 90 | + | clearAttachmentsForTestLocked(source) | |
| 133 | 91 | source.clipFiles = []string{clip.Name()} | |
| 134 | 92 | source.mu.Unlock() | |
| 135 | 93 | active.setSession(nil) | |
| @@ -140,9 +98,7 @@ func TestMovePaneRetiresEmptySourceWithoutClosingTransferredResources(t *testing | |||
| 140 | 98 | movedTab.stableID = "source-tab" | |
| 141 | 99 | ||
| 142 | 100 | destinationPTY := newQuietPTY() | |
| 143 | - | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, | |
| 144 | - | active: 0, | |
| 145 | - | } | |
| 101 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}} | |
| 146 | 102 | publishTiledPaneOwners(destination, destination.tabs[0]) | |
| 147 | 103 | d.mu.Lock() | |
| 148 | 104 | d.sessions[destination.id] = destination | |
| @@ -179,7 +135,7 @@ func TestMovePaneRetiresSourceParkedClients(t *testing.T) { | |||
| 179 | 135 | movedPTY := newQuietPTY() | |
| 180 | 136 | d, source, _, _ := newManualSessionWithPTYs(t, movedPTY) | |
| 181 | 137 | source.mu.Lock() | |
| 182 | - | source.client = nil | |
| 138 | + | clearAttachmentsForTestLocked(source) | |
| 183 | 139 | source.mu.Unlock() | |
| 184 | 140 | moved := source.tabs[0].focusedPane() | |
| 185 | 141 | movedTab := source.tabs[0] | |
| @@ -190,9 +146,7 @@ func TestMovePaneRetiresSourceParkedClients(t *testing.T) { | |||
| 190 | 146 | parked.initOverlays() | |
| 191 | 147 | require.True(t, d.parkAttachment(source, parked)) | |
| 192 | 148 | destinationPTY := newQuietPTY() | |
| 193 | - | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, | |
| 194 | - | active: 0, | |
| 195 | - | } | |
| 149 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}} | |
| 196 | 150 | publishTiledPaneOwners(destination, destination.tabs[0]) | |
| 197 | 151 | d.mu.Lock() | |
| 198 | 152 | d.sessions[destination.id] = destination | |
A internal/usecase/daemon/multi_attachment_callers_test.go +119 -0
| @@ -0,0 +1,119 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestDaemonActionTargetsUseEachInitiatingAttachmentView(t *testing.T) { | |
| 13 | + | sess := registryTestSession() | |
| 14 | + | first, second := registryTestAttachment(1), registryTestAttachment(2) | |
| 15 | + | require.True(t, sess.registerAttachment(first)) | |
| 16 | + | require.True(t, sess.registerAttachment(second)) | |
| 17 | + | require.True(t, sess.selectAttachmentTab(second, domain.TabStableID(sess.tabs[1].stableID))) | |
| 18 | + | ||
| 19 | + | firstTarget := resolveDaemonActionTargetForAttachment(sess, first) | |
| 20 | + | secondTarget := resolveDaemonActionTargetForAttachment(sess, second) | |
| 21 | + | require.Same(t, sess.tabs[0], firstTarget.tab) | |
| 22 | + | require.Same(t, sess.tabs[1], secondTarget.tab) | |
| 23 | + | require.Same(t, sess.tabs[0].focusedPane(), firstTarget.pane) | |
| 24 | + | require.Same(t, sess.tabs[1].focusedPane(), secondTarget.pane) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestAttachmentsKeepDifferentPaneFocusForActionsRenderingAndMetadata(t *testing.T) { | |
| 28 | + | sess, firstPane, secondPane := multiPaneAttachmentSession(t) | |
| 29 | + | first, second := registryTestAttachment(1), registryTestAttachment(2) | |
| 30 | + | require.True(t, sess.registerAttachment(first)) | |
| 31 | + | require.True(t, sess.registerAttachment(second)) | |
| 32 | + | require.True(t, sess.updateAttachmentView(second, func(view *attachmentView) { | |
| 33 | + | view.paneID = domain.PaneStableID(secondPane.stableID) | |
| 34 | + | })) | |
| 35 | + | ||
| 36 | + | firstTarget := resolveDaemonActionTargetForAttachment(sess, first) | |
| 37 | + | secondTarget := resolveDaemonActionTargetForAttachment(sess, second) | |
| 38 | + | require.Same(t, firstPane, firstTarget.pane) | |
| 39 | + | require.Same(t, secondPane, secondTarget.pane) | |
| 40 | + | ||
| 41 | + | firstState, ok := captureLocalRenderState(sess, first, renderCaptureRequest{}) | |
| 42 | + | require.True(t, ok) | |
| 43 | + | secondState, ok := captureLocalRenderState(sess, second, renderCaptureRequest{}) | |
| 44 | + | require.True(t, ok) | |
| 45 | + | require.Equal(t, firstPane.id, firstState.layout.focus) | |
| 46 | + | require.Equal(t, secondPane.id, secondState.layout.focus) | |
| 47 | + | ||
| 48 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 49 | + | var rows []struct { | |
| 50 | + | Pane string `json:"pane"` | |
| 51 | + | Focused bool `json:"focused"` | |
| 52 | + | } | |
| 53 | + | output, err := (controlExec{d: d, sess: sess, tab: sess.tabs[0], target: daemonActionTarget{attachment: second}}).ListPanes(true) | |
| 54 | + | require.NoError(t, err) | |
| 55 | + | require.NoError(t, json.Unmarshal([]byte(output), &rows)) | |
| 56 | + | for _, row := range rows { | |
| 57 | + | require.Equal(t, row.Pane == string(secondPane.id), row.Focused) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestAttachmentFocusActionUpdatesOnlyInitiatingPaneView(t *testing.T) { | |
| 62 | + | sess, firstPane, secondPane := multiPaneAttachmentSession(t) | |
| 63 | + | first, second := registryTestAttachment(1), registryTestAttachment(2) | |
| 64 | + | require.True(t, sess.registerAttachment(first)) | |
| 65 | + | require.True(t, sess.registerAttachment(second)) | |
| 66 | + | ||
| 67 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 68 | + | require.NoError(t, d.focusDir(sess, second, layout.Right, nil)) | |
| 69 | + | require.Equal(t, domain.PaneStableID(firstPane.stableID), first.viewSnapshot().paneID) | |
| 70 | + | require.Equal(t, domain.PaneStableID(secondPane.stableID), second.viewSnapshot().paneID) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestPaneRemovalRepairsEachAttachmentViewIndependently(t *testing.T) { | |
| 74 | + | sess, firstPane, secondPane := multiPaneAttachmentSession(t) | |
| 75 | + | first, second := registryTestAttachment(1), registryTestAttachment(2) | |
| 76 | + | require.True(t, sess.registerAttachment(first)) | |
| 77 | + | require.True(t, sess.registerAttachment(second)) | |
| 78 | + | require.True(t, sess.updateAttachmentView(second, func(view *attachmentView) { | |
| 79 | + | view.paneID = domain.PaneStableID(secondPane.stableID) | |
| 80 | + | })) | |
| 81 | + | ||
| 82 | + | require.NoError(t, sess.runMutation(func() error { | |
| 83 | + | sess.mu.Lock() | |
| 84 | + | defer sess.mu.Unlock() | |
| 85 | + | tb := sess.tabs[0] | |
| 86 | + | tb.mu.Lock() | |
| 87 | + | closeErr := tb.tree.Close(firstPane.id) | |
| 88 | + | if closeErr == nil { | |
| 89 | + | delete(tb.panes, firstPane.id) | |
| 90 | + | tb.bumpLayoutGenerationLocked() | |
| 91 | + | } | |
| 92 | + | tb.mu.Unlock() | |
| 93 | + | if closeErr != nil { | |
| 94 | + | return closeErr | |
| 95 | + | } | |
| 96 | + | sess.repairAttachmentViewsLocked() | |
| 97 | + | return nil | |
| 98 | + | })) | |
| 99 | + | ||
| 100 | + | require.Equal(t, domain.PaneStableID(secondPane.stableID), first.viewSnapshot().paneID) | |
| 101 | + | require.Equal(t, domain.PaneStableID(secondPane.stableID), second.viewSnapshot().paneID) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func multiPaneAttachmentSession(t *testing.T) (*session, *pane, *pane) { | |
| 105 | + | t.Helper() | |
| 106 | + | sess := registryTestSession() | |
| 107 | + | tb := sess.tabs[0] | |
| 108 | + | firstPane := tb.panes[layout.PaneID("pane-1")] | |
| 109 | + | secondPane := newPaneWithStableID("pane-2", "stable-pane-b", nil, domain.Size{Cols: 80, Rows: 23}) | |
| 110 | + | tb.mu.Lock() | |
| 111 | + | tb.panes[secondPane.id] = secondPane | |
| 112 | + | tb.tree = &layout.Tree{ | |
| 113 | + | Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(firstPane.id), layout.NewLeaf(secondPane.id)}}, | |
| 114 | + | Focus: firstPane.id, | |
| 115 | + | } | |
| 116 | + | tb.bumpLayoutGenerationLocked() | |
| 117 | + | tb.mu.Unlock() | |
| 118 | + | return sess, firstPane, secondPane | |
| 119 | + | } | |
R internal/usecase/daemon/navigation_role_handoff_test.go → internal/usecase/daemon/navigation_attachment_handoff_test.go +25 -26 renamed, 73% similar
| @@ -16,31 +16,31 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |||
| 16 | 16 | tests := []struct { | |
| 17 | 17 | name string | |
| 18 | 18 | action string | |
| 19 | - | run func(*Daemon, *session, *session, *attachedClient, *roleEffectTicket) error | |
| 19 | + | run func(*Daemon, *session, *session, *attachedClient, *attachmentEffectTicket) error | |
| 20 | 20 | check func(*testing.T, *Daemon, *session, *session) | |
| 21 | 21 | }{ | |
| 22 | 22 | { | |
| 23 | 23 | name: "picker selection", action: "picker-select", | |
| 24 | - | run: func(d *Daemon, source, target *session, ac *attachedClient, effect *roleEffectTicket) error { | |
| 24 | + | run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error { | |
| 25 | 25 | d.enterPicker(source, ac) | |
| 26 | - | return d.switchToTargetForRole(effect.roleToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "picker-select") | |
| 26 | + | return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "picker-select") | |
| 27 | 27 | }, | |
| 28 | 28 | }, | |
| 29 | 29 | { | |
| 30 | 30 | name: "palette session", action: "palette-session", | |
| 31 | - | run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *roleEffectTicket) error { | |
| 32 | - | return d.switchToTargetForRole(effect.roleToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session") | |
| 31 | + | run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffectTicket) error { | |
| 32 | + | return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session") | |
| 33 | 33 | }, | |
| 34 | 34 | }, | |
| 35 | 35 | { | |
| 36 | 36 | name: "palette recent session", action: "palette-recent-session", | |
| 37 | - | run: func(d *Daemon, source, target *session, ac *attachedClient, effect *roleEffectTicket) error { | |
| 37 | + | run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error { | |
| 38 | 38 | return paletteExec{d: d, sess: source, ac: ac, recent: []recentSession{{id: target.id}}, effect: effect}.JumpRecentSession(1) | |
| 39 | 39 | }, | |
| 40 | 40 | }, | |
| 41 | 41 | { | |
| 42 | 42 | name: "session overflow", action: "overflow-session", | |
| 43 | - | run: func(d *Daemon, source, target *session, ac *attachedClient, effect *roleEffectTicket) error { | |
| 43 | + | run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error { | |
| 44 | 44 | source.mu.Lock() | |
| 45 | 45 | source.name = "alpha" | |
| 46 | 46 | source.mu.Unlock() | |
| @@ -53,13 +53,13 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |||
| 53 | 53 | }, | |
| 54 | 54 | { | |
| 55 | 55 | name: "stopped session resume", action: "stopped-session", | |
| 56 | - | run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *roleEffectTicket) error { | |
| 56 | + | run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffectTicket) error { | |
| 57 | 57 | d.mu.Lock() | |
| 58 | 58 | delete(d.sessions, target.id) | |
| 59 | 59 | d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 9, state: ports.SessionStopped} | |
| 60 | 60 | d.mu.Unlock() | |
| 61 | 61 | expectedCreatedAt := int64(9) | |
| 62 | - | return d.switchToTargetForRole(effect.roleToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "stopped-session") | |
| 62 | + | return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "stopped-session") | |
| 63 | 63 | }, | |
| 64 | 64 | check: func(t *testing.T, d *Daemon, _, _ *session) { | |
| 65 | 65 | d.mu.Lock() | |
| @@ -72,8 +72,8 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |||
| 72 | 72 | }, | |
| 73 | 73 | { | |
| 74 | 74 | name: "transition prompt creation", action: "create-session", | |
| 75 | - | run: func(d *Daemon, _ *session, _ *session, _ *attachedClient, effect *roleEffectTicket) error { | |
| 76 | - | return d.createSessionAndSwitchForRole(effect.roleToken(), "created") | |
| 75 | + | run: func(d *Daemon, _ *session, _ *session, _ *attachedClient, effect *attachmentEffectTicket) error { | |
| 76 | + | return d.createSessionAndSwitchForAttachment(effect.connectionToken(), "created") | |
| 77 | 77 | }, | |
| 78 | 78 | check: func(t *testing.T, d *Daemon, _, _ *session) { | |
| 79 | 79 | d.mu.Lock() | |
| @@ -97,17 +97,17 @@ 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 | - | old.publishRoleCapability(token) | |
| 104 | - | effect, admitted := old.beginRoleEffect(token) | |
| 103 | + | old.publishAttachmentCapability(token) | |
| 104 | + | effect, admitted := old.beginAttachmentEffect(token) | |
| 105 | 105 | require.True(t, admitted) | |
| 106 | 106 | ||
| 107 | 107 | admissionEnded := make(chan struct{}) | |
| 108 | 108 | releaseAction := make(chan struct{}) | |
| 109 | 109 | var admissionEndedOnce sync.Once | |
| 110 | - | d.afterActionRoleEffectEnded = func(action string) { | |
| 110 | + | d.afterActionAttachmentEffectEnded = func(action string) { | |
| 111 | 111 | if action == tt.action { | |
| 112 | 112 | admissionEndedOnce.Do(func() { close(admissionEnded) }) | |
| 113 | 113 | <-releaseAction | |
| @@ -123,7 +123,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |||
| 123 | 123 | ||
| 124 | 124 | replacement, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 125 | 125 | source: source, target: source, next: old, | |
| 126 | - | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 126 | + | ||
| 127 | 127 | expectedTransport: old.transportSnapshot(), ready: true, | |
| 128 | 128 | }) | |
| 129 | 129 | require.NoError(t, err) | |
| @@ -132,11 +132,11 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |||
| 132 | 132 | ||
| 133 | 133 | require.Same(t, source, old.currentSession()) | |
| 134 | 134 | source.mu.Lock() | |
| 135 | - | require.Same(t, old, source.client) | |
| 135 | + | require.Contains(t, source.snapshotAttachmentsLocked(), old) | |
| 136 | 136 | source.mu.Unlock() | |
| 137 | 137 | target.mu.Lock() | |
| 138 | - | require.Nil(t, target.client, "stale handoff attached to the target") | |
| 139 | - | require.Zero(t, target.active, "stale handoff changed target tab focus") | |
| 138 | + | require.Empty(t, target.snapshotAttachmentsLocked(), "stale handoff attached to the target") | |
| 139 | + | require.Zero(t, testAttachmentTabIndexLocked(target), "stale handoff changed target tab focus") | |
| 140 | 140 | target.mu.Unlock() | |
| 141 | 141 | if tt.check != nil { | |
| 142 | 142 | tt.check(t, d, source, target) | |
| @@ -152,17 +152,17 @@ 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 | - | old.publishRoleCapability(token) | |
| 159 | - | effect, admitted := old.beginRoleEffect(token) | |
| 158 | + | old.publishAttachmentCapability(token) | |
| 159 | + | effect, admitted := old.beginAttachmentEffect(token) | |
| 160 | 160 | require.True(t, admitted) | |
| 161 | 161 | ||
| 162 | 162 | ended := make(chan struct{}) | |
| 163 | 163 | release := make(chan struct{}) | |
| 164 | 164 | var endedOnce sync.Once | |
| 165 | - | d.afterActionRoleEffectEnded = func(action string) { | |
| 165 | + | d.afterActionAttachmentEffectEnded = func(action string) { | |
| 166 | 166 | if action == "picker-stopped" { | |
| 167 | 167 | endedOnce.Do(func() { close(ended) }) | |
| 168 | 168 | <-release | |
| @@ -171,7 +171,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing. | |||
| 171 | 171 | expectedCreatedAt := int64(7) | |
| 172 | 172 | done := make(chan error, 1) | |
| 173 | 173 | go func() { | |
| 174 | - | done <- d.switchToTargetForRole(effect.roleToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "picker-stopped") | |
| 174 | + | done <- d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "picker-stopped") | |
| 175 | 175 | }() | |
| 176 | 176 | select { | |
| 177 | 177 | case <-ended: | |
| @@ -182,8 +182,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing. | |||
| 182 | 182 | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 183 | 183 | next.initOverlays() | |
| 184 | 184 | replacement, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 185 | - | target: source, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 186 | - | expectedTransport: next.transportSnapshot(), ready: true, | |
| 185 | + | target: source, next: next, expectedTransport: next.transportSnapshot(), ready: true, | |
| 187 | 186 | }) | |
| 188 | 187 | require.NoError(t, err) | |
| 189 | 188 | close(release) | |
R internal/usecase/daemon/navigation_role_incarnation_test.go → internal/usecase/daemon/navigation_attachment_incarnation_test.go +9 -9 renamed, 80% similar
| @@ -18,7 +18,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing | |||
| 18 | 18 | rebind: func(t *testing.T, d *Daemon, source *session, ac *attachedClient) { | |
| 19 | 19 | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 20 | 20 | source: source, target: source, next: ac, | |
| 21 | - | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 21 | + | ||
| 22 | 22 | expectedTransport: ac.transportSnapshot(), ready: true, | |
| 23 | 23 | }) | |
| 24 | 24 | require.NoError(t, err) | |
| @@ -31,7 +31,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing | |||
| 31 | 31 | ac.replaceTransport(&closeTrackingTransport{}) | |
| 32 | 32 | current := source.attachmentToken(ac, ac.transport()) | |
| 33 | 33 | current.lease = source.renderCoordinator().attachmentLease(ac) | |
| 34 | - | ac.publishRoleCapability(current) | |
| 34 | + | ac.publishAttachmentCapability(current) | |
| 35 | 35 | }, | |
| 36 | 36 | }, | |
| 37 | 37 | } | |
| @@ -47,16 +47,16 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing | |||
| 47 | 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 | - | ac.publishRoleCapability(token) | |
| 54 | - | effect, admitted := ac.beginRoleEffect(token) | |
| 53 | + | ac.publishAttachmentCapability(token) | |
| 54 | + | effect, admitted := ac.beginAttachmentEffect(token) | |
| 55 | 55 | require.True(t, admitted) | |
| 56 | 56 | ||
| 57 | 57 | ended := make(chan struct{}) | |
| 58 | 58 | release := make(chan struct{}) | |
| 59 | - | d.afterActionRoleEffectEnded = func(action string) { | |
| 59 | + | d.afterActionAttachmentEffectEnded = func(action string) { | |
| 60 | 60 | if action == "test-handoff" { | |
| 61 | 61 | close(ended) | |
| 62 | 62 | <-release | |
| @@ -64,7 +64,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing | |||
| 64 | 64 | } | |
| 65 | 65 | done := make(chan error, 1) | |
| 66 | 66 | go func() { | |
| 67 | - | done <- d.switchToTargetForRole(effect.roleToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "test-handoff") | |
| 67 | + | done <- d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "test-handoff") | |
| 68 | 68 | }() | |
| 69 | 69 | awaitTestCompletion(t, ended, "test handoff did not release its role ticket") | |
| 70 | 70 | tt.rebind(t, d, source, ac) | |
| @@ -73,8 +73,8 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing | |||
| 73 | 73 | ||
| 74 | 74 | require.Same(t, source, ac.currentSession()) | |
| 75 | 75 | target.mu.Lock() | |
| 76 | - | require.Nil(t, target.client) | |
| 77 | - | require.Zero(t, target.active) | |
| 76 | + | require.Empty(t, target.snapshotAttachmentsLocked()) | |
| 77 | + | require.Zero(t, testAttachmentTabIndexLocked(target)) | |
| 78 | 78 | target.mu.Unlock() | |
| 79 | 79 | }) | |
| 80 | 80 | } | |
M internal/usecase/daemon/navigation_overflow.go +19 -14
| @@ -53,13 +53,13 @@ func resolveOverflow(dir layout.Direction, cfg domain.NavConfig, position, count | |||
| 53 | 53 | // overflowSourceEligible validates that directional overflow still originates | |
| 54 | 54 | // from the active tiled tab. The session-to-tab lock order matches tab overflow | |
| 55 | 55 | // commit and leaves no lock held across a session handoff. | |
| 56 | - | func overflowSourceEligible(sess *session, expectedSource *tab) bool { | |
| 56 | + | func overflowSourceEligible(sess *session, ac *attachedClient, expectedSource *tab) bool { | |
| 57 | 57 | if sess == nil || expectedSource == nil { | |
| 58 | 58 | return false | |
| 59 | 59 | } | |
| 60 | 60 | sess.mu.Lock() | |
| 61 | 61 | defer sess.mu.Unlock() | |
| 62 | - | if sess.active < 0 || sess.active >= len(sess.tabs) || sess.tabs[sess.active] != expectedSource { | |
| 62 | + | if ac == nil || !attachmentRegisteredLocked(sess, ac) || domain.TabStableID(expectedSource.stableID) != ac.viewSnapshot().tabID { | |
| 63 | 63 | return false | |
| 64 | 64 | } | |
| 65 | 65 | expectedSource.mu.Lock() | |
| @@ -134,18 +134,26 @@ type tabOverflowCandidate struct { | |||
| 134 | 134 | targetOldFocus layout.PaneID | |
| 135 | 135 | } | |
| 136 | 136 | ||
| 137 | - | // prepareTabOverflow captures tab identities under the session lock and uses | |
| 138 | - | // EntryPane's pure lookup before any active-tab state is changed. | |
| 139 | - | func (d *Daemon) prepareTabOverflow(sess *session, expectedSource *tab, dir layout.Direction, span domain.Rect, delta int) (tabOverflowCandidate, bool) { | |
| 137 | + | func (d *Daemon) prepareTabOverflowForAttachment(sess *session, ac *attachedClient, expectedSource *tab, dir layout.Direction, span domain.Rect, delta int) (tabOverflowCandidate, bool) { | |
| 140 | 138 | if sess == nil || expectedSource == nil || (delta != -1 && delta != 1) { | |
| 141 | 139 | return tabOverflowCandidate{}, false | |
| 142 | 140 | } | |
| 143 | 141 | sess.mu.Lock() | |
| 144 | - | if sess.active < 0 || sess.active >= len(sess.tabs) || sess.tabs[sess.active] != expectedSource { | |
| 142 | + | if ac != nil && (!attachmentRegisteredLocked(sess, ac) || domain.TabStableID(expectedSource.stableID) != ac.viewSnapshot().tabID) { | |
| 143 | + | sess.mu.Unlock() | |
| 144 | + | return tabOverflowCandidate{}, false | |
| 145 | + | } | |
| 146 | + | sourceIndex := -1 | |
| 147 | + | for i, tb := range sess.tabs { | |
| 148 | + | if tb == expectedSource { | |
| 149 | + | sourceIndex = i | |
| 150 | + | break | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | if sourceIndex < 0 { | |
| 145 | 154 | sess.mu.Unlock() | |
| 146 | 155 | return tabOverflowCandidate{}, false | |
| 147 | 156 | } | |
| 148 | - | sourceIndex := sess.active | |
| 149 | 157 | targetIndex := sourceIndex + delta | |
| 150 | 158 | if targetIndex < 0 || targetIndex >= len(sess.tabs) { | |
| 151 | 159 | sess.mu.Unlock() | |
| @@ -189,17 +197,14 @@ func (d *Daemon) prepareTabOverflow(sess *session, expectedSource *tab, dir layo | |||
| 189 | 197 | }, true | |
| 190 | 198 | } | |
| 191 | 199 | ||
| 192 | - | // commitTabOverflow atomically revalidates the source and target tab entries. | |
| 193 | - | // The target layout and selected pane are revalidated while locked before the | |
| 194 | - | // focus and active-tab mutations become visible together. | |
| 195 | - | func (d *Daemon) commitTabOverflow(sess *session, candidate tabOverflowCandidate) bool { | |
| 200 | + | func (d *Daemon) commitTabOverflowForAttachment(sess *session, ac *attachedClient, candidate tabOverflowCandidate) bool { | |
| 196 | 201 | if sess == nil || candidate.source == nil || candidate.target == nil { | |
| 197 | 202 | return false | |
| 198 | 203 | } | |
| 199 | 204 | sess.mu.Lock() | |
| 200 | 205 | targetIndex := candidate.sourceIndex + candidate.delta | |
| 201 | - | if candidate.sourceIndex < 0 || candidate.sourceIndex >= len(sess.tabs) || targetIndex < 0 || targetIndex >= len(sess.tabs) || | |
| 202 | - | sess.active != candidate.sourceIndex || sess.tabs[candidate.sourceIndex] != candidate.source || sess.tabs[targetIndex] != candidate.target { | |
| 206 | + | if (ac != nil && (!attachmentRegisteredLocked(sess, ac) || domain.TabStableID(candidate.source.stableID) != ac.viewSnapshot().tabID)) || candidate.sourceIndex < 0 || candidate.sourceIndex >= len(sess.tabs) || targetIndex < 0 || targetIndex >= len(sess.tabs) || | |
| 207 | + | sess.tabs[candidate.sourceIndex] != candidate.source || sess.tabs[targetIndex] != candidate.target { | |
| 203 | 208 | sess.mu.Unlock() | |
| 204 | 209 | return false | |
| 205 | 210 | } | |
| @@ -234,10 +239,10 @@ func (d *Daemon) commitTabOverflow(sess *session, candidate tabOverflowCandidate | |||
| 234 | 239 | } | |
| 235 | 240 | candidate.target.tree = targetTree | |
| 236 | 241 | candidate.target.bumpLayoutGenerationLocked() | |
| 237 | - | sess.active = targetIndex | |
| 238 | 242 | candidate.target.mu.Unlock() | |
| 239 | 243 | candidate.source.mu.Unlock() | |
| 240 | 244 | sess.mu.Unlock() | |
| 245 | + | sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID)) | |
| 241 | 246 | d.applyTabLayout(sess, candidate.target) | |
| 242 | 247 | return true | |
| 243 | 248 | } | |
M internal/usecase/daemon/navigation_overflow_test.go +33 -53
| @@ -42,6 +42,20 @@ func TestResolveOverflowObeysAxisConfigurationAndWalls(t *testing.T) { | |||
| 42 | 42 | } | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | + | func TestPrepareTabOverflowRejectsMissingExpectedSource(t *testing.T) { | |
| 46 | + | d, sess, _, _ := newManualSessionWithPTYs(t, nil, nil) | |
| 47 | + | target := sess.tabs[1] | |
| 48 | + | target.mu.Lock() | |
| 49 | + | target.size = domain.Size{Cols: 41, Rows: 10} | |
| 50 | + | target.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"} | |
| 51 | + | target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |
| 52 | + | target.mu.Unlock() | |
| 53 | + | ||
| 54 | + | missing := &tab{stableID: "missing-source"} | |
| 55 | + | _, ok := d.prepareTabOverflowForAttachment(sess, nil, missing, layout.Right, domain.Rect{Width: 20, Height: 10}, 1) | |
| 56 | + | require.False(t, ok) | |
| 57 | + | } | |
| 58 | + | ||
| 45 | 59 | func TestKeyboardHorizontalOverflowLandsOnFacingEdge(t *testing.T) { | |
| 46 | 60 | tests := []struct { | |
| 47 | 61 | name string | |
| @@ -58,7 +72,7 @@ func TestKeyboardHorizontalOverflowLandsOnFacingEdge(t *testing.T) { | |||
| 58 | 72 | t.Run(tt.name, func(t *testing.T) { | |
| 59 | 73 | d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil) | |
| 60 | 74 | sess.mu.Lock() | |
| 61 | - | sess.active = tt.start | |
| 75 | + | selectTestAttachmentTabLocked(sess, tt.start) | |
| 62 | 76 | target := sess.tabs[tt.wantActive] | |
| 63 | 77 | source := sess.tabs[tt.start] | |
| 64 | 78 | sess.mu.Unlock() | |
| @@ -71,11 +85,11 @@ func TestKeyboardHorizontalOverflowLandsOnFacingEdge(t *testing.T) { | |||
| 71 | 85 | ||
| 72 | 86 | daemonKeyHandler{d: d, ac: ac}.Action(tt.action, nil) | |
| 73 | 87 | ||
| 74 | - | require.Equal(t, tt.wantActive, activeTabIndex(sess)) | |
| 88 | + | require.Equal(t, tt.wantActive, testAttachmentTabIndex(sess)) | |
| 75 | 89 | target.mu.Lock() | |
| 76 | 90 | require.Equal(t, tt.wantFocus, target.tree.Focus) | |
| 77 | 91 | target.mu.Unlock() | |
| 78 | - | require.NotSame(t, source, sess.activeTab()) | |
| 92 | + | require.NotSame(t, source, testAttachmentTab(sess)) | |
| 79 | 93 | }) | |
| 80 | 94 | } | |
| 81 | 95 | } | |
| @@ -93,7 +107,7 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t | |||
| 93 | 107 | target.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: focus} | |
| 94 | 108 | target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |
| 95 | 109 | target.mu.Unlock() | |
| 96 | - | return &session{sessionCore: sessionCore{id: domain.SessionID(id), name: name}, ctx: t.Context(), cancel: func() {}, tabs: tabs, active: active} | |
| 110 | + | return &session{sessionCore: sessionCore{id: domain.SessionID(id), name: name}, ctx: t.Context(), cancel: func() {}, tabs: tabs} | |
| 97 | 111 | } | |
| 98 | 112 | charlie := newSession("live-charlie", "charlie", 1, "pane-2") | |
| 99 | 113 | echo := newSession("live-echo", "echo", 1, "pane-2") | |
| @@ -130,7 +144,7 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t | |||
| 130 | 144 | } | |
| 131 | 145 | ||
| 132 | 146 | for _, target := range []*session{charlie, echo} { | |
| 133 | - | require.Equal(t, 1, activeTabIndex(target), "switch preserves the target active tab") | |
| 147 | + | require.Equal(t, 0, testAttachmentTabIndex(target), "new attachment starts at the deterministic first tab") | |
| 134 | 148 | target.tabs[1].mu.Lock() | |
| 135 | 149 | require.Equal(t, layout.PaneID("pane-2"), target.tabs[1].tree.Focus, "switch preserves the target pane focus") | |
| 136 | 150 | target.tabs[1].mu.Unlock() | |
| @@ -149,11 +163,11 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t | |||
| 149 | 163 | } | |
| 150 | 164 | d.mu.Unlock() | |
| 151 | 165 | alpha.mu.Lock() | |
| 152 | - | attached := alpha.client | |
| 166 | + | attached := alpha.snapshotAttachmentsLocked() | |
| 153 | 167 | alpha.mu.Unlock() | |
| 154 | 168 | require.True(t, stopped, "vertical overflow leaves the stopped session stopped") | |
| 155 | 169 | require.False(t, bravoLive, "the stopped session is absent from the live registry") | |
| 156 | - | require.Same(t, ac, attached, "the source session owns the genuinely attached client after returning") | |
| 170 | + | require.Contains(t, attached, ac, "the source session owns the genuinely attached client after returning") | |
| 157 | 171 | } | |
| 158 | 172 | ||
| 159 | 173 | func TestKeyboardVerticalOverflowRefusesVisibleFloatingSource(t *testing.T) { | |
| @@ -172,10 +186,10 @@ func TestKeyboardVerticalOverflowRefusesVisibleFloatingSource(t *testing.T) { | |||
| 172 | 186 | ||
| 173 | 187 | require.Same(t, alpha, ac.currentSession()) | |
| 174 | 188 | alpha.mu.Lock() | |
| 175 | - | require.Same(t, ac, alpha.client) | |
| 189 | + | require.Contains(t, alpha.snapshotAttachmentsLocked(), ac) | |
| 176 | 190 | alpha.mu.Unlock() | |
| 177 | 191 | charlie.mu.Lock() | |
| 178 | - | require.Nil(t, charlie.client) | |
| 192 | + | require.Empty(t, charlie.snapshotAttachmentsLocked()) | |
| 179 | 193 | charlie.mu.Unlock() | |
| 180 | 194 | } | |
| 181 | 195 | ||
| @@ -196,10 +210,10 @@ func TestVerticalOverflowRevalidatesFloatingSourceBeforeHandoff(t *testing.T) { | |||
| 196 | 210 | require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor) | |
| 197 | 211 | require.Same(t, alpha, ac.currentSession()) | |
| 198 | 212 | alpha.mu.Lock() | |
| 199 | - | require.Same(t, ac, alpha.client) | |
| 213 | + | require.Contains(t, alpha.snapshotAttachmentsLocked(), ac) | |
| 200 | 214 | alpha.mu.Unlock() | |
| 201 | 215 | charlie.mu.Lock() | |
| 202 | - | require.Nil(t, charlie.client) | |
| 216 | + | require.Empty(t, charlie.snapshotAttachmentsLocked()) | |
| 203 | 217 | charlie.mu.Unlock() | |
| 204 | 218 | } | |
| 205 | 219 | ||
| @@ -208,7 +222,7 @@ func TestVerticalOverflowRejectsStaleSourceTab(t *testing.T) { | |||
| 208 | 222 | alpha.mu.Lock() | |
| 209 | 223 | alpha.name = "alpha" | |
| 210 | 224 | expectedSource := alpha.tabs[0] | |
| 211 | - | alpha.active = 1 | |
| 225 | + | selectTestAttachmentTabLocked(alpha, 1) | |
| 212 | 226 | alpha.mu.Unlock() | |
| 213 | 227 | charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |
| 214 | 228 | d.mu.Lock() | |
| @@ -218,10 +232,10 @@ func TestVerticalOverflowRejectsStaleSourceTab(t *testing.T) { | |||
| 218 | 232 | require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, expectedSource, picker.Target{Session: charlie.id, TabIndex: -1}), errNoNeighbor) | |
| 219 | 233 | require.Same(t, alpha, ac.currentSession()) | |
| 220 | 234 | alpha.mu.Lock() | |
| 221 | - | require.Same(t, ac, alpha.client) | |
| 235 | + | require.Contains(t, alpha.snapshotAttachmentsLocked(), ac) | |
| 222 | 236 | alpha.mu.Unlock() | |
| 223 | 237 | charlie.mu.Lock() | |
| 224 | - | require.Nil(t, charlie.client) | |
| 238 | + | require.Empty(t, charlie.snapshotAttachmentsLocked()) | |
| 225 | 239 | charlie.mu.Unlock() | |
| 226 | 240 | } | |
| 227 | 241 | ||
| @@ -251,16 +265,16 @@ func TestVerticalOverflowTreatsDisplacedSourceClientAsNoNeighbor(t *testing.T) { | |||
| 251 | 265 | require.True(t, ok) | |
| 252 | 266 | replacement := &attachedClient{} | |
| 253 | 267 | alpha.mu.Lock() | |
| 254 | - | alpha.client = replacement | |
| 268 | + | alpha.registerAttachmentLocked(replacement) | |
| 255 | 269 | alpha.mu.Unlock() | |
| 256 | 270 | ||
| 257 | 271 | require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor) | |
| 258 | 272 | require.Same(t, alpha, ac.currentSession()) | |
| 259 | 273 | alpha.mu.Lock() | |
| 260 | - | require.Same(t, replacement, alpha.client) | |
| 274 | + | require.Contains(t, alpha.snapshotAttachmentsLocked(), replacement) | |
| 261 | 275 | alpha.mu.Unlock() | |
| 262 | 276 | charlie.mu.Lock() | |
| 263 | - | require.Nil(t, charlie.client) | |
| 277 | + | require.Empty(t, charlie.snapshotAttachmentsLocked()) | |
| 264 | 278 | charlie.mu.Unlock() | |
| 265 | 279 | } | |
| 266 | 280 | ||
| @@ -314,7 +328,7 @@ func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) { | |||
| 314 | 328 | continue | |
| 315 | 329 | } | |
| 316 | 330 | candidate.mu.Lock() | |
| 317 | - | if candidate.client == ac { | |
| 331 | + | if attachmentRegisteredLocked(candidate, ac) { | |
| 318 | 332 | owners = append(owners, candidate) | |
| 319 | 333 | } | |
| 320 | 334 | candidate.mu.Unlock() | |
| @@ -351,41 +365,7 @@ func TestKeyboardHorizontalOverflowRespectsDefaultsWallsFailedEntryAndFloating(t | |||
| 351 | 365 | ||
| 352 | 366 | daemonKeyHandler{d: d, ac: ac}.Action(tt.dir, nil) | |
| 353 | 367 | ||
| 354 | - | require.Equal(t, 0, activeTabIndex(sess)) | |
| 355 | - | }) | |
| 356 | - | } | |
| 357 | - | } | |
| 358 | - | ||
| 359 | - | func TestCommitTabOverflowRevalidatesTabPointerIdentities(t *testing.T) { | |
| 360 | - | tests := []struct { | |
| 361 | - | name string | |
| 362 | - | mutate func(sess *session, candidate tabOverflowCandidate) | |
| 363 | - | }{ | |
| 364 | - | {name: "source no longer active", mutate: func(sess *session, _ tabOverflowCandidate) { | |
| 365 | - | sess.mu.Lock() | |
| 366 | - | sess.active = 1 | |
| 367 | - | sess.mu.Unlock() | |
| 368 | - | }}, | |
| 369 | - | {name: "target entry replaced", mutate: func(sess *session, candidate tabOverflowCandidate) { | |
| 370 | - | sess.mu.Lock() | |
| 371 | - | sess.tabs[1] = newTab(nil, candidate.target.size) | |
| 372 | - | sess.mu.Unlock() | |
| 373 | - | }}, | |
| 374 | - | {name: "source gains visible floating pane", mutate: func(_ *session, candidate tabOverflowCandidate) { | |
| 375 | - | installTestFloating(candidate.source, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true) | |
| 376 | - | }}, | |
| 377 | - | } | |
| 378 | - | ||
| 379 | - | for _, tt := range tests { | |
| 380 | - | t.Run(tt.name, func(t *testing.T) { | |
| 381 | - | d, sess, _, _ := newManualSessionWithPTYs(t, nil, nil) | |
| 382 | - | candidate, ok := d.prepareTabOverflow(sess, sess.tabs[0], layout.Right, domain.Rect{Width: 80, Height: 23}, 1) | |
| 383 | - | require.True(t, ok) | |
| 384 | - | tt.mutate(sess, candidate) | |
| 385 | - | activeBeforeCommit := activeTabIndex(sess) | |
| 386 | - | ||
| 387 | - | require.False(t, d.commitTabOverflow(sess, candidate)) | |
| 388 | - | require.Equal(t, activeBeforeCommit, activeTabIndex(sess)) | |
| 368 | + | require.Equal(t, 0, testAttachmentTabIndex(sess)) | |
| 389 | 369 | }) | |
| 390 | 370 | } | |
| 391 | 371 | } | |
M internal/usecase/daemon/notices_overlay_test.go +2 -2
| @@ -434,7 +434,7 @@ func TestYankLastNotificationWithEmptyHistoryShowsWarnToast(t *testing.T) { | |||
| 434 | 434 | func TestProxyYankLastNotificationEmptyHistoryUsesAttachmentScope(t *testing.T) { | |
| 435 | 435 | d, proxy, ac, _, handler := newProxyInputHarness(t) | |
| 436 | 436 | handler.enterPalette() | |
| 437 | - | d.handlePaletteInput(ac, []byte("YLN\r"), handler.roleToken.effect) | |
| 437 | + | d.handlePaletteInput(ac, []byte("YLN\r"), handler.connectionToken.effect) | |
| 438 | 438 | ||
| 439 | 439 | history := d.notices.history() | |
| 440 | 440 | require.Len(t, history, 1) | |
| @@ -444,7 +444,7 @@ func TestProxyYankLastNotificationEmptyHistoryUsesAttachmentScope(t *testing.T) | |||
| 444 | 444 | ||
| 445 | 445 | func TestProxyNoticeYankSendFailureDetachesAndArmsWarmExpiry(t *testing.T) { | |
| 446 | 446 | d, proxy, ac, _, handler := newProxyInputHarness(t) | |
| 447 | - | handler.roleToken.effect.End() | |
| 447 | + | handler.connectionToken.effect.End() | |
| 448 | 448 | client, ok := ac.transport().(*proxyTestTransport) | |
| 449 | 449 | require.True(t, ok) | |
| 450 | 450 | client.sendFails.Store(true) | |
M internal/usecase/daemon/notify.go +15 -22
| @@ -267,9 +267,9 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No | |||
| 267 | 267 | if sess != nil { | |
| 268 | 268 | d.notices.routingMu.Lock() | |
| 269 | 269 | sess.mu.Lock() | |
| 270 | - | ac := sess.client | |
| 270 | + | attachments := sess.snapshotAttachmentsLocked() | |
| 271 | 271 | sess.mu.Unlock() | |
| 272 | - | if ac == nil { | |
| 272 | + | if len(attachments) == 0 { | |
| 273 | 273 | d.notices.queueGlobal(n) | |
| 274 | 274 | d.notices.routingMu.Unlock() | |
| 275 | 275 | return | |
| @@ -277,11 +277,14 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No | |||
| 277 | 277 | if d.notices.beforeSessionDelivery != nil { | |
| 278 | 278 | d.notices.beforeSessionDelivery() | |
| 279 | 279 | } | |
| 280 | - | // Publish the toast while detach is excluded, then release routing before | |
| 281 | - | // repainting. Rendering can report another notice and re-enter routing. | |
| 282 | - | published := d.publishToast(ac, n) | |
| 280 | + | published := make([]*attachedClient, 0, len(attachments)) | |
| 281 | + | for _, ac := range attachments { | |
| 282 | + | if d.publishToast(ac, n) { | |
| 283 | + | published = append(published, ac) | |
| 284 | + | } | |
| 285 | + | } | |
| 283 | 286 | d.notices.routingMu.Unlock() | |
| 284 | - | if published { | |
| 287 | + | for _, ac := range published { | |
| 285 | 288 | d.repaintForNotice(ac) | |
| 286 | 289 | } | |
| 287 | 290 | return | |
| @@ -308,14 +311,12 @@ func (d *Daemon) deliverGlobal(n domain.Notification) { | |||
| 308 | 311 | } | |
| 309 | 312 | targets := make([]*attachedClient, 0, len(sessions)) | |
| 310 | 313 | for _, s := range sessions { | |
| 311 | - | core := s.core() | |
| 312 | - | core.mu.Lock() | |
| 313 | - | ac := core.client | |
| 314 | - | core.mu.Unlock() | |
| 315 | - | if ac == nil || !d.publishToast(ac, n) { | |
| 316 | - | continue | |
| 314 | + | for _, ac := range snapshotAttachmentSession(s) { | |
| 315 | + | if !d.publishToast(ac, n) { | |
| 316 | + | continue | |
| 317 | + | } | |
| 318 | + | targets = append(targets, ac) | |
| 317 | 319 | } | |
| 318 | - | targets = append(targets, ac) | |
| 319 | 320 | } | |
| 320 | 321 | if len(targets) == 0 { | |
| 321 | 322 | if d.notices.beforeQueueGlobal != nil { | |
| @@ -338,7 +339,7 @@ func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) { | |||
| 338 | 339 | d.notices.routingMu.Lock() | |
| 339 | 340 | ||
| 340 | 341 | sess.mu.Lock() | |
| 341 | - | current := sess.client == ac | |
| 342 | + | current := attachmentRegisteredLocked(sess, ac) | |
| 342 | 343 | sess.mu.Unlock() | |
| 343 | 344 | if !current { | |
| 344 | 345 | d.notices.routingMu.Unlock() | |
| @@ -367,14 +368,6 @@ func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode, | |||
| 367 | 368 | d.notify(nil, sev, code, msg, cause) | |
| 368 | 369 | } | |
| 369 | 370 | ||
| 370 | - | // showToast publishes a notice into one client's toast stack and repaints only | |
| 371 | - | // after the state mutation is complete. | |
| 372 | - | func (d *Daemon) showToast(ac *attachedClient, n domain.Notification) { | |
| 373 | - | if d.publishToast(ac, n) { | |
| 374 | - | d.repaintForNotice(ac) | |
| 375 | - | } | |
| 376 | - | } | |
| 377 | - | ||
| 378 | 371 | // publishToast durably mutates one client's toast state without repainting. | |
| 379 | 372 | // Routing paths call it while routingMu excludes detach, then release routingMu | |
| 380 | 373 | // before rendering so a render failure can safely route another notice. | |
M internal/usecase/daemon/notify_test.go +17 -148
| @@ -55,102 +55,9 @@ func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing. | |||
| 55 | 55 | } | |
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | - | func TestHandleClientNoticeSerializesReplacement(t *testing.T) { | |
| 59 | - | d, sess, old, _ := newNoticeFixture(t, newNoticeClock()) | |
| 60 | - | ||
| 61 | - | enteredMutation := make(chan struct{}) | |
| 62 | - | releaseMutation := make(chan struct{}) | |
| 63 | - | d.notices.beforeClientNoticeMutation = func() { | |
| 64 | - | close(enteredMutation) | |
| 65 | - | <-releaseMutation | |
| 66 | - | } | |
| 67 | - | t.Cleanup(func() { | |
| 68 | - | select { | |
| 69 | - | case <-releaseMutation: | |
| 70 | - | default: | |
| 71 | - | close(releaseMutation) | |
| 72 | - | } | |
| 73 | - | }) | |
| 74 | - | ||
| 75 | - | noticeDone := make(chan struct{}) | |
| 76 | - | go func() { | |
| 77 | - | d.handleClientNotice(sess, old, ports.ClientNotice{Action: ports.ClientNoticeClipboardFallback}) | |
| 78 | - | close(noticeDone) | |
| 79 | - | }() | |
| 80 | - | <-enteredMutation // old has validated ownership and holds routingMu | |
| 81 | - | if d.notices.routingMu.TryLock() { | |
| 82 | - | d.notices.routingMu.Unlock() | |
| 83 | - | t.Fatal("client notice mutation must retain routingMu through publication") | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | tr, _ := newCapturingTransport(t) | |
| 87 | - | replaced := make(chan *attachedClient, 1) | |
| 88 | - | go func() { | |
| 89 | - | current, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 90 | - | replaced <- current | |
| 91 | - | }() | |
| 92 | - | ||
| 93 | - | // Releasing the mutation lets the already-validated notice publish before | |
| 94 | - | // replacement can own the session. Snatch cleanup then clears the displaced | |
| 95 | - | // attachment's presentation state without moving the notice to the new one. | |
| 96 | - | close(releaseMutation) | |
| 97 | - | <-noticeDone | |
| 98 | - | current := <-replaced | |
| 99 | - | d.attachmentCleanupWg.Wait() | |
| 100 | - | ||
| 101 | - | history := d.notices.history() | |
| 102 | - | require.Len(t, history, 1) | |
| 103 | - | require.Equal(t, domain.NoticeClipboard, history[0].Code) | |
| 104 | - | oldToasts, _ := visibleToasts(old) | |
| 105 | - | require.Empty(t, oldToasts) | |
| 106 | - | newToasts, _ := visibleToasts(current) | |
| 107 | - | require.Empty(t, newToasts) | |
| 108 | - | sess.mu.Lock() | |
| 109 | - | require.Same(t, current, sess.client) | |
| 110 | - | sess.mu.Unlock() | |
| 111 | - | } | |
| 112 | - | ||
| 113 | - | func TestHandleClientNoticeRejectsReplacedClient(t *testing.T) { | |
| 114 | - | for _, tt := range []struct { | |
| 115 | - | name string | |
| 116 | - | setup func(*Daemon, *attachedClient, domain.SessionID) | |
| 117 | - | notice ports.ClientNotice | |
| 118 | - | }{ | |
| 119 | - | { | |
| 120 | - | name: "record", | |
| 121 | - | notice: ports.ClientNotice{Action: ports.ClientNoticeClipboardFallback}, | |
| 122 | - | }, | |
| 123 | - | { | |
| 124 | - | name: "dismiss", | |
| 125 | - | setup: func(d *Daemon, ac *attachedClient, sid domain.SessionID) { | |
| 126 | - | d.showToast(ac, notice(domain.NoticeConnection, "connection degraded", sid)) | |
| 127 | - | }, | |
| 128 | - | notice: ports.ClientNotice{Action: ports.ClientNoticeLinkConnected}, | |
| 129 | - | }, | |
| 130 | - | } { | |
| 131 | - | t.Run(tt.name, func(t *testing.T) { | |
| 132 | - | d, sess, old, _ := newNoticeFixture(t, newNoticeClock()) | |
| 133 | - | if tt.setup != nil { | |
| 134 | - | tt.setup(d, old, sess.id) | |
| 135 | - | } | |
| 136 | - | tr, _ := newCapturingTransport(t) | |
| 137 | - | current, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 138 | - | d.attachmentCleanupWg.Wait() | |
| 139 | - | ||
| 140 | - | d.handleClientNotice(sess, old, tt.notice) | |
| 141 | - | ||
| 142 | - | require.Empty(t, d.notices.history(), "a replaced client must not record notices") | |
| 143 | - | oldToasts, _ := visibleToasts(old) | |
| 144 | - | require.Empty(t, oldToasts, "snatch cleanup must remove displaced notice presentation") | |
| 145 | - | currentToasts, _ := visibleToasts(current) | |
| 146 | - | require.Empty(t, currentToasts) | |
| 147 | - | }) | |
| 148 | - | } | |
| 149 | - | } | |
| 150 | - | ||
| 151 | 58 | func TestMalformedClientNoticeIsIgnored(t *testing.T) { | |
| 152 | 59 | d, sess, ac, _ := newNoticeFixture(t, ¬iceClock{}) | |
| 153 | - | d.attachCoordinator(sess, nil, ac, true) | |
| 60 | + | d.attachCoordinator(sess, ac, true) | |
| 154 | 61 | tr, ok := ac.tr.(*portsmocks.MockTransport) | |
| 155 | 62 | require.True(t, ok, "attached client transport must be a MockTransport") | |
| 156 | 63 | frames := []ports.Frame{ | |
| @@ -379,7 +286,7 @@ func newNoticeFixture(t *testing.T, clk ports.Clock) (*Daemon, *session, *attach | |||
| 379 | 286 | for _, pane := range tb.panes { | |
| 380 | 287 | pane.ctx, pane.cancel = wctx, wcancel | |
| 381 | 288 | } | |
| 382 | - | sess := &session{sessionCore: sessionCore{id: "manual", name: "work", client: ac}, ctx: sctx, cancel: cancel, tabs: []*tab{tb}} | |
| 289 | + | sess := &session{sessionCore: sessionCore{id: "manual", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx, cancel: cancel, tabs: []*tab{tb}} | |
| 383 | 290 | ac.setSession(sess) | |
| 384 | 291 | d.sessions[sess.id] = sess | |
| 385 | 292 | t.Cleanup(cancel) | |
| @@ -521,7 +428,7 @@ func TestNotifyRoutesToSessionClientOnly(t *testing.T) { | |||
| 521 | 428 | ||
| 522 | 429 | other := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 523 | 430 | other.initOverlays() | |
| 524 | - | sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", client: other}, ctx: sess.ctx, cancel: func() {}} | |
| 431 | + | sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", attachments: map[*attachedClient]struct{}{other: {}}}, ctx: sess.ctx, cancel: func() {}} | |
| 525 | 432 | other.setSession(sess2) | |
| 526 | 433 | d.mu.Lock() | |
| 527 | 434 | d.sessions[sess2.id] = sess2 | |
| @@ -606,7 +513,7 @@ func TestNotifySessionScopedReleasesRoutingBeforeRepaint(t *testing.T) { | |||
| 606 | 513 | func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) { | |
| 607 | 514 | d, sessA, oldA, _ := newNoticeFixture(t, newNoticeClock()) | |
| 608 | 515 | sessA.mu.Lock() | |
| 609 | - | sessA.client = nil | |
| 516 | + | clearAttachmentsForTestLocked(sessA) | |
| 610 | 517 | sessA.mu.Unlock() | |
| 611 | 518 | ||
| 612 | 519 | clientB := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| @@ -628,7 +535,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) { | |||
| 628 | 535 | require.Empty(t, oldToasts, "a detached session must not paint a toast") | |
| 629 | 536 | ||
| 630 | 537 | sessB.mu.Lock() | |
| 631 | - | sessB.client = clientB | |
| 538 | + | sessB.registerAttachmentLocked(clientB) | |
| 632 | 539 | sessB.mu.Unlock() | |
| 633 | 540 | d.drainPendingForFirstPaint(sessB, clientB) | |
| 634 | 541 | bToasts, _ := visibleToasts(clientB) | |
| @@ -638,7 +545,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) { | |||
| 638 | 545 | require.Equal(t, "global announcement", bToasts[0].Message) | |
| 639 | 546 | ||
| 640 | 547 | sessA.mu.Lock() | |
| 641 | - | sessA.client = oldA | |
| 548 | + | sessA.registerAttachmentLocked(oldA) | |
| 642 | 549 | sessA.mu.Unlock() | |
| 643 | 550 | d.drainPendingForFirstPaint(sessA, oldA) | |
| 644 | 551 | aToasts, _ := visibleToasts(oldA) | |
| @@ -652,7 +559,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) { | |||
| 652 | 559 | secondA.initOverlays() | |
| 653 | 560 | secondA.setSession(sessA) | |
| 654 | 561 | sessA.mu.Lock() | |
| 655 | - | sessA.client = secondA | |
| 562 | + | sessA.registerAttachmentLocked(secondA) | |
| 656 | 563 | sessA.mu.Unlock() | |
| 657 | 564 | d.drainPendingForFirstPaint(sessA, secondA) | |
| 658 | 565 | secondToasts, _ := visibleToasts(secondA) | |
| @@ -664,7 +571,7 @@ func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) { | |||
| 664 | 571 | ||
| 665 | 572 | second := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 666 | 573 | second.initOverlays() | |
| 667 | - | sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", client: second}, ctx: sess.ctx, cancel: func() {}} | |
| 574 | + | sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", attachments: map[*attachedClient]struct{}{second: {}}}, ctx: sess.ctx, cancel: func() {}} | |
| 668 | 575 | second.setSession(sess2) | |
| 669 | 576 | d.mu.Lock() | |
| 670 | 577 | d.sessions[sess2.id] = sess2 | |
| @@ -682,7 +589,7 @@ func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) { | |||
| 682 | 589 | func TestNotifyGlobalDoesNotStrandNoticeWhenFirstPaintRacesQueue(t *testing.T) { | |
| 683 | 590 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |
| 684 | 591 | sess.mu.Lock() | |
| 685 | - | sess.client = nil | |
| 592 | + | clearAttachmentsForTestLocked(sess) | |
| 686 | 593 | sess.mu.Unlock() | |
| 687 | 594 | ||
| 688 | 595 | routeObserved := make(chan struct{}) | |
| @@ -701,7 +608,7 @@ func TestNotifyGlobalDoesNotStrandNoticeWhenFirstPaintRacesQueue(t *testing.T) { | |||
| 701 | 608 | // Publish the attachment and start firstPaint while global routing still | |
| 702 | 609 | // owns its gate. firstPaint must wait for the queue, then drain it. | |
| 703 | 610 | sess.mu.Lock() | |
| 704 | - | sess.client = ac | |
| 611 | + | sess.registerAttachmentLocked(ac) | |
| 705 | 612 | sess.mu.Unlock() | |
| 706 | 613 | paintDone := make(chan struct{}) | |
| 707 | 614 | go func() { | |
| @@ -744,52 +651,14 @@ func TestNotifyGlobalSerializesDetachWithDelivery(t *testing.T) { | |||
| 744 | 651 | toasts, _ := visibleToasts(ac) | |
| 745 | 652 | require.Len(t, toasts, 1) | |
| 746 | 653 | sess.mu.Lock() | |
| 747 | - | require.Nil(t, sess.client) | |
| 748 | - | sess.mu.Unlock() | |
| 749 | - | } | |
| 750 | - | ||
| 751 | - | func TestNotifyGlobalSerializesReplacementWithDelivery(t *testing.T) { | |
| 752 | - | d, sess, old, _ := newNoticeFixture(t, newNoticeClock()) | |
| 753 | - | ||
| 754 | - | routeObserved := make(chan struct{}) | |
| 755 | - | releaseRoute := make(chan struct{}) | |
| 756 | - | d.notices.beforeGlobalDelivery = func() { | |
| 757 | - | close(routeObserved) | |
| 758 | - | <-releaseRoute | |
| 759 | - | } | |
| 760 | - | globalDone := make(chan struct{}) | |
| 761 | - | go func() { | |
| 762 | - | d.NotifyGlobal(domain.NoticeWarn, domain.NoticeConfigReload, "config reload failed", nil) | |
| 763 | - | close(globalDone) | |
| 764 | - | }() | |
| 765 | - | <-routeObserved // old is selected and routingMu is held | |
| 766 | - | ||
| 767 | - | tr, _ := newCapturingTransport(t) | |
| 768 | - | replaced := make(chan *attachedClient, 1) | |
| 769 | - | go func() { | |
| 770 | - | ac, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 771 | - | replaced <- ac | |
| 772 | - | }() | |
| 773 | - | close(releaseRoute) | |
| 774 | - | <-globalDone | |
| 775 | - | current := <-replaced | |
| 776 | - | d.attachmentCleanupWg.Wait() | |
| 777 | - | ||
| 778 | - | // Replacement publishes only after delivery, then snatch cleanup removes | |
| 779 | - | // the displaced attachment's presentation state. | |
| 780 | - | oldToasts, _ := visibleToasts(old) | |
| 781 | - | require.Empty(t, oldToasts) | |
| 782 | - | newToasts, _ := visibleToasts(current) | |
| 783 | - | require.Empty(t, newToasts) | |
| 784 | - | sess.mu.Lock() | |
| 785 | - | require.Same(t, current, sess.client) | |
| 654 | + | require.Empty(t, sess.snapshotAttachmentsLocked()) | |
| 786 | 655 | sess.mu.Unlock() | |
| 787 | 656 | } | |
| 788 | 657 | ||
| 789 | 658 | func TestDetachedNoticeCoalescingPreservesDistinctContentAndSeverity(t *testing.T) { | |
| 790 | 659 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |
| 791 | 660 | sess.mu.Lock() | |
| 792 | - | sess.client = nil | |
| 661 | + | clearAttachmentsForTestLocked(sess) | |
| 793 | 662 | sess.mu.Unlock() | |
| 794 | 663 | ||
| 795 | 664 | d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "build started", nil) | |
| @@ -797,7 +666,7 @@ func TestDetachedNoticeCoalescingPreservesDistinctContentAndSeverity(t *testing. | |||
| 797 | 666 | d.notify(sess, domain.NoticeWarn, domain.NoticeUser, "build delayed", nil) | |
| 798 | 667 | ||
| 799 | 668 | sess.mu.Lock() | |
| 800 | - | sess.client = ac | |
| 669 | + | sess.registerAttachmentLocked(ac) | |
| 801 | 670 | sess.mu.Unlock() | |
| 802 | 671 | d.drainPendingForFirstPaint(sess, ac) | |
| 803 | 672 | ||
| @@ -813,7 +682,7 @@ func TestDetachedNoticeCoalescingPreservesDistinctContentAndSeverity(t *testing. | |||
| 813 | 682 | func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) { | |
| 814 | 683 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |
| 815 | 684 | sess.mu.Lock() | |
| 816 | - | sess.client = nil | |
| 685 | + | clearAttachmentsForTestLocked(sess) | |
| 817 | 686 | sess.mu.Unlock() | |
| 818 | 687 | ||
| 819 | 688 | d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotWrite, "snapshot write failed", nil) | |
| @@ -824,7 +693,7 @@ func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) { | |||
| 824 | 693 | ||
| 825 | 694 | // Re-attach and let firstPaint drain the pending queue. | |
| 826 | 695 | sess.mu.Lock() | |
| 827 | - | sess.client = ac | |
| 696 | + | sess.registerAttachmentLocked(ac) | |
| 828 | 697 | sess.mu.Unlock() | |
| 829 | 698 | d.firstPaint(sess, ac, domain.Size{}) | |
| 830 | 699 | ||
| @@ -841,7 +710,7 @@ func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) { | |||
| 841 | 710 | func TestNotifyGlobalPersistDisabledDrainsAtFirstAttach(t *testing.T) { | |
| 842 | 711 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |
| 843 | 712 | sess.mu.Lock() | |
| 844 | - | sess.client = nil | |
| 713 | + | clearAttachmentsForTestLocked(sess) | |
| 845 | 714 | sess.mu.Unlock() | |
| 846 | 715 | ||
| 847 | 716 | cause := errors.New("open store.db: permission denied") | |
| @@ -854,7 +723,7 @@ func TestNotifyGlobalPersistDisabledDrainsAtFirstAttach(t *testing.T) { | |||
| 854 | 723 | ||
| 855 | 724 | // Re-attach and let firstPaint drain the pending queue. | |
| 856 | 725 | sess.mu.Lock() | |
| 857 | - | sess.client = ac | |
| 726 | + | sess.registerAttachmentLocked(ac) | |
| 858 | 727 | sess.mu.Unlock() | |
| 859 | 728 | d.firstPaint(sess, ac, domain.Size{}) | |
| 860 | 729 | ||
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 } | |
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) | |
M internal/usecase/daemon/output_state_sync_test.go +3 -56
| @@ -285,7 +285,7 @@ func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) { | |||
| 285 | 285 | p := newResizeReaderPTY([]byte("\x1b[1;81H" + strings.Repeat("B", 40))) | |
| 286 | 286 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 287 | 287 | ac.output.maxOutstanding = tc.window | |
| 288 | - | pane := sess.activeTab().focusedPane() | |
| 288 | + | pane := testAttachmentTab(sess).focusedPane() | |
| 289 | 289 | p.applying = func() bool { | |
| 290 | 290 | pane.mu.Lock() | |
| 291 | 291 | defer pane.mu.Unlock() | |
| @@ -299,7 +299,7 @@ func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) { | |||
| 299 | 299 | ||
| 300 | 300 | d.sessWg.Add(1) | |
| 301 | 301 | pane.onExit = func() {} | |
| 302 | - | go d.ptyReader(sess, sess.activeTab(), pane) | |
| 302 | + | go d.ptyReader(sess, testAttachmentTab(sess), pane) | |
| 303 | 303 | defer func() { p.close(); d.sessWg.Wait() }() | |
| 304 | 304 | ||
| 305 | 305 | // The resize deadline drives coordinator prepare/apply/commit. No test | |
| @@ -387,59 +387,6 @@ func TestResizeBurstFlushesOnlyLatestGeometry(t *testing.T) { | |||
| 387 | 387 | awaitTestCompletion(t, latestDone, "latest resize callback did not complete") | |
| 388 | 388 | awaitFrame(t, sends, ports.MsgOutput) | |
| 389 | 389 | require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size) | |
| 390 | - | require.Equal(t, 120, sess.activeTab().focusedPane().screen.Frame.Width) | |
| 390 | + | require.Equal(t, 120, testAttachmentTab(sess).focusedPane().screen.Frame.Width) | |
| 391 | 391 | requireNoOutputFrame(t, sends) | |
| 392 | 392 | } | |
| 393 | - | ||
| 394 | - | func TestDatagramWindowOneCoalescesPaintsUntilMsgAck(t *testing.T) { | |
| 395 | - | p, releasePTY := newBlockingPTY(t) | |
| 396 | - | defer releasePTY() | |
| 397 | - | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 398 | - | tr := newDatagramTestTransport() | |
| 399 | - | _, ac, err := d.route(ports.Hello{ | |
| 400 | - | Version: ports.ProtocolVersion, | |
| 401 | - | Intent: ports.IntentAttach, | |
| 402 | - | Name: sess.name, | |
| 403 | - | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 404 | - | MaxOutputInFlight: 1, | |
| 405 | - | }, tr) | |
| 406 | - | require.NoError(t, err) | |
| 407 | - | require.True(t, sess.renderCoordinator().markAttachmentReady(sess.renderCoordinator().attachmentLease(ac))) | |
| 408 | - | ||
| 409 | - | pane := sess.tabs[0].focusedPane() | |
| 410 | - | pane.screen.Write([]byte("A")) | |
| 411 | - | d.paint(sess, ac, false, nil) | |
| 412 | - | first := awaitFrame(t, tr.sends, ports.MsgOutput) | |
| 413 | - | firstOutput, err := ports.UnmarshalOutput(first.Payload) | |
| 414 | - | require.NoError(t, err) | |
| 415 | - | require.Equal(t, uint64(1), firstOutput.NewStateNum) | |
| 416 | - | for range 100 { | |
| 417 | - | pane.screen.Write([]byte("x")) | |
| 418 | - | d.invalidateRender(sess, ac, false, "output_state_sync_test.go") | |
| 419 | - | } | |
| 420 | - | pane.screen.Write([]byte("LATEST")) | |
| 421 | - | d.invalidateRender(sess, ac, false, "output_state_sync_test.go") | |
| 422 | - | requireNoOutputFrame(t, tr.sends) | |
| 423 | - | require.Equal(t, uint64(1), ac.output.outstanding(), "only one state-bearing datagram output may be unacknowledged") | |
| 424 | - | ||
| 425 | - | // Side-effect output is control-like: it must pass while the state window is | |
| 426 | - | // full without consuming another state number. | |
| 427 | - | require.NoError(t, d.boundedSendOutputErr(ac, []byte("side-effect"))) | |
| 428 | - | sideEffect := awaitFrame(t, tr.sends, ports.MsgOutput) | |
| 429 | - | sideEffectOutput, err := ports.UnmarshalOutput(sideEffect.Payload) | |
| 430 | - | require.NoError(t, err) | |
| 431 | - | require.Zero(t, sideEffectOutput.BaseStateNum) | |
| 432 | - | require.Zero(t, sideEffectOutput.NewStateNum) | |
| 433 | - | require.Equal(t, uint64(1), ac.output.outstanding()) | |
| 434 | - | ||
| 435 | - | tr.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: firstOutput.NewStateNum})} | |
| 436 | - | require.NoError(t, tr.Close()) | |
| 437 | - | d.runConnLoop(ac) | |
| 438 | - | second := awaitFrame(t, tr.sends, ports.MsgOutput) | |
| 439 | - | secondOutput, err := ports.UnmarshalOutput(second.Payload) | |
| 440 | - | require.NoError(t, err) | |
| 441 | - | require.Equal(t, firstOutput.NewStateNum, secondOutput.BaseStateNum) | |
| 442 | - | require.Equal(t, uint64(2), secondOutput.NewStateNum) | |
| 443 | - | require.Contains(t, string(secondOutput.Data), "LATEST", "cumulative ACK must release the latest coalesced state") | |
| 444 | - | require.Equal(t, uint64(1), ac.output.outstanding()) | |
| 445 | - | } | |
M internal/usecase/daemon/overlay_runtime.go +3 -109
| @@ -48,7 +48,7 @@ type overlayRuntime struct { | |||
| 48 | 48 | promptMu sync.Mutex | |
| 49 | 49 | prompt *promptui.Model | |
| 50 | 50 | promptSubmit func(string) error | |
| 51 | - | promptTransitionSubmit func(string, attachmentRoleToken) error | |
| 51 | + | promptTransitionSubmit func(string, attachmentConnectionToken) error | |
| 52 | 52 | promptPending []byte | |
| 53 | 53 | ||
| 54 | 54 | resizeMu sync.Mutex | |
| @@ -242,118 +242,12 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool { | |||
| 242 | 242 | return active || prePublication | |
| 243 | 243 | } | |
| 244 | 244 | ||
| 245 | - | // clearForSnatch clears each overlay family independently. Every family | |
| 246 | - | // revalidates the displaced role generation while holding only its own mutex, | |
| 247 | - | // so cleanup can neither participate in cross-family lock inversion nor erase | |
| 248 | - | // state published by a later promotion. | |
| 249 | - | func (d *Daemon) clearForSnatch(token attachmentRoleToken) bool { | |
| 250 | - | if token.ac == nil || token.ac.overlays == nil { | |
| 251 | - | return false | |
| 252 | - | } | |
| 253 | - | rt := token.ac.overlays | |
| 254 | - | current := func() bool { | |
| 255 | - | return token.ac.roleGeneration.Load() == token.generation && token.ac.transportSnapshotCurrent(token.transport) | |
| 256 | - | } | |
| 257 | - | afterFamily := func(family string) { | |
| 258 | - | if d.afterSnatchOverlayFamily != nil { | |
| 259 | - | d.afterSnatchOverlayFamily(family) | |
| 260 | - | } | |
| 261 | - | } | |
| 262 | - | ||
| 263 | - | rt.pickerMu.Lock() | |
| 264 | - | if !current() { | |
| 265 | - | rt.pickerMu.Unlock() | |
| 266 | - | return false | |
| 267 | - | } | |
| 268 | - | previewSession := rt.pickerPreviewSession | |
| 269 | - | previewGeneration := rt.pickerPreviewGeneration | |
| 270 | - | pickerInstance := remotePickerInstance{ac: token.ac, generation: rt.pickerGeneration, model: rt.picker} | |
| 271 | - | rt.picker = nil | |
| 272 | - | rt.pickerIntent = pickerNavigate | |
| 273 | - | rt.pickerSource = moveSourceLocator{} | |
| 274 | - | rt.pickerPending = nil | |
| 275 | - | rt.pickerESC.stop() | |
| 276 | - | rt.pickerPreviewGeneration++ | |
| 277 | - | rt.pickerPreview = nil | |
| 278 | - | rt.pickerPreviewSession = nil | |
| 279 | - | rt.pickerMu.Unlock() | |
| 280 | - | afterFamily("picker") | |
| 281 | - | // Subscription teardown may enter a coordinator and therefore runs outside | |
| 282 | - | // every overlay-family lock. | |
| 283 | - | d.teardownPreviewSubscription(token.ac, previewSession, previewGeneration) | |
| 284 | - | if pickerInstance.model != nil { | |
| 285 | - | d.remotePickerClosed(pickerInstance) | |
| 286 | - | } | |
| 287 | - | ||
| 288 | - | rt.paletteMu.Lock() | |
| 289 | - | if !current() { | |
| 290 | - | rt.paletteMu.Unlock() | |
| 291 | - | return false | |
| 292 | - | } | |
| 293 | - | token.ac.clearPaletteLocked() | |
| 294 | - | rt.paletteMu.Unlock() | |
| 295 | - | afterFamily("palette") | |
| 296 | - | ||
| 297 | - | rt.promptMu.Lock() | |
| 298 | - | if !current() { | |
| 299 | - | rt.promptMu.Unlock() | |
| 300 | - | return false | |
| 301 | - | } | |
| 302 | - | rt.prompt = nil | |
| 303 | - | rt.promptSubmit = nil | |
| 304 | - | rt.promptTransitionSubmit = nil | |
| 305 | - | rt.promptPending = nil | |
| 306 | - | rt.promptMu.Unlock() | |
| 307 | - | afterFamily("prompt") | |
| 308 | - | ||
| 309 | - | rt.resizeMu.Lock() | |
| 310 | - | if !current() { | |
| 311 | - | rt.resizeMu.Unlock() | |
| 312 | - | return false | |
| 313 | - | } | |
| 314 | - | rt.resizeActive = false | |
| 315 | - | rt.resizePending = nil | |
| 316 | - | rt.resizeESC.stop() | |
| 317 | - | rt.resizeMu.Unlock() | |
| 318 | - | afterFamily("resize") | |
| 319 | - | ||
| 320 | - | rt.copyMu.Lock() | |
| 321 | - | if !current() { | |
| 322 | - | rt.copyMu.Unlock() | |
| 323 | - | return false | |
| 324 | - | } | |
| 325 | - | d.stopCopyPendingTimerLocked(token.ac) | |
| 326 | - | rt.copyPending = nil | |
| 327 | - | rt.clearCopyModeLocked() | |
| 328 | - | rt.statusFeedback = "" | |
| 329 | - | rt.copyMu.Unlock() | |
| 330 | - | afterFamily("copy") | |
| 331 | - | ||
| 332 | - | rt.noticeMu.Lock() | |
| 333 | - | if !current() { | |
| 334 | - | rt.noticeMu.Unlock() | |
| 335 | - | return false | |
| 336 | - | } | |
| 337 | - | for i := range rt.noticeToasts { | |
| 338 | - | rt.noticeToasts[i].timer.stop() | |
| 339 | - | } | |
| 340 | - | rt.noticeToasts = nil | |
| 341 | - | rt.noticeOverflow = 0 | |
| 342 | - | rt.noticeSeq++ | |
| 343 | - | rt.noticesOverlay = nil | |
| 344 | - | rt.noticesPending = nil | |
| 345 | - | rt.noticesESC.stop() | |
| 346 | - | rt.noticeMu.Unlock() | |
| 347 | - | afterFamily("notice") | |
| 348 | - | return true | |
| 349 | - | } | |
| 350 | - | ||
| 351 | - | func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*roleEffectTicket) bool { | |
| 245 | + | func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*attachmentEffectTicket) bool { | |
| 352 | 246 | if rt == nil || rt.ac == nil { | |
| 353 | 247 | return false | |
| 354 | 248 | } | |
| 355 | 249 | ac := rt.ac | |
| 356 | - | var effect *roleEffectTicket | |
| 250 | + | var effect *attachmentEffectTicket | |
| 357 | 251 | if len(effects) != 0 { | |
| 358 | 252 | effect = effects[0] | |
| 359 | 253 | } | |
M internal/usecase/daemon/palette.go +38 -27
| @@ -174,7 +174,7 @@ func (d *Daemon) recordPaletteUse(code string) { | |||
| 174 | 174 | d.paletteRecent = recent | |
| 175 | 175 | } | |
| 176 | 176 | ||
| 177 | - | func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) { | |
| 177 | + | func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) { | |
| 178 | 178 | entry := ac.currentAttachmentSession() | |
| 179 | 179 | if entry == nil { | |
| 180 | 180 | return | |
| @@ -189,7 +189,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 189 | 189 | var generation uint64 | |
| 190 | 190 | var rawQuery string | |
| 191 | 191 | changed, cancel, execute := false, false, false | |
| 192 | - | var effect *roleEffectTicket | |
| 192 | + | var effect *attachmentEffectTicket | |
| 193 | 193 | if len(effects) != 0 { | |
| 194 | 194 | effect = effects[0] | |
| 195 | 195 | } | |
| @@ -305,7 +305,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 305 | 305 | } | |
| 306 | 306 | var err error | |
| 307 | 307 | if effect != nil { | |
| 308 | - | err = d.switchToTargetForRole(effect.roleToken(), target, sessionHandoffGuard{}, "palette-session") | |
| 308 | + | err = d.switchToTargetForAttachment(effect.connectionToken(), target, sessionHandoffGuard{}, "palette-session") | |
| 309 | 309 | } else if local { | |
| 310 | 310 | err = d.switchToTarget(sess, ac, target) | |
| 311 | 311 | } else { | |
| @@ -389,10 +389,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 389 | 389 | } | |
| 390 | 390 | case "back-session": | |
| 391 | 391 | if effect != nil { | |
| 392 | - | err = d.backSessionForRole(effect.roleToken()) | |
| 392 | + | err = d.backSessionForAttachment(effect.connectionToken()) | |
| 393 | 393 | } | |
| 394 | 394 | case "detach": | |
| 395 | - | if effect != nil && !d.clientGoneForRole(effect.roleToken(), true) { | |
| 395 | + | if effect != nil && !d.clientGoneForAttachment(effect.connectionToken(), true) { | |
| 396 | 396 | err = errAttachmentTransition | |
| 397 | 397 | } | |
| 398 | 398 | case "session-picker": | |
| @@ -413,17 +413,17 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 413 | 413 | } | |
| 414 | 414 | return | |
| 415 | 415 | } | |
| 416 | - | roleHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach" | |
| 417 | - | if !roleHandoff && !ac.closeExecutedPalette(generation, rawQuery) { | |
| 416 | + | attachmentHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach" | |
| 417 | + | if !attachmentHandoff && !ac.closeExecutedPalette(generation, rawQuery) { | |
| 418 | 418 | return | |
| 419 | 419 | } | |
| 420 | 420 | sess.dispatchMu.Lock() | |
| 421 | 421 | err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, effect: effect, redrawClosedPalette: true}, args) | |
| 422 | 422 | sess.dispatchMu.Unlock() | |
| 423 | - | if roleHandoff { | |
| 423 | + | if attachmentHandoff { | |
| 424 | 424 | if current := ac.currentSession(); current != nil { | |
| 425 | 425 | currentToken := current.attachmentToken(ac, ac.transport()) | |
| 426 | - | fresh, admitted := ac.beginRoleEffect(currentToken) | |
| 426 | + | fresh, admitted := ac.beginAttachmentEffect(currentToken) | |
| 427 | 427 | if ac.closeExecutedPalette(generation, rawQuery) { | |
| 428 | 428 | d.invalidateRender(current, ac, true, "palette.go") | |
| 429 | 429 | } | |
| @@ -495,13 +495,14 @@ type paletteExec struct { | |||
| 495 | 495 | ac *attachedClient | |
| 496 | 496 | recent []recentSession | |
| 497 | 497 | actions daemonActionRunner | |
| 498 | - | effect *roleEffectTicket | |
| 498 | + | effect *attachmentEffectTicket | |
| 499 | 499 | redrawClosedPalette bool | |
| 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 | - | request.target = resolveDaemonActionTarget(e.sess) | |
| 505 | + | request.target = resolveDaemonActionTargetForAttachment(e.sess, e.ac) | |
| 505 | 506 | } | |
| 506 | 507 | runner := e.actions | |
| 507 | 508 | if runner == nil { | |
| @@ -532,7 +533,7 @@ func (e paletteExec) CreateSession() error { | |||
| 532 | 533 | if entry == nil { | |
| 533 | 534 | entry = e.sess | |
| 534 | 535 | } | |
| 535 | - | e.d.enterTransitionPrompt(entry, e.ac, " Create session ", "", func(name string, token attachmentRoleToken) error { | |
| 536 | + | e.d.enterTransitionPrompt(entry, e.ac, " Create session ", "", func(name string, token attachmentConnectionToken) error { | |
| 536 | 537 | if token.ac == nil { | |
| 537 | 538 | sess, ok := e.localSession() | |
| 538 | 539 | if !ok { | |
| @@ -540,7 +541,7 @@ func (e paletteExec) CreateSession() error { | |||
| 540 | 541 | } | |
| 541 | 542 | return e.d.createSessionAndSwitch(sess, e.ac, name) | |
| 542 | 543 | } | |
| 543 | - | return e.d.createSessionAndSwitchForRole(token, name) | |
| 544 | + | return e.d.createSessionAndSwitchForAttachment(token, name) | |
| 544 | 545 | }) | |
| 545 | 546 | return nil | |
| 546 | 547 | } | |
| @@ -580,27 +581,27 @@ func (e paletteExec) ClosePane() error { | |||
| 580 | 581 | } | |
| 581 | 582 | ||
| 582 | 583 | func (e paletteExec) OpenMovePanePicker() error { | |
| 583 | - | target := resolveDaemonActionTarget(e.sess) | |
| 584 | + | target := resolveDaemonActionTargetForAttachment(e.sess, e.ac) | |
| 584 | 585 | if target.tab == nil || target.pane == nil { | |
| 585 | 586 | return errMovePaneInvalid | |
| 586 | 587 | } | |
| 587 | 588 | return e.d.enterPickerForIntent(e.sess, e.ac, pickerMovePane, moveSourceLocator{ | |
| 588 | - | Session: sessionMoveLocator(e.sess), | |
| 589 | - | TabID: domain.TabStableID(target.tab.stableID), | |
| 590 | - | PaneID: domain.PaneStableID(target.pane.stableID), | |
| 591 | - | Client: e.ac, | |
| 589 | + | Session: sessionMoveLocator(e.sess), | |
| 590 | + | TabID: domain.TabStableID(target.tab.stableID), | |
| 591 | + | PaneID: domain.PaneStableID(target.pane.stableID), | |
| 592 | + | Attachment: e.ac, | |
| 592 | 593 | }) | |
| 593 | 594 | } | |
| 594 | 595 | ||
| 595 | 596 | func (e paletteExec) OpenMoveTabPicker() error { | |
| 596 | - | target := resolveDaemonActionTarget(e.sess) | |
| 597 | + | target := resolveDaemonActionTargetForAttachment(e.sess, e.ac) | |
| 597 | 598 | if target.tab == nil { | |
| 598 | 599 | return errMovePaneInvalid | |
| 599 | 600 | } | |
| 600 | 601 | return e.d.enterPickerForIntent(e.sess, e.ac, pickerMoveTab, moveSourceLocator{ | |
| 601 | - | Session: sessionMoveLocator(e.sess), | |
| 602 | - | TabID: domain.TabStableID(target.tab.stableID), | |
| 603 | - | Client: e.ac, | |
| 602 | + | Session: sessionMoveLocator(e.sess), | |
| 603 | + | TabID: domain.TabStableID(target.tab.stableID), | |
| 604 | + | Attachment: e.ac, | |
| 604 | 605 | }) | |
| 605 | 606 | } | |
| 606 | 607 | ||
| @@ -639,7 +640,7 @@ func (e paletteExec) ToggleFloatingPane() error { | |||
| 639 | 640 | ||
| 640 | 641 | func (e paletteExec) BackSession() error { | |
| 641 | 642 | if e.effect != nil { | |
| 642 | - | return e.d.backSessionForRole(e.effect.roleToken()) | |
| 643 | + | return e.d.backSessionForAttachment(e.effect.connectionToken()) | |
| 643 | 644 | } | |
| 644 | 645 | e.d.backSession(e.sess, e.ac) | |
| 645 | 646 | return nil | |
| @@ -647,7 +648,7 @@ func (e paletteExec) BackSession() error { | |||
| 647 | 648 | ||
| 648 | 649 | func (e paletteExec) Detach() error { | |
| 649 | 650 | if e.effect != nil { | |
| 650 | - | if !e.d.clientGoneForRole(e.effect.roleToken(), true) { | |
| 651 | + | if !e.d.clientGoneForAttachment(e.effect.connectionToken(), true) { | |
| 651 | 652 | return errAttachmentTransition | |
| 652 | 653 | } | |
| 653 | 654 | return nil | |
| @@ -672,12 +673,22 @@ func (e paletteExec) RenameSession() error { | |||
| 672 | 673 | } | |
| 673 | 674 | ||
| 674 | 675 | func (e paletteExec) RenameTab() error { | |
| 675 | - | tb := e.sess.activeTab() | |
| 676 | + | tb := e.sess.tabForAttachment(e.ac) | |
| 677 | + | if e.ac == nil { | |
| 678 | + | tb = e.sess.firstTab() | |
| 679 | + | } | |
| 676 | 680 | if tb == nil { | |
| 677 | 681 | return nil | |
| 678 | 682 | } | |
| 679 | 683 | e.sess.mu.Lock() | |
| 680 | - | currentName := tabDisplayName(tb, e.sess.active) | |
| 684 | + | index := 0 | |
| 685 | + | for i, candidate := range e.sess.tabs { | |
| 686 | + | if candidate == tb { | |
| 687 | + | index = i | |
| 688 | + | break | |
| 689 | + | } | |
| 690 | + | } | |
| 691 | + | currentName := tabDisplayName(tb, index) | |
| 681 | 692 | e.sess.mu.Unlock() | |
| 682 | 693 | e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error { | |
| 683 | 694 | return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, target: daemonActionTarget{session: e.sess, tab: tb}, name: name}) | |
| @@ -733,7 +744,7 @@ func (e paletteExec) JumpRecentSession(rank int) error { | |||
| 733 | 744 | } | |
| 734 | 745 | var err error | |
| 735 | 746 | if e.effect != nil { | |
| 736 | - | err = e.d.switchToTargetForRole(e.effect.roleToken(), picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "palette-recent-session") | |
| 747 | + | err = e.d.switchToTargetForAttachment(e.effect.connectionToken(), picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "palette-recent-session") | |
| 737 | 748 | } else { | |
| 738 | 749 | sess, ok := e.localSession() | |
| 739 | 750 | if !ok { | |
M internal/usecase/daemon/palette_test.go +24 -24
| @@ -94,7 +94,7 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) { | |||
| 94 | 94 | p2, release2 := newBlockingPTY(t) | |
| 95 | 95 | d, sess, ac, sends := newManualSessionWithPTYs(t, p1, p2) | |
| 96 | 96 | sess.mu.Lock() | |
| 97 | - | sess.client = ac | |
| 97 | + | sess.registerAttachmentLocked(ac) | |
| 98 | 98 | sess.mu.Unlock() | |
| 99 | 99 | d.ptys = newBlockingOpenFactory(t, d) | |
| 100 | 100 | defer release1() | |
| @@ -109,8 +109,8 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) { | |||
| 109 | 109 | ||
| 110 | 110 | d.handleInput(sess, ac, []byte("NXT\r")) | |
| 111 | 111 | require.False(t, ac.overlays.paletteActive()) | |
| 112 | - | require.Equal(t, 1, activeTabIndex(sess)) | |
| 113 | - | requireFloatingInitialized(t, sess.activeTab()) | |
| 112 | + | require.Equal(t, 1, testAttachmentTabIndex(sess)) | |
| 113 | + | requireFloatingInitialized(t, testAttachmentTab(sess)) | |
| 114 | 114 | awaitFrame(t, sends, ports.MsgOutput) | |
| 115 | 115 | ||
| 116 | 116 | d.handleInput(sess, ac, []byte("\x1b ")) | |
| @@ -138,7 +138,7 @@ func TestRemotePaletteClosesBeforeOpeningLocalOverlay(t *testing.T) { | |||
| 138 | 138 | t.Run(test.name, func(t *testing.T) { | |
| 139 | 139 | d, _, ac, link, handler := newProxyInputHarness(t) | |
| 140 | 140 | handler.enterPalette() | |
| 141 | - | d.handlePaletteInput(ac, []byte(test.code+"\r"), handler.roleToken.effect) | |
| 141 | + | d.handlePaletteInput(ac, []byte(test.code+"\r"), handler.connectionToken.effect) | |
| 142 | 142 | ||
| 143 | 143 | require.False(t, ac.overlays.paletteActive(), "stale remote palette remained active") | |
| 144 | 144 | require.True(t, test.active(ac.overlays), "replacement local overlay was not opened") | |
| @@ -317,7 +317,7 @@ func TestPaletteSelectedActiveSessionSwitchesWithoutRecordingCommandRecency(t *t | |||
| 317 | 317 | d.handleInput(current, ac, []byte("recent\r")) | |
| 318 | 318 | ||
| 319 | 319 | require.Same(t, target, ac.currentSession()) | |
| 320 | - | require.Same(t, ac, target.client, "canonical handoff reuses the attached client") | |
| 320 | + | require.Contains(t, target.snapshotAttachments(), ac, "canonical handoff reuses the attached client") | |
| 321 | 321 | require.False(t, ac.overlays.paletteActive()) | |
| 322 | 322 | require.Empty(t, d.paletteRecent, "session selections are not command recency") | |
| 323 | 323 | awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -340,7 +340,7 @@ func TestPaletteStoppedSessionResumeFailureKeepsPaletteAndSourceAttachment(t *te | |||
| 340 | 340 | ||
| 341 | 341 | require.Same(t, current, ac.currentSession(), "failed resume must retain the source attachment") | |
| 342 | 342 | current.mu.Lock() | |
| 343 | - | require.Same(t, ac, current.client) | |
| 343 | + | require.Contains(t, current.snapshotAttachmentsLocked(), ac) | |
| 344 | 344 | current.mu.Unlock() | |
| 345 | 345 | require.True(t, ac.overlays.paletteActive()) | |
| 346 | 346 | ac.overlays.paletteMu.Lock() | |
| @@ -361,14 +361,14 @@ func TestPaletteSelectedStoppedSessionResumesAndSwitches(t *testing.T) { | |||
| 361 | 361 | ||
| 362 | 362 | d.handleInput(current, ac, []byte("\x1b ")) | |
| 363 | 363 | awaitFrame(t, sends, ports.MsgOutput) | |
| 364 | - | generation := ac.roleGeneration.Load() | |
| 364 | + | generation := ac.connectionGeneration.Load() | |
| 365 | 365 | d.handleInput(current, ac, []byte("stopped\r")) | |
| 366 | 366 | ||
| 367 | 367 | resumed := ac.currentSession() | |
| 368 | 368 | require.Equal(t, "stopped", resumed.name) | |
| 369 | 369 | require.Equal(t, int64(42), resumed.createdAt) | |
| 370 | - | require.Equal(t, attachmentActive, resumed.attachmentRole(ac)) | |
| 371 | - | require.Greater(t, ac.roleGeneration.Load(), generation, "stopped-session handoff must publish through the attachment transition") | |
| 370 | + | require.Equal(t, true, resumed.attachmentRegistered(ac)) | |
| 371 | + | require.Greater(t, ac.connectionGeneration.Load(), generation, "stopped-session handoff must publish through the attachment transition") | |
| 372 | 372 | require.False(t, ac.overlays.paletteActive()) | |
| 373 | 373 | firstPaint := awaitFrame(t, sends, ports.MsgOutput) | |
| 374 | 374 | firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload) | |
| @@ -440,7 +440,7 @@ func TestPaletteFuzzySelectedStaticCommandExecutes(t *testing.T) { | |||
| 440 | 440 | d.handleInput(sess, ac, []byte("next\r")) | |
| 441 | 441 | ||
| 442 | 442 | require.False(t, ac.overlays.paletteActive()) | |
| 443 | - | require.Equal(t, 1, activeTabIndex(sess)) | |
| 443 | + | require.Equal(t, 1, testAttachmentTabIndex(sess)) | |
| 444 | 444 | awaitFrame(t, sends, ports.MsgOutput) | |
| 445 | 445 | } | |
| 446 | 446 | ||
| @@ -486,7 +486,7 @@ func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) { | |||
| 486 | 486 | invalidations := installPaletteInvalidationObserver(current) | |
| 487 | 487 | d.enterPalette(current, ac) | |
| 488 | 488 | d.handlePaletteInput(ac, []byte("BSK")) | |
| 489 | - | d.afterActionRoleEffectEnded = func(action string) { | |
| 489 | + | d.afterActionAttachmentEffectEnded = func(action string) { | |
| 490 | 490 | if action == "back-session" { | |
| 491 | 491 | d.mu.Lock() | |
| 492 | 492 | delete(d.sessions, target.core().id) | |
| @@ -495,7 +495,7 @@ func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) { | |||
| 495 | 495 | } | |
| 496 | 496 | ||
| 497 | 497 | token := current.attachmentToken(ac, ac.transport()) | |
| 498 | - | effect, admitted := ac.beginRoleEffect(token) | |
| 498 | + | effect, admitted := ac.beginAttachmentEffect(token) | |
| 499 | 499 | require.True(t, admitted) | |
| 500 | 500 | d.handlePaletteInput(ac, []byte("\r"), effect) | |
| 501 | 501 | ||
| @@ -505,7 +505,7 @@ func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) { | |||
| 505 | 505 | require.True(t, invalidation.reset) | |
| 506 | 506 | } | |
| 507 | 507 | ||
| 508 | - | func TestPaletteDeniedPostHandoffRoleEffectClosesAndInvalidates(t *testing.T) { | |
| 508 | + | func TestPaletteDeniedPostHandoffAttachmentEffectClosesAndInvalidates(t *testing.T) { | |
| 509 | 509 | p, release := newBlockingPTY(t) | |
| 510 | 510 | defer release() | |
| 511 | 511 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| @@ -514,10 +514,10 @@ func TestPaletteDeniedPostHandoffRoleEffectClosesAndInvalidates(t *testing.T) { | |||
| 514 | 514 | d.enterPalette(sess, ac) | |
| 515 | 515 | d.handlePaletteInput(ac, []byte("BSK")) | |
| 516 | 516 | // Make the attachment token detached while retaining currentSession. The | |
| 517 | - | // no-op BSK command succeeds, but its post-execution beginRoleEffect is | |
| 517 | + | // no-op BSK command succeeds, but its post-execution beginAttachmentEffect is | |
| 518 | 518 | // deterministically denied. | |
| 519 | 519 | sess.mu.Lock() | |
| 520 | - | sess.client = nil | |
| 520 | + | clearAttachmentsForTestLocked(sess) | |
| 521 | 521 | sess.mu.Unlock() | |
| 522 | 522 | ||
| 523 | 523 | d.handlePaletteInput(ac, []byte("\r")) | |
| @@ -639,7 +639,7 @@ func TestPaletteFLTExecutesFloatingToggle(t *testing.T) { | |||
| 639 | 639 | p, release := newBlockingPTY(t) | |
| 640 | 640 | defer release() | |
| 641 | 641 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 642 | - | tb := sess.activeTab() | |
| 642 | + | tb := testAttachmentTab(sess) | |
| 643 | 643 | installTestFloating(tb, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), false) | |
| 644 | 644 | ||
| 645 | 645 | d.handleInput(sess, ac, []byte("\x1b ")) | |
| @@ -671,7 +671,7 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) { | |||
| 671 | 671 | require.True(t, ac.overlays.promptActive()) | |
| 672 | 672 | require.Contains(t, string(promptOutput.Data), "Create session") | |
| 673 | 673 | ||
| 674 | - | generation := ac.roleGeneration.Load() | |
| 674 | + | generation := ac.connectionGeneration.Load() | |
| 675 | 675 | d.handleInput(sess, ac, []byte("scratch\r")) | |
| 676 | 676 | // The submit first paints the newly attached session while the prompt is | |
| 677 | 677 | // still open, then handlePromptInput closes the prompt and repaints the | |
| @@ -685,15 +685,15 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) { | |||
| 685 | 685 | require.NoError(t, err) | |
| 686 | 686 | require.False(t, ac.overlays.promptActive()) | |
| 687 | 687 | require.Equal(t, 2, sessionCount(d)) | |
| 688 | - | require.Nil(t, sess.client) | |
| 688 | + | require.Empty(t, sess.snapshotAttachments()) | |
| 689 | 689 | newSess := ac.currentSession() | |
| 690 | 690 | require.NotNil(t, newSess) | |
| 691 | 691 | require.NotSame(t, sess, newSess) | |
| 692 | 692 | require.Equal(t, "scratch", newSess.name) | |
| 693 | 693 | require.False(t, newSess.ephemeral) | |
| 694 | - | require.Same(t, ac, newSess.client) | |
| 695 | - | require.Equal(t, attachmentActive, newSess.attachmentRole(ac)) | |
| 696 | - | require.Greater(t, ac.roleGeneration.Load(), generation, "new-session handoff must publish through the attachment transition") | |
| 694 | + | require.Contains(t, newSess.snapshotAttachments(), ac) | |
| 695 | + | require.Equal(t, true, newSess.attachmentRegistered(ac)) | |
| 696 | + | require.Greater(t, ac.connectionGeneration.Load(), generation, "new-session handoff must publish through the attachment transition") | |
| 697 | 697 | require.Contains(t, string(finalOutput.Data), "scratch") | |
| 698 | 698 | require.NotContains(t, string(finalOutput.Data), "Create session") | |
| 699 | 699 | } | |
| @@ -701,7 +701,7 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) { | |||
| 701 | 701 | func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) { | |
| 702 | 702 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |
| 703 | 703 | sess.mu.Lock() | |
| 704 | - | sess.client = ac | |
| 704 | + | sess.registerAttachmentLocked(ac) | |
| 705 | 705 | sess.mu.Unlock() | |
| 706 | 706 | defer releases[0]() | |
| 707 | 707 | defer releases[1]() | |
| @@ -1125,9 +1125,9 @@ func TestPaletteExecMethods(t *testing.T) { | |||
| 1125 | 1125 | exec := paletteExec{d: d, sess: sess, ac: ac} | |
| 1126 | 1126 | ||
| 1127 | 1127 | require.NoError(t, exec.NextTab()) | |
| 1128 | - | require.Equal(t, 1, activeTabIndex(sess)) | |
| 1128 | + | require.Equal(t, 1, testAttachmentTabIndex(sess)) | |
| 1129 | 1129 | require.NoError(t, exec.PrevTab()) | |
| 1130 | - | require.Equal(t, 0, activeTabIndex(sess)) | |
| 1130 | + | require.Equal(t, 0, testAttachmentTabIndex(sess)) | |
| 1131 | 1131 | sess.ephemeral = true | |
| 1132 | 1132 | require.NoError(t, exec.RenameSession()) | |
| 1133 | 1133 | require.True(t, ac.overlays.promptActive()) | |
M internal/usecase/daemon/pane_actions.go +145 -45
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Direction) error { | |
| 13 | - | target := resolveDaemonActionTarget(sess) | |
| 13 | + | target := resolveDaemonActionTargetForAttachment(sess, ac) | |
| 14 | 14 | err := d.splitPaneAt(sess, target.tab, target.pane, dir) | |
| 15 | 15 | if err == nil && ac != nil { | |
| 16 | 16 | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| @@ -131,7 +131,7 @@ func placementContent(placements []layout.Placement, id layout.PaneID) domain.Re | |||
| 131 | 131 | func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Rows: r.Height} } | |
| 132 | 132 | ||
| 133 | 133 | func (d *Daemon) stackPane(sess *session, ac *attachedClient) error { | |
| 134 | - | target := resolveDaemonActionTarget(sess) | |
| 134 | + | target := resolveDaemonActionTargetForAttachment(sess, ac) | |
| 135 | 135 | err := d.stackPaneAt(sess, target.tab, target.pane) | |
| 136 | 136 | if err == nil && ac != nil { | |
| 137 | 137 | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| @@ -146,7 +146,7 @@ func (d *Daemon) stackPaneAt(sess *session, tb *tab, target *pane) error { | |||
| 146 | 146 | } | |
| 147 | 147 | ||
| 148 | 148 | func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error { | |
| 149 | - | target := resolveDaemonActionTarget(sess) | |
| 149 | + | target := resolveDaemonActionTargetForAttachment(sess, ac) | |
| 150 | 150 | err := d.toggleStackAt(sess, target.tab, target.pane) | |
| 151 | 151 | if err == nil && ac != nil { | |
| 152 | 152 | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| @@ -190,18 +190,15 @@ func (d *Daemon) closeFocusedPane(sess *session, ac *attachedClient) error { | |||
| 190 | 190 | } | |
| 191 | 191 | return nil | |
| 192 | 192 | } | |
| 193 | - | tb := sess.activeTab() | |
| 193 | + | target := resolveDaemonActionTargetForAttachment(sess, ac) | |
| 194 | + | tb := target.tab | |
| 194 | 195 | if tb == nil { | |
| 195 | 196 | return layout.ErrNotFound | |
| 196 | 197 | } | |
| 197 | - | tb.mu.Lock() | |
| 198 | - | if tb.tree == nil { | |
| 199 | - | tb.mu.Unlock() | |
| 198 | + | if target.pane == nil { | |
| 200 | 199 | return layout.ErrNotFound | |
| 201 | 200 | } | |
| 202 | - | id := tb.tree.Focus | |
| 203 | - | tb.mu.Unlock() | |
| 204 | - | return d.closePane(sess, tb, id, ac, true) | |
| 201 | + | return d.closePane(sess, tb, target.pane.id, ac, true) | |
| 205 | 202 | } | |
| 206 | 203 | ||
| 207 | 204 | // reapPane retains the explicit-owner entry point for focused close tests. PTY | |
| @@ -244,6 +241,11 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool { | |||
| 244 | 241 | return true | |
| 245 | 242 | } | |
| 246 | 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() | |
| 247 | 249 | sess.mu.Lock() | |
| 248 | 250 | ownedTab := slices.Contains(sess.tabs, tb) | |
| 249 | 251 | if p.owner.Load() != owner { | |
| @@ -254,7 +256,7 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool { | |||
| 254 | 256 | sess.mu.Unlock() | |
| 255 | 257 | return true | |
| 256 | 258 | } | |
| 257 | - | ac := sess.client | |
| 259 | + | attachments := sess.snapshotAttachmentsLocked() | |
| 258 | 260 | sessionName := sess.name | |
| 259 | 261 | tb.mu.Lock() | |
| 260 | 262 | if p.owner.Load() != owner { | |
| @@ -288,15 +290,23 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool { | |||
| 288 | 290 | p.clearOwnerLocked() | |
| 289 | 291 | p.mu.Unlock() | |
| 290 | 292 | tb.mu.Unlock() | |
| 293 | + | sess.invalidateViewsLocked() | |
| 291 | 294 | sess.mu.Unlock() | |
| 292 | 295 | ||
| 293 | 296 | if finalPane { | |
| 294 | - | _ = d.closeTab(sess, tb, true) | |
| 297 | + | for _, ac := range attachments { | |
| 298 | + | if ac.overlays != nil { | |
| 299 | + | ac.overlays.clearCopyModeForPane(p) | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | _ = d.closeTabLocked(sess, tb, true) | |
| 295 | 303 | return true | |
| 296 | 304 | } | |
| 297 | 305 | d.applyTabLayout(sess, tb) | |
| 298 | - | if ac != nil { | |
| 299 | - | ac.overlays.clearCopyModeForPane(p) | |
| 306 | + | for _, ac := range attachments { | |
| 307 | + | if ac.overlays != nil { | |
| 308 | + | ac.overlays.clearCopyModeForPane(p) | |
| 309 | + | } | |
| 300 | 310 | ac.pruneCaptureFrames(p) | |
| 301 | 311 | } | |
| 302 | 312 | if rc := sess.renderCoordinator(); rc != nil { | |
| @@ -304,21 +314,36 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool { | |||
| 304 | 314 | } | |
| 305 | 315 | closePaneProcess(p) | |
| 306 | 316 | d.log.Info("pane closed", "session", sessionName, "pane", p.id) | |
| 307 | - | if ac != nil { | |
| 317 | + | for _, ac := range attachments { | |
| 308 | 318 | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| 309 | 319 | } | |
| 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 { | |
| 314 | - | if tb == nil { | |
| 326 | + | if sess == nil { | |
| 315 | 327 | return layout.ErrNotFound | |
| 316 | 328 | } | |
| 317 | - | if ac == nil { | |
| 318 | - | sess.mu.Lock() | |
| 319 | - | ac = sess.client | |
| 320 | - | sess.mu.Unlock() | |
| 329 | + | sess.dispatchMu.Lock() | |
| 330 | + | defer sess.dispatchMu.Unlock() | |
| 331 | + | return d.closePaneLocked(sess, tb, id, ac, repaint) | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | // closePaneLocked requires sess.dispatchMu before entering any architecture | |
| 335 | + | // lock. It is used by existing runMutation callers to avoid nested dispatch | |
| 336 | + | // locking. | |
| 337 | + | func (d *Daemon) closePaneLocked(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool) error { | |
| 338 | + | return d.closePaneLockedWithEffect(sess, tb, id, ac, repaint, nil) | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | func (d *Daemon) closePaneLockedWithEffect(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool, effect *attachmentEffectTicket) error { | |
| 342 | + | if tb == nil { | |
| 343 | + | return layout.ErrNotFound | |
| 321 | 344 | } | |
| 345 | + | // A nil attachment denotes a session-wide close with an explicit pane target. | |
| 346 | + | attachments := sess.snapshotAttachments() | |
| 322 | 347 | tb.mu.Lock() | |
| 323 | 348 | p := tb.panes[id] | |
| 324 | 349 | if p == nil || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, id) { | |
| @@ -327,10 +352,13 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||
| 327 | 352 | } | |
| 328 | 353 | if len(layout.LeafIDs(tb.tree.Root)) <= 1 { | |
| 329 | 354 | tb.mu.Unlock() | |
| 330 | - | if ac != nil { | |
| 331 | - | ac.overlays.clearCopyModeForPane(p) | |
| 355 | + | for _, viewer := range attachments { | |
| 356 | + | if viewer.overlays != nil { | |
| 357 | + | viewer.overlays.clearCopyModeForPane(p) | |
| 358 | + | } | |
| 359 | + | viewer.pruneCaptureFrames(p) | |
| 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() | |
| @@ -339,11 +367,16 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||
| 339 | 367 | delete(tb.panes, id) | |
| 340 | 368 | tb.bumpLayoutGenerationLocked() | |
| 341 | 369 | tb.mu.Unlock() | |
| 370 | + | sess.mu.Lock() | |
| 371 | + | sess.invalidateViewsLocked() | |
| 372 | + | sess.mu.Unlock() | |
| 342 | 373 | d.applyTabLayout(sess, tb) | |
| 343 | 374 | ||
| 344 | - | if ac != nil { | |
| 345 | - | ac.overlays.clearCopyModeForPane(p) | |
| 346 | - | ac.pruneCaptureFrames(p) | |
| 375 | + | for _, viewer := range attachments { | |
| 376 | + | if viewer.overlays != nil { | |
| 377 | + | viewer.overlays.clearCopyModeForPane(p) | |
| 378 | + | } | |
| 379 | + | viewer.pruneCaptureFrames(p) | |
| 347 | 380 | } | |
| 348 | 381 | ||
| 349 | 382 | if rc := sess.renderCoordinator(); rc != nil { | |
| @@ -354,18 +387,22 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||
| 354 | 387 | if repaint { | |
| 355 | 388 | if ac != nil { | |
| 356 | 389 | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| 390 | + | } else { | |
| 391 | + | for _, viewer := range attachments { | |
| 392 | + | d.invalidateRender(sess, viewer, true, "pane_actions.go") | |
| 393 | + | } | |
| 357 | 394 | } | |
| 358 | 395 | } | |
| 359 | 396 | return nil | |
| 360 | 397 | } | |
| 361 | 398 | ||
| 362 | - | func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction, effect *roleEffectTicket) error { | |
| 363 | - | target := resolveDaemonActionTarget(sess) | |
| 399 | + | func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction, effect *attachmentEffectTicket) error { | |
| 400 | + | target := resolveDaemonActionTargetForAttachment(sess, ac) | |
| 364 | 401 | oldFocus := layout.PaneID("") | |
| 365 | 402 | if target.pane != nil { | |
| 366 | 403 | oldFocus = target.pane.id | |
| 367 | 404 | } | |
| 368 | - | span, err := d.focusDirAt(sess, target.tab, target.pane, dir) | |
| 405 | + | span, err := d.focusDirAt(sess, target.tab, target.pane, dir, ac) | |
| 369 | 406 | if err == nil { | |
| 370 | 407 | if ac != nil { | |
| 371 | 408 | d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "pane_actions.go") | |
| @@ -375,7 +412,7 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio | |||
| 375 | 412 | if !errors.Is(err, errNoNeighbor) || target.tab == nil { | |
| 376 | 413 | return err | |
| 377 | 414 | } | |
| 378 | - | if !overflowSourceEligible(sess, target.tab) { | |
| 415 | + | if !overflowSourceEligible(sess, ac, target.tab) { | |
| 379 | 416 | return errNoNeighbor | |
| 380 | 417 | } | |
| 381 | 418 | ||
| @@ -385,24 +422,22 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio | |||
| 385 | 422 | return errNoNeighbor | |
| 386 | 423 | } | |
| 387 | 424 | if effect != nil { | |
| 388 | - | return d.switchToTargetForRole(effect.roleToken(), sessionTarget, sessionHandoffGuard{expectedSource: target.tab}, "overflow-session") | |
| 425 | + | return d.switchToTargetForAttachment(effect.connectionToken(), sessionTarget, sessionHandoffGuard{expectedSource: target.tab}, "overflow-session") | |
| 389 | 426 | } | |
| 390 | 427 | return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget) | |
| 391 | 428 | } | |
| 392 | 429 | ||
| 393 | - | sess.mu.Lock() | |
| 394 | - | position, count := sess.active, len(sess.tabs) | |
| 395 | - | sess.mu.Unlock() | |
| 430 | + | position, count := sess.tabIndexForAttachment(ac) | |
| 396 | 431 | step := resolveOverflow(dir, cfg, position, count) | |
| 397 | 432 | if step.kind != overflowTabs { | |
| 398 | 433 | return err | |
| 399 | 434 | } | |
| 400 | - | candidate, ok := d.prepareTabOverflow(sess, target.tab, dir, span, step.delta) | |
| 401 | - | if !ok || !d.commitTabOverflow(sess, candidate) { | |
| 435 | + | candidate, ok := d.prepareTabOverflowForAttachment(sess, ac, target.tab, dir, span, step.delta) | |
| 436 | + | if !ok || !d.commitTabOverflowForAttachment(sess, ac, candidate) { | |
| 402 | 437 | return errNoNeighbor | |
| 403 | 438 | } | |
| 404 | - | d.activateTab(sess, candidate.target) | |
| 405 | 439 | if ac != nil { | |
| 440 | + | sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID)) | |
| 406 | 441 | d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "pane_actions.go") | |
| 407 | 442 | } | |
| 408 | 443 | return nil | |
| @@ -412,23 +447,77 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio | |||
| 412 | 447 | // successful directional focus move. Keep copy-mode exit before the optional | |
| 413 | 448 | // stack title refresh, and both before render invalidation. | |
| 414 | 449 | func (d *Daemon) finishPaneFocusForClient(sess *session, ac *attachedClient, tb *tab, oldFocus layout.PaneID, producer string) { | |
| 415 | - | tb.mu.Lock() | |
| 416 | - | newFocus := tb.tree.Focus | |
| 417 | - | pl, hasPlacement := focusedPlacementLocked(tb) | |
| 418 | - | tb.mu.Unlock() | |
| 419 | - | if newFocus != oldFocus { | |
| 450 | + | var newFocus layout.PaneID | |
| 451 | + | var newPane *pane | |
| 452 | + | var pl layout.Placement | |
| 453 | + | var hasPlacement bool | |
| 454 | + | beforePaneID := domain.PaneStableID("") | |
| 455 | + | if ac != nil { | |
| 456 | + | beforePaneID = ac.viewSnapshot().paneID | |
| 457 | + | } | |
| 458 | + | if tb != nil { | |
| 459 | + | tb.mu.Lock() | |
| 460 | + | if ac != nil { | |
| 461 | + | view := ac.viewSnapshot() | |
| 462 | + | if domain.TabStableID(tb.stableID) == view.tabID { | |
| 463 | + | for _, candidate := range tb.panes { | |
| 464 | + | if candidate != nil && domain.PaneStableID(candidate.stableID) == view.paneID { | |
| 465 | + | newPane = candidate | |
| 466 | + | break | |
| 467 | + | } | |
| 468 | + | } | |
| 469 | + | } | |
| 470 | + | } else { | |
| 471 | + | newPane = tb.focusedPane() | |
| 472 | + | if newPane != nil { | |
| 473 | + | newFocus = newPane.id | |
| 474 | + | } | |
| 475 | + | } | |
| 476 | + | if newPane != nil { | |
| 477 | + | newFocus = newPane.id | |
| 478 | + | pl, hasPlacement = panePlacementLocked(tb, newFocus) | |
| 479 | + | } | |
| 480 | + | tb.mu.Unlock() | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | moved := newFocus != oldFocus | |
| 484 | + | if ac != nil && newPane != nil { | |
| 485 | + | sess.mu.Lock() | |
| 486 | + | sess.setAttachmentPaneLocked(ac, tb, newPane) | |
| 487 | + | sess.mu.Unlock() | |
| 488 | + | after := ac.viewSnapshot() | |
| 489 | + | moved = beforePaneID != after.paneID | |
| 490 | + | } | |
| 491 | + | if moved { | |
| 420 | 492 | d.exitCopyMode(ac) | |
| 421 | 493 | if hasPlacement && pl.InStack { | |
| 422 | - | d.refreshPaneTitleOnFocus(sess, newFocus) | |
| 494 | + | d.refreshPaneTitleOnFocus(sess, newFocus, tb) | |
| 423 | 495 | } | |
| 424 | 496 | } | |
| 425 | 497 | d.invalidateRender(sess, ac, true, producer) | |
| 426 | 498 | } | |
| 427 | 499 | ||
| 428 | - | func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction) (domain.Rect, error) { | |
| 500 | + | func panePlacementLocked(tb *tab, id layout.PaneID) (layout.Placement, bool) { | |
| 501 | + | placements, ok := solvedPlacementsLocked(tb) | |
| 502 | + | if !ok { | |
| 503 | + | return layout.Placement{}, false | |
| 504 | + | } | |
| 505 | + | for _, placement := range placements { | |
| 506 | + | if placement.ID == id { | |
| 507 | + | return placement, true | |
| 508 | + | } | |
| 509 | + | } | |
| 510 | + | return layout.Placement{}, false | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction, initiating ...*attachedClient) (domain.Rect, error) { | |
| 429 | 514 | if tb == nil || target == nil { | |
| 430 | 515 | return domain.Rect{}, layout.ErrNotFound | |
| 431 | 516 | } | |
| 517 | + | var ac *attachedClient | |
| 518 | + | if len(initiating) > 0 { | |
| 519 | + | ac = initiating[0] | |
| 520 | + | } | |
| 432 | 521 | tb.mu.Lock() | |
| 433 | 522 | if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) { | |
| 434 | 523 | tb.mu.Unlock() | |
| @@ -439,13 +528,24 @@ func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Dir | |||
| 439 | 528 | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | |
| 440 | 529 | span, _ := candidate.FocusSpan(area) | |
| 441 | 530 | err := candidate.FocusDir(dir, area) | |
| 442 | - | committed := err == nil && candidate.Focus != tb.tree.Focus | |
| 531 | + | currentFocus := tb.focusedPane() | |
| 532 | + | committed := err == nil && currentFocus != nil && candidate.Focus != currentFocus.id | |
| 533 | + | var focusedPane *pane | |
| 443 | 534 | if committed { | |
| 535 | + | focusedPane = tb.panes[candidate.Focus] | |
| 444 | 536 | tb.tree = candidate | |
| 445 | 537 | tb.bumpLayoutGenerationLocked() | |
| 446 | 538 | } | |
| 447 | 539 | tb.mu.Unlock() | |
| 448 | 540 | if committed { | |
| 541 | + | if ac != nil { | |
| 542 | + | d.exitCopyMode(ac) | |
| 543 | + | } | |
| 544 | + | if sess != nil && ac != nil && focusedPane != nil { | |
| 545 | + | sess.mu.Lock() | |
| 546 | + | sess.setAttachmentPaneLocked(ac, tb, focusedPane) | |
| 547 | + | sess.mu.Unlock() | |
| 548 | + | } | |
| 449 | 549 | d.applyTabLayout(sess, tb) | |
| 450 | 550 | } | |
| 451 | 551 | if errors.Is(err, layout.ErrNoPane) { | |
M internal/usecase/daemon/pane_actions_test.go +137 -24
| @@ -78,7 +78,7 @@ func TestLayoutRetryIsBoundedDeduplicatedAndCanceled(t *testing.T) { | |||
| 78 | 78 | d, sess, _, _ := newManualSessionWithPTYs(t, pty) | |
| 79 | 79 | clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 16)} | |
| 80 | 80 | d.clock = clock | |
| 81 | - | tb := sess.activeTab() | |
| 81 | + | tb := testAttachmentTab(sess) | |
| 82 | 82 | tb.mu.Lock() | |
| 83 | 83 | tb.size = domain.Size{Cols: 100, Rows: 20} | |
| 84 | 84 | tb.bumpLayoutGenerationLocked() | |
| @@ -118,7 +118,7 @@ func TestLayoutRetryIsBoundedDeduplicatedAndCanceled(t *testing.T) { | |||
| 118 | 118 | d, sess, _, _ := newManualSessionWithPTYs(t, pty) | |
| 119 | 119 | clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 16)} | |
| 120 | 120 | d.clock = clock | |
| 121 | - | tb := sess.activeTab() | |
| 121 | + | tb := testAttachmentTab(sess) | |
| 122 | 122 | tb.mu.Lock() | |
| 123 | 123 | tb.size = domain.Size{Cols: 100, Rows: 20} | |
| 124 | 124 | tb.bumpLayoutGenerationLocked() | |
| @@ -145,7 +145,7 @@ func TestLayoutRetryIsBoundedDeduplicatedAndCanceled(t *testing.T) { | |||
| 145 | 145 | func TestFocusDirAtDoesNotApplyUncommittedCandidate(t *testing.T) { | |
| 146 | 146 | pty := &transactionalResizePTY{} | |
| 147 | 147 | d, sess, _, _ := newManualSessionWithPTYs(t, pty) | |
| 148 | - | tb := sess.activeTab() | |
| 148 | + | tb := testAttachmentTab(sess) | |
| 149 | 149 | second := newPane("pane-2", nil, domain.Size{Cols: 80, Rows: 23}) | |
| 150 | 150 | tb.mu.Lock() | |
| 151 | 151 | tb.panes[second.id] = second | |
| @@ -164,7 +164,7 @@ func TestLayoutApplicationRetriesOnlyAcceptedFailedGeometry(t *testing.T) { | |||
| 164 | 164 | d, sess, _, _ := newManualSessionWithPTYs(t, pty) | |
| 165 | 165 | clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 16)} | |
| 166 | 166 | d.clock = clock | |
| 167 | - | tb, p := sess.activeTab(), sess.activeTab().focusedPane() | |
| 167 | + | tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane() | |
| 168 | 168 | tb.mu.Lock() | |
| 169 | 169 | tb.size = domain.Size{Cols: 100, Rows: 20} | |
| 170 | 170 | tb.bumpLayoutGenerationLocked() | |
| @@ -206,7 +206,7 @@ func TestDelayedRetryUsesFreshLayoutAfterPaneResize(t *testing.T) { | |||
| 206 | 206 | failed := &transactionalResizePTY{errs: []error{errors.New("first resize fails")}} | |
| 207 | 207 | d, sess, ac, _ := newManualSessionWithPTYs(t, failed) | |
| 208 | 208 | d.clock = clock.clock | |
| 209 | - | tb := sess.activeTab() | |
| 209 | + | tb := testAttachmentTab(sess) | |
| 210 | 210 | first := tb.focusedPane() | |
| 211 | 211 | second := newPane("pane-2", nil, domain.Size{Cols: 80, Rows: 23}) | |
| 212 | 212 | tb.mu.Lock() | |
| @@ -260,7 +260,7 @@ func TestDelayedRetryUsesFreshLayoutAfterPaneResize(t *testing.T) { | |||
| 260 | 260 | ||
| 261 | 261 | func TestLayoutApplicationDoesNotHoldTabLock(t *testing.T) { | |
| 262 | 262 | d, sess, pty, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 263 | - | tb := sess.activeTab() | |
| 263 | + | tb := testAttachmentTab(sess) | |
| 264 | 264 | tb.focusedPane().rect = domain.Rect{Width: 20, Height: 10} | |
| 265 | 265 | entered := make(chan struct{}) | |
| 266 | 266 | release := make(chan struct{}) | |
| @@ -290,7 +290,7 @@ func TestLayoutApplicationDoesNotHoldTabLock(t *testing.T) { | |||
| 290 | 290 | ||
| 291 | 291 | func TestLayoutApplicationRejectsStalePaneIdentity(t *testing.T) { | |
| 292 | 292 | d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 293 | - | tb := sess.activeTab() | |
| 293 | + | tb := testAttachmentTab(sess) | |
| 294 | 294 | oldPane := tb.focusedPane() | |
| 295 | 295 | oldPane.rect = domain.Rect{Width: 20, Height: 10} | |
| 296 | 296 | oldPane.screen.Resize(20, 10) | |
| @@ -341,7 +341,7 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) { | |||
| 341 | 341 | first, entered, release := newBlockedPTY() | |
| 342 | 342 | second := &transactionalResizePTY{} | |
| 343 | 343 | d, sess, _, _ := newManualSessionWithPTYs(t, first) | |
| 344 | - | tb := sess.activeTab() | |
| 344 | + | tb := testAttachmentTab(sess) | |
| 345 | 345 | p1 := tb.focusedPane() | |
| 346 | 346 | p2 := newPane("pane-2", second, domain.Size{Cols: 80, Rows: 23}) | |
| 347 | 347 | tb.mu.Lock() | |
| @@ -383,7 +383,7 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) { | |||
| 383 | 383 | first, entered, release := newBlockedPTY() | |
| 384 | 384 | second := &transactionalResizePTY{} | |
| 385 | 385 | d, sess, _, _ := newManualSessionWithPTYs(t, first) | |
| 386 | - | tb := sess.activeTab() | |
| 386 | + | tb := testAttachmentTab(sess) | |
| 387 | 387 | p1 := tb.focusedPane() | |
| 388 | 388 | p2 := newPane("pane-2", second, domain.Size{Cols: 80, Rows: 23}) | |
| 389 | 389 | tb.mu.Lock() | |
| @@ -417,7 +417,7 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) { | |||
| 417 | 417 | t.Run("client size rejects and retries a blocked plan", func(t *testing.T) { | |
| 418 | 418 | pty, entered, release := newBlockedPTY() | |
| 419 | 419 | d, sess, _, _ := newManualSessionWithPTYs(t, pty) | |
| 420 | - | tb := sess.activeTab() | |
| 420 | + | tb := testAttachmentTab(sess) | |
| 421 | 421 | p := tb.focusedPane() | |
| 422 | 422 | p.rect = domain.Rect{Width: 20, Height: 10} | |
| 423 | 423 | ||
| @@ -476,7 +476,7 @@ func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) { | |||
| 476 | 476 | ||
| 477 | 477 | require.NoError(t, d.splitPane(sess, nil, tt.dir)) | |
| 478 | 478 | ||
| 479 | - | tb := sess.activeTab() | |
| 479 | + | tb := testAttachmentTab(sess) | |
| 480 | 480 | tb.mu.Lock() | |
| 481 | 481 | defer tb.mu.Unlock() | |
| 482 | 482 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |
| @@ -492,7 +492,7 @@ func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) { | |||
| 492 | 492 | ||
| 493 | 493 | func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) { | |
| 494 | 494 | d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 4}) | |
| 495 | - | tb := sess.activeTab() | |
| 495 | + | tb := testAttachmentTab(sess) | |
| 496 | 496 | stackPTY := portsmocks.NewMockPTY(t) | |
| 497 | 497 | stackPane := newPane("pane-2", stackPTY, domain.Size{Cols: 41, Rows: 2}) | |
| 498 | 498 | tb.panes[stackPane.id] = stackPane | |
| @@ -520,7 +520,7 @@ func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) { | |||
| 520 | 520 | cause := errors.New("open failed") | |
| 521 | 521 | factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(nil, cause).Once() | |
| 522 | 522 | ||
| 523 | - | tb := sess.activeTab() | |
| 523 | + | tb := testAttachmentTab(sess) | |
| 524 | 524 | tb.mu.Lock() | |
| 525 | 525 | beforeRoot, beforeFocus, beforeNext, beforePaneCount := tb.tree.Root.Leaf, tb.tree.Focus, tb.nextPaneID, len(tb.panes) | |
| 526 | 526 | tb.mu.Unlock() | |
| @@ -547,7 +547,7 @@ func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) { | |||
| 547 | 547 | ||
| 548 | 548 | func TestApplyLayoutResizesPTYsAndScreens(t *testing.T) { | |
| 549 | 549 | d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 550 | - | tb := sess.activeTab() | |
| 550 | + | tb := testAttachmentTab(sess) | |
| 551 | 551 | newPTY := portsmocks.NewMockPTY(t) | |
| 552 | 552 | newPane := newPane("pane-2", newPTY, domain.Size{Cols: 41, Rows: 10}) | |
| 553 | 553 | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"} | |
| @@ -564,7 +564,7 @@ func TestApplyLayoutResizesPTYsAndScreens(t *testing.T) { | |||
| 564 | 564 | ||
| 565 | 565 | func TestResizeReflowsAllPanes(t *testing.T) { | |
| 566 | 566 | d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 567 | - | tb := sess.activeTab() | |
| 567 | + | tb := testAttachmentTab(sess) | |
| 568 | 568 | newPTY := portsmocks.NewMockPTY(t) | |
| 569 | 569 | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"} | |
| 570 | 570 | tb.panes["pane-2"] = newPane("pane-2", newPTY, domain.Size{Cols: 20, Rows: 10}) | |
| @@ -596,7 +596,7 @@ func TestFocusDirAtReturnsDepartingSpanAndMapsNoNeighbor(t *testing.T) { | |||
| 596 | 596 | for _, tt := range tests { | |
| 597 | 597 | t.Run(tt.name, func(t *testing.T) { | |
| 598 | 598 | d, sess, _, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 599 | - | tb := sess.activeTab() | |
| 599 | + | tb := testAttachmentTab(sess) | |
| 600 | 600 | target := tb.panes["pane-1"] | |
| 601 | 601 | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"} | |
| 602 | 602 | tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10} | |
| @@ -614,7 +614,7 @@ func TestFocusDirAtReturnsDepartingSpanAndMapsNoNeighbor(t *testing.T) { | |||
| 614 | 614 | ||
| 615 | 615 | func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) { | |
| 616 | 616 | d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 617 | - | tb := sess.activeTab() | |
| 617 | + | tb := testAttachmentTab(sess) | |
| 618 | 618 | newPTY := portsmocks.NewMockPTY(t) | |
| 619 | 619 | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"} | |
| 620 | 620 | tb.panes["pane-2"] = newPane("pane-2", newPTY, domain.Size{Cols: 20, Rows: 10}) | |
| @@ -623,6 +623,8 @@ func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) { | |||
| 623 | 623 | tr, _ := newCapturingTransport(t) | |
| 624 | 624 | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 41, Rows: 12}} | |
| 625 | 625 | ac.initOverlays() | |
| 626 | + | ac.setSession(sess) | |
| 627 | + | require.True(t, sess.registerAttachment(ac)) | |
| 626 | 628 | ac.overlays.copyMode = &scopy.Mode{} | |
| 627 | 629 | ||
| 628 | 630 | require.NoError(t, d.focusDir(sess, ac, layout.Right, nil)) | |
| @@ -633,9 +635,119 @@ func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) { | |||
| 633 | 635 | newPTY.AssertNotCalled(t, "Resize", mock.Anything) | |
| 634 | 636 | } | |
| 635 | 637 | ||
| 638 | + | func TestClosePaneRepaintFanoutRespectsAttachmentScope(t *testing.T) { | |
| 639 | + | for _, tt := range []struct { | |
| 640 | + | name string | |
| 641 | + | attachment *attachedClient | |
| 642 | + | wantFirst bool | |
| 643 | + | wantSecond bool | |
| 644 | + | }{ | |
| 645 | + | {name: "nil attachment fans out", wantFirst: true, wantSecond: true}, | |
| 646 | + | {name: "non-nil attachment stays targeted"}, | |
| 647 | + | } { | |
| 648 | + | t.Run(tt.name, func(t *testing.T) { | |
| 649 | + | d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 650 | + | tb := testAttachmentTab(sess) | |
| 651 | + | closingPTY := portsmocks.NewMockPTY(t) | |
| 652 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"} | |
| 653 | + | tb.panes["pane-2"] = newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10}) | |
| 654 | + | tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10} | |
| 655 | + | tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10} | |
| 656 | + | oldPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Return(nil).Once() | |
| 657 | + | closingPTY.EXPECT().Close().Return(nil).Once() | |
| 658 | + | ||
| 659 | + | clients := make([]*attachedClient, 2) | |
| 660 | + | sends := make([]chan ports.Frame, 2) | |
| 661 | + | for i := range clients { | |
| 662 | + | tr, sent := newCapturingTransport(t) | |
| 663 | + | clients[i] = &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 41, Rows: 12}} | |
| 664 | + | clients[i].clientID[0] = byte(i + 1) | |
| 665 | + | clients[i].initOverlays() | |
| 666 | + | clients[i].setSession(sess) | |
| 667 | + | require.True(t, sess.registerAttachment(clients[i])) | |
| 668 | + | sends[i] = sent | |
| 669 | + | } | |
| 670 | + | if tt.name == "non-nil attachment stays targeted" { | |
| 671 | + | tt.attachment = clients[0] | |
| 672 | + | tt.wantFirst = true | |
| 673 | + | } | |
| 674 | + | ||
| 675 | + | require.NoError(t, d.closePane(sess, tb, "pane-2", tt.attachment, true)) | |
| 676 | + | for i, want := range []bool{tt.wantFirst, tt.wantSecond} { | |
| 677 | + | select { | |
| 678 | + | case frame := <-sends[i]: | |
| 679 | + | require.True(t, want, "attachment %d unexpectedly repainted: %#v", i, frame) | |
| 680 | + | require.Equal(t, ports.MsgOutput, frame.Type) | |
| 681 | + | default: | |
| 682 | + | require.False(t, want, "attachment %d was not repainted", i) | |
| 683 | + | } | |
| 684 | + | } | |
| 685 | + | }) | |
| 686 | + | } | |
| 687 | + | } | |
| 688 | + | ||
| 689 | + | func TestFinalPaneReapClearsCopyModeForEveryAttachment(t *testing.T) { | |
| 690 | + | d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 691 | + | tb := testAttachmentTab(sess) | |
| 692 | + | other := newTab(nil, domain.Size{Cols: 41, Rows: 10}) | |
| 693 | + | sess.tabs = append(sess.tabs, other) | |
| 694 | + | d.sessions[sess.id] = sess | |
| 695 | + | pane := tb.focusedPane() | |
| 696 | + | publishTiledPaneOwners(sess, tb) | |
| 697 | + | oldPTY.EXPECT().Close().Return(nil).Once() | |
| 698 | + | ||
| 699 | + | for i := range 2 { | |
| 700 | + | tr, _ := newCapturingTransport(t) | |
| 701 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 41, Rows: 12}} | |
| 702 | + | ac.clientID[0] = byte(i + 1) | |
| 703 | + | ac.initOverlays() | |
| 704 | + | ac.setSession(sess) | |
| 705 | + | require.True(t, sess.registerAttachment(ac)) | |
| 706 | + | ac.overlays.copyMode = &scopy.Mode{} | |
| 707 | + | ac.overlays.copyPane = pane | |
| 708 | + | } | |
| 709 | + | ||
| 710 | + | d.reapPaneOwner(pane) | |
| 711 | + | ||
| 712 | + | require.Len(t, sess.tabs, 1) | |
| 713 | + | for _, ac := range sess.snapshotAttachments() { | |
| 714 | + | require.Nil(t, ac.overlays.copyMode, "final-pane reap left copy mode active for attachment %d", ac.clientID[0]) | |
| 715 | + | require.Nil(t, ac.overlays.copyPane, "final-pane reap left copy pane active for attachment %d", ac.clientID[0]) | |
| 716 | + | } | |
| 717 | + | } | |
| 718 | + | ||
| 719 | + | func TestClosePaneActionDoesNotReenterDispatchBoundary(t *testing.T) { | |
| 720 | + | d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 721 | + | tb := testAttachmentTab(sess) | |
| 722 | + | closingPTY := portsmocks.NewMockPTY(t) | |
| 723 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"} | |
| 724 | + | tb.panes["pane-2"] = newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10}) | |
| 725 | + | tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10} | |
| 726 | + | tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10} | |
| 727 | + | d.sessions[sess.id] = sess | |
| 728 | + | oldPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Return(nil).Once() | |
| 729 | + | closingPTY.EXPECT().Close().Return(nil).Once() | |
| 730 | + | ||
| 731 | + | done := make(chan error, 1) | |
| 732 | + | go func() { | |
| 733 | + | done <- sess.runMutation(func() error { | |
| 734 | + | return (daemonActions{d: d}).Run(daemonActionRequest{ | |
| 735 | + | kind: daemonActionClosePane, | |
| 736 | + | target: daemonActionTarget{session: sess, tab: tb, pane: tb.panes["pane-2"]}, | |
| 737 | + | }) | |
| 738 | + | }) | |
| 739 | + | }() | |
| 740 | + | select { | |
| 741 | + | case err := <-done: | |
| 742 | + | require.NoError(t, err) | |
| 743 | + | case <-time.After(time.Second): | |
| 744 | + | t.Fatal("close pane action re-entered dispatch boundary") | |
| 745 | + | } | |
| 746 | + | } | |
| 747 | + | ||
| 636 | 748 | func TestCloseFocusedPaneRemovesPaneReflowsAndIsIdempotent(t *testing.T) { | |
| 637 | 749 | d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 638 | - | tb := sess.activeTab() | |
| 750 | + | tb := testAttachmentTab(sess) | |
| 639 | 751 | closingPTY := portsmocks.NewMockPTY(t) | |
| 640 | 752 | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"} | |
| 641 | 753 | tb.panes["pane-2"] = newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10}) | |
| @@ -654,7 +766,7 @@ func TestCloseFocusedPaneRemovesPaneReflowsAndIsIdempotent(t *testing.T) { | |||
| 654 | 766 | ||
| 655 | 767 | func TestReapPaneSharesClosePathAndIsIdempotent(t *testing.T) { | |
| 656 | 768 | d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 657 | - | tb := sess.activeTab() | |
| 769 | + | tb := testAttachmentTab(sess) | |
| 658 | 770 | reapedPTY := portsmocks.NewMockPTY(t) | |
| 659 | 771 | reaped := newPane("pane-2", reapedPTY, domain.Size{Cols: 20, Rows: 10}) | |
| 660 | 772 | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"} | |
| @@ -673,7 +785,7 @@ func TestReapPaneSharesClosePathAndIsIdempotent(t *testing.T) { | |||
| 673 | 785 | ||
| 674 | 786 | func TestClosePaneKeepsTabOpenWhenPendingSpawnLeafExists(t *testing.T) { | |
| 675 | 787 | d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 676 | - | tb := sess.activeTab() | |
| 788 | + | tb := testAttachmentTab(sess) | |
| 677 | 789 | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"} | |
| 678 | 790 | oldPTY.EXPECT().Close().Return(nil).Once() | |
| 679 | 791 | ||
| @@ -705,7 +817,7 @@ func TestStackPaneCreatesStackAndToggleRestoresSplit(t *testing.T) { | |||
| 705 | 817 | factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once() | |
| 706 | 818 | ||
| 707 | 819 | require.NoError(t, d.stackPane(sess, nil)) | |
| 708 | - | tb := sess.activeTab() | |
| 820 | + | tb := testAttachmentTab(sess) | |
| 709 | 821 | require.Equal(t, layout.Stack, tb.tree.Root.Kind) | |
| 710 | 822 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |
| 711 | 823 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded) | |
| @@ -728,7 +840,7 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) { | |||
| 728 | 840 | ||
| 729 | 841 | require.NoError(t, d.focusDir(sess, nil, layout.Up, nil)) | |
| 730 | 842 | ||
| 731 | - | tb := sess.activeTab() | |
| 843 | + | tb := testAttachmentTab(sess) | |
| 732 | 844 | require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus) | |
| 733 | 845 | require.Equal(t, layout.PaneID("pane-1"), tb.tree.Root.Expanded) | |
| 734 | 846 | thirdPTY := portsmocks.NewMockPTY(t) | |
| @@ -773,7 +885,7 @@ func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) { | |||
| 773 | 885 | factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once() | |
| 774 | 886 | ||
| 775 | 887 | require.NoError(t, d.splitPane(sess, nil, layout.Right)) | |
| 776 | - | tb := sess.activeTab() | |
| 888 | + | tb := testAttachmentTab(sess) | |
| 777 | 889 | tb.mu.Lock() | |
| 778 | 890 | tb.tree.Focus = "pane-1" | |
| 779 | 891 | tb.mu.Unlock() | |
| @@ -785,6 +897,7 @@ func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) { | |||
| 785 | 897 | ac := &attachedClient{} | |
| 786 | 898 | ac.initOverlays() | |
| 787 | 899 | ac.setSession(sess) | |
| 900 | + | require.True(t, sess.registerAttachment(ac)) | |
| 788 | 901 | daemonKeyHandler{d: d, ac: ac}.Forward([]byte("Z")) | |
| 789 | 902 | require.Equal(t, []byte("Z"), <-writes) | |
| 790 | 903 | tb.focusedPane().screen.Write([]byte("survivor")) | |
| @@ -844,7 +957,7 @@ func TestSplitPaneUsesExactAuthoritativeEnvironment(t *testing.T) { | |||
| 844 | 957 | ).Once() | |
| 845 | 958 | ||
| 846 | 959 | require.NoError(t, d.splitPane(sess, nil, layout.Right)) | |
| 847 | - | tb := sess.activeTab() | |
| 960 | + | tb := testAttachmentTab(sess) | |
| 848 | 961 | tb.mu.Lock() | |
| 849 | 962 | paneID := tb.panes["pane-2"].stableID | |
| 850 | 963 | tabID := tb.stableID | |
M internal/usecase/daemon/pane_lifetime_test.go +0 -128
| @@ -64,12 +64,6 @@ func (f *lifetimePTYFactory) Open(ctx context.Context, _ string, _ []string, _ [ | |||
| 64 | 64 | return p, nil | |
| 65 | 65 | } | |
| 66 | 66 | ||
| 67 | - | func (f *lifetimePTYFactory) opened(index int) (*lifetimePTY, context.Context) { | |
| 68 | - | f.mu.Lock() | |
| 69 | - | defer f.mu.Unlock() | |
| 70 | - | return f.ptys[index], f.contexts[index] | |
| 71 | - | } | |
| 72 | - | ||
| 73 | 67 | func TestPaneOpenErrorsReleaseReturnedPTYWithoutPublication(t *testing.T) { | |
| 74 | 68 | type openCase struct { | |
| 75 | 69 | name string | |
| @@ -211,125 +205,3 @@ func (f *paneErrorFactory) Open(ctx context.Context, _ string, _ []string, _ []s | |||
| 211 | 205 | } | |
| 212 | 206 | return f.pty, f.err | |
| 213 | 207 | } | |
| 214 | - | ||
| 215 | - | func TestPaneProcessLifetimeIsDaemonRooted(t *testing.T) { | |
| 216 | - | type setupFunc func(*testing.T, *Daemon) (*pane, func(), func()) | |
| 217 | - | tests := []struct { | |
| 218 | - | name string | |
| 219 | - | setup setupFunc | |
| 220 | - | }{ | |
| 221 | - | { | |
| 222 | - | name: "initial pane", | |
| 223 | - | setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) { | |
| 224 | - | sess, err := createSessionForTest(d, "work", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil) | |
| 225 | - | require.NoError(t, err) | |
| 226 | - | return sess.activeTab().focusedPane(), sess.cancel, func() { | |
| 227 | - | _ = d.killSession(sess, ports.ReasonSessionKilled, false) | |
| 228 | - | } | |
| 229 | - | }, | |
| 230 | - | }, | |
| 231 | - | { | |
| 232 | - | name: "created tab", | |
| 233 | - | setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) { | |
| 234 | - | sessCtx, cancelSession := context.WithCancel(t.Context()) | |
| 235 | - | base := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 236 | - | base.ctx, base.cancel = context.WithCancel(sessCtx) | |
| 237 | - | sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{base}} | |
| 238 | - | d.mu.Lock() | |
| 239 | - | d.sessions[sess.id] = sess | |
| 240 | - | d.mu.Unlock() | |
| 241 | - | require.NoError(t, d.createTab(sess, domain.Size{Cols: 80, Rows: 24})) | |
| 242 | - | created := sess.activeTab() | |
| 243 | - | return created.focusedPane(), cancelSession, created.closeAllPanes | |
| 244 | - | }, | |
| 245 | - | }, | |
| 246 | - | { | |
| 247 | - | name: "split pane", | |
| 248 | - | setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) { | |
| 249 | - | sessCtx, cancelSession := context.WithCancel(t.Context()) | |
| 250 | - | tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 251 | - | tb.ctx, tb.cancel = context.WithCancel(sessCtx) | |
| 252 | - | sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}} | |
| 253 | - | target := tb.focusedPane() | |
| 254 | - | require.NoError(t, d.splitPaneAt(sess, tb, target, layout.Right)) | |
| 255 | - | tb.mu.Lock() | |
| 256 | - | p := tb.panes["pane-2"] | |
| 257 | - | tb.mu.Unlock() | |
| 258 | - | require.NotNil(t, p) | |
| 259 | - | return p, cancelSession, tb.closeAllPanes | |
| 260 | - | }, | |
| 261 | - | }, | |
| 262 | - | { | |
| 263 | - | name: "floating pane", | |
| 264 | - | setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) { | |
| 265 | - | sessCtx, cancelSession := context.WithCancel(t.Context()) | |
| 266 | - | tb := newFloatingTestTab(t) | |
| 267 | - | tb.ctx, tb.cancel = context.WithCancel(sessCtx) | |
| 268 | - | sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}} | |
| 269 | - | tb.mu.Lock() | |
| 270 | - | generation := tb.beginFloatingWarmLocked(true) | |
| 271 | - | tb.mu.Unlock() | |
| 272 | - | d.openAndInstallFloating(sess, tb, floatingLaunchSpec{ | |
| 273 | - | sessionName: "work", size: domain.Size{Cols: 20, Rows: 8}, | |
| 274 | - | geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}}, | |
| 275 | - | paneStableID: "p_floating", parentCtx: tb.ctx, | |
| 276 | - | }, generation) | |
| 277 | - | tb.mu.Lock() | |
| 278 | - | p := tb.floating.pane | |
| 279 | - | tb.mu.Unlock() | |
| 280 | - | require.NotNil(t, p) | |
| 281 | - | return p, cancelSession, func() { d.teardownFloating(tb, nil) } | |
| 282 | - | }, | |
| 283 | - | }, | |
| 284 | - | { | |
| 285 | - | name: "restored pane", | |
| 286 | - | setup: func(t *testing.T, d *Daemon) (*pane, func(), func()) { | |
| 287 | - | restoreCtx, cancelRestore := context.WithCancel(t.Context()) | |
| 288 | - | sessCtx, cancelSession := context.WithCancel(t.Context()) | |
| 289 | - | tabs, err := d.restoreSnapshotTabs(restoreCtx, sessCtx, restoreAcceptanceSession(t, "work")) | |
| 290 | - | require.NoError(t, err) | |
| 291 | - | require.Len(t, tabs, 1) | |
| 292 | - | return tabs[0].focusedPane(), cancelSession, func() { | |
| 293 | - | cancelRestore() | |
| 294 | - | closeRestoredTabs(tabs) | |
| 295 | - | } | |
| 296 | - | }, | |
| 297 | - | }, | |
| 298 | - | } | |
| 299 | - | ||
| 300 | - | for _, tt := range tests { | |
| 301 | - | t.Run(tt.name, func(t *testing.T) { | |
| 302 | - | factory := &lifetimePTYFactory{} | |
| 303 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 304 | - | p, cancelSession, teardown := tt.setup(t, d) | |
| 305 | - | defer func() { | |
| 306 | - | if teardown != nil { | |
| 307 | - | teardown() | |
| 308 | - | } | |
| 309 | - | }() | |
| 310 | - | ||
| 311 | - | require.NotNil(t, p) | |
| 312 | - | pty, openCtx := factory.opened(0) | |
| 313 | - | require.NotNil(t, p.ctx) | |
| 314 | - | require.Same(t, openCtx, p.ctx) | |
| 315 | - | require.NotNil(t, p.cancel) | |
| 316 | - | ||
| 317 | - | cancelSession() | |
| 318 | - | select { | |
| 319 | - | case <-openCtx.Done(): | |
| 320 | - | t.Fatal("session cancellation killed a published pane process") | |
| 321 | - | default: | |
| 322 | - | } | |
| 323 | - | ||
| 324 | - | teardown() | |
| 325 | - | teardown = nil | |
| 326 | - | select { | |
| 327 | - | case <-openCtx.Done(): | |
| 328 | - | case <-time.After(time.Second): | |
| 329 | - | t.Fatal("teardown did not cancel the pane process") | |
| 330 | - | } | |
| 331 | - | d.sessWg.Wait() | |
| 332 | - | require.Equal(t, int32(1), pty.closes.Load()) | |
| 333 | - | }) | |
| 334 | - | } | |
| 335 | - | } | |
M internal/usecase/daemon/pane_owner_reader_test.go +1 -66
| @@ -51,81 +51,16 @@ func (*ownerRoutingPTY) ForegroundPgid() (int, error) { | |||
| 51 | 51 | return 0, nil | |
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | - | func installTiledPaneOwnerForTest(sess *session, tb *tab, p *pane) { | |
| 55 | - | tb.mu.Lock() | |
| 56 | - | old := tb.panes[layout.PaneID("pane-1")] | |
| 57 | - | delete(tb.panes, layout.PaneID("pane-1")) | |
| 58 | - | if old != nil { | |
| 59 | - | old.clearOwnerForTest() | |
| 60 | - | } | |
| 61 | - | p.id = layout.PaneID("pane-1") | |
| 62 | - | tb.panes[p.id] = p | |
| 63 | - | tb.tree = &layout.Tree{Root: layout.NewLeaf(p.id), Focus: p.id} | |
| 64 | - | publishPaneOwner(p, sess, tb, 0) | |
| 65 | - | tb.mu.Unlock() | |
| 66 | - | } | |
| 67 | - | ||
| 68 | 54 | func (p *pane) clearOwnerForTest() { | |
| 69 | 55 | p.mu.Lock() | |
| 70 | 56 | p.clearOwnerLocked() | |
| 71 | 57 | p.mu.Unlock() | |
| 72 | 58 | } | |
| 73 | 59 | ||
| 74 | - | func TestPTYReaderRoutesEffectsToOwnerPublishedForEachRead(t *testing.T) { | |
| 75 | - | pty := newOwnerRoutingPTY() | |
| 76 | - | d, source, _, _ := newManualSessionWithPTYs(t, pty) | |
| 77 | - | pane := source.activeTab().focusedPane() | |
| 78 | - | pane.onExit = func() {} | |
| 79 | - | source.snapEligible.Store(true) | |
| 80 | - | ||
| 81 | - | destinationTab := newTab(nil, domain.Size{Cols: 80, Rows: 23}) | |
| 82 | - | destination := &session{sessionCore: sessionCore{id: domain.SessionID("destination"), | |
| 83 | - | name: "destination", | |
| 84 | - | ||
| 85 | - | client: &attachedClient{}}, tabs: []*tab{destinationTab}, | |
| 86 | - | ||
| 87 | - | snapEligible: atomic.Bool{}, | |
| 88 | - | } | |
| 89 | - | destination.snapEligible.Store(true) | |
| 90 | - | destination.client.initOverlays() | |
| 91 | - | destination.client.setSession(destination) | |
| 92 | - | d.sessions[destination.id] = destination | |
| 93 | - | ||
| 94 | - | sourceInvalidations := make(chan renderInvalidation, 2) | |
| 95 | - | destinationInvalidations := make(chan renderInvalidation, 2) | |
| 96 | - | sourceCoordinator := d.attachCoordinator(source, nil, source.client, true) | |
| 97 | - | sourceCoordinator.opts.onInvalidate = func(invalidation renderInvalidation) { sourceInvalidations <- invalidation } | |
| 98 | - | destinationCoordinator := d.attachCoordinator(destination, nil, destination.client, true) | |
| 99 | - | destinationCoordinator.opts.onInvalidate = func(invalidation renderInvalidation) { destinationInvalidations <- invalidation } | |
| 100 | - | ||
| 101 | - | d.startPaneGoroutines(source, source.activeTab(), pane) | |
| 102 | - | pty.steps <- channelPTYStep{data: []byte("source\a")} | |
| 103 | - | <-pty.processed | |
| 104 | - | require.Len(t, sourceInvalidations, 1) | |
| 105 | - | require.Empty(t, destinationInvalidations) | |
| 106 | - | require.Equal(t, uint64(1), source.snapshotGeneration) | |
| 107 | - | require.Zero(t, destination.snapshotGeneration) | |
| 108 | - | require.True(t, source.activeTab().attention) | |
| 109 | - | ||
| 110 | - | installTiledPaneOwnerForTest(destination, destinationTab, pane) | |
| 111 | - | pty.steps <- channelPTYStep{data: []byte("destination\a")} | |
| 112 | - | <-pty.processed | |
| 113 | - | ||
| 114 | - | require.Len(t, sourceInvalidations, 1, "output after owner publication must not invalidate the source") | |
| 115 | - | require.Len(t, destinationInvalidations, 1, "output after owner publication must invalidate the destination") | |
| 116 | - | require.Equal(t, uint64(1), source.snapshotGeneration, "source snapshot must not be dirtied after transfer") | |
| 117 | - | require.Equal(t, uint64(1), destination.snapshotGeneration) | |
| 118 | - | require.True(t, destinationTab.attention) | |
| 119 | - | require.Equal(t, int32(1), pty.maxReaders.Load(), "owner publication must not start another PTY reader") | |
| 120 | - | ||
| 121 | - | pty.steps <- channelPTYStep{err: io.EOF} | |
| 122 | - | d.sessWg.Wait() | |
| 123 | - | } | |
| 124 | - | ||
| 125 | 60 | func TestPTYReaderExitReapsExactlyCurrentOwnerAfterPublication(t *testing.T) { | |
| 126 | 61 | pty := newOwnerRoutingPTY() | |
| 127 | 62 | d, source, _, _ := newManualSessionWithPTYs(t, pty) | |
| 128 | - | sourceTab := source.activeTab() | |
| 63 | + | sourceTab := testAttachmentTab(source) | |
| 129 | 64 | moved := sourceTab.focusedPane() | |
| 130 | 65 | ||
| 131 | 66 | destinationTab := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 23}) | |
M internal/usecase/daemon/pane_rearrange_test.go +2 -110
| @@ -2,7 +2,6 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "errors" | |
| 6 | 5 | "io" | |
| 7 | 6 | "maps" | |
| 8 | 7 | "sync" | |
| @@ -185,7 +184,7 @@ func TestPaneRearrangeEdgeNoopHasExactSilentInvariants(t *testing.T) { | |||
| 185 | 184 | ac := &attachedClient{} | |
| 186 | 185 | ac.setSession(h.session) | |
| 187 | 186 | h.session.mu.Lock() | |
| 188 | - | h.session.client = ac | |
| 187 | + | h.session.registerAttachmentLocked(ac) | |
| 189 | 188 | h.session.mu.Unlock() | |
| 190 | 189 | invalidations := make(chan renderInvalidation, 2) | |
| 191 | 190 | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |
| @@ -265,113 +264,6 @@ func TestPaneRearrangeMapsUnsupportedAndTooSmallToUserNoticesAtomically(t *testi | |||
| 265 | 264 | } | |
| 266 | 265 | } | |
| 267 | 266 | ||
| 268 | - | func TestPaneRearrangeChangedActionPublishesOnce(t *testing.T) { | |
| 269 | - | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |
| 270 | - | ac := &attachedClient{} | |
| 271 | - | ac.setSession(h.session) | |
| 272 | - | h.session.mu.Lock() | |
| 273 | - | h.session.client = ac | |
| 274 | - | h.session.mu.Unlock() | |
| 275 | - | invalidations := make(chan renderInvalidation, 2) | |
| 276 | - | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |
| 277 | - | rc.attach(ac) | |
| 278 | - | h.session.installRenderCoordinator(rc) | |
| 279 | - | ||
| 280 | - | err := (controlExec{d: h.daemon, sess: h.session, target: h.target("pane-1")}).runAction(daemonActionRequest{ | |
| 281 | - | kind: daemonActionConsumeOrExpelPane, direction: layout.Right, | |
| 282 | - | }) | |
| 283 | - | ||
| 284 | - | require.NoError(t, err) | |
| 285 | - | h.tab.mu.Lock() | |
| 286 | - | require.Equal(t, uint64(1), h.tab.layoutGeneration) | |
| 287 | - | h.tab.mu.Unlock() | |
| 288 | - | h.session.snapshotMu.Lock() | |
| 289 | - | require.Equal(t, uint64(1), h.session.snapshotGeneration) | |
| 290 | - | h.session.snapshotMu.Unlock() | |
| 291 | - | require.NotZero(t, h.totalResizes()) | |
| 292 | - | awaitInvalidation(t, invalidations) | |
| 293 | - | requireNoInvalidation(t, invalidations) | |
| 294 | - | } | |
| 295 | - | ||
| 296 | - | func TestPaneRearrangeAdaptersOwnDirectionalRequestsAndNormalizeOnlyNoChange(t *testing.T) { | |
| 297 | - | genuine := errors.New("genuine rearrange failure") | |
| 298 | - | directions := []struct { | |
| 299 | - | name string | |
| 300 | - | direction layout.Direction | |
| 301 | - | keyAction keys.Action | |
| 302 | - | paletteRun func(paletteExec) error | |
| 303 | - | controlRun func(controlExec) error | |
| 304 | - | }{ | |
| 305 | - | { | |
| 306 | - | name: "left", direction: layout.Left, keyAction: keys.ActionConsumeOrExpelPaneLeft, | |
| 307 | - | paletteRun: func(e paletteExec) error { return e.ConsumeOrExpelPaneLeft() }, | |
| 308 | - | controlRun: func(e controlExec) error { return e.ConsumeOrExpelPaneLeft() }, | |
| 309 | - | }, | |
| 310 | - | { | |
| 311 | - | name: "right", direction: layout.Right, keyAction: keys.ActionConsumeOrExpelPaneRight, | |
| 312 | - | paletteRun: func(e paletteExec) error { return e.ConsumeOrExpelPaneRight() }, | |
| 313 | - | controlRun: func(e controlExec) error { return e.ConsumeOrExpelPaneRight() }, | |
| 314 | - | }, | |
| 315 | - | } | |
| 316 | - | outcomes := []struct { | |
| 317 | - | name string | |
| 318 | - | err error | |
| 319 | - | wantErr error | |
| 320 | - | wantNotice bool | |
| 321 | - | }{ | |
| 322 | - | {name: "silent no-change", err: errDaemonActionNoChange}, | |
| 323 | - | {name: "genuine error", err: genuine, wantErr: genuine, wantNotice: true}, | |
| 324 | - | } | |
| 325 | - | ||
| 326 | - | for _, direction := range directions { | |
| 327 | - | t.Run(direction.name, func(t *testing.T) { | |
| 328 | - | for _, outcome := range outcomes { | |
| 329 | - | t.Run(outcome.name, func(t *testing.T) { | |
| 330 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 331 | - | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 332 | - | ac := &attachedClient{} | |
| 333 | - | ac.setSession(sess) | |
| 334 | - | target := resolveDaemonActionTarget(sess) | |
| 335 | - | paletteSpy := &actionRunnerSpy{err: outcome.err} | |
| 336 | - | controlSpy := &actionRunnerSpy{err: outcome.err} | |
| 337 | - | keySpy := &actionRunnerSpy{err: outcome.err} | |
| 338 | - | ||
| 339 | - | paletteErr := direction.paletteRun(paletteExec{d: d, sess: sess, ac: ac, actions: paletteSpy}) | |
| 340 | - | controlErr := direction.controlRun(controlExec{d: d, sess: sess, target: target, actions: controlSpy}) | |
| 341 | - | daemonKeyHandler{d: d, ac: ac, actions: keySpy}.Action(direction.keyAction, nil) | |
| 342 | - | ||
| 343 | - | if outcome.wantErr == nil { | |
| 344 | - | require.NoError(t, paletteErr) | |
| 345 | - | require.NoError(t, controlErr) | |
| 346 | - | } else { | |
| 347 | - | require.ErrorIs(t, paletteErr, outcome.wantErr) | |
| 348 | - | require.ErrorIs(t, controlErr, outcome.wantErr) | |
| 349 | - | } | |
| 350 | - | if outcome.wantNotice { | |
| 351 | - | require.Len(t, d.notices.history(), 1, "the key adapter owns reporting its genuine error") | |
| 352 | - | } else { | |
| 353 | - | require.Empty(t, d.notices.history(), "no-change is silent in the key adapter") | |
| 354 | - | } | |
| 355 | - | ||
| 356 | - | for adapter, spy := range map[string]*actionRunnerSpy{ | |
| 357 | - | "palette": paletteSpy, | |
| 358 | - | "control": controlSpy, | |
| 359 | - | "key": keySpy, | |
| 360 | - | } { | |
| 361 | - | require.Len(t, spy.requests, 1, "%s adapter request count", adapter) | |
| 362 | - | request := spy.requests[0] | |
| 363 | - | require.Equal(t, daemonActionConsumeOrExpelPane, request.kind, "%s adapter action kind", adapter) | |
| 364 | - | require.Equal(t, direction.direction, request.direction, "%s adapter direction", adapter) | |
| 365 | - | require.Same(t, target.session, request.target.session, "%s adapter target session", adapter) | |
| 366 | - | require.Same(t, target.tab, request.target.tab, "%s adapter target tab", adapter) | |
| 367 | - | require.Same(t, target.pane, request.target.pane, "%s adapter target pane", adapter) | |
| 368 | - | } | |
| 369 | - | }) | |
| 370 | - | } | |
| 371 | - | }) | |
| 372 | - | } | |
| 373 | - | } | |
| 374 | - | ||
| 375 | 267 | func TestPaneRearrangeNoChangeAdapterSideEffects(t *testing.T) { | |
| 376 | 268 | tests := []struct { | |
| 377 | 269 | name string | |
| @@ -406,7 +298,7 @@ func TestPaneRearrangeNoChangeAdapterSideEffects(t *testing.T) { | |||
| 406 | 298 | ac := &attachedClient{} | |
| 407 | 299 | ac.setSession(h.session) | |
| 408 | 300 | h.session.mu.Lock() | |
| 409 | - | h.session.client = ac | |
| 301 | + | h.session.registerAttachmentLocked(ac) | |
| 410 | 302 | h.session.mu.Unlock() | |
| 411 | 303 | invalidations := make(chan renderInvalidation, 2) | |
| 412 | 304 | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |
D internal/usecase/daemon/pane_sync_owner_test.go +0 -175
| @@ -1,175 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "sync" | |
| 5 | - | "testing" | |
| 6 | - | "time" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/vev/internal/domain" | |
| 9 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |
| 11 | - | "github.com/stretchr/testify/require" | |
| 12 | - | ) | |
| 13 | - | ||
| 14 | - | type paneSyncOwnerFixture struct { | |
| 15 | - | d *Daemon | |
| 16 | - | pane *pane | |
| 17 | - | source, destination *session | |
| 18 | - | sourceTab, targetTab *tab | |
| 19 | - | sourceCoordinator *renderCoordinator | |
| 20 | - | targetCoordinator *renderCoordinator | |
| 21 | - | sourceClock *coordinatorMockClock | |
| 22 | - | targetClock *coordinatorMockClock | |
| 23 | - | } | |
| 24 | - | ||
| 25 | - | func newPaneSyncOwnerFixture(t *testing.T) paneSyncOwnerFixture { | |
| 26 | - | t.Helper() | |
| 27 | - | size := domain.Size{Cols: 80, Rows: 23} | |
| 28 | - | sourceTab := newTab(newQuietPTY(), size) | |
| 29 | - | targetTab := newTab(newQuietPTY(), size) | |
| 30 | - | source := &session{sessionCore: sessionCore{id: "sync-source", client: &attachedClient{}}, tabs: []*tab{sourceTab}, active: 0} | |
| 31 | - | destination := &session{sessionCore: sessionCore{id: "sync-destination", client: &attachedClient{}}, tabs: []*tab{targetTab}, active: 0} | |
| 32 | - | d := &Daemon{sessions: map[domain.SessionID]attachmentSession{source.id: source, destination.id: destination}} | |
| 33 | - | p := sourceTab.focusedPane() | |
| 34 | - | publishPaneOwner(p, source, sourceTab, 0) | |
| 35 | - | ||
| 36 | - | // Model the membership commit that accompanies owner publication. Keeping | |
| 37 | - | // the source membership is harmless here; callbacks must route by owner. | |
| 38 | - | targetTab.mu.Lock() | |
| 39 | - | targetTab.panes[p.id] = p | |
| 40 | - | targetTab.tree.Root = layout.NewLeaf(p.id) | |
| 41 | - | targetTab.tree.Focus = p.id | |
| 42 | - | targetTab.mu.Unlock() | |
| 43 | - | ||
| 44 | - | sourceClock := newCoordinatorMockClock(t, 8) | |
| 45 | - | targetClock := newCoordinatorMockClock(t, 8) | |
| 46 | - | sourceCoordinator := newRenderCoordinator(renderCoordinatorOptions{clock: sourceClock.clock}) | |
| 47 | - | targetCoordinator := newRenderCoordinator(renderCoordinatorOptions{clock: targetClock.clock}) | |
| 48 | - | source.installRenderCoordinator(sourceCoordinator) | |
| 49 | - | destination.installRenderCoordinator(targetCoordinator) | |
| 50 | - | return paneSyncOwnerFixture{ | |
| 51 | - | d: d, pane: p, source: source, destination: destination, | |
| 52 | - | sourceTab: sourceTab, targetTab: targetTab, | |
| 53 | - | sourceCoordinator: sourceCoordinator, targetCoordinator: targetCoordinator, | |
| 54 | - | sourceClock: sourceClock, targetClock: targetClock, | |
| 55 | - | } | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | func (f paneSyncOwnerFixture) armSourceBatch(t *testing.T, active bool, force func()) (*coordinatorMockTimer, paneEffectLease) { | |
| 59 | - | t.Helper() | |
| 60 | - | f.pane.mu.Lock() | |
| 61 | - | if active { | |
| 62 | - | f.pane.screen.Write([]byte(renderer.SyncStartCSI)) | |
| 63 | - | } | |
| 64 | - | generation := f.source.syncGen.Add(1) | |
| 65 | - | f.pane.syncGen = generation | |
| 66 | - | lease := f.pane.effectLeaseLocked() | |
| 67 | - | f.pane.mu.Unlock() | |
| 68 | - | f.sourceCoordinator.noteSyncBeginWithRenderability(f.pane, generation, lease.Current, force) | |
| 69 | - | return awaitCoordinatorScheduledTimer(t, f.sourceClock), lease | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | func TestMigratePaneSynchronizedOutputOwner(t *testing.T) { | |
| 73 | - | t.Run("active batch moves to destination and only destination consumes sync end", func(t *testing.T) { | |
| 74 | - | fixture := newPaneSyncOwnerFixture(t) | |
| 75 | - | forceEntered := make(chan struct{}) | |
| 76 | - | var enterOnce sync.Once | |
| 77 | - | var oldLease paneEffectLease | |
| 78 | - | oldTimer, oldLease := fixture.armSourceBatch(t, true, func() { | |
| 79 | - | enterOnce.Do(func() { close(forceEntered) }) | |
| 80 | - | fixture.pane.mu.Lock() | |
| 81 | - | if fixture.pane.owner.Load() == oldLease.owner && fixture.pane.screen.SyncUpdateActive() { | |
| 82 | - | fixture.pane.screen.ForceSyncEnd() | |
| 83 | - | } | |
| 84 | - | fixture.pane.mu.Unlock() | |
| 85 | - | }) | |
| 86 | - | fixture.sourceCoordinator.mu.Lock() | |
| 87 | - | oldWorkerDone := fixture.sourceCoordinator.syncBatches[fixture.pane].lane.token.done | |
| 88 | - | fixture.sourceCoordinator.mu.Unlock() | |
| 89 | - | ||
| 90 | - | // Select the old watchdog while the pane parsing fence is held. The | |
| 91 | - | // migration must detach it without waiting for its blocked callback. | |
| 92 | - | fixture.pane.mu.Lock() | |
| 93 | - | oldTimer.ch <- time.Time{} | |
| 94 | - | <-forceEntered | |
| 95 | - | oldOwner, newOwner := fixture.pane.publishOwnerLocked(fixture.destination, fixture.targetTab, 0) | |
| 96 | - | cleanup := fixture.d.migratePaneSyncOwnerLocked(fixture.pane, oldOwner, newOwner) | |
| 97 | - | oldTimer.mock.AssertNotCalled(t, "Stop") | |
| 98 | - | fixture.sourceCoordinator.mu.Lock() | |
| 99 | - | require.NotContains(t, fixture.sourceCoordinator.syncBatches, fixture.pane) | |
| 100 | - | fixture.sourceCoordinator.mu.Unlock() | |
| 101 | - | fixture.targetCoordinator.mu.Lock() | |
| 102 | - | targetBatch := fixture.targetCoordinator.syncBatches[fixture.pane] | |
| 103 | - | fixture.targetCoordinator.mu.Unlock() | |
| 104 | - | require.NotNil(t, targetBatch) | |
| 105 | - | require.Equal(t, fixture.destination.syncGen.Load(), targetBatch.generation) | |
| 106 | - | fixture.pane.mu.Unlock() | |
| 107 | - | cleanup.finish() | |
| 108 | - | oldTimer.mock.AssertNumberOfCalls(t, "Stop", 1) | |
| 109 | - | <-oldWorkerDone | |
| 110 | - | ||
| 111 | - | fixture.pane.mu.Lock() | |
| 112 | - | require.True(t, fixture.pane.screen.SyncUpdateActive(), "selected old watchdog must become a stale no-op") | |
| 113 | - | fixture.pane.mu.Unlock() | |
| 114 | - | ||
| 115 | - | fixture.d.processPanePTYData(fixture.pane, []byte(renderer.SyncEndCSI), false) | |
| 116 | - | fixture.sourceCoordinator.mu.Lock() | |
| 117 | - | require.NotContains(t, fixture.sourceCoordinator.syncBatches, fixture.pane) | |
| 118 | - | fixture.sourceCoordinator.mu.Unlock() | |
| 119 | - | fixture.targetCoordinator.mu.Lock() | |
| 120 | - | require.NotContains(t, fixture.targetCoordinator.syncBatches, fixture.pane) | |
| 121 | - | fixture.targetCoordinator.mu.Unlock() | |
| 122 | - | fixture.pane.mu.Lock() | |
| 123 | - | require.False(t, fixture.pane.screen.SyncUpdateActive()) | |
| 124 | - | fixture.pane.mu.Unlock() | |
| 125 | - | }) | |
| 126 | - | ||
| 127 | - | t.Run("destination callbacks reject a newer owner generation", func(t *testing.T) { | |
| 128 | - | fixture := newPaneSyncOwnerFixture(t) | |
| 129 | - | _, _ = fixture.armSourceBatch(t, true, nil) | |
| 130 | - | fixture.pane.mu.Lock() | |
| 131 | - | oldOwner, newOwner := fixture.pane.publishOwnerLocked(fixture.destination, fixture.targetTab, 0) | |
| 132 | - | cleanup := fixture.d.migratePaneSyncOwnerLocked(fixture.pane, oldOwner, newOwner) | |
| 133 | - | fixture.pane.mu.Unlock() | |
| 134 | - | cleanup.finish() | |
| 135 | - | _ = awaitCoordinatorScheduledTimer(t, fixture.targetClock) | |
| 136 | - | ||
| 137 | - | fixture.targetCoordinator.mu.Lock() | |
| 138 | - | targetBatch := fixture.targetCoordinator.syncBatches[fixture.pane] | |
| 139 | - | fixture.targetCoordinator.mu.Unlock() | |
| 140 | - | require.NotNil(t, targetBatch) | |
| 141 | - | require.True(t, targetBatch.renderable()) | |
| 142 | - | ||
| 143 | - | fixture.pane.mu.Lock() | |
| 144 | - | fixture.pane.publishOwnerLocked(fixture.source, fixture.sourceTab, 0) | |
| 145 | - | fixture.pane.mu.Unlock() | |
| 146 | - | require.False(t, targetBatch.renderable(), "renderability must validate the destination owner generation") | |
| 147 | - | targetBatch.force() | |
| 148 | - | fixture.pane.mu.Lock() | |
| 149 | - | require.True(t, fixture.pane.screen.SyncUpdateActive(), "force-end must validate the destination owner generation") | |
| 150 | - | fixture.pane.mu.Unlock() | |
| 151 | - | fixture.targetCoordinator.noteSyncPaneRemoved(fixture.pane) | |
| 152 | - | }) | |
| 153 | - | ||
| 154 | - | t.Run("inactive screen removes source batch without creating destination state", func(t *testing.T) { | |
| 155 | - | fixture := newPaneSyncOwnerFixture(t) | |
| 156 | - | oldTimer, _ := fixture.armSourceBatch(t, false, nil) | |
| 157 | - | ||
| 158 | - | fixture.pane.mu.Lock() | |
| 159 | - | oldOwner, newOwner := fixture.pane.publishOwnerLocked(fixture.destination, fixture.targetTab, 0) | |
| 160 | - | cleanup := fixture.d.migratePaneSyncOwnerLocked(fixture.pane, oldOwner, newOwner) | |
| 161 | - | require.Zero(t, fixture.pane.syncGen) | |
| 162 | - | fixture.pane.mu.Unlock() | |
| 163 | - | cleanup.finish() | |
| 164 | - | ||
| 165 | - | fixture.sourceCoordinator.mu.Lock() | |
| 166 | - | require.NotContains(t, fixture.sourceCoordinator.syncBatches, fixture.pane) | |
| 167 | - | fixture.sourceCoordinator.mu.Unlock() | |
| 168 | - | fixture.targetCoordinator.mu.Lock() | |
| 169 | - | require.NotContains(t, fixture.targetCoordinator.syncBatches, fixture.pane) | |
| 170 | - | fixture.targetCoordinator.mu.Unlock() | |
| 171 | - | require.Zero(t, fixture.destination.syncGen.Load()) | |
| 172 | - | require.Empty(t, fixture.targetClock.timers) | |
| 173 | - | oldTimer.mock.AssertNumberOfCalls(t, "Stop", 1) | |
| 174 | - | }) | |
| 175 | - | } | |
M internal/usecase/daemon/pane_titles.go +27 -16
| @@ -137,32 +137,43 @@ func (d *Daemon) paneTitleFallback() string { | |||
| 137 | 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.activeTab() | |
| 155 | - | if tb == nil { | |
| 156 | - | return fallback | |
| 156 | + | tabs := owningTab | |
| 157 | + | if len(tabs) == 0 || tabs[0] == nil { | |
| 158 | + | sess.mu.Lock() | |
| 159 | + | tabs = append([]*tab(nil), sess.tabs...) | |
| 160 | + | sess.mu.Unlock() | |
| 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 | |
M internal/usecase/daemon/pane_titles_test.go +24 -3
| @@ -78,7 +78,7 @@ func TestRefreshPaneTitleUsesForegroundProcessComm(t *testing.T) { | |||
| 78 | 78 | title := d.refreshPaneTitle(sess, "pane-1") | |
| 79 | 79 | require.Equal(t, "vim", title) | |
| 80 | 80 | ||
| 81 | - | p := sess.activeTab().focusedPane() | |
| 81 | + | p := testAttachmentTab(sess).focusedPane() | |
| 82 | 82 | p.mu.Lock() | |
| 83 | 83 | p.screen.Write([]byte("\x1b]2;project/main.go\a")) | |
| 84 | 84 | p.refreshTerminalTitleLocked() | |
| @@ -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() | |
| @@ -142,7 +163,7 @@ func TestRefreshFloatingPaneTitleUsesShellFallbackForEmptyCommand(t *testing.T) | |||
| 142 | 163 | ||
| 143 | 164 | func TestRefreshPaneTitleUpdatesNormalPaneShellFallback(t *testing.T) { | |
| 144 | 165 | d, sess, _, _ := newManualSessionWithPTYs(t, nil) | |
| 145 | - | p := sess.activeTab().focusedPane() | |
| 166 | + | p := testAttachmentTab(sess).focusedPane() | |
| 146 | 167 | ||
| 147 | 168 | d.shell = "/usr/bin/fish" | |
| 148 | 169 | require.Equal(t, "fish", d.refreshPaneTitle(sess, "pane-1")) | |
| @@ -170,7 +191,7 @@ func TestRefreshPaneTitleLookupFailureKeepsProcessNameEmpty(t *testing.T) { | |||
| 170 | 191 | d := newTestDaemon(t, nil, clk) | |
| 171 | 192 | d.shell = "/usr/bin/fish" | |
| 172 | 193 | d.procComm = func(int) (string, error) { return "", errors.New("unused") } | |
| 173 | - | p := sess.activeTab().focusedPane() | |
| 194 | + | p := testAttachmentTab(sess).focusedPane() | |
| 174 | 195 | ||
| 175 | 196 | p.mu.Lock() | |
| 176 | 197 | p.screen.Write([]byte("\x1b]2;terminal\a")) | |
M internal/usecase/daemon/performance_benchmark_test.go +11 -11
| @@ -1247,19 +1247,19 @@ func TestLivePaintAllocationBudget(t *testing.T) { | |||
| 1247 | 1247 | } | |
| 1248 | 1248 | } | |
| 1249 | 1249 | ||
| 1250 | - | // TestCopyEnterAllocationBudget protects the parent baseline plus 10%. Copy | |
| 1251 | - | // rendering borrows sealed VT history rows; allocating a copy per viewport row | |
| 1252 | - | // is a production regression even though the capture itself remains immutable. | |
| 1250 | + | // TestCopyEnterAllocationBudget protects the attachment-aware baseline plus | |
| 1251 | + | // 10%. Copy rendering borrows sealed VT history rows; allocating a copy per | |
| 1252 | + | // viewport row is a production regression even though capture is immutable. | |
| 1253 | 1253 | func TestCopyEnterAllocationBudget(t *testing.T) { | |
| 1254 | 1254 | for _, tt := range []struct { | |
| 1255 | 1255 | name string | |
| 1256 | 1256 | tabs, panes, max int | |
| 1257 | 1257 | }{ | |
| 1258 | - | {name: "1tab-1pane", tabs: 1, panes: 1, max: 38}, | |
| 1259 | - | {name: "1tab-4panes", tabs: 1, panes: 4, max: 43}, | |
| 1260 | - | {name: "4tabs-1pane", tabs: 4, panes: 1, max: 42}, | |
| 1261 | - | {name: "4tabs-4panes", tabs: 4, panes: 4, max: 48}, | |
| 1262 | - | {name: "8tabs-1pane", tabs: 8, panes: 1, max: 50}, | |
| 1258 | + | {name: "1tab-1pane", tabs: 1, panes: 1, max: 55}, | |
| 1259 | + | {name: "1tab-4panes", tabs: 1, panes: 4, max: 55}, | |
| 1260 | + | {name: "4tabs-1pane", tabs: 4, panes: 1, max: 60}, | |
| 1261 | + | {name: "4tabs-4panes", tabs: 4, panes: 4, max: 60}, | |
| 1262 | + | {name: "8tabs-1pane", tabs: 8, panes: 1, max: 68}, | |
| 1263 | 1263 | } { | |
| 1264 | 1264 | t.Run(tt.name, func(t *testing.T) { | |
| 1265 | 1265 | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: 10_000}) | |
| @@ -1278,7 +1278,7 @@ func TestCopyEnterAllocationBudget(t *testing.T) { | |||
| 1278 | 1278 | } | |
| 1279 | 1279 | ||
| 1280 | 1280 | allocs := testing.AllocsPerRun(20, run) | |
| 1281 | - | require.LessOrEqual(t, allocs, float64(tt.max), "copy enter must stay within 10%% of the parent allocation baseline") | |
| 1281 | + | require.LessOrEqual(t, allocs, float64(tt.max), "copy enter must stay within 10% of the attachment-aware allocation baseline") | |
| 1282 | 1282 | }) | |
| 1283 | 1283 | } | |
| 1284 | 1284 | } | |
| @@ -1488,7 +1488,7 @@ func newPerformanceFixtureWithCleanup(t testing.TB, config performanceConfig, re | |||
| 1488 | 1488 | compose: func() { fixture.renderCompositions++ }, | |
| 1489 | 1489 | emit: func() { fixture.renderEmissions++ }, | |
| 1490 | 1490 | } | |
| 1491 | - | d.attachCoordinator(sess, nil, ac, true) | |
| 1491 | + | d.attachCoordinator(sess, ac, true) | |
| 1492 | 1492 | ||
| 1493 | 1493 | // Prime the real renderer shadow before measurements. Subsequent paints use | |
| 1494 | 1494 | // actual production diffs rather than the initial full frame. | |
| @@ -1565,7 +1565,7 @@ func performanceFullWidthRow(width, tabIndex, row int) string { | |||
| 1565 | 1565 | ||
| 1566 | 1566 | func (f *performanceFixture) findActivePane() *pane { | |
| 1567 | 1567 | f.t.Helper() | |
| 1568 | - | tb := f.sess.activeTab() | |
| 1568 | + | tb := testAttachmentTab(f.sess) | |
| 1569 | 1569 | require.NotNil(f.t, tb) | |
| 1570 | 1570 | tb.mu.Lock() | |
| 1571 | 1571 | defer tb.mu.Unlock() | |
M internal/usecase/daemon/picker.go +86 -71
| @@ -52,7 +52,7 @@ func (d *Daemon) togglePickerSort() { | |||
| 52 | 52 | // publishes its model, including an empty one; only move entry can fail for a | |
| 53 | 53 | // missing destination. | |
| 54 | 54 | func (d *Daemon) enterPicker(sess attachmentSession, ac *attachedClient) { | |
| 55 | - | model := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | |
| 55 | + | model := d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | |
| 56 | 56 | d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{}) | |
| 57 | 57 | } | |
| 58 | 58 | ||
| @@ -83,7 +83,7 @@ func (d *Daemon) publishPicker(sess attachmentSession, ac *attachedClient, model | |||
| 83 | 83 | ||
| 84 | 84 | // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally | |
| 85 | 85 | // knows nothing about picker intent; picker.New owns all destination policy. | |
| 86 | - | func (d *Daemon) pickerViews(cur attachmentSession) ([]picker.SessionView, picker.SourceFilter) { | |
| 86 | + | func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picker.SessionView, picker.SourceFilter) { | |
| 87 | 87 | d.mu.Lock() | |
| 88 | 88 | sessions := make([]attachmentSession, 0, len(d.sessions)) | |
| 89 | 89 | proxies := make(map[domain.SessionID]*proxySession) | |
| @@ -119,13 +119,23 @@ func (d *Daemon) pickerViews(cur attachmentSession) ([]picker.SessionView, picke | |||
| 119 | 119 | live := make([]liveSnapshot, 0, len(sessions)) | |
| 120 | 120 | var current picker.SourceFilter | |
| 121 | 121 | for _, entry := range sessions { | |
| 122 | + | if entry == cur && ac != nil { | |
| 123 | + | if local, ok := localSession(entry); ok { | |
| 124 | + | local.repairAttachmentView(ac) | |
| 125 | + | } | |
| 126 | + | } | |
| 122 | 127 | snap := entry.snapshotView(opts) | |
| 123 | 128 | if entry == cur { | |
| 124 | 129 | if proxy, ok := entry.(*proxySession); ok { | |
| 125 | 130 | key := proxy.key | |
| 126 | 131 | current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, RemoteKey: &key} | |
| 127 | - | } else if snap.active >= 0 && snap.active < len(snap.tabs) { | |
| 128 | - | current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.active].id} | |
| 132 | + | } else if _, ok := localSession(entry); ok && ac != nil { | |
| 133 | + | view := ac.viewSnapshot() | |
| 134 | + | if view.tabID != "" { | |
| 135 | + | current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: view.tabID} | |
| 136 | + | } | |
| 137 | + | } else if len(snap.tabs) > 0 { | |
| 138 | + | current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.defaultTab].id} | |
| 129 | 139 | } | |
| 130 | 140 | } | |
| 131 | 141 | live = append(live, liveSnapshot{entry: entry, view: snap}) | |
| @@ -201,8 +211,8 @@ func (d *Daemon) pickerViews(cur attachmentSession) ([]picker.SessionView, picke | |||
| 201 | 211 | return views, current | |
| 202 | 212 | } | |
| 203 | 213 | ||
| 204 | - | func (d *Daemon) newPickerModel(cur attachmentSession, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model { | |
| 205 | - | views, attachedCurrent := d.pickerViews(cur) | |
| 214 | + | func (d *Daemon) newPickerModel(cur attachmentSession, ac *attachedClient, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model { | |
| 215 | + | views, attachedCurrent := d.pickerViews(cur, ac) | |
| 206 | 216 | if intent == pickerNavigate && current == (picker.SourceFilter{}) { | |
| 207 | 217 | current = attachedCurrent | |
| 208 | 218 | } | |
| @@ -248,7 +258,7 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState { | |||
| 248 | 258 | } | |
| 249 | 259 | } | |
| 250 | 260 | ||
| 251 | - | func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) { | |
| 261 | + | func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) { | |
| 252 | 262 | sess := ac.currentAttachmentSession() | |
| 253 | 263 | if sess == nil { | |
| 254 | 264 | return | |
| @@ -293,7 +303,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||
| 293 | 303 | return | |
| 294 | 304 | } | |
| 295 | 305 | if result.action == 'x' { | |
| 296 | - | var effect *roleEffectTicket | |
| 306 | + | var effect *attachmentEffectTicket | |
| 297 | 307 | if len(effects) != 0 { | |
| 298 | 308 | effect = effects[0] | |
| 299 | 309 | } | |
| @@ -301,7 +311,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||
| 301 | 311 | var err error | |
| 302 | 312 | if effect != nil { | |
| 303 | 313 | effect.bindActionEnd(d, "picker-delete") | |
| 304 | - | err = d.killPickerTargetForRole(target, effect.roleToken()) | |
| 314 | + | err = d.killPickerTargetForAttachment(target, effect.connectionToken()) | |
| 305 | 315 | } else { | |
| 306 | 316 | err = d.killPickerTarget(target) | |
| 307 | 317 | } | |
| @@ -310,7 +320,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||
| 310 | 320 | } | |
| 311 | 321 | } | |
| 312 | 322 | if effect != nil && effect.ended.Load() { | |
| 313 | - | fresh, admitted := ac.beginRoleEffect(effect.token) | |
| 323 | + | fresh, admitted := ac.beginAttachmentEffect(effect.token) | |
| 314 | 324 | if !admitted { | |
| 315 | 325 | return | |
| 316 | 326 | } | |
| @@ -349,7 +359,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||
| 349 | 359 | } | |
| 350 | 360 | var err error | |
| 351 | 361 | if len(effects) != 0 && effects[0] != nil { | |
| 352 | - | err = d.switchToTargetForRole(effects[0].roleToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select") | |
| 362 | + | err = d.switchToTargetForAttachment(effects[0].connectionToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select") | |
| 353 | 363 | } else if local, ok := localSession(sess); ok { | |
| 354 | 364 | d.closePicker(ac) | |
| 355 | 365 | err = d.switchToTarget(local, ac, target) | |
| @@ -413,7 +423,7 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | |||
| 413 | 423 | if targetSess == ac.currentAttachmentSession() { | |
| 414 | 424 | return | |
| 415 | 425 | } | |
| 416 | - | rc := d.attachCoordinator(targetSess, nil, nil, false) | |
| 426 | + | rc := d.attachCoordinator(targetSess, nil, false) | |
| 417 | 427 | rc.subscribePreviewFor(ac, generation, func(renderWake) { | |
| 418 | 428 | if pickerPreviewCurrent(ac, targetSess, next, generation) { | |
| 419 | 429 | if owner := ac.currentAttachmentSession(); owner != nil { | |
| @@ -489,9 +499,7 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) { | |||
| 489 | 499 | continue | |
| 490 | 500 | } | |
| 491 | 501 | sess.mu.Lock() | |
| 492 | - | if sess.client != nil { | |
| 493 | - | clients = append(clients, sess.client) | |
| 494 | - | } | |
| 502 | + | clients = append(clients, sess.snapshotAttachmentsLocked()...) | |
| 495 | 503 | sess.mu.Unlock() | |
| 496 | 504 | } | |
| 497 | 505 | d.mu.Unlock() | |
| @@ -519,6 +527,9 @@ func (d *Daemon) closePicker(ac *attachedClient) { | |||
| 519 | 527 | // generation guards terminal parked cleanup even when a refresh replaced the | |
| 520 | 528 | // model within that generation. | |
| 521 | 529 | func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, generation uint64) bool { | |
| 530 | + | if ac == nil || ac.overlays == nil { | |
| 531 | + | return false | |
| 532 | + | } | |
| 522 | 533 | ac.overlays.pickerMu.Lock() | |
| 523 | 534 | if model != nil && (ac.overlays.picker != model || ac.overlays.pickerGeneration != generation) || model == nil && generation != 0 && ac.overlays.pickerGeneration != generation { | |
| 524 | 535 | ac.overlays.pickerMu.Unlock() | |
| @@ -573,12 +584,12 @@ type sessionHandoffGuard struct { | |||
| 573 | 584 | closePicker bool | |
| 574 | 585 | } | |
| 575 | 586 | ||
| 576 | - | // switchActiveTargetForRole hands a frame-bound navigation request to the | |
| 587 | + | // switchActiveTargetForAttachment hands a frame-bound navigation request to the | |
| 577 | 588 | // centralized transition. The transition releases admission at its freeze seam | |
| 578 | 589 | // and revalidates the exact initiating token before changing target focus or | |
| 579 | 590 | // attachment ownership. | |
| 580 | - | func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target picker.Target) error { | |
| 581 | - | return d.switchActiveTargetForRoleGuarded(token, target, sessionHandoffGuard{}, "jump-attention") | |
| 591 | + | func (d *Daemon) switchActiveTargetForAttachment(token attachmentConnectionToken, target picker.Target) error { | |
| 592 | + | return d.switchActiveTargetForAttachmentGuarded(token, target, sessionHandoffGuard{}, "jump-attention") | |
| 582 | 593 | } | |
| 583 | 594 | ||
| 584 | 595 | func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence { | |
| @@ -603,15 +614,15 @@ func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence | |||
| 603 | 614 | return fence | |
| 604 | 615 | } | |
| 605 | 616 | ||
| 606 | - | func (d *Daemon) switchActiveTargetForRoleGuarded(token attachmentRoleToken, target picker.Target, guard sessionHandoffGuard, action string) error { | |
| 607 | - | if token.sess == nil || token.ac == nil || token.role != attachmentActive { | |
| 617 | + | func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error { | |
| 618 | + | if token.sess == nil || token.ac == nil { | |
| 608 | 619 | return nil | |
| 609 | 620 | } | |
| 610 | 621 | d.mu.Lock() | |
| 611 | 622 | targetSess, ok := localSession(d.sessions[target.Session]) | |
| 612 | 623 | d.mu.Unlock() | |
| 613 | 624 | if !ok { | |
| 614 | - | if !token.activeCurrent() { | |
| 625 | + | if !token.attachmentCurrent() { | |
| 615 | 626 | return nil | |
| 616 | 627 | } | |
| 617 | 628 | d.invalidateRender(token.sess, token.ac, true, "picker.go") | |
| @@ -623,21 +634,21 @@ func (d *Daemon) switchActiveTargetForRoleGuarded(token attachmentRoleToken, tar | |||
| 623 | 634 | ||
| 624 | 635 | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 625 | 636 | source: token.sess, target: targetSess, next: token.ac, | |
| 626 | - | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 637 | + | ||
| 627 | 638 | expectedTransport: token.transport, sourceToken: &token, action: action, | |
| 628 | 639 | expectedTargetLifecycle: pickerTargetLifecycleFence(target), | |
| 629 | 640 | activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true, | |
| 630 | 641 | }) | |
| 631 | 642 | if err != nil { | |
| 632 | - | // Losing the exact source role is a benign stale action, not a notice for | |
| 643 | + | // Losing the exact source attachment is a benign stale action, not a notice for | |
| 633 | 644 | // whichever attachment replaced the initiator. | |
| 634 | - | if !token.activeCurrent() { | |
| 645 | + | if !token.attachmentCurrent() { | |
| 635 | 646 | return nil | |
| 636 | 647 | } | |
| 637 | 648 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err) | |
| 638 | 649 | } | |
| 639 | 650 | if guard.closePicker { | |
| 640 | - | fresh, admitted := token.ac.beginRoleEffect(transition.published) | |
| 651 | + | fresh, admitted := token.ac.beginAttachmentEffect(transition.published) | |
| 641 | 652 | if admitted { | |
| 642 | 653 | d.closePicker(token.ac) | |
| 643 | 654 | fresh.End() | |
| @@ -657,34 +668,34 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker | |||
| 657 | 668 | return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{}) | |
| 658 | 669 | } | |
| 659 | 670 | ||
| 660 | - | // switchToTargetForRole is the sole client-originated navigation entry. The | |
| 671 | + | // switchToTargetForAttachment is the sole client-originated navigation entry. The | |
| 661 | 672 | // intent captures the exact initiating capability before admission is released; | |
| 662 | 673 | // every active, stopped, and same-session target then uses transitionAttachment | |
| 663 | 674 | // for frozen, atomic source-token preflight. | |
| 664 | - | func (d *Daemon) switchToTargetForRole(token attachmentRoleToken, target picker.Target, guard sessionHandoffGuard, action string) error { | |
| 665 | - | if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.effect == nil { | |
| 675 | + | func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error { | |
| 676 | + | if token.sess == nil || token.ac == nil || token.effect == nil { | |
| 666 | 677 | return nil | |
| 667 | 678 | } | |
| 668 | 679 | token.effect.bindActionEnd(d, action) | |
| 669 | 680 | token.effect.End() | |
| 670 | 681 | if target.RemoteKey != nil { | |
| 671 | - | return d.switchToRemoteTargetForRole(token, target, *target.RemoteKey, guard, action) | |
| 682 | + | return d.switchToRemoteTargetForAttachment(token, target, *target.RemoteKey, guard, action) | |
| 672 | 683 | } | |
| 673 | 684 | if token.sess.isProxy() && !target.Stopped { | |
| 674 | - | return d.switchActiveTargetForRoleGuarded(token, target, guard, action) | |
| 685 | + | return d.switchActiveTargetForAttachmentGuarded(token, target, guard, action) | |
| 675 | 686 | } | |
| 676 | 687 | sess, ok := localSession(token.sess) | |
| 677 | 688 | if !ok { | |
| 678 | 689 | return errAttachmentTransition | |
| 679 | 690 | } | |
| 680 | - | return d.switchToTargetGuardedForRole(sess, token.ac, target, guard, &token, action) | |
| 691 | + | return d.switchToTargetGuardedForAttachment(sess, token.ac, target, guard, &token, action) | |
| 681 | 692 | } | |
| 682 | 693 | ||
| 683 | - | // switchToRemoteTargetForRole performs dialing after the initiating effect has | |
| 684 | - | // ended and without role or architecture locks. transitionAttachment then | |
| 685 | - | // freezes and revalidates the exact source role and exact structured-key proxy | |
| 694 | + | // switchToRemoteTargetForAttachment performs dialing after the initiating effect has | |
| 695 | + | // ended and without attachment-effect or architecture locks. transitionAttachment then | |
| 696 | + | // freezes and revalidates the exact source attachment and exact structured-key proxy | |
| 686 | 697 | // before publishing ownership. | |
| 687 | - | func (d *Daemon) switchToRemoteTargetForRole(token attachmentRoleToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, action string) error { | |
| 698 | + | func (d *Daemon) switchToRemoteTargetForAttachment(token attachmentConnectionToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, action string) error { | |
| 688 | 699 | if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" { | |
| 689 | 700 | return errAttachmentTransition | |
| 690 | 701 | } | |
| @@ -702,7 +713,7 @@ func (d *Daemon) switchToRemoteTargetForRole(token attachmentRoleToken, target p | |||
| 702 | 713 | ownsCandidate := !alreadyPublished && !constructionInFlight | |
| 703 | 714 | proxy, err := d.openProxySession(ctx, key, token.ac.size) | |
| 704 | 715 | if err != nil { | |
| 705 | - | if !token.activeCurrent() { | |
| 716 | + | if !token.attachmentCurrent() { | |
| 706 | 717 | return errAttachmentTransition | |
| 707 | 718 | } | |
| 708 | 719 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't connect to that remote session", err) | |
| @@ -716,9 +727,9 @@ func (d *Daemon) switchToRemoteTargetForRole(token attachmentRoleToken, target p | |||
| 716 | 727 | ||
| 717 | 728 | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 718 | 729 | source: token.sess, target: proxy, next: token.ac, | |
| 719 | - | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 730 | + | ||
| 720 | 731 | expectedTransport: token.transport, sourceToken: &token, action: action, | |
| 721 | - | preserveRole: proxy == token.sess, ready: true, | |
| 732 | + | preserveAttachment: proxy == token.sess, ready: true, | |
| 722 | 733 | }) | |
| 723 | 734 | if err != nil { | |
| 724 | 735 | if ownsCandidate { | |
| @@ -727,7 +738,7 @@ func (d *Daemon) switchToRemoteTargetForRole(token attachmentRoleToken, target p | |||
| 727 | 738 | return errAttachmentTransition | |
| 728 | 739 | } | |
| 729 | 740 | if guard.closePicker { | |
| 730 | - | fresh, admitted := token.ac.beginRoleEffect(transition.published) | |
| 741 | + | fresh, admitted := token.ac.beginAttachmentEffect(transition.published) | |
| 731 | 742 | if admitted { | |
| 732 | 743 | d.closePicker(token.ac) | |
| 733 | 744 | fresh.End() | |
| @@ -759,7 +770,7 @@ func (d *Daemon) discardUnattachedProxyGeneration(proxy *proxySession, generatio | |||
| 759 | 770 | return false | |
| 760 | 771 | } | |
| 761 | 772 | proxy.sessionCore.mu.Lock() | |
| 762 | - | if proxy.client != nil { | |
| 773 | + | if len(proxy.attachments) != 0 { | |
| 763 | 774 | proxy.sessionCore.mu.Unlock() | |
| 764 | 775 | d.mu.Unlock() | |
| 765 | 776 | return false | |
| @@ -810,17 +821,17 @@ func (d *Daemon) discardUnattachedProxyGeneration(proxy *proxySession, generatio | |||
| 810 | 821 | ||
| 811 | 822 | // switchToTargetGuarded is retained for daemon-internal and headless callers. | |
| 812 | 823 | func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error { | |
| 813 | - | return d.switchToTargetGuardedForRole(from, ac, target, guard, nil, "") | |
| 824 | + | return d.switchToTargetGuardedForAttachment(from, ac, target, guard, nil, "") | |
| 814 | 825 | } | |
| 815 | 826 | ||
| 816 | - | func (d *Daemon) switchToTargetGuardedForRole(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentRoleToken, action string) error { | |
| 827 | + | func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentConnectionToken, action string) error { | |
| 817 | 828 | if target.Name != "" { | |
| 818 | 829 | ctx := d.serveCtx | |
| 819 | 830 | if ctx == nil { | |
| 820 | 831 | ctx = context.Background() | |
| 821 | 832 | } | |
| 822 | 833 | if err := d.waitForTargetRestore(ctx, target.Name); err != nil { | |
| 823 | - | if sourceToken != nil && !sourceToken.activeCurrent() { | |
| 834 | + | if sourceToken != nil && !sourceToken.attachmentCurrent() { | |
| 824 | 835 | return errAttachmentTransition | |
| 825 | 836 | } | |
| 826 | 837 | d.invalidateRender(from, ac, true, "picker.go") | |
| @@ -854,7 +865,7 @@ func (d *Daemon) switchToTargetGuardedForRole(from *session, ac *attachedClient, | |||
| 854 | 865 | d.mu.Unlock() | |
| 855 | 866 | ||
| 856 | 867 | if !switched { | |
| 857 | - | if sourceToken != nil && !sourceToken.activeCurrent() { | |
| 868 | + | if sourceToken != nil && !sourceToken.attachmentCurrent() { | |
| 858 | 869 | return errAttachmentTransition | |
| 859 | 870 | } | |
| 860 | 871 | if guard.expectedSource != nil { | |
| @@ -865,7 +876,7 @@ func (d *Daemon) switchToTargetGuardedForRole(from *session, ac *attachedClient, | |||
| 865 | 876 | } | |
| 866 | 877 | if guard.closePicker { | |
| 867 | 878 | if sourceToken != nil { | |
| 868 | - | fresh, admitted := ac.beginRoleEffect(transition.published) | |
| 879 | + | fresh, admitted := ac.beginAttachmentEffect(transition.published) | |
| 869 | 880 | if admitted { | |
| 870 | 881 | d.closePicker(ac) | |
| 871 | 882 | fresh.End() | |
| @@ -877,14 +888,14 @@ func (d *Daemon) switchToTargetGuardedForRole(from *session, ac *attachedClient, | |||
| 877 | 888 | d.deferAttachmentTransitionCleanups(transition) | |
| 878 | 889 | if targetSess == from { | |
| 879 | 890 | if sourceToken != nil { | |
| 880 | - | fresh, admitted := ac.beginRoleEffect(transition.published) | |
| 891 | + | fresh, admitted := ac.beginAttachmentEffect(transition.published) | |
| 881 | 892 | if admitted { | |
| 882 | - | d.activateTab(from, from.activeTab()) | |
| 893 | + | d.activateTabAfterResizeForLease(from, from.tabForAttachment(ac), false, ac, transition.published.lease) | |
| 883 | 894 | d.invalidateRender(from, ac, true, "picker.go") | |
| 884 | 895 | fresh.End() | |
| 885 | 896 | } | |
| 886 | 897 | } else { | |
| 887 | - | d.activateTab(from, from.activeTab()) | |
| 898 | + | d.activateTabAfterResizeForLease(from, from.tabForAttachment(ac), false, ac, nil) | |
| 888 | 899 | d.invalidateRender(from, ac, true, "picker.go") | |
| 889 | 900 | } | |
| 890 | 901 | } else { | |
| @@ -915,7 +926,7 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi | |||
| 915 | 926 | ||
| 916 | 927 | // switchToActiveTargetLocked commits an active target handoff through the | |
| 917 | 928 | // centralized attachment transition. Caller holds d.mu. | |
| 918 | - | func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentRoleToken, action string) (*session, attachmentTransitionResult, bool) { | |
| 929 | + | func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentConnectionToken, action string) (*session, attachmentTransitionResult, bool) { | |
| 919 | 930 | if d.sessions[target.Session] != targetSess { | |
| 920 | 931 | return nil, attachmentTransitionResult{}, false | |
| 921 | 932 | } | |
| @@ -923,30 +934,35 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||
| 923 | 934 | if sourceToken == nil { | |
| 924 | 935 | targetSess.mu.Lock() | |
| 925 | 936 | defer targetSess.mu.Unlock() | |
| 926 | - | if targetSess.client != ac || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) { | |
| 937 | + | _, attached := targetSess.attachments[ac] | |
| 938 | + | if !attached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) { | |
| 939 | + | return nil, attachmentTransitionResult{}, false | |
| 940 | + | } | |
| 941 | + | if !targetSess.activateAttachmentViewLocked(ac, target.TabIndex) { | |
| 927 | 942 | return nil, attachmentTransitionResult{}, false | |
| 928 | 943 | } | |
| 929 | - | targetSess.active = target.TabIndex | |
| 930 | 944 | return targetSess, attachmentTransitionResult{}, true | |
| 931 | 945 | } | |
| 932 | 946 | d.mu.Unlock() | |
| 933 | 947 | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 934 | 948 | source: from, target: targetSess, next: ac, | |
| 935 | - | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 949 | + | ||
| 936 | 950 | expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action, | |
| 937 | - | activateTargetTab: true, targetTabIndex: target.TabIndex, preserveRole: true, | |
| 951 | + | activateTargetTab: true, targetTabIndex: target.TabIndex, preserveAttachment: true, | |
| 938 | 952 | }) | |
| 939 | 953 | d.mu.Lock() | |
| 940 | 954 | return targetSess, transition, err == nil | |
| 941 | 955 | } | |
| 942 | 956 | ||
| 943 | 957 | unlock := lockAttachmentSessions(from, targetSess) | |
| 944 | - | if from.client != ac || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) { | |
| 958 | + | _, sourceAttached := from.attachments[ac] | |
| 959 | + | if !sourceAttached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) { | |
| 945 | 960 | unlock() | |
| 946 | 961 | return nil, attachmentTransitionResult{}, false | |
| 947 | 962 | } | |
| 948 | 963 | if guard.expectedSource != nil { | |
| 949 | - | if from.active < 0 || from.active >= len(from.tabs) || from.tabs[from.active] != guard.expectedSource { | |
| 964 | + | view := ac.viewSnapshot() | |
| 965 | + | if domain.TabStableID(guard.expectedSource.stableID) != view.tabID { | |
| 950 | 966 | unlock() | |
| 951 | 967 | return nil, attachmentTransitionResult{}, false | |
| 952 | 968 | } | |
| @@ -968,11 +984,10 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||
| 968 | 984 | expectedTransport = sourceToken.transport | |
| 969 | 985 | } | |
| 970 | 986 | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 971 | - | source: from, | |
| 972 | - | target: targetSess, | |
| 973 | - | next: ac, | |
| 974 | - | expectedRole: attachmentActive, | |
| 975 | - | targetRole: attachmentActive, | |
| 987 | + | source: from, | |
| 988 | + | target: targetSess, | |
| 989 | + | next: ac, | |
| 990 | + | ||
| 976 | 991 | expectedTransport: expectedTransport, | |
| 977 | 992 | sourceToken: sourceToken, | |
| 978 | 993 | action: action, | |
| @@ -994,7 +1009,7 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||
| 994 | 1009 | // resumeStoppedAndSwitchLocked creates the stopped representation and commits | |
| 995 | 1010 | // the handoff while d.mu is held. Creation failure leaves the source client | |
| 996 | 1011 | // and stopped record untouched. | |
| 997 | - | func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession, sourceToken *attachmentRoleToken, guard sessionHandoffGuard, action string) (*session, attachmentTransitionResult, bool, error) { | |
| 1012 | + | func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession, sourceToken *attachmentConnectionToken, guard sessionHandoffGuard, action string) (*session, attachmentTransitionResult, bool, error) { | |
| 998 | 1013 | if stopped.record.Name != "" && stopped.state == ports.SessionBroken { | |
| 999 | 1014 | return nil, attachmentTransitionResult{}, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name} | |
| 1000 | 1015 | } | |
| @@ -1003,7 +1018,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||
| 1003 | 1018 | var targetSess *session | |
| 1004 | 1019 | d.mu.Unlock() | |
| 1005 | 1020 | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1006 | - | source: from, next: ac, expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 1021 | + | source: from, next: ac, | |
| 1007 | 1022 | expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action, | |
| 1008 | 1023 | expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true, | |
| 1009 | 1024 | createTargetLocked: func() (*session, error) { | |
| @@ -1034,7 +1049,8 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||
| 1034 | 1049 | } | |
| 1035 | 1050 | ||
| 1036 | 1051 | from.mu.Lock() | |
| 1037 | - | if from.client != ac { | |
| 1052 | + | _, attached := from.attachments[ac] | |
| 1053 | + | if !attached { | |
| 1038 | 1054 | from.mu.Unlock() | |
| 1039 | 1055 | return nil, attachmentTransitionResult{}, false, nil | |
| 1040 | 1056 | } | |
| @@ -1051,7 +1067,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||
| 1051 | 1067 | d.mu.Unlock() | |
| 1052 | 1068 | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1053 | 1069 | source: from, target: targetSess, next: ac, | |
| 1054 | - | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 1070 | + | ||
| 1055 | 1071 | expectedTransport: ac.transportSnapshot(), ready: true, | |
| 1056 | 1072 | }) | |
| 1057 | 1073 | d.mu.Lock() | |
| @@ -1069,15 +1085,14 @@ func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) | |||
| 1069 | 1085 | ||
| 1070 | 1086 | // stealClientForTarget is retained for direct-ID callers and tests. Named | |
| 1071 | 1087 | // targets must use switchToTarget so resolution and commit share d.mu. | |
| 1072 | - | func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient { | |
| 1088 | + | func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) { | |
| 1073 | 1089 | d.mu.Lock() | |
| 1074 | 1090 | _, transition, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{}, nil, "") | |
| 1075 | 1091 | d.mu.Unlock() | |
| 1076 | 1092 | if !switched { | |
| 1077 | - | return nil | |
| 1093 | + | return | |
| 1078 | 1094 | } | |
| 1079 | 1095 | d.deferAttachmentTransitionCleanups(transition) | |
| 1080 | - | return transition.displaced.ac | |
| 1081 | 1096 | } | |
| 1082 | 1097 | ||
| 1083 | 1098 | // resumeStoppedAndSwitch is retained for direct callers and tests. It resolves | |
| @@ -1102,7 +1117,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||
| 1102 | 1117 | return true | |
| 1103 | 1118 | } | |
| 1104 | 1119 | ||
| 1105 | - | func (d *Daemon) killPickerTargetForRole(target picker.Target, token attachmentRoleToken) error { | |
| 1120 | + | func (d *Daemon) killPickerTargetForAttachment(target picker.Target, token attachmentConnectionToken) error { | |
| 1106 | 1121 | if token.ac == nil { | |
| 1107 | 1122 | return d.killPickerTarget(target) | |
| 1108 | 1123 | } | |
| @@ -1113,9 +1128,9 @@ func (d *Daemon) killPickerTargetForRole(target picker.Target, token attachmentR | |||
| 1113 | 1128 | if target.Stopped { | |
| 1114 | 1129 | token.effect.bindActionEnd(d, "picker-delete") | |
| 1115 | 1130 | token.effect.End() | |
| 1116 | - | frozen := freezeRoleEffectGates(token.ac) | |
| 1131 | + | frozen := freezeAttachmentEffectGates(token.ac) | |
| 1117 | 1132 | defer frozen.unfreeze() | |
| 1118 | - | if !d.sourceRoleTokenCurrentFrozen(token) { | |
| 1133 | + | if !d.sourceAttachmentTokenCurrentFrozen(token) { | |
| 1119 | 1134 | return nil | |
| 1120 | 1135 | } | |
| 1121 | 1136 | return d.killPickerTarget(target) | |
| @@ -1126,7 +1141,7 @@ func (d *Daemon) killPickerTargetForRole(target picker.Target, token attachmentR | |||
| 1126 | 1141 | if !ok { | |
| 1127 | 1142 | return nil | |
| 1128 | 1143 | } | |
| 1129 | - | return d.killSessionForRole(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete") | |
| 1144 | + | return d.killSessionForAttachment(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete") | |
| 1130 | 1145 | } | |
| 1131 | 1146 | ||
| 1132 | 1147 | func (d *Daemon) killPickerTarget(target picker.Target) error { | |
M internal/usecase/daemon/picker_test.go +38 -191
| @@ -14,7 +14,6 @@ import ( | |||
| 14 | 14 | "github.com/bnema/vev/internal/persist" | |
| 15 | 15 | "github.com/bnema/vev/internal/ports" | |
| 16 | 16 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 17 | - | "github.com/bnema/vev/internal/usecase/keys" | |
| 18 | 17 | "github.com/bnema/vev/internal/usecase/layout" | |
| 19 | 18 | "github.com/bnema/vev/internal/usecase/picker" | |
| 20 | 19 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | |
| @@ -79,7 +78,7 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) { | |||
| 79 | 78 | d.sessions[current.id] = current | |
| 80 | 79 | d.sessions[ringing.id] = ringing | |
| 81 | 80 | ||
| 82 | - | views, currentSelection := d.pickerViews(current) | |
| 81 | + | views, currentSelection := d.pickerViews(current, nil) | |
| 83 | 82 | ||
| 84 | 83 | require.Equal(t, picker.SourceFilter{Session: current.id}, currentSelection) | |
| 85 | 84 | require.Len(t, views, 2) | |
| @@ -104,7 +103,7 @@ func TestPickerViewsIncludesEphemeralSessions(t *testing.T) { | |||
| 104 | 103 | d.sessions[current.id] = current | |
| 105 | 104 | d.sessions[ephemeral.id] = ephemeral | |
| 106 | 105 | ||
| 107 | - | views, _ := d.pickerViews(current) | |
| 106 | + | views, _ := d.pickerViews(current, nil) | |
| 108 | 107 | ||
| 109 | 108 | require.Len(t, views, 2) | |
| 110 | 109 | var ephemeralView picker.SessionView | |
| @@ -132,7 +131,7 @@ func TestPickerViewsOrdersByMRUWithEphemeralInterleaved(t *testing.T) { | |||
| 132 | 131 | d.stopped["halted-old"] = stoppedSession{name: "halted-old", createdAt: 10, lastUsedSeq: 1} | |
| 133 | 132 | d.stopped["halted-new"] = stoppedSession{name: "halted-new", createdAt: 11, lastUsedSeq: 2} | |
| 134 | 133 | ||
| 135 | - | views, _ := d.pickerViews(recent) | |
| 134 | + | views, _ := d.pickerViews(recent, nil) | |
| 136 | 135 | ||
| 137 | 136 | names := make([]string, 0, len(views)) | |
| 138 | 137 | for _, v := range views { | |
| @@ -152,7 +151,7 @@ func TestPickerViewsMRUTieBreaksAlphabetically(t *testing.T) { | |||
| 152 | 151 | d.sessions[b.id] = b | |
| 153 | 152 | d.sessions[a.id] = a | |
| 154 | 153 | ||
| 155 | - | views, _ := d.pickerViews(a) | |
| 154 | + | views, _ := d.pickerViews(a, nil) | |
| 156 | 155 | ||
| 157 | 156 | require.Equal(t, "alpha", views[0].Name) | |
| 158 | 157 | require.Equal(t, "bravo", views[1].Name) | |
| @@ -172,7 +171,7 @@ func TestPickerViewsGroupedModePutsNamedBeforeEphemeral(t *testing.T) { | |||
| 172 | 171 | d.sessions[named2.id] = named2 | |
| 173 | 172 | d.stopped["halted"] = stoppedSession{name: "halted", createdAt: 9, lastUsedSeq: 9} | |
| 174 | 173 | ||
| 175 | - | views, _ := d.pickerViews(named) | |
| 174 | + | views, _ := d.pickerViews(named, nil) | |
| 176 | 175 | ||
| 177 | 176 | names := make([]string, 0, len(views)) | |
| 178 | 177 | for _, v := range views { | |
| @@ -200,7 +199,7 @@ func TestPickerSortToggleFlipsModeAndKeepsSelection(t *testing.T) { | |||
| 200 | 199 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |
| 201 | 200 | defer releaseAll(releases) | |
| 202 | 201 | sess.mu.Lock() | |
| 203 | - | sess.client = ac | |
| 202 | + | sess.registerAttachmentLocked(ac) | |
| 204 | 203 | secondTabID := domain.TabStableID(sess.tabs[1].stableID) | |
| 205 | 204 | sess.mu.Unlock() | |
| 206 | 205 | sess.mruAt.Store(1) | |
| @@ -283,7 +282,7 @@ func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) { | |||
| 283 | 282 | d.sessions[active.id] = active | |
| 284 | 283 | d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 24} | |
| 285 | 284 | ||
| 286 | - | views, _ := d.pickerViews(active) | |
| 285 | + | views, _ := d.pickerViews(active, nil) | |
| 287 | 286 | ||
| 288 | 287 | require.Len(t, views, 2) | |
| 289 | 288 | require.NotNil(t, views[0].ExpectedCreatedAt) | |
| @@ -294,7 +293,7 @@ func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) { | |||
| 294 | 293 | ||
| 295 | 294 | func TestPickerCanonicalViewsLeaveMoveFilteringToModel(t *testing.T) { | |
| 296 | 295 | d := newTestDaemon(t, nil, stubClock{}) | |
| 297 | - | source := &session{sessionCore: sessionCore{id: "source", name: "source", incarnation: domain.IncarnationID{1}}, active: 0, | |
| 296 | + | source := &session{sessionCore: sessionCore{id: "source", name: "source", incarnation: domain.IncarnationID{1}}, | |
| 298 | 297 | tabs: []*tab{ | |
| 299 | 298 | {stableID: "source-tab", panes: map[layout.PaneID]*pane{}}, | |
| 300 | 299 | {stableID: "sibling-tab", panes: map[layout.PaneID]*pane{}}, | |
| @@ -310,7 +309,7 @@ func TestPickerCanonicalViewsLeaveMoveFilteringToModel(t *testing.T) { | |||
| 310 | 309 | PaneID: "source-pane", | |
| 311 | 310 | } | |
| 312 | 311 | ||
| 313 | - | views, current := d.pickerViews(source) | |
| 312 | + | views, current := d.pickerViews(source, nil) | |
| 314 | 313 | require.Len(t, views, 3, "canonical capture includes active and stopped lifecycles") | |
| 315 | 314 | require.Equal(t, picker.SourceFilter{Session: source.id, Incarnation: source.incarnation, TabID: "source-tab"}, current) | |
| 316 | 315 | var sourceView picker.SessionView | |
| @@ -322,14 +321,14 @@ func TestPickerCanonicalViewsLeaveMoveFilteringToModel(t *testing.T) { | |||
| 322 | 321 | require.Equal(t, []picker.TabEntry{{TabID: "source-tab", Name: "1"}, {TabID: "sibling-tab", Name: "2"}}, sourceView.Tabs, | |
| 323 | 322 | "daemon capture does not remove the move source") | |
| 324 | 323 | ||
| 325 | - | paneModel := d.newPickerModel(source, pickerMovePane, sourceLocator, picker.SourceFilter{ | |
| 324 | + | paneModel := d.newPickerModel(source, nil, pickerMovePane, sourceLocator, picker.SourceFilter{ | |
| 326 | 325 | Session: source.id, Incarnation: source.incarnation, TabID: "sibling-tab", | |
| 327 | 326 | }) | |
| 328 | 327 | paneTarget, ok := paneModel.Selected() | |
| 329 | 328 | require.True(t, ok) | |
| 330 | 329 | require.Equal(t, domain.TabStableID("sibling-tab"), paneTarget.TabID) | |
| 331 | 330 | ||
| 332 | - | tabModel := d.newPickerModel(source, pickerMoveTab, sourceLocator, picker.SourceFilter{}) | |
| 331 | + | tabModel := d.newPickerModel(source, nil, pickerMoveTab, sourceLocator, picker.SourceFilter{}) | |
| 333 | 332 | tabTarget, ok := tabModel.Selected() | |
| 334 | 333 | require.True(t, ok) | |
| 335 | 334 | require.Equal(t, destination.id, tabTarget.Session) | |
| @@ -445,7 +444,7 @@ func TestPickerMoveTabPreviewsDestinationActiveTabWithoutActivatingIt(t *testing | |||
| 445 | 444 | sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1} | |
| 446 | 445 | delete(d.sessions, domain.SessionID("manual")) | |
| 447 | 446 | d.sessions[sourceSession.id] = sourceSession | |
| 448 | - | target := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, active: 1, | |
| 447 | + | target := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, | |
| 449 | 448 | ctx: sourceSession.ctx, cancel: func() {}, | |
| 450 | 449 | tabs: []*tab{ | |
| 451 | 450 | newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel), | |
| @@ -463,7 +462,7 @@ func TestPickerMoveTabPreviewsDestinationActiveTabWithoutActivatingIt(t *testing | |||
| 463 | 462 | ||
| 464 | 463 | ac.overlays.pickerMu.Lock() | |
| 465 | 464 | selected, ok := ac.overlays.picker.Selected() | |
| 466 | - | require.Same(t, target.tabs[1], ac.overlays.pickerPreview) | |
| 465 | + | require.Same(t, target.tabs[0], ac.overlays.pickerPreview) | |
| 467 | 466 | ac.overlays.pickerMu.Unlock() | |
| 468 | 467 | require.True(t, ok) | |
| 469 | 468 | require.Equal(t, target.id, selected.Session) | |
| @@ -471,7 +470,7 @@ func TestPickerMoveTabPreviewsDestinationActiveTabWithoutActivatingIt(t *testing | |||
| 471 | 470 | require.Equal(t, target.name, selected.Name) | |
| 472 | 471 | require.Equal(t, -1, selected.TabIndex) | |
| 473 | 472 | require.Nil(t, selected.ExpectedCreatedAt) | |
| 474 | - | require.Equal(t, 1, activeTabIndex(target), "preview must not mutate destination focus") | |
| 473 | + | require.Equal(t, 0, testAttachmentTabIndex(target), "preview must not mutate destination view") | |
| 475 | 474 | d.closePicker(ac) | |
| 476 | 475 | } | |
| 477 | 476 | ||
| @@ -575,7 +574,7 @@ func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) { | |||
| 575 | 574 | original := &session{sessionCore: sessionCore{id: "ephemeral-1", name: "2", ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} | |
| 576 | 575 | d.sessions[original.id] = original | |
| 577 | 576 | ||
| 578 | - | views, _ := d.pickerViews(current) | |
| 577 | + | views, _ := d.pickerViews(current, nil) | |
| 579 | 578 | var originalView picker.SessionView | |
| 580 | 579 | for _, view := range views { | |
| 581 | 580 | if view.ID == original.id { | |
| @@ -595,18 +594,6 @@ func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) { | |||
| 595 | 594 | require.Same(t, current, ac.currentSession()) | |
| 596 | 595 | } | |
| 597 | 596 | ||
| 598 | - | func TestSameSessionSwitchRejectsReplacedClient(t *testing.T) { | |
| 599 | - | p1, release1 := newBlockingPTY(t) | |
| 600 | - | p2, release2 := newBlockingPTY(t) | |
| 601 | - | defer release1() | |
| 602 | - | defer release2() | |
| 603 | - | d, current, ac, _ := newManualSessionWithPTYs(t, p1, p2) | |
| 604 | - | current.client = &attachedClient{} | |
| 605 | - | ||
| 606 | - | require.Error(t, d.switchToTarget(current, ac, picker.Target{Session: current.id, TabIndex: 1})) | |
| 607 | - | require.Equal(t, 0, current.active) | |
| 608 | - | } | |
| 609 | - | ||
| 610 | 597 | func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) { | |
| 611 | 598 | p1, releasePTY1 := newBlockingPTY(t) | |
| 612 | 599 | p2, releasePTY2 := newBlockingPTY(t) | |
| @@ -626,7 +613,7 @@ func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) { | |||
| 626 | 613 | sess.tabs[1].attention = true | |
| 627 | 614 | sess.mu.Unlock() | |
| 628 | 615 | ||
| 629 | - | views, _ := d.pickerViews(sess) | |
| 616 | + | views, _ := d.pickerViews(sess, nil) | |
| 630 | 617 | ||
| 631 | 618 | require.Len(t, views, 1) | |
| 632 | 619 | require.Equal(t, []picker.TabEntry{ | |
| @@ -653,7 +640,7 @@ func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) { | |||
| 653 | 640 | ||
| 654 | 641 | d.ApplyConfig(domain.Config{Tabs: domain.TabsConfig{TerminalTitle: false}}) | |
| 655 | 642 | ||
| 656 | - | views, _ := d.pickerViews(sess) | |
| 643 | + | views, _ := d.pickerViews(sess, nil) | |
| 657 | 644 | ||
| 658 | 645 | require.Len(t, views, 1) | |
| 659 | 646 | require.Equal(t, []picker.TabEntry{ | |
| @@ -829,7 +816,7 @@ func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) { | |||
| 829 | 816 | func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) { | |
| 830 | 817 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |
| 831 | 818 | sess.mu.Lock() | |
| 832 | - | sess.client = ac | |
| 819 | + | sess.registerAttachmentLocked(ac) | |
| 833 | 820 | sess.mu.Unlock() | |
| 834 | 821 | d.ptys = newBlockingOpenFactory(t, d) | |
| 835 | 822 | defer func() { | |
| @@ -844,8 +831,8 @@ func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) { | |||
| 844 | 831 | awaitFrame(t, sends, ports.MsgOutput) | |
| 845 | 832 | d.handleInput(sess, ac, []byte("\r")) | |
| 846 | 833 | ||
| 847 | - | require.Equal(t, 1, activeTabIndex(sess)) | |
| 848 | - | requireFloatingInitialized(t, sess.activeTab()) | |
| 834 | + | require.Equal(t, 1, testAttachmentTabIndex(sess)) | |
| 835 | + | requireFloatingInitialized(t, testAttachmentTab(sess)) | |
| 849 | 836 | awaitFrame(t, sends, ports.MsgOutput) | |
| 850 | 837 | d.enterPicker(sess, ac) | |
| 851 | 838 | awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -886,7 +873,7 @@ func TestPickerSplitArrowNavigatesWithoutExiting(t *testing.T) { | |||
| 886 | 873 | } | |
| 887 | 874 | require.True(t, ac.overlays.pickerActive()) | |
| 888 | 875 | d.handleInput(sess, ac, []byte("\r")) | |
| 889 | - | require.Equal(t, tc.wantActive, activeTabIndex(sess)) | |
| 876 | + | require.Equal(t, tc.wantActive, testAttachmentTabIndex(sess)) | |
| 890 | 877 | }) | |
| 891 | 878 | } | |
| 892 | 879 | } | |
| @@ -946,95 +933,6 @@ func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) { | |||
| 946 | 933 | require.Contains(t, frame, "TARGET", "first target reset must immediately show clean target VT state") | |
| 947 | 934 | } | |
| 948 | 935 | ||
| 949 | - | func TestPickerCrossSessionSwitchSnatchesExistingClient(t *testing.T) { | |
| 950 | - | p1, releasePTY1 := newBlockingPTY(t) | |
| 951 | - | p2, releasePTY2 := newBlockingPTY(t) | |
| 952 | - | defer releasePTY1() | |
| 953 | - | defer releasePTY2() | |
| 954 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 955 | - | tr1, sends1 := newCapturingTransport(t) | |
| 956 | - | tr2, sends2 := newCapturingTransport(t) | |
| 957 | - | ac1 := &attachedClient{tr: tr1, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 958 | - | ac1.initOverlays() | |
| 959 | - | ac2 := &attachedClient{tr: tr2, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 960 | - | ac2.initOverlays() | |
| 961 | - | sctx1, cancel1 := context.WithCancel(d.serveCtx) | |
| 962 | - | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |
| 963 | - | defer cancel1() | |
| 964 | - | defer cancel2() | |
| 965 | - | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", ephemeral: true, client: ac1}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} | |
| 966 | - | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | |
| 967 | - | ac1.setSession(sess1) | |
| 968 | - | ac2.setSession(sess2) | |
| 969 | - | ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}, nil) | |
| 970 | - | ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2}, nil) | |
| 971 | - | d.sessions[sess1.id] = sess1 | |
| 972 | - | d.sessions[sess2.id] = sess2 | |
| 973 | - | ||
| 974 | - | d.enterPicker(sess1, ac1) | |
| 975 | - | awaitFrame(t, sends1, ports.MsgOutput) | |
| 976 | - | d.handleInput(sess1, ac1, []byte("j")) | |
| 977 | - | awaitFrame(t, sends1, ports.MsgOutput) | |
| 978 | - | d.handleInput(sess1, ac1, []byte("\r")) | |
| 979 | - | ||
| 980 | - | require.Same(t, sess2, ac1.currentSession()) | |
| 981 | - | require.Same(t, ac1, sess2.client) | |
| 982 | - | require.Nil(t, sess1.client) | |
| 983 | - | require.Equal(t, 2, sessionCount(d), "old ephemeral session remains alive after picker switch") | |
| 984 | - | ||
| 985 | - | d.attachmentCleanupWg.Wait() | |
| 986 | - | var displacedFrames []ports.Frame | |
| 987 | - | for len(sends2) > 0 { | |
| 988 | - | displacedFrames = append(displacedFrames, <-sends2) | |
| 989 | - | } | |
| 990 | - | require.Len(t, displacedFrames, 1, "healthy displaced client receives only its snatched panel") | |
| 991 | - | require.Equal(t, ports.MsgOutput, displacedFrames[0].Type) | |
| 992 | - | displacedOutput, err := ports.UnmarshalOutput(displacedFrames[0].Payload) | |
| 993 | - | require.NoError(t, err) | |
| 994 | - | require.Contains(t, string(displacedOutput.Data), "Session snatched") | |
| 995 | - | require.Same(t, sess2, ac2.currentSession()) | |
| 996 | - | require.Equal(t, attachmentSnatched, sess2.attachmentRole(ac2)) | |
| 997 | - | ||
| 998 | - | movingFrame := awaitFrame(t, sends1, ports.MsgOutput) | |
| 999 | - | movingOutput, err := ports.UnmarshalOutput(movingFrame.Payload) | |
| 1000 | - | require.NoError(t, err) | |
| 1001 | - | require.Zero(t, movingOutput.BaseStateNum, "moving client must reset before painting the destination") | |
| 1002 | - | } | |
| 1003 | - | ||
| 1004 | - | func TestPickerDisplacementCancelsSupersededResize(t *testing.T) { | |
| 1005 | - | p1, releasePTY1 := newBlockingPTY(t) | |
| 1006 | - | p2, releasePTY2 := newBlockingPTY(t) | |
| 1007 | - | defer releasePTY1() | |
| 1008 | - | defer releasePTY2() | |
| 1009 | - | clock := &signalClock{timers: make(chan *signalTimer, 2)} | |
| 1010 | - | d := newTestDaemon(t, nil, clock) | |
| 1011 | - | tr1, _ := newCapturingTransport(t) | |
| 1012 | - | tr2, _ := newCapturingTransport(t) | |
| 1013 | - | ac1 := &attachedClient{tr: tr1, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 1014 | - | ac2 := &attachedClient{tr: tr2, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 1015 | - | sctx1, cancel1 := context.WithCancel(d.serveCtx) | |
| 1016 | - | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |
| 1017 | - | defer cancel1() | |
| 1018 | - | defer cancel2() | |
| 1019 | - | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", client: ac1}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} | |
| 1020 | - | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | |
| 1021 | - | ac1.setSession(sess1) | |
| 1022 | - | ac2.setSession(sess2) | |
| 1023 | - | d.sessions[sess1.id], d.sessions[sess2.id] = sess1, sess2 | |
| 1024 | - | ||
| 1025 | - | d.resize(sess2, ac2, domain.Size{Cols: 100, Rows: 24}) | |
| 1026 | - | timer := <-clock.timers | |
| 1027 | - | done := captureResizeCallbackDone(t, sess2.renderCoordinator()) | |
| 1028 | - | before := sess2.renderCoordinator().resizeSnapshot().epoch | |
| 1029 | - | ||
| 1030 | - | require.Same(t, ac2, d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id})) | |
| 1031 | - | // The target coordinator invalidates its scheduled epoch during handoff; | |
| 1032 | - | // no attachment-local timer survives the transfer. | |
| 1033 | - | require.Equal(t, before, sess2.renderCoordinator().resizeSnapshot().epoch) | |
| 1034 | - | timer.ch <- time.Time{} | |
| 1035 | - | awaitTestCompletion(t, done, "superseded resize callback did not complete") | |
| 1036 | - | } | |
| 1037 | - | ||
| 1038 | 936 | func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) { | |
| 1039 | 937 | p1, releasePTY1 := newBlockingPTY(t) | |
| 1040 | 938 | p2, releasePTY2 := newBlockingPTY(t) | |
| @@ -1051,8 +949,8 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) { | |||
| 1051 | 949 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |
| 1052 | 950 | defer cancel1() | |
| 1053 | 951 | defer cancel2() | |
| 1054 | - | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", ephemeral: true, client: ac1}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} | |
| 1055 | - | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | |
| 952 | + | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", ephemeral: true, attachments: map[*attachedClient]struct{}{ac1: {}}}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} | |
| 953 | + | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", attachments: map[*attachedClient]struct{}{ac2: {}}}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | |
| 1056 | 954 | ac1.setSession(sess1) | |
| 1057 | 955 | ac2.setSession(sess2) | |
| 1058 | 956 | d.sessions[sess1.id] = sess1 | |
| @@ -1102,7 +1000,7 @@ func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing | |||
| 1102 | 1000 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |
| 1103 | 1001 | defer cancel1() | |
| 1104 | 1002 | defer cancel2() | |
| 1105 | - | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", client: ac}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} | |
| 1003 | + | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} | |
| 1106 | 1004 | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta"}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | |
| 1107 | 1005 | ac.setSession(sess1) | |
| 1108 | 1006 | d.sessions[sess1.id] = sess1 | |
| @@ -1120,11 +1018,10 @@ func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing | |||
| 1120 | 1018 | switchErr := make(chan error, 1) | |
| 1121 | 1019 | go func() { | |
| 1122 | 1020 | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1123 | - | source: sess1, | |
| 1124 | - | target: sess2, | |
| 1125 | - | next: ac, | |
| 1126 | - | expectedRole: attachmentActive, | |
| 1127 | - | targetRole: attachmentActive, | |
| 1021 | + | source: sess1, | |
| 1022 | + | target: sess2, | |
| 1023 | + | next: ac, | |
| 1024 | + | ||
| 1128 | 1025 | expectedTransport: ac.transportSnapshot(), | |
| 1129 | 1026 | ready: true, | |
| 1130 | 1027 | }) | |
| @@ -1139,7 +1036,7 @@ func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing | |||
| 1139 | 1036 | case <-time.After(2 * time.Second): | |
| 1140 | 1037 | t.Fatal("session switch waited for in-flight transport send") | |
| 1141 | 1038 | } | |
| 1142 | - | require.True(t, result.published.activeCurrent()) | |
| 1039 | + | require.True(t, result.published.attachmentCurrent()) | |
| 1143 | 1040 | select { | |
| 1144 | 1041 | case <-handoffAtSendMu: | |
| 1145 | 1042 | t.Fatal("output rebase ran during architecture publication") | |
| @@ -1153,56 +1050,6 @@ func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing | |||
| 1153 | 1050 | } | |
| 1154 | 1051 | } | |
| 1155 | 1052 | ||
| 1156 | - | func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) { | |
| 1157 | - | p1, releasePTY1 := newBlockingPTY(t) | |
| 1158 | - | p2, releasePTY2 := newBlockingPTY(t) | |
| 1159 | - | p3, releasePTY3 := newBlockingPTY(t) | |
| 1160 | - | defer releasePTY1() | |
| 1161 | - | defer releasePTY2() | |
| 1162 | - | defer releasePTY3() | |
| 1163 | - | var openedEnv []string | |
| 1164 | - | var openedCommand string | |
| 1165 | - | f := portsmocks.NewMockPTYFactory(t) | |
| 1166 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 1167 | - | func(_ context.Context, command string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) { | |
| 1168 | - | if sz != (domain.Size{Cols: 80, Rows: 22}) { | |
| 1169 | - | return newQuietPTY(), nil | |
| 1170 | - | } | |
| 1171 | - | openedCommand = command | |
| 1172 | - | openedEnv = append([]string(nil), env...) | |
| 1173 | - | return p3, nil | |
| 1174 | - | }, | |
| 1175 | - | ).Maybe() | |
| 1176 | - | d := newTestDaemon(t, f, stubClock{}) | |
| 1177 | - | tr1, _ := newCapturingTransport(t) | |
| 1178 | - | tr2, _ := newCapturingTransport(t) | |
| 1179 | - | ac1 := &attachedClient{tr: tr1, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 1180 | - | ac2 := &attachedClient{tr: tr2, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 1181 | - | sctx1, cancel1 := context.WithCancel(d.serveCtx) | |
| 1182 | - | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |
| 1183 | - | defer cancel1() | |
| 1184 | - | defer cancel2() | |
| 1185 | - | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", client: ac1}, cwd: t.TempDir(), ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, terminal: terminalEnv{}, env: []string{"SECRET=from-active-session", "SHELL=/usr/bin/fish", "PAIR=a=b"}} | |
| 1186 | - | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, cwd: t.TempDir(), ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, terminal: terminalEnv{TrueColor: true}, env: []string{"STALE=destination-only", "SHELL=/bin/stale", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=stale", "VEV=stale"}} | |
| 1187 | - | ac1.setSession(sess1) | |
| 1188 | - | ac2.setSession(sess2) | |
| 1189 | - | d.sessions[sess1.id] = sess1 | |
| 1190 | - | d.sessions[sess2.id] = sess2 | |
| 1191 | - | ||
| 1192 | - | old := d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id}) | |
| 1193 | - | ||
| 1194 | - | require.Same(t, ac2, old) | |
| 1195 | - | require.False(t, sess2.terminal.TrueColor) | |
| 1196 | - | require.NoError(t, d.createTab(sess2, domain.Size{Cols: 80, Rows: 24})) | |
| 1197 | - | sess2.mu.Lock() | |
| 1198 | - | futureTabID := sess2.tabs[1].stableID | |
| 1199 | - | futurePaneID := sess2.tabs[1].focusedPane().stableID | |
| 1200 | - | sess2.mu.Unlock() | |
| 1201 | - | require.Equal(t, "/usr/bin/fish", openedCommand) | |
| 1202 | - | require.Equal(t, []string{"SECRET=from-active-session", "SHELL=/usr/bin/fish", "PAIR=a=b", "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=beta,tab=" + futureTabID + ",pane=" + futurePaneID}, openedEnv) | |
| 1203 | - | require.NotContains(t, openedEnv, "STALE=destination-only") | |
| 1204 | - | } | |
| 1205 | - | ||
| 1206 | 1053 | func TestPickerPreviewGenerationRejectsStaleSubscriptionReplacement(t *testing.T) { | |
| 1207 | 1054 | rc := newRenderCoordinator(renderCoordinatorOptions{}) | |
| 1208 | 1055 | viewer := &attachedClient{} | |
| @@ -1549,7 +1396,7 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) { | |||
| 1549 | 1396 | require.Contains(t, opens[1], "COLORTERM=truecolor") | |
| 1550 | 1397 | require.Contains(t, opens[1], "TERM_PROGRAM=vev") | |
| 1551 | 1398 | require.Eventually(t, func() bool { | |
| 1552 | - | tb := got.activeTab() | |
| 1399 | + | tb := testAttachmentTab(got) | |
| 1553 | 1400 | if tb == nil { | |
| 1554 | 1401 | return false | |
| 1555 | 1402 | } | |
| @@ -1640,8 +1487,9 @@ func TestPickerNavigationRefreshAfterDeleteSelectsReplacingRow(t *testing.T) { | |||
| 1640 | 1487 | t.Run(tt.name, func(t *testing.T) { | |
| 1641 | 1488 | d, current, ac, _, currentReleases := newManualTabSession(t, 2) | |
| 1642 | 1489 | defer releaseAll(currentReleases) | |
| 1643 | - | current.id, current.name, current.active = "current", tt.currentName, 1 | |
| 1490 | + | current.id, current.name = "current", tt.currentName | |
| 1644 | 1491 | current.tabs[0].stableID, current.tabs[1].stableID = "current-first", "current-active" | |
| 1492 | + | selectTestAttachmentTab(current, 1) | |
| 1645 | 1493 | delete(d.sessions, domain.SessionID("manual")) | |
| 1646 | 1494 | d.sessions[current.id] = current | |
| 1647 | 1495 | ||
| @@ -1687,7 +1535,7 @@ func TestPickerNavigationRefreshAfterDeleteSelectsReplacingRow(t *testing.T) { | |||
| 1687 | 1535 | } | |
| 1688 | 1536 | } | |
| 1689 | 1537 | ||
| 1690 | - | func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T) { | |
| 1538 | + | func TestPickerAttachmentEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T) { | |
| 1691 | 1539 | d, current, ac, _, currentReleases := newManualTabSession(t, 1) | |
| 1692 | 1540 | defer releaseAll(currentReleases) | |
| 1693 | 1541 | current.id, current.name = "current", "z-current" | |
| @@ -1700,9 +1548,9 @@ func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T) | |||
| 1700 | 1548 | target := &session{sessionCore: sessionCore{id: "target", name: "a-target", ephemeral: true}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}} | |
| 1701 | 1549 | d.sessions[target.id] = target | |
| 1702 | 1550 | current.mu.Lock() | |
| 1703 | - | current.client = ac | |
| 1551 | + | current.registerAttachmentLocked(ac) | |
| 1704 | 1552 | current.mu.Unlock() | |
| 1705 | - | require.NotNil(t, d.attachCoordinator(current, nil, ac, true)) | |
| 1553 | + | require.NotNil(t, d.attachCoordinator(current, ac, true)) | |
| 1706 | 1554 | ||
| 1707 | 1555 | d.enterPicker(current, ac) | |
| 1708 | 1556 | d.handlePickerInput(ac, []byte("k")) | |
| @@ -1714,8 +1562,8 @@ func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T) | |||
| 1714 | 1562 | ||
| 1715 | 1563 | token := current.attachmentToken(ac, ac.transport()) | |
| 1716 | 1564 | token.lease = current.renderCoordinator().attachmentLease(ac) | |
| 1717 | - | ac.publishRoleCapability(token) | |
| 1718 | - | effect, admitted := ac.beginRoleEffect(token) | |
| 1565 | + | ac.publishAttachmentCapability(token) | |
| 1566 | + | effect, admitted := ac.beginAttachmentEffect(token) | |
| 1719 | 1567 | require.True(t, admitted) | |
| 1720 | 1568 | d.handlePickerInput(ac, []byte("x"), effect) | |
| 1721 | 1569 | ||
| @@ -1811,8 +1659,7 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry | |||
| 1811 | 1659 | require.True(t, stopped.purging, "an uncertain deletion stays fenced from restore") | |
| 1812 | 1660 | ||
| 1813 | 1661 | from.mu.Lock() | |
| 1814 | - | activeTab := from.tabs[from.active] | |
| 1815 | - | activeTabID := activeTab.stableID | |
| 1662 | + | activeTabID := testAttachmentTabLocked(from).stableID | |
| 1816 | 1663 | from.mu.Unlock() | |
| 1817 | 1664 | ac.overlays.pickerMu.Lock() | |
| 1818 | 1665 | selected, selectedOK := ac.overlays.picker.Selected() | |
M internal/usecase/daemon/prompt.go +5 -5
| @@ -23,7 +23,7 @@ func (d *Daemon) enterPrompt(sess attachmentSession, ac *attachedClient, title, | |||
| 23 | 23 | d.invalidateRender(sess, ac, true, "prompt.go") | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | - | func (d *Daemon) enterTransitionPrompt(sess attachmentSession, ac *attachedClient, title, initial string, submit func(string, attachmentRoleToken) error) { | |
| 26 | + | func (d *Daemon) enterTransitionPrompt(sess attachmentSession, ac *attachedClient, title, initial string, submit func(string, attachmentConnectionToken) error) { | |
| 27 | 27 | d.closePrompt(ac) | |
| 28 | 28 | ac.overlays.promptMu.Lock() | |
| 29 | 29 | ac.overlays.prompt = promptui.New(title, initial) | |
| @@ -51,7 +51,7 @@ func promptValidationError(err error) bool { | |||
| 51 | 51 | errors.Is(err, errProxyKillConfirmation) | |
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | - | func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) { | |
| 54 | + | func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) { | |
| 55 | 55 | sess := ac.currentAttachmentSession() | |
| 56 | 56 | if sess == nil { | |
| 57 | 57 | return | |
| @@ -67,7 +67,7 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...* | |||
| 67 | 67 | changed := false | |
| 68 | 68 | exit := false | |
| 69 | 69 | var submit func(string) error | |
| 70 | - | var transitionSubmit func(string, attachmentRoleToken) error | |
| 70 | + | var transitionSubmit func(string, attachmentConnectionToken) error | |
| 71 | 71 | var submittedPrompt *promptui.Model | |
| 72 | 72 | var value string | |
| 73 | 73 | ||
| @@ -95,9 +95,9 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...* | |||
| 95 | 95 | if submit != nil || transitionSubmit != nil { | |
| 96 | 96 | var err error | |
| 97 | 97 | if transitionSubmit != nil { | |
| 98 | - | var token attachmentRoleToken | |
| 98 | + | var token attachmentConnectionToken | |
| 99 | 99 | if len(effects) != 0 && effects[0] != nil { | |
| 100 | - | token = effects[0].roleToken() | |
| 100 | + | token = effects[0].connectionToken() | |
| 101 | 101 | } | |
| 102 | 102 | err = transitionSubmit(value, token) | |
| 103 | 103 | } else { | |
M internal/usecase/daemon/proxied_contract_test.go +20 -18
| @@ -57,9 +57,10 @@ func TestProxiedHandshakeOrdersWelcomeMetadataAndBaseZeroContent(t *testing.T) { | |||
| 57 | 57 | ||
| 58 | 58 | sess := firstSession(d) | |
| 59 | 59 | require.NotNil(t, sess) | |
| 60 | - | sess.activeTab().mu.Lock() | |
| 61 | - | actualContentSize := sess.activeTab().size | |
| 62 | - | sess.activeTab().mu.Unlock() | |
| 60 | + | tb := sess.tabs[0] | |
| 61 | + | tb.mu.Lock() | |
| 62 | + | actualContentSize := tb.size | |
| 63 | + | tb.mu.Unlock() | |
| 63 | 64 | require.Equal(t, contentSize, actualContentSize, "proxied geometry must not subtract chrome a second time") | |
| 64 | 65 | ||
| 65 | 66 | release() | |
| @@ -94,9 +95,10 @@ func TestOrdinaryHandshakeRetainsChromeAndNoMetadata(t *testing.T) { | |||
| 94 | 95 | ||
| 95 | 96 | sess := firstSession(d) | |
| 96 | 97 | require.NotNil(t, sess) | |
| 97 | - | sess.activeTab().mu.Lock() | |
| 98 | - | actualContentSize := sess.activeTab().size | |
| 99 | - | sess.activeTab().mu.Unlock() | |
| 98 | + | tb := sess.tabs[0] | |
| 99 | + | tb.mu.Lock() | |
| 100 | + | actualContentSize := tb.size | |
| 101 | + | tb.mu.Unlock() | |
| 100 | 102 | require.Equal(t, tabSize(viewport), actualContentSize) | |
| 101 | 103 | requireNoOutputFrame(t, sends) | |
| 102 | 104 | ||
| @@ -127,7 +129,7 @@ func TestProxiedRemotePaintSendsMetadataBeforeScreen(t *testing.T) { | |||
| 127 | 129 | require.True(t, applyTestScreenText(proxy, 0, 0, "remote")) | |
| 128 | 130 | ||
| 129 | 131 | ac.sendMu.Lock() | |
| 130 | - | state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: true}) | |
| 132 | + | state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: true}) | |
| 131 | 133 | require.True(t, ok) | |
| 132 | 134 | composed := composeFrame(*state, composeCacheInput{}) | |
| 133 | 135 | require.True(t, d.emitFrame(proxy, ac, state, composed)) | |
| @@ -143,7 +145,7 @@ func TestProxiedRemotePaintSendsMetadataBeforeScreen(t *testing.T) { | |||
| 143 | 145 | func TestProxiedPaintPublishesAbsoluteVisibleCursorStyle(t *testing.T) { | |
| 144 | 146 | d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 145 | 147 | ac.proxied = true | |
| 146 | - | pane := sess.activeTab().focusedPane() | |
| 148 | + | pane := testAttachmentTab(sess).focusedPane() | |
| 147 | 149 | pane.mu.Lock() | |
| 148 | 150 | pane.screen.Write([]byte("\x1b[3;6H\x1b[0 q")) | |
| 149 | 151 | pane.mu.Unlock() | |
| @@ -203,13 +205,13 @@ func TestProxyResizeUpdatesAttachmentViewport(t *testing.T) { | |||
| 203 | 205 | func TestProxiedTransactionalResizeUsesReceivedContentGeometry(t *testing.T) { | |
| 204 | 206 | d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 205 | 207 | ac.proxied = true | |
| 206 | - | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 208 | + | rc := d.attachCoordinator(sess, ac, true) | |
| 207 | 209 | lease := rc.attachmentLease(ac) | |
| 208 | 210 | require.NotNil(t, lease) | |
| 209 | 211 | ||
| 210 | 212 | want := domain.Size{Cols: 100, Rows: 12} | |
| 211 | 213 | require.True(t, d.requestTransactionalResizeForLease(sess, ac, lease, want, true)) | |
| 212 | - | tb := sess.activeTab() | |
| 214 | + | tb := testAttachmentTab(sess) | |
| 213 | 215 | tb.mu.Lock() | |
| 214 | 216 | require.Equal(t, want, tb.size) | |
| 215 | 217 | tb.mu.Unlock() | |
| @@ -260,7 +262,7 @@ func TestProxiedModeCannotChangeAcrossResume(t *testing.T) { | |||
| 260 | 262 | func TestOutputResetRebasesFullWindowAndSchedulesBaseZeroPaint(t *testing.T) { | |
| 261 | 263 | d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 262 | 264 | ac.proxied = true | |
| 263 | - | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 265 | + | rc := d.attachCoordinator(sess, ac, true) | |
| 264 | 266 | lease := rc.attachmentLease(ac) | |
| 265 | 267 | require.NotNil(t, lease) | |
| 266 | 268 | ||
| @@ -270,7 +272,7 @@ func TestOutputResetRebasesFullWindowAndSchedulesBaseZeroPaint(t *testing.T) { | |||
| 270 | 272 | ac.sendMu.Unlock() | |
| 271 | 273 | ||
| 272 | 274 | token := sess.attachmentToken(ac, ac.transport()) | |
| 273 | - | require.False(t, d.handleActiveClientFrame(token, ports.Frame{ | |
| 275 | + | require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{ | |
| 274 | 276 | Type: ports.MsgOutputResetRequest, | |
| 275 | 277 | Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}), | |
| 276 | 278 | })) | |
| @@ -302,16 +304,16 @@ func TestOutputResetRequiresProxiedActiveRoleAndStrictPayload(t *testing.T) { | |||
| 302 | 304 | t.Run(tt.name, func(t *testing.T) { | |
| 303 | 305 | d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 304 | 306 | ac.proxied = tt.proxied | |
| 305 | - | d.attachCoordinator(sess, nil, ac, true) | |
| 307 | + | d.attachCoordinator(sess, ac, true) | |
| 306 | 308 | ac.sendMu.Lock() | |
| 307 | 309 | ac.output.next, ac.output.acked = 4, 0 | |
| 308 | 310 | ac.sendMu.Unlock() | |
| 309 | 311 | ||
| 310 | 312 | token := sess.attachmentToken(ac, ac.transport()) | |
| 311 | 313 | if tt.staleRole { | |
| 312 | - | token.role = attachmentSnatched | |
| 314 | + | token.generation++ | |
| 313 | 315 | } | |
| 314 | - | require.False(t, d.handleActiveClientFrame(token, ports.Frame{Type: ports.MsgOutputResetRequest, Payload: tt.payload})) | |
| 316 | + | require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgOutputResetRequest, Payload: tt.payload})) | |
| 315 | 317 | ac.sendMu.Lock() | |
| 316 | 318 | acked := ac.output.acked | |
| 317 | 319 | ac.sendMu.Unlock() | |
| @@ -323,7 +325,7 @@ func TestOutputResetRequiresProxiedActiveRoleAndStrictPayload(t *testing.T) { | |||
| 323 | 325 | func TestOutputResetRevalidatesTransportUnderSendLock(t *testing.T) { | |
| 324 | 326 | d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 325 | 327 | ac.proxied = true | |
| 326 | - | d.attachCoordinator(sess, nil, ac, true) | |
| 328 | + | d.attachCoordinator(sess, ac, true) | |
| 327 | 329 | ac.sendMu.Lock() | |
| 328 | 330 | ac.output.next, ac.output.acked = 4, 0 | |
| 329 | 331 | ac.sendMu.Unlock() | |
| @@ -331,11 +333,11 @@ func TestOutputResetRevalidatesTransportUnderSendLock(t *testing.T) { | |||
| 331 | 333 | ||
| 332 | 334 | admitted := make(chan struct{}) | |
| 333 | 335 | var once sync.Once | |
| 334 | - | d.afterRoleEffectAdmitted = func(attachmentRoleToken) { once.Do(func() { close(admitted) }) } | |
| 336 | + | d.afterAttachmentEffectAdmitted = func(attachmentConnectionToken) { once.Do(func() { close(admitted) }) } | |
| 335 | 337 | ac.sendMu.Lock() | |
| 336 | 338 | done := make(chan bool, 1) | |
| 337 | 339 | go func() { | |
| 338 | - | done <- d.handleActiveClientFrame(token, ports.Frame{ | |
| 340 | + | done <- d.handleAttachmentClientFrame(token, ports.Frame{ | |
| 339 | 341 | Type: ports.MsgOutputResetRequest, | |
| 340 | 342 | Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}), | |
| 341 | 343 | }) | |
M internal/usecase/daemon/proxy_input.go +25 -27
| @@ -42,36 +42,36 @@ func (p *proxySession) sendInput(raw []byte) error { | |||
| 42 | 42 | // proxy. Local UI actions never reach the link. Every remote-owned action is | |
| 43 | 43 | // sent using the exact bytes supplied by keys.Router. | |
| 44 | 44 | type proxyKeyHandler struct { | |
| 45 | - | d *Daemon | |
| 46 | - | proxy *proxySession | |
| 47 | - | ac *attachedClient | |
| 48 | - | roleToken attachmentRoleToken | |
| 45 | + | d *Daemon | |
| 46 | + | proxy *proxySession | |
| 47 | + | ac *attachedClient | |
| 48 | + | connectionToken attachmentConnectionToken | |
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | - | func (h proxyKeyHandler) acquireRoleEffect() (*roleEffectTicket, bool, bool) { | |
| 51 | + | func (h proxyKeyHandler) acquireAttachmentEffect() (*attachmentEffectTicket, bool, bool) { | |
| 52 | 52 | if h.proxy == nil || h.ac == nil { | |
| 53 | 53 | return nil, false, false | |
| 54 | 54 | } | |
| 55 | - | if h.roleToken.ac == nil { | |
| 55 | + | if h.connectionToken.ac == nil { | |
| 56 | 56 | return nil, false, false | |
| 57 | 57 | } | |
| 58 | - | if h.roleToken.sess != h.proxy || h.roleToken.ac != h.ac { | |
| 58 | + | if h.connectionToken.sess != h.proxy || h.connectionToken.ac != h.ac { | |
| 59 | 59 | return nil, false, false | |
| 60 | 60 | } | |
| 61 | - | if effect := h.roleToken.effect; effect != nil && !effect.ended.Load() { | |
| 61 | + | if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() { | |
| 62 | 62 | return effect, false, true | |
| 63 | 63 | } | |
| 64 | - | effect, admitted := h.roleToken.ac.beginRoleEffect(h.roleToken) | |
| 64 | + | effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken) | |
| 65 | 65 | if h.d != nil && h.d.afterDelayedKeyEffectAttempt != nil { | |
| 66 | 66 | h.d.afterDelayedKeyEffectAttempt(admitted) | |
| 67 | 67 | } | |
| 68 | 68 | if !admitted { | |
| 69 | 69 | return nil, false, false | |
| 70 | 70 | } | |
| 71 | - | if h.d != nil && h.d.afterRoleEffectAdmitted != nil { | |
| 72 | - | token := h.roleToken | |
| 71 | + | if h.d != nil && h.d.afterAttachmentEffectAdmitted != nil { | |
| 72 | + | token := h.connectionToken | |
| 73 | 73 | token.effect = effect | |
| 74 | - | h.d.afterRoleEffectAdmitted(token) | |
| 74 | + | h.d.afterAttachmentEffectAdmitted(token) | |
| 75 | 75 | } | |
| 76 | 76 | return effect, true, true | |
| 77 | 77 | } | |
| @@ -86,7 +86,7 @@ func (h proxyKeyHandler) sendOwned(raw []byte) { | |||
| 86 | 86 | } | |
| 87 | 87 | ||
| 88 | 88 | func (h proxyKeyHandler) send(raw []byte) { | |
| 89 | - | effect, owned, ok := h.acquireRoleEffect() | |
| 89 | + | effect, owned, ok := h.acquireAttachmentEffect() | |
| 90 | 90 | if !ok { | |
| 91 | 91 | return | |
| 92 | 92 | } | |
| @@ -101,14 +101,14 @@ func (h proxyKeyHandler) Forward(raw []byte) { h.send(raw) } | |||
| 101 | 101 | func (h proxyKeyHandler) Mouse(event mouse.Event) { h.send(event.Raw) } | |
| 102 | 102 | ||
| 103 | 103 | func (h proxyKeyHandler) Action(action keys.Action, raw []byte) { | |
| 104 | - | effect, owned, ok := h.acquireRoleEffect() | |
| 104 | + | effect, owned, ok := h.acquireAttachmentEffect() | |
| 105 | 105 | if !ok { | |
| 106 | 106 | return | |
| 107 | 107 | } | |
| 108 | 108 | if owned { | |
| 109 | 109 | defer effect.End() | |
| 110 | 110 | } | |
| 111 | - | h.roleToken.effect = effect | |
| 111 | + | h.connectionToken.effect = effect | |
| 112 | 112 | switch action { | |
| 113 | 113 | case keys.ActionOpenPalette: | |
| 114 | 114 | h.enterPalette() | |
| @@ -176,7 +176,7 @@ func (h proxyKeyHandler) enterPalette() { | |||
| 176 | 176 | // navigation. A pending tab on the proxy remains remote-owned; without one, | |
| 177 | 177 | // the local daemon may move the client to another local session. | |
| 178 | 178 | func (h proxyKeyHandler) jumpLocalAttention() { | |
| 179 | - | if h.d == nil || h.roleToken.ac == nil || h.roleToken.effect == nil { | |
| 179 | + | if h.d == nil || h.connectionToken.ac == nil || h.connectionToken.effect == nil { | |
| 180 | 180 | return | |
| 181 | 181 | } | |
| 182 | 182 | target, ok := h.d.oldestOtherSessionAttention(nil) | |
| @@ -190,12 +190,12 @@ func (h proxyKeyHandler) jumpLocalAttention() { | |||
| 190 | 190 | return | |
| 191 | 191 | } | |
| 192 | 192 | ||
| 193 | - | token := h.roleToken | |
| 193 | + | token := h.connectionToken | |
| 194 | 194 | token.effect.bindActionEnd(h.d, "proxy-jump-attention") | |
| 195 | 195 | token.effect.End() | |
| 196 | 196 | transition, err := h.d.transitionAttachment(attachmentTransitionRequest{ | |
| 197 | 197 | source: h.proxy, target: targetSession, next: h.ac, | |
| 198 | - | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 198 | + | ||
| 199 | 199 | expectedTransport: token.transport, sourceToken: &token, | |
| 200 | 200 | action: "proxy-jump-attention", activateTargetTab: true, | |
| 201 | 201 | targetTabIndex: target.tabIndex, ready: true, | |
| @@ -227,34 +227,32 @@ func proxiedJumpSearchesOtherSessions(proxied bool) bool { return !proxied } | |||
| 227 | 227 | // but never follows OverflowSessions. The local proxy daemon is the only owner | |
| 228 | 228 | // allowed to change the selected session. | |
| 229 | 229 | func (d *Daemon) focusDirProxied(sess *session, ac *attachedClient, dir layout.Direction) error { | |
| 230 | - | target := resolveDaemonActionTarget(sess) | |
| 230 | + | target := resolveDaemonActionTargetForAttachment(sess, ac) | |
| 231 | 231 | oldFocus := layout.PaneID("") | |
| 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") | |
| 239 | 239 | } | |
| 240 | 240 | return nil | |
| 241 | 241 | } | |
| 242 | - | if !errors.Is(err, errNoNeighbor) || target.tab == nil || !overflowSourceEligible(sess, target.tab) { | |
| 242 | + | if !errors.Is(err, errNoNeighbor) || target.tab == nil || !overflowSourceEligible(sess, ac, target.tab) { | |
| 243 | 243 | return err | |
| 244 | 244 | } | |
| 245 | 245 | ||
| 246 | - | sess.mu.Lock() | |
| 247 | - | position, count := sess.active, len(sess.tabs) | |
| 248 | - | sess.mu.Unlock() | |
| 246 | + | position, count := sess.tabIndexForAttachment(ac) | |
| 249 | 247 | step := resolveOverflow(dir, proxiedNavConfig(d.currentNavConfig()), position, count) | |
| 250 | 248 | if step.kind != overflowTabs { | |
| 251 | 249 | return errNoNeighbor | |
| 252 | 250 | } | |
| 253 | - | candidate, ok := d.prepareTabOverflow(sess, target.tab, dir, span, step.delta) | |
| 254 | - | if !ok || !d.commitTabOverflow(sess, candidate) { | |
| 251 | + | candidate, ok := d.prepareTabOverflowForAttachment(sess, ac, target.tab, dir, span, step.delta) | |
| 252 | + | if !ok || !d.commitTabOverflowForAttachment(sess, ac, candidate) { | |
| 255 | 253 | return errNoNeighbor | |
| 256 | 254 | } | |
| 257 | - | d.activateTab(sess, candidate.target) | |
| 255 | + | sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID)) | |
| 258 | 256 | if ac != nil { | |
| 259 | 257 | d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "proxy_input.go") | |
| 260 | 258 | } | |
M internal/usecase/daemon/proxy_input_test.go +75 -75
| @@ -35,11 +35,11 @@ func newProxyInputHarness(t *testing.T) (*Daemon, *proxySession, *attachedClient | |||
| 35 | 35 | proxy.mu.Unlock() | |
| 36 | 36 | ac := &attachedClient{size: domain.Size{Cols: 80, Rows: 24}, output: newOutputStateStream()} | |
| 37 | 37 | ac.initOverlays() | |
| 38 | - | token := roleEffectForTest(t, attachProxyInputRole(t, d, proxy, ac)) | |
| 39 | - | return d, proxy, ac, link, proxyKeyHandler{d: d, proxy: proxy, ac: ac, roleToken: token} | |
| 38 | + | token := attachmentEffectForTest(t, attachProxyInputRole(t, d, proxy, ac)) | |
| 39 | + | return d, proxy, ac, link, proxyKeyHandler{d: d, proxy: proxy, ac: ac, connectionToken: token} | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | - | func attachProxyInputRole(t *testing.T, d *Daemon, proxy *proxySession, ac *attachedClient) attachmentRoleToken { | |
| 42 | + | func attachProxyInputRole(t *testing.T, d *Daemon, proxy *proxySession, ac *attachedClient) attachmentConnectionToken { | |
| 43 | 43 | t.Helper() | |
| 44 | 44 | clientTransport := newProxyTestTransport() | |
| 45 | 45 | ac.replaceTransport(clientTransport) | |
| @@ -49,7 +49,7 @@ func attachProxyInputRole(t *testing.T, d *Daemon, proxy *proxySession, ac *atta | |||
| 49 | 49 | d.mu.Unlock() | |
| 50 | 50 | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 51 | 51 | target: proxy, next: ac, | |
| 52 | - | expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 52 | + | ||
| 53 | 53 | expectedTransport: ac.transportSnapshot(), ready: true, | |
| 54 | 54 | }) | |
| 55 | 55 | require.NoError(t, err) | |
| @@ -57,9 +57,9 @@ func attachProxyInputRole(t *testing.T, d *Daemon, proxy *proxySession, ac *atta | |||
| 57 | 57 | return transition.published | |
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | - | func roleEffectForTest(t *testing.T, token attachmentRoleToken) attachmentRoleToken { | |
| 60 | + | func attachmentEffectForTest(t *testing.T, token attachmentConnectionToken) attachmentConnectionToken { | |
| 61 | 61 | t.Helper() | |
| 62 | - | effect, admitted := token.ac.beginRoleEffect(token) | |
| 62 | + | effect, admitted := token.ac.beginAttachmentEffect(token) | |
| 63 | 63 | require.True(t, admitted) | |
| 64 | 64 | token.effect = effect | |
| 65 | 65 | t.Cleanup(effect.End) | |
| @@ -151,22 +151,22 @@ func TestProxyInputRouterPreservesCustomAndDelayedActionBytes(t *testing.T) { | |||
| 151 | 151 | ||
| 152 | 152 | t.Run("retained escape reacquires its original role", func(t *testing.T) { | |
| 153 | 153 | d, proxy, ac, link, initial := newProxyInputHarness(t) | |
| 154 | - | base := initial.roleToken | |
| 154 | + | base := initial.connectionToken | |
| 155 | 155 | base.effect.End() | |
| 156 | 156 | base.effect = nil | |
| 157 | 157 | ||
| 158 | - | firstEffect, admitted := ac.beginRoleEffect(base) | |
| 158 | + | firstEffect, admitted := ac.beginAttachmentEffect(base) | |
| 159 | 159 | require.True(t, admitted) | |
| 160 | 160 | first := base | |
| 161 | 161 | first.effect = firstEffect | |
| 162 | - | ac.keys.RouteWithHandler([]byte{keys.ESC}, proxyKeyHandler{d: d, proxy: proxy, ac: ac, roleToken: first}) | |
| 162 | + | ac.keys.RouteWithHandler([]byte{keys.ESC}, proxyKeyHandler{d: d, proxy: proxy, ac: ac, connectionToken: first}) | |
| 163 | 163 | firstEffect.End() | |
| 164 | 164 | ||
| 165 | - | secondEffect, admitted := ac.beginRoleEffect(base) | |
| 165 | + | secondEffect, admitted := ac.beginAttachmentEffect(base) | |
| 166 | 166 | require.True(t, admitted) | |
| 167 | 167 | second := base | |
| 168 | 168 | second.effect = secondEffect | |
| 169 | - | ac.keys.RouteWithHandler([]byte{'j'}, proxyKeyHandler{d: d, proxy: proxy, ac: ac, roleToken: second}) | |
| 169 | + | ac.keys.RouteWithHandler([]byte{'j'}, proxyKeyHandler{d: d, proxy: proxy, ac: ac, connectionToken: second}) | |
| 170 | 170 | secondEffect.End() | |
| 171 | 171 | ||
| 172 | 172 | requireProxyInputFrame(t, link, 1, []byte{keys.ESC, 'j'}) | |
| @@ -175,15 +175,15 @@ func TestProxyInputRouterPreservesCustomAndDelayedActionBytes(t *testing.T) { | |||
| 175 | 175 | ||
| 176 | 176 | func TestProxyInputLocalPaletteNeverForwards(t *testing.T) { | |
| 177 | 177 | d, _, ac, link, handler := newProxyInputHarness(t) | |
| 178 | - | token := handler.roleToken | |
| 178 | + | token := handler.connectionToken | |
| 179 | 179 | handler.Action(keys.ActionOpenPalette, []byte{keys.ESC, ' '}) | |
| 180 | 180 | require.True(t, ac.overlays.paletteActive()) | |
| 181 | 181 | requireNoProxyFrame(t, link) | |
| 182 | 182 | ||
| 183 | 183 | // Keyboard and mouse navigation are consumed by the local overlay before | |
| 184 | 184 | // either the proxy router or proxy mouse sender can observe them. | |
| 185 | - | d.handleInputForRole(token, []byte("x")) | |
| 186 | - | d.handleInputForRole(token, []byte("\x1b[<0;12;8M")) | |
| 185 | + | d.handleInputForAttachment(token, []byte("x")) | |
| 186 | + | d.handleInputForAttachment(token, []byte("\x1b[<0;12;8M")) | |
| 187 | 187 | requireNoProxyFrame(t, link) | |
| 188 | 188 | } | |
| 189 | 189 | ||
| @@ -259,7 +259,7 @@ func TestProxyPaletteCanonicalLocalCommandsNeverReachRemote(t *testing.T) { | |||
| 259 | 259 | t.Run("new session opens local transition prompt", func(t *testing.T) { | |
| 260 | 260 | d, proxy, ac, link, handler := newProxyInputHarness(t) | |
| 261 | 261 | handler.enterPalette() | |
| 262 | - | d.handlePaletteInput(ac, []byte("CNS\r"), handler.roleToken.effect) | |
| 262 | + | d.handlePaletteInput(ac, []byte("CNS\r"), handler.connectionToken.effect) | |
| 263 | 263 | ||
| 264 | 264 | require.False(t, ac.overlays.paletteActive()) | |
| 265 | 265 | require.True(t, ac.overlays.promptActive()) | |
| @@ -276,7 +276,7 @@ func TestProxyPaletteCanonicalLocalCommandsNeverReachRemote(t *testing.T) { | |||
| 276 | 276 | for len(client.sent) > 0 { | |
| 277 | 277 | <-client.sent | |
| 278 | 278 | } | |
| 279 | - | d.handlePaletteInput(ac, []byte("YLN\r"), handler.roleToken.effect) | |
| 279 | + | d.handlePaletteInput(ac, []byte("YLN\r"), handler.connectionToken.effect) | |
| 280 | 280 | ||
| 281 | 281 | require.False(t, ac.overlays.paletteActive()) | |
| 282 | 282 | require.Same(t, proxy, ac.currentAttachmentSession()) | |
| @@ -514,15 +514,15 @@ func TestProxyAttachedCommandRejectsTargetsAndExecutesExactActiveSession(t *test | |||
| 514 | 514 | publishTiledPaneOwners(sess, second) | |
| 515 | 515 | sess.mu.Lock() | |
| 516 | 516 | sess.tabs = append(sess.tabs, second) | |
| 517 | - | sess.active = 0 | |
| 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) | |
| 523 | 523 | require.NoError(t, err) | |
| 524 | 524 | token := sess.attachmentToken(ac, ac.transport()) | |
| 525 | - | require.False(t, d.handleActiveClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload})) | |
| 525 | + | require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload})) | |
| 526 | 526 | frame := awaitFrame(t, sends, ports.MsgCommandResult) | |
| 527 | 527 | result, err := ports.UnmarshalCommandResult(frame.Payload) | |
| 528 | 528 | require.NoError(t, err) | |
| @@ -532,13 +532,13 @@ func TestProxyAttachedCommandRejectsTargetsAndExecutesExactActiveSession(t *test | |||
| 532 | 532 | result := send(ports.CommandRequest{Version: ports.ProtocolVersion, RequestID: 1, Attached: true, Slug: "next-tab"}) | |
| 533 | 533 | require.True(t, result.OK) | |
| 534 | 534 | sess.mu.Lock() | |
| 535 | - | require.Equal(t, 1, sess.active) | |
| 535 | + | require.Equal(t, 1, testAttachmentTabIndexLocked(sess)) | |
| 536 | 536 | sess.mu.Unlock() | |
| 537 | 537 | ||
| 538 | 538 | result = send(ports.CommandRequest{Version: ports.ProtocolVersion, RequestID: 2, Attached: true, Slug: "previous-tab", TargetSession: "other"}) | |
| 539 | 539 | require.False(t, result.OK) | |
| 540 | 540 | sess.mu.Lock() | |
| 541 | - | require.Equal(t, 1, sess.active, "target override must not execute against any session") | |
| 541 | + | require.Equal(t, 1, testAttachmentTabIndexLocked(sess), "target override must not execute against any session") | |
| 542 | 542 | sess.mu.Unlock() | |
| 543 | 543 | ||
| 544 | 544 | result = send(ports.CommandRequest{Version: ports.ProtocolVersion, RequestID: 3, Attached: true, Slug: "rename-tab"}) | |
| @@ -553,7 +553,7 @@ func TestProxyAttachedCommandRemoteErrorCreatesLocalNotice(t *testing.T) { | |||
| 553 | 553 | handler.enterPalette() | |
| 554 | 554 | done := make(chan struct{}) | |
| 555 | 555 | go func() { | |
| 556 | - | d.handlePaletteInput(ac, []byte("NXT\r"), handler.roleToken.effect) | |
| 556 | + | d.handlePaletteInput(ac, []byte("NXT\r"), handler.connectionToken.effect) | |
| 557 | 557 | close(done) | |
| 558 | 558 | }() | |
| 559 | 559 | request := requireProxyCommandRequest(t, link) | |
| @@ -620,13 +620,13 @@ 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 | - | effect, admitted := ac.beginRoleEffect(token) | |
| 626 | + | effect, admitted := ac.beginAttachmentEffect(token) | |
| 627 | 627 | require.True(t, admitted) | |
| 628 | 628 | token.effect = effect | |
| 629 | - | err := d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select") | |
| 629 | + | err := d.switchToTargetForAttachment(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select") | |
| 630 | 630 | require.NoError(t, err) | |
| 631 | 631 | require.True(t, daemonLockAvailable) | |
| 632 | 632 | require.True(t, sourceLockAvailable) | |
| @@ -637,26 +637,26 @@ func TestProxyPickerSelectionDialsOutsideLocksAndRevalidatesExactRoleAndKey(t *t | |||
| 637 | 637 | ||
| 638 | 638 | wrong := domain.RemoteSessionKey{Host: "arch", Name: "other"} | |
| 639 | 639 | currentToken := attachmentToken(proxy, ac, ac.transport()) | |
| 640 | - | currentEffect, admitted := ac.beginRoleEffect(currentToken) | |
| 640 | + | currentEffect, admitted := ac.beginAttachmentEffect(currentToken) | |
| 641 | 641 | require.True(t, admitted) | |
| 642 | 642 | currentToken.effect = currentEffect | |
| 643 | - | err = d.switchToTargetForRole(currentToken, picker.Target{Session: key.ID(), RemoteKey: &wrong, TabIndex: -1}, sessionHandoffGuard{}, "picker-select") | |
| 643 | + | err = d.switchToTargetForAttachment(currentToken, picker.Target{Session: key.ID(), RemoteKey: &wrong, TabIndex: -1}, sessionHandoffGuard{}, "picker-select") | |
| 644 | 644 | require.ErrorIs(t, err, errAttachmentTransition) | |
| 645 | 645 | require.Same(t, proxy, ac.currentAttachmentSession(), "mismatched structured key must not redirect attachment") | |
| 646 | 646 | ||
| 647 | 647 | staleToken := attachmentToken(proxy, ac, ac.transport()) | |
| 648 | - | staleEffect, admitted := ac.beginRoleEffect(staleToken) | |
| 648 | + | staleEffect, admitted := ac.beginAttachmentEffect(staleToken) | |
| 649 | 649 | require.True(t, admitted) | |
| 650 | 650 | staleToken.effect = staleEffect | |
| 651 | - | ac.roleGeneration.Add(1) // model a concurrent role replacement after selection | |
| 652 | - | err = d.switchToTargetForRole(staleToken, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select") | |
| 651 | + | ac.connectionGeneration.Add(1) // model a concurrent role replacement after selection | |
| 652 | + | err = d.switchToTargetForAttachment(staleToken, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select") | |
| 653 | 653 | require.ErrorIs(t, err, errAttachmentTransition) | |
| 654 | 654 | require.Same(t, proxy, ac.currentAttachmentSession(), "stale initiating role must not republish attachment") | |
| 655 | 655 | } | |
| 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)} | |
| @@ -669,14 +669,14 @@ func TestRemotePickerEnterRoutesStructuredKeyThroughProxyOwnership(t *testing.T) | |||
| 669 | 669 | Sessions: []ports.RemoteCatalogSession{{Name: key.Name, State: "running"}}, | |
| 670 | 670 | }}) | |
| 671 | 671 | ||
| 672 | - | model := d.newPickerModel(local, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key}) | |
| 672 | + | model := d.newPickerModel(local, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key}) | |
| 673 | 673 | d.publishPicker(local, ac, model, pickerNavigate, moveSourceLocator{}) | |
| 674 | 674 | target, selectable := model.Selected() | |
| 675 | 675 | require.True(t, selectable) | |
| 676 | 676 | require.Equal(t, key, *target.RemoteKey) | |
| 677 | 677 | ||
| 678 | 678 | token := local.attachmentToken(ac, ac.transport()) | |
| 679 | - | effect, admitted := ac.beginRoleEffect(token) | |
| 679 | + | effect, admitted := ac.beginAttachmentEffect(token) | |
| 680 | 680 | require.True(t, admitted) | |
| 681 | 681 | d.handlePickerInput(ac, []byte("\r"), effect) | |
| 682 | 682 | proxy, ok := ac.currentAttachmentSession().(*proxySession) | |
| @@ -694,7 +694,7 @@ func TestRemotePickerSwitchesFromProxyBackToLocalSession(t *testing.T) { | |||
| 694 | 694 | require.True(t, d.registerSessionLocked(local)) | |
| 695 | 695 | d.mu.Unlock() | |
| 696 | 696 | ||
| 697 | - | model := d.newPickerModel(proxy, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(tb.stableID)}) | |
| 697 | + | model := d.newPickerModel(proxy, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(tb.stableID)}) | |
| 698 | 698 | d.publishPicker(proxy, ac, model, pickerNavigate, moveSourceLocator{}) | |
| 699 | 699 | target, selectable := model.Selected() | |
| 700 | 700 | require.True(t, selectable) | |
| @@ -702,17 +702,17 @@ func TestRemotePickerSwitchesFromProxyBackToLocalSession(t *testing.T) { | |||
| 702 | 702 | require.Equal(t, local.name, target.Name) | |
| 703 | 703 | require.False(t, target.Stopped) | |
| 704 | 704 | ||
| 705 | - | require.NoError(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{closePicker: true}, "picker-select")) | |
| 705 | + | require.NoError(t, d.switchToTargetForAttachment(handler.connectionToken, target, sessionHandoffGuard{closePicker: true}, "picker-select")) | |
| 706 | 706 | require.Same(t, local, ac.currentSession()) | |
| 707 | 707 | require.False(t, ac.overlays.pickerActive(), "successful handoff must close the picker") | |
| 708 | 708 | ||
| 709 | 709 | d.remoteDialerFactory = portsmocks.NewMockRemoteDialerFactory(t) | |
| 710 | - | localToken := roleEffectForTest(t, local.attachmentToken(ac, ac.transport())) | |
| 711 | - | require.NoError(t, d.backSessionForRole(localToken)) | |
| 710 | + | localToken := attachmentEffectForTest(t, local.attachmentToken(ac, ac.transport())) | |
| 711 | + | require.NoError(t, d.backSessionForAttachment(localToken)) | |
| 712 | 712 | require.Same(t, proxy, ac.currentAttachmentSession(), "back-session must return to the previous warm proxy") | |
| 713 | 713 | ||
| 714 | - | proxyToken := roleEffectForTest(t, attachmentToken(proxy, ac, ac.transport())) | |
| 715 | - | require.NoError(t, d.switchToTargetForRole(proxyToken, target, sessionHandoffGuard{}, "picker-select")) | |
| 714 | + | proxyToken := attachmentEffectForTest(t, attachmentToken(proxy, ac, ac.transport())) | |
| 715 | + | require.NoError(t, d.switchToTargetForAttachment(proxyToken, target, sessionHandoffGuard{}, "picker-select")) | |
| 716 | 716 | require.Same(t, local, ac.currentSession()) | |
| 717 | 717 | d.backSession(local, ac) | |
| 718 | 718 | require.Same(t, proxy, ac.currentAttachmentSession(), "legacy back-session must preserve the previous proxy identity") | |
| @@ -720,7 +720,7 @@ func TestRemotePickerSwitchesFromProxyBackToLocalSession(t *testing.T) { | |||
| 720 | 720 | ||
| 721 | 721 | func TestRemotePickerReportsUnavailableLocalTarget(t *testing.T) { | |
| 722 | 722 | d, proxy, ac, _, handler := newProxyInputHarness(t) | |
| 723 | - | err := d.switchToTargetForRole(handler.roleToken, picker.Target{Session: "missing", TabIndex: 0}, sessionHandoffGuard{}, "picker-select") | |
| 723 | + | err := d.switchToTargetForAttachment(handler.connectionToken, picker.Target{Session: "missing", TabIndex: 0}, sessionHandoffGuard{}, "picker-select") | |
| 724 | 724 | require.Error(t, err) | |
| 725 | 725 | require.Same(t, proxy, ac.currentAttachmentSession()) | |
| 726 | 726 | } | |
| @@ -734,18 +734,18 @@ func TestRemotePickerRejectsReplacedLocalLifecycle(t *testing.T) { | |||
| 734 | 734 | require.True(t, d.registerSessionLocked(local)) | |
| 735 | 735 | d.mu.Unlock() | |
| 736 | 736 | ||
| 737 | - | model := d.newPickerModel(proxy, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(tb.stableID)}) | |
| 737 | + | model := d.newPickerModel(proxy, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(tb.stableID)}) | |
| 738 | 738 | target, selectable := model.Selected() | |
| 739 | 739 | require.True(t, selectable) | |
| 740 | - | d.afterRoleEffectsFrozen = func() { | |
| 741 | - | d.afterRoleEffectsFrozen = nil | |
| 740 | + | d.afterAttachmentEffectsFrozen = func() { | |
| 741 | + | d.afterAttachmentEffectsFrozen = nil | |
| 742 | 742 | local.mu.Lock() | |
| 743 | 743 | local.name = "replacement" | |
| 744 | 744 | local.createdAt++ | |
| 745 | 745 | local.mu.Unlock() | |
| 746 | 746 | } | |
| 747 | 747 | ||
| 748 | - | require.Error(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{}, "picker-select")) | |
| 748 | + | require.Error(t, d.switchToTargetForAttachment(handler.connectionToken, target, sessionHandoffGuard{}, "picker-select")) | |
| 749 | 749 | require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker lifecycle must not redirect the attachment") | |
| 750 | 750 | } | |
| 751 | 751 | ||
| @@ -759,17 +759,17 @@ func TestRemotePickerRejectsReplacedLocalIncarnation(t *testing.T) { | |||
| 759 | 759 | require.True(t, d.registerSessionLocked(local)) | |
| 760 | 760 | d.mu.Unlock() | |
| 761 | 761 | ||
| 762 | - | model := d.newPickerModel(proxy, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, Incarnation: local.incarnation, TabID: domain.TabStableID(tb.stableID)}) | |
| 762 | + | model := d.newPickerModel(proxy, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, Incarnation: local.incarnation, TabID: domain.TabStableID(tb.stableID)}) | |
| 763 | 763 | target, selectable := model.Selected() | |
| 764 | 764 | require.True(t, selectable) | |
| 765 | - | d.afterRoleEffectsFrozen = func() { | |
| 766 | - | d.afterRoleEffectsFrozen = nil | |
| 765 | + | d.afterAttachmentEffectsFrozen = func() { | |
| 766 | + | d.afterAttachmentEffectsFrozen = nil | |
| 767 | 767 | local.mu.Lock() | |
| 768 | 768 | local.incarnation = domain.IncarnationID{2} | |
| 769 | 769 | local.mu.Unlock() | |
| 770 | 770 | } | |
| 771 | 771 | ||
| 772 | - | require.Error(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{}, "picker-select")) | |
| 772 | + | require.Error(t, d.switchToTargetForAttachment(handler.connectionToken, target, sessionHandoffGuard{}, "picker-select")) | |
| 773 | 773 | require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker incarnation must not redirect the attachment") | |
| 774 | 774 | } | |
| 775 | 775 | ||
| @@ -786,24 +786,24 @@ func TestRemotePickerRejectsReplacedLocalTab(t *testing.T) { | |||
| 786 | 786 | require.True(t, d.registerSessionLocked(local)) | |
| 787 | 787 | d.mu.Unlock() | |
| 788 | 788 | ||
| 789 | - | model := d.newPickerModel(proxy, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(second.stableID)}) | |
| 789 | + | model := d.newPickerModel(proxy, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(second.stableID)}) | |
| 790 | 790 | target, selectable := model.Selected() | |
| 791 | 791 | require.True(t, selectable) | |
| 792 | 792 | require.Equal(t, domain.TabStableID(second.stableID), target.TabID) | |
| 793 | - | d.afterRoleEffectsFrozen = func() { | |
| 794 | - | d.afterRoleEffectsFrozen = nil | |
| 793 | + | d.afterAttachmentEffectsFrozen = func() { | |
| 794 | + | d.afterAttachmentEffectsFrozen = nil | |
| 795 | 795 | local.mu.Lock() | |
| 796 | 796 | local.tabs[0], local.tabs[1] = local.tabs[1], local.tabs[0] | |
| 797 | 797 | local.mu.Unlock() | |
| 798 | 798 | } | |
| 799 | 799 | ||
| 800 | - | require.Error(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{}, "picker-select")) | |
| 800 | + | require.Error(t, d.switchToTargetForAttachment(handler.connectionToken, target, sessionHandoffGuard{}, "picker-select")) | |
| 801 | 801 | require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker tab must not redirect the attachment") | |
| 802 | 802 | } | |
| 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)} | |
| @@ -812,23 +812,23 @@ func TestRemotePickerCanReselectWarmProxyAfterReturningLocal(t *testing.T) { | |||
| 812 | 812 | d.remoteDialerFactory = newProxyConstructionFactory(remote) | |
| 813 | 813 | d.remoteTransportMode = ports.RemoteTransportUDP | |
| 814 | 814 | ||
| 815 | - | localToken := roleEffectForTest(t, local.attachmentToken(ac, ac.transport())) | |
| 816 | - | require.NoError(t, d.switchToTargetForRole(localToken, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")) | |
| 815 | + | localToken := attachmentEffectForTest(t, local.attachmentToken(ac, ac.transport())) | |
| 816 | + | require.NoError(t, d.switchToTargetForAttachment(localToken, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")) | |
| 817 | 817 | proxy, ok := ac.currentAttachmentSession().(*proxySession) | |
| 818 | 818 | require.True(t, ok) | |
| 819 | 819 | t.Cleanup(func() { stopProxy(t, proxy) }) | |
| 820 | 820 | ||
| 821 | - | proxyToken := roleEffectForTest(t, attachmentToken(proxy, ac, ac.transport())) | |
| 822 | - | require.NoError(t, d.switchToTargetForRole(proxyToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select")) | |
| 821 | + | proxyToken := attachmentEffectForTest(t, attachmentToken(proxy, ac, ac.transport())) | |
| 822 | + | require.NoError(t, d.switchToTargetForAttachment(proxyToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select")) | |
| 823 | 823 | require.Same(t, local, ac.currentSession()) | |
| 824 | 824 | ||
| 825 | - | model := d.newPickerModel(local, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key}) | |
| 825 | + | model := d.newPickerModel(local, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key}) | |
| 826 | 826 | d.publishPicker(local, ac, model, pickerNavigate, moveSourceLocator{}) | |
| 827 | 827 | target, selectable := model.Selected() | |
| 828 | 828 | require.True(t, selectable) | |
| 829 | 829 | require.Empty(t, target.Name, "live proxy rows must route only by their structured remote key") | |
| 830 | - | localToken = roleEffectForTest(t, local.attachmentToken(ac, ac.transport())) | |
| 831 | - | require.NoError(t, d.switchToTargetForRole(localToken, target, sessionHandoffGuard{closePicker: true}, "picker-select")) | |
| 830 | + | localToken = attachmentEffectForTest(t, local.attachmentToken(ac, ac.transport())) | |
| 831 | + | require.NoError(t, d.switchToTargetForAttachment(localToken, target, sessionHandoffGuard{closePicker: true}, "picker-select")) | |
| 832 | 832 | require.Same(t, proxy, ac.currentAttachmentSession()) | |
| 833 | 833 | require.False(t, ac.overlays.pickerActive()) | |
| 834 | 834 | } | |
| @@ -838,8 +838,8 @@ func TestConnectionRoleResolutionStopsAfterUnstableSnapshots(t *testing.T) { | |||
| 838 | 838 | client := newProxyTestTransport() | |
| 839 | 839 | ac := &attachedClient{} | |
| 840 | 840 | ac.replaceTransport(client) | |
| 841 | - | first := &session{sessionCore: sessionCore{client: ac}} | |
| 842 | - | second := &session{sessionCore: sessionCore{client: ac}} | |
| 841 | + | first := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{ac: {}}}} | |
| 842 | + | second := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{ac: {}}}} | |
| 843 | 843 | ac.setSession(first) | |
| 844 | 844 | ||
| 845 | 845 | const unstableSnapshots = 100 | |
| @@ -856,7 +856,7 @@ func TestConnectionRoleResolutionStopsAfterUnstableSnapshots(t *testing.T) { | |||
| 856 | 856 | } | |
| 857 | 857 | } | |
| 858 | 858 | ||
| 859 | - | _, _, ok := d.currentConnectionRole(ac, client) | |
| 859 | + | _, _, ok := d.currentAttachmentConnection(ac, client) | |
| 860 | 860 | require.False(t, ok) | |
| 861 | 861 | require.Less(t, attempts, unstableSnapshots) | |
| 862 | 862 | } | |
| @@ -866,14 +866,14 @@ func TestConnectionLoopStopsWhenReceiveErrorCleanupMakesNoProgress(t *testing.T) | |||
| 866 | 866 | client := newProxyTestTransport() | |
| 867 | 867 | ac := &attachedClient{} | |
| 868 | 868 | ac.replaceTransport(client) | |
| 869 | - | local := &session{sessionCore: sessionCore{client: ac}} | |
| 869 | + | local := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{ac: {}}}} | |
| 870 | 870 | ac.setSession(local) | |
| 871 | 871 | ||
| 872 | 872 | cleanupAttempts := 0 | |
| 873 | 873 | d.beforeClientGoneDetach = func() { | |
| 874 | 874 | cleanupAttempts++ | |
| 875 | 875 | local.mu.Lock() | |
| 876 | - | local.client = &attachedClient{} | |
| 876 | + | local.registerAttachmentLocked(&attachedClient{}) | |
| 877 | 877 | local.mu.Unlock() | |
| 878 | 878 | } | |
| 879 | 879 | snapshots := 0 | |
| @@ -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() | |
| @@ -904,13 +904,13 @@ func TestConnectionLoopRetriesRoleResolutionAcrossHandoff(t *testing.T) { | |||
| 904 | 904 | remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |
| 905 | 905 | d.remoteDialerFactory = newProxyConstructionFactory(remote) | |
| 906 | 906 | d.remoteTransportMode = ports.RemoteTransportUDP | |
| 907 | - | token := roleEffectForTest(t, local.attachmentToken(ac, client)) | |
| 907 | + | token := attachmentEffectForTest(t, local.attachmentToken(ac, client)) | |
| 908 | 908 | snapshots := make(chan attachmentSession, 1) | |
| 909 | 909 | transitionErr := make(chan error, 1) | |
| 910 | 910 | d.afterConnectionSessionSnapshot = func(snapshot attachmentSession) { | |
| 911 | 911 | d.afterConnectionSessionSnapshot = nil | |
| 912 | 912 | snapshots <- snapshot | |
| 913 | - | transitionErr <- d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select") | |
| 913 | + | transitionErr <- d.switchToTargetForAttachment(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select") | |
| 914 | 914 | } | |
| 915 | 915 | ||
| 916 | 916 | loopDone := make(chan struct{}) | |
| @@ -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() | |
| @@ -951,8 +951,8 @@ func TestConnectionLoopCleansCurrentProxyAfterHandoffReceiveError(t *testing.T) | |||
| 951 | 951 | }() | |
| 952 | 952 | require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond) | |
| 953 | 953 | ||
| 954 | - | token := roleEffectForTest(t, local.attachmentToken(ac, client)) | |
| 955 | - | require.NoError(t, d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")) | |
| 954 | + | token := attachmentEffectForTest(t, local.attachmentToken(ac, client)) | |
| 955 | + | require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")) | |
| 956 | 956 | proxy, ok := ac.currentAttachmentSession().(*proxySession) | |
| 957 | 957 | require.True(t, ok) | |
| 958 | 958 | t.Cleanup(func() { stopProxy(t, proxy) }) | |
| @@ -980,7 +980,7 @@ func TestConnectionLoopFollowsClientFromProxyBackToLocal(t *testing.T) { | |||
| 980 | 980 | }() | |
| 981 | 981 | require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond) | |
| 982 | 982 | ||
| 983 | - | require.NoError(t, d.switchToTargetForRole(handler.roleToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select")) | |
| 983 | + | require.NoError(t, d.switchToTargetForAttachment(handler.connectionToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select")) | |
| 984 | 984 | require.Same(t, local, ac.currentSession()) | |
| 985 | 985 | client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}} | |
| 986 | 986 | awaitTestCompletion(t, loopDone, "connection loop did not follow the client back to its local session") | |
| @@ -991,7 +991,7 @@ func TestConnectionLoopFollowsClientFromLocalToRemoteProxy(t *testing.T) { | |||
| 991 | 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() | |
| @@ -1009,10 +1009,10 @@ func TestConnectionLoopFollowsClientFromLocalToRemoteProxy(t *testing.T) { | |||
| 1009 | 1009 | require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond) | |
| 1010 | 1010 | ||
| 1011 | 1011 | token := local.attachmentToken(ac, client) | |
| 1012 | - | effect, admitted := ac.beginRoleEffect(token) | |
| 1012 | + | effect, admitted := ac.beginAttachmentEffect(token) | |
| 1013 | 1013 | require.True(t, admitted) | |
| 1014 | 1014 | token.effect = effect | |
| 1015 | - | require.NoError(t, d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")) | |
| 1015 | + | require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")) | |
| 1016 | 1016 | proxy, ok := ac.currentAttachmentSession().(*proxySession) | |
| 1017 | 1017 | require.True(t, ok) | |
| 1018 | 1018 | t.Cleanup(func() { stopProxy(t, proxy) }) | |
M internal/usecase/daemon/proxy_kill.go +2 -2
| @@ -47,12 +47,12 @@ func (d *Daemon) enterRemoteKillConfirmation(owner attachmentSession, ac *attach | |||
| 47 | 47 | if unreachable { | |
| 48 | 48 | title = " Type " + key.Display() + " to kill (remote unreachable) " | |
| 49 | 49 | } | |
| 50 | - | d.enterTransitionPrompt(owner, ac, title, "", func(value string, token attachmentRoleToken) error { | |
| 50 | + | d.enterTransitionPrompt(owner, ac, title, "", func(value string, token attachmentConnectionToken) error { | |
| 51 | 51 | if value != key.Display() { | |
| 52 | 52 | return errProxyKillConfirmation | |
| 53 | 53 | } | |
| 54 | 54 | if token.ac != nil { | |
| 55 | - | if !token.activeCurrent() { | |
| 55 | + | if !token.attachmentCurrent() { | |
| 56 | 56 | return errAttachmentTransition | |
| 57 | 57 | } | |
| 58 | 58 | token.effect.bindActionEnd(d, "remote-picker-delete") | |
M internal/usecase/daemon/proxy_lifecycle.go +12 -10
| @@ -39,8 +39,8 @@ func (t *proxyWarmTimer) stop() { | |||
| 39 | 39 | // proxyAttachmentTransitionCommitted applies lifecycle policy only after the | |
| 40 | 40 | // attachment publication has released every architecture lock. Each helper | |
| 41 | 41 | // performs its own exact pointer/client revalidation before changing a timer. | |
| 42 | - | func (d *Daemon) proxyAttachmentTransitionCommitted(source, target attachmentSession, ac *attachedClient, preserveRole bool) { | |
| 43 | - | if d == nil || preserveRole || source == target { | |
| 42 | + | func (d *Daemon) proxyAttachmentTransitionCommitted(source, target attachmentSession, ac *attachedClient, preserveAttachment bool) { | |
| 43 | + | if d == nil || preserveAttachment || source == target { | |
| 44 | 44 | return | |
| 45 | 45 | } | |
| 46 | 46 | if proxy, ok := source.(*proxySession); ok { | |
| @@ -66,7 +66,7 @@ func (d *Daemon) cancelProxyWarmForAttachment(p *proxySession, ac *attachedClien | |||
| 66 | 66 | return false | |
| 67 | 67 | } | |
| 68 | 68 | p.sessionCore.mu.Lock() | |
| 69 | - | if p.client != ac { | |
| 69 | + | if !attachmentRegisteredLocked(attachmentSession(p), ac) { | |
| 70 | 70 | p.sessionCore.mu.Unlock() | |
| 71 | 71 | d.mu.Unlock() | |
| 72 | 72 | return false | |
| @@ -100,7 +100,7 @@ func (d *Daemon) armProxyWarm(p *proxySession) bool { | |||
| 100 | 100 | return false | |
| 101 | 101 | } | |
| 102 | 102 | p.sessionCore.mu.Lock() | |
| 103 | - | if p.client != nil { | |
| 103 | + | if len(p.attachments) != 0 { | |
| 104 | 104 | p.sessionCore.mu.Unlock() | |
| 105 | 105 | d.mu.Unlock() | |
| 106 | 106 | return false | |
| @@ -126,7 +126,7 @@ func (d *Daemon) armProxyWarm(p *proxySession) bool { | |||
| 126 | 126 | valid := d.sessions[p.id] == p && !d.closing | |
| 127 | 127 | if valid { | |
| 128 | 128 | p.sessionCore.mu.Lock() | |
| 129 | - | valid = p.client == nil | |
| 129 | + | valid = len(p.attachments) == 0 | |
| 130 | 130 | if valid { | |
| 131 | 131 | p.mu.Lock() | |
| 132 | 132 | valid = p.generation == generation && p.warm == old | |
| @@ -174,7 +174,7 @@ func (d *Daemon) expireWarmProxy(p *proxySession, token *proxyWarmTimer) bool { | |||
| 174 | 174 | return false | |
| 175 | 175 | } | |
| 176 | 176 | p.sessionCore.mu.Lock() | |
| 177 | - | if p.client != nil { | |
| 177 | + | if len(p.attachments) != 0 { | |
| 178 | 178 | p.sessionCore.mu.Unlock() | |
| 179 | 179 | d.mu.Unlock() | |
| 180 | 180 | return false | |
| @@ -245,12 +245,14 @@ func (d *Daemon) withProxyLocked(p *proxySession, update func() bool) bool { | |||
| 245 | 245 | p.sessionCore.mu.Lock() | |
| 246 | 246 | p.mu.Lock() | |
| 247 | 247 | current := update() | |
| 248 | - | ac := p.client | |
| 248 | + | attachments := append([]*attachedClient(nil), p.snapshotAttachmentsLocked()...) | |
| 249 | 249 | p.mu.Unlock() | |
| 250 | 250 | p.sessionCore.mu.Unlock() | |
| 251 | 251 | d.mu.Unlock() | |
| 252 | - | if current && ac != nil { | |
| 253 | - | d.invalidateRender(p, ac, false, "proxy_lifecycle.go") | |
| 252 | + | if current { | |
| 253 | + | for _, ac := range attachments { | |
| 254 | + | d.invalidateRender(p, ac, false, "proxy_lifecycle.go") | |
| 255 | + | } | |
| 254 | 256 | } | |
| 255 | 257 | return current | |
| 256 | 258 | } | |
| @@ -317,7 +319,7 @@ func (d *Daemon) markProxyReplaced(p *proxySession, generation uint64, transport | |||
| 317 | 319 | p.warm.generation = p.generation | |
| 318 | 320 | } | |
| 319 | 321 | } | |
| 320 | - | needsWarm = current && p.client == nil && p.warm == nil | |
| 322 | + | needsWarm = current && len(p.attachments) == 0 && p.warm == nil | |
| 321 | 323 | return current | |
| 322 | 324 | }) | |
| 323 | 325 | if needsWarm { | |
M internal/usecase/daemon/proxy_lifecycle_test.go +66 -67
| @@ -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 | ||
| @@ -186,7 +186,7 @@ func TestProxyWarmLifecycleFollowsAttachmentTransitions(t *testing.T) { | |||
| 186 | 186 | ||
| 187 | 187 | toProxy, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 188 | 188 | source: local, target: proxy, next: ac, | |
| 189 | - | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 189 | + | ||
| 190 | 190 | expectedTransport: ac.transportSnapshot(), sourceToken: &localToken, | |
| 191 | 191 | action: "lifecycle test", ready: true, | |
| 192 | 192 | }) | |
| @@ -198,7 +198,7 @@ func TestProxyWarmLifecycleFollowsAttachmentTransitions(t *testing.T) { | |||
| 198 | 198 | ||
| 199 | 199 | _, err = d.transitionAttachment(attachmentTransitionRequest{ | |
| 200 | 200 | source: proxy, target: local, next: ac, | |
| 201 | - | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 201 | + | ||
| 202 | 202 | expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published, | |
| 203 | 203 | action: "lifecycle test", ready: true, | |
| 204 | 204 | }) | |
| @@ -255,7 +255,7 @@ func TestProxyWarmReattachCancelsAndStaleTimerCannotRemoveProxy(t *testing.T) { | |||
| 255 | 255 | ||
| 256 | 256 | ac := &attachedClient{} | |
| 257 | 257 | proxy.sessionCore.mu.Lock() | |
| 258 | - | proxy.client = ac | |
| 258 | + | proxy.sessionCore.registerAttachmentLocked(ac) | |
| 259 | 259 | proxy.sessionCore.mu.Unlock() | |
| 260 | 260 | d.cancelProxyWarmForAttachment(proxy, ac) | |
| 261 | 261 | awaitTestCompletion(t, firstToken.done, "first proxy warm lifecycle did not complete") | |
| @@ -266,7 +266,9 @@ func TestProxyWarmReattachCancelsAndStaleTimerCannotRemoveProxy(t *testing.T) { | |||
| 266 | 266 | d.mu.Unlock() | |
| 267 | 267 | ||
| 268 | 268 | proxy.sessionCore.mu.Lock() | |
| 269 | - | proxy.client = nil | |
| 269 | + | for _, attachment := range proxy.sessionCore.snapshotAttachmentsLocked() { | |
| 270 | + | proxy.sessionCore.unregisterAttachmentLocked(attachment) | |
| 271 | + | } | |
| 270 | 272 | proxy.sessionCore.mu.Unlock() | |
| 271 | 273 | d.armProxyWarm(proxy) | |
| 272 | 274 | secondTimer := awaitTestValue(t, clock.timers, "second proxy timer was not registered") | |
| @@ -464,42 +466,43 @@ func TestProxyLinkStateDisplayAndExactGeneration(t *testing.T) { | |||
| 464 | 466 | require.Equal(t, proxy.name+" [dead]", proxy.statusSegments(false).session) | |
| 465 | 467 | } | |
| 466 | 468 | ||
| 467 | - | func TestIncomingDirectAttachTerminatesProxiedRemoteAttachment(t *testing.T) { | |
| 469 | + | func TestIncomingDirectAttachPreservesProxiedRemoteAttachment(t *testing.T) { | |
| 468 | 470 | d := newTestDaemon(t, nil, newProxyLifecycleClock()) | |
| 469 | 471 | sess := &session{sessionCore: sessionCore{id: "work", name: "work"}} | |
| 470 | 472 | oldTransport := newProxyTestTransport() | |
| 471 | 473 | old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), proxied: true} | |
| 472 | 474 | old.initOverlays() | |
| 473 | 475 | old.setSession(sess) | |
| 474 | - | sess.client = old | |
| 476 | + | sess.registerAttachment(old) | |
| 475 | 477 | d.mu.Lock() | |
| 476 | 478 | require.True(t, d.registerSessionLocked(sess)) | |
| 477 | 479 | d.mu.Unlock() | |
| 478 | - | d.attachCoordinator(sess, nil, old, true) | |
| 480 | + | d.attachCoordinator(sess, old, true) | |
| 479 | 481 | ||
| 480 | 482 | newTransport := newProxyTestTransport() | |
| 481 | 483 | next := &attachedClient{tr: newTransport, output: newOutputStateStream()} | |
| 482 | 484 | next.initOverlays() | |
| 483 | 485 | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 484 | 486 | target: sess, next: next, | |
| 485 | - | expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 487 | + | ||
| 486 | 488 | expectedTransport: next.transportSnapshot(), ready: true, | |
| 487 | 489 | }) | |
| 488 | 490 | require.NoError(t, err) | |
| 489 | - | require.Nil(t, result.displaced.ac, "a proxied attachment must never enter the snatched role") | |
| 490 | - | require.Same(t, next, sess.client) | |
| 491 | - | require.Nil(t, old.currentAttachmentSession()) | |
| 491 | + | require.NotNil(t, result.published.ac) | |
| 492 | + | require.Contains(t, sess.snapshotAttachments(), next) | |
| 493 | + | require.Same(t, sess, old.currentAttachmentSession()) | |
| 492 | 494 | ||
| 493 | 495 | d.deferAttachmentTransitionCleanups(result) | |
| 494 | 496 | d.waitNotifies() | |
| 495 | - | frame := awaitTestValue(t, oldTransport.sent, "replacement did not notify old transport") | |
| 496 | - | detached, err := ports.UnmarshalDetached(frame.Payload) | |
| 497 | - | require.NoError(t, err) | |
| 498 | - | require.Equal(t, ports.ReasonReplaced, detached.Reason) | |
| 497 | + | select { | |
| 498 | + | case <-oldTransport.sent: | |
| 499 | + | t.Fatal("publishing an attachment displaced the existing connection") | |
| 500 | + | default: | |
| 501 | + | } | |
| 499 | 502 | select { | |
| 500 | 503 | case <-oldTransport.closed: | |
| 504 | + | t.Fatal("publishing an attachment closed the existing connection") | |
| 501 | 505 | default: | |
| 502 | - | t.Fatal("terminal proxied replacement did not close its transport") | |
| 503 | 506 | } | |
| 504 | 507 | } | |
| 505 | 508 | ||
| @@ -532,7 +535,7 @@ func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T) | |||
| 532 | 535 | clock := newProxyLifecycleClock() | |
| 533 | 536 | d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 534 | 537 | d.clock = clock | |
| 535 | - | coordinator := d.attachCoordinator(local, nil, ac, true) | |
| 538 | + | coordinator := d.attachCoordinator(local, ac, true) | |
| 536 | 539 | localToken := attachmentToken(local, ac, ac.transport()) | |
| 537 | 540 | localToken.lease = coordinator.attachmentLease(ac) | |
| 538 | 541 | ||
| @@ -550,7 +553,7 @@ func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T) | |||
| 550 | 553 | ||
| 551 | 554 | toProxy, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 552 | 555 | source: local, target: proxy, next: ac, | |
| 553 | - | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 556 | + | ||
| 554 | 557 | expectedTransport: ac.transportSnapshot(), sourceToken: &localToken, | |
| 555 | 558 | action: "replaced picker regression", ready: true, | |
| 556 | 559 | }) | |
| @@ -565,7 +568,7 @@ func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T) | |||
| 565 | 568 | ||
| 566 | 569 | _, err = d.transitionAttachment(attachmentTransitionRequest{ | |
| 567 | 570 | source: proxy, target: local, next: ac, | |
| 568 | - | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 571 | + | ||
| 569 | 572 | expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published, | |
| 570 | 573 | action: "replaced picker regression", ready: true, | |
| 571 | 574 | }) | |
| @@ -579,7 +582,7 @@ func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T) | |||
| 579 | 582 | require.True(t, ok) | |
| 580 | 583 | require.Equal(t, proxyWarmDuration, warmTimer.delay) | |
| 581 | 584 | ||
| 582 | - | views, _ := d.pickerViews(local) | |
| 585 | + | views, _ := d.pickerViews(local, nil) | |
| 583 | 586 | var expiredView picker.SessionView | |
| 584 | 587 | for _, view := range views { | |
| 585 | 588 | if view.RemoteKey != nil && *view.RemoteKey == key { | |
| @@ -595,7 +598,7 @@ func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T) | |||
| 595 | 598 | require.Equal(t, key.Display()+" [expired]", proxy.lifecycleDisplayName()) | |
| 596 | 599 | ||
| 597 | 600 | d.enterPicker(local, ac) | |
| 598 | - | forced := d.newPickerModel(local, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{ | |
| 601 | + | forced := d.newPickerModel(local, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{ | |
| 599 | 602 | Session: key.ID(), RemoteKey: &key, | |
| 600 | 603 | }) | |
| 601 | 604 | ac.overlays.pickerMu.Lock() | |
| @@ -611,10 +614,10 @@ func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T) | |||
| 611 | 614 | require.Same(t, local, ac.currentAttachmentSession(), "picker activation reattached the terminal proxy") | |
| 612 | 615 | ||
| 613 | 616 | token := attachmentToken(local, ac, ac.transport()) | |
| 614 | - | effect, admitted := ac.beginRoleEffect(token) | |
| 617 | + | effect, admitted := ac.beginAttachmentEffect(token) | |
| 615 | 618 | require.True(t, admitted) | |
| 616 | 619 | token.effect = effect | |
| 617 | - | err = d.switchToTargetForRole(token, cursor, sessionHandoffGuard{}, "replaced picker regression") | |
| 620 | + | err = d.switchToTargetForAttachment(token, cursor, sessionHandoffGuard{}, "replaced picker regression") | |
| 618 | 621 | require.Error(t, err) | |
| 619 | 622 | require.Same(t, local, ac.currentAttachmentSession(), "defensive activation reused the terminal proxy") | |
| 620 | 623 | ||
| @@ -1085,7 +1088,7 @@ func TestProxyKillConfirmationRequiresExactRemoteDisplay(t *testing.T) { | |||
| 1085 | 1088 | require.Equal(t, " Type work@arch to kill ", ac.overlays.prompt.Title()) | |
| 1086 | 1089 | submit := ac.overlays.promptTransitionSubmit | |
| 1087 | 1090 | ac.overlays.promptMu.Unlock() | |
| 1088 | - | require.Error(t, submit("work", attachmentRoleToken{})) | |
| 1091 | + | require.Error(t, submit("work", attachmentConnectionToken{})) | |
| 1089 | 1092 | d.mu.Lock() | |
| 1090 | 1093 | require.Same(t, proxy, d.sessions[proxy.id]) | |
| 1091 | 1094 | d.mu.Unlock() | |
| @@ -1199,7 +1202,7 @@ func TestOpenProxySessionSerializesSameKeyAttachConstruction(t *testing.T) { | |||
| 1199 | 1202 | require.Equal(t, 1, awaitTestValue(t, factory.calls, "leader did not dial")) | |
| 1200 | 1203 | _ = requireProxyHello(t, first) | |
| 1201 | 1204 | ||
| 1202 | - | waitCtx, cancelWait := context.WithCancel(context.Background()) | |
| 1205 | + | waitCtx, cancelWait := context.WithCancel(t.Context()) | |
| 1203 | 1206 | defer cancelWait() | |
| 1204 | 1207 | observed := &proxyConstructionWaitContext{Context: waitCtx, observed: make(chan struct{})} | |
| 1205 | 1208 | waiter := openProxyForTest(d, observed, key) | |
| @@ -1322,7 +1325,7 @@ func proxyWarmToken(p *proxySession) (*proxyWarmTimer, uint64) { | |||
| 1322 | 1325 | ||
| 1323 | 1326 | func setProxyLifecycleClient(p *proxySession, ac *attachedClient) { | |
| 1324 | 1327 | p.sessionCore.mu.Lock() | |
| 1325 | - | p.client = ac | |
| 1328 | + | p.sessionCore.registerAttachmentLocked(ac) | |
| 1326 | 1329 | p.sessionCore.mu.Unlock() | |
| 1327 | 1330 | } | |
| 1328 | 1331 | ||
| @@ -1354,37 +1357,33 @@ func armWarmTimer(t *testing.T, d *Daemon, p *proxySession, clock *warmHookClock | |||
| 1354 | 1357 | func TestArmProxyWarmKeepsExpiryPathWhenPublicationFails(t *testing.T) { | |
| 1355 | 1358 | tests := []struct { | |
| 1356 | 1359 | name string | |
| 1357 | - | race func(d *Daemon, p *proxySession) | |
| 1358 | - | undo func(d *Daemon, p *proxySession) | |
| 1360 | + | race func(*Daemon, *proxySession) func() | |
| 1359 | 1361 | }{ | |
| 1360 | 1362 | { | |
| 1361 | - | name: "client attaches during arm", | |
| 1362 | - | race: func(_ *Daemon, p *proxySession) { setProxyLifecycleClient(p, &attachedClient{}) }, | |
| 1363 | - | undo: func(_ *Daemon, p *proxySession) { setProxyLifecycleClient(p, nil) }, | |
| 1364 | - | }, | |
| 1365 | - | { | |
| 1366 | - | name: "replacement rekeys the lifecycle generation", | |
| 1367 | - | race: func(_ *Daemon, p *proxySession) { | |
| 1368 | - | p.mu.Lock() | |
| 1369 | - | p.expired = true | |
| 1370 | - | p.generation++ | |
| 1371 | - | if p.warm != nil { | |
| 1372 | - | p.warm.generation = p.generation | |
| 1363 | + | name: "fresh attachment publishes during arm", | |
| 1364 | + | race: func(_ *Daemon, p *proxySession) func() { | |
| 1365 | + | ac := &attachedClient{} | |
| 1366 | + | p.sessionCore.mu.Lock() | |
| 1367 | + | p.sessionCore.registerAttachmentLocked(ac) | |
| 1368 | + | p.sessionCore.mu.Unlock() | |
| 1369 | + | return func() { | |
| 1370 | + | p.sessionCore.mu.Lock() | |
| 1371 | + | p.sessionCore.unregisterAttachmentLocked(ac) | |
| 1372 | + | p.sessionCore.mu.Unlock() | |
| 1373 | 1373 | } | |
| 1374 | - | p.mu.Unlock() | |
| 1375 | 1374 | }, | |
| 1376 | 1375 | }, | |
| 1377 | 1376 | { | |
| 1378 | - | name: "daemon starts closing during arm", | |
| 1379 | - | race: func(d *Daemon, _ *proxySession) { | |
| 1377 | + | name: "daemon shutdown publishes during arm", | |
| 1378 | + | race: func(d *Daemon, _ *proxySession) func() { | |
| 1380 | 1379 | d.mu.Lock() | |
| 1381 | 1380 | d.closing = true | |
| 1382 | 1381 | d.mu.Unlock() | |
| 1383 | - | }, | |
| 1384 | - | undo: func(d *Daemon, _ *proxySession) { | |
| 1385 | - | d.mu.Lock() | |
| 1386 | - | d.closing = false | |
| 1387 | - | d.mu.Unlock() | |
| 1382 | + | return func() { | |
| 1383 | + | d.mu.Lock() | |
| 1384 | + | d.closing = false | |
| 1385 | + | d.mu.Unlock() | |
| 1386 | + | } | |
| 1388 | 1387 | }, | |
| 1389 | 1388 | }, | |
| 1390 | 1389 | } | |
| @@ -1393,7 +1392,8 @@ func TestArmProxyWarmKeepsExpiryPathWhenPublicationFails(t *testing.T) { | |||
| 1393 | 1392 | d, p, clock := newProxyLifecycleFixture(t) | |
| 1394 | 1393 | token, timer := armWarmTimer(t, d, p, clock) | |
| 1395 | 1394 | ||
| 1396 | - | clock.setHook(func() { tt.race(d, p) }) | |
| 1395 | + | var undo func() | |
| 1396 | + | clock.setHook(func() { undo = tt.race(d, p) }) | |
| 1397 | 1397 | require.False(t, d.armProxyWarm(p), "raced re-arm must not publish a replacement") | |
| 1398 | 1398 | awaitTestValue(t, clock.timers, "raced re-arm never reached its clock") | |
| 1399 | 1399 | ||
| @@ -1403,9 +1403,8 @@ func TestArmProxyWarmKeepsExpiryPathWhenPublicationFails(t *testing.T) { | |||
| 1403 | 1403 | require.Equal(t, generation, retained.generation, "retained timer no longer owns the lifecycle generation") | |
| 1404 | 1404 | require.True(t, proxyRegistered(d, p), "a failed re-arm must not unregister the proxy") | |
| 1405 | 1405 | ||
| 1406 | - | if tt.undo != nil { | |
| 1407 | - | tt.undo(d, p) | |
| 1408 | - | } | |
| 1406 | + | require.NotNil(t, undo, "publication race did not run") | |
| 1407 | + | undo() | |
| 1409 | 1408 | timer.ch <- time.Time{} | |
| 1410 | 1409 | awaitTestCompletion(t, token.done, "retained warm timer never ran to completion") | |
| 1411 | 1410 | require.False(t, proxyRegistered(d, p), "retained warm timer did not expire the dormant proxy") | |
| @@ -1544,20 +1543,20 @@ func TestExpireWarmProxyIgnoresSupersededToken(t *testing.T) { | |||
| 1544 | 1543 | require.False(t, proxyRegistered(d, p)) | |
| 1545 | 1544 | } | |
| 1546 | 1545 | ||
| 1547 | - | func newProxyRoleDetachFixture(t *testing.T) (*Daemon, *proxySession, *attachedClient, *closeTrackingTransport, *warmHookClock, attachmentRoleToken, *renderCoordinator) { | |
| 1546 | + | func newProxyRoleDetachFixture(t *testing.T) (*Daemon, *proxySession, *attachedClient, *closeTrackingTransport, *warmHookClock, attachmentConnectionToken, *renderCoordinator) { | |
| 1548 | 1547 | t.Helper() | |
| 1549 | 1548 | d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 1550 | 1549 | clock := &warmHookClock{timers: make(chan *signalTimer, 4)} | |
| 1551 | 1550 | d.clock = clock | |
| 1552 | 1551 | clientTransport := &closeTrackingTransport{} | |
| 1553 | 1552 | ac.replaceTransport(clientTransport) | |
| 1554 | - | localCoordinator := d.attachCoordinator(local, nil, ac, true) | |
| 1553 | + | localCoordinator := d.attachCoordinator(local, ac, true) | |
| 1555 | 1554 | localToken := attachmentToken(local, ac, clientTransport) | |
| 1556 | 1555 | localToken.lease = localCoordinator.attachmentLease(ac) | |
| 1557 | 1556 | proxy := registerLifecycleProxy(t, d, domain.RemoteSessionKey{Host: "arch", Name: "detach"}) | |
| 1558 | 1557 | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1559 | 1558 | source: local, target: proxy, next: ac, | |
| 1560 | - | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 1559 | + | ||
| 1561 | 1560 | expectedTransport: ac.transportSnapshot(), sourceToken: &localToken, | |
| 1562 | 1561 | action: "proxy detach fixture", ready: true, | |
| 1563 | 1562 | }) | |
| @@ -1568,21 +1567,21 @@ func newProxyRoleDetachFixture(t *testing.T) (*Daemon, *proxySession, *attachedC | |||
| 1568 | 1567 | func TestProxyRoleDetachRetiresExactClientAndOwnsOneWarmTimer(t *testing.T) { | |
| 1569 | 1568 | tests := []struct { | |
| 1570 | 1569 | name string | |
| 1571 | - | detach func(*Daemon, attachmentRoleToken, ports.Transport) bool | |
| 1570 | + | detach func(*Daemon, attachmentConnectionToken, ports.Transport) bool | |
| 1572 | 1571 | }{ | |
| 1573 | 1572 | { | |
| 1574 | 1573 | name: "explicit", | |
| 1575 | - | detach: func(d *Daemon, token attachmentRoleToken, _ ports.Transport) bool { | |
| 1576 | - | effect, admitted := token.ac.beginRoleEffect(token) | |
| 1574 | + | detach: func(d *Daemon, token attachmentConnectionToken, _ ports.Transport) bool { | |
| 1575 | + | effect, admitted := token.ac.beginAttachmentEffect(token) | |
| 1577 | 1576 | require.True(t, admitted) | |
| 1578 | 1577 | token.effect = effect | |
| 1579 | - | return d.clientGoneForRole(token, true) | |
| 1578 | + | return d.clientGoneForAttachment(token, true) | |
| 1580 | 1579 | }, | |
| 1581 | 1580 | }, | |
| 1582 | 1581 | { | |
| 1583 | 1582 | name: "send error", | |
| 1584 | - | detach: func(d *Daemon, token attachmentRoleToken, failed ports.Transport) bool { | |
| 1585 | - | d.detachOnRoleSendError(token, failed) | |
| 1583 | + | detach: func(d *Daemon, token attachmentConnectionToken, failed ports.Transport) bool { | |
| 1584 | + | d.detachOnAttachmentSendError(token, failed) | |
| 1586 | 1585 | return token.ac.currentAttachmentSession() == nil | |
| 1587 | 1586 | }, | |
| 1588 | 1587 | }, | |
| @@ -1620,11 +1619,11 @@ func TestProxyRoleDetachRetiresExactClientAndOwnsOneWarmTimer(t *testing.T) { | |||
| 1620 | 1619 | func TestProxyRoleDetachRejectsStaleTransportAndRole(t *testing.T) { | |
| 1621 | 1620 | tests := []struct { | |
| 1622 | 1621 | name string | |
| 1623 | - | stale func(*attachedClient, *attachmentRoleToken) *closeTrackingTransport | |
| 1622 | + | stale func(*attachedClient, *attachmentConnectionToken) *closeTrackingTransport | |
| 1624 | 1623 | }{ | |
| 1625 | 1624 | { | |
| 1626 | 1625 | name: "transport", | |
| 1627 | - | stale: func(ac *attachedClient, _ *attachmentRoleToken) *closeTrackingTransport { | |
| 1626 | + | stale: func(ac *attachedClient, _ *attachmentConnectionToken) *closeTrackingTransport { | |
| 1628 | 1627 | current := &closeTrackingTransport{} | |
| 1629 | 1628 | ac.replaceTransport(current) | |
| 1630 | 1629 | return current | |
| @@ -1632,8 +1631,8 @@ func TestProxyRoleDetachRejectsStaleTransportAndRole(t *testing.T) { | |||
| 1632 | 1631 | }, | |
| 1633 | 1632 | { | |
| 1634 | 1633 | name: "role generation", | |
| 1635 | - | stale: func(ac *attachedClient, _ *attachmentRoleToken) *closeTrackingTransport { | |
| 1636 | - | ac.roleGeneration.Add(1) | |
| 1634 | + | stale: func(ac *attachedClient, _ *attachmentConnectionToken) *closeTrackingTransport { | |
| 1635 | + | ac.connectionGeneration.Add(1) | |
| 1637 | 1636 | return nil | |
| 1638 | 1637 | }, | |
| 1639 | 1638 | }, | |
| @@ -1642,7 +1641,7 @@ func TestProxyRoleDetachRejectsStaleTransportAndRole(t *testing.T) { | |||
| 1642 | 1641 | t.Run(tt.name, func(t *testing.T) { | |
| 1643 | 1642 | d, proxy, ac, failed, clock, token, _ := newProxyRoleDetachFixture(t) | |
| 1644 | 1643 | current := tt.stale(ac, &token) | |
| 1645 | - | d.detachOnRoleSendError(token, failed) | |
| 1644 | + | d.detachOnAttachmentSendError(token, failed) | |
| 1646 | 1645 | require.Same(t, proxy, ac.currentAttachmentSession()) | |
| 1647 | 1646 | if current != nil { | |
| 1648 | 1647 | require.False(t, current.Closed(), "stale transport error closed the rebound client") | |
M internal/usecase/daemon/proxy_link.go +24 -12
| @@ -651,18 +651,27 @@ func (d *Daemon) runProxyTransport(ctx context.Context, p *proxySession, generat | |||
| 651 | 651 | } | |
| 652 | 652 | } | |
| 653 | 653 | ||
| 654 | - | // handleProxySideEffect forwards a stateless remote effect to the exact local | |
| 655 | - | // attachment that currently owns this proxy. Role admission and transport | |
| 656 | - | // incarnation checks make a stale handoff a harmless drop rather than a send | |
| 657 | - | // to the client's next session. | |
| 654 | + | // handleProxySideEffect fans out a stateless remote effect to every attachment | |
| 655 | + | // currently owning this proxy. Each attachment gets an independent capability, | |
| 656 | + | // effect ticket, output snapshot, and exact transport-incarnation fence. | |
| 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 | - | p.sessionCore.mu.Lock() | |
| 663 | - | ac := p.client | |
| 664 | - | p.sessionCore.mu.Unlock() | |
| 665 | - | if ac == nil || ac.output == nil { | |
| 661 | + | var sendErr error | |
| 662 | + | for _, ac := range snapshotAttachmentSession(p) { | |
| 663 | + | if !p.currentLinkGeneration(generation) { | |
| 664 | + | break | |
| 665 | + | } | |
| 666 | + | if err := d.sendProxySideEffect(p, generation, ac, out); err != nil && sendErr == nil { | |
| 667 | + | sendErr = err | |
| 668 | + | } | |
| 669 | + | } | |
| 670 | + | return sendErr | |
| 671 | + | } | |
| 672 | + | ||
| 673 | + | func (d *Daemon) sendProxySideEffect(p *proxySession, generation uint64, ac *attachedClient, out ports.Output) error { | |
| 674 | + | if p == nil || ac == nil || ac.output == nil || !p.currentLinkGeneration(generation) { | |
| 666 | 675 | return nil | |
| 667 | 676 | } | |
| 668 | 677 | expected := ac.transportSnapshot() | |
| @@ -670,19 +679,22 @@ func (d *Daemon) handleProxySideEffect(p *proxySession, generation uint64, out p | |||
| 670 | 679 | return nil | |
| 671 | 680 | } | |
| 672 | 681 | token := attachmentToken(p, ac, expected.transport) | |
| 673 | - | if token.role != attachmentActive || token.sess != p { | |
| 682 | + | if token.sess != p || !token.current() { | |
| 674 | 683 | return nil | |
| 675 | 684 | } | |
| 676 | - | ticket, admitted := ac.beginRoleEffect(token) | |
| 685 | + | ticket, admitted := ac.beginAttachmentEffect(token) | |
| 677 | 686 | if !admitted { | |
| 678 | 687 | return nil | |
| 679 | 688 | } | |
| 680 | 689 | defer ticket.End() | |
| 681 | - | if !p.currentLinkGeneration(generation) { | |
| 690 | + | if !p.currentLinkGeneration(generation) || !token.current() { | |
| 682 | 691 | return nil | |
| 683 | 692 | } | |
| 684 | 693 | frame := ac.output.sideEffect(out.Data, ac.echoAck.Load()) | |
| 685 | - | if err := ac.sendExpectedTransportForRole(expected, frame, ticket); err != nil { | |
| 694 | + | if !p.currentLinkGeneration(generation) || !token.current() { | |
| 695 | + | return nil | |
| 696 | + | } | |
| 697 | + | if err := ac.sendExpectedTransportForAttachment(expected, frame, ticket); err != nil { | |
| 686 | 698 | if errors.Is(err, errAttachmentTransition) { | |
| 687 | 699 | return nil | |
| 688 | 700 | } | |
M internal/usecase/daemon/proxy_output.go +3 -4
| @@ -91,10 +91,9 @@ func (d *Daemon) handleProxyScreenUpdate(p *proxySession, generation uint64, upd | |||
| 91 | 91 | return nil | |
| 92 | 92 | } | |
| 93 | 93 | ||
| 94 | - | p.sessionCore.mu.Lock() | |
| 95 | - | ac := p.client | |
| 96 | - | p.sessionCore.mu.Unlock() | |
| 97 | - | d.invalidateRender(p, ac, false, "proxy_output.go") | |
| 94 | + | for _, ac := range snapshotAttachmentSession(p) { | |
| 95 | + | d.invalidateRender(p, ac, false, "proxy_output.go") | |
| 96 | + | } | |
| 98 | 97 | return nil | |
| 99 | 98 | } | |
| 100 | 99 | ||
M internal/usecase/daemon/proxy_output_test.go +65 -2
| @@ -987,6 +987,69 @@ func TestProxySideEffectForwardsOSC52ToCurrentThinClient(t *testing.T) { | |||
| 987 | 987 | } | |
| 988 | 988 | } | |
| 989 | 989 | ||
| 990 | + | func TestProxySideEffectFansOutToEveryActiveAttachment(t *testing.T) { | |
| 991 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 992 | + | remote := newProxyTestTransport() | |
| 993 | + | firstTransport := newProxyTestTransport() | |
| 994 | + | proxy, first := newAttachedProxyFixture(t, d, firstTransport, remote) | |
| 995 | + | secondTransport := newProxyTestTransport() | |
| 996 | + | second := &attachedClient{tr: secondTransport, output: newOutputStateStream()} | |
| 997 | + | second.setSession(proxy) | |
| 998 | + | proxy.sessionCore.mu.Lock() | |
| 999 | + | require.True(t, proxy.sessionCore.registerAttachmentLocked(second)) | |
| 1000 | + | proxy.sessionCore.mu.Unlock() | |
| 1001 | + | first.echoAck.Store(11) | |
| 1002 | + | second.echoAck.Store(22) | |
| 1003 | + | ||
| 1004 | + | result, err := d.handleLinkFrame(proxy, 1, proxySideEffectFrame([]byte("effect"))) | |
| 1005 | + | require.Equal(t, proxyLinkResume, result) | |
| 1006 | + | require.NoError(t, err) | |
| 1007 | + | for _, tc := range []struct { | |
| 1008 | + | name string | |
| 1009 | + | ch <-chan ports.Frame | |
| 1010 | + | ack uint64 | |
| 1011 | + | }{ | |
| 1012 | + | {name: "first", ch: firstTransport.sent, ack: 11}, | |
| 1013 | + | {name: "second", ch: secondTransport.sent, ack: 22}, | |
| 1014 | + | } { | |
| 1015 | + | t.Run(tc.name, func(t *testing.T) { | |
| 1016 | + | frame := awaitTestValue(t, tc.ch, tc.name+" attachment did not receive side effect") | |
| 1017 | + | out, err := ports.UnmarshalOutput(frame.Payload) | |
| 1018 | + | require.NoError(t, err) | |
| 1019 | + | require.Equal(t, tc.ack, out.EchoAck) | |
| 1020 | + | require.Equal(t, []byte("effect"), out.Data) | |
| 1021 | + | }) | |
| 1022 | + | } | |
| 1023 | + | } | |
| 1024 | + | ||
| 1025 | + | func TestProxySideEffectSkipsStaleAttachmentAndForwardsLiveAttachment(t *testing.T) { | |
| 1026 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 1027 | + | remote := newProxyTestTransport() | |
| 1028 | + | staleTransport := newProxyTestTransport() | |
| 1029 | + | proxy, stale := newAttachedProxyFixture(t, d, staleTransport, remote) | |
| 1030 | + | liveTransport := newProxyTestTransport() | |
| 1031 | + | live := &attachedClient{tr: liveTransport, output: newOutputStateStream()} | |
| 1032 | + | live.setSession(proxy) | |
| 1033 | + | proxy.sessionCore.mu.Lock() | |
| 1034 | + | require.True(t, proxy.sessionCore.registerAttachmentLocked(live)) | |
| 1035 | + | proxy.sessionCore.mu.Unlock() | |
| 1036 | + | staleToken := attachmentToken(proxy, stale, stale.transport()) | |
| 1037 | + | staleEffect, admitted := stale.beginAttachmentEffect(staleToken) | |
| 1038 | + | require.True(t, admitted) | |
| 1039 | + | staleEffect.End() | |
| 1040 | + | stale.connectionGeneration.Add(1) | |
| 1041 | + | ||
| 1042 | + | result, err := d.handleLinkFrame(proxy, 1, proxySideEffectFrame([]byte("effect"))) | |
| 1043 | + | require.Equal(t, proxyLinkResume, result) | |
| 1044 | + | require.NoError(t, err) | |
| 1045 | + | awaitTestValue(t, liveTransport.sent, "live attachment did not receive side effect") | |
| 1046 | + | select { | |
| 1047 | + | case frame := <-staleTransport.sent: | |
| 1048 | + | t.Fatalf("stale attachment received side effect: %v", frame.Type) | |
| 1049 | + | default: | |
| 1050 | + | } | |
| 1051 | + | } | |
| 1052 | + | ||
| 990 | 1053 | func TestProxySideEffectRejectsStateBearingAndMalformedMsgOutput(t *testing.T) { | |
| 991 | 1054 | tests := []struct { | |
| 992 | 1055 | name string | |
| @@ -1031,10 +1094,10 @@ func TestProxySideEffectDropsStaleGenerationAndRole(t *testing.T) { | |||
| 1031 | 1094 | } | |
| 1032 | 1095 | ||
| 1033 | 1096 | token := attachmentToken(proxy, ac, ac.transport()) | |
| 1034 | - | ticket, admitted := ac.beginRoleEffect(token) | |
| 1097 | + | ticket, admitted := ac.beginAttachmentEffect(token) | |
| 1035 | 1098 | require.True(t, admitted) | |
| 1036 | 1099 | ticket.End() | |
| 1037 | - | ac.roleGeneration.Add(1) | |
| 1100 | + | ac.connectionGeneration.Add(1) | |
| 1038 | 1101 | result, err = d.handleLinkFrame(proxy, 1, frame) | |
| 1039 | 1102 | require.Equal(t, proxyLinkResume, result) | |
| 1040 | 1103 | require.NoError(t, err) | |
M internal/usecase/daemon/proxy_render.go +4 -4
| @@ -17,11 +17,11 @@ type proxyCapture struct { | |||
| 17 | 17 | cursor capturedCursorInputs | |
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | - | // capturePrimary snapshots the remote VT while holding only proxy.mu. The | |
| 20 | + | // captureRenderState snapshots the remote VT while holding only proxy.mu. The | |
| 21 | 21 | // snapshot is then composed by the ordinary local frame pipeline, which owns | |
| 22 | 22 | // the bars, overlays, cursor, and renderer shadow. Remote ANSI is therefore | |
| 23 | 23 | // never handed to a renderer. | |
| 24 | - | func (p *proxySession) capturePrimary(ac *attachedClient, request primaryCaptureRequest) (*capturedRenderState, bool) { | |
| 24 | + | func (p *proxySession) captureRenderState(ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) { | |
| 25 | 25 | if p == nil || ac == nil { | |
| 26 | 26 | return nil, false | |
| 27 | 27 | } | |
| @@ -35,7 +35,7 @@ func (p *proxySession) capturePrimary(ac *attachedClient, request primaryCapture | |||
| 35 | 35 | capture := &ac.proxyCapture | |
| 36 | 36 | damage := screen.CaptureDamage() | |
| 37 | 37 | if capture.screen != screen { | |
| 38 | - | // A replacement screen has no relationship to the retained frame. | |
| 38 | + | // A new screen has no relationship to the retained frame. | |
| 39 | 39 | capture.frame = renderer.Frame{} | |
| 40 | 40 | } | |
| 41 | 41 | if capture.screen != screen || capture.generation != damage.Generation || capture.frame.Validate() != nil { | |
| @@ -152,7 +152,7 @@ func (p *proxySession) resize(size domain.Size) (changed, sent bool) { | |||
| 152 | 152 | p.screen.ResizePlaceholder(content) | |
| 153 | 153 | p.contentSize = content | |
| 154 | 154 | // A resize invalidates the local replay chain, but the last applied state | |
| 155 | - | // remains the moving snapshot floor for the replacement geometry. | |
| 155 | + | // remains the moving snapshot floor for the new geometry. | |
| 156 | 156 | p.screenReady = false | |
| 157 | 157 | p.resetRequested = false | |
| 158 | 158 | p.screen.stateNum = 0 | |
M internal/usecase/daemon/proxy_render_test.go +13 -13
| @@ -25,7 +25,7 @@ func TestProxyCaptureRetainsOwnedFrameAcrossSafeDamage(t *testing.T) { | |||
| 25 | 25 | proxy.mu.Unlock() | |
| 26 | 26 | ||
| 27 | 27 | ac := &attachedClient{} | |
| 28 | - | state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{}) | |
| 28 | + | state, ok := proxy.captureRenderState(ac, renderCaptureRequest{}) | |
| 29 | 29 | require.True(t, ok) | |
| 30 | 30 | require.Equal(t, "abcdefghijkl", proxyFrameText(state.panes[0].frame)) | |
| 31 | 31 | commitDamageReceipts(state.receipts) | |
| @@ -43,7 +43,7 @@ func TestProxyCaptureRetainsOwnedFrameAcrossSafeDamage(t *testing.T) { | |||
| 43 | 43 | proxy.mu.Unlock() | |
| 44 | 44 | ||
| 45 | 45 | cellsBefore := &ac.proxyCapture.frame.Cells[0] | |
| 46 | - | state, ok = proxy.capturePrimary(ac, primaryCaptureRequest{}) | |
| 46 | + | state, ok = proxy.captureRenderState(ac, renderCaptureRequest{}) | |
| 47 | 47 | require.True(t, ok) | |
| 48 | 48 | require.Equal(t, "abcdeZghijkl", proxyFrameText(state.panes[0].frame)) | |
| 49 | 49 | require.Equal(t, cellsBefore, &ac.proxyCapture.frame.Cells[0], "safe span damage must update the retained frame") | |
| @@ -59,7 +59,7 @@ func TestProxyRenderComposesRemoteVTUnderExactlyOneLocalChrome(t *testing.T) { | |||
| 59 | 59 | changed := applyTestScreenText(proxy, 1, 2, "remote") | |
| 60 | 60 | require.True(t, changed) | |
| 61 | 61 | ||
| 62 | - | state, ok := proxy.capturePrimary(&attachedClient{}, primaryCaptureRequest{ | |
| 62 | + | state, ok := proxy.captureRenderState(&attachedClient{}, renderCaptureRequest{ | |
| 63 | 63 | bars: barState{status: proxy.statusSegments(false)}, | |
| 64 | 64 | reset: true, | |
| 65 | 65 | }) | |
| @@ -91,7 +91,7 @@ func TestProxyRenderPaintsAfterLocalToRemoteHandoff(t *testing.T) { | |||
| 91 | 91 | ac := &attachedClient{tr: transport, output: newOutputStateStream(), size: domain.Size{Cols: 16, Rows: 6}} | |
| 92 | 92 | ac.setSession(proxy) | |
| 93 | 93 | proxy.sessionCore.mu.Lock() | |
| 94 | - | proxy.client = ac | |
| 94 | + | proxy.sessionCore.registerAttachmentLocked(ac) | |
| 95 | 95 | proxy.sessionCore.mu.Unlock() | |
| 96 | 96 | ||
| 97 | 97 | d.paint(proxy, ac, true, nil) | |
| @@ -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 | ||
| @@ -118,7 +118,7 @@ func TestProxyRenderTransitionsPaintBothLocalAndRemoteSessions(t *testing.T) { | |||
| 118 | 118 | ||
| 119 | 119 | toProxy, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 120 | 120 | source: local, target: proxy, next: ac, | |
| 121 | - | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 121 | + | ||
| 122 | 122 | expectedTransport: ac.transportSnapshot(), sourceToken: &localToken, | |
| 123 | 123 | action: "test transition", ready: true, | |
| 124 | 124 | }) | |
| @@ -129,7 +129,7 @@ func TestProxyRenderTransitionsPaintBothLocalAndRemoteSessions(t *testing.T) { | |||
| 129 | 129 | ||
| 130 | 130 | toLocal, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 131 | 131 | source: proxy, target: local, next: ac, | |
| 132 | - | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 132 | + | ||
| 133 | 133 | expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published, | |
| 134 | 134 | action: "test transition", ready: true, | |
| 135 | 135 | }) | |
| @@ -269,7 +269,7 @@ func TestProxyResizeUsesReducedRemoteGeometryOnce(t *testing.T) { | |||
| 269 | 269 | default: | |
| 270 | 270 | } | |
| 271 | 271 | ||
| 272 | - | state, ok := proxy.capturePrimary(&attachedClient{}, primaryCaptureRequest{reset: true}) | |
| 272 | + | state, ok := proxy.captureRenderState(&attachedClient{}, renderCaptureRequest{reset: true}) | |
| 273 | 273 | require.True(t, ok) | |
| 274 | 274 | composed := composeFrame(*state, composeCacheInput{}) | |
| 275 | 275 | require.Equal(t, 10, composed.frame.Height, "local viewport remains full-sized") | |
| @@ -296,7 +296,7 @@ func newAttachedProxyFixture(t *testing.T, d *Daemon, clientTr ports.Transport, | |||
| 296 | 296 | ac := &attachedClient{tr: clientTr, output: newOutputStateStream(), size: domain.Size{Cols: 16, Rows: 6}} | |
| 297 | 297 | ac.setSession(proxy) | |
| 298 | 298 | proxy.sessionCore.mu.Lock() | |
| 299 | - | proxy.client = ac | |
| 299 | + | proxy.sessionCore.registerAttachmentLocked(ac) | |
| 300 | 300 | proxy.sessionCore.mu.Unlock() | |
| 301 | 301 | return proxy, ac | |
| 302 | 302 | } | |
| @@ -318,7 +318,7 @@ func TestProxyStructuredSendFailureRetainsProxyDamage(t *testing.T) { | |||
| 318 | 318 | require.True(t, applyTestScreenText(proxy, 0, 0, "pending")) | |
| 319 | 319 | ||
| 320 | 320 | ac.sendMu.Lock() | |
| 321 | - | state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: true}) | |
| 321 | + | state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: true}) | |
| 322 | 322 | require.True(t, ok) | |
| 323 | 323 | composed := composeFrame(*state, composeCacheInput{}) | |
| 324 | 324 | require.True(t, d.emitFrame(proxy, ac, state, composed)) | |
| @@ -373,7 +373,7 @@ func TestProxyPrepareFailureRepaintsWithoutLocalSession(t *testing.T) { | |||
| 373 | 373 | proxy, ac := newAttachedProxyFixture(t, d, clientTr, newProxyTestTransport()) | |
| 374 | 374 | ||
| 375 | 375 | ac.sendMu.Lock() // emitFrame releases the transaction lock. | |
| 376 | - | state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{bars: barState{status: proxy.statusSegments(false)}, reset: true}) | |
| 376 | + | state, ok := proxy.captureRenderState(ac, renderCaptureRequest{bars: barState{status: proxy.statusSegments(false)}, reset: true}) | |
| 377 | 377 | require.True(t, ok) | |
| 378 | 378 | composed := composeFrame(*state, composeCacheInput{}) | |
| 379 | 379 | // A malformed frame is what makes outputStateStream.prepare fail. | |
| @@ -394,9 +394,9 @@ func TestProxySendErrorDetachesProxyAttachment(t *testing.T) { | |||
| 394 | 394 | d.paint(proxy, ac, true, nil) | |
| 395 | 395 | ||
| 396 | 396 | proxy.sessionCore.mu.Lock() | |
| 397 | - | client := proxy.client | |
| 397 | + | attachments := proxy.sessionCore.snapshotAttachmentsLocked() | |
| 398 | 398 | proxy.sessionCore.mu.Unlock() | |
| 399 | - | require.Nil(t, client, "a failed send must release the proxy attachment") | |
| 399 | + | require.Empty(t, attachments, "a failed send must release the proxy attachment") | |
| 400 | 400 | require.Nil(t, ac.currentAttachmentSession(), "the client must no longer point at the proxy") | |
| 401 | 401 | } | |
| 402 | 402 | ||
M internal/usecase/daemon/proxy_session.go +4 -7
| @@ -122,10 +122,7 @@ func (d *Daemon) applyProxySessionMeta(p *proxySession, generation uint64, meta | |||
| 122 | 122 | p.mu.Unlock() | |
| 123 | 123 | ||
| 124 | 124 | d.pokeAttentionTicker() | |
| 125 | - | p.sessionCore.mu.Lock() | |
| 126 | - | ac := p.client | |
| 127 | - | p.sessionCore.mu.Unlock() | |
| 128 | - | if ac != nil { | |
| 125 | + | for _, ac := range snapshotAttachmentSession(p) { | |
| 129 | 126 | d.invalidateRender(p, ac, false, "proxy_session.go") | |
| 130 | 127 | } | |
| 131 | 128 | return true, nil | |
| @@ -151,12 +148,12 @@ func (p *proxySession) snapshotView(opts viewOptions) sessionView { | |||
| 151 | 148 | expired: expired, | |
| 152 | 149 | createdAt: p.createdAt, | |
| 153 | 150 | mruAt: p.mruAt.Load(), | |
| 154 | - | active: int(meta.Active), | |
| 151 | + | defaultTab: 0, | |
| 155 | 152 | tabCount: len(meta.Tabs), | |
| 156 | 153 | cannotAcceptMoves: true, | |
| 157 | 154 | } | |
| 158 | 155 | p.sessionCore.mu.Lock() | |
| 159 | - | view.attached = p.client != nil | |
| 156 | + | view.attached = len(p.attachments) != 0 | |
| 160 | 157 | p.sessionCore.mu.Unlock() | |
| 161 | 158 | if opts.tabDetails { | |
| 162 | 159 | view.tabs = make([]tabView, len(meta.Tabs)) | |
| @@ -194,7 +191,7 @@ func (p *proxySession) statusSegments(_ bool) statusSnapshot { | |||
| 194 | 191 | return snapshot | |
| 195 | 192 | } | |
| 196 | 193 | ||
| 197 | - | func (p *proxySession) activateTargetLocked(tabIndex int) bool { | |
| 194 | + | func (p *proxySession) validTargetTabLocked(tabIndex int) bool { | |
| 198 | 195 | if tabIndex < 0 { | |
| 199 | 196 | return true | |
| 200 | 197 | } | |
M internal/usecase/daemon/remote_picker_test.go +22 -56
| @@ -83,7 +83,7 @@ func TestRemotePickerMerge(t *testing.T) { | |||
| 83 | 83 | d.remoteCatalog.status["learned"] = remoteHostFresh | |
| 84 | 84 | d.remoteCatalog.mu.Unlock() | |
| 85 | 85 | ||
| 86 | - | views, _ := d.pickerViews(local) | |
| 86 | + | views, _ := d.pickerViews(local, nil) | |
| 87 | 87 | ||
| 88 | 88 | require.Equal(t, []string{"local", "work@pinned", "alpha@pinned", "alpha@learned", "stopped"}, pickerViewNames(views)) | |
| 89 | 89 | require.False(t, views[0].Stopped) | |
| @@ -113,7 +113,7 @@ func TestRemotePickerDedupeUsesExactKeyAndKeepsLiveID(t *testing.T) { | |||
| 113 | 113 | }, | |
| 114 | 114 | }}) | |
| 115 | 115 | ||
| 116 | - | views, _ := d.pickerViews(nil) | |
| 116 | + | views, _ := d.pickerViews(nil, nil) | |
| 117 | 117 | ||
| 118 | 118 | require.Len(t, views, 1) | |
| 119 | 119 | require.Equal(t, key.ID(), views[0].ID) | |
| @@ -132,7 +132,7 @@ func TestRemotePickerSnapshotsDoNotRetainProxyLock(t *testing.T) { | |||
| 132 | 132 | } | |
| 133 | 133 | d.sessions[key.ID()] = proxy | |
| 134 | 134 | ||
| 135 | - | views, _ := d.pickerViews(nil) | |
| 135 | + | views, _ := d.pickerViews(nil, nil) | |
| 136 | 136 | ||
| 137 | 137 | require.Len(t, views, 1) | |
| 138 | 138 | require.True(t, proxy.mu.TryLock(), "picker construction must release the proxy leaf lock") | |
| @@ -162,7 +162,7 @@ func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) { | |||
| 162 | 162 | d.remoteCatalog.status["arch"] = test.status | |
| 163 | 163 | d.remoteCatalog.mu.Unlock() | |
| 164 | 164 | ||
| 165 | - | views, _ := d.pickerViews(nil) | |
| 165 | + | views, _ := d.pickerViews(nil, nil) | |
| 166 | 166 | ||
| 167 | 167 | require.Len(t, views, 1) | |
| 168 | 168 | require.Equal(t, test.want, views[0].RemoteAvailability) | |
| @@ -182,7 +182,7 @@ func TestRemotePickerStaleDetailUsesLastSuccessfulFetch(t *testing.T) { | |||
| 182 | 182 | d.remoteCatalog.failure["arch"] = failedAt | |
| 183 | 183 | d.remoteCatalog.mu.Unlock() | |
| 184 | 184 | ||
| 185 | - | views, _ := d.pickerViews(nil) | |
| 185 | + | views, _ := d.pickerViews(nil, nil) | |
| 186 | 186 | ||
| 187 | 187 | require.Len(t, views, 1) | |
| 188 | 188 | require.Equal(t, "stale since "+fetchedAt.Format(time.RFC3339), views[0].RemoteDetail) | |
| @@ -206,7 +206,7 @@ func TestRemotePickerNoCacheHostFailuresRemainVisible(t *testing.T) { | |||
| 206 | 206 | d.remoteCatalog.failure["arch"] = time.Unix(50, 0) | |
| 207 | 207 | d.remoteCatalog.mu.Unlock() | |
| 208 | 208 | ||
| 209 | - | views, _ := d.pickerViews(nil) | |
| 209 | + | views, _ := d.pickerViews(nil, nil) | |
| 210 | 210 | ||
| 211 | 211 | require.Len(t, views, 1) | |
| 212 | 212 | require.Equal(t, "arch", views[0].Name) | |
| @@ -233,7 +233,7 @@ func TestRemotePickerGroupedSortTreatsProxiesAsLiveSessions(t *testing.T) { | |||
| 233 | 233 | d.sessions[proxy.id] = proxy | |
| 234 | 234 | d.sessions[named.id] = named | |
| 235 | 235 | ||
| 236 | - | views, _ := d.pickerViews(nil) | |
| 236 | + | views, _ := d.pickerViews(nil, nil) | |
| 237 | 237 | ||
| 238 | 238 | require.Equal(t, []string{"named", "work@arch", "1"}, pickerViewNames(views)) | |
| 239 | 239 | } | |
| @@ -539,7 +539,7 @@ func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID) ( | |||
| 539 | 539 | ac.initOverlays() | |
| 540 | 540 | tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 541 | 541 | tb.stableID = string(id) + "-tab" | |
| 542 | - | sess := &session{sessionCore: sessionCore{id: id, name: string(id), client: ac}, tabs: []*tab{tb}, active: 0} | |
| 542 | + | sess := &session{sessionCore: sessionCore{id: id, name: string(id), attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}} | |
| 543 | 543 | publishTiledPaneOwners(sess, tb) | |
| 544 | 544 | ac.setSession(sess) | |
| 545 | 545 | d.sessions[id] = sess | |
| @@ -555,7 +555,7 @@ func TestRemoteRefreshUpdatesAllOpenPickersPreservingSelection(t *testing.T) { | |||
| 555 | 555 | ||
| 556 | 556 | for _, owner := range []*attachedClient{first, second} { | |
| 557 | 557 | owner.overlays.pickerMu.Lock() | |
| 558 | - | owner.overlays.picker = d.newPickerModel(firstSession, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: "second", TabID: "second-tab"}) | |
| 558 | + | owner.overlays.picker = d.newPickerModel(firstSession, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: "second", TabID: "second-tab"}) | |
| 559 | 559 | owner.overlays.pickerIntent = pickerNavigate | |
| 560 | 560 | owner.overlays.pickerGeneration++ | |
| 561 | 561 | instance := remotePickerInstance{ac: owner, generation: owner.overlays.pickerGeneration, model: owner.overlays.picker} | |
| @@ -600,7 +600,7 @@ func TestRemotePickerTeardownLifecycle(t *testing.T) { | |||
| 600 | 600 | d := newRemotePickerDaemon(nil) | |
| 601 | 601 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 602 | 602 | ac.resumeCapable = test.resumeCapable | |
| 603 | - | model := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | |
| 603 | + | model := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | |
| 604 | 604 | d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{}) | |
| 605 | 605 | ac.overlays.pickerMu.Lock() | |
| 606 | 606 | generation := ac.overlays.pickerGeneration | |
| @@ -639,7 +639,7 @@ func TestParkedPickerExpiryClosesCapturedGenerationAndCancelsRefresh(t *testing. | |||
| 639 | 639 | d.clock = clock | |
| 640 | 640 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 641 | 641 | ac.resumeCapable = true | |
| 642 | - | d.publishPicker(sess, ac, d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{}) | |
| 642 | + | d.publishPicker(sess, ac, d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{}) | |
| 643 | 643 | request := receiveRemotePicker(t, catalog.requests, "catalog request") | |
| 644 | 644 | ||
| 645 | 645 | require.True(t, d.parkAttachment(sess, ac)) | |
| @@ -659,7 +659,7 @@ func TestParkedPickerRetirementClosesCapturedGenerationOnly(t *testing.T) { | |||
| 659 | 659 | d := newRemotePickerDaemon(nil) | |
| 660 | 660 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 661 | 661 | ac.resumeCapable = true | |
| 662 | - | d.publishPicker(sess, ac, d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{}) | |
| 662 | + | d.publishPicker(sess, ac, d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{}) | |
| 663 | 663 | d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1}) | |
| 664 | 664 | ac.overlays.pickerMu.Lock() | |
| 665 | 665 | refreshed := ac.overlays.picker | |
| @@ -686,10 +686,10 @@ func TestParkedPickerTokenReplacementRetiresPreviousGeneration(t *testing.T) { | |||
| 686 | 686 | firstSession, first, _ := addRemoteRefreshPickerOwner(t, d, "first") | |
| 687 | 687 | secondSession, second, _ := addRemoteRefreshPickerOwner(t, d, "second") | |
| 688 | 688 | first.resumeCapable, second.resumeCapable = true, true | |
| 689 | - | d.publishPicker(firstSession, first, d.newPickerModel(firstSession, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{}) | |
| 689 | + | d.publishPicker(firstSession, first, d.newPickerModel(firstSession, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{}) | |
| 690 | 690 | require.True(t, d.parkAttachment(firstSession, first)) | |
| 691 | 691 | token := first.resumeToken | |
| 692 | - | d.publishPicker(secondSession, second, d.newPickerModel(secondSession, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{}) | |
| 692 | + | d.publishPicker(secondSession, second, d.newPickerModel(secondSession, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{}) | |
| 693 | 693 | second.resumeToken = token | |
| 694 | 694 | require.True(t, d.parkAttachment(secondSession, second)) | |
| 695 | 695 | ||
| @@ -706,7 +706,7 @@ func TestParkedPickerResumePreservesGeneration(t *testing.T) { | |||
| 706 | 706 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 707 | 707 | ac.resumeCapable = true | |
| 708 | 708 | ac.clientID = [16]byte{1, 2, 3, 4} | |
| 709 | - | d.publishPicker(sess, ac, d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{}) | |
| 709 | + | d.publishPicker(sess, ac, d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{}) | |
| 710 | 710 | ac.overlays.pickerMu.Lock() | |
| 711 | 711 | model, generation := ac.overlays.picker, ac.overlays.pickerGeneration | |
| 712 | 712 | ac.overlays.pickerMu.Unlock() | |
| @@ -728,7 +728,7 @@ func TestStaleParkedPickerExpiryPreservesNewGeneration(t *testing.T) { | |||
| 728 | 728 | d := newRemotePickerDaemon(nil) | |
| 729 | 729 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 730 | 730 | ac.resumeCapable = true | |
| 731 | - | d.publishPicker(sess, ac, d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{}) | |
| 731 | + | d.publishPicker(sess, ac, d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{}) | |
| 732 | 732 | require.True(t, d.parkAttachment(sess, ac)) | |
| 733 | 733 | token := ac.resumeToken | |
| 734 | 734 | d.mu.Lock() | |
| @@ -736,7 +736,7 @@ func TestStaleParkedPickerExpiryPreservesNewGeneration(t *testing.T) { | |||
| 736 | 736 | d.mu.Unlock() | |
| 737 | 737 | require.NotNil(t, parked) | |
| 738 | 738 | ||
| 739 | - | newer := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | |
| 739 | + | newer := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | |
| 740 | 740 | d.publishPicker(sess, ac, newer, pickerNavigate, moveSourceLocator{}) | |
| 741 | 741 | d.expireParked(token, parked) | |
| 742 | 742 | ac.overlays.pickerMu.Lock() | |
| @@ -752,7 +752,7 @@ func TestRemoteRefreshPreservesRemoteCursorAcrossCacheToProxyUpgrade(t *testing. | |||
| 752 | 752 | Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: key.Name, State: "running"}}, | |
| 753 | 753 | }}) | |
| 754 | 754 | owner.overlays.pickerMu.Lock() | |
| 755 | - | owner.overlays.picker = d.newPickerModel(ownerSession, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key}) | |
| 755 | + | owner.overlays.picker = d.newPickerModel(ownerSession, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key}) | |
| 756 | 756 | owner.overlays.pickerIntent = pickerNavigate | |
| 757 | 757 | before, ok := owner.overlays.picker.Cursor() | |
| 758 | 758 | _, beforeSelectable := owner.overlays.picker.Selected() | |
| @@ -861,44 +861,10 @@ func TestRemoteRefreshCannotStartAfterLastPickerCloseWins(t *testing.T) { | |||
| 861 | 861 | } | |
| 862 | 862 | } | |
| 863 | 863 | ||
| 864 | - | func TestRemotePickerPublishCannotRegisterAfterSnatchClear(t *testing.T) { | |
| 865 | - | d := newRemotePickerDaemon(nil) | |
| 866 | - | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 867 | - | ac.roleGeneration.Store(1) | |
| 868 | - | model := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | |
| 869 | - | registrationReached := make(chan struct{}) | |
| 870 | - | allowRegistration := make(chan struct{}) | |
| 871 | - | registrationWait := make(chan error, 1) | |
| 872 | - | ac.overlays.beforeRemotePickerRegistration = func() { | |
| 873 | - | close(registrationReached) | |
| 874 | - | registrationWait <- waitRemotePickerClose(allowRegistration, "picker registration release") | |
| 875 | - | } | |
| 876 | - | published := make(chan struct{}) | |
| 877 | - | go func() { | |
| 878 | - | d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{}) | |
| 879 | - | close(published) | |
| 880 | - | }() | |
| 881 | - | receiveRemotePickerClose(t, registrationReached, "picker registration") | |
| 882 | - | ||
| 883 | - | token := attachmentRoleToken{ | |
| 884 | - | sess: sess, ac: ac, role: attachmentSnatched, | |
| 885 | - | generation: 1, transport: ac.transportSnapshot(), | |
| 886 | - | } | |
| 887 | - | require.True(t, d.clearForSnatch(token)) | |
| 888 | - | close(allowRegistration) | |
| 889 | - | require.NoError(t, receiveRemotePicker(t, registrationWait, "picker registration release")) | |
| 890 | - | receiveRemotePickerClose(t, published, "picker publication") | |
| 891 | - | ||
| 892 | - | d.remoteCatalog.mu.Lock() | |
| 893 | - | _, registered := d.remoteCatalog.pickers[ac] | |
| 894 | - | d.remoteCatalog.mu.Unlock() | |
| 895 | - | require.False(t, registered, "a picker cleared before delayed registration must not become a refresh owner") | |
| 896 | - | } | |
| 897 | - | ||
| 898 | 864 | func TestRemotePickerStaleAfterCloseCannotRemoveReopenedPicker(t *testing.T) { | |
| 899 | 865 | d := newRemotePickerDaemon(nil) | |
| 900 | 866 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 901 | - | first := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | |
| 867 | + | first := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | |
| 902 | 868 | d.publishPicker(sess, ac, first, pickerNavigate, moveSourceLocator{}) | |
| 903 | 869 | ||
| 904 | 870 | staleClose := d.pickerListInputState(ac) | |
| @@ -906,7 +872,7 @@ func TestRemotePickerStaleAfterCloseCannotRemoveReopenedPicker(t *testing.T) { | |||
| 906 | 872 | staleClose.closeLocked() | |
| 907 | 873 | ac.overlays.pickerMu.Unlock() | |
| 908 | 874 | ||
| 909 | - | reopened := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | |
| 875 | + | reopened := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | |
| 910 | 876 | d.publishPicker(sess, ac, reopened, pickerNavigate, moveSourceLocator{}) | |
| 911 | 877 | staleClose.afterClose() | |
| 912 | 878 | ||
| @@ -922,7 +888,7 @@ func TestRemotePickerStaleAfterCloseCannotRemoveReopenedPicker(t *testing.T) { | |||
| 922 | 888 | func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) { | |
| 923 | 889 | d := newRemotePickerDaemon(nil) | |
| 924 | 890 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 925 | - | first := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | |
| 891 | + | first := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | |
| 926 | 892 | d.publishPicker(sess, ac, first, pickerNavigate, moveSourceLocator{}) | |
| 927 | 893 | ||
| 928 | 894 | rebuildReached := make(chan struct{}) | |
| @@ -940,7 +906,7 @@ func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) { | |||
| 940 | 906 | receiveRemotePickerClose(t, rebuildReached, "picker rebuild") | |
| 941 | 907 | ||
| 942 | 908 | d.closePicker(ac) | |
| 943 | - | reopened := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | |
| 909 | + | reopened := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | |
| 944 | 910 | d.publishPicker(sess, ac, reopened, pickerNavigate, moveSourceLocator{}) | |
| 945 | 911 | close(allowPublication) | |
| 946 | 912 | require.NoError(t, receiveRemotePicker(t, publicationWait, "picker publication release")) | |
M internal/usecase/daemon/render.go +24 -25
| @@ -26,8 +26,8 @@ func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool { | |||
| 26 | 26 | return false | |
| 27 | 27 | } | |
| 28 | 28 | sess.mu.Lock() | |
| 29 | - | active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb | |
| 30 | - | attached := sess.client != nil | |
| 29 | + | active := sess.attachmentViewsTabLocked(tb) | |
| 30 | + | attached := len(sess.attachments) != 0 | |
| 31 | 31 | sess.mu.Unlock() | |
| 32 | 32 | ||
| 33 | 33 | // The normal attached render path needs no cross-session picker lookup. | |
| @@ -63,17 +63,16 @@ func (d *Daemon) tabIsPickerPreview(tb *tab) bool { | |||
| 63 | 63 | sessions := localSessionsSnapshot(d.sessions) | |
| 64 | 64 | d.mu.Unlock() | |
| 65 | 65 | for _, sess := range sessions { | |
| 66 | - | sess.mu.Lock() | |
| 67 | - | ac := sess.client | |
| 68 | - | sess.mu.Unlock() | |
| 69 | - | if ac == nil || ac.overlays == nil { | |
| 70 | - | continue | |
| 71 | - | } | |
| 72 | - | ac.overlays.pickerMu.Lock() | |
| 73 | - | preview := ac.overlays.pickerPreview == tb | |
| 74 | - | ac.overlays.pickerMu.Unlock() | |
| 75 | - | if preview { | |
| 76 | - | return true | |
| 66 | + | for _, ac := range sess.snapshotAttachments() { | |
| 67 | + | if ac == nil || ac.overlays == nil { | |
| 68 | + | continue | |
| 69 | + | } | |
| 70 | + | ac.overlays.pickerMu.Lock() | |
| 71 | + | preview := ac.overlays.pickerPreview == tb | |
| 72 | + | ac.overlays.pickerMu.Unlock() | |
| 73 | + | if preview { | |
| 74 | + | return true | |
| 75 | + | } | |
| 77 | 76 | } | |
| 78 | 77 | } | |
| 79 | 78 | return false | |
| @@ -259,9 +258,9 @@ func (d *Daemon) observeRuntime(kind ports.RuntimeMarkKind, bytes uint64, valid | |||
| 259 | 258 | // paint owns attachment sendMu while it captures, composes, and emits; JSONL | |
| 260 | 259 | // observer I/O must therefore happen only after that ownership is released. | |
| 261 | 260 | type runtimeMarkBatch struct { | |
| 262 | - | observer ports.RuntimeObserver | |
| 263 | - | marks []ports.RuntimeMark | |
| 264 | - | roleEffect *roleEffectTicket | |
| 261 | + | observer ports.RuntimeObserver | |
| 262 | + | marks []ports.RuntimeMark | |
| 263 | + | attachmentEffect *attachmentEffectTicket | |
| 265 | 264 | } | |
| 266 | 265 | ||
| 267 | 266 | func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch { | |
| @@ -305,22 +304,22 @@ func (b *runtimeMarkBatch) flush() { | |||
| 305 | 304 | func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, lease *attachmentLease) { | |
| 306 | 305 | // Local sessions retain their tab/PTY preparation below. Proxy sessions | |
| 307 | 306 | // enter the same attachment pipeline but supply their VT snapshot through | |
| 308 | - | // attachmentSession.capturePrimary. | |
| 307 | + | // attachmentSession.captureRenderState. | |
| 309 | 308 | sess, local := localSession(entry) | |
| 310 | 309 | marks := d.newRuntimeMarkBatch() | |
| 311 | 310 | if lease != nil { | |
| 312 | 311 | token := attachmentToken(entry, ac, ac.transport()) | |
| 313 | 312 | token.lease = lease | |
| 314 | - | ticket, admitted := ac.beginRoleEffect(token) | |
| 313 | + | ticket, admitted := ac.beginAttachmentEffect(token) | |
| 315 | 314 | if !admitted { | |
| 316 | 315 | return | |
| 317 | 316 | } | |
| 318 | - | marks.roleEffect = ticket | |
| 317 | + | marks.attachmentEffect = ticket | |
| 319 | 318 | defer ticket.End() | |
| 320 | 319 | } | |
| 321 | 320 | var tb *tab | |
| 322 | 321 | if local { | |
| 323 | - | tb = sess.activeTab() | |
| 322 | + | tb = sess.tabForAttachment(ac) | |
| 324 | 323 | if tb == nil { | |
| 325 | 324 | return | |
| 326 | 325 | } | |
| @@ -331,7 +330,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, | |||
| 331 | 330 | // published identity while holding it so a deadline captured before an | |
| 332 | 331 | // attach/replace cannot emit on either the old or new output chain. | |
| 333 | 332 | entry.core().mu.Lock() | |
| 334 | - | owned := entry.core().client == ac | |
| 333 | + | _, owned := entry.core().attachments[ac] | |
| 335 | 334 | entry.core().mu.Unlock() | |
| 336 | 335 | if !owned || ac.currentAttachmentSession() != entry { | |
| 337 | 336 | ac.sendMu.Unlock() | |
| @@ -382,12 +381,12 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, | |||
| 382 | 381 | if statusFeedback == "" && overlays.resizeActive { | |
| 383 | 382 | statusFeedback = "resize: h/j/k/l or arrows · = equalize · q/esc/enter exit" | |
| 384 | 383 | } | |
| 385 | - | bars := d.barStateForAttachmentPaletteHints(entry, statusFeedback, overlays.paletteHints, overlays.paletteRecent) | |
| 384 | + | bars := d.barStateForAttachmentPaletteHintsFor(entry, ac, statusFeedback, overlays.paletteHints, overlays.paletteRecent) | |
| 386 | 385 | applied := ac.getAppliedTheme() | |
| 387 | 386 | bars.theme = applied.Raw | |
| 388 | 387 | if local { | |
| 389 | 388 | attentionVisible := pulseVisible(bars.attentionFrame) | |
| 390 | - | repaintAttachedClients = sess.ackAttention(tb, attentionVisible) | |
| 389 | + | repaintAttachedClients = sess.ackAttention(tb, ac, attentionVisible) | |
| 391 | 390 | } | |
| 392 | 391 | ||
| 393 | 392 | paletteCfg := d.currentPaletteConfig() | |
| @@ -405,7 +404,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, | |||
| 405 | 404 | hasFloating := tb.floating.state == floatingVisible && floating != nil | |
| 406 | 405 | tb.mu.Unlock() | |
| 407 | 406 | for _, id := range titleIDs { | |
| 408 | - | d.refreshPaneTitle(sess, id) | |
| 407 | + | d.refreshPaneTitle(sess, id, tb) | |
| 409 | 408 | } | |
| 410 | 409 | if hasFloating { | |
| 411 | 410 | d.refreshPaneDisplayTitle(sess, floating, false) | |
| @@ -418,7 +417,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, | |||
| 418 | 417 | resizeActive: overlays.resizeActive, statusFeedback: statusFeedback, | |
| 419 | 418 | } | |
| 420 | 419 | endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0) | |
| 421 | - | state, ok := entry.capturePrimary(ac, primaryCaptureRequest{ | |
| 420 | + | state, ok := entry.captureRenderState(ac, renderCaptureRequest{ | |
| 422 | 421 | bars: bars, | |
| 423 | 422 | overlays: capturedOverlays, | |
| 424 | 423 | preview: preview, | |
M internal/usecase/daemon/render_capture.go +21 -14
| @@ -182,7 +182,7 @@ func uncertainDamage(damage []renderer.Damage, width, height int) bool { | |||
| 182 | 182 | return false | |
| 183 | 183 | } | |
| 184 | 184 | ||
| 185 | - | type primaryCaptureRequest struct { | |
| 185 | + | type renderCaptureRequest struct { | |
| 186 | 186 | bars barState | |
| 187 | 187 | overlays capturedOverlayRenderState | |
| 188 | 188 | preview picker.Preview | |
| @@ -193,14 +193,14 @@ type primaryCaptureRequest struct { | |||
| 193 | 193 | lease *attachmentLease | |
| 194 | 194 | } | |
| 195 | 195 | ||
| 196 | - | // captureLocalPrimaryRenderState is the ownership boundary for a local primary | |
| 196 | + | // captureLocalRenderState is the ownership boundary for a local attachment | |
| 197 | 197 | // render transaction. Callers hold attachment sendMu; this function then follows | |
| 198 | 198 | // session -> tab -> pane lock order. ACK-blocked capture returns before touching | |
| 199 | 199 | // VT damage, and every captured pane records a receipt for successful emission. | |
| 200 | - | func captureLocalPrimaryRenderState( | |
| 200 | + | func captureLocalRenderState( | |
| 201 | 201 | sess *session, | |
| 202 | 202 | ac *attachedClient, | |
| 203 | - | request primaryCaptureRequest, | |
| 203 | + | request renderCaptureRequest, | |
| 204 | 204 | ) (*capturedRenderState, bool) { | |
| 205 | 205 | bars := request.bars | |
| 206 | 206 | overlays := request.overlays | |
| @@ -212,12 +212,18 @@ func captureLocalPrimaryRenderState( | |||
| 212 | 212 | return nil, false | |
| 213 | 213 | } | |
| 214 | 214 | sess.mu.Lock() | |
| 215 | - | if sess.client != ac || sess.active < 0 || sess.active >= len(sess.tabs) { | |
| 216 | - | sess.mu.Unlock() | |
| 215 | + | _, owned := sess.attachments[ac] | |
| 216 | + | sess.mu.Unlock() | |
| 217 | + | if !owned { | |
| 218 | + | return nil, false | |
| 219 | + | } | |
| 220 | + | tb, focusedPane := sess.paneForAttachment(ac) | |
| 221 | + | if tb == nil { | |
| 222 | + | tb = sess.tabForAttachment(ac) | |
| 223 | + | } | |
| 224 | + | if tb == nil { | |
| 217 | 225 | return nil, false | |
| 218 | 226 | } | |
| 219 | - | tb := sess.tabs[sess.active] | |
| 220 | - | sess.mu.Unlock() | |
| 221 | 227 | ||
| 222 | 228 | scratch := &ac.renderScratch | |
| 223 | 229 | scratch.statusTabs = append(scratch.statusTabs[:0], bars.status.tabs...) | |
| @@ -238,8 +244,8 @@ func captureLocalPrimaryRenderState( | |||
| 238 | 244 | defer tb.mu.Unlock() | |
| 239 | 245 | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | |
| 240 | 246 | var focus layout.PaneID | |
| 241 | - | if tb.tree != nil { | |
| 242 | - | focus = tb.tree.Focus | |
| 247 | + | if focusedPane != nil && tb.panes[focusedPane.id] == focusedPane { | |
| 248 | + | focus = focusedPane.id | |
| 243 | 249 | } | |
| 244 | 250 | layoutSnap := tabLayoutSnapshot{ | |
| 245 | 251 | area: area, focus: focus, placements: scratch.placements, | |
| @@ -247,6 +253,7 @@ func captureLocalPrimaryRenderState( | |||
| 247 | 253 | } | |
| 248 | 254 | if scratch.layoutTab != tb || scratch.layoutGeneration != tb.layoutGeneration || scratch.layoutArea != area || scratch.layoutFocus != focus { | |
| 249 | 255 | layoutSnap = solveTabLayoutLocked(tb) | |
| 256 | + | layoutSnap.focus = focus | |
| 250 | 257 | scratch.placements = append(scratch.placements[:0], layoutSnap.placements...) | |
| 251 | 258 | scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...) | |
| 252 | 259 | scratch.layoutTab = tb | |
| @@ -324,10 +331,10 @@ func captureLocalPrimaryRenderState( | |||
| 324 | 331 | return state, true | |
| 325 | 332 | } | |
| 326 | 333 | ||
| 327 | - | // capturePrimaryRenderState preserves the focused local test seam. Production | |
| 328 | - | // rendering delegates through attachmentSession.capturePrimary. | |
| 329 | - | func capturePrimaryRenderState(sess *session, ac *attachedClient, request primaryCaptureRequest) (*capturedRenderState, bool) { | |
| 330 | - | return captureLocalPrimaryRenderState(sess, ac, request) | |
| 334 | + | // captureRenderState preserves the focused local test seam. Production | |
| 335 | + | // rendering delegates through attachmentSession.captureRenderState. | |
| 336 | + | func captureRenderState(sess *session, ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) { | |
| 337 | + | return captureLocalRenderState(sess, ac, request) | |
| 331 | 338 | } | |
| 332 | 339 | ||
| 333 | 340 | // copyRankedRecentInto preserves the non-nil empty slice that selects | |
M internal/usecase/daemon/render_coordinator.go +395 -169
| @@ -21,13 +21,27 @@ type renderCoordinator struct { | |||
| 21 | 21 | // enqueue until fire consumes the coalesced work. | |
| 22 | 22 | queueMarked bool | |
| 23 | 23 | queueCorrelation ports.RuntimeCorrelation | |
| 24 | - | // pendingPreview is accounted separately from the target primary wake: | |
| 25 | - | // viewers may consume a coalesced target snapshot while that target is | |
| 24 | + | // pendingPreview is accounted separately from the attachment wake: | |
| 25 | + | // viewers may consume a coalesced target snapshot while output is | |
| 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 | |
| @@ -40,17 +54,16 @@ type renderCoordinator struct { | |||
| 40 | 54 | // batch perpetually reset its own timer. | |
| 41 | 55 | outputPressure int | |
| 42 | 56 | ||
| 43 | - | // previewWake receives the same coalesced wakes for the legacy single | |
| 44 | - | // subscriber. previewWakes tracks picker subscriptions by viewer, so one | |
| 45 | - | // inactive session cannot replace another viewer's live preview. | |
| 46 | - | previewWake func(renderWake) | |
| 57 | + | // previewWakes tracks picker subscriptions by viewer, so one inactive session | |
| 58 | + | // cannot replace another viewer's live preview. | |
| 47 | 59 | previewWakes map[*attachedClient]previewSubscription | |
| 48 | 60 | ||
| 49 | - | // lease is the sole primary attachment lifecycle state. A revoked lease is | |
| 50 | - | // retained after detach so headless preview delivery remains distinguishable | |
| 51 | - | // from an unbound test coordinator. | |
| 52 | - | lease *attachmentLease | |
| 53 | - | torndown bool | |
| 61 | + | // attachments and leases are coordinator-owned lifecycle snapshots. Shared | |
| 62 | + | // render work is not tied to any one attachment; leases only fence callbacks | |
| 63 | + | // for the attachment that captured them. | |
| 64 | + | attachments map[*attachedClient]struct{} | |
| 65 | + | leases map[*attachedClient]*attachmentLease | |
| 66 | + | torndown bool | |
| 54 | 67 | ||
| 55 | 68 | resize resizeRequestMetadata | |
| 56 | 69 | // Each lane owns one complete immutable worker token at a time. | |
| @@ -107,13 +120,13 @@ func (c *renderCoordinator) detachSyncTimersLocked() []*timerToken { | |||
| 107 | 120 | ||
| 108 | 121 | // invalidate publishes a coordinator-owned producer transition. Callers that | |
| 109 | 122 | // carry an attachment identity must use invalidateForAttachment instead. | |
| 110 | - | func (c *renderCoordinator) invalidate(inv renderInvalidation) { | |
| 111 | - | c.invalidateForAttachment(nil, inv) | |
| 123 | + | func (c *renderCoordinator) invalidate(inv renderInvalidation) bool { | |
| 124 | + | return c.invalidateForAttachment(nil, inv) | |
| 112 | 125 | } | |
| 113 | 126 | ||
| 114 | - | // invalidateForAttachment publishes only while source is still the bound | |
| 115 | - | // attachment. This check shares c.mu with replacement, making a stale | |
| 116 | - | // attachment's timer callback unable to enqueue work for its replacement. | |
| 127 | + | // invalidateForAttachment publishes only while source is still registered. | |
| 128 | + | // This check shares c.mu with attachment lifecycle changes, making a stale | |
| 129 | + | // timer callback unable to enqueue work for a newer attachment incarnation. | |
| 117 | 130 | // A nil source represents an internal pane/session invalidation. | |
| 118 | 131 | func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv renderInvalidation) bool { | |
| 119 | 132 | return c.invalidateForAttachmentAtResizeEpoch(source, 0, inv) | |
| @@ -155,17 +168,21 @@ type renderInvalidationReservation struct { | |||
| 155 | 168 | func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) (*renderInvalidationReservation, bool) { | |
| 156 | 169 | c.mu.Lock() | |
| 157 | 170 | defer c.mu.Unlock() | |
| 158 | - | detachedPreviewOnly := c.primaryDetachedLocked() && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0) | |
| 159 | 171 | if c.torndown || (lease != nil && (!c.leaseCurrentLocked(lease, true) || lease.attachment != source)) || | |
| 160 | - | (c.primaryDetachedLocked() && !detachedPreviewOnly) || (source != nil && c.lease != nil && c.lease.attachment != source) || | |
| 172 | + | (source != nil && !c.attachmentRegisteredLocked(source)) || | |
| 161 | 173 | (epoch != 0 && (c.resize.epoch != epoch || c.resize.source != source || (lease != nil && c.resize.lease != lease))) { | |
| 162 | 174 | return nil, false | |
| 163 | 175 | } | |
| 164 | 176 | reservation := &renderInvalidationReservation{coordinator: c, invalidation: inv, onInvalidate: c.opts.onInvalidate} | |
| 165 | 177 | c.metrics.invalidations.Add(1) | |
| 178 | + | c.pendingGeneration++ | |
| 179 | + | if c.pendingGeneration == 0 { | |
| 180 | + | c.pendingGeneration = 1 | |
| 181 | + | } | |
| 166 | 182 | wasPending, wasUrgent, wasPreviewPending := c.pending, c.pendingUrgent, c.pendingPreview | |
| 167 | 183 | if !wasPending { | |
| 168 | 184 | c.ackDeferred = false | |
| 185 | + | c.ackDeferredFor = nil | |
| 169 | 186 | c.deadlineDue = false | |
| 170 | 187 | if c.opts.observer != nil { | |
| 171 | 188 | c.queueMarked = true | |
| @@ -175,11 +192,26 @@ func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpoch(source *att | |||
| 175 | 192 | } | |
| 176 | 193 | } | |
| 177 | 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 | + | } | |
| 178 | 210 | c.pendingReset = c.pendingReset || inv.reset | |
| 179 | 211 | c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent | |
| 180 | - | c.pendingPreview = c.pendingPreview || c.previewWake != nil || len(c.previewWakes) != 0 | |
| 212 | + | c.pendingPreview = c.pendingPreview || len(c.previewWakes) != 0 | |
| 181 | 213 | c.coalesced++ | |
| 182 | - | reservation.arm = !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue | |
| 214 | + | reservation.arm = !wasPending || !c.armed || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue | |
| 183 | 215 | if reservation.arm { | |
| 184 | 216 | _, reservation.old = c.normalLane.replaceLocked() | |
| 185 | 217 | } | |
| @@ -240,8 +272,8 @@ func (r *renderInvalidationReservation) finish() { | |||
| 240 | 272 | } | |
| 241 | 273 | ||
| 242 | 274 | // invalidateForLeaseAtResizeEpoch is the attachment-callback variant: it | |
| 243 | - | // rejects a same-object replacement whose source pointer still matches but | |
| 244 | - | // whose lifecycle lease has been revoked. | |
| 275 | + | // rejects a callback whose source pointer still matches but whose lifecycle | |
| 276 | + | // lease has been revoked. | |
| 245 | 277 | func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) bool { | |
| 246 | 278 | reservation, ok := c.reserveInvalidationForLeaseAtResizeEpoch(source, lease, epoch, inv) | |
| 247 | 279 | if !ok { | |
| @@ -252,20 +284,25 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie | |||
| 252 | 284 | } | |
| 253 | 285 | ||
| 254 | 286 | // notifyAck reports that the client acknowledged an output state, releasing | |
| 255 | - | // at most one deadline/watchdog-deferred wake. It never bypasses an unexpired | |
| 256 | - | // normal or urgent deadline. | |
| 287 | + | // at most one attachment's blocked wake. It never bypasses an unexpired normal | |
| 288 | + | // or urgent deadline. | |
| 257 | 289 | func (c *renderCoordinator) notifyAck() { c.notifyAckForLease(nil) } | |
| 258 | 290 | ||
| 259 | 291 | // notifyAckForLease prevents an acknowledgement captured by an obsolete active | |
| 260 | - | // frame from flushing the replacement attachment's render queue. A nil lease is | |
| 292 | + | // frame from flushing another attachment's render queue. A nil lease is | |
| 261 | 293 | // reserved for coordinator-owned/direct test callers. | |
| 262 | 294 | func (c *renderCoordinator) notifyAckForLease(lease *attachmentLease) { | |
| 263 | 295 | c.mu.Lock() | |
| 264 | - | if lease != nil && !c.leaseCurrentLocked(lease, true) { | |
| 296 | + | if lease != nil { | |
| 297 | + | if !c.leaseCurrentLocked(lease, true) || !c.ackDeferredFor[lease.attachment] { | |
| 298 | + | c.mu.Unlock() | |
| 299 | + | return | |
| 300 | + | } | |
| 301 | + | } else if !c.ackDeferred && !c.deadlineDue { | |
| 265 | 302 | c.mu.Unlock() | |
| 266 | 303 | return | |
| 267 | 304 | } | |
| 268 | - | if (!c.ackDeferred && !c.deadlineDue) || !c.pending { | |
| 305 | + | if !c.pending { | |
| 269 | 306 | c.mu.Unlock() | |
| 270 | 307 | return | |
| 271 | 308 | } | |
| @@ -482,16 +519,6 @@ func (c *renderCoordinator) noteSyncPaneRemoved(p *pane) { | |||
| 482 | 519 | stopDetachedTimer(worker) | |
| 483 | 520 | } | |
| 484 | 521 | ||
| 485 | - | // subscribePreview installs fn as the preview observer for coalesced wakes. | |
| 486 | - | func (c *renderCoordinator) subscribePreview(fn func(renderWake)) { | |
| 487 | - | c.mu.Lock() | |
| 488 | - | c.previewWake = fn | |
| 489 | - | c.mu.Unlock() | |
| 490 | - | } | |
| 491 | - | ||
| 492 | - | // teardownPreview removes the legacy preview subscription. | |
| 493 | - | func (c *renderCoordinator) teardownPreview() { c.mu.Lock(); c.previewWake = nil; c.mu.Unlock() } | |
| 494 | - | ||
| 495 | 522 | type previewSubscription struct { | |
| 496 | 523 | generation uint64 | |
| 497 | 524 | fn func(renderWake) | |
| @@ -519,7 +546,7 @@ func (c *renderCoordinator) subscribePreviewFor(viewer *attachedClient, generati | |||
| 519 | 546 | } | |
| 520 | 547 | ||
| 521 | 548 | // teardownPreviewFor removes viewer's observer only when it still belongs to | |
| 522 | - | // generation. A delayed teardown cannot clear a replacement subscription. | |
| 549 | + | // generation. A delayed teardown cannot clear a newer subscription. | |
| 523 | 550 | func (c *renderCoordinator) teardownPreviewFor(viewer *attachedClient, generation uint64) { | |
| 524 | 551 | c.mu.Lock() | |
| 525 | 552 | if current, ok := c.previewWakes[viewer]; ok && current.generation == generation { | |
| @@ -528,29 +555,54 @@ func (c *renderCoordinator) teardownPreviewFor(viewer *attachedClient, generatio | |||
| 528 | 555 | c.mu.Unlock() | |
| 529 | 556 | } | |
| 530 | 557 | ||
| 531 | - | // primaryDetachedLocked distinguishes an explicitly detached primary from an | |
| 532 | - | // unbound coordinator used by headless tests. Preview subscribers remain live | |
| 533 | - | // for the former, but it can never regain a primary transport without a lease. | |
| 534 | - | func (c *renderCoordinator) primaryDetachedLocked() bool { return c.lease != nil && !c.lease.active } | |
| 558 | + | func (c *renderCoordinator) attachmentRegisteredLocked(ac *attachedClient) bool { | |
| 559 | + | if ac == nil { | |
| 560 | + | return false | |
| 561 | + | } | |
| 562 | + | _, ok := c.attachments[ac] | |
| 563 | + | return ok | |
| 564 | + | } | |
| 535 | 565 | ||
| 536 | 566 | func (c *renderCoordinator) installLeaseLocked(ac *attachedClient, ready bool) *attachmentLease { | |
| 537 | - | if c.lease != nil { | |
| 538 | - | c.lease.active = false | |
| 567 | + | if ac == nil { | |
| 568 | + | return nil | |
| 539 | 569 | } | |
| 540 | - | c.lease = &attachmentLease{attachment: ac, ready: ready, active: ac != nil} | |
| 541 | - | return c.lease | |
| 570 | + | if c.attachments == nil { | |
| 571 | + | c.attachments = make(map[*attachedClient]struct{}) | |
| 572 | + | } | |
| 573 | + | if c.leases == nil { | |
| 574 | + | c.leases = make(map[*attachedClient]*attachmentLease) | |
| 575 | + | } | |
| 576 | + | c.attachments[ac] = struct{}{} | |
| 577 | + | if lease := c.leases[ac]; lease != nil && lease.active { | |
| 578 | + | lease.ready = ready | |
| 579 | + | return lease | |
| 580 | + | } | |
| 581 | + | lease := &attachmentLease{attachment: ac, ready: ready, active: true} | |
| 582 | + | c.leases[ac] = lease | |
| 583 | + | if c.delivered != nil { | |
| 584 | + | delete(c.delivered, ac) | |
| 585 | + | } | |
| 586 | + | if c.ackDeferredFor != nil { | |
| 587 | + | delete(c.ackDeferredFor, ac) | |
| 588 | + | } | |
| 589 | + | return lease | |
| 542 | 590 | } | |
| 543 | 591 | ||
| 544 | 592 | // attachmentLease returns the exact currently published lease for ac. Welcome | |
| 545 | - | // continuations capture this before their transport write and cannot later | |
| 546 | - | // bless a same-object park/resume replacement. | |
| 593 | + | // continuations capture this before their transport write and revalidate it at | |
| 594 | + | // every side-effect boundary. | |
| 547 | 595 | func (c *renderCoordinator) attachmentLease(ac *attachedClient) *attachmentLease { | |
| 548 | 596 | c.mu.Lock() | |
| 549 | 597 | defer c.mu.Unlock() | |
| 550 | - | if c.torndown || c.lease == nil || !c.lease.active || c.lease.attachment != ac { | |
| 598 | + | if c.torndown { | |
| 599 | + | return nil | |
| 600 | + | } | |
| 601 | + | lease := c.leases[ac] | |
| 602 | + | if lease == nil || !lease.active { | |
| 551 | 603 | return nil | |
| 552 | 604 | } | |
| 553 | - | return c.lease | |
| 605 | + | return lease | |
| 554 | 606 | } | |
| 555 | 607 | ||
| 556 | 608 | func (c *renderCoordinator) leaseCurrent(lease *attachmentLease, ready bool) bool { | |
| @@ -563,23 +615,45 @@ func (c *renderCoordinator) leaseCurrent(lease *attachmentLease, ready bool) boo | |||
| 563 | 615 | // must not be used to retain c.mu across input routing, transport operations, | |
| 564 | 616 | // or other arbitrary handler work. | |
| 565 | 617 | func (c *renderCoordinator) leaseCurrentLocked(lease *attachmentLease, ready bool) bool { | |
| 566 | - | return !c.torndown && lease != nil && c.lease == lease && lease.active && (!ready || lease.ready) | |
| 618 | + | return !c.torndown && lease != nil && lease.active && | |
| 619 | + | c.leases[lease.attachment] == lease && (!ready || lease.ready) | |
| 567 | 620 | } | |
| 568 | 621 | ||
| 569 | 622 | // attach binds an already-handshaken internal attachment. Route/resume use | |
| 570 | 623 | // attachWithReadiness(..., false) until their Welcome frame is accepted. | |
| 571 | 624 | func (c *renderCoordinator) attach(ac *attachedClient) { c.attachWithReadiness(ac, true) } | |
| 572 | 625 | ||
| 573 | - | func (c *renderCoordinator) attachWithReadiness(ac *attachedClient, ready bool) { | |
| 626 | + | func (c *renderCoordinator) attachWithReadiness(ac *attachedClient, ready bool) *attachmentLease { | |
| 574 | 627 | c.mu.Lock() | |
| 575 | - | if !c.torndown && (c.lease == nil || !c.lease.active || c.lease.attachment != ac) { | |
| 576 | - | c.installLeaseLocked(ac, ready) | |
| 628 | + | defer c.mu.Unlock() | |
| 629 | + | return c.attachWithReadinessLocked(ac, ready) | |
| 630 | + | } | |
| 631 | + | ||
| 632 | + | // attachWithReadinessLocked publishes one attachment while the coordinator | |
| 633 | + | // lock is already held by an ordered transition publication. | |
| 634 | + | func (c *renderCoordinator) attachWithReadinessLocked(ac *attachedClient, ready bool) *attachmentLease { | |
| 635 | + | if c.torndown { | |
| 636 | + | return nil | |
| 577 | 637 | } | |
| 578 | - | c.mu.Unlock() | |
| 638 | + | return c.installLeaseLocked(ac, ready) | |
| 639 | + | } | |
| 640 | + | ||
| 641 | + | // rebindAttachmentWithReadinessLocked retires the previous lease before | |
| 642 | + | // publishing a new one for the same attachment object. A queued wake carries | |
| 643 | + | // the old lease pointer, so reusing it would let that wake paint a rebound | |
| 644 | + | // transport. | |
| 645 | + | func (c *renderCoordinator) rebindAttachmentWithReadinessLocked(ac *attachedClient, ready bool) *attachmentLease { | |
| 646 | + | if c.torndown { | |
| 647 | + | return nil | |
| 648 | + | } | |
| 649 | + | if lease := c.leases[ac]; lease != nil { | |
| 650 | + | lease.active = false | |
| 651 | + | delete(c.leases, ac) | |
| 652 | + | } | |
| 653 | + | return c.installLeaseLocked(ac, ready) | |
| 579 | 654 | } | |
| 580 | 655 | ||
| 581 | 656 | // markAttachmentReady completes only the captured attachment incarnation. | |
| 582 | - | // A stale Welcome must never bless a replacement that reused its client object. | |
| 583 | 657 | func (c *renderCoordinator) markAttachmentReady(lease *attachmentLease) bool { | |
| 584 | 658 | c.mu.Lock() | |
| 585 | 659 | defer c.mu.Unlock() | |
| @@ -590,12 +664,13 @@ func (c *renderCoordinator) markAttachmentReady(lease *attachmentLease) bool { | |||
| 590 | 664 | return true | |
| 591 | 665 | } | |
| 592 | 666 | ||
| 593 | - | // wakeCurrent validates a dispatched wake. paint repeats this check after | |
| 594 | - | // sendMu is acquired, so a reused attachedClient cannot revive an old wake. | |
| 667 | + | // wakeCurrent validates an attachment-specific wake. Shared wakes have no | |
| 668 | + | // lease and remain valid while the coordinator is live; their paint callback | |
| 669 | + | // rechecks each attachment's connection fence. | |
| 595 | 670 | func (c *renderCoordinator) wakeCurrent(w renderWake) bool { | |
| 596 | 671 | c.mu.Lock() | |
| 597 | 672 | defer c.mu.Unlock() | |
| 598 | - | return c.leaseCurrentLocked(w.lease, true) | |
| 673 | + | return !c.torndown && (w.lease == nil || c.leaseCurrentLocked(w.lease, true)) | |
| 599 | 674 | } | |
| 600 | 675 | ||
| 601 | 676 | type renderLifecycleCleanup struct { | |
| @@ -629,12 +704,28 @@ func (c *renderCoordinator) beginDetach(ac *attachedClient) renderLifecycleClean | |||
| 629 | 704 | // beginDetachLocked is the prelocked lifecycle seam used when registry, | |
| 630 | 705 | // current-session, and lease publication must share one commit boundary. | |
| 631 | 706 | func (c *renderCoordinator) beginDetachLocked(ac *attachedClient) renderLifecycleCleanup { | |
| 632 | - | var cleanup renderLifecycleCleanup | |
| 633 | - | if c.lease != nil && c.lease.active && c.lease.attachment == ac { | |
| 634 | - | c.lease.active = false | |
| 635 | - | cleanup = c.resetAttachmentLifecycleLocked() | |
| 707 | + | if ac == nil || !c.attachmentRegisteredLocked(ac) { | |
| 708 | + | return renderLifecycleCleanup{} | |
| 636 | 709 | } | |
| 637 | - | return cleanup | |
| 710 | + | delete(c.attachments, ac) | |
| 711 | + | if lease := c.leases[ac]; lease != nil { | |
| 712 | + | lease.active = false | |
| 713 | + | delete(c.leases, ac) | |
| 714 | + | } | |
| 715 | + | delete(c.delivered, ac) | |
| 716 | + | delete(c.ackDeferredFor, ac) | |
| 717 | + | // Shared pending output belongs to the session, not the detached | |
| 718 | + | // attachment. Keep it alive while another attachment remains registered. | |
| 719 | + | if len(c.attachments) != 0 { | |
| 720 | + | var cleanup renderLifecycleCleanup | |
| 721 | + | if c.resize.source == ac { | |
| 722 | + | _, resizeTimer := c.resizeLane.replaceLocked() | |
| 723 | + | _, retryTimer := c.retryLane.replaceLocked() | |
| 724 | + | cleanup.tokens = []*timerToken{resizeTimer, retryTimer} | |
| 725 | + | } | |
| 726 | + | return cleanup | |
| 727 | + | } | |
| 728 | + | return c.resetAttachmentLifecycleLocked() | |
| 638 | 729 | } | |
| 639 | 730 | ||
| 640 | 731 | func (c *renderCoordinator) resetAttachmentLifecycleLocked() renderLifecycleCleanup { | |
| @@ -642,7 +733,11 @@ func (c *renderCoordinator) resetAttachmentLifecycleLocked() renderLifecycleClea | |||
| 642 | 733 | c.pendingReset = false | |
| 643 | 734 | c.pendingUrgent = false | |
| 644 | 735 | c.ackDeferred = false | |
| 736 | + | c.ackDeferredFor = nil | |
| 737 | + | c.delivered = nil | |
| 645 | 738 | c.pendingPreview = false | |
| 739 | + | c.pendingShared = false | |
| 740 | + | c.pendingTargets = nil | |
| 646 | 741 | c.coalesced = 0 | |
| 647 | 742 | _, timer := c.normalLane.replaceLocked() | |
| 648 | 743 | c.armed = false | |
| @@ -664,45 +759,6 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) { | |||
| 664 | 759 | c.beginDetach(ac).finish() | |
| 665 | 760 | } | |
| 666 | 761 | ||
| 667 | - | // beginReplace hands the coordinator from old to replacement without waiting | |
| 668 | - | // for detached workers. | |
| 669 | - | func (c *renderCoordinator) beginReplace(old, replacement *attachedClient, ready bool) renderLifecycleCleanup { | |
| 670 | - | c.mu.Lock() | |
| 671 | - | cleanup, _ := c.beginReplaceLocked(old, replacement, ready) | |
| 672 | - | c.mu.Unlock() | |
| 673 | - | return cleanup | |
| 674 | - | } | |
| 675 | - | ||
| 676 | - | // beginReplaceLocked requires c.mu. Callers validate canReplaceLocked before | |
| 677 | - | // their ownership commit, making lease installation non-failing afterward. | |
| 678 | - | func (c *renderCoordinator) beginReplaceLocked(old, replacement *attachedClient, ready bool) (renderLifecycleCleanup, *attachmentLease) { | |
| 679 | - | if !c.canReplaceLocked(old, replacement) { | |
| 680 | - | return renderLifecycleCleanup{}, nil | |
| 681 | - | } | |
| 682 | - | lease := c.installLeaseLocked(replacement, ready) | |
| 683 | - | return c.resetAttachmentLifecycleLocked(), lease | |
| 684 | - | } | |
| 685 | - | ||
| 686 | - | func (c *renderCoordinator) canReplaceLocked(old, replacement *attachedClient) bool { | |
| 687 | - | // A coordinator may be installed while replacing a legacy attachment which | |
| 688 | - | // predated coordinator ownership. Any inactive lease is detached historical | |
| 689 | - | // state; a resume may also replace the same client object because registry and | |
| 690 | - | // transport validation proved a new role incarnation. | |
| 691 | - | return !c.torndown && (c.lease == nil || !c.lease.active || c.lease.attachment == old || c.lease.attachment == replacement) | |
| 692 | - | } | |
| 693 | - | ||
| 694 | - | // noteReplace is for callers that hold no outer lifecycle locks. | |
| 695 | - | func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readiness ...bool) { | |
| 696 | - | ready := true | |
| 697 | - | if len(readiness) != 0 { | |
| 698 | - | ready = readiness[0] | |
| 699 | - | } | |
| 700 | - | c.beginReplace(old, replacement, ready).finish() | |
| 701 | - | } | |
| 702 | - | ||
| 703 | - | // notePark invalidates pending wakes when the attachment parks for resume. | |
| 704 | - | func (c *renderCoordinator) notePark(ac *attachedClient) { c.noteDetach(ac) } | |
| 705 | - | ||
| 706 | 762 | // beginSessionTeardown is terminal phase one. It rejects future worker | |
| 707 | 763 | // registration and atomically detaches every complete lane token. | |
| 708 | 764 | func (c *renderCoordinator) beginSessionTeardown() renderLifecycleCleanup { | |
| @@ -712,16 +768,22 @@ func (c *renderCoordinator) beginSessionTeardown() renderLifecycleCleanup { | |||
| 712 | 768 | return renderLifecycleCleanup{} | |
| 713 | 769 | } | |
| 714 | 770 | c.torndown = true | |
| 715 | - | if c.lease != nil { | |
| 716 | - | c.lease.active = false | |
| 771 | + | for ac, lease := range c.leases { | |
| 772 | + | lease.active = false | |
| 773 | + | delete(c.attachments, ac) | |
| 717 | 774 | } | |
| 718 | - | c.previewWake = nil | |
| 775 | + | c.leases = nil | |
| 776 | + | c.attachments = nil | |
| 719 | 777 | c.previewWakes = nil | |
| 720 | 778 | c.pending = false | |
| 721 | 779 | _, resizeTimer := c.resizeLane.replaceLocked() | |
| 722 | 780 | _, retryTimer := c.retryLane.replaceLocked() | |
| 723 | 781 | c.ackDeferred = false | |
| 782 | + | c.ackDeferredFor = nil | |
| 783 | + | c.delivered = nil | |
| 724 | 784 | c.pendingPreview = false | |
| 785 | + | c.pendingShared = false | |
| 786 | + | c.pendingTargets = nil | |
| 725 | 787 | _, timer := c.normalLane.replaceLocked() | |
| 726 | 788 | c.armed = false | |
| 727 | 789 | workers := c.detachSyncTimersLocked() | |
| @@ -747,7 +809,19 @@ func (c *renderCoordinator) waitForTimerWorkers() { c.supervisor.wait() } | |||
| 747 | 809 | // compositor; attached sessions always schedule through their coordinator. | |
| 748 | 810 | func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, reset bool, producer string) { | |
| 749 | 811 | if rc := attachmentRenderCoordinator(entry); rc != nil { | |
| 750 | - | rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}) | |
| 812 | + | invalidation := renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer} | |
| 813 | + | if rc.invalidateForAttachment(ac, invalidation) { | |
| 814 | + | return | |
| 815 | + | } | |
| 816 | + | // Additional attachments do not borrow another attachment's lease. | |
| 817 | + | // Their shared mutation still schedules the session wake, whose fanout | |
| 818 | + | // paints every live attachment with its own output state. | |
| 819 | + | if ac != nil && attachmentRegistered(entry, ac) && ac.currentAttachmentSession() == entry { | |
| 820 | + | if rc.invalidate(invalidation) { | |
| 821 | + | return | |
| 822 | + | } | |
| 823 | + | d.paint(entry, ac, reset, nil) | |
| 824 | + | } | |
| 751 | 825 | return | |
| 752 | 826 | } | |
| 753 | 827 | if ac != nil { | |
| @@ -760,8 +834,13 @@ func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, r | |||
| 760 | 834 | // the required first full frame never depends on a debounce timer. | |
| 761 | 835 | func (d *Daemon) invalidateRenderNow(entry attachmentSession, ac *attachedClient, reset bool, producer string) { | |
| 762 | 836 | if rc := attachmentRenderCoordinator(entry); rc != nil { | |
| 763 | - | rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}) | |
| 764 | - | rc.fireCurrent(false) | |
| 837 | + | if rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}) { | |
| 838 | + | rc.fireCurrent(false) | |
| 839 | + | return | |
| 840 | + | } | |
| 841 | + | if ac != nil && attachmentRegistered(entry, ac) && ac.currentAttachmentSession() == entry { | |
| 842 | + | d.paint(entry, ac, reset, nil) | |
| 843 | + | } | |
| 765 | 844 | return | |
| 766 | 845 | } | |
| 767 | 846 | if ac != nil { | |
| @@ -780,27 +859,73 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) { | |||
| 780 | 859 | c.fireWithTimerToken(token, gen, watchdog, deadline) | |
| 781 | 860 | } | |
| 782 | 861 | ||
| 862 | + | // fireCurrentForLease makes the selected attachment's wake synchronous. The | |
| 863 | + | // handshake must not finish before that attachment's first output is emitted, | |
| 864 | + | // while other attachments remain independently asynchronous. | |
| 865 | + | func (c *renderCoordinator) fireCurrentForLease(lease *attachmentLease) { | |
| 866 | + | c.mu.Lock() | |
| 867 | + | gen := c.normalLane.generation | |
| 868 | + | token := c.normalLane.token | |
| 869 | + | c.mu.Unlock() | |
| 870 | + | c.fireWithTimerTokenAndLease(token, gen, false, false, lease) | |
| 871 | + | } | |
| 872 | + | ||
| 783 | 873 | // fireFromTimer consumes a normal deadline using its exact immutable token. | |
| 784 | 874 | func (c *renderCoordinator) fireFromTimer(token *timerToken, watchdog, deadline bool) { | |
| 785 | 875 | c.fireWithTimerToken(token, token.generation, watchdog, deadline) | |
| 786 | 876 | } | |
| 787 | 877 | ||
| 788 | 878 | func (c *renderCoordinator) fireWithTimerToken(token *timerToken, gen uint64, watchdog, deadline bool) { | |
| 789 | - | // Readiness probes enter the attachment send path, which may in turn read | |
| 790 | - | // coordinator metadata. Never hold c.mu across that external callback. | |
| 879 | + | c.fireWithTimerTokenAndLease(token, gen, watchdog, deadline, nil) | |
| 880 | + | } | |
| 881 | + | ||
| 882 | + | func (c *renderCoordinator) attachmentAckReady(ac *attachedClient) bool { | |
| 883 | + | if c.ackReadyFor != nil { | |
| 884 | + | return c.ackReadyFor(ac) | |
| 885 | + | } | |
| 886 | + | if c.opts.ackReady != nil { | |
| 887 | + | return c.opts.ackReady() | |
| 888 | + | } | |
| 889 | + | return true | |
| 890 | + | } | |
| 891 | + | ||
| 892 | + | func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen uint64, watchdog, deadline bool, syncLease *attachmentLease) { | |
| 893 | + | // Readiness probes enter each attachment's send path, which may in turn | |
| 894 | + | // read coordinator metadata. Never hold c.mu across those callbacks. | |
| 791 | 895 | c.mu.Lock() | |
| 792 | 896 | if !c.fireValidLocked(token, gen, watchdog) { | |
| 793 | 897 | c.mu.Unlock() | |
| 794 | 898 | return | |
| 795 | 899 | } | |
| 796 | - | // Welcome holds attachment.sendMu while its transport Send is in flight. | |
| 797 | - | // Do not probe ACK capacity through that lock before this incarnation is | |
| 798 | - | // ready: a deadline that expires during the handshake must complete and | |
| 799 | - | // leave work pending, rather than wedging behind Welcome. | |
| 800 | - | handshakePending := c.lease != nil && c.lease.active && !c.lease.ready | |
| 801 | - | ackReady := c.opts.ackReady | |
| 900 | + | if c.pendingGeneration == 0 { | |
| 901 | + | c.pendingGeneration = 1 | |
| 902 | + | } | |
| 903 | + | leases := make(map[*attachedClient]*attachmentLease, len(c.leases)) | |
| 904 | + | for ac, lease := range c.leases { | |
| 905 | + | if lease != nil && lease.active { | |
| 906 | + | leases[ac] = lease | |
| 907 | + | } | |
| 908 | + | } | |
| 909 | + | previewOnly := len(leases) == 0 && len(c.previewWakes) != 0 | |
| 910 | + | legacyAckReady := c.opts.ackReady | |
| 802 | 911 | c.mu.Unlock() | |
| 803 | - | ready := !handshakePending && (ackReady == nil || ackReady()) | |
| 912 | + | ||
| 913 | + | ready := make(map[*attachmentLease]bool, len(leases)) | |
| 914 | + | if len(leases) != 0 { | |
| 915 | + | for ac, lease := range leases { | |
| 916 | + | // Welcome holds attachment.sendMu while its transport Send is in | |
| 917 | + | // flight. Do not probe an incarnation before it is ready. | |
| 918 | + | if lease.ready { | |
| 919 | + | ready[lease] = c.attachmentAckReady(ac) | |
| 920 | + | } | |
| 921 | + | } | |
| 922 | + | } else if !previewOnly && legacyAckReady != nil { | |
| 923 | + | // Keep coordinator-only/headless callers compatible; production always | |
| 924 | + | // has attachment leases and uses the per-attachment callback above. | |
| 925 | + | ready[nil] = legacyAckReady() | |
| 926 | + | } else { | |
| 927 | + | ready[nil] = true | |
| 928 | + | } | |
| 804 | 929 | ||
| 805 | 930 | // The readiness callback can detach, replace, or publish a sync batch. | |
| 806 | 931 | // syncGateOpenLocked retries predicate evaluation on every registry change | |
| @@ -818,67 +943,171 @@ func (c *renderCoordinator) fireWithTimerToken(token *timerToken, gen uint64, wa | |||
| 818 | 943 | c.mu.Unlock() | |
| 819 | 944 | return | |
| 820 | 945 | } | |
| 821 | - | // A detached target has no primary transport or ACK window. Its pending | |
| 822 | - | // state belongs exclusively to picker previews and must be consumed after | |
| 823 | - | // notification rather than accumulating ackDeferred forever. Do not pass | |
| 824 | - | // its revoked primary lease to preview consumers. | |
| 825 | - | headlessPreviewOnly := c.primaryDetachedLocked() | |
| 826 | - | lease := c.lease | |
| 827 | - | if headlessPreviewOnly { | |
| 828 | - | lease = nil | |
| 829 | - | } | |
| 830 | - | w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog, lease: lease} | |
| 831 | - | if !headlessPreviewOnly && (!ready || (c.lease != nil && c.lease.active && !c.lease.ready)) { | |
| 832 | - | var ackStart *ackBlockedSpan | |
| 833 | - | if !ready && deadline { | |
| 834 | - | c.ackDeferred = true | |
| 835 | - | if c.ackBlocked == nil && c.opts.observer != nil { | |
| 836 | - | // Install the span while coordinator state is owned. Its Start | |
| 837 | - | // publication runs unlocked, and its own handoff defers End until | |
| 838 | - | // that call has returned. | |
| 839 | - | ackStart = newACKBlockedSpan(c.opts.observer) | |
| 840 | - | c.ackBlocked = ackStart | |
| 946 | + | ||
| 947 | + | // Re-snapshot after every external readiness probe. A replacement may have | |
| 948 | + | // retired one of the leases we probed while the callback was unlocked. | |
| 949 | + | leases = make(map[*attachedClient]*attachmentLease, len(c.leases)) | |
| 950 | + | for ac, lease := range c.leases { | |
| 951 | + | if lease != nil && lease.active { | |
| 952 | + | leases[ac] = lease | |
| 953 | + | } | |
| 954 | + | } | |
| 955 | + | var attachmentLeases map[*attachedClient]*attachmentLease | |
| 956 | + | if len(leases) != 0 { | |
| 957 | + | attachmentLeases = make(map[*attachedClient]*attachmentLease, len(leases)) | |
| 958 | + | } | |
| 959 | + | blockedByAck := false | |
| 960 | + | allDelivered := true | |
| 961 | + | if len(leases) != 0 { | |
| 962 | + | for ac, lease := range leases { | |
| 963 | + | if !c.pendingShared && len(c.pendingTargets) != 0 { | |
| 964 | + | targetLease, targeted := c.pendingTargets[ac] | |
| 965 | + | if !targeted || targetLease != lease { | |
| 966 | + | continue | |
| 967 | + | } | |
| 968 | + | } | |
| 969 | + | if c.delivered[ac] == c.pendingGeneration { | |
| 970 | + | continue | |
| 971 | + | } | |
| 972 | + | allDelivered = false | |
| 973 | + | if !lease.ready { | |
| 974 | + | continue | |
| 975 | + | } | |
| 976 | + | canReady, probed := ready[lease] | |
| 977 | + | if !probed { | |
| 978 | + | continue | |
| 979 | + | } | |
| 980 | + | if canReady { | |
| 981 | + | attachmentLeases[ac] = lease | |
| 982 | + | if c.ackDeferredFor != nil { | |
| 983 | + | delete(c.ackDeferredFor, ac) | |
| 984 | + | } | |
| 985 | + | continue | |
| 841 | 986 | } | |
| 987 | + | blockedByAck = true | |
| 988 | + | if c.ackDeferredFor == nil { | |
| 989 | + | c.ackDeferredFor = make(map[*attachedClient]bool) | |
| 990 | + | } | |
| 991 | + | c.ackDeferredFor[ac] = true | |
| 992 | + | } | |
| 993 | + | } else if !ready[nil] { | |
| 994 | + | blockedByAck = true | |
| 995 | + | allDelivered = false | |
| 996 | + | } | |
| 997 | + | legacyOutput := len(leases) == 0 && ready[nil] && (c.pendingShared || len(c.pendingTargets) == 0) | |
| 998 | + | outputReady := len(attachmentLeases) != 0 || legacyOutput | |
| 999 | + | if len(leases) != 0 { | |
| 1000 | + | for ac, lease := range leases { | |
| 1001 | + | if !c.pendingShared && len(c.pendingTargets) != 0 { | |
| 1002 | + | targetLease, targeted := c.pendingTargets[ac] | |
| 1003 | + | if !targeted || targetLease != lease { | |
| 1004 | + | continue | |
| 1005 | + | } | |
| 1006 | + | } | |
| 1007 | + | if c.delivered[ac] != c.pendingGeneration { | |
| 1008 | + | allDelivered = false | |
| 1009 | + | break | |
| 1010 | + | } | |
| 1011 | + | } | |
| 1012 | + | } | |
| 1013 | + | if len(leases) == 0 && legacyOutput { | |
| 1014 | + | allDelivered = true | |
| 1015 | + | } | |
| 1016 | + | w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, attachmentLeases: attachmentLeases, watchdog: watchdog} | |
| 1017 | + | if syncLease != nil { | |
| 1018 | + | if selected, ok := attachmentLeases[syncLease.attachment]; ok && selected == syncLease { | |
| 1019 | + | w.lease = syncLease | |
| 842 | 1020 | } | |
| 843 | - | preview, previews := c.takePendingPreviewsLocked() | |
| 1021 | + | } | |
| 1022 | + | previews := c.takePendingPreviewsLocked() | |
| 1023 | + | worker := c.detachNormalTimerLocked() | |
| 1024 | + | c.armed = false | |
| 1025 | + | ||
| 1026 | + | var ackStart *ackBlockedSpan | |
| 1027 | + | if blockedByAck && deadline && c.ackBlocked == nil && c.opts.observer != nil { | |
| 1028 | + | // Install the span while coordinator state is owned. Its Start | |
| 1029 | + | // publication runs unlocked, and its own handoff defers End until that | |
| 1030 | + | // call has returned. | |
| 1031 | + | ackStart = newACKBlockedSpan(c.opts.observer) | |
| 1032 | + | c.ackBlocked = ackStart | |
| 1033 | + | } | |
| 1034 | + | c.ackDeferred = len(c.ackDeferredFor) != 0 || (len(leases) == 0 && blockedByAck && deadline) | |
| 1035 | + | if !outputReady && !allDelivered { | |
| 844 | 1036 | c.mu.Unlock() | |
| 1037 | + | stopDetachedTimer(worker) | |
| 845 | 1038 | if ackStart != nil { | |
| 846 | 1039 | ackStart.publishStart() | |
| 847 | 1040 | } | |
| 848 | - | c.notifyPreviews(w, preview, previews) | |
| 1041 | + | c.notifyPreviews(w, previews) | |
| 849 | 1042 | return | |
| 850 | 1043 | } | |
| 851 | - | preview, previews := c.takePendingPreviewsLocked() | |
| 852 | - | c.metrics.wakes.Add(1) | |
| 853 | - | c.metrics.coalesced.Add(uint64(w.coalesced)) | |
| 854 | - | if !w.urgent { | |
| 855 | - | if w.coalesced > 1 { | |
| 856 | - | c.outputPressure += w.coalesced - 1 | |
| 857 | - | if c.outputPressure > int((maxOutputRenderDeadline-minOutputRenderDeadline)/time.Millisecond) { | |
| 858 | - | c.outputPressure = int((maxOutputRenderDeadline - minOutputRenderDeadline) / time.Millisecond) | |
| 1044 | + | ||
| 1045 | + | if outputReady { | |
| 1046 | + | for ac := range attachmentLeases { | |
| 1047 | + | if c.delivered == nil { | |
| 1048 | + | c.delivered = make(map[*attachedClient]uint64) | |
| 1049 | + | } | |
| 1050 | + | c.delivered[ac] = c.pendingGeneration | |
| 1051 | + | } | |
| 1052 | + | } | |
| 1053 | + | if len(leases) != 0 { | |
| 1054 | + | allDelivered = true | |
| 1055 | + | for ac, lease := range leases { | |
| 1056 | + | if !c.pendingShared && len(c.pendingTargets) != 0 { | |
| 1057 | + | targetLease, targeted := c.pendingTargets[ac] | |
| 1058 | + | if !targeted || targetLease != lease { | |
| 1059 | + | continue | |
| 1060 | + | } | |
| 1061 | + | } | |
| 1062 | + | if c.delivered[ac] != c.pendingGeneration { | |
| 1063 | + | allDelivered = false | |
| 1064 | + | break | |
| 1065 | + | } | |
| 1066 | + | } | |
| 1067 | + | } | |
| 1068 | + | complete := allDelivered || (len(leases) == 0 && outputReady) | |
| 1069 | + | if complete { | |
| 1070 | + | c.pending, c.pendingReset, c.pendingUrgent, c.ackDeferred, c.deadlineDue, c.coalesced, c.armed = false, false, false, false, false, 0, false | |
| 1071 | + | c.ackDeferredFor = nil | |
| 1072 | + | c.delivered = nil | |
| 1073 | + | c.pendingShared = false | |
| 1074 | + | c.pendingTargets = nil | |
| 1075 | + | } | |
| 1076 | + | if outputReady { | |
| 1077 | + | c.metrics.wakes.Add(1) | |
| 1078 | + | c.metrics.coalesced.Add(uint64(w.coalesced)) | |
| 1079 | + | if !w.urgent { | |
| 1080 | + | if w.coalesced > 1 { | |
| 1081 | + | c.outputPressure += w.coalesced - 1 | |
| 1082 | + | if c.outputPressure > int((maxOutputRenderDeadline-minOutputRenderDeadline)/time.Millisecond) { | |
| 1083 | + | c.outputPressure = int((maxOutputRenderDeadline - minOutputRenderDeadline) / time.Millisecond) | |
| 1084 | + | } | |
| 1085 | + | } else if c.outputPressure > 0 { | |
| 1086 | + | c.outputPressure-- | |
| 859 | 1087 | } | |
| 860 | - | } else if c.outputPressure > 0 { | |
| 861 | - | c.outputPressure-- | |
| 862 | 1088 | } | |
| 863 | 1089 | } | |
| 864 | - | worker := c.detachNormalTimerLocked() | |
| 865 | - | c.pending, c.pendingReset, c.pendingUrgent, c.ackDeferred, c.deadlineDue, c.coalesced, c.armed = false, false, false, false, false, 0, false | |
| 866 | 1090 | observer, queueCorrelation, queueMarked := c.opts.observer, c.queueCorrelation, c.queueMarked | |
| 867 | 1091 | ackBlocked := c.ackBlocked | |
| 868 | - | c.queueMarked, c.ackBlocked = false, nil | |
| 1092 | + | if complete { | |
| 1093 | + | c.queueMarked, c.ackBlocked = false, nil | |
| 1094 | + | } | |
| 869 | 1095 | wake := c.opts.wake | |
| 870 | 1096 | c.mu.Unlock() | |
| 871 | 1097 | stopDetachedTimer(worker) | |
| 872 | - | if queueMarked && observer != nil { | |
| 1098 | + | if ackStart != nil { | |
| 1099 | + | ackStart.publishStart() | |
| 1100 | + | } | |
| 1101 | + | if complete && queueMarked && observer != nil { | |
| 873 | 1102 | observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueDequeued, 0, true)) | |
| 874 | 1103 | } | |
| 875 | - | if ackBlocked != nil { | |
| 1104 | + | if complete && ackBlocked != nil { | |
| 876 | 1105 | ackBlocked.finish(true) | |
| 877 | 1106 | } | |
| 878 | - | if wake != nil && !headlessPreviewOnly { | |
| 1107 | + | if outputReady && wake != nil { | |
| 879 | 1108 | wake(w) | |
| 880 | 1109 | } | |
| 881 | - | c.notifyPreviews(w, preview, previews) | |
| 1110 | + | c.notifyPreviews(w, previews) | |
| 882 | 1111 | } | |
| 883 | 1112 | ||
| 884 | 1113 | func (c *renderCoordinator) fireValidLocked(token *timerToken, gen uint64, watchdog bool) bool { | |
| @@ -920,24 +1149,21 @@ func (c *renderCoordinator) syncGateOpenLocked() bool { | |||
| 920 | 1149 | } | |
| 921 | 1150 | } | |
| 922 | 1151 | ||
| 923 | - | // takePendingPreviewsLocked snapshots and consumes the preview delivery for | |
| 924 | - | // this target generation. Unlike the target primary, this is never ACK-gated. | |
| 925 | - | func (c *renderCoordinator) takePendingPreviewsLocked() (func(renderWake), []func(renderWake)) { | |
| 1152 | + | // takePendingPreviewsLocked snapshots and consumes picker preview delivery | |
| 1153 | + | // for this target generation. Preview callbacks are never ACK-gated. | |
| 1154 | + | func (c *renderCoordinator) takePendingPreviewsLocked() []func(renderWake) { | |
| 926 | 1155 | if !c.pendingPreview { | |
| 927 | - | return nil, nil | |
| 1156 | + | return nil | |
| 928 | 1157 | } | |
| 929 | 1158 | c.pendingPreview = false | |
| 930 | 1159 | previews := make([]func(renderWake), 0, len(c.previewWakes)) | |
| 931 | 1160 | for _, subscription := range c.previewWakes { | |
| 932 | 1161 | previews = append(previews, subscription.fn) | |
| 933 | 1162 | } | |
| 934 | - | return c.previewWake, previews | |
| 1163 | + | return previews | |
| 935 | 1164 | } | |
| 936 | 1165 | ||
| 937 | - | func (c *renderCoordinator) notifyPreviews(w renderWake, preview func(renderWake), previews []func(renderWake)) { | |
| 938 | - | if preview != nil { | |
| 939 | - | preview(w) | |
| 940 | - | } | |
| 1166 | + | func (c *renderCoordinator) notifyPreviews(w renderWake, previews []func(renderWake)) { | |
| 941 | 1167 | for _, fn := range previews { | |
| 942 | 1168 | fn(w) | |
| 943 | 1169 | } | |
M internal/usecase/daemon/render_coordinator_handshake_test.go +132 -2
| @@ -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. | |
| @@ -32,10 +75,97 @@ func TestRenderCoordinatorFreshWakeWaitsForWelcome(t *testing.T) { | |||
| 32 | 75 | wake := awaitWake(t, h.wakes) | |
| 33 | 76 | require.True(t, wake.reset) | |
| 34 | 77 | require.Equal(t, 1, wake.coalesced) | |
| 35 | - | require.Same(t, ac, wake.lease.attachment) | |
| 78 | + | require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease") | |
| 36 | 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. | |
| @@ -45,7 +175,7 @@ func TestRenderCoordinatorParkedResumeRequiresNewWelcome(t *testing.T) { | |||
| 45 | 175 | h.rc.attachWithReadiness(ac, false) | |
| 46 | 176 | firstLease := h.rc.attachmentLease(ac) | |
| 47 | 177 | require.True(t, h.rc.markAttachmentReady(firstLease)) | |
| 48 | - | h.rc.notePark(ac) | |
| 178 | + | h.rc.noteDetach(ac) | |
| 49 | 179 | h.rc.attachWithReadiness(ac, false) | |
| 50 | 180 | resumedLease := h.rc.attachmentLease(ac) | |
| 51 | 181 | require.NotNil(t, resumedLease) | |
M internal/usecase/daemon/render_coordinator_publication_test.go +2 -1
| @@ -14,7 +14,8 @@ func TestCoordinatorRejectsStaleAttachmentInvalidationAfterReplacement(t *testin | |||
| 14 | 14 | old := &attachedClient{} | |
| 15 | 15 | replacement := &attachedClient{} | |
| 16 | 16 | rc.attach(old) | |
| 17 | - | rc.noteReplace(old, replacement) | |
| 17 | + | rc.noteDetach(old) | |
| 18 | + | rc.attach(replacement) | |
| 18 | 19 | ||
| 19 | 20 | // This models a stale resize callback that passed its sendMu generation | |
| 20 | 21 | // check immediately before a replacement was published. | |
M internal/usecase/daemon/render_coordinator_test.go +69 -69
| @@ -181,7 +181,9 @@ func (h *coordinatorHarness) armedTimers(t *testing.T) []*coordinatorMockTimer { | |||
| 181 | 181 | // deterministic contract failure, not a slow behavior to poll for. | |
| 182 | 182 | func awaitWake(t *testing.T, ch chan renderWake) renderWake { | |
| 183 | 183 | t.Helper() | |
| 184 | - | return awaitTestValue(t, ch, "coordinator did not publish a wake after fake-clock advancement") | |
| 184 | + | wake := awaitTestValue(t, ch, "coordinator did not publish a wake after fake-clock advancement") | |
| 185 | + | wake.attachmentLeases = nil | |
| 186 | + | return wake | |
| 185 | 187 | } | |
| 186 | 188 | ||
| 187 | 189 | func requireNoWake(t *testing.T, ch chan renderWake) { | |
| @@ -376,6 +378,37 @@ func TestRenderCoordinatorUrgentDeadlineCannotBeExtended(t *testing.T) { | |||
| 376 | 378 | ||
| 377 | 379 | // --- ACK gating ---------------------------------------------------------------- | |
| 378 | 380 | ||
| 381 | + | func TestRenderCoordinatorAckReadinessIsAttachmentScoped(t *testing.T) { | |
| 382 | + | slow, healthy := &attachedClient{}, &attachedClient{} | |
| 383 | + | var slowReady atomic.Bool | |
| 384 | + | slowReady.Store(false) | |
| 385 | + | wakes := make(chan renderWake, 2) | |
| 386 | + | rc := newRenderCoordinator(renderCoordinatorOptions{ | |
| 387 | + | wake: func(w renderWake) { wakes <- w }, | |
| 388 | + | }) | |
| 389 | + | rc.ackReadyFor = func(ac *attachedClient) bool { return ac == healthy || slowReady.Load() } | |
| 390 | + | rc.attach(slow) | |
| 391 | + | rc.attach(healthy) | |
| 392 | + | rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"}) | |
| 393 | + | rc.fireCurrent(false) | |
| 394 | + | ||
| 395 | + | wake := awaitTestValue(t, wakes, "healthy attachment did not receive an independent wake") | |
| 396 | + | require.Contains(t, wake.attachmentLeases, healthy) | |
| 397 | + | _, slowSelected := wake.attachmentLeases[slow] | |
| 398 | + | require.False(t, slowSelected) | |
| 399 | + | rc.mu.Lock() | |
| 400 | + | require.True(t, rc.pending, "slow attachment must retain the shared mutation until its ACK window opens") | |
| 401 | + | rc.mu.Unlock() | |
| 402 | + | ||
| 403 | + | slowReady.Store(true) | |
| 404 | + | rc.notifyAckForLease(rc.attachmentLease(slow)) | |
| 405 | + | wake = awaitTestValue(t, wakes, "slow attachment did not receive its deferred wake") | |
| 406 | + | require.Contains(t, wake.attachmentLeases, slow) | |
| 407 | + | rc.mu.Lock() | |
| 408 | + | require.False(t, rc.pending, "the shared mutation must clear after every attachment catches up") | |
| 409 | + | rc.mu.Unlock() | |
| 410 | + | } | |
| 411 | + | ||
| 379 | 412 | func TestRenderCoordinatorAckReadinessReentersWithoutBlockingResize(t *testing.T) { | |
| 380 | 413 | owner := &attachedClient{} | |
| 381 | 414 | wakes := make(chan renderWake, 1) | |
| @@ -407,8 +440,7 @@ func TestRenderCoordinatorAckReadinessReentersWithoutBlockingResize(t *testing.T | |||
| 407 | 440 | epoch := awaitTestValue(t, resizeDone, "ack readiness re-entry blocked resize metadata") | |
| 408 | 441 | require.Equal(t, uint64(1), epoch) | |
| 409 | 442 | wake := awaitWake(t, wakes) | |
| 410 | - | require.Same(t, owner, wake.lease.attachment) | |
| 411 | - | wake.lease = nil | |
| 443 | + | require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease") | |
| 412 | 444 | require.Equal(t, renderWake{coalesced: 1}, wake) | |
| 413 | 445 | awaitTestCompletion(t, fireDone, "fire did not return after wake") | |
| 414 | 446 | } | |
| @@ -749,7 +781,8 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) { | |||
| 749 | 781 | ||
| 750 | 782 | func TestRenderCoordinatorPreviewSubscription(t *testing.T) { | |
| 751 | 783 | h := newCoordinatorHarness(t) | |
| 752 | - | h.rc.subscribePreview(func(w renderWake) { h.previews <- w }) | |
| 784 | + | viewer := &attachedClient{} | |
| 785 | + | h.rc.subscribePreviewFor(viewer, 1, func(w renderWake) { h.previews <- w }) | |
| 753 | 786 | ||
| 754 | 787 | h.rc.invalidate(renderInvalidation{class: invalidateUrgent}) | |
| 755 | 788 | timers := h.armedTimers(t) | |
| @@ -760,7 +793,7 @@ func TestRenderCoordinatorPreviewSubscription(t *testing.T) { | |||
| 760 | 793 | require.Equal(t, owner, preview, "a subscribed preview observes the same coalesced wake") | |
| 761 | 794 | requireNoWake(t, h.previews) | |
| 762 | 795 | ||
| 763 | - | h.rc.teardownPreview() | |
| 796 | + | h.rc.teardownPreviewFor(viewer, 1) | |
| 764 | 797 | h.rc.invalidate(renderInvalidation{class: invalidateUrgent}) | |
| 765 | 798 | timers = h.armedTimers(t) | |
| 766 | 799 | require.NotEmpty(t, timers, "invalidate must arm a deadline timer") | |
| @@ -795,24 +828,24 @@ func TestRenderCoordinatorPreviewWakesDoNotWaitForTargetAck(t *testing.T) { | |||
| 795 | 828 | timers[0].ch <- time.Time{} | |
| 796 | 829 | ||
| 797 | 830 | preview := awaitWake(t, h.previews) | |
| 798 | - | if tc.attach { | |
| 799 | - | require.NotNil(t, preview.lease.attachment) | |
| 800 | - | } | |
| 801 | - | preview.lease = nil | |
| 802 | 831 | require.Equal(t, renderWake{coalesced: 1}, preview, | |
| 803 | 832 | "the viewer preview must receive target output without target ACK capacity") | |
| 804 | - | requireNoWake(t, h.wakes) | |
| 805 | 833 | requireNoWake(t, h.previews) | |
| 834 | + | if tc.attach { | |
| 835 | + | requireNoWake(t, h.wakes) | |
| 836 | + | } else { | |
| 837 | + | wake := awaitWake(t, h.wakes) | |
| 838 | + | require.Equal(t, renderWake{coalesced: 1}, wake) | |
| 839 | + | require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease") | |
| 840 | + | } | |
| 806 | 841 | ||
| 807 | 842 | h.ackReady.Store(true) | |
| 808 | 843 | h.rc.notifyAck() | |
| 809 | - | wake := awaitWake(t, h.wakes) | |
| 810 | 844 | if tc.attach { | |
| 811 | - | require.NotNil(t, wake.lease.attachment) | |
| 845 | + | wake := awaitWake(t, h.wakes) | |
| 846 | + | require.Equal(t, renderWake{coalesced: 1}, wake, | |
| 847 | + | "the target frame remains pending for its ACK") | |
| 812 | 848 | } | |
| 813 | - | wake.lease = nil | |
| 814 | - | require.Equal(t, renderWake{coalesced: 1}, wake, | |
| 815 | - | "the target primary frame remains pending for its own ACK") | |
| 816 | 849 | requireNoWake(t, h.previews) | |
| 817 | 850 | }) | |
| 818 | 851 | } | |
| @@ -837,9 +870,10 @@ func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *tes | |||
| 837 | 870 | require.Len(t, timers, 1) | |
| 838 | 871 | timers[0].ch <- time.Time{} | |
| 839 | 872 | preview := awaitWake(t, h.previews) | |
| 840 | - | require.Nil(t, preview.lease, "headless preview wakes must not retain a revoked primary lease") | |
| 841 | 873 | require.Equal(t, renderWake{coalesced: 1}, preview) | |
| 842 | - | requireNoWake(t, h.wakes) | |
| 874 | + | wake := awaitWake(t, h.wakes) | |
| 875 | + | require.Equal(t, renderWake{coalesced: 1}, wake) | |
| 876 | + | require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease") | |
| 843 | 877 | ||
| 844 | 878 | h.rc.mu.Lock() | |
| 845 | 879 | require.False(t, h.rc.pending) | |
| @@ -847,9 +881,7 @@ func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *tes | |||
| 847 | 881 | require.False(t, h.rc.ackDeferred) | |
| 848 | 882 | require.False(t, h.rc.deadlineDue) | |
| 849 | 883 | require.Zero(t, h.rc.coalesced) | |
| 850 | - | require.True(t, h.rc.primaryDetachedLocked()) | |
| 851 | - | require.NotNil(t, h.rc.lease) | |
| 852 | - | require.False(t, h.rc.lease.active) | |
| 884 | + | require.Empty(t, h.rc.attachments) | |
| 853 | 885 | h.rc.mu.Unlock() | |
| 854 | 886 | } | |
| 855 | 887 | ||
| @@ -863,7 +895,7 @@ func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *tes | |||
| 863 | 895 | timers[0].ch <- time.Time{} | |
| 864 | 896 | wake := awaitWake(t, h.wakes) | |
| 865 | 897 | require.True(t, wake.reset) | |
| 866 | - | require.Same(t, replacement, wake.lease.attachment) | |
| 898 | + | require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease") | |
| 867 | 899 | } | |
| 868 | 900 | ||
| 869 | 901 | func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) { | |
| @@ -872,7 +904,10 @@ func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) { | |||
| 872 | 904 | transition func(*renderCoordinator, *attachedClient, *attachedClient) | |
| 873 | 905 | }{ | |
| 874 | 906 | {"target detach", func(rc *renderCoordinator, target, _ *attachedClient) { rc.noteDetach(target) }}, | |
| 875 | - | {"target replacement", func(rc *renderCoordinator, target, replacement *attachedClient) { rc.noteReplace(target, replacement) }}, | |
| 907 | + | {"target detach and attach", func(rc *renderCoordinator, target, replacement *attachedClient) { | |
| 908 | + | rc.noteDetach(target) | |
| 909 | + | rc.attach(replacement) | |
| 910 | + | }}, | |
| 876 | 911 | {"target teardown", func(rc *renderCoordinator, _ *attachedClient, _ *attachedClient) { rc.beginSessionTeardown().finish() }}, | |
| 877 | 912 | } | |
| 878 | 913 | for _, tc := range cases { | |
| @@ -920,7 +955,6 @@ func TestRenderCoordinatorLifecycleDropsStaleWakes(t *testing.T) { | |||
| 920 | 955 | teardown func(rc *renderCoordinator, owner *attachedClient) | |
| 921 | 956 | }{ | |
| 922 | 957 | {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }}, | |
| 923 | - | {"park", func(rc *renderCoordinator, owner *attachedClient) { rc.notePark(owner) }}, | |
| 924 | 958 | {"session teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.beginSessionTeardown().finish() }}, | |
| 925 | 959 | } | |
| 926 | 960 | for _, tc := range cases { | |
| @@ -955,7 +989,7 @@ func TestRenderCoordinatorLifecycleDropsStaleWakes(t *testing.T) { | |||
| 955 | 989 | stale := h.armedTimers(t) | |
| 956 | 990 | require.NotEmpty(t, stale, "invalidate must arm a deadline timer") | |
| 957 | 991 | ||
| 958 | - | h.rc.noteReplace(owner, replacement) | |
| 992 | + | h.rc.noteDetach(owner) | |
| 959 | 993 | h.rc.attach(replacement) | |
| 960 | 994 | for _, timer := range stale { | |
| 961 | 995 | timer.ch <- time.Time{} | |
| @@ -1035,11 +1069,11 @@ func TestRenderCoordinatorResizeMetadata(t *testing.T) { | |||
| 1035 | 1069 | case "detach": | |
| 1036 | 1070 | h.rc.noteDetach(staleOwner) | |
| 1037 | 1071 | case "park": | |
| 1038 | - | h.rc.notePark(staleOwner) | |
| 1072 | + | h.rc.noteDetach(staleOwner) | |
| 1039 | 1073 | case "session teardown": | |
| 1040 | 1074 | h.rc.beginSessionTeardown().finish() | |
| 1041 | 1075 | case "replacement": | |
| 1042 | - | h.rc.noteReplace(staleOwner, freshOwner) | |
| 1076 | + | h.rc.noteDetach(staleOwner) | |
| 1043 | 1077 | h.rc.attach(freshOwner) | |
| 1044 | 1078 | require.Equal(t, uint64(2), h.rc.recordResizeRequest(sz(100, 50), freshOwner)) | |
| 1045 | 1079 | } | |
| @@ -1116,7 +1150,7 @@ func TestProducerInvalidations(t *testing.T) { | |||
| 1116 | 1150 | name: "floating toggle to visible", | |
| 1117 | 1151 | run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) { | |
| 1118 | 1152 | fp := newPaneWithStableID(layout.PaneID("floating"), "float-producer", newQuietPTY(), domain.Size{Cols: 40, Rows: 10}) | |
| 1119 | - | tb := sess.activeTab() | |
| 1153 | + | tb := testAttachmentTab(sess) | |
| 1120 | 1154 | tb.mu.Lock() | |
| 1121 | 1155 | tb.floating.pane = fp | |
| 1122 | 1156 | tb.floating.state = floatingHidden | |
| @@ -1134,7 +1168,7 @@ func TestProducerInvalidations(t *testing.T) { | |||
| 1134 | 1168 | ac.proxied = true | |
| 1135 | 1169 | sess.tabs[1].attention = true | |
| 1136 | 1170 | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionJumpAttention, nil) | |
| 1137 | - | require.Equal(t, 1, activeTabIndex(sess)) | |
| 1171 | + | require.Equal(t, 1, testAttachmentTabIndex(sess)) | |
| 1138 | 1172 | }, | |
| 1139 | 1173 | }, | |
| 1140 | 1174 | { | |
| @@ -1275,7 +1309,7 @@ func TestConcurrentPaintInitializesOverlayUnderSendOwnership(t *testing.T) { | |||
| 1275 | 1309 | func TestStartPaneGoroutinesAccountsForOneReader(t *testing.T) { | |
| 1276 | 1310 | p, release := newBlockingPTY(t) | |
| 1277 | 1311 | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 1278 | - | tb := sess.activeTab() | |
| 1312 | + | tb := testAttachmentTab(sess) | |
| 1279 | 1313 | require.NotNil(t, tb) | |
| 1280 | 1314 | d.startPaneGoroutines(sess, tb, tb.focusedPane()) | |
| 1281 | 1315 | release() | |
| @@ -1291,41 +1325,6 @@ func requireWorkerExit(t *testing.T, done <-chan struct{}) { | |||
| 1291 | 1325 | awaitTestCompletion(t, done, "cancelled coordinator timer worker did not exit") | |
| 1292 | 1326 | } | |
| 1293 | 1327 | ||
| 1294 | - | func TestCoordinatorDeadlineCannotPaintPublishedReplacementBeforeOwnershipInstall(t *testing.T) { | |
| 1295 | - | p, releasePTY := newBlockingPTY(t) | |
| 1296 | - | defer releasePTY() | |
| 1297 | - | d, sess, owner, ownerSends := newManualSessionWithPTYs(t, p) | |
| 1298 | - | replacementTransport, replacementSends := newCapturingTransport(t) | |
| 1299 | - | replacement := &attachedClient{tr: replacementTransport, output: newOutputStateStream(), size: owner.size} | |
| 1300 | - | replacement.initOverlays() | |
| 1301 | - | replacement.setSession(sess) | |
| 1302 | - | ||
| 1303 | - | clock := newCoordinatorMockClock(t, 2) | |
| 1304 | - | d.clock = clock.clock | |
| 1305 | - | rc := newRenderCoordinator(renderCoordinatorOptions{ | |
| 1306 | - | clock: clock.clock, | |
| 1307 | - | wake: func(w renderWake) { | |
| 1308 | - | // This is the production ownership boundary: composition must use | |
| 1309 | - | // the coordinator's captured attachment, never sess.client. | |
| 1310 | - | d.paint(sess, w.lease.attachment, w.reset, w.lease) | |
| 1311 | - | }, | |
| 1312 | - | ackReady: func() bool { return true }, | |
| 1313 | - | }) | |
| 1314 | - | rc.attach(owner) | |
| 1315 | - | sess.installRenderCoordinator(rc) | |
| 1316 | - | rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true}) | |
| 1317 | - | timer := awaitCoordinatorScheduledTimer(t, clock) | |
| 1318 | - | ||
| 1319 | - | // Model attachClient's publication window exactly: sess.client is new, | |
| 1320 | - | // but coordinator replacement has not yet invalidated the old deadline. | |
| 1321 | - | sess.mu.Lock() | |
| 1322 | - | sess.client = replacement | |
| 1323 | - | sess.mu.Unlock() | |
| 1324 | - | timer.ch <- time.Time{} | |
| 1325 | - | requireNoCoordinatorOutputFrame(t, replacementSends) | |
| 1326 | - | requireNoCoordinatorOutputFrame(t, ownerSends) | |
| 1327 | - | } | |
| 1328 | - | ||
| 1329 | 1328 | func TestRenderCoordinatorResizeLaneRejectsStaleToken(t *testing.T) { | |
| 1330 | 1329 | rc := newRenderCoordinator(renderCoordinatorOptions{}) | |
| 1331 | 1330 | newer := &timerToken{generation: 2, timer: &portsmocks.MockTimer{}, cancel: make(chan struct{}), done: make(chan struct{})} | |
| @@ -1388,7 +1387,6 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) { | |||
| 1388 | 1387 | run func(*renderCoordinator, *attachedClient) | |
| 1389 | 1388 | }{ | |
| 1390 | 1389 | {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }}, | |
| 1391 | - | {"park", func(rc *renderCoordinator, ac *attachedClient) { rc.notePark(ac) }}, | |
| 1392 | 1390 | } { | |
| 1393 | 1391 | t.Run(transition.name, func(t *testing.T) { | |
| 1394 | 1392 | h := newCoordinatorHarness(t) | |
| @@ -1417,10 +1415,11 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) { | |||
| 1417 | 1415 | h.rc.mu.Unlock() | |
| 1418 | 1416 | h.rc.noteSyncEnd(p, 1) | |
| 1419 | 1417 | preview := awaitWake(t, h.previews) | |
| 1420 | - | preview.lease = nil | |
| 1421 | 1418 | require.Equal(t, renderWake{urgent: true, coalesced: 1}, preview) | |
| 1422 | 1419 | requireNoWake(t, h.previews) | |
| 1423 | - | requireNoWake(t, h.wakes) | |
| 1420 | + | wake := awaitWake(t, h.wakes) | |
| 1421 | + | require.Equal(t, renderWake{urgent: true, coalesced: 1}, wake) | |
| 1422 | + | require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease") | |
| 1424 | 1423 | }) | |
| 1425 | 1424 | } | |
| 1426 | 1425 | }) | |
| @@ -1431,7 +1430,8 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) { | |||
| 1431 | 1430 | h.rc.attach(old) | |
| 1432 | 1431 | h.rc.noteSyncBegin(p, 1) | |
| 1433 | 1432 | watchdog := h.armedTimers(t)[0] | |
| 1434 | - | h.rc.noteReplace(old, replacement, false) | |
| 1433 | + | h.rc.noteDetach(old) | |
| 1434 | + | h.rc.attachWithReadiness(replacement, false) | |
| 1435 | 1435 | require.False(t, h.rc.markAttachmentReady(h.rc.attachmentLease(old)), "old attachment Welcome is stale") | |
| 1436 | 1436 | require.True(t, h.rc.markAttachmentReady(h.rc.attachmentLease(replacement))) | |
| 1437 | 1437 | ||
| @@ -1455,7 +1455,7 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) { | |||
| 1455 | 1455 | wake := awaitWake(t, h.wakes) | |
| 1456 | 1456 | require.True(t, wake.reset) | |
| 1457 | 1457 | require.True(t, wake.urgent) | |
| 1458 | - | require.Same(t, replacement, wake.lease.attachment) | |
| 1458 | + | require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease") | |
| 1459 | 1459 | requireNoWake(t, h.wakes) | |
| 1460 | 1460 | }) | |
| 1461 | 1461 | ||
M internal/usecase/daemon/render_coordinator_types.go +9 -7
| @@ -51,17 +51,19 @@ type renderWake struct { | |||
| 51 | 51 | reset bool | |
| 52 | 52 | urgent bool | |
| 53 | 53 | coalesced int | |
| 54 | - | // lease is snapshotted under coordinator ownership. The wake consumer must | |
| 55 | - | // compose only for this exact active attachment incarnation, never by | |
| 56 | - | // rereading session.client after an attach publication. | |
| 57 | - | lease *attachmentLease | |
| 54 | + | // lease is optional coordinator-owned lifecycle safety for an attachment | |
| 55 | + | // specific wake. Shared wakes leave it nil and snapshot live attachment | |
| 56 | + | // leases at dispatch time; each paint then revalidates its exact connection | |
| 57 | + | // incarnation. | |
| 58 | + | lease *attachmentLease | |
| 59 | + | attachmentLeases map[*attachedClient]*attachmentLease | |
| 58 | 60 | // watchdog marks a flush forced by the synchronized-output watchdog. | |
| 59 | 61 | watchdog bool | |
| 60 | 62 | } | |
| 61 | 63 | ||
| 62 | - | // attachmentLease is the sole lifecycle identity for a primary render | |
| 63 | - | // attachment. It is only mutated while renderCoordinator.mu is held; callbacks | |
| 64 | - | // carry its pointer and must revalidate it before every side effect. | |
| 64 | + | // attachmentLease is the lifecycle identity for one render attachment. It is | |
| 65 | + | // only mutated while renderCoordinator.mu is held; callbacks carry its pointer | |
| 66 | + | // and must revalidate it before every side effect. | |
| 65 | 67 | type attachmentLease struct { | |
| 66 | 68 | attachment *attachedClient | |
| 67 | 69 | ready bool | |
M internal/usecase/daemon/render_damage_receipt_test.go +6 -6
| @@ -27,7 +27,7 @@ func TestPrimaryCaptureAloneRecordsDamageReceipts(t *testing.T) { | |||
| 27 | 27 | p.mu.Unlock() | |
| 28 | 28 | ||
| 29 | 29 | ac.sendMu.Lock() | |
| 30 | - | state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{ | |
| 30 | + | state, ok := captureRenderState(sess, ac, renderCaptureRequest{ | |
| 31 | 31 | bars: barState{}, | |
| 32 | 32 | overlays: capturedOverlayRenderState{}, | |
| 33 | 33 | preview: pickerPreviewEmpty(), | |
| @@ -61,7 +61,7 @@ func TestCaptureComposeEmitAcknowledgesCollapsedPaneDamageOnlyAfterEmission(t *t | |||
| 61 | 61 | collapsed.screen.Write([]byte("hidden damage")) | |
| 62 | 62 | ||
| 63 | 63 | ac.sendMu.Lock() | |
| 64 | - | state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{bars: barState{}}) | |
| 64 | + | state, ok := captureRenderState(sess, ac, renderCaptureRequest{bars: barState{}}) | |
| 65 | 65 | require.True(t, ok) | |
| 66 | 66 | collapsedReceipt := false | |
| 67 | 67 | for _, receipt := range state.receipts { | |
| @@ -95,7 +95,7 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T | |||
| 95 | 95 | fail: func(_ *composedRenderFrame, ac *attachedClient) { ac.replaceTransport(cacheFailTransport{}) }, | |
| 96 | 96 | restore: func(sess *session, ac *attachedClient, healthy ports.Transport) { | |
| 97 | 97 | sess.mu.Lock() | |
| 98 | - | sess.client = ac | |
| 98 | + | sess.registerAttachmentLocked(ac) | |
| 99 | 99 | sess.mu.Unlock() | |
| 100 | 100 | ac.setSession(sess) | |
| 101 | 101 | ac.replaceTransport(healthy) | |
| @@ -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 | |
| @@ -223,7 +223,7 @@ func TestRenderDamageReceiptStaleGenerationForcesFullRedraw(t *testing.T) { | |||
| 223 | 223 | func captureComposeForReceiptTest(t *testing.T, sess *session, ac *attachedClient) (*capturedRenderState, composedRenderFrame) { | |
| 224 | 224 | t.Helper() | |
| 225 | 225 | ac.sendMu.Lock() // emitFrame releases the transaction lock. | |
| 226 | - | state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{ | |
| 226 | + | state, ok := captureRenderState(sess, ac, renderCaptureRequest{ | |
| 227 | 227 | bars: barState{}, | |
| 228 | 228 | overlays: capturedOverlayRenderState{}, | |
| 229 | 229 | preview: pickerPreviewEmpty(), | |
M internal/usecase/daemon/render_pipeline.go +29 -21
| @@ -59,7 +59,7 @@ type composedRenderFrame struct { | |||
| 59 | 59 | ||
| 60 | 60 | // composeFrame is pure with respect to daemon ownership: it consumes only the | |
| 61 | 61 | // capture, the last committed cache, and an independent attachment-owned | |
| 62 | - | // scratch cache. It returns a replacement cache without mutating committed. | |
| 62 | + | // scratch cache. It returns a new cache without mutating committed. | |
| 63 | 63 | const inactivePaneForegroundDimming = 55 | |
| 64 | 64 | ||
| 65 | 65 | func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...composeCacheInput) composedRenderFrame { | |
| @@ -459,7 +459,7 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c | |||
| 459 | 459 | return false | |
| 460 | 460 | } | |
| 461 | 461 | entry.core().mu.Lock() | |
| 462 | - | owned := entry.core().client == ac | |
| 462 | + | _, owned := entry.core().attachments[ac] | |
| 463 | 463 | entry.core().mu.Unlock() | |
| 464 | 464 | if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry { | |
| 465 | 465 | ac.sendMu.Unlock() | |
| @@ -532,13 +532,13 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c | |||
| 532 | 532 | sendTr = sendTransport.transport | |
| 533 | 533 | if ac.proxied { | |
| 534 | 534 | if sess, ok := localSession(entry); ok { | |
| 535 | - | sendErr = ac.sendSessionMetaIfChanged(sess, sendTransport, marks.roleEffect) | |
| 535 | + | sendErr = ac.sendSessionMetaIfChanged(sess, sendTransport, marks.attachmentEffect) | |
| 536 | 536 | } else if proxy, ok := entry.(*proxySession); ok { | |
| 537 | 537 | meta, metaOK := proxy.sessionMetaSnapshot() | |
| 538 | 538 | if !metaOK { | |
| 539 | 539 | sendErr = errSessionMetaUnavailable | |
| 540 | 540 | } else { | |
| 541 | - | sendErr = ac.sendSessionMetaSnapshot(meta, sendTransport, marks.roleEffect) | |
| 541 | + | sendErr = ac.sendSessionMetaSnapshot(meta, sendTransport, marks.attachmentEffect) | |
| 542 | 542 | } | |
| 543 | 543 | } | |
| 544 | 544 | } | |
| @@ -548,21 +548,29 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c | |||
| 548 | 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.roleEffect != nil && marks.roleEffect.beginTransportSend(sendTransport) | |
| 556 | - | if ac.proxied { | |
| 557 | - | sendErr = preparedScreen.send(send) | |
| 558 | - | } else { | |
| 559 | - | sendErr = preparedANSI.send(data, ac.echoAck.Load(), send) | |
| 558 | + | if sendErr == nil { | |
| 559 | + | send := sendTr.Send | |
| 560 | + | if async, ok := sendTr.(ports.AsyncTransport); ok { | |
| 561 | + | send = async.SendAsync | |
| 562 | + | } | |
| 563 | + | if ac.proxied { | |
| 564 | + | sendErr = preparedScreen.send(send) | |
| 565 | + | } else { | |
| 566 | + | sendErr = preparedANSI.send(data, ac.echoAck.Load(), send) | |
| 567 | + | } | |
| 560 | 568 | } | |
| 561 | - | if interruptible { | |
| 569 | + | if marks.attachmentEffect != nil && interruptible { | |
| 562 | 570 | if sendErr != nil { | |
| 563 | - | marks.roleEffect.reportTransportFailure(sendTransport) | |
| 571 | + | marks.attachmentEffect.reportTransportFailure(sendTransport) | |
| 564 | 572 | } | |
| 565 | - | marks.roleEffect.endTransportSend() | |
| 573 | + | marks.attachmentEffect.endTransportSend() | |
| 566 | 574 | } | |
| 567 | 575 | } | |
| 568 | 576 | endEmit(uint64(len(data)), sendErr == nil) | |
| @@ -611,7 +619,7 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c | |||
| 611 | 619 | // A transport failure may invalidate the role gate. Release this render's | |
| 612 | 620 | // admission first. Detachment freezes the gate and therefore cannot mutate | |
| 613 | 621 | // ownership until any enclosing admitted operation has also ended. | |
| 614 | - | if marks.roleEffect == nil { | |
| 622 | + | if marks.attachmentEffect == nil { | |
| 615 | 623 | if sess, ok := localSession(entry); ok { | |
| 616 | 624 | d.detachOnSendError(sess, ac, sendTr) | |
| 617 | 625 | } else if proxy, ok := entry.(*proxySession); ok { | |
| @@ -619,11 +627,11 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c | |||
| 619 | 627 | } | |
| 620 | 628 | } else { | |
| 621 | 629 | // Capture the exact admitted capability, including its coordinator | |
| 622 | - | // lease, before End permits a replacement publication. Reserve cleanup | |
| 623 | - | // accounting before End so terminal Wait cannot race a later Add. | |
| 624 | - | token := marks.roleEffect.roleToken() | |
| 625 | - | launchCleanup := d.reserveRoleSendErrorCleanup(token, sendTr) | |
| 626 | - | marks.roleEffect.End() | |
| 630 | + | // lease, before End permits a new attachment publication. Reserve | |
| 631 | + | // cleanup accounting before End so terminal Wait cannot race a later Add. | |
| 632 | + | token := marks.attachmentEffect.connectionToken() | |
| 633 | + | launchCleanup := d.reserveAttachmentSendErrorCleanup(token, sendTr) | |
| 634 | + | marks.attachmentEffect.End() | |
| 627 | 635 | launchCleanup() | |
| 628 | 636 | } | |
| 629 | 637 | } | |
M internal/usecase/daemon/render_pipeline_cache_test.go +1 -1
| @@ -57,7 +57,7 @@ func TestPipelineCachePublishesOnlyAfterEmission(t *testing.T) { | |||
| 57 | 57 | // its original healthy transport, then retry the pending state. | |
| 58 | 58 | if failure.name == "send" { | |
| 59 | 59 | sess.mu.Lock() | |
| 60 | - | sess.client = ac | |
| 60 | + | sess.registerAttachmentLocked(ac) | |
| 61 | 61 | sess.mu.Unlock() | |
| 62 | 62 | ac.setSession(sess) | |
| 63 | 63 | ac.replaceTransport(healthy) | |
M internal/usecase/daemon/render_pipeline_test.go +27 -2
| @@ -127,10 +127,11 @@ func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) { | |||
| 127 | 127 | pty, release := newBlockingPTY(t) | |
| 128 | 128 | t.Cleanup(release) | |
| 129 | 129 | d, sess, _, sends := newManualSessionWithPTYs(t, pty) | |
| 130 | - | ac := sess.client | |
| 130 | + | ac := sess.snapshotAttachments()[0] | |
| 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() | |
| @@ -240,7 +241,7 @@ func TestEmitFrameFailedSendDoesNotPublishCursorOrOutputState(t *testing.T) { | |||
| 240 | 241 | require.NotEmpty(t, probe.Bytes(), "failed output must not commit the renderer shadow") | |
| 241 | 242 | ||
| 242 | 243 | sess.mu.Lock() | |
| 243 | - | sess.client = ac | |
| 244 | + | sess.registerAttachmentLocked(ac) | |
| 244 | 245 | sess.mu.Unlock() | |
| 245 | 246 | ac.setSession(sess) | |
| 246 | 247 | ac.replaceTransport(healthy) | |
| @@ -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 | + | } | |
M internal/usecase/daemon/render_test.go +51 -172
| @@ -79,7 +79,7 @@ func TestFirstPaintRetainedFloatingPaneEmitsOneReset(t *testing.T) { | |||
| 79 | 79 | // outer terminal also changes, its completed transaction must cover | |
| 80 | 80 | // that popup rather than emitting a second reset-producing frame. | |
| 81 | 81 | floating := newPane(layout.PaneID("floating"), nil, domain.Size{Cols: 20, Rows: 8}) | |
| 82 | - | installTestFloating(sess.activeTab(), floating, true) | |
| 82 | + | installTestFloating(testAttachmentTab(sess), floating, true) | |
| 83 | 83 | ||
| 84 | 84 | d.firstPaint(sess, ac, tc.clientSize) | |
| 85 | 85 | ||
| @@ -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()) | |
| @@ -321,7 +321,7 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) { | |||
| 321 | 321 | awaitPTYReadProcessed(t, inactiveProcessed) | |
| 322 | 322 | ||
| 323 | 323 | sess.mu.Lock() | |
| 324 | - | sess.active = 1 | |
| 324 | + | selectTestAttachmentTabLocked(sess, 1) | |
| 325 | 325 | sess.mu.Unlock() | |
| 326 | 326 | d.invalidateRender(sess, ac, true, "sync activation") | |
| 327 | 327 | fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), urgentRenderDeadline) | |
| @@ -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()) | |
| @@ -358,7 +358,7 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) { | |||
| 358 | 358 | awaitPTYReadProcessed(t, oldProcessed) | |
| 359 | 359 | ||
| 360 | 360 | sess.mu.Lock() | |
| 361 | - | sess.active = 1 | |
| 361 | + | selectTestAttachmentTabLocked(sess, 1) | |
| 362 | 362 | sess.mu.Unlock() | |
| 363 | 363 | newSteps <- channelPTYStep{data: []byte("newly active")} | |
| 364 | 364 | awaitPTYReadProcessed(t, newProcessed) | |
| @@ -379,111 +379,13 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) { | |||
| 379 | 379 | }) | |
| 380 | 380 | } | |
| 381 | 381 | ||
| 382 | - | func TestPTYReaderRestoresPrimaryScreenImmediatelyAfterDEC1049Exit(t *testing.T) { | |
| 383 | - | for _, tc := range []struct { | |
| 384 | - | name string | |
| 385 | - | window uint64 | |
| 386 | - | exitBeforeACK bool | |
| 387 | - | exitSuffix string | |
| 388 | - | overlay bool | |
| 389 | - | }{ | |
| 390 | - | {name: "datagram output window (1): exit waits for ACK", window: 1}, | |
| 391 | - | {name: "datagram output window (1): exit and prompt arrive before ACK", window: 1, exitBeforeACK: true, exitSuffix: "PROMPT> "}, | |
| 392 | - | {name: "local default output window (8): exit waits for ACK", window: maxUnackedOutputStates}, | |
| 393 | - | {name: "local default output window (8): exit and prompt arrive before ACK", window: maxUnackedOutputStates, exitBeforeACK: true, exitSuffix: "PROMPT> "}, | |
| 394 | - | {name: "local default output window (8): palette overlay with bars", window: maxUnackedOutputStates, overlay: true}, | |
| 395 | - | } { | |
| 396 | - | t.Run(tc.name, func(t *testing.T) { | |
| 397 | - | pty, steps, processed := newChannelPTY(t) | |
| 398 | - | d, sess, ac, sends := newManualSessionWithPTYs(t, pty) | |
| 399 | - | // Datagram clients use a one-frame window; local clients retain the | |
| 400 | - | // default eight-frame window. Both ACK orderings stay deterministic. | |
| 401 | - | ac.output.maxOutstanding = tc.window | |
| 402 | - | clock := newCoordinatorMockClock(t, 8) | |
| 403 | - | d.clock = clock.clock | |
| 404 | - | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 405 | - | client := vt.NewScreen(80, 25) | |
| 406 | - | pane := sess.tabs[0].focusedPane() | |
| 407 | - | ||
| 408 | - | d.sessWg.Add(1) | |
| 409 | - | go d.ptyReader(sess, sess.tabs[0], pane) | |
| 410 | - | t.Cleanup(func() { | |
| 411 | - | steps <- channelPTYStep{err: io.EOF} | |
| 412 | - | d.sessWg.Wait() | |
| 413 | - | d.waitNotifies() | |
| 414 | - | }) | |
| 415 | - | ||
| 416 | - | replay := func(data []byte, deadline time.Duration) ports.Output { | |
| 417 | - | t.Helper() | |
| 418 | - | steps <- channelPTYStep{data: data} | |
| 419 | - | awaitPTYReadProcessed(t, processed) | |
| 420 | - | fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), deadline) | |
| 421 | - | return mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends)) | |
| 422 | - | } | |
| 423 | - | acknowledge := func(out ports.Output) { | |
| 424 | - | ac.ackOutputState(out.NewStateNum) | |
| 425 | - | rc.notifyAck() | |
| 426 | - | } | |
| 427 | - | ||
| 428 | - | if tc.overlay { | |
| 429 | - | d.enterPalette(sess, ac) | |
| 430 | - | fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), urgentRenderDeadline) | |
| 431 | - | acknowledge(mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))) | |
| 432 | - | } | |
| 433 | - | acknowledge(replay([]byte("PRIMARY"), minOutputRenderDeadline)) | |
| 434 | - | primary := client.Frame.Clone() | |
| 435 | - | ||
| 436 | - | // The reported PTY stream is deliberately split into enter, body, and | |
| 437 | - | // exit reads. MATRIX distinguishes alternate cells in the replayed | |
| 438 | - | // terminal model from all primary, chrome, and overlay cells. | |
| 439 | - | acknowledge(replay([]byte("\x1b[?1049h"), minOutputRenderDeadline)) | |
| 440 | - | alternate := replay([]byte(strings.Repeat("MATRIX", 300)), minOutputRenderDeadline) | |
| 441 | - | pane.mu.Lock() | |
| 442 | - | require.Contains(t, strings.Join(frameRows(pane.screen.Frame), "\n"), "MATRIX", "fixture must make alternate cells visible to the production renderer") | |
| 443 | - | pane.mu.Unlock() | |
| 444 | - | ||
| 445 | - | exitData := []byte("\x1b[?1049l" + tc.exitSuffix) | |
| 446 | - | var exit ports.Output | |
| 447 | - | if tc.exitBeforeACK { | |
| 448 | - | steps <- channelPTYStep{data: exitData} | |
| 449 | - | awaitPTYReadProcessed(t, processed) | |
| 450 | - | fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline) | |
| 451 | - | if tc.window == 1 { | |
| 452 | - | requireNoCoordinatorOutputFrame(t, sends) | |
| 453 | - | acknowledge(alternate) | |
| 454 | - | exit = mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends)) | |
| 455 | - | } else { | |
| 456 | - | exit = mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends)) | |
| 457 | - | require.Equal(t, alternate.NewStateNum, exit.BaseStateNum, | |
| 458 | - | "local exit must be rendered before the alternate-frame ACK") | |
| 459 | - | acknowledge(alternate) | |
| 460 | - | } | |
| 461 | - | } else { | |
| 462 | - | acknowledge(alternate) | |
| 463 | - | steps <- channelPTYStep{data: exitData} | |
| 464 | - | awaitPTYReadProcessed(t, processed) | |
| 465 | - | fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline) | |
| 466 | - | exit = mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends)) | |
| 467 | - | } | |
| 468 | - | require.NotEmpty(t, exit.Data, "the first post-exit output must actively remove alternate cells") | |
| 469 | - | rendered := strings.Join(frameRows(client.Frame), "\n") | |
| 470 | - | require.NotContains(t, rendered, "MATRIX", "the first post-exit frame must not retain alternate cells") | |
| 471 | - | if tc.exitSuffix == "" { | |
| 472 | - | require.Equal(t, frameRows(primary), frameRows(client.Frame), "the first post-exit output must restore the primary screen") | |
| 473 | - | } else { | |
| 474 | - | require.Contains(t, rendered, tc.exitSuffix, "bytes immediately after DEC 1049 exit must appear in the first post-exit frame") | |
| 475 | - | } | |
| 476 | - | }) | |
| 477 | - | } | |
| 478 | - | } | |
| 479 | - | ||
| 480 | 382 | func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *testing.T) { | |
| 481 | 383 | t.Run("detached target completes cross-session preview without later PTY output", func(t *testing.T) { | |
| 482 | 384 | pty, steps, processed := newChannelPTY(t) | |
| 483 | 385 | d, target, targetClient, _ := newManualSessionWithPTYs(t, pty) | |
| 484 | 386 | clock := newCoordinatorMockClock(t, 8) | |
| 485 | 387 | d.clock = clock.clock | |
| 486 | - | rc := d.attachCoordinator(target, nil, targetClient, true) | |
| 388 | + | rc := d.attachCoordinator(target, targetClient, true) | |
| 487 | 389 | ||
| 488 | 390 | // A viewer in a different session is previewing the headless target. | |
| 489 | 391 | viewer := &attachedClient{} | |
| @@ -491,7 +393,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t | |||
| 491 | 393 | viewer.overlays.pickerMu.Lock() | |
| 492 | 394 | viewer.overlays.pickerPreview = target.tabs[0] | |
| 493 | 395 | viewer.overlays.pickerMu.Unlock() | |
| 494 | - | d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", client: viewer}} | |
| 396 | + | d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", attachments: map[*attachedClient]struct{}{viewer: {}}}} | |
| 495 | 397 | previews := make(chan renderWake, 2) | |
| 496 | 398 | rc.subscribePreviewFor(viewer, 1, func(w renderWake) { previews <- w }) | |
| 497 | 399 | ||
| @@ -502,7 +404,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t | |||
| 502 | 404 | drainCoordinatorTimers(clock) // detach must clear the pending output work. | |
| 503 | 405 | ||
| 504 | 406 | target.mu.Lock() | |
| 505 | - | target.client = nil | |
| 407 | + | clearAttachmentsForTestLocked(target) | |
| 506 | 408 | target.mu.Unlock() | |
| 507 | 409 | rc.noteDetach(targetClient) | |
| 508 | 410 | steps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")} | |
| @@ -522,45 +424,42 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t | |||
| 522 | 424 | d.sessWg.Wait() | |
| 523 | 425 | }) | |
| 524 | 426 | ||
| 525 | - | t.Run("replacement receives one complete reset frame without later PTY output", func(t *testing.T) { | |
| 427 | + | t.Run("remaining attachment receives shared output after peer detach", func(t *testing.T) { | |
| 526 | 428 | pty, steps, processed := newChannelPTY(t) | |
| 527 | - | d, target, oldClient, _ := newManualSessionWithPTYs(t, pty) | |
| 429 | + | d, target, detached, _ := newManualSessionWithPTYs(t, pty) | |
| 528 | 430 | clock := newCoordinatorMockClock(t, 8) | |
| 529 | 431 | d.clock = clock.clock | |
| 530 | - | rc := d.attachCoordinator(target, nil, oldClient, true) | |
| 432 | + | rc := d.attachCoordinator(target, detached, true) | |
| 433 | + | ||
| 434 | + | secondTransport, secondSends := newCapturingTransport(t) | |
| 435 | + | remaining := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: detached.size} | |
| 436 | + | remaining.initOverlays() | |
| 437 | + | remaining.setSession(target) | |
| 438 | + | target.mu.Lock() | |
| 439 | + | require.True(t, target.registerAttachmentLocked(remaining)) | |
| 440 | + | target.mu.Unlock() | |
| 441 | + | rc.attach(remaining) | |
| 531 | 442 | ||
| 532 | - | wakes := make(chan renderWake, 2) | |
| 533 | - | rc.opts.wake = func(w renderWake) { wakes <- w } | |
| 534 | 443 | d.sessWg.Add(1) | |
| 535 | 444 | go d.ptyReader(target, target.tabs[0], target.tabs[0].focusedPane()) | |
| 536 | - | steps <- channelPTYStep{data: []byte("\x1b[?2026hpending")} | |
| 445 | + | steps <- channelPTYStep{data: []byte("before detach")} | |
| 537 | 446 | awaitPTYReadProcessed(t, processed) | |
| 538 | - | drainCoordinatorTimers(clock) | |
| 447 | + | timers := drainCoordinatorTimers(clock) | |
| 539 | 448 | ||
| 540 | - | replacement := &attachedClient{output: newOutputStateStream()} | |
| 541 | - | rc.noteReplace(oldClient, replacement, true) | |
| 449 | + | // Detaching one attachment must not discard the session-shared pending | |
| 450 | + | // invalidation or stop the coordinator from painting the remaining one. | |
| 542 | 451 | target.mu.Lock() | |
| 543 | - | target.client = replacement | |
| 452 | + | require.True(t, target.unregisterAttachmentLocked(detached)) | |
| 544 | 453 | target.mu.Unlock() | |
| 545 | - | // The replacement's initial full paint remains gated by the batch. Its | |
| 546 | - | // reset must survive completion and coalesce with the completion wake. | |
| 547 | - | rc.invalidateForAttachment(replacement, renderInvalidation{class: invalidateUrgent, reset: true, producer: "replacement first paint"}) | |
| 548 | - | replacementTimers := drainCoordinatorTimers(clock) | |
| 549 | - | steps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")} | |
| 550 | - | awaitPTYReadProcessed(t, processed) | |
| 551 | - | requireNoWake(t, wakes) | |
| 454 | + | rc.noteDetach(detached) | |
| 455 | + | fireCoordinatorTimer(t, rc, timers, minOutputRenderDeadline) | |
| 456 | + | require.NotEmpty(t, secondSends, "the remaining attachment received no shared output after its peer detached") | |
| 457 | + | before := len(secondSends) | |
| 552 | 458 | ||
| 553 | - | fireCoordinatorTimer(t, rc, replacementTimers, urgentRenderDeadline) | |
| 554 | - | wake := <-wakes | |
| 555 | - | require.True(t, wake.urgent) | |
| 556 | - | require.True(t, wake.reset, "the replacement's cleared batch must repaint a complete frame") | |
| 557 | - | require.Equal(t, 2, wake.coalesced, "completion coalesces only with the replacement's fresh reset batch") | |
| 558 | - | require.Same(t, replacement, wake.lease.attachment) | |
| 559 | - | select { | |
| 560 | - | case duplicate := <-wakes: | |
| 561 | - | t.Fatalf("sync completion must publish exactly one replacement wake: %#v", duplicate) | |
| 562 | - | default: | |
| 563 | - | } | |
| 459 | + | steps <- channelPTYStep{data: []byte("after detach")} | |
| 460 | + | awaitPTYReadProcessed(t, processed) | |
| 461 | + | fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline) | |
| 462 | + | require.Greater(t, len(secondSends), before, "shared output stopped after the peer detached") | |
| 564 | 463 | ||
| 565 | 464 | steps <- channelPTYStep{err: io.EOF} | |
| 566 | 465 | d.sessWg.Wait() | |
| @@ -571,11 +470,11 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t | |||
| 571 | 470 | d, target, client, _ := newManualSessionWithPTYs(t, pty) | |
| 572 | 471 | clock := newCoordinatorMockClock(t, 8) | |
| 573 | 472 | d.clock = clock.clock | |
| 574 | - | rc := d.attachCoordinator(target, nil, client, true) | |
| 473 | + | rc := d.attachCoordinator(target, client, true) | |
| 575 | 474 | wakes := make(chan renderWake, 1) | |
| 576 | 475 | rc.opts.wake = func(w renderWake) { wakes <- w } | |
| 577 | 476 | target.mu.Lock() | |
| 578 | - | target.client = nil | |
| 477 | + | clearAttachmentsForTestLocked(target) | |
| 579 | 478 | target.mu.Unlock() | |
| 580 | 479 | rc.noteDetach(client) | |
| 581 | 480 | ||
| @@ -601,7 +500,7 @@ func TestPaneRenderableActiveAttachmentDoesNotScanPickerPreviews(t *testing.T) { | |||
| 601 | 500 | p, release := newBlockingPTY(t) | |
| 602 | 501 | defer release() | |
| 603 | 502 | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 604 | - | tb := sess.activeTab() | |
| 503 | + | tb := testAttachmentTab(sess) | |
| 605 | 504 | pane := tb.focusedPane() | |
| 606 | 505 | ||
| 607 | 506 | // Holding daemon ownership makes a picker scan block. An active attached | |
| @@ -628,11 +527,11 @@ func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) { | |||
| 628 | 527 | name string | |
| 629 | 528 | setup func(*Daemon, *session, *tab, *pane) | |
| 630 | 529 | }{ | |
| 631 | - | {name: "headless", setup: func(_ *Daemon, sess *session, _ *tab, _ *pane) { sess.client = nil }}, | |
| 530 | + | {name: "headless", setup: func(_ *Daemon, sess *session, _ *tab, _ *pane) { clearAttachmentsForTest(sess) }}, | |
| 632 | 531 | {name: "inactive tab", setup: func(_ *Daemon, sess *session, tb *tab, _ *pane) { | |
| 633 | 532 | other := newTab(nil, domain.Size{Cols: 80, Rows: 23}) | |
| 634 | 533 | sess.tabs = append([]*tab{other}, sess.tabs...) | |
| 635 | - | sess.active = 0 | |
| 534 | + | selectTestAttachmentTab(sess, 0) | |
| 636 | 535 | require.NotSame(t, other, tb) | |
| 637 | 536 | }}, | |
| 638 | 537 | {name: "collapsed", setup: func(_ *Daemon, _ *session, tb *tab, p *pane) { | |
| @@ -667,39 +566,19 @@ func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) { | |||
| 667 | 566 | require.NotEmpty(t, p.screen.Damage(), "active pane damage belongs to coordinator composition") | |
| 668 | 567 | p.screen.ClearDamage() | |
| 669 | 568 | ||
| 670 | - | sess.client = nil | |
| 569 | + | clearAttachmentsForTest(sess) | |
| 671 | 570 | viewer := &attachedClient{} | |
| 672 | 571 | viewer.initOverlays() | |
| 673 | 572 | viewer.overlays.pickerMu.Lock() | |
| 674 | 573 | viewer.overlays.pickerPreview = tb | |
| 675 | 574 | viewer.overlays.pickerMu.Unlock() | |
| 676 | - | d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", client: viewer}} | |
| 575 | + | d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", attachments: map[*attachedClient]struct{}{viewer: {}}}} | |
| 677 | 576 | p.screen.Write([]byte("preview")) | |
| 678 | 577 | _ = d.paneRenderable(sess, tb, p) | |
| 679 | 578 | require.NotEmpty(t, p.screen.Damage(), "picker preview damage must remain for coordinator composition") | |
| 680 | 579 | }) | |
| 681 | 580 | } | |
| 682 | 581 | ||
| 683 | - | func TestPTYWriteErrorIsLogged(t *testing.T) { | |
| 684 | - | var logs bytes.Buffer | |
| 685 | - | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logs, nil))) | |
| 686 | - | errBoom := errors.New("boom") | |
| 687 | - | p := portsmocks.NewMockPTY(t) | |
| 688 | - | p.EXPECT().Write([]byte("input")).Return(0, errBoom).Once() | |
| 689 | - | win := newTab(p, domain.Size{Cols: 80, Rows: 23}) | |
| 690 | - | sess := &session{sessionCore: sessionCore{id: "manual", name: "work"}, tabs: []*tab{win}} | |
| 691 | - | ac := &attachedClient{} | |
| 692 | - | ac.initOverlays() | |
| 693 | - | ac.setSession(sess) | |
| 694 | - | ||
| 695 | - | daemonKeyHandler{d: d, ac: ac}.Forward([]byte("input")) | |
| 696 | - | ||
| 697 | - | got := logs.String() | |
| 698 | - | if !strings.Contains(got, "pty write failed") || !strings.Contains(got, "boom") || !strings.Contains(got, "work") { | |
| 699 | - | t.Fatalf("log output %q does not contain PTY write failure details", got) | |
| 700 | - | } | |
| 701 | - | } | |
| 702 | - | ||
| 703 | 582 | func TestAltXClosesFinalTabAndDetaches(t *testing.T) { | |
| 704 | 583 | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |
| 705 | 584 | defer releases[0]() | |
| @@ -719,7 +598,7 @@ func TestPTYEOFClosesActiveNonFinalTabAndRepaintsRemaining(t *testing.T) { | |||
| 719 | 598 | p2, releasePTY2 := newBlockingPTY(t) | |
| 720 | 599 | d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2) | |
| 721 | 600 | defer releasePTY2() | |
| 722 | - | sess.active = 0 | |
| 601 | + | selectTestAttachmentTab(sess, 0) | |
| 723 | 602 | sess.tabs[1].focusedPane().screen.Write([]byte("remaining")) | |
| 724 | 603 | ||
| 725 | 604 | d.sessWg.Add(1) | |
| @@ -728,7 +607,7 @@ func TestPTYEOFClosesActiveNonFinalTabAndRepaintsRemaining(t *testing.T) { | |||
| 728 | 607 | ||
| 729 | 608 | require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond) | |
| 730 | 609 | require.Equal(t, 1, sessionCount(d)) | |
| 731 | - | require.Equal(t, 0, activeTabIndex(sess)) | |
| 610 | + | require.Equal(t, 0, testAttachmentTabIndex(sess)) | |
| 732 | 611 | f := awaitFrame(t, sends, ports.MsgOutput) | |
| 733 | 612 | out, err := ports.UnmarshalOutput(f.Payload) | |
| 734 | 613 | require.NoError(t, err) | |
| @@ -745,7 +624,7 @@ func TestPTYEOFClosesInactiveNonFinalTabAndRepaintsStatus(t *testing.T) { | |||
| 745 | 624 | p2, releasePTY2 := newBlockingPTY(t) | |
| 746 | 625 | d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2) | |
| 747 | 626 | defer releasePTY1() | |
| 748 | - | sess.active = 0 | |
| 627 | + | selectTestAttachmentTab(sess, 0) | |
| 749 | 628 | sess.tabs[0].focusedPane().screen.Write([]byte("active")) | |
| 750 | 629 | ||
| 751 | 630 | d.sessWg.Add(1) | |
| @@ -754,7 +633,7 @@ func TestPTYEOFClosesInactiveNonFinalTabAndRepaintsStatus(t *testing.T) { | |||
| 754 | 633 | ||
| 755 | 634 | require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond) | |
| 756 | 635 | require.Equal(t, 1, sessionCount(d)) | |
| 757 | - | require.Equal(t, 0, activeTabIndex(sess)) | |
| 636 | + | require.Equal(t, 0, testAttachmentTabIndex(sess)) | |
| 758 | 637 | f := awaitFrame(t, sends, ports.MsgOutput) | |
| 759 | 638 | out, err := ports.UnmarshalOutput(f.Payload) | |
| 760 | 639 | require.NoError(t, err) | |
| @@ -802,7 +681,7 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) { | |||
| 802 | 681 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 803 | 682 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 804 | 683 | ac.initOverlays() | |
| 805 | - | sess := &session{sessionCore: sessionCore{id: "s", name: "s", client: ac}, tabs: []*tab{win}} | |
| 684 | + | sess := &session{sessionCore: sessionCore{id: "s", name: "s", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}} | |
| 806 | 685 | ac.setSession(sess) | |
| 807 | 686 | ||
| 808 | 687 | // Client rows are one more than the equivalent case in a single-bar | |
| @@ -1062,7 +941,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) { | |||
| 1062 | 941 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 1063 | 942 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 1064 | 943 | ac.initOverlays() | |
| 1065 | - | sess := &session{sessionCore: sessionCore{id: "s", name: "s", client: ac}, tabs: []*tab{win}} | |
| 944 | + | sess := &session{sessionCore: sessionCore{id: "s", name: "s", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}} | |
| 1066 | 945 | ac.setSession(sess) | |
| 1067 | 946 | ||
| 1068 | 947 | d.resize(sess, ac, newSize) | |
| @@ -1091,7 +970,7 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) { | |||
| 1091 | 970 | sctx, cancel := context.WithCancel(context.Background()) | |
| 1092 | 971 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 1093 | 972 | ac.initOverlays() | |
| 1094 | - | sess := &session{sessionCore: sessionCore{id: "e", name: "0", ephemeral: true, client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 973 | + | sess := &session{sessionCore: sessionCore{id: "e", name: "0", ephemeral: true, attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 1095 | 974 | ac.setSession(sess) | |
| 1096 | 975 | d.sessions[sess.id] = sess | |
| 1097 | 976 | ||
| @@ -1103,7 +982,7 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) { | |||
| 1103 | 982 | ||
| 1104 | 983 | require.Equal(t, 1, sessionCount(d), "ephemeral session survives failed client send") | |
| 1105 | 984 | sess.mu.Lock() | |
| 1106 | - | require.Nil(t, sess.client) | |
| 985 | + | require.Empty(t, sess.snapshotAttachmentsLocked()) | |
| 1107 | 986 | sess.mu.Unlock() | |
| 1108 | 987 | ||
| 1109 | 988 | _ = d.killSession(sess, ports.ReasonServerShutdown, false) | |
| @@ -1135,7 +1014,7 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) { | |||
| 1135 | 1014 | win := newTestTabWithContext(p, sctx, cancel) | |
| 1136 | 1015 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 1137 | 1016 | ac.initOverlays() | |
| 1138 | - | sess := &session{sessionCore: sessionCore{id: "query", name: "query", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 1017 | + | sess := &session{sessionCore: sessionCore{id: "query", name: "query", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 1139 | 1018 | ac.setSession(sess) | |
| 1140 | 1019 | d.sessions[sess.id] = sess | |
| 1141 | 1020 | d.sessWg.Add(1) | |
| @@ -1198,7 +1077,7 @@ func TestPaintAlignsFloatingCursorWithCommittedGeometry(t *testing.T) { | |||
| 1198 | 1077 | floating := newPane("floating", nil, rectSize(committed.Inner)) | |
| 1199 | 1078 | floating.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'F'}) | |
| 1200 | 1079 | floating.popupGeometry = committed | |
| 1201 | - | installTestFloating(sess.activeTab(), floating, true) | |
| 1080 | + | installTestFloating(testAttachmentTab(sess), floating, true) | |
| 1202 | 1081 | ||
| 1203 | 1082 | d.paint(sess, ac, true, nil) | |
| 1204 | 1083 | data := mustOutputData(t, sends) | |
| @@ -1300,7 +1179,7 @@ func TestPaintComposesCopyBodyAboveFloating(t *testing.T) { | |||
| 1300 | 1179 | fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3}) | |
| 1301 | 1180 | appendHistoryRow(t, fp.history, testRow("flt-old")) | |
| 1302 | 1181 | fp.screen.Write([]byte("flt-live")) | |
| 1303 | - | installTestFloating(sess.activeTab(), fp, true) | |
| 1182 | + | installTestFloating(testAttachmentTab(sess), fp, true) | |
| 1304 | 1183 | ||
| 1305 | 1184 | d.enterCopyMode(sess, ac) | |
| 1306 | 1185 | data := mustOutputData(t, sends) | |
M internal/usecase/daemon/render_timer.go +1 -1
| @@ -81,7 +81,7 @@ func (s *timerSupervisor) startLocked(token *timerToken, timerC <-chan time.Time | |||
| 81 | 81 | func (s *timerSupervisor) wait() { s.workers.Wait() } | |
| 82 | 82 | ||
| 83 | 83 | // stopDetachedTimer is intentionally non-blocking. Ordinary lifecycle and | |
| 84 | - | // replacement transitions may cancel/stop stale workers but callback stacks | |
| 84 | + | // attachment transitions may cancel/stop stale workers but callback stacks | |
| 85 | 85 | // never wait for a worker to finish. | |
| 86 | 86 | func stopDetachedTimer(token *timerToken) { | |
| 87 | 87 | if token != nil && token.timer != nil { | |
M internal/usecase/daemon/resize_mode.go +5 -3
| @@ -47,7 +47,7 @@ func (d *Daemon) enterResizeMode(sess *session, ac *attachedClient) error { | |||
| 47 | 47 | if sess == nil || ac == nil { | |
| 48 | 48 | return layout.ErrNotFound | |
| 49 | 49 | } | |
| 50 | - | target := resolveDaemonActionTarget(sess) | |
| 50 | + | target := resolveDaemonActionTargetForAttachment(sess, ac) | |
| 51 | 51 | if target.tab == nil || target.pane == nil { | |
| 52 | 52 | return layout.ErrNotFound | |
| 53 | 53 | } | |
| @@ -131,8 +131,10 @@ func (d *Daemon) handleResizeInput(ac *attachedClient, data []byte) { | |||
| 131 | 131 | return | |
| 132 | 132 | } | |
| 133 | 133 | for _, request := range requests { | |
| 134 | - | request.target = resolveDaemonActionTarget(sess) | |
| 135 | - | if err := (daemonActions{d: d}).Run(request); err != nil { | |
| 134 | + | if err := sess.runMutation(func() error { | |
| 135 | + | request.target = resolveDaemonActionTargetForAttachment(sess, ac) | |
| 136 | + | return (daemonActions{d: d}).Run(request) | |
| 137 | + | }); err != nil { | |
| 136 | 138 | d.reportError(sess, resizeUserError(err)) | |
| 137 | 139 | continue | |
| 138 | 140 | } | |
M internal/usecase/daemon/resize_mode_test.go +3 -239
| @@ -6,9 +6,7 @@ import ( | |||
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/vev/internal/domain" | |
| 8 | 8 | "github.com/bnema/vev/internal/ports" | |
| 9 | - | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 10 | 9 | "github.com/bnema/vev/internal/usecase/layout" | |
| 11 | - | "github.com/bnema/vev/internal/usecase/mouse" | |
| 12 | 10 | "github.com/stretchr/testify/require" | |
| 13 | 11 | ) | |
| 14 | 12 | ||
| @@ -64,7 +62,7 @@ func TestResizeControlHeadlessAndErrors(t *testing.T) { | |||
| 64 | 62 | require.Equal(t, "pane is not in a split", tooEarly.Text) | |
| 65 | 63 | ||
| 66 | 64 | require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK) | |
| 67 | - | tb := sess.activeTab() | |
| 65 | + | tb := testAttachmentTab(sess) | |
| 68 | 66 | tb.mu.Lock() | |
| 69 | 67 | beforeFocus := tb.tree.Focus | |
| 70 | 68 | beforeGeneration := tb.layoutGeneration | |
| @@ -94,67 +92,6 @@ func TestResizeControlHeadlessAndErrors(t *testing.T) { | |||
| 94 | 92 | require.Empty(t, equalized.Output) | |
| 95 | 93 | } | |
| 96 | 94 | ||
| 97 | - | func TestEnterResizeModeRejectsPaneOutsideSplit(t *testing.T) { | |
| 98 | - | factory := &controlPTYFactory{} | |
| 99 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 100 | - | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |
| 101 | - | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 102 | - | ac := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 103 | - | ac.initOverlays() | |
| 104 | - | ac.setSession(sess) | |
| 105 | - | ||
| 106 | - | err := d.enterResizeMode(sess, ac) | |
| 107 | - | require.ErrorIs(t, err, layout.ErrNotInSplit) | |
| 108 | - | require.False(t, ac.overlays.resizeModeActive()) | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | func TestResizeModeStateRenderingAndMouseConsumption(t *testing.T) { | |
| 112 | - | factory := &controlPTYFactory{} | |
| 113 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 114 | - | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |
| 115 | - | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 116 | - | require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK) | |
| 117 | - | ||
| 118 | - | ac := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 119 | - | ac.initOverlays() | |
| 120 | - | ac.setSession(sess) | |
| 121 | - | require.NoError(t, d.enterResizeMode(sess, ac)) | |
| 122 | - | require.True(t, ac.overlays.resizeModeActive()) | |
| 123 | - | d.handleResizeInput(ac, []byte("\x1b")) | |
| 124 | - | require.True(t, ac.overlays.resizeModeActive(), "a bare ESC read must wait for a possible arrow suffix") | |
| 125 | - | d.handleResizeInput(ac, []byte("[D")) | |
| 126 | - | require.True(t, ac.overlays.resizeModeActive()) | |
| 127 | - | ||
| 128 | - | snapshot := ac.overlays.SnapshotForRender() | |
| 129 | - | require.True(t, snapshot.resizeActive) | |
| 130 | - | snapshot.Unlock() | |
| 131 | - | require.False(t, (capturedOverlayRenderState{resizeActive: true}).active(), "resize must not create a modal render layer") | |
| 132 | - | ||
| 133 | - | tb := sess.activeTab() | |
| 134 | - | tb.mu.Lock() | |
| 135 | - | focused := tb.focusedPane() | |
| 136 | - | tb.mu.Unlock() | |
| 137 | - | focused.mu.Lock() | |
| 138 | - | cursor := captureCursorInputsLocked(focused, domain.Rect{Width: 40, Height: 10}, capturedOverlayRenderState{resizeActive: true}) | |
| 139 | - | focused.mu.Unlock() | |
| 140 | - | require.False(t, cursor.hiddenByOverlay, "resize must leave the pane cursor live") | |
| 141 | - | ||
| 142 | - | ac.overlays.copyMu.Lock() | |
| 143 | - | ac.overlays.copyPointer.valid = true | |
| 144 | - | ac.overlays.copyMu.Unlock() | |
| 145 | - | d.handleMouse(ac, mouse.Event{Button: mouse.Left, Type: mouse.Release}) | |
| 146 | - | ac.overlays.copyMu.Lock() | |
| 147 | - | require.True(t, ac.overlays.copyPointer.valid, "resize mode must consume mouse input before copy/terminal routing") | |
| 148 | - | ac.overlays.copyMu.Unlock() | |
| 149 | - | ||
| 150 | - | ac.overlays.copyMu.Lock() | |
| 151 | - | ac.overlays.copyMode = &scopy.Mode{} | |
| 152 | - | ac.overlays.copyMu.Unlock() | |
| 153 | - | require.True(t, ac.overlays.HandleInput(d, []byte("q"))) | |
| 154 | - | require.False(t, ac.overlays.resizeModeActive()) | |
| 155 | - | require.True(t, ac.overlays.copyActive(), "resize input must take priority over copy mode") | |
| 156 | - | } | |
| 157 | - | ||
| 158 | 95 | func TestRouteOverlayDecodesHorizontalArrows(t *testing.T) { | |
| 159 | 96 | var left, right int | |
| 160 | 97 | pending := []byte(nil) | |
| @@ -170,66 +107,10 @@ func TestRouteOverlayDecodesHorizontalArrows(t *testing.T) { | |||
| 170 | 107 | // TestResizeModeKeysAndEscapes exercises the modal parser through the real | |
| 171 | 108 | // action seam. Every directional spelling must commit exactly one mutation; | |
| 172 | 109 | // parser prefixes are deliberately split across reads. | |
| 173 | - | func TestResizeModeKeysAndEscapes(t *testing.T) { | |
| 174 | - | tests := []struct { | |
| 175 | - | name string | |
| 176 | - | chunks [][]byte | |
| 177 | - | change bool | |
| 178 | - | exit bool | |
| 179 | - | equalize bool | |
| 180 | - | }{ | |
| 181 | - | {"h", [][]byte{[]byte("h")}, true, false, false}, | |
| 182 | - | {"j", [][]byte{[]byte("j")}, true, false, false}, | |
| 183 | - | {"k", [][]byte{[]byte("k")}, true, false, false}, | |
| 184 | - | {"l", [][]byte{[]byte("l")}, true, false, false}, | |
| 185 | - | {"CSI left split", [][]byte{[]byte("\x1b["), []byte("D")}, true, false, false}, | |
| 186 | - | {"CSI right", [][]byte{[]byte("\x1b[C")}, true, false, false}, | |
| 187 | - | {"CSI up", [][]byte{[]byte("\x1b[A")}, true, false, false}, | |
| 188 | - | {"CSI down", [][]byte{[]byte("\x1b[B")}, true, false, false}, | |
| 189 | - | {"SS3 left", [][]byte{[]byte("\x1bOD")}, true, false, false}, | |
| 190 | - | {"SS3 right split", [][]byte{[]byte("\x1bO"), []byte("C")}, true, false, false}, | |
| 191 | - | {"SS3 up", [][]byte{[]byte("\x1bOA")}, true, false, false}, | |
| 192 | - | {"SS3 down", [][]byte{[]byte("\x1bOB")}, true, false, false}, | |
| 193 | - | {"equalize", [][]byte{[]byte("=")}, true, false, true}, | |
| 194 | - | {"ignored", [][]byte{[]byte("z\x00")}, false, false, false}, | |
| 195 | - | {"q exits", [][]byte{[]byte("q")}, false, true, false}, | |
| 196 | - | {"enter exits", [][]byte{[]byte("\r")}, false, true, false}, | |
| 197 | - | {"escape exits", [][]byte{[]byte("\x1b"), []byte("x")}, false, true, false}, | |
| 198 | - | } | |
| 199 | - | for _, tt := range tests { | |
| 200 | - | t.Run(tt.name, func(t *testing.T) { | |
| 201 | - | d, sess, ac := resizeModeFixture(t) | |
| 202 | - | tb := sess.activeTab() | |
| 203 | - | tb.mu.Lock() | |
| 204 | - | before := tb.layoutGeneration | |
| 205 | - | if tt.equalize { | |
| 206 | - | tb.tree.Root.Children[0].Weight = 3 | |
| 207 | - | } | |
| 208 | - | tb.mu.Unlock() | |
| 209 | - | for _, chunk := range tt.chunks { | |
| 210 | - | d.handleResizeInput(ac, chunk) | |
| 211 | - | } | |
| 212 | - | tb.mu.Lock() | |
| 213 | - | after := tb.layoutGeneration | |
| 214 | - | if tt.equalize { | |
| 215 | - | for _, child := range tb.tree.Root.Children { | |
| 216 | - | require.Zero(t, child.Weight) | |
| 217 | - | } | |
| 218 | - | } | |
| 219 | - | tb.mu.Unlock() | |
| 220 | - | if tt.change { | |
| 221 | - | require.Equal(t, before+1, after) | |
| 222 | - | } else { | |
| 223 | - | require.Equal(t, before, after) | |
| 224 | - | } | |
| 225 | - | require.Equal(t, !tt.exit, ac.overlays.resizeModeActive()) | |
| 226 | - | }) | |
| 227 | - | } | |
| 228 | - | } | |
| 229 | 110 | ||
| 230 | 111 | func TestResizeModeRendersGuidanceWithoutHidingContentOrCursor(t *testing.T) { | |
| 231 | 112 | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |
| 232 | - | tb := sess.activeTab() | |
| 113 | + | tb := testAttachmentTab(sess) | |
| 233 | 114 | second := newPane("pane-2", nil, domain.Size{Cols: 39, Rows: 23}) | |
| 234 | 115 | tb.mu.Lock() | |
| 235 | 116 | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"} | |
| @@ -243,108 +124,6 @@ func TestResizeModeRendersGuidanceWithoutHidingContentOrCursor(t *testing.T) { | |||
| 243 | 124 | require.Contains(t, data, "\x1b[?25h", "resize leaves the focused pane cursor visible") | |
| 244 | 125 | } | |
| 245 | 126 | ||
| 246 | - | func TestResizeModeOverlayPriority(t *testing.T) { | |
| 247 | - | tests := []struct { | |
| 248 | - | name string | |
| 249 | - | input []byte | |
| 250 | - | setup func(*Daemon, *session, *attachedClient) | |
| 251 | - | check func(*testing.T, *attachedClient) | |
| 252 | - | }{ | |
| 253 | - | {"prompt before palette picker notices resize and copy", []byte("q"), func(d *Daemon, sess *session, ac *attachedClient) { | |
| 254 | - | d.enterCopyMode(sess, ac) | |
| 255 | - | d.enterNotices(sess, ac) | |
| 256 | - | d.enterPicker(sess, ac) | |
| 257 | - | d.enterPalette(sess, ac) | |
| 258 | - | d.enterPrompt(sess, ac, "prompt", "", func(string) error { return nil }) | |
| 259 | - | }, func(t *testing.T, ac *attachedClient) { | |
| 260 | - | require.True(t, ac.overlays.promptActive()) | |
| 261 | - | require.True(t, ac.overlays.resizeModeActive()) | |
| 262 | - | }}, | |
| 263 | - | {"palette before picker notices resize and copy", []byte("q"), func(d *Daemon, sess *session, ac *attachedClient) { | |
| 264 | - | d.enterCopyMode(sess, ac) | |
| 265 | - | d.enterPicker(sess, ac) | |
| 266 | - | d.enterPalette(sess, ac) | |
| 267 | - | }, func(t *testing.T, ac *attachedClient) { | |
| 268 | - | require.True(t, ac.overlays.paletteActive()) | |
| 269 | - | require.True(t, ac.overlays.resizeModeActive()) | |
| 270 | - | }}, | |
| 271 | - | {"picker before resize and copy", []byte("j"), func(d *Daemon, sess *session, ac *attachedClient) { | |
| 272 | - | d.enterCopyMode(sess, ac) | |
| 273 | - | d.enterPicker(sess, ac) | |
| 274 | - | }, func(t *testing.T, ac *attachedClient) { | |
| 275 | - | require.True(t, ac.overlays.pickerActive()) | |
| 276 | - | require.True(t, ac.overlays.resizeModeActive()) | |
| 277 | - | }}, | |
| 278 | - | {"notices before resize and copy", []byte("q"), func(d *Daemon, sess *session, ac *attachedClient) { | |
| 279 | - | d.enterCopyMode(sess, ac) | |
| 280 | - | d.enterNotices(sess, ac) | |
| 281 | - | }, func(t *testing.T, ac *attachedClient) { | |
| 282 | - | require.False(t, ac.overlays.noticesActive(), "q must be consumed by notices") | |
| 283 | - | require.True(t, ac.overlays.resizeModeActive()) | |
| 284 | - | }}, | |
| 285 | - | } | |
| 286 | - | for _, tt := range tests { | |
| 287 | - | t.Run(tt.name, func(t *testing.T) { | |
| 288 | - | d, sess, ac := resizeModeFixture(t) | |
| 289 | - | tt.setup(d, sess, ac) | |
| 290 | - | require.True(t, ac.overlays.HandleInput(d, tt.input)) | |
| 291 | - | tt.check(t, ac) | |
| 292 | - | }) | |
| 293 | - | } | |
| 294 | - | } | |
| 295 | - | ||
| 296 | - | func TestResizeModeWarningPersistsAndActionsShareTransaction(t *testing.T) { | |
| 297 | - | t.Run("too small warning leaves mode active", func(t *testing.T) { | |
| 298 | - | d, sess, ac := resizeModeFixture(t) | |
| 299 | - | tb := sess.activeTab() | |
| 300 | - | tb.mu.Lock() | |
| 301 | - | tb.size.Cols = 41 // two minimum-width panes and their separator | |
| 302 | - | tb.bumpLayoutGenerationLocked() | |
| 303 | - | tb.mu.Unlock() | |
| 304 | - | d.handleResizeInput(ac, []byte("l")) | |
| 305 | - | require.True(t, ac.overlays.resizeModeActive()) | |
| 306 | - | history := d.notices.history() | |
| 307 | - | require.NotEmpty(t, history) | |
| 308 | - | require.Equal(t, "pane cannot be resized further", history[len(history)-1].Message) | |
| 309 | - | }) | |
| 310 | - | ||
| 311 | - | t.Run("palette control and modal have the same committed weights", func(t *testing.T) { | |
| 312 | - | type invoke func(*Daemon, *session, *attachedClient) error | |
| 313 | - | invocations := []struct { | |
| 314 | - | name string | |
| 315 | - | run invoke | |
| 316 | - | }{ | |
| 317 | - | {"palette", func(d *Daemon, sess *session, ac *attachedClient) error { | |
| 318 | - | return (paletteExec{d: d, sess: sess, ac: ac}).GrowPaneWidth() | |
| 319 | - | }}, | |
| 320 | - | {"control", func(d *Daemon, sess *session, _ *attachedClient) error { | |
| 321 | - | target := resolveDaemonActionTarget(sess) | |
| 322 | - | return (controlExec{d: d, sess: sess, tab: target.tab, target: target}).GrowPaneWidth() | |
| 323 | - | }}, | |
| 324 | - | {"modal", func(d *Daemon, _ *session, ac *attachedClient) error { | |
| 325 | - | d.handleResizeInput(ac, []byte("l")) | |
| 326 | - | return nil | |
| 327 | - | }}, | |
| 328 | - | } | |
| 329 | - | var want []float64 | |
| 330 | - | for _, tt := range invocations { | |
| 331 | - | t.Run(tt.name, func(t *testing.T) { | |
| 332 | - | d, sess, ac := resizeModeFixture(t) | |
| 333 | - | require.NoError(t, tt.run(d, sess, ac)) | |
| 334 | - | tb := sess.activeTab() | |
| 335 | - | tb.mu.Lock() | |
| 336 | - | got := []float64{tb.tree.Root.Children[0].Weight, tb.tree.Root.Children[1].Weight} | |
| 337 | - | tb.mu.Unlock() | |
| 338 | - | if want == nil { | |
| 339 | - | want = got | |
| 340 | - | } else { | |
| 341 | - | require.Equal(t, want, got) | |
| 342 | - | } | |
| 343 | - | }) | |
| 344 | - | } | |
| 345 | - | }) | |
| 346 | - | } | |
| 347 | - | ||
| 348 | 127 | type resizeLockProbePTY struct { | |
| 349 | 128 | once sync.Once | |
| 350 | 129 | entered chan struct{} | |
| @@ -372,7 +151,7 @@ func TestResizeModeReleasesTabAndOverlayLocksBeforePTY(t *testing.T) { | |||
| 372 | 151 | // Build a live tree directly so the probe is not concurrently owned by a | |
| 373 | 152 | // pane reader; this isolates the lock boundary being asserted. | |
| 374 | 153 | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 375 | - | tb := sess.activeTab() | |
| 154 | + | tb := testAttachmentTab(sess) | |
| 376 | 155 | probe := &resizeLockProbePTY{entered: make(chan struct{}), release: make(chan struct{})} | |
| 377 | 156 | second := newPane("pane-2", probe, domain.Size{Cols: 39, Rows: 23}) | |
| 378 | 157 | tb.mu.Lock() | |
| @@ -398,18 +177,3 @@ func TestResizeModeReleasesTabAndOverlayLocksBeforePTY(t *testing.T) { | |||
| 398 | 177 | close(probe.release) | |
| 399 | 178 | <-done | |
| 400 | 179 | } | |
| 401 | - | ||
| 402 | - | func resizeModeFixture(t *testing.T) (*Daemon, *session, *attachedClient) { | |
| 403 | - | t.Helper() | |
| 404 | - | factory := &controlPTYFactory{} | |
| 405 | - | d := newTestDaemon(t, factory, stubClock{}) | |
| 406 | - | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |
| 407 | - | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 408 | - | require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK) | |
| 409 | - | require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-down", TargetSession: "work"}).OK) | |
| 410 | - | ac := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 411 | - | ac.initOverlays() | |
| 412 | - | ac.setSession(sess) | |
| 413 | - | require.NoError(t, d.enterResizeMode(sess, ac)) | |
| 414 | - | return d, sess, ac | |
| 415 | - | } | |
M internal/usecase/daemon/resize_publication.go +11 -13
| @@ -11,7 +11,7 @@ type resizeCommitPublication struct { | |||
| 11 | 11 | attachment *attachedClient | |
| 12 | 12 | lease *attachmentLease | |
| 13 | 13 | epoch uint64 | |
| 14 | - | role attachmentRoleToken | |
| 14 | + | connection attachmentConnectionToken | |
| 15 | 15 | coordinator *renderCoordinator | |
| 16 | 16 | observer ports.RuntimeObserver | |
| 17 | 17 | mark ports.RuntimeMark | |
| @@ -24,21 +24,19 @@ 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. | |
| 28 | - | if p.role.effect == nil { | |
| 27 | + | // Attached resize publication is admitted only through a live attachment effect ticket. | |
| 28 | + | if p.connection.effect == nil { | |
| 29 | 29 | return false | |
| 30 | 30 | } | |
| 31 | - | if p.role.effect.ended.Load() || p.role.sess != p.session || | |
| 32 | - | p.role.ac != p.attachment || p.role.role != attachmentActive || p.role.lease != p.lease || | |
| 33 | - | p.role.generation != p.attachment.roleGeneration.Load() || | |
| 34 | - | !p.attachment.transportSnapshotCurrent(p.role.transport) || | |
| 31 | + | if p.connection.effect.ended.Load() || p.connection.sess != p.session || | |
| 32 | + | p.connection.ac != p.attachment || p.connection.lease != p.lease || | |
| 33 | + | p.connection.generation != p.attachment.connectionGeneration.Load() || | |
| 34 | + | !p.attachment.transportSnapshotCurrent(p.connection.transport) || | |
| 35 | 35 | p.attachment.currentSession() != p.session || p.coordinator == nil { | |
| 36 | 36 | return false | |
| 37 | 37 | } | |
| 38 | - | p.session.mu.Lock() | |
| 39 | - | active := p.session.attachmentRoleLocked(p.attachment) == attachmentActive | |
| 40 | - | p.session.mu.Unlock() | |
| 41 | - | return active && p.coordinator.resizeCurrentForLease(p.epoch, p.attachment, p.lease, false) | |
| 38 | + | registered := attachmentRegistered(p.session, p.attachment) | |
| 39 | + | return registered && p.coordinator.resizeCurrentForLease(p.epoch, p.attachment, p.lease, false) | |
| 42 | 40 | } | |
| 43 | 41 | ||
| 44 | 42 | // emit performs observer work only after every resize-owner fence and | |
| @@ -59,7 +57,7 @@ func (p resizeCommitPublication) emit() bool { | |||
| 59 | 57 | // reservation with pane ownership. sendMu is acquired before resize fences, so | |
| 60 | 58 | // the canonical rule never acquires an attachment lock while a pane fence is | |
| 61 | 59 | // held. Observer callbacks run only after both lock families are released. | |
| 62 | - | func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, ticket *roleEffectTicket, size domain.Size) bool { | |
| 60 | + | func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, ticket *attachmentEffectTicket, size domain.Size) bool { | |
| 63 | 61 | if ac != nil && ticket == nil { | |
| 64 | 62 | return false | |
| 65 | 63 | } | |
| @@ -87,7 +85,7 @@ func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac * | |||
| 87 | 85 | mark: ports.NewRuntimeMark("daemon", ports.RuntimeResizeCommitted, 0, true), | |
| 88 | 86 | } | |
| 89 | 87 | if ticket != nil { | |
| 90 | - | publication.role = ticket.roleToken() | |
| 88 | + | publication.connection = ticket.connectionToken() | |
| 91 | 89 | publication.coordinator = sess.renderCoordinator() | |
| 92 | 90 | } | |
| 93 | 91 | if !publication.current() { | |
M internal/usecase/daemon/resume.go +130 -55
| @@ -37,8 +37,8 @@ func (d *Daemon) nextResumeTokenLocked() uint64 { | |||
| 37 | 37 | // can wait out the detach→park gap. Callers must publish this before clearing | |
| 38 | 38 | // the live seat so resume never observes both registries empty for a still-valid | |
| 39 | 39 | // credential. Under d.mu then sess.mu it advertises only when the daemon still | |
| 40 | - | // registers the exact session and ac is still the exact active or snatched | |
| 41 | - | // owner; otherwise it returns 0 and never creates a marker after terminal | |
| 40 | + | // registers the exact session and ac remains a registered attachment; otherwise | |
| 41 | + | // it returns 0 and never creates a marker after terminal | |
| 42 | 42 | // cleanup. Returns the resume token, or 0 when parking cannot be advertised. | |
| 43 | 43 | func (d *Daemon) markParkingInFlight(sess *session, ac *attachedClient) uint64 { | |
| 44 | 44 | if sess == nil || ac == nil || !ac.resumeCapable { | |
| @@ -50,10 +50,9 @@ func (d *Daemon) markParkingInFlight(sess *session, ac *attachedClient) uint64 { | |||
| 50 | 50 | return 0 | |
| 51 | 51 | } | |
| 52 | 52 | sess.mu.Lock() | |
| 53 | - | role := sess.attachmentRoleLocked(ac) | |
| 54 | - | owner := role == attachmentActive || role == attachmentSnatched | |
| 53 | + | registered := attachmentRegisteredLocked(sess, ac) | |
| 55 | 54 | sess.mu.Unlock() | |
| 56 | - | if !owner { | |
| 55 | + | if !registered { | |
| 57 | 56 | return 0 | |
| 58 | 57 | } | |
| 59 | 58 | if ac.resumeToken == 0 { | |
| @@ -96,8 +95,8 @@ func (d *Daemon) clearParkingInFlight(token uint64, ac *attachedClient) { | |||
| 96 | 95 | } | |
| 97 | 96 | ||
| 98 | 97 | // clearParkingInFlightIfAbandoned drops a pre-detach parking marker when this | |
| 99 | - | // teardown lost the seat while still the live owner (stale transport fence for | |
| 100 | - | // active or snatched). If another path already detached/unrouted, that path | |
| 98 | + | // teardown lost the seat while still the live owner (stale transport fence). | |
| 99 | + | // If another path already detached/unrouted, that path | |
| 101 | 100 | // owns park publication. | |
| 102 | 101 | func (d *Daemon) clearParkingInFlightIfAbandoned(sess *session, ac *attachedClient, token uint64) { | |
| 103 | 102 | if token == 0 || ac == nil { | |
| @@ -111,8 +110,7 @@ func (d *Daemon) clearParkingInFlightIfAbandoned(sess *session, ac *attachedClie | |||
| 111 | 110 | stillOwner := false | |
| 112 | 111 | if sess != nil { | |
| 113 | 112 | sess.mu.Lock() | |
| 114 | - | role := sess.attachmentRoleLocked(ac) | |
| 115 | - | stillOwner = role == attachmentActive || role == attachmentSnatched | |
| 113 | + | stillOwner = attachmentRegisteredLocked(sess, ac) | |
| 116 | 114 | sess.mu.Unlock() | |
| 117 | 115 | } | |
| 118 | 116 | if !stillOwner { | |
| @@ -204,10 +202,6 @@ func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool { | |||
| 204 | 202 | } | |
| 205 | 203 | ||
| 206 | 204 | func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool { | |
| 207 | - | return d.parkAttachmentAs(sess, ac, attachmentActive) | |
| 208 | - | } | |
| 209 | - | ||
| 210 | - | func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attachmentRole) bool { | |
| 211 | 205 | if !d.prepareParkAttachment(sess, ac) { | |
| 212 | 206 | return false | |
| 213 | 207 | } | |
| @@ -228,13 +222,6 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach | |||
| 228 | 222 | d.mu.Unlock() | |
| 229 | 223 | return false | |
| 230 | 224 | } | |
| 231 | - | if role == attachmentActive { | |
| 232 | - | sess.mu.Lock() | |
| 233 | - | if sess.client != nil { | |
| 234 | - | role = attachmentSnatched | |
| 235 | - | } | |
| 236 | - | sess.mu.Unlock() | |
| 237 | - | } | |
| 238 | 225 | token := ac.resumeToken | |
| 239 | 226 | var ( | |
| 240 | 227 | oldRetirement *parkedAttachmentRetirement | |
| @@ -250,7 +237,7 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach | |||
| 250 | 237 | } | |
| 251 | 238 | grace := d.resumeParkGrace | |
| 252 | 239 | timer := d.clock.NewTimer(grace) | |
| 253 | - | parked := &parkedAttachment{sess: sess, ac: ac, role: role, pickerGeneration: pickerGeneration, timer: timer, done: make(chan struct{})} | |
| 240 | + | parked := &parkedAttachment{sess: sess, ac: ac, pickerGeneration: pickerGeneration, timer: timer, done: make(chan struct{})} | |
| 254 | 241 | ac.parked = true | |
| 255 | 242 | d.parked[token] = parked | |
| 256 | 243 | d.clearParkingInFlightLocked(token, ac) | |
| @@ -265,20 +252,31 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach | |||
| 265 | 252 | oldSame.closeDone() | |
| 266 | 253 | } | |
| 267 | 254 | d.log.Info("client parked for resume", "session", sess.name, "grace", grace) | |
| 255 | + | d.watchParkedTimer(token, parked) | |
| 256 | + | return true | |
| 257 | + | } | |
| 268 | 258 | ||
| 269 | - | go func(token uint64, parked *parkedAttachment) { | |
| 259 | + | func (d *Daemon) watchParkedTimer(token uint64, parked *parkedAttachment) { | |
| 260 | + | if parked == nil || parked.timer == nil { | |
| 261 | + | return | |
| 262 | + | } | |
| 263 | + | timer := parked.timer | |
| 264 | + | go func() { | |
| 270 | 265 | select { | |
| 271 | 266 | case <-timer.C(): | |
| 272 | 267 | d.expireParked(token, parked) | |
| 273 | 268 | case <-parked.done: | |
| 274 | 269 | } | |
| 275 | - | }(token, parked) | |
| 276 | - | return true | |
| 270 | + | }() | |
| 277 | 271 | } | |
| 278 | 272 | ||
| 279 | 273 | func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) { | |
| 280 | 274 | d.mu.Lock() | |
| 281 | 275 | if d.parked[token] == parked { | |
| 276 | + | if parked.claimed { | |
| 277 | + | d.mu.Unlock() | |
| 278 | + | return | |
| 279 | + | } | |
| 282 | 280 | d.removeParkedLocked(token, parked) | |
| 283 | 281 | d.mu.Unlock() | |
| 284 | 282 | d.closePickerIfCurrent(parked.ac, nil, parked.pickerGeneration) | |
| @@ -308,14 +306,81 @@ func (p *parkedAttachment) closeDone() { | |||
| 308 | 306 | p.doneOnce.Do(func() { close(p.done) }) | |
| 309 | 307 | } | |
| 310 | 308 | ||
| 311 | - | // demoteParkedActiveForSessionLocked preserves resumable predecessors when a | |
| 312 | - | // new active owner is published. Caller holds d.mu. | |
| 313 | - | func (d *Daemon) demoteParkedActiveForSessionLocked(sess *session) { | |
| 314 | - | for _, parked := range d.parked { | |
| 315 | - | if parked.sess == sess && parked.role == attachmentActive { | |
| 316 | - | parked.role = attachmentSnatched | |
| 317 | - | } | |
| 309 | + | // commitResumeClaim consumes the old credential only after Welcome has been | |
| 310 | + | // written successfully. The claim itself is serialized by d.mu, so a racing | |
| 311 | + | // resume cannot acquire the same parked attachment. | |
| 312 | + | func (d *Daemon) commitResumeClaim(ac *attachedClient) bool { | |
| 313 | + | if d == nil || ac == nil { | |
| 314 | + | return false | |
| 315 | + | } | |
| 316 | + | d.mu.Lock() | |
| 317 | + | defer d.mu.Unlock() | |
| 318 | + | token := ac.resumeClaimToken | |
| 319 | + | if token == 0 { | |
| 320 | + | return true | |
| 321 | + | } | |
| 322 | + | parked := d.parked[token] | |
| 323 | + | if parked == nil || parked.ac != ac || !parked.claimed { | |
| 324 | + | return false | |
| 325 | + | } | |
| 326 | + | delete(d.parked, token) | |
| 327 | + | parked.claimed = false | |
| 328 | + | if parked.timer != nil { | |
| 329 | + | parked.timer.Stop() | |
| 330 | + | } | |
| 331 | + | parked.closeDone() | |
| 332 | + | ac.resumeClaimToken = 0 | |
| 333 | + | return true | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | // abortResumeClaim restores a parked credential when the transport could not | |
| 337 | + | // complete the pre-claim Welcome handshake. It invalidates the claimed link | |
| 338 | + | // generation before making the credential available to another winner. | |
| 339 | + | func (d *Daemon) abortResumeClaim(ac *attachedClient) bool { | |
| 340 | + | if d == nil || ac == nil { | |
| 341 | + | return false | |
| 318 | 342 | } | |
| 343 | + | d.mu.Lock() | |
| 344 | + | token := ac.resumeClaimToken | |
| 345 | + | parked := d.parked[token] | |
| 346 | + | if token == 0 || parked == nil || parked.ac != ac || !parked.claimed { | |
| 347 | + | d.mu.Unlock() | |
| 348 | + | return false | |
| 349 | + | } | |
| 350 | + | parked.claimed = false | |
| 351 | + | ac.resumeClaimToken = 0 | |
| 352 | + | ac.resumeToken = token | |
| 353 | + | ac.parked = true | |
| 354 | + | ac.connectionGeneration.Add(1) | |
| 355 | + | sess := parked.sess | |
| 356 | + | if sess != nil { | |
| 357 | + | sess.mu.Lock() | |
| 358 | + | sess.unregisterAttachmentLocked(ac) | |
| 359 | + | sess.mu.Unlock() | |
| 360 | + | } | |
| 361 | + | // Recreate the parked entry and its watcher instead of restoring a timer | |
| 362 | + | // that may already have fired while the claim held the old entry. This also | |
| 363 | + | // makes a failed handshake's credential expiry deterministic under fake clocks. | |
| 364 | + | if parked.timer != nil { | |
| 365 | + | parked.timer.Stop() | |
| 366 | + | } | |
| 367 | + | parked.closeDone() | |
| 368 | + | rearmed := &parkedAttachment{ | |
| 369 | + | sess: parked.sess, | |
| 370 | + | ac: ac, | |
| 371 | + | pickerGeneration: parked.pickerGeneration, | |
| 372 | + | timer: d.clock.NewTimer(d.resumeParkGrace), | |
| 373 | + | done: make(chan struct{}), | |
| 374 | + | } | |
| 375 | + | d.parked[token] = rearmed | |
| 376 | + | captured := ac.transportSnapshot().transport | |
| 377 | + | ac.setSession(nil) | |
| 378 | + | d.mu.Unlock() | |
| 379 | + | d.watchParkedTimer(token, rearmed) | |
| 380 | + | if captured != nil { | |
| 381 | + | _ = ac.closeCapturedTransport(ac.revokeTransport(captured)) | |
| 382 | + | } | |
| 383 | + | return true | |
| 319 | 384 | } | |
| 320 | 385 | ||
| 321 | 386 | type parkedAttachmentRetirement struct { | |
| @@ -329,7 +394,7 @@ func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttach | |||
| 329 | 394 | parked.ac.clearPreviousSession() | |
| 330 | 395 | parked.ac.resumeToken = 0 | |
| 331 | 396 | parked.ac.parked = false | |
| 332 | - | parked.ac.roleGeneration.Add(1) | |
| 397 | + | parked.ac.connectionGeneration.Add(1) | |
| 333 | 398 | parked.ac.setSession(nil) | |
| 334 | 399 | return parkedAttachmentRetirement{ | |
| 335 | 400 | parked: parked, | |
| @@ -398,11 +463,11 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma | |||
| 398 | 463 | ) | |
| 399 | 464 | if sess != nil { | |
| 400 | 465 | sess.mu.Lock() | |
| 401 | - | ac = sess.client | |
| 402 | - | // resumeToken writes run under d.mu; read the live credential here | |
| 403 | - | // before releasing the daemon lock, then revalidate via the exact | |
| 404 | - | // transport incarnation through detachIfCurrentTransport. | |
| 405 | - | if ac != nil && ac.resumeCapable && ac.resumeToken == h.ResumeToken { | |
| 466 | + | for candidate := range sess.attachments { | |
| 467 | + | if !candidate.resumeCapable || candidate.resumeToken != h.ResumeToken { | |
| 468 | + | continue | |
| 469 | + | } | |
| 470 | + | ac = candidate | |
| 406 | 471 | if ac.clientID != h.ClientID { | |
| 407 | 472 | clientMismatch = true | |
| 408 | 473 | } else if ac.proxied != h.Proxied { | |
| @@ -411,6 +476,7 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma | |||
| 411 | 476 | oldSnap = ac.transportSnapshot() | |
| 412 | 477 | credentialMatch = oldSnap.transport != nil | |
| 413 | 478 | } | |
| 479 | + | break | |
| 414 | 480 | } | |
| 415 | 481 | sess.mu.Unlock() | |
| 416 | 482 | } | |
| @@ -494,19 +560,29 @@ func (d *Daemon) resumeParked(h ports.Hello, tr ports.Transport, sz domain.Size) | |||
| 494 | 560 | d.beforeResumeParkedSendMu() | |
| 495 | 561 | } | |
| 496 | 562 | ac.sendMu.Lock() | |
| 497 | - | defer ac.sendMu.Unlock() | |
| 498 | 563 | d.mu.Lock() | |
| 499 | - | defer d.mu.Unlock() | |
| 500 | 564 | if d.closing { | |
| 565 | + | d.mu.Unlock() | |
| 566 | + | ac.sendMu.Unlock() | |
| 501 | 567 | return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"} | |
| 502 | 568 | } | |
| 503 | - | if d.parked[h.ResumeToken] != parked { | |
| 569 | + | if d.parked[h.ResumeToken] != parked || parked.claimed { | |
| 504 | 570 | if d.sessions[parked.sess.id] == parked.sess { | |
| 571 | + | d.mu.Unlock() | |
| 572 | + | ac.sendMu.Unlock() | |
| 505 | 573 | return nil, nil, false, errResumeTokenLifecycleRace | |
| 506 | 574 | } | |
| 575 | + | d.mu.Unlock() | |
| 576 | + | ac.sendMu.Unlock() | |
| 507 | 577 | return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | |
| 508 | 578 | } | |
| 509 | - | return d.resumeParkedLocked(h, tr, sz) | |
| 579 | + | sess, resumed, ok, err := d.resumeParkedLocked(h, tr, sz) | |
| 580 | + | d.mu.Unlock() | |
| 581 | + | ac.sendMu.Unlock() | |
| 582 | + | if err != nil && resumed != nil { | |
| 583 | + | d.abortResumeClaim(resumed) | |
| 584 | + | } | |
| 585 | + | return sess, resumed, ok, err | |
| 510 | 586 | } | |
| 511 | 587 | ||
| 512 | 588 | // resumeParkedLocked completes a validated resume. Caller holds both ac.sendMu | |
| @@ -523,13 +599,10 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain | |||
| 523 | 599 | d.log.Warn("resume rejected", "session", sess.name, "registered", false) | |
| 524 | 600 | return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | |
| 525 | 601 | } | |
| 526 | - | sess.mu.Lock() | |
| 527 | - | active := sess.client != nil | |
| 528 | - | sess.mu.Unlock() | |
| 529 | - | if active && parked.role == attachmentActive { | |
| 530 | - | parked.role = attachmentSnatched | |
| 531 | - | } | |
| 532 | 602 | ac := parked.ac | |
| 603 | + | if parked.claimed { | |
| 604 | + | return nil, nil, false, errResumeTokenLifecycleRace | |
| 605 | + | } | |
| 533 | 606 | if ac.clientID != h.ClientID || ac.proxied != h.Proxied { | |
| 534 | 607 | reason := "client id mismatch" | |
| 535 | 608 | if ac.proxied != h.Proxied { | |
| @@ -538,14 +611,17 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain | |||
| 538 | 611 | d.log.Warn("resume rejected", "session", sess.name, "err", reason) | |
| 539 | 612 | return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | |
| 540 | 613 | } | |
| 541 | - | delete(d.parked, h.ResumeToken) | |
| 542 | - | parked.timer.Stop() | |
| 543 | - | parked.closeDone() | |
| 614 | + | // Claim the attachment while retaining the old credential until Welcome is | |
| 615 | + | // accepted. A failed handshake can therefore restore this exact parked | |
| 616 | + | // entry; another resume sees claimed and loses atomically. | |
| 617 | + | parked.claimed = true | |
| 618 | + | ac.resumeClaimToken = h.ResumeToken | |
| 544 | 619 | // The parked attachment has no session owner and cannot paint. Retire the | |
| 545 | 620 | // abandoned transport's output chain before binding the replacement so the | |
| 546 | 621 | // mandatory first paint cannot be blocked by ACKs that died with the link. | |
| 547 | 622 | ac.rebaseOutput() | |
| 548 | 623 | ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight)) | |
| 624 | + | ac.output.syncCapacityLocked() | |
| 549 | 625 | ac.replaceTransport(tr) | |
| 550 | 626 | ac.size = sz | |
| 551 | 627 | ac.resumeToken = d.nextResumeTokenLocked() | |
| @@ -562,17 +638,16 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain | |||
| 562 | 638 | d.mu.Unlock() | |
| 563 | 639 | ac.sendMu.Unlock() | |
| 564 | 640 | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 565 | - | target: sess, | |
| 566 | - | next: ac, | |
| 567 | - | expectedRole: attachmentDetached, | |
| 568 | - | targetRole: parked.role, | |
| 641 | + | target: sess, | |
| 642 | + | next: ac, | |
| 643 | + | ||
| 569 | 644 | expectedTransport: ac.transportSnapshot(), | |
| 570 | 645 | ready: false, | |
| 571 | 646 | }) | |
| 572 | 647 | ac.sendMu.Lock() | |
| 573 | 648 | d.mu.Lock() | |
| 574 | 649 | if err != nil { | |
| 575 | - | return nil, nil, false, &protoErr{ports.ErrInternal, "resume attachment transition failed"} | |
| 650 | + | return sess, ac, false, &protoErr{ports.ErrInternal, "resume attachment transition failed"} | |
| 576 | 651 | } | |
| 577 | 652 | d.deferAttachmentTransitionCleanups(transition) | |
| 578 | 653 | d.touchMRU(sess) | |
M internal/usecase/daemon/resume_test.go +79 -523
| @@ -5,7 +5,6 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "strings" | |
| 7 | 7 | "sync" | |
| 8 | - | "sync/atomic" | |
| 9 | 8 | "testing" | |
| 10 | 9 | "time" | |
| 11 | 10 | ||
| @@ -98,6 +97,53 @@ func helloResumeCapable(intent uint8, name string, token uint64) ports.Hello { | |||
| 98 | 97 | } | |
| 99 | 98 | } | |
| 100 | 99 | ||
| 100 | + | func TestFailedResumeRearmsParkExpiryAfterClaimTimerFires(t *testing.T) { | |
| 101 | + | clock := &signalClock{timers: make(chan *signalTimer, 8)} | |
| 102 | + | pty, release := newBlockingPTY(t) | |
| 103 | + | defer release() | |
| 104 | + | d := newTestDaemon(t, newFactory(t, pty), clock) | |
| 105 | + | ||
| 106 | + | oldTransport := &closeTrackingTransport{} | |
| 107 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 108 | + | require.NoError(t, err) | |
| 109 | + | token := ac.resumeToken | |
| 110 | + | d.clientGone(sess, ac, oldTransport, false) | |
| 111 | + | parkTimer := <-clock.timers | |
| 112 | + | d.mu.Lock() | |
| 113 | + | parked := d.parked[token] | |
| 114 | + | d.mu.Unlock() | |
| 115 | + | require.NotNil(t, parked) | |
| 116 | + | ||
| 117 | + | resumed, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, defaultSize) | |
| 118 | + | require.NoError(t, err) | |
| 119 | + | require.True(t, ok) | |
| 120 | + | require.Same(t, sess, resumed) | |
| 121 | + | require.Same(t, ac, resumedAC) | |
| 122 | + | ||
| 123 | + | // Drive the original fake timer after the claim. The old entry remains | |
| 124 | + | // claimable only until abort; abort must install a fresh expiry rather than | |
| 125 | + | // restoring a credential whose timer has already fired. | |
| 126 | + | parkTimer.ch <- time.Time{} | |
| 127 | + | d.expireParked(token, parked) | |
| 128 | + | require.True(t, d.abortResumeClaim(ac)) | |
| 129 | + | rearmedTimer := <-clock.timers | |
| 130 | + | require.NotSame(t, parkTimer, rearmedTimer) | |
| 131 | + | d.mu.Lock() | |
| 132 | + | rearmed := d.parked[token] | |
| 133 | + | d.mu.Unlock() | |
| 134 | + | require.NotNil(t, rearmed) | |
| 135 | + | require.False(t, rearmed.claimed) | |
| 136 | + | rearmedTimer.ch <- time.Time{} | |
| 137 | + | d.expireParked(token, rearmed) | |
| 138 | + | d.mu.Lock() | |
| 139 | + | _, retained := d.parked[token] | |
| 140 | + | d.mu.Unlock() | |
| 141 | + | require.False(t, retained, "rearmed credential must still expire") | |
| 142 | + | ||
| 143 | + | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |
| 144 | + | d.sessWg.Wait() | |
| 145 | + | } | |
| 146 | + | ||
| 101 | 147 | func TestNamedLinkLossParks(t *testing.T) { | |
| 102 | 148 | pty, release := newBlockingPTY(t) | |
| 103 | 149 | defer release() | |
| @@ -111,7 +157,7 @@ func TestNamedLinkLossParks(t *testing.T) { | |||
| 111 | 157 | ||
| 112 | 158 | d.clientGone(sess, ac, ac.transport(), false) | |
| 113 | 159 | require.Equal(t, 1, sessionCount(d), "named session survives parked link loss") | |
| 114 | - | require.Nil(t, sess.client) | |
| 160 | + | require.Empty(t, sess.snapshotAttachments()) | |
| 115 | 161 | d.mu.Lock() | |
| 116 | 162 | _, parked := d.parked[token] | |
| 117 | 163 | d.mu.Unlock() | |
| @@ -215,7 +261,7 @@ func TestHandleHelloResumeDefersFreshOutputUntilWelcome(t *testing.T) { | |||
| 215 | 261 | welcome := <-tr.sends | |
| 216 | 262 | require.Equal(t, ports.MsgWelcome, welcome.Type) | |
| 217 | 263 | sess.mu.Lock() | |
| 218 | - | resumed := sess.client | |
| 264 | + | resumed := sess.snapshotAttachmentsLocked()[0] | |
| 219 | 265 | sess.mu.Unlock() | |
| 220 | 266 | require.Same(t, ac, resumed) | |
| 221 | 267 | ||
| @@ -254,7 +300,7 @@ func TestEphemeralLinkLossParksAndResumes(t *testing.T) { | |||
| 254 | 300 | ||
| 255 | 301 | d.clientGone(sess, ac, ac.transport(), false) | |
| 256 | 302 | require.Equal(t, 1, sessionCount(d), "ephemeral link loss keeps session alive") | |
| 257 | - | require.Nil(t, sess.client) | |
| 303 | + | require.Empty(t, sess.snapshotAttachments()) | |
| 258 | 304 | d.mu.Lock() | |
| 259 | 305 | _, parked := d.parked[token] | |
| 260 | 306 | d.mu.Unlock() | |
| @@ -266,7 +312,7 @@ func TestEphemeralLinkLossParksAndResumes(t *testing.T) { | |||
| 266 | 312 | require.Same(t, sess, resumedSess) | |
| 267 | 313 | require.Same(t, ac, resumedAC) | |
| 268 | 314 | require.NotEqual(t, token, resumedAC.resumeToken, "resume rotates token") | |
| 269 | - | require.Same(t, resumedAC, sess.client) | |
| 315 | + | require.Contains(t, sess.snapshotAttachments(), resumedAC) | |
| 270 | 316 | } | |
| 271 | 317 | ||
| 272 | 318 | // TestResumeParkedTokenReplacedDuringWaitFailsClosed covers the lifecycle race | |
| @@ -284,7 +330,7 @@ func TestResumeParkedTokenReplacedDuringWaitFailsClosed(t *testing.T) { | |||
| 284 | 330 | token := ac.resumeToken | |
| 285 | 331 | require.NotZero(t, token) | |
| 286 | 332 | d.clientGone(sess, ac, oldTr, false) | |
| 287 | - | require.Nil(t, sess.client) | |
| 333 | + | require.Empty(t, sess.snapshotAttachments()) | |
| 288 | 334 | d.mu.Lock() | |
| 289 | 335 | parked := d.parked[token] | |
| 290 | 336 | sessionsBefore := len(d.sessions) | |
| @@ -330,7 +376,7 @@ func TestResumeParkedTokenReplacedDuringWaitFailsClosed(t *testing.T) { | |||
| 330 | 376 | require.ErrorAs(t, got.err, &pe) | |
| 331 | 377 | require.Equal(t, ports.ErrNoSuchSession, pe.code) | |
| 332 | 378 | require.Contains(t, pe.Error(), "resume token is no longer valid") | |
| 333 | - | require.Nil(t, sess.client, "lifecycle-race resume must not take over the named session") | |
| 379 | + | require.Empty(t, sess.snapshotAttachments(), "lifecycle-race resume must not take over the named session") | |
| 334 | 380 | require.Empty(t, resumeTr.Sends(), "failed resume must not complete a Welcome handshake") | |
| 335 | 381 | d.mu.Lock() | |
| 336 | 382 | _, stillParked := d.parked[token] | |
| @@ -355,7 +401,7 @@ func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) { | |||
| 355 | 401 | require.NoError(t, err) | |
| 356 | 402 | token := ac.resumeToken | |
| 357 | 403 | require.NotZero(t, token) | |
| 358 | - | require.Same(t, ac, sess.client) | |
| 404 | + | require.Contains(t, sess.snapshotAttachments(), ac) | |
| 359 | 405 | d.mu.Lock() | |
| 360 | 406 | _, parkedAtStart := d.parked[token] | |
| 361 | 407 | sessionsBefore := len(d.sessions) | |
| @@ -404,7 +450,7 @@ func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) { | |||
| 404 | 450 | require.ErrorAs(t, got.err, &pe) | |
| 405 | 451 | require.Equal(t, ports.ErrNoSuchSession, pe.code) | |
| 406 | 452 | require.Contains(t, pe.Error(), "resume token is no longer valid") | |
| 407 | - | require.Nil(t, sess.client, "losing live resume must not take over the named session") | |
| 453 | + | require.Empty(t, sess.snapshotAttachments(), "losing live resume must not take over the named session") | |
| 408 | 454 | require.Empty(t, resumeTr.Sends(), "failed live resume must not complete a Welcome handshake") | |
| 409 | 455 | d.mu.Lock() | |
| 410 | 456 | _, stillParked := d.parked[token] | |
| @@ -418,70 +464,6 @@ func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) { | |||
| 418 | 464 | // reconnect race: IntentResume arrives with the live attachment's token before | |
| 419 | 465 | // transport teardown has parked it. Same client must recover; unknown and | |
| 420 | 466 | // mismatched credentials stay fail-closed. | |
| 421 | - | func TestResumeActiveTokenBeforeParkRecoversSameAttachment(t *testing.T) { | |
| 422 | - | pty, release := newBlockingPTY(t) | |
| 423 | - | defer release() | |
| 424 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 425 | - | ||
| 426 | - | oldTr := &closeTrackingTransport{} | |
| 427 | - | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr) | |
| 428 | - | require.NoError(t, err) | |
| 429 | - | token := ac.resumeToken | |
| 430 | - | require.NotZero(t, token) | |
| 431 | - | require.True(t, ac.resumeCapable) | |
| 432 | - | require.Same(t, ac, sess.client) | |
| 433 | - | d.mu.Lock() | |
| 434 | - | _, parked := d.parked[token] | |
| 435 | - | d.mu.Unlock() | |
| 436 | - | require.False(t, parked, "fixture: live attachment must not be parked yet") | |
| 437 | - | ||
| 438 | - | _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{}) | |
| 439 | - | require.Error(t, err) | |
| 440 | - | var unknownErr *protoErr | |
| 441 | - | require.ErrorAs(t, err, &unknownErr) | |
| 442 | - | require.Equal(t, ports.ErrNoSuchSession, unknownErr.code) | |
| 443 | - | require.Contains(t, unknownErr.Error(), "resume token is no longer valid") | |
| 444 | - | require.Same(t, ac, sess.client) | |
| 445 | - | require.Equal(t, token, ac.resumeToken) | |
| 446 | - | require.Same(t, oldTr, ac.transport()) | |
| 447 | - | ||
| 448 | - | wrongClient := helloResumeCapable(ports.IntentResume, "work", token) | |
| 449 | - | wrongClient.ClientID = [16]byte{9, 9, 9, 9} | |
| 450 | - | _, _, err = d.route(wrongClient, &closeTrackingTransport{}) | |
| 451 | - | require.Error(t, err) | |
| 452 | - | var mismatchErr *protoErr | |
| 453 | - | require.ErrorAs(t, err, &mismatchErr) | |
| 454 | - | require.Equal(t, ports.ErrNoSuchSession, mismatchErr.code) | |
| 455 | - | require.Contains(t, mismatchErr.Error(), "resume token is no longer valid") | |
| 456 | - | require.Same(t, ac, sess.client) | |
| 457 | - | require.Equal(t, token, ac.resumeToken) | |
| 458 | - | require.Same(t, oldTr, ac.transport()) | |
| 459 | - | ||
| 460 | - | newTr := &closeTrackingTransport{} | |
| 461 | - | resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr) | |
| 462 | - | require.NoError(t, err, "same-token same-client IntentResume must recover before parking") | |
| 463 | - | require.Same(t, sess, resumedSess) | |
| 464 | - | require.Same(t, ac, resumedAC) | |
| 465 | - | require.Same(t, ac, sess.client) | |
| 466 | - | require.Same(t, newTr, ac.transport()) | |
| 467 | - | require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential") | |
| 468 | - | d.mu.Lock() | |
| 469 | - | _, oldTokenParked := d.parked[token] | |
| 470 | - | d.mu.Unlock() | |
| 471 | - | require.False(t, oldTokenParked, "consumed live credential must not remain resumeable") | |
| 472 | - | ||
| 473 | - | d.clientGone(sess, ac, oldTr, false) | |
| 474 | - | require.Same(t, ac, sess.client, "retired pre-park transport must not detach the rebound attachment") | |
| 475 | - | require.Same(t, newTr, ac.transport()) | |
| 476 | - | require.False(t, newTr.Closed()) | |
| 477 | - | } | |
| 478 | - | ||
| 479 | - | // TestResumeDuringTeardownBeforeParkRecoversSameAttachment covers the opposite | |
| 480 | - | // interleaving from live-before-park recovery: old-link teardown wins | |
| 481 | - | // detachIfCurrentTransport, then pauses before parkAttachment publishes the | |
| 482 | - | // resume token. The parking-in-flight marker must already be visible in that | |
| 483 | - | // gap (published before detach). Same-client IntentResume must wait it out and | |
| 484 | - | // recover; unknown tokens stay fail-closed. | |
| 485 | 467 | func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) { | |
| 486 | 468 | pty, release := newBlockingPTY(t) | |
| 487 | 469 | defer release() | |
| @@ -492,7 +474,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) { | |||
| 492 | 474 | require.NoError(t, err) | |
| 493 | 475 | token := ac.resumeToken | |
| 494 | 476 | require.NotZero(t, token) | |
| 495 | - | require.Same(t, ac, sess.client) | |
| 477 | + | require.Contains(t, sess.snapshotAttachments(), ac) | |
| 496 | 478 | ||
| 497 | 479 | reachedGap := make(chan struct{}) | |
| 498 | 480 | releaseGap := make(chan struct{}) | |
| @@ -510,7 +492,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) { | |||
| 510 | 492 | }() | |
| 511 | 493 | awaitTestCompletion(t, reachedGap, "teardown did not pause after detach before park") | |
| 512 | 494 | ||
| 513 | - | require.Nil(t, sess.client, "fixture: detach must have cleared the live owner") | |
| 495 | + | require.Empty(t, sess.snapshotAttachments(), "fixture: detach must have cleared the live owner") | |
| 514 | 496 | d.mu.Lock() | |
| 515 | 497 | parkingInGap := d.parking[token] | |
| 516 | 498 | _, parkedInGap := d.parked[token] | |
| @@ -558,9 +540,10 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) { | |||
| 558 | 540 | require.NoError(t, got.err, "same-client token must recover across detach-before-park") | |
| 559 | 541 | require.Same(t, sess, got.sess) | |
| 560 | 542 | require.Same(t, ac, got.ac) | |
| 561 | - | require.Same(t, ac, sess.client) | |
| 543 | + | require.Contains(t, sess.snapshotAttachments(), ac) | |
| 562 | 544 | require.Same(t, newTr, ac.transport()) | |
| 563 | 545 | require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential") | |
| 546 | + | require.True(t, d.commitResumeClaim(ac), "successful resume must consume its parked credential") | |
| 564 | 547 | require.True(t, oldTr.Closed(), "teardown/resume must retire the old transport") | |
| 565 | 548 | require.False(t, newTr.Closed(), "rebound transport must survive") | |
| 566 | 549 | d.mu.Lock() | |
| @@ -571,7 +554,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) { | |||
| 571 | 554 | require.False(t, stillParking, "parking marker must be consumed after park/resume") | |
| 572 | 555 | ||
| 573 | 556 | d.clientGone(sess, ac, oldTr, false) | |
| 574 | - | require.Same(t, ac, sess.client, "stale old-link cleanup must not detach the rebound attachment") | |
| 557 | + | require.Contains(t, sess.snapshotAttachments(), ac, "stale old-link cleanup must not detach the rebound attachment") | |
| 575 | 558 | require.Same(t, newTr, ac.transport()) | |
| 576 | 559 | require.False(t, newTr.Closed()) | |
| 577 | 560 | } | |
| @@ -590,7 +573,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) { | |||
| 590 | 573 | require.NoError(t, err) | |
| 591 | 574 | token := ac.resumeToken | |
| 592 | 575 | require.NotZero(t, token) | |
| 593 | - | require.Same(t, ac, sess.client) | |
| 576 | + | require.Contains(t, sess.snapshotAttachments(), ac) | |
| 594 | 577 | ||
| 595 | 578 | reachedGap := make(chan struct{}) | |
| 596 | 579 | releaseGap := make(chan struct{}) | |
| @@ -614,7 +597,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) { | |||
| 614 | 597 | }() | |
| 615 | 598 | awaitTestCompletion(t, reachedGap, "first live resume did not pause after detach before park") | |
| 616 | 599 | ||
| 617 | - | require.Nil(t, sess.client, "fixture: winning resume must have cleared the live owner") | |
| 600 | + | require.Empty(t, sess.snapshotAttachments(), "fixture: winning resume must have cleared the live owner") | |
| 618 | 601 | d.mu.Lock() | |
| 619 | 602 | parkingInGap := d.parking[token] | |
| 620 | 603 | _, parkedInGap := d.parked[token] | |
| @@ -660,7 +643,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) { | |||
| 660 | 643 | ||
| 661 | 644 | require.Same(t, sess, winner.sess) | |
| 662 | 645 | require.Same(t, ac, winner.ac) | |
| 663 | - | require.Same(t, ac, sess.client) | |
| 646 | + | require.Contains(t, sess.snapshotAttachments(), ac) | |
| 664 | 647 | require.Same(t, winnerTr, ac.transport()) | |
| 665 | 648 | require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential") | |
| 666 | 649 | require.True(t, oldTr.Closed(), "winning resume must retire the old transport") | |
| @@ -672,6 +655,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) { | |||
| 672 | 655 | require.Contains(t, loserErr.Error(), "resume token is no longer valid") | |
| 673 | 656 | require.True(t, loserTr.Closed() || len(loserTr.Sends()) == 0, "losing resume must not complete Welcome") | |
| 674 | 657 | ||
| 658 | + | require.True(t, d.commitResumeClaim(ac), "successful concurrent resume must consume its parked credential") | |
| 675 | 659 | d.mu.Lock() | |
| 676 | 660 | _, oldTokenParked := d.parked[token] | |
| 677 | 661 | _, stillParking := d.parking[token] | |
| @@ -680,370 +664,11 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) { | |||
| 680 | 664 | require.False(t, stillParking, "parking marker must be consumed after park/resume") | |
| 681 | 665 | ||
| 682 | 666 | d.clientGone(sess, ac, oldTr, false) | |
| 683 | - | require.Same(t, ac, sess.client, "stale old-link cleanup must not detach the rebound attachment") | |
| 667 | + | require.Contains(t, sess.snapshotAttachments(), ac, "stale old-link cleanup must not detach the rebound attachment") | |
| 684 | 668 | require.Same(t, winnerTr, ac.transport()) | |
| 685 | 669 | require.False(t, winnerTr.Closed()) | |
| 686 | 670 | } | |
| 687 | 671 | ||
| 688 | - | func setupResumableSnatchedAttachment(t *testing.T, d *Daemon) (*session, *attachedClient, *attachedClient, *closeTrackingTransport, uint64) { | |
| 689 | - | t.Helper() | |
| 690 | - | oldTr := &closeTrackingTransport{} | |
| 691 | - | sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr) | |
| 692 | - | require.NoError(t, err) | |
| 693 | - | token := waiting.resumeToken | |
| 694 | - | require.NotZero(t, token) | |
| 695 | - | ||
| 696 | - | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{}) | |
| 697 | - | require.NoError(t, err) | |
| 698 | - | d.attachmentCleanupWg.Wait() | |
| 699 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting)) | |
| 700 | - | require.Same(t, active, sess.client) | |
| 701 | - | return sess, waiting, active, oldTr, token | |
| 702 | - | } | |
| 703 | - | ||
| 704 | - | // TestSnatchedResumeDuringTeardownBeforeParkRecoversSameAttachment covers the | |
| 705 | - | // snatched unroute→park gap: parkSnatchedAttachment publishes the parking | |
| 706 | - | // marker before ownership removal, then pauses before parkAttachmentAs. | |
| 707 | - | // Same-client IntentResume must wait it out and recover as snatched without | |
| 708 | - | // replacing the active owner; unknown/mismatched tokens stay fail-closed. | |
| 709 | - | func TestSnatchedResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) { | |
| 710 | - | pty, release := newBlockingPTY(t) | |
| 711 | - | defer release() | |
| 712 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 713 | - | ||
| 714 | - | sess, waiting, active, oldTr, token := setupResumableSnatchedAttachment(t, d) | |
| 715 | - | roleToken := sess.attachmentToken(waiting, oldTr) | |
| 716 | - | ||
| 717 | - | reachedGap := make(chan struct{}) | |
| 718 | - | releaseGap := make(chan struct{}) | |
| 719 | - | var releaseOnce sync.Once | |
| 720 | - | t.Cleanup(func() { releaseOnce.Do(func() { close(releaseGap) }) }) | |
| 721 | - | d.afterSnatchedUnrouteBeforePark = func() { | |
| 722 | - | close(reachedGap) | |
| 723 | - | <-releaseGap | |
| 724 | - | } | |
| 725 | - | ||
| 726 | - | parkDone := make(chan bool, 1) | |
| 727 | - | go func() { | |
| 728 | - | parkDone <- d.parkSnatchedAttachment(roleToken) | |
| 729 | - | }() | |
| 730 | - | awaitTestCompletion(t, reachedGap, "snatched park did not pause after unroute before park") | |
| 731 | - | ||
| 732 | - | require.Equal(t, attachmentDetached, sess.attachmentRole(waiting), "fixture: unroute must have cleared snatched ownership") | |
| 733 | - | d.mu.Lock() | |
| 734 | - | parkingInGap := d.parking[token] | |
| 735 | - | _, parkedInGap := d.parked[token] | |
| 736 | - | d.mu.Unlock() | |
| 737 | - | require.NotNil(t, parkingInGap, "fixture: parking marker must precede snatched ownership removal") | |
| 738 | - | require.Same(t, waiting, parkingInGap.ac) | |
| 739 | - | require.Same(t, sess, parkingInGap.sess) | |
| 740 | - | require.False(t, parkedInGap, "fixture: park must not have published yet") | |
| 741 | - | require.Same(t, active, sess.client, "fixture: active owner must remain undisturbed") | |
| 742 | - | ||
| 743 | - | _, _, err := d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{}) | |
| 744 | - | require.Error(t, err) | |
| 745 | - | var unknownErr *protoErr | |
| 746 | - | require.ErrorAs(t, err, &unknownErr) | |
| 747 | - | require.Equal(t, ports.ErrNoSuchSession, unknownErr.code) | |
| 748 | - | require.Contains(t, unknownErr.Error(), "resume token is no longer valid") | |
| 749 | - | ||
| 750 | - | wrongClient := helloResumeCapable(ports.IntentResume, "work", token) | |
| 751 | - | wrongClient.ClientID = [16]byte{9, 9, 9, 9} | |
| 752 | - | _, _, err = d.route(wrongClient, &closeTrackingTransport{}) | |
| 753 | - | require.Error(t, err) | |
| 754 | - | var mismatchErr *protoErr | |
| 755 | - | require.ErrorAs(t, err, &mismatchErr) | |
| 756 | - | require.Equal(t, ports.ErrNoSuchSession, mismatchErr.code) | |
| 757 | - | require.Contains(t, mismatchErr.Error(), "resume token is no longer valid") | |
| 758 | - | ||
| 759 | - | newTr := &closeTrackingTransport{} | |
| 760 | - | type routeResult struct { | |
| 761 | - | sess *session | |
| 762 | - | ac *attachedClient | |
| 763 | - | err error | |
| 764 | - | } | |
| 765 | - | waiterArmed := make(chan struct{}) | |
| 766 | - | d.afterParkingWaitArmed = func() { close(waiterArmed) } | |
| 767 | - | result := make(chan routeResult, 1) | |
| 768 | - | go func() { | |
| 769 | - | routedSess, routedAC, routeErr := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr) | |
| 770 | - | result <- routeResult{sess: routedSess, ac: routedAC, err: routeErr} | |
| 771 | - | }() | |
| 772 | - | awaitTestCompletion(t, waiterArmed, "resume waiter did not arm on snatched parking marker before park") | |
| 773 | - | ||
| 774 | - | releaseOnce.Do(func() { close(releaseGap) }) | |
| 775 | - | require.True(t, awaitTestValue(t, parkDone, "snatched park did not finish after release"), "parkSnatchedAttachment must succeed") | |
| 776 | - | got := awaitTestValue(t, result, "IntentResume did not finish after snatched teardown-before-park gap") | |
| 777 | - | ||
| 778 | - | require.NoError(t, got.err, "same-client snatched token must recover across unroute-before-park") | |
| 779 | - | require.Same(t, sess, got.sess) | |
| 780 | - | require.Same(t, waiting, got.ac) | |
| 781 | - | require.Same(t, active, sess.client, "snatched resume must not replace the active owner") | |
| 782 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting)) | |
| 783 | - | require.Same(t, newTr, waiting.transport()) | |
| 784 | - | require.NotEqual(t, token, waiting.resumeToken, "successful resume rotates the credential") | |
| 785 | - | require.True(t, oldTr.Closed(), "teardown/resume must retire the old transport") | |
| 786 | - | require.False(t, newTr.Closed(), "rebound transport must survive") | |
| 787 | - | d.mu.Lock() | |
| 788 | - | _, oldTokenParked := d.parked[token] | |
| 789 | - | _, stillParking := d.parking[token] | |
| 790 | - | d.mu.Unlock() | |
| 791 | - | require.False(t, oldTokenParked, "consumed credential must not remain resumeable") | |
| 792 | - | require.False(t, stillParking, "parking marker must be consumed after park/resume") | |
| 793 | - | } | |
| 794 | - | ||
| 795 | - | // TestConcurrentSnatchedResumesWaitParkingMarkerBeforePark covers two same-token | |
| 796 | - | // IntentResume handshakes while parkSnatchedAttachment pauses after unroute and | |
| 797 | - | // before park. The parking marker must already be published so losers wait | |
| 798 | - | // instead of treating the credential as unknown across that gap. | |
| 799 | - | func TestConcurrentSnatchedResumesWaitParkingMarkerBeforePark(t *testing.T) { | |
| 800 | - | pty, release := newBlockingPTY(t) | |
| 801 | - | defer release() | |
| 802 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 803 | - | ||
| 804 | - | sess, waiting, active, oldTr, token := setupResumableSnatchedAttachment(t, d) | |
| 805 | - | roleToken := sess.attachmentToken(waiting, oldTr) | |
| 806 | - | ||
| 807 | - | reachedGap := make(chan struct{}) | |
| 808 | - | releaseGap := make(chan struct{}) | |
| 809 | - | var releaseOnce sync.Once | |
| 810 | - | t.Cleanup(func() { releaseOnce.Do(func() { close(releaseGap) }) }) | |
| 811 | - | d.afterSnatchedUnrouteBeforePark = func() { | |
| 812 | - | close(reachedGap) | |
| 813 | - | <-releaseGap | |
| 814 | - | } | |
| 815 | - | ||
| 816 | - | parkDone := make(chan bool, 1) | |
| 817 | - | go func() { | |
| 818 | - | parkDone <- d.parkSnatchedAttachment(roleToken) | |
| 819 | - | }() | |
| 820 | - | awaitTestCompletion(t, reachedGap, "snatched park did not pause after unroute before park") | |
| 821 | - | ||
| 822 | - | require.Equal(t, attachmentDetached, sess.attachmentRole(waiting), "fixture: unroute must have cleared snatched ownership") | |
| 823 | - | d.mu.Lock() | |
| 824 | - | parkingInGap := d.parking[token] | |
| 825 | - | _, parkedInGap := d.parked[token] | |
| 826 | - | d.mu.Unlock() | |
| 827 | - | require.NotNil(t, parkingInGap, "fixture: parking marker must precede unroute for concurrent resumes") | |
| 828 | - | require.Same(t, waiting, parkingInGap.ac) | |
| 829 | - | require.False(t, parkedInGap, "fixture: park must not have published yet") | |
| 830 | - | ||
| 831 | - | _, _, err := d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{}) | |
| 832 | - | require.Error(t, err) | |
| 833 | - | var unknownErr *protoErr | |
| 834 | - | require.ErrorAs(t, err, &unknownErr) | |
| 835 | - | require.Equal(t, ports.ErrNoSuchSession, unknownErr.code) | |
| 836 | - | ||
| 837 | - | type routeResult struct { | |
| 838 | - | sess *session | |
| 839 | - | ac *attachedClient | |
| 840 | - | err error | |
| 841 | - | } | |
| 842 | - | firstTr := &closeTrackingTransport{} | |
| 843 | - | secondTr := &closeTrackingTransport{} | |
| 844 | - | bothWaitersArmed := make(chan struct{}) | |
| 845 | - | var armedCount atomic.Int32 | |
| 846 | - | d.afterParkingWaitArmed = func() { | |
| 847 | - | if armedCount.Add(1) == 2 { | |
| 848 | - | close(bothWaitersArmed) | |
| 849 | - | } | |
| 850 | - | } | |
| 851 | - | firstResult := make(chan routeResult, 1) | |
| 852 | - | secondResult := make(chan routeResult, 1) | |
| 853 | - | go func() { | |
| 854 | - | routedSess, routedAC, routeErr := d.route(helloResumeCapable(ports.IntentResume, "work", token), firstTr) | |
| 855 | - | firstResult <- routeResult{sess: routedSess, ac: routedAC, err: routeErr} | |
| 856 | - | }() | |
| 857 | - | go func() { | |
| 858 | - | routedSess, routedAC, routeErr := d.route(helloResumeCapable(ports.IntentResume, "work", token), secondTr) | |
| 859 | - | secondResult <- routeResult{sess: routedSess, ac: routedAC, err: routeErr} | |
| 860 | - | }() | |
| 861 | - | awaitTestCompletion(t, bothWaitersArmed, "both snatched resume waiters did not arm on parking marker before park") | |
| 862 | - | ||
| 863 | - | releaseOnce.Do(func() { close(releaseGap) }) | |
| 864 | - | require.True(t, awaitTestValue(t, parkDone, "snatched park did not finish after release")) | |
| 865 | - | first := awaitTestValue(t, firstResult, "first concurrent snatched IntentResume did not finish") | |
| 866 | - | second := awaitTestValue(t, secondResult, "second concurrent snatched IntentResume did not finish") | |
| 867 | - | ||
| 868 | - | var winner, loser routeResult | |
| 869 | - | var winnerTr, loserTr *closeTrackingTransport | |
| 870 | - | switch { | |
| 871 | - | case first.err == nil && second.err != nil: | |
| 872 | - | winner, loser = first, second | |
| 873 | - | winnerTr, loserTr = firstTr, secondTr | |
| 874 | - | case second.err == nil && first.err != nil: | |
| 875 | - | winner, loser = second, first | |
| 876 | - | winnerTr, loserTr = secondTr, firstTr | |
| 877 | - | default: | |
| 878 | - | t.Fatalf("expected exactly one concurrent snatched resume to succeed; first=%v second=%v", first.err, second.err) | |
| 879 | - | } | |
| 880 | - | ||
| 881 | - | require.Same(t, sess, winner.sess) | |
| 882 | - | require.Same(t, waiting, winner.ac) | |
| 883 | - | require.Same(t, active, sess.client, "snatched resume must not replace the active owner") | |
| 884 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting)) | |
| 885 | - | require.Same(t, winnerTr, waiting.transport()) | |
| 886 | - | require.NotEqual(t, token, waiting.resumeToken, "successful resume rotates the credential") | |
| 887 | - | require.True(t, oldTr.Closed(), "winning resume must retire the old transport") | |
| 888 | - | require.False(t, winnerTr.Closed(), "winning rebound transport must survive") | |
| 889 | - | ||
| 890 | - | var loserErr *protoErr | |
| 891 | - | require.ErrorAs(t, loser.err, &loserErr) | |
| 892 | - | require.Equal(t, ports.ErrNoSuchSession, loserErr.code) | |
| 893 | - | require.Contains(t, loserErr.Error(), "resume token is no longer valid") | |
| 894 | - | require.True(t, loserTr.Closed() || len(loserTr.Sends()) == 0, "losing resume must not complete Welcome") | |
| 895 | - | ||
| 896 | - | d.mu.Lock() | |
| 897 | - | _, oldTokenParked := d.parked[token] | |
| 898 | - | _, stillParking := d.parking[token] | |
| 899 | - | d.mu.Unlock() | |
| 900 | - | require.False(t, oldTokenParked, "consumed credential must not remain resumeable") | |
| 901 | - | require.False(t, stillParking, "parking marker must be consumed after park/resume") | |
| 902 | - | } | |
| 903 | - | ||
| 904 | - | // TestStaleSnatchedParkClearsOnlyAbandonedParkingMarker covers a stale | |
| 905 | - | // incarnation that publishes a parking marker then loses exact unroute. Only | |
| 906 | - | // that attachment's abandoned marker is cleared; another attachment's marker | |
| 907 | - | // and the fresh snatched incarnation remain intact. | |
| 908 | - | func TestStaleSnatchedParkClearsOnlyAbandonedParkingMarker(t *testing.T) { | |
| 909 | - | workPTY, releaseWork := newBlockingPTY(t) | |
| 910 | - | defer releaseWork() | |
| 911 | - | otherPTY, releaseOther := newBlockingPTY(t) | |
| 912 | - | defer releaseOther() | |
| 913 | - | // Separate PTYs so work teardown cannot cascade-close the other session. | |
| 914 | - | d := newTestDaemon(t, newFactorySeq(t, workPTY, otherPTY), stubClock{}) | |
| 915 | - | ||
| 916 | - | sess, waiting, active, oldTr, token := setupResumableSnatchedAttachment(t, d) | |
| 917 | - | stale := sess.attachmentToken(waiting, oldTr) | |
| 918 | - | ||
| 919 | - | otherTr := &closeTrackingTransport{} | |
| 920 | - | otherSess, otherAC, err := d.route(helloResumeCapable(ports.IntentNew, "other", 0), otherTr) | |
| 921 | - | require.NoError(t, err) | |
| 922 | - | otherToken := d.markParkingInFlight(otherSess, otherAC) | |
| 923 | - | require.NotZero(t, otherToken) | |
| 924 | - | require.NotEqual(t, token, otherToken) | |
| 925 | - | ||
| 926 | - | fresh := &closeTrackingTransport{} | |
| 927 | - | waiting.replaceTransport(fresh) | |
| 928 | - | ||
| 929 | - | require.False(t, d.parkSnatchedAttachment(stale)) | |
| 930 | - | d.mu.Lock() | |
| 931 | - | _, parked := d.parked[token] | |
| 932 | - | _, stillParking := d.parking[token] | |
| 933 | - | otherMarker := d.parking[otherToken] | |
| 934 | - | d.mu.Unlock() | |
| 935 | - | require.False(t, parked) | |
| 936 | - | require.False(t, stillParking, "stale unroute must clear only the abandoned same-attachment marker") | |
| 937 | - | require.NotNil(t, otherMarker, "stale unroute must not clear another attachment's marker") | |
| 938 | - | require.Same(t, otherAC, otherMarker.ac) | |
| 939 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting)) | |
| 940 | - | require.Same(t, fresh, waiting.transport()) | |
| 941 | - | require.False(t, fresh.Closed()) | |
| 942 | - | require.Same(t, active, sess.client) | |
| 943 | - | require.Equal(t, token, waiting.resumeToken) | |
| 944 | - | } | |
| 945 | - | ||
| 946 | - | // TestTerminalSnatchedRemovalClearsOrphanedParkingMarker covers the race where a | |
| 947 | - | // park path publishes a same-attachment parking-in-flight marker, then terminal | |
| 948 | - | // snatched removal wins before park completes. Only that attachment's marker is | |
| 949 | - | // retired and waiters unblock; other markers stay intact and the stale token | |
| 950 | - | // fails closed. A subsequent legitimate snatched park remains resumable. | |
| 951 | - | func TestTerminalSnatchedRemovalClearsOrphanedParkingMarker(t *testing.T) { | |
| 952 | - | workPTY, releaseWork := newBlockingPTY(t) | |
| 953 | - | defer releaseWork() | |
| 954 | - | otherPTY, releaseOther := newBlockingPTY(t) | |
| 955 | - | defer releaseOther() | |
| 956 | - | legitPTY, releaseLegit := newBlockingPTY(t) | |
| 957 | - | defer releaseLegit() | |
| 958 | - | // Separate PTYs so session teardown cannot cascade across marker fixtures. | |
| 959 | - | d := newTestDaemon(t, newFactorySeq(t, workPTY, otherPTY, legitPTY), stubClock{}) | |
| 960 | - | ||
| 961 | - | sess, waiting, active, oldTr, token := setupResumableSnatchedAttachment(t, d) | |
| 962 | - | roleToken := sess.attachmentToken(waiting, oldTr) | |
| 963 | - | ||
| 964 | - | otherTr := &closeTrackingTransport{} | |
| 965 | - | otherSess, otherAC, err := d.route(helloResumeCapable(ports.IntentNew, "other", 0), otherTr) | |
| 966 | - | require.NoError(t, err) | |
| 967 | - | otherToken := d.markParkingInFlight(otherSess, otherAC) | |
| 968 | - | require.NotZero(t, otherToken) | |
| 969 | - | require.NotEqual(t, token, otherToken) | |
| 970 | - | ||
| 971 | - | // Simulate the losing park path publishing before terminal unroute wins. | |
| 972 | - | require.Equal(t, token, d.markParkingInFlight(sess, waiting)) | |
| 973 | - | d.mu.Lock() | |
| 974 | - | orphaned := d.parking[token] | |
| 975 | - | otherMarker := d.parking[otherToken] | |
| 976 | - | d.mu.Unlock() | |
| 977 | - | require.NotNil(t, orphaned, "fixture: park path must publish the parking marker") | |
| 978 | - | require.Same(t, waiting, orphaned.ac) | |
| 979 | - | require.NotNil(t, otherMarker, "fixture: other attachment marker must remain published") | |
| 980 | - | require.Same(t, otherAC, otherMarker.ac) | |
| 981 | - | ||
| 982 | - | waiterArmed := make(chan struct{}) | |
| 983 | - | d.afterParkingWaitArmed = func() { close(waiterArmed) } | |
| 984 | - | waiterDone := make(chan bool, 1) | |
| 985 | - | go func() { | |
| 986 | - | waiterDone <- d.waitParkingInFlight(helloResumeCapable(ports.IntentResume, "work", token)) | |
| 987 | - | }() | |
| 988 | - | awaitTestCompletion(t, waiterArmed, "parking waiter did not arm on the orphaned snatched marker") | |
| 989 | - | ||
| 990 | - | require.True(t, d.removeSnatchedAttachment(roleToken), "terminal snatched removal must win exact unroute") | |
| 991 | - | require.Equal(t, attachmentDetached, sess.attachmentRole(waiting)) | |
| 992 | - | require.Same(t, active, sess.client, "terminal snatched removal must not disturb the active owner") | |
| 993 | - | ||
| 994 | - | waited := awaitTestValue(t, waiterDone, "parking waiter stayed blocked after terminal snatched removal") | |
| 995 | - | require.True(t, waited, "waiter must observe the same-attachment marker before it is cleared") | |
| 996 | - | ||
| 997 | - | d.mu.Lock() | |
| 998 | - | _, stillParking := d.parking[token] | |
| 999 | - | otherAfter := d.parking[otherToken] | |
| 1000 | - | _, parked := d.parked[token] | |
| 1001 | - | d.mu.Unlock() | |
| 1002 | - | require.False(t, stillParking, "terminal winner must clear the same attachment's orphaned marker") | |
| 1003 | - | require.False(t, parked, "terminal removal must not publish parked ownership") | |
| 1004 | - | require.NotNil(t, otherAfter, "terminal winner must not clear another attachment's marker") | |
| 1005 | - | require.Same(t, otherAC, otherAfter.ac) | |
| 1006 | - | ||
| 1007 | - | _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{}) | |
| 1008 | - | require.Error(t, err) | |
| 1009 | - | var unknownErr *protoErr | |
| 1010 | - | require.ErrorAs(t, err, &unknownErr) | |
| 1011 | - | require.Equal(t, ports.ErrNoSuchSession, unknownErr.code) | |
| 1012 | - | require.Contains(t, unknownErr.Error(), "resume token is no longer valid") | |
| 1013 | - | ||
| 1014 | - | _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}) | |
| 1015 | - | require.Error(t, err) | |
| 1016 | - | var retiredErr *protoErr | |
| 1017 | - | require.ErrorAs(t, err, &retiredErr) | |
| 1018 | - | require.Equal(t, ports.ErrNoSuchSession, retiredErr.code) | |
| 1019 | - | require.Contains(t, retiredErr.Error(), "resume token is no longer valid") | |
| 1020 | - | ||
| 1021 | - | // A later legitimate snatched park on a fresh attachment must still resume. | |
| 1022 | - | legitTr := &closeTrackingTransport{} | |
| 1023 | - | legitSess, legitWaiting, err := d.route(helloResumeCapable(ports.IntentNew, "legit", 0), legitTr) | |
| 1024 | - | require.NoError(t, err) | |
| 1025 | - | legitToken := legitWaiting.resumeToken | |
| 1026 | - | require.NotZero(t, legitToken) | |
| 1027 | - | _, _, err = d.route(helloResumeCapable(ports.IntentAttach, "legit", 0), &closeTrackingTransport{}) | |
| 1028 | - | require.NoError(t, err) | |
| 1029 | - | d.attachmentCleanupWg.Wait() | |
| 1030 | - | require.Equal(t, attachmentSnatched, legitSess.attachmentRole(legitWaiting)) | |
| 1031 | - | require.True(t, d.parkSnatchedAttachment(legitSess.attachmentToken(legitWaiting, legitTr))) | |
| 1032 | - | ||
| 1033 | - | resumeTr := &closeTrackingTransport{} | |
| 1034 | - | routedSess, routedAC, err := d.route(helloResumeCapable(ports.IntentResume, "legit", legitToken), resumeTr) | |
| 1035 | - | require.NoError(t, err) | |
| 1036 | - | require.Same(t, legitSess, routedSess) | |
| 1037 | - | require.Same(t, legitWaiting, routedAC) | |
| 1038 | - | require.Equal(t, attachmentSnatched, legitSess.attachmentRole(legitWaiting)) | |
| 1039 | - | require.Same(t, resumeTr, legitWaiting.transport()) | |
| 1040 | - | } | |
| 1041 | - | ||
| 1042 | - | // TestExplicitDetachClearsOrphanedSameAttachmentParkingMarker covers the race | |
| 1043 | - | // where non-explicit teardown publishes a parking-in-flight marker, then | |
| 1044 | - | // explicit detach wins the seat. The winner must clear only that attachment's | |
| 1045 | - | // orphaned marker and unblock waiters; other attachments' markers and a later | |
| 1046 | - | // parked lifecycle must stay intact. Unknown tokens stay fail-closed. | |
| 1047 | 672 | func TestExplicitDetachClearsOrphanedSameAttachmentParkingMarker(t *testing.T) { | |
| 1048 | 673 | workPTY, releaseWork := newBlockingPTY(t) | |
| 1049 | 674 | defer releaseWork() | |
| @@ -1085,7 +710,7 @@ func TestExplicitDetachClearsOrphanedSameAttachmentParkingMarker(t *testing.T) { | |||
| 1085 | 710 | awaitTestCompletion(t, waiterArmed, "parking waiter did not arm on the orphaned marker") | |
| 1086 | 711 | ||
| 1087 | 712 | d.clientGone(sess, ac, oldTr, true) | |
| 1088 | - | require.Nil(t, sess.client, "explicit detach must clear the live owner") | |
| 713 | + | require.Empty(t, sess.snapshotAttachments(), "explicit detach must clear the live owner") | |
| 1089 | 714 | ||
| 1090 | 715 | waited := awaitTestValue(t, waiterDone, "parking waiter stayed blocked after explicit detach") | |
| 1091 | 716 | require.True(t, waited, "waiter must observe the same-attachment marker before it is cleared") | |
| @@ -1144,7 +769,7 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) { | |||
| 1144 | 769 | win: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, oldTr *closeTrackingTransport, token uint64) { | |
| 1145 | 770 | t.Helper() | |
| 1146 | 771 | d.clientGone(sess, ac, oldTr, true) | |
| 1147 | - | require.Nil(t, sess.client, "explicit detach must clear the live owner before late mark") | |
| 772 | + | require.Empty(t, sess.snapshotAttachments(), "explicit detach must clear the live owner before late mark") | |
| 1148 | 773 | }, | |
| 1149 | 774 | }, | |
| 1150 | 775 | { | |
| @@ -1170,7 +795,7 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) { | |||
| 1170 | 795 | require.NoError(t, err) | |
| 1171 | 796 | token := ac.resumeToken | |
| 1172 | 797 | require.NotZero(t, token) | |
| 1173 | - | require.Same(t, ac, sess.client) | |
| 798 | + | require.Contains(t, sess.snapshotAttachments(), ac) | |
| 1174 | 799 | ||
| 1175 | 800 | validated := make(chan struct{}) | |
| 1176 | 801 | releaseMark := make(chan struct{}) | |
| @@ -1227,7 +852,7 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) { | |||
| 1227 | 852 | require.NotZero(t, token) | |
| 1228 | 853 | ||
| 1229 | 854 | require.True(t, d.detachIfCurrentTransport(sess, ac, ac.transportSnapshot())) | |
| 1230 | - | require.Nil(t, sess.client) | |
| 855 | + | require.Empty(t, sess.snapshotAttachments()) | |
| 1231 | 856 | require.Zero(t, d.markParkingInFlight(sess, ac), "detached active attachment must not publish a late marker") | |
| 1232 | 857 | ||
| 1233 | 858 | d.mu.Lock() | |
| @@ -1249,46 +874,6 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) { | |||
| 1249 | 874 | // TestMarkParkingInFlightRequiresExactLiveOwnership covers lock-safe rejection | |
| 1250 | 875 | // when the attachment is no longer the exact active or snatched owner, while | |
| 1251 | 876 | // still advertising for both live roles. | |
| 1252 | - | func TestMarkParkingInFlightRequiresExactLiveOwnership(t *testing.T) { | |
| 1253 | - | pty, release := newBlockingPTY(t) | |
| 1254 | - | defer release() | |
| 1255 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 1256 | - | ||
| 1257 | - | oldTr := &closeTrackingTransport{} | |
| 1258 | - | sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr) | |
| 1259 | - | require.NoError(t, err) | |
| 1260 | - | token := waiting.resumeToken | |
| 1261 | - | require.NotZero(t, token) | |
| 1262 | - | ||
| 1263 | - | require.Equal(t, token, d.markParkingInFlight(sess, waiting), "active owner must publish") | |
| 1264 | - | d.clearParkingInFlight(token, waiting) | |
| 1265 | - | ||
| 1266 | - | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{}) | |
| 1267 | - | require.NoError(t, err) | |
| 1268 | - | d.attachmentCleanupWg.Wait() | |
| 1269 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting)) | |
| 1270 | - | require.Same(t, active, sess.client) | |
| 1271 | - | ||
| 1272 | - | require.Equal(t, token, d.markParkingInFlight(sess, waiting), "snatched owner must publish") | |
| 1273 | - | d.clearParkingInFlight(token, waiting) | |
| 1274 | - | ||
| 1275 | - | roleToken := sess.attachmentToken(waiting, oldTr) | |
| 1276 | - | require.True(t, d.unrouteSnatchedAttachment(roleToken, false)) | |
| 1277 | - | require.Equal(t, attachmentDetached, sess.attachmentRole(waiting)) | |
| 1278 | - | require.Zero(t, d.markParkingInFlight(sess, waiting), "detached snatched attachment must not publish") | |
| 1279 | - | ||
| 1280 | - | d.mu.Lock() | |
| 1281 | - | _, stillParking := d.parking[token] | |
| 1282 | - | d.mu.Unlock() | |
| 1283 | - | require.False(t, stillParking) | |
| 1284 | - | ||
| 1285 | - | require.Equal(t, active.resumeToken, d.markParkingInFlight(sess, active), "active owner must still publish after snatched unroute") | |
| 1286 | - | d.clearParkingInFlight(active.resumeToken, active) | |
| 1287 | - | } | |
| 1288 | - | ||
| 1289 | - | // TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment covers | |
| 1290 | - | // the TOCTOU where clientGone passes the old-transport precheck, then a live | |
| 1291 | - | // resume rebinds the same attachment before detach runs. | |
| 1292 | 877 | func TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment(t *testing.T) { | |
| 1293 | 878 | pty, release := newBlockingPTY(t) | |
| 1294 | 879 | defer release() | |
| @@ -1326,7 +911,7 @@ func TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment(t *tes | |||
| 1326 | 911 | releaseOnce.Do(func() { close(releaseDetach) }) | |
| 1327 | 912 | awaitTestCompletion(t, goneDone, "stale clientGone did not finish after resume rebound") | |
| 1328 | 913 | ||
| 1329 | - | require.Same(t, ac, sess.client, "stale cleanup must not detach the rebound attachment") | |
| 914 | + | require.Contains(t, sess.snapshotAttachments(), ac, "stale cleanup must not detach the rebound attachment") | |
| 1330 | 915 | require.Same(t, newTr, ac.transport()) | |
| 1331 | 916 | require.False(t, newTr.Closed(), "rebound transport must survive stale old-link cleanup") | |
| 1332 | 917 | require.True(t, oldTr.Closed(), "live resume retires the captured old transport") | |
| @@ -1346,7 +931,7 @@ func TestResumeLiveAttachmentParkFailureRetiresOldTransport(t *testing.T) { | |||
| 1346 | 931 | token := ac.resumeToken | |
| 1347 | 932 | require.NotZero(t, token) | |
| 1348 | 933 | ||
| 1349 | - | d.afterDetachRoleEffectsFrozen = func() { | |
| 934 | + | d.afterDetachAttachmentEffectsFrozen = func() { | |
| 1350 | 935 | d.mu.Lock() | |
| 1351 | 936 | d.closing = true | |
| 1352 | 937 | d.mu.Unlock() | |
| @@ -1357,7 +942,7 @@ func TestResumeLiveAttachmentParkFailureRetiresOldTransport(t *testing.T) { | |||
| 1357 | 942 | var pe *protoErr | |
| 1358 | 943 | require.ErrorAs(t, err, &pe) | |
| 1359 | 944 | require.Equal(t, ports.ErrServerShutdown, pe.code) | |
| 1360 | - | require.Nil(t, sess.client, "failed live park must leave the session without an owner") | |
| 945 | + | require.Empty(t, sess.snapshotAttachments(), "failed live park must leave the session without an owner") | |
| 1361 | 946 | require.Nil(t, ac.transport(), "failed live park must revoke the captured old transport") | |
| 1362 | 947 | require.True(t, oldTr.Closed(), "failed live park must close the captured old transport") | |
| 1363 | 948 | d.mu.Lock() | |
| @@ -1383,7 +968,7 @@ func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) { | |||
| 1383 | 968 | require.Same(t, sess, resumedSess) | |
| 1384 | 969 | require.Same(t, ac, resumedAC) | |
| 1385 | 970 | require.NotEqual(t, oldToken, resumedAC.resumeToken, "resume rotates token") | |
| 1386 | - | require.Same(t, resumedAC, sess.client) | |
| 971 | + | require.Contains(t, sess.snapshotAttachments(), resumedAC) | |
| 1387 | 972 | } | |
| 1388 | 973 | ||
| 1389 | 974 | func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) { | |
| @@ -1458,7 +1043,7 @@ func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing. | |||
| 1458 | 1043 | token := ac.resumeToken | |
| 1459 | 1044 | require.True(t, sess.detachIfCurrent(ac)) | |
| 1460 | 1045 | require.True(t, d.parkAttachment(sess, ac)) | |
| 1461 | - | generation := ac.roleGeneration.Load() | |
| 1046 | + | generation := ac.connectionGeneration.Load() | |
| 1462 | 1047 | ||
| 1463 | 1048 | newTr := &closeTrackingTransport{} | |
| 1464 | 1049 | resumedSess, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), newTr, domain.Size{Cols: 80, Rows: 24}) | |
| @@ -1466,7 +1051,7 @@ func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing. | |||
| 1466 | 1051 | require.True(t, ok) | |
| 1467 | 1052 | require.Same(t, sess, resumedSess) | |
| 1468 | 1053 | require.Same(t, ac, resumedAC) | |
| 1469 | - | require.Greater(t, ac.roleGeneration.Load(), generation, "resume must publish active ownership through the attachment transition") | |
| 1054 | + | require.Greater(t, ac.connectionGeneration.Load(), generation, "resume must publish active ownership through the attachment transition") | |
| 1470 | 1055 | ||
| 1471 | 1056 | _ = ac.closeCapturedTransport(oldTr) | |
| 1472 | 1057 | require.True(t, oldTr.Closed(), "old transport is closed") | |
| @@ -1490,8 +1075,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) { | |||
| 1490 | 1075 | resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr) | |
| 1491 | 1076 | require.NoError(t, err) | |
| 1492 | 1077 | require.Same(t, ac, resumedAC) | |
| 1493 | - | require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedSess.renderCoordinator().attachmentLease(resumedAC))) | |
| 1494 | - | d.firstPaint(resumedSess, resumedAC, resumedAC.size) | |
| 1078 | + | d.paint(resumedSess, resumedAC, true, nil) | |
| 1495 | 1079 | ||
| 1496 | 1080 | sends := newTr.Sends() | |
| 1497 | 1081 | require.Len(t, sends, 1) | |
| @@ -1538,7 +1122,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) { | |||
| 1538 | 1122 | require.True(t, sess.detachIfCurrent(ac)) | |
| 1539 | 1123 | require.True(t, d.parkAttachment(sess, ac)) | |
| 1540 | 1124 | ||
| 1541 | - | // Headless close cannot find the parked attachment through sess.client. | |
| 1125 | + | // Headless close cannot find the parked attachment through sess.snapshotAttachments()[0]. | |
| 1542 | 1126 | // Its capture must already have been released before the attachment parked. | |
| 1543 | 1127 | require.NoError(t, d.closePane(sess, tb, closed.id, nil, false)) | |
| 1544 | 1128 | ac.sendMu.Lock() | |
| @@ -1734,7 +1318,7 @@ func TestEphemeralParkExpiryKeepsSession(t *testing.T) { | |||
| 1734 | 1318 | ||
| 1735 | 1319 | require.Equal(t, 1, sessionCount(d), "token expiry does not kill ephemeral session") | |
| 1736 | 1320 | sess.mu.Lock() | |
| 1737 | - | require.Nil(t, sess.client) | |
| 1321 | + | require.Empty(t, sess.snapshotAttachmentsLocked()) | |
| 1738 | 1322 | sess.mu.Unlock() | |
| 1739 | 1323 | } | |
| 1740 | 1324 | ||
| @@ -1828,34 +1412,6 @@ func TestKilledSessionUnblocksParkingWaiterFailsClosed(t *testing.T) { | |||
| 1828 | 1412 | require.Contains(t, killedErr.Error(), "resume token is no longer valid") | |
| 1829 | 1413 | } | |
| 1830 | 1414 | ||
| 1831 | - | func TestParkedPredecessorResumesSnatchedWithoutStealingActiveAttachment(t *testing.T) { | |
| 1832 | - | pty, release := newBlockingPTY(t) | |
| 1833 | - | defer release() | |
| 1834 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 1835 | - | tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |
| 1836 | - | sess, oldAC, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | |
| 1837 | - | require.NoError(t, err) | |
| 1838 | - | token := oldAC.resumeToken | |
| 1839 | - | d.clientGone(sess, oldAC, oldAC.transport(), false) | |
| 1840 | - | ||
| 1841 | - | activeTr := &closeTrackingTransport{} | |
| 1842 | - | _, activeAC, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTr) | |
| 1843 | - | require.NoError(t, err) | |
| 1844 | - | require.NotSame(t, oldAC, activeAC) | |
| 1845 | - | ||
| 1846 | - | d.mu.Lock() | |
| 1847 | - | parked := d.parked[token] | |
| 1848 | - | d.mu.Unlock() | |
| 1849 | - | require.NotNil(t, parked, "normal attach preserves the parked predecessor") | |
| 1850 | - | require.Equal(t, attachmentSnatched, parked.role) | |
| 1851 | - | _, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) | |
| 1852 | - | require.NoError(t, err) | |
| 1853 | - | require.True(t, ok) | |
| 1854 | - | require.Same(t, oldAC, resumedAC) | |
| 1855 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(resumedAC)) | |
| 1856 | - | require.Same(t, activeAC, sess.client) | |
| 1857 | - | } | |
| 1858 | - | ||
| 1859 | 1415 | func TestStaleClientGoneDoesNotDetachOrCloseFreshTransport(t *testing.T) { | |
| 1860 | 1416 | p, release := newBlockingPTY(t) | |
| 1861 | 1417 | defer release() | |
| @@ -1869,7 +1425,7 @@ func TestStaleClientGoneDoesNotDetachOrCloseFreshTransport(t *testing.T) { | |||
| 1869 | 1425 | ||
| 1870 | 1426 | d.clientGone(sess, ac, oldTr, false) | |
| 1871 | 1427 | ||
| 1872 | - | require.Same(t, ac, sess.client, "stale connection must not detach current client") | |
| 1428 | + | require.Contains(t, sess.snapshotAttachments(), ac, "stale connection must not detach current client") | |
| 1873 | 1429 | require.False(t, oldTr.Closed(), "stale transport is owned by its own loop/handler") | |
| 1874 | 1430 | require.False(t, freshTr.Closed(), "fresh resumed transport must not be closed by stale loop") | |
| 1875 | 1431 | } | |
M internal/usecase/daemon/resume_wake_incarnation_test.go +42 -0
| @@ -66,3 +66,45 @@ func TestParkedResumeRejectsDispatchedWakeFromPriorAttachment(t *testing.T) { | |||
| 66 | 66 | d.firstPaint(sess, ac, ac.size) | |
| 67 | 67 | require.NotEmpty(t, newTransport.Sends()) | |
| 68 | 68 | } | |
| 69 | + | ||
| 70 | + | func TestSameAttachmentRebindRejectsQueuedWakeAndFansOutFreshGeneration(t *testing.T) { | |
| 71 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 72 | + | rc := d.attachCoordinator(sess, ac, true) | |
| 73 | + | oldLease := rc.attachmentLease(ac) | |
| 74 | + | require.NotNil(t, oldLease) | |
| 75 | + | ||
| 76 | + | originalWake := rc.opts.wake | |
| 77 | + | wakeDispatched := make(chan struct{}) | |
| 78 | + | releaseWake := make(chan struct{}) | |
| 79 | + | rc.opts.wake = func(w renderWake) { | |
| 80 | + | close(wakeDispatched) | |
| 81 | + | <-releaseWake | |
| 82 | + | originalWake(w) | |
| 83 | + | } | |
| 84 | + | wakeDone := make(chan struct{}) | |
| 85 | + | go func() { | |
| 86 | + | d.invalidateRenderNow(sess, ac, true, "same-attachment-rebind") | |
| 87 | + | close(wakeDone) | |
| 88 | + | }() | |
| 89 | + | <-wakeDispatched | |
| 90 | + | ||
| 91 | + | oldGeneration := ac.connectionGeneration.Load() | |
| 92 | + | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 93 | + | source: sess, target: sess, next: ac, | |
| 94 | + | ||
| 95 | + | expectedTransport: ac.transportSnapshot(), ready: true, | |
| 96 | + | }) | |
| 97 | + | require.NoError(t, err) | |
| 98 | + | d.deferAttachmentTransitionCleanups(result) | |
| 99 | + | require.Greater(t, ac.connectionGeneration.Load(), oldGeneration) | |
| 100 | + | freshLease := rc.attachmentLease(ac) | |
| 101 | + | require.NotSame(t, oldLease, freshLease) | |
| 102 | + | ||
| 103 | + | close(releaseWake) | |
| 104 | + | <-wakeDone | |
| 105 | + | require.Empty(t, sends, "a queued wake from the prior generation must not paint the rebound transport") | |
| 106 | + | ||
| 107 | + | rc.opts.wake = originalWake | |
| 108 | + | d.invalidateRenderNow(sess, ac, true, "fresh-generation-fanout") | |
| 109 | + | require.Greater(t, len(sends), 0, "the current generation must receive fanout output") | |
| 110 | + | } | |
D internal/usecase/daemon/role_aware_parking_test.go +0 -482
| @@ -1,482 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | "sync" | |
| 6 | - | "testing" | |
| 7 | - | ||
| 8 | - | "github.com/stretchr/testify/require" | |
| 9 | - | ||
| 10 | - | "github.com/bnema/vev/internal/domain" | |
| 11 | - | "github.com/bnema/vev/internal/ports" | |
| 12 | - | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 13 | - | "github.com/bnema/vev/pkg/renderer" | |
| 14 | - | ) | |
| 15 | - | ||
| 16 | - | func TestActiveLinkLossParksActiveAndDetachesCoordinator(t *testing.T) { | |
| 17 | - | pty, release := newBlockingPTY(t) | |
| 18 | - | defer release() | |
| 19 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 20 | - | ||
| 21 | - | transport := &closeTrackingTransport{} | |
| 22 | - | sess, active, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), transport) | |
| 23 | - | require.NoError(t, err) | |
| 24 | - | token := active.resumeToken | |
| 25 | - | rc := sess.renderCoordinator() | |
| 26 | - | require.NotNil(t, rc.attachmentLease(active)) | |
| 27 | - | ||
| 28 | - | d.clientGone(sess, active, transport, false) | |
| 29 | - | ||
| 30 | - | d.mu.Lock() | |
| 31 | - | parked := d.parked[token] | |
| 32 | - | d.mu.Unlock() | |
| 33 | - | require.NotNil(t, parked) | |
| 34 | - | require.Equal(t, attachmentActive, parked.role) | |
| 35 | - | require.Nil(t, sess.client) | |
| 36 | - | require.Nil(t, rc.attachmentLease(active), "active loss should detach coordinator ownership") | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | func TestSnatchedLinkLossParksWithoutDisturbingActiveOwner(t *testing.T) { | |
| 40 | - | pty, release := newBlockingPTY(t) | |
| 41 | - | defer release() | |
| 42 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 43 | - | ||
| 44 | - | oldTransport := &closeTrackingTransport{} | |
| 45 | - | sess, old, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 46 | - | require.NoError(t, err) | |
| 47 | - | oldToken := old.resumeToken | |
| 48 | - | ||
| 49 | - | activeTransport := &closeTrackingTransport{} | |
| 50 | - | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport) | |
| 51 | - | require.NoError(t, err) | |
| 52 | - | d.attachmentCleanupWg.Wait() | |
| 53 | - | lease := sess.renderCoordinator().attachmentLease(active) | |
| 54 | - | require.NotNil(t, lease) | |
| 55 | - | ||
| 56 | - | d.runConnLoop(old) | |
| 57 | - | ||
| 58 | - | d.mu.Lock() | |
| 59 | - | parked := d.parked[oldToken] | |
| 60 | - | d.mu.Unlock() | |
| 61 | - | require.NotNil(t, parked, "resumable snatched attachment should survive link loss") | |
| 62 | - | require.Equal(t, attachmentSnatched, parked.role) | |
| 63 | - | require.Same(t, active, sess.client) | |
| 64 | - | require.Same(t, lease, sess.renderCoordinator().attachmentLease(active)) | |
| 65 | - | require.False(t, activeTransport.Closed()) | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | type sendErrorTransport struct { | |
| 69 | - | closeTrackingTransport | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | func (*sendErrorTransport) Send(ports.Frame) error { return errors.New("send failed") } | |
| 73 | - | ||
| 74 | - | func TestSnatchedPanelSendFailureParksResumableAttachment(t *testing.T) { | |
| 75 | - | pty, release := newBlockingPTY(t) | |
| 76 | - | defer release() | |
| 77 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 78 | - | ||
| 79 | - | failed := &sendErrorTransport{} | |
| 80 | - | sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), failed) | |
| 81 | - | require.NoError(t, err) | |
| 82 | - | token := waiting.resumeToken | |
| 83 | - | ||
| 84 | - | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{}) | |
| 85 | - | require.NoError(t, err) | |
| 86 | - | d.attachmentCleanupWg.Wait() | |
| 87 | - | ||
| 88 | - | d.mu.Lock() | |
| 89 | - | parked := d.parked[token] | |
| 90 | - | d.mu.Unlock() | |
| 91 | - | require.NotNil(t, parked, "failed snatched panel should park a resumable attachment") | |
| 92 | - | require.Equal(t, attachmentSnatched, parked.role) | |
| 93 | - | require.Nil(t, waiting.transport(), "parking should revoke the exact failed transport") | |
| 94 | - | require.True(t, failed.Closed()) | |
| 95 | - | require.Same(t, active, sess.client) | |
| 96 | - | } | |
| 97 | - | ||
| 98 | - | func TestStaleSnatchedTransportCannotParkFreshIncarnation(t *testing.T) { | |
| 99 | - | pty, release := newBlockingPTY(t) | |
| 100 | - | defer release() | |
| 101 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 102 | - | ||
| 103 | - | oldTransport := &closeTrackingTransport{} | |
| 104 | - | sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 105 | - | require.NoError(t, err) | |
| 106 | - | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{}) | |
| 107 | - | require.NoError(t, err) | |
| 108 | - | d.attachmentCleanupWg.Wait() | |
| 109 | - | stale := sess.attachmentToken(waiting, oldTransport) | |
| 110 | - | fresh := &closeTrackingTransport{} | |
| 111 | - | waiting.replaceTransport(fresh) | |
| 112 | - | ||
| 113 | - | require.False(t, d.parkOrDropSnatchedAttachment(stale)) | |
| 114 | - | d.mu.Lock() | |
| 115 | - | _, parked := d.parked[waiting.resumeToken] | |
| 116 | - | d.mu.Unlock() | |
| 117 | - | require.False(t, parked) | |
| 118 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting)) | |
| 119 | - | require.Same(t, fresh, waiting.transport()) | |
| 120 | - | require.False(t, fresh.Closed()) | |
| 121 | - | require.Same(t, active, sess.client) | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | func TestSnatchedParkExpiryDoesNotAffectActiveOwner(t *testing.T) { | |
| 125 | - | pty, release := newBlockingPTY(t) | |
| 126 | - | defer release() | |
| 127 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 128 | - | ||
| 129 | - | waitingTransport := &closeTrackingTransport{} | |
| 130 | - | sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), waitingTransport) | |
| 131 | - | require.NoError(t, err) | |
| 132 | - | token := waiting.resumeToken | |
| 133 | - | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{}) | |
| 134 | - | require.NoError(t, err) | |
| 135 | - | d.attachmentCleanupWg.Wait() | |
| 136 | - | lease := sess.renderCoordinator().attachmentLease(active) | |
| 137 | - | ||
| 138 | - | d.runConnLoop(waiting) | |
| 139 | - | d.mu.Lock() | |
| 140 | - | parked := d.parked[token] | |
| 141 | - | d.mu.Unlock() | |
| 142 | - | require.NotNil(t, parked) | |
| 143 | - | ||
| 144 | - | d.expireParked(token, parked) | |
| 145 | - | ||
| 146 | - | d.mu.Lock() | |
| 147 | - | _, retained := d.parked[token] | |
| 148 | - | d.mu.Unlock() | |
| 149 | - | require.False(t, retained) | |
| 150 | - | require.Same(t, active, sess.client) | |
| 151 | - | require.Same(t, lease, sess.renderCoordinator().attachmentLease(active)) | |
| 152 | - | } | |
| 153 | - | ||
| 154 | - | func TestNonzeroResumeTokenOverridesOrdinaryRoutingFallbacks(t *testing.T) { | |
| 155 | - | pty, release := newBlockingPTY(t) | |
| 156 | - | defer release() | |
| 157 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 158 | - | ||
| 159 | - | work, active, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), &closeTrackingTransport{}) | |
| 160 | - | require.NoError(t, err) | |
| 161 | - | const unknownToken = uint64(0xdecafbad) | |
| 162 | - | for _, tc := range []struct { | |
| 163 | - | name string | |
| 164 | - | intent uint8 | |
| 165 | - | target string | |
| 166 | - | }{ | |
| 167 | - | {name: "attach existing", intent: ports.IntentAttach, target: "work"}, | |
| 168 | - | {name: "create unique", intent: ports.IntentNew, target: "fallback"}, | |
| 169 | - | {name: "create ephemeral", intent: ports.IntentEphemeral}, | |
| 170 | - | } { | |
| 171 | - | t.Run(tc.name, func(t *testing.T) { | |
| 172 | - | h := helloResumeCapable(tc.intent, tc.target, unknownToken) | |
| 173 | - | routedSession, routedClient, routeErr := d.route(h, &closeTrackingTransport{}) | |
| 174 | - | require.Error(t, routeErr) | |
| 175 | - | require.Nil(t, routedSession) | |
| 176 | - | require.Nil(t, routedClient) | |
| 177 | - | var protocolErr *protoErr | |
| 178 | - | require.ErrorAs(t, routeErr, &protocolErr) | |
| 179 | - | require.Equal(t, ports.ErrNoSuchSession, protocolErr.code) | |
| 180 | - | require.Same(t, active, work.client) | |
| 181 | - | d.mu.Lock() | |
| 182 | - | require.Len(t, d.sessions, 1) | |
| 183 | - | require.Nil(t, d.findByNameLocked("fallback")) | |
| 184 | - | d.mu.Unlock() | |
| 185 | - | }) | |
| 186 | - | } | |
| 187 | - | ||
| 188 | - | _, replacement, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{}) | |
| 189 | - | require.NoError(t, err, "a zero token keeps ordinary attach semantics") | |
| 190 | - | require.NotSame(t, active, replacement) | |
| 191 | - | require.Same(t, replacement, work.client) | |
| 192 | - | require.Equal(t, attachmentSnatched, work.attachmentRole(active)) | |
| 193 | - | } | |
| 194 | - | ||
| 195 | - | func TestHandleHelloExpiredSnatchedResumeFailsClosedAfterSessionEnds(t *testing.T) { | |
| 196 | - | workPTY, releaseWork := newBlockingPTY(t) | |
| 197 | - | keeperPTY, releaseKeeper := newBlockingPTY(t) | |
| 198 | - | fallbackPTY, releaseFallback := newBlockingPTY(t) | |
| 199 | - | defer releaseWork() | |
| 200 | - | defer releaseKeeper() | |
| 201 | - | defer releaseFallback() | |
| 202 | - | d := newTestDaemon(t, newFactorySeq(t, workPTY, keeperPTY, fallbackPTY), stubClock{}) | |
| 203 | - | ||
| 204 | - | oldTransport := &closeTrackingTransport{} | |
| 205 | - | work, old, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 206 | - | require.NoError(t, err) | |
| 207 | - | oldToken := old.resumeToken | |
| 208 | - | ||
| 209 | - | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{}) | |
| 210 | - | require.NoError(t, err) | |
| 211 | - | d.attachmentCleanupWg.Wait() | |
| 212 | - | require.Equal(t, attachmentSnatched, work.attachmentRole(old)) | |
| 213 | - | require.Same(t, active, work.client) | |
| 214 | - | ||
| 215 | - | d.runConnLoop(old) | |
| 216 | - | d.mu.Lock() | |
| 217 | - | parked := d.parked[oldToken] | |
| 218 | - | d.mu.Unlock() | |
| 219 | - | require.NotNil(t, parked) | |
| 220 | - | require.Equal(t, attachmentSnatched, parked.role) | |
| 221 | - | ||
| 222 | - | keeper, _, err := d.route(helloResumeCapable(ports.IntentNew, "keeper", 0), &closeTrackingTransport{}) | |
| 223 | - | require.NoError(t, err) | |
| 224 | - | require.NoError(t, d.killSession(work, ports.ReasonSessionKilled, false)) | |
| 225 | - | ||
| 226 | - | d.mu.Lock() | |
| 227 | - | _, tokenRetained := d.parked[oldToken] | |
| 228 | - | stoppedWork, lifecycleRetained := d.stopped["work"] | |
| 229 | - | d.mu.Unlock() | |
| 230 | - | require.False(t, tokenRetained, "terminal lifecycle teardown must purge the parked token") | |
| 231 | - | require.True(t, lifecycleRetained, "the stopped lifecycle makes attach fallback observable") | |
| 232 | - | ||
| 233 | - | reconnect := &closeTrackingTransport{} | |
| 234 | - | d.handleHello(reconnect, ports.Frame{ | |
| 235 | - | Type: ports.MsgHello, | |
| 236 | - | Payload: ports.MarshalHello(helloResumeCapable(ports.IntentResume, "work", oldToken)), | |
| 237 | - | }) | |
| 238 | - | ||
| 239 | - | frames := reconnect.Sends() | |
| 240 | - | require.Len(t, frames, 1) | |
| 241 | - | require.Equal(t, ports.MsgError, frames[0].Type) | |
| 242 | - | wireErr, err := ports.UnmarshalErrorMsg(frames[0].Payload) | |
| 243 | - | require.NoError(t, err) | |
| 244 | - | require.Equal(t, ports.ErrNoSuchSession, wireErr.Code) | |
| 245 | - | require.True(t, reconnect.Closed()) | |
| 246 | - | d.mu.Lock() | |
| 247 | - | require.Same(t, keeper, d.findByNameLocked("keeper")) | |
| 248 | - | require.Nil(t, d.findByNameLocked("work"), "expired resume must not recreate the stopped session") | |
| 249 | - | require.Equal(t, stoppedWork, d.stopped["work"]) | |
| 250 | - | require.Len(t, d.sessions, 1) | |
| 251 | - | d.mu.Unlock() | |
| 252 | - | require.Nil(t, work.client, "expired resume must not reclaim the ended lifecycle") | |
| 253 | - | require.Nil(t, active.currentSession()) | |
| 254 | - | } | |
| 255 | - | ||
| 256 | - | func TestSnatchedResumeHandshakeSendsWelcomeAndPanelWithoutReplacingOwner(t *testing.T) { | |
| 257 | - | pty, release := newBlockingPTY(t) | |
| 258 | - | defer release() | |
| 259 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 260 | - | ||
| 261 | - | oldTransport := &closeTrackingTransport{} | |
| 262 | - | sess, old, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 263 | - | require.NoError(t, err) | |
| 264 | - | token := old.resumeToken | |
| 265 | - | d.clientGone(sess, old, oldTransport, false) | |
| 266 | - | ||
| 267 | - | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{}) | |
| 268 | - | require.NoError(t, err) | |
| 269 | - | ||
| 270 | - | resumedTransport := &closeTrackingTransport{} | |
| 271 | - | hello := helloResumeCapable(ports.IntentResume, "work", token) | |
| 272 | - | d.handleHello(resumedTransport, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}) | |
| 273 | - | ||
| 274 | - | frames := resumedTransport.Sends() | |
| 275 | - | require.GreaterOrEqual(t, len(frames), 2) | |
| 276 | - | require.Equal(t, ports.MsgWelcome, frames[0].Type) | |
| 277 | - | welcome, err := ports.UnmarshalWelcome(frames[0].Payload) | |
| 278 | - | require.NoError(t, err) | |
| 279 | - | newToken := welcome.ResumeToken | |
| 280 | - | require.NotZero(t, newToken) | |
| 281 | - | require.NotEqual(t, token, newToken, "successful snatched resume rotates the credential") | |
| 282 | - | require.Equal(t, newToken, old.resumeToken, "the Welcome publishes the one rotated credential") | |
| 283 | - | require.Equal(t, ports.MsgOutput, frames[1].Type) | |
| 284 | - | output, err := ports.UnmarshalOutput(frames[1].Payload) | |
| 285 | - | require.NoError(t, err) | |
| 286 | - | require.Contains(t, string(output.Data), "Session snatched") | |
| 287 | - | d.mu.Lock() | |
| 288 | - | _, oldTokenParked := d.parked[token] | |
| 289 | - | parked := d.parked[newToken] | |
| 290 | - | d.mu.Unlock() | |
| 291 | - | require.False(t, oldTokenParked, "the consumed credential cannot be reused") | |
| 292 | - | require.NotNil(t, parked) | |
| 293 | - | require.Equal(t, attachmentSnatched, parked.role, "the rotated credential remains bound to the snatched role") | |
| 294 | - | require.Same(t, active, sess.client) | |
| 295 | - | ||
| 296 | - | replay := &closeTrackingTransport{} | |
| 297 | - | d.handleHello(replay, ports.Frame{ | |
| 298 | - | Type: ports.MsgHello, | |
| 299 | - | Payload: ports.MarshalHello(helloResumeCapable(ports.IntentResume, "work", token)), | |
| 300 | - | }) | |
| 301 | - | replayFrames := replay.Sends() | |
| 302 | - | require.Len(t, replayFrames, 1) | |
| 303 | - | require.Equal(t, ports.MsgError, replayFrames[0].Type) | |
| 304 | - | replayErr, err := ports.UnmarshalErrorMsg(replayFrames[0].Payload) | |
| 305 | - | require.NoError(t, err) | |
| 306 | - | require.Equal(t, ports.ErrNoSuchSession, replayErr.Code) | |
| 307 | - | require.True(t, replay.Closed()) | |
| 308 | - | require.Same(t, active, sess.client, "replaying the old credential must not replace active ownership") | |
| 309 | - | d.mu.Lock() | |
| 310 | - | require.Same(t, parked, d.parked[newToken]) | |
| 311 | - | d.mu.Unlock() | |
| 312 | - | } | |
| 313 | - | ||
| 314 | - | func TestResumeWelcomeRechecksRoleAfterReplacement(t *testing.T) { | |
| 315 | - | pty, release := newBlockingPTY(t) | |
| 316 | - | defer release() | |
| 317 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 318 | - | ||
| 319 | - | oldTransport := &closeTrackingTransport{} | |
| 320 | - | sess, resumed, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 321 | - | require.NoError(t, err) | |
| 322 | - | token := resumed.resumeToken | |
| 323 | - | d.clientGone(sess, resumed, oldTransport, false) | |
| 324 | - | ||
| 325 | - | blocked := newWelcomeBlockingTransport(t) | |
| 326 | - | handshakeDone := make(chan struct{}) | |
| 327 | - | go func() { | |
| 328 | - | hello := helloResumeCapable(ports.IntentResume, "work", token) | |
| 329 | - | d.handleHello(blocked.tr, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}) | |
| 330 | - | close(handshakeDone) | |
| 331 | - | }() | |
| 332 | - | awaitTestCompletion(t, blocked.welcomeEntered, "resume did not start Welcome") | |
| 333 | - | welcome := awaitFrame(t, blocked.sends, ports.MsgWelcome) | |
| 334 | - | require.Equal(t, ports.MsgWelcome, welcome.Type) | |
| 335 | - | requireNoCoordinatorOutputFrame(t, blocked.sends) | |
| 336 | - | resumedLease := sess.renderCoordinator().attachmentLease(resumed) | |
| 337 | - | require.NotNil(t, resumedLease) | |
| 338 | - | require.False(t, resumedLease.ready, "blocked Welcome must keep the resumed lease unready") | |
| 339 | - | ||
| 340 | - | resumeGateFrozen := make(chan struct{}) | |
| 341 | - | var frozenOnce sync.Once | |
| 342 | - | d.afterRoleEffectGateFrozen = func(_ string, ac *attachedClient) { | |
| 343 | - | if ac == resumed { | |
| 344 | - | frozenOnce.Do(func() { close(resumeGateFrozen) }) | |
| 345 | - | } | |
| 346 | - | } | |
| 347 | - | type attachResult struct { | |
| 348 | - | ac *attachedClient | |
| 349 | - | err error | |
| 350 | - | } | |
| 351 | - | replacementResult := make(chan attachResult, 1) | |
| 352 | - | go func() { | |
| 353 | - | _, replacement, routeErr := d.route( | |
| 354 | - | helloResumeCapable(ports.IntentAttach, "work", 0), | |
| 355 | - | &closeTrackingTransport{}, | |
| 356 | - | ) | |
| 357 | - | replacementResult <- attachResult{ac: replacement, err: routeErr} | |
| 358 | - | }() | |
| 359 | - | awaitTestCompletion(t, resumeGateFrozen, "replacement did not freeze the resumed Welcome role") | |
| 360 | - | ||
| 361 | - | blocked.release() | |
| 362 | - | replacement := awaitTestValue(t, replacementResult, "replacement did not finish after Welcome") | |
| 363 | - | require.NoError(t, replacement.err) | |
| 364 | - | d.attachmentCleanupWg.Wait() | |
| 365 | - | panelFrame := awaitFrame(t, blocked.sends, ports.MsgOutput) | |
| 366 | - | panel, err := ports.UnmarshalOutput(panelFrame.Payload) | |
| 367 | - | require.NoError(t, err) | |
| 368 | - | require.Zero(t, panel.BaseStateNum, "post-Welcome snatched panel must reset output state") | |
| 369 | - | require.Contains(t, string(panel.Data), "Session snatched") | |
| 370 | - | requireNoCoordinatorOutputFrame(t, blocked.sends) | |
| 371 | - | ||
| 372 | - | require.Same(t, replacement.ac, sess.client) | |
| 373 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(resumed)) | |
| 374 | - | rc := sess.renderCoordinator() | |
| 375 | - | rc.mu.Lock() | |
| 376 | - | require.False(t, resumedLease.ready, "replacement during Welcome must not ready the stale active lease") | |
| 377 | - | rc.mu.Unlock() | |
| 378 | - | require.Nil(t, rc.attachmentLease(resumed)) | |
| 379 | - | require.NotNil(t, rc.attachmentLease(replacement.ac)) | |
| 380 | - | ||
| 381 | - | blocked.finish() | |
| 382 | - | awaitTestCompletion(t, handshakeDone, "resumed snatched connection did not continue after replacement") | |
| 383 | - | } | |
| 384 | - | ||
| 385 | - | func TestSnatchedResumePreservesOwnerCapabilityAndCanReclaim(t *testing.T) { | |
| 386 | - | pty, release := newBlockingPTY(t) | |
| 387 | - | defer release() | |
| 388 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 389 | - | ||
| 390 | - | oldTransport := &closeTrackingTransport{} | |
| 391 | - | sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 392 | - | require.NoError(t, err) | |
| 393 | - | token := waiting.resumeToken | |
| 394 | - | waiting.setClientTheme(themeui.Theme{ | |
| 395 | - | Foreground: renderer.RGB{R: 90, G: 10, B: 10}, | |
| 396 | - | Background: renderer.RGB{R: 20, G: 1, B: 1}, | |
| 397 | - | HasFG: true, HasBG: true, Known: true, | |
| 398 | - | }) | |
| 399 | - | d.clientGone(sess, waiting, oldTransport, false) | |
| 400 | - | ||
| 401 | - | activeTransport := &closeTrackingTransport{} | |
| 402 | - | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport) | |
| 403 | - | require.NoError(t, err) | |
| 404 | - | rc := sess.renderCoordinator() | |
| 405 | - | activeGeneration := active.roleGeneration.Load() | |
| 406 | - | activeLease := rc.attachmentLease(active) | |
| 407 | - | require.NotNil(t, activeLease) | |
| 408 | - | activeTheme := themeui.Theme{ | |
| 409 | - | Foreground: renderer.RGB{R: 10, G: 80, B: 20}, | |
| 410 | - | Background: renderer.RGB{R: 1, G: 15, B: 3}, | |
| 411 | - | HasFG: true, HasBG: true, Known: true, | |
| 412 | - | } | |
| 413 | - | active.setClientTheme(activeTheme) | |
| 414 | - | require.True(t, d.applyHostTheme(sess, active, activeTheme, false)) | |
| 415 | - | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |
| 416 | - | ||
| 417 | - | resumedTransport := &closeTrackingTransport{} | |
| 418 | - | resumedSess, resumed, ok, err := d.resumeParked( | |
| 419 | - | helloResumeCapable(ports.IntentResume, "work", token), | |
| 420 | - | resumedTransport, | |
| 421 | - | domain.Size{Cols: 80, Rows: 24}, | |
| 422 | - | ) | |
| 423 | - | require.NoError(t, err) | |
| 424 | - | require.True(t, ok) | |
| 425 | - | require.Same(t, sess, resumedSess) | |
| 426 | - | require.Same(t, waiting, resumed) | |
| 427 | - | require.Same(t, active, sess.client) | |
| 428 | - | require.Equal(t, activeGeneration, active.roleGeneration.Load()) | |
| 429 | - | require.Same(t, activeLease, rc.attachmentLease(active)) | |
| 430 | - | require.Nil(t, rc.attachmentLease(waiting), "snatched resume must not acquire a render lease") | |
| 431 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting)) | |
| 432 | - | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |
| 433 | - | ||
| 434 | - | resumedGeneration := waiting.roleGeneration.Load() | |
| 435 | - | require.False(t, d.handleSnatchedClientFrame(sess.attachmentToken(waiting, resumedTransport), ports.Frame{ | |
| 436 | - | Type: ports.MsgInput, | |
| 437 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 438 | - | })) | |
| 439 | - | d.attachmentCleanupWg.Wait() | |
| 440 | - | ||
| 441 | - | require.Same(t, waiting, sess.client) | |
| 442 | - | require.Equal(t, attachmentActive, sess.attachmentRole(waiting)) | |
| 443 | - | require.Greater(t, waiting.roleGeneration.Load(), resumedGeneration) | |
| 444 | - | require.NotNil(t, rc.attachmentLease(waiting)) | |
| 445 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(active)) | |
| 446 | - | } | |
| 447 | - | ||
| 448 | - | func TestOrdinaryAttachDemotesParkedActiveAndAllowsSnatchedResume(t *testing.T) { | |
| 449 | - | pty, release := newBlockingPTY(t) | |
| 450 | - | defer release() | |
| 451 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 452 | - | ||
| 453 | - | oldTransport := &closeTrackingTransport{} | |
| 454 | - | sess, old, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 455 | - | require.NoError(t, err) | |
| 456 | - | token := old.resumeToken | |
| 457 | - | d.clientGone(sess, old, oldTransport, false) | |
| 458 | - | ||
| 459 | - | activeTransport := &closeTrackingTransport{} | |
| 460 | - | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport) | |
| 461 | - | require.NoError(t, err) | |
| 462 | - | ||
| 463 | - | d.mu.Lock() | |
| 464 | - | parked := d.parked[token] | |
| 465 | - | d.mu.Unlock() | |
| 466 | - | require.NotNil(t, parked, "ordinary attach must preserve the predecessor's resume token") | |
| 467 | - | require.Equal(t, attachmentSnatched, parked.role) | |
| 468 | - | require.Equal(t, token, old.resumeToken) | |
| 469 | - | ||
| 470 | - | resumedTransport := &closeTrackingTransport{} | |
| 471 | - | resumedSess, resumed, ok, err := d.resumeParked( | |
| 472 | - | helloResumeCapable(ports.IntentResume, "work", token), | |
| 473 | - | resumedTransport, | |
| 474 | - | domain.Size{Cols: 80, Rows: 24}, | |
| 475 | - | ) | |
| 476 | - | require.NoError(t, err) | |
| 477 | - | require.True(t, ok) | |
| 478 | - | require.Same(t, sess, resumedSess) | |
| 479 | - | require.Same(t, old, resumed) | |
| 480 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(old)) | |
| 481 | - | require.Same(t, active, sess.client) | |
| 482 | - | } | |
D internal/usecase/daemon/role_effect_gate.go +0 -307
| @@ -1,307 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "sync" | |
| 5 | - | "sync/atomic" | |
| 6 | - | ||
| 7 | - | "github.com/bnema/vev/internal/ports" | |
| 8 | - | ) | |
| 9 | - | ||
| 10 | - | type roleEffectPhase uint8 | |
| 11 | - | ||
| 12 | - | const ( | |
| 13 | - | roleEffectsStable roleEffectPhase = iota | |
| 14 | - | roleEffectsFrozen | |
| 15 | - | ) | |
| 16 | - | ||
| 17 | - | // roleCapability is the single attachment-local publication consumed by role | |
| 18 | - | // effect admission. It deliberately contains no independently mutable state: | |
| 19 | - | // a ticket is admitted only when every field matches the frame's token. | |
| 20 | - | type roleCapability struct { | |
| 21 | - | sess attachmentSession | |
| 22 | - | role attachmentRole | |
| 23 | - | generation uint64 | |
| 24 | - | transport transportSnapshot | |
| 25 | - | lease *attachmentLease | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | func capabilityFromToken(token attachmentRoleToken) roleCapability { | |
| 29 | - | return roleCapability{ | |
| 30 | - | sess: token.sess, | |
| 31 | - | role: token.role, | |
| 32 | - | generation: token.generation, | |
| 33 | - | transport: token.transport, | |
| 34 | - | lease: token.lease, | |
| 35 | - | } | |
| 36 | - | } | |
| 37 | - | ||
| 38 | - | func (c roleCapability) matches(token attachmentRoleToken) bool { | |
| 39 | - | return token.ac != nil && c.sess == token.sess && c.role == token.role && | |
| 40 | - | c.generation == token.generation && c.transport.transport == token.transport.transport && | |
| 41 | - | c.transport.incarnation == token.transport.incarnation && c.lease == token.lease | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | type roleEffectGate struct { | |
| 45 | - | mu sync.Mutex | |
| 46 | - | cond *sync.Cond | |
| 47 | - | changed chan struct{} | |
| 48 | - | phase roleEffectPhase | |
| 49 | - | capability roleCapability | |
| 50 | - | inFlight uint64 | |
| 51 | - | transportEffects map[*roleEffectTicket]transportSnapshot | |
| 52 | - | failedTransport transportSnapshot | |
| 53 | - | order atomic.Uint64 | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | var nextRoleEffectGateOrder atomic.Uint64 | |
| 57 | - | ||
| 58 | - | func (g *roleEffectGate) initLocked() { | |
| 59 | - | if g.cond == nil { | |
| 60 | - | g.cond = sync.NewCond(&g.mu) | |
| 61 | - | } | |
| 62 | - | if g.changed == nil { | |
| 63 | - | g.changed = make(chan struct{}) | |
| 64 | - | } | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | func (g *roleEffectGate) signalLocked() { | |
| 68 | - | g.initLocked() | |
| 69 | - | close(g.changed) | |
| 70 | - | g.changed = make(chan struct{}) | |
| 71 | - | g.cond.Broadcast() | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | func (g *roleEffectGate) immutableOrder() uint64 { | |
| 75 | - | if order := g.order.Load(); order != 0 { | |
| 76 | - | return order | |
| 77 | - | } | |
| 78 | - | order := nextRoleEffectGateOrder.Add(1) | |
| 79 | - | if g.order.CompareAndSwap(0, order) { | |
| 80 | - | return order | |
| 81 | - | } | |
| 82 | - | return g.order.Load() | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | func (g *roleEffectGate) inFlightCount() uint64 { | |
| 86 | - | if g == nil { | |
| 87 | - | return 0 | |
| 88 | - | } | |
| 89 | - | g.mu.Lock() | |
| 90 | - | defer g.mu.Unlock() | |
| 91 | - | return g.inFlight | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | // roleEffectTicket reserves the exact published capability until End. End is | |
| 95 | - | // idempotent so error paths can safely defer it before doing any observable I/O. | |
| 96 | - | type roleEffectTicket struct { | |
| 97 | - | gate *roleEffectGate | |
| 98 | - | token attachmentRoleToken | |
| 99 | - | actionDaemon *Daemon | |
| 100 | - | action string | |
| 101 | - | ended atomic.Bool | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | func (t *roleEffectTicket) bindActionEnd(d *Daemon, action string) { | |
| 105 | - | if t == nil || t.ended.Load() { | |
| 106 | - | return | |
| 107 | - | } | |
| 108 | - | t.actionDaemon = d | |
| 109 | - | t.action = action | |
| 110 | - | } | |
| 111 | - | ||
| 112 | - | func (t *roleEffectTicket) roleToken() attachmentRoleToken { | |
| 113 | - | if t == nil { | |
| 114 | - | return attachmentRoleToken{} | |
| 115 | - | } | |
| 116 | - | token := t.token | |
| 117 | - | token.effect = t | |
| 118 | - | return token | |
| 119 | - | } | |
| 120 | - | ||
| 121 | - | func (t *roleEffectTicket) End() { | |
| 122 | - | if t == nil || t.gate == nil || !t.ended.CompareAndSwap(false, true) { | |
| 123 | - | return | |
| 124 | - | } | |
| 125 | - | g := t.gate | |
| 126 | - | g.mu.Lock() | |
| 127 | - | g.initLocked() | |
| 128 | - | if g.inFlight == 0 { | |
| 129 | - | g.mu.Unlock() | |
| 130 | - | panic("daemon: role effect ticket ended without admission") | |
| 131 | - | } | |
| 132 | - | delete(g.transportEffects, t) | |
| 133 | - | g.inFlight-- | |
| 134 | - | g.signalLocked() | |
| 135 | - | g.mu.Unlock() | |
| 136 | - | if t.actionDaemon != nil && t.actionDaemon.afterActionRoleEffectEnded != nil { | |
| 137 | - | t.actionDaemon.afterActionRoleEffectEnded(t.action) | |
| 138 | - | } | |
| 139 | - | } | |
| 140 | - | ||
| 141 | - | func (d *Daemon) endActionRoleEffect(effect *roleEffectTicket, _ string) { | |
| 142 | - | if effect != nil { | |
| 143 | - | effect.End() | |
| 144 | - | } | |
| 145 | - | } | |
| 146 | - | ||
| 147 | - | // beginTransportSend marks only the interval spent in an external transport | |
| 148 | - | // send as interruptible. A replacement may close this exact published link to | |
| 149 | - | // release the send, but cannot retire an idle healthy snatched connection. | |
| 150 | - | func (t *roleEffectTicket) beginTransportSend(expected transportSnapshot) bool { | |
| 151 | - | if t == nil || t.gate == nil || t.ended.Load() || expected.transport == nil { | |
| 152 | - | return false | |
| 153 | - | } | |
| 154 | - | g := t.gate | |
| 155 | - | g.mu.Lock() | |
| 156 | - | g.initLocked() | |
| 157 | - | if t.ended.Load() || g.capability.transport.transport != expected.transport || | |
| 158 | - | g.capability.transport.incarnation != expected.incarnation { | |
| 159 | - | g.mu.Unlock() | |
| 160 | - | return false | |
| 161 | - | } | |
| 162 | - | if g.transportEffects == nil { | |
| 163 | - | g.transportEffects = make(map[*roleEffectTicket]transportSnapshot) | |
| 164 | - | } | |
| 165 | - | g.transportEffects[t] = expected | |
| 166 | - | g.signalLocked() | |
| 167 | - | g.mu.Unlock() | |
| 168 | - | return true | |
| 169 | - | } | |
| 170 | - | ||
| 171 | - | func (t *roleEffectTicket) reportTransportFailure(expected transportSnapshot) { | |
| 172 | - | if t == nil || t.gate == nil || expected.transport == nil { | |
| 173 | - | return | |
| 174 | - | } | |
| 175 | - | g := t.gate | |
| 176 | - | g.mu.Lock() | |
| 177 | - | if !t.ended.Load() && g.capability.transport.transport == expected.transport && | |
| 178 | - | g.capability.transport.incarnation == expected.incarnation { | |
| 179 | - | g.failedTransport = expected | |
| 180 | - | g.signalLocked() | |
| 181 | - | } | |
| 182 | - | g.mu.Unlock() | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | func (t *roleEffectTicket) endTransportSend() { | |
| 186 | - | if t == nil || t.gate == nil { | |
| 187 | - | return | |
| 188 | - | } | |
| 189 | - | g := t.gate | |
| 190 | - | g.mu.Lock() | |
| 191 | - | if _, exists := g.transportEffects[t]; exists { | |
| 192 | - | delete(g.transportEffects, t) | |
| 193 | - | g.signalLocked() | |
| 194 | - | } | |
| 195 | - | g.mu.Unlock() | |
| 196 | - | } | |
| 197 | - | ||
| 198 | - | // beginRoleEffect is the sole role-bound effect admission point. The gate is | |
| 199 | - | // held only long enough to reserve the capability; the effect itself must run | |
| 200 | - | // without the gate mutex. | |
| 201 | - | func (ac *attachedClient) beginRoleEffect(token attachmentRoleToken) (*roleEffectTicket, bool) { | |
| 202 | - | if ac == nil || token.ac != ac { | |
| 203 | - | return nil, false | |
| 204 | - | } | |
| 205 | - | g := &ac.roleEffects | |
| 206 | - | g.mu.Lock() | |
| 207 | - | g.initLocked() | |
| 208 | - | if g.phase != roleEffectsStable || !g.capability.matches(token) { | |
| 209 | - | g.mu.Unlock() | |
| 210 | - | return nil, false | |
| 211 | - | } | |
| 212 | - | g.inFlight++ | |
| 213 | - | g.mu.Unlock() | |
| 214 | - | captured := token | |
| 215 | - | captured.effect = nil | |
| 216 | - | return &roleEffectTicket{gate: g, token: captured}, true | |
| 217 | - | } | |
| 218 | - | ||
| 219 | - | // beginCurrentRoleEffect waits out a transition that already froze ac, then | |
| 220 | - | // admits the role capability derived from the session registries. Handshakes | |
| 221 | - | // use it after Welcome has completed so a replacement blocked behind that send | |
| 222 | - | // can publish before readiness, first paint, or the snatched reset panel. | |
| 223 | - | func (ac *attachedClient) beginCurrentRoleEffect(sess *session, tr ports.Transport) (attachmentRoleToken, *roleEffectTicket, bool) { | |
| 224 | - | if ac == nil || sess == nil || tr == nil { | |
| 225 | - | return attachmentRoleToken{}, nil, false | |
| 226 | - | } | |
| 227 | - | for { | |
| 228 | - | token := sess.attachmentToken(ac, tr) | |
| 229 | - | if token.role == attachmentDetached { | |
| 230 | - | return token, nil, false | |
| 231 | - | } | |
| 232 | - | if ticket, admitted := ac.beginRoleEffect(token); admitted { | |
| 233 | - | return token, ticket, true | |
| 234 | - | } | |
| 235 | - | ||
| 236 | - | g := &ac.roleEffects | |
| 237 | - | g.mu.Lock() | |
| 238 | - | g.initLocked() | |
| 239 | - | if g.phase == roleEffectsFrozen { | |
| 240 | - | changed := g.changed | |
| 241 | - | g.mu.Unlock() | |
| 242 | - | <-changed | |
| 243 | - | continue | |
| 244 | - | } | |
| 245 | - | g.mu.Unlock() | |
| 246 | - | // Publication may have completed between token capture and admission. | |
| 247 | - | // Re-read the registry and exact generation instead of using stale role | |
| 248 | - | // or lease authority. | |
| 249 | - | } | |
| 250 | - | } | |
| 251 | - | ||
| 252 | - | // publishRoleCapability is used by direct/headless setup and by transition | |
| 253 | - | // publication while the gate is frozen. Production role changes publish only | |
| 254 | - | // through publishFrozenRoleCapability. | |
| 255 | - | func (ac *attachedClient) publishRoleCapability(token attachmentRoleToken) { | |
| 256 | - | if ac == nil { | |
| 257 | - | return | |
| 258 | - | } | |
| 259 | - | g := &ac.roleEffects | |
| 260 | - | g.mu.Lock() | |
| 261 | - | g.initLocked() | |
| 262 | - | g.capability = capabilityFromToken(token) | |
| 263 | - | g.failedTransport = transportSnapshot{} | |
| 264 | - | g.mu.Unlock() | |
| 265 | - | } | |
| 266 | - | ||
| 267 | - | // bootstrapRoleCapability supports direct/headless session construction. It | |
| 268 | - | // never changes an existing or frozen production publication. | |
| 269 | - | func (ac *attachedClient) bootstrapRoleCapability(token attachmentRoleToken) { | |
| 270 | - | if ac == nil || token.sess == nil || token.role == attachmentDetached { | |
| 271 | - | return | |
| 272 | - | } | |
| 273 | - | g := &ac.roleEffects | |
| 274 | - | g.mu.Lock() | |
| 275 | - | g.initLocked() | |
| 276 | - | if g.phase == roleEffectsStable && g.capability.sess == nil { | |
| 277 | - | g.capability = capabilityFromToken(token) | |
| 278 | - | g.failedTransport = transportSnapshot{} | |
| 279 | - | } | |
| 280 | - | g.mu.Unlock() | |
| 281 | - | } | |
| 282 | - | ||
| 283 | - | func (ac *attachedClient) publishFrozenRoleCapability(token attachmentRoleToken) { | |
| 284 | - | g := &ac.roleEffects | |
| 285 | - | g.mu.Lock() | |
| 286 | - | g.initLocked() | |
| 287 | - | if g.phase != roleEffectsFrozen { | |
| 288 | - | g.mu.Unlock() | |
| 289 | - | panic("daemon: publishing role capability without transition freeze") | |
| 290 | - | } | |
| 291 | - | g.capability = capabilityFromToken(token) | |
| 292 | - | g.failedTransport = transportSnapshot{} | |
| 293 | - | g.mu.Unlock() | |
| 294 | - | } | |
| 295 | - | ||
| 296 | - | func (ac *attachedClient) invalidateFrozenRoleCapability() { | |
| 297 | - | g := &ac.roleEffects | |
| 298 | - | g.mu.Lock() | |
| 299 | - | g.initLocked() | |
| 300 | - | if g.phase != roleEffectsFrozen { | |
| 301 | - | g.mu.Unlock() | |
| 302 | - | panic("daemon: invalidating role capability without transition freeze") | |
| 303 | - | } | |
| 304 | - | g.capability = roleCapability{} | |
| 305 | - | g.failedTransport = transportSnapshot{} | |
| 306 | - | g.mu.Unlock() | |
| 307 | - | } | |
D internal/usecase/daemon/role_effect_gate_test.go +0 -1505
| @@ -1,1505 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | "io" | |
| 6 | - | "sync" | |
| 7 | - | "testing" | |
| 8 | - | "time" | |
| 9 | - | ||
| 10 | - | "github.com/stretchr/testify/require" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/vev/internal/domain" | |
| 13 | - | "github.com/bnema/vev/internal/ports" | |
| 14 | - | "github.com/bnema/vev/internal/usecase/keys" | |
| 15 | - | "github.com/bnema/vev/internal/usecase/picker" | |
| 16 | - | "github.com/bnema/vev/pkg/renderer" | |
| 17 | - | ) | |
| 18 | - | ||
| 19 | - | func releaseTestGate(t *testing.T, ch chan struct{}) func() { | |
| 20 | - | t.Helper() | |
| 21 | - | var once sync.Once | |
| 22 | - | release := func() { once.Do(func() { close(ch) }) } | |
| 23 | - | t.Cleanup(release) | |
| 24 | - | return release | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | type blockedRenderReplacementTransport struct { | |
| 28 | - | sendEntered chan struct{} | |
| 29 | - | closed chan struct{} | |
| 30 | - | sendOnce sync.Once | |
| 31 | - | closeOnce sync.Once | |
| 32 | - | locksFree chan bool | |
| 33 | - | d *Daemon | |
| 34 | - | sess *session | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | func newBlockedRenderReplacementTransport(d *Daemon, sess *session) *blockedRenderReplacementTransport { | |
| 38 | - | return &blockedRenderReplacementTransport{ | |
| 39 | - | sendEntered: make(chan struct{}), | |
| 40 | - | closed: make(chan struct{}), | |
| 41 | - | locksFree: make(chan bool, 1), | |
| 42 | - | d: d, | |
| 43 | - | sess: sess, | |
| 44 | - | } | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | func (t *blockedRenderReplacementTransport) Send(ports.Frame) error { | |
| 48 | - | t.sendOnce.Do(func() { close(t.sendEntered) }) | |
| 49 | - | <-t.closed | |
| 50 | - | return errors.New("transport closed") | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | func (*blockedRenderReplacementTransport) Recv() (ports.Frame, error) { | |
| 54 | - | return ports.Frame{}, io.EOF | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | func (t *blockedRenderReplacementTransport) Close() error { | |
| 58 | - | locksFree := t.d.mu.TryLock() | |
| 59 | - | if locksFree { | |
| 60 | - | t.d.mu.Unlock() | |
| 61 | - | } | |
| 62 | - | if locksFree { | |
| 63 | - | locksFree = t.d.notices.routingMu.TryLock() | |
| 64 | - | if locksFree { | |
| 65 | - | t.d.notices.routingMu.Unlock() | |
| 66 | - | } | |
| 67 | - | } | |
| 68 | - | if locksFree { | |
| 69 | - | locksFree = t.sess.mu.TryLock() | |
| 70 | - | if locksFree { | |
| 71 | - | t.sess.mu.Unlock() | |
| 72 | - | } | |
| 73 | - | } | |
| 74 | - | if locksFree { | |
| 75 | - | if rc := t.sess.renderCoordinator(); rc != nil { | |
| 76 | - | locksFree = rc.mu.TryLock() | |
| 77 | - | if locksFree { | |
| 78 | - | rc.mu.Unlock() | |
| 79 | - | } | |
| 80 | - | } | |
| 81 | - | } | |
| 82 | - | t.closeOnce.Do(func() { | |
| 83 | - | t.locksFree <- locksFree | |
| 84 | - | close(t.closed) | |
| 85 | - | }) | |
| 86 | - | return nil | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | type teardownBlockingTransport struct { | |
| 90 | - | sendEntered chan struct{} | |
| 91 | - | closed chan struct{} | |
| 92 | - | sendOnce sync.Once | |
| 93 | - | closeOnce sync.Once | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | func newTeardownBlockingTransport() *teardownBlockingTransport { | |
| 97 | - | return &teardownBlockingTransport{sendEntered: make(chan struct{}), closed: make(chan struct{})} | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | func (t *teardownBlockingTransport) Send(ports.Frame) error { | |
| 101 | - | t.sendOnce.Do(func() { close(t.sendEntered) }) | |
| 102 | - | <-t.closed | |
| 103 | - | return errors.New("transport closed") | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | func (*teardownBlockingTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |
| 107 | - | ||
| 108 | - | func (t *teardownBlockingTransport) Close() error { | |
| 109 | - | t.closeOnce.Do(func() { close(t.closed) }) | |
| 110 | - | return nil | |
| 111 | - | } | |
| 112 | - | ||
| 113 | - | func (t *teardownBlockingTransport) Closed() bool { | |
| 114 | - | select { | |
| 115 | - | case <-t.closed: | |
| 116 | - | return true | |
| 117 | - | default: | |
| 118 | - | return false | |
| 119 | - | } | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | func startBlockedRoleSend(t *testing.T, token attachmentRoleToken) <-chan struct{} { | |
| 123 | - | t.Helper() | |
| 124 | - | ticket, admitted := token.ac.beginRoleEffect(token) | |
| 125 | - | require.True(t, admitted) | |
| 126 | - | done := make(chan struct{}) | |
| 127 | - | go func() { | |
| 128 | - | defer close(done) | |
| 129 | - | defer ticket.End() | |
| 130 | - | _ = token.ac.sendExpectedTransportForRole(token.transport, framePong(), ticket) | |
| 131 | - | }() | |
| 132 | - | return done | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | func requireRoleGateRetired(t *testing.T, ac *attachedClient) { | |
| 136 | - | t.Helper() | |
| 137 | - | ac.roleEffects.mu.Lock() | |
| 138 | - | defer ac.roleEffects.mu.Unlock() | |
| 139 | - | require.Equal(t, roleEffectsStable, ac.roleEffects.phase) | |
| 140 | - | require.Zero(t, ac.roleEffects.inFlight) | |
| 141 | - | require.Empty(t, ac.roleEffects.transportEffects) | |
| 142 | - | } | |
| 143 | - | ||
| 144 | - | func roleGatePublicationSnapshot(ac *attachedClient) (roleEffectPhase, roleCapability) { | |
| 145 | - | ac.roleEffects.mu.Lock() | |
| 146 | - | defer ac.roleEffects.mu.Unlock() | |
| 147 | - | return ac.roleEffects.phase, ac.roleEffects.capability | |
| 148 | - | } | |
| 149 | - | ||
| 150 | - | func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testing.T) { | |
| 151 | - | d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 152 | - | firstTransport := &closeTrackingTransport{} | |
| 153 | - | first.replaceTransport(firstTransport) | |
| 154 | - | ||
| 155 | - | blockedTransport := &closeTrackingTransport{} | |
| 156 | - | blocked := &attachedClient{tr: blockedTransport, output: newOutputStateStream(), size: first.size} | |
| 157 | - | blocked.initOverlays() | |
| 158 | - | blocked.setSession(sess) | |
| 159 | - | laterTransport := &closeTrackingTransport{} | |
| 160 | - | later := &attachedClient{tr: laterTransport, output: newOutputStateStream(), size: first.size} | |
| 161 | - | later.initOverlays() | |
| 162 | - | later.setSession(sess) | |
| 163 | - | sess.mu.Lock() | |
| 164 | - | sess.addSnatchedLocked(blocked) | |
| 165 | - | sess.addSnatchedLocked(later) | |
| 166 | - | sess.mu.Unlock() | |
| 167 | - | ||
| 168 | - | rc := d.attachCoordinator(sess, nil, first, true) | |
| 169 | - | firstToken := sess.attachmentToken(first, firstTransport) | |
| 170 | - | firstToken.lease = rc.attachmentLease(first) | |
| 171 | - | first.publishRoleCapability(firstToken) | |
| 172 | - | blockedToken := sess.attachmentToken(blocked, blockedTransport) | |
| 173 | - | blocked.publishRoleCapability(blockedToken) | |
| 174 | - | laterToken := sess.attachmentToken(later, laterTransport) | |
| 175 | - | later.publishRoleCapability(laterToken) | |
| 176 | - | ||
| 177 | - | // Fix the canonical order so teardown acquires first, waits behind the gate | |
| 178 | - | // owned by this test, and never reaches the still-stable later participant. | |
| 179 | - | first.roleEffects.immutableOrder() | |
| 180 | - | blocked.roleEffects.immutableOrder() | |
| 181 | - | later.roleEffects.immutableOrder() | |
| 182 | - | require.Less(t, first.roleEffects.order.Load(), blocked.roleEffects.order.Load()) | |
| 183 | - | require.Less(t, blocked.roleEffects.order.Load(), later.roleEffects.order.Load()) | |
| 184 | - | ||
| 185 | - | blockedOwner := freezeRoleEffectGates(blocked) | |
| 186 | - | blockedOwnerHeld := true | |
| 187 | - | t.Cleanup(func() { | |
| 188 | - | if blockedOwnerHeld { | |
| 189 | - | blockedOwner.unfreeze() | |
| 190 | - | } | |
| 191 | - | }) | |
| 192 | - | ||
| 193 | - | firstPhase, firstCapability := roleGatePublicationSnapshot(first) | |
| 194 | - | blockedPhase, blockedCapability := roleGatePublicationSnapshot(blocked) | |
| 195 | - | laterPhase, laterCapability := roleGatePublicationSnapshot(later) | |
| 196 | - | require.Equal(t, roleEffectsStable, firstPhase) | |
| 197 | - | require.Equal(t, roleEffectsFrozen, blockedPhase) | |
| 198 | - | require.Equal(t, roleEffectsStable, laterPhase) | |
| 199 | - | firstGeneration := first.roleGeneration.Load() | |
| 200 | - | blockedGeneration := blocked.roleGeneration.Load() | |
| 201 | - | laterGeneration := later.roleGeneration.Load() | |
| 202 | - | firstSnapshot := first.transportSnapshot() | |
| 203 | - | blockedSnapshot := blocked.transportSnapshot() | |
| 204 | - | laterSnapshot := later.transportSnapshot() | |
| 205 | - | ||
| 206 | - | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 207 | - | d.clock = clock | |
| 208 | - | frozenByKill := make(chan *attachedClient, 3) | |
| 209 | - | d.afterRoleEffectGateFrozen = func(_ string, ac *attachedClient) { frozenByKill <- ac } | |
| 210 | - | killDone := make(chan error, 1) | |
| 211 | - | go func() { killDone <- d.killSession(sess, ports.ReasonSessionKilled, false) }() | |
| 212 | - | ||
| 213 | - | require.Same(t, first, awaitTestValue(t, frozenByKill, "teardown did not acquire the first role gate")) | |
| 214 | - | deadline := awaitTestValue(t, clock.timers, "teardown did not arm the gate acquisition deadline") | |
| 215 | - | require.Equal(t, detachNotifyTimeout, deadline.duration) | |
| 216 | - | deadline.ch <- time.Now() | |
| 217 | - | select { | |
| 218 | - | case err := <-killDone: | |
| 219 | - | require.NoError(t, err) | |
| 220 | - | case <-time.After(time.Second): | |
| 221 | - | t.Fatal("teardown did not abort after partial gate acquisition timed out") | |
| 222 | - | } | |
| 223 | - | require.Empty(t, frozenByKill, "teardown acquired a participant after its deadline expired") | |
| 224 | - | ||
| 225 | - | d.mu.Lock() | |
| 226 | - | require.Same(t, sess, d.sessions[sess.id], "partial gate acquisition removed the session registry owner") | |
| 227 | - | d.mu.Unlock() | |
| 228 | - | sess.mu.Lock() | |
| 229 | - | require.Same(t, first, sess.client) | |
| 230 | - | require.Contains(t, sess.snatched, blocked) | |
| 231 | - | require.Contains(t, sess.snatched, later) | |
| 232 | - | sess.mu.Unlock() | |
| 233 | - | for _, ac := range []*attachedClient{first, blocked, later} { | |
| 234 | - | require.Same(t, sess, ac.currentSession(), "partial gate acquisition cleared attachment ownership") | |
| 235 | - | } | |
| 236 | - | require.Equal(t, firstGeneration, first.roleGeneration.Load()) | |
| 237 | - | require.Equal(t, blockedGeneration, blocked.roleGeneration.Load()) | |
| 238 | - | require.Equal(t, laterGeneration, later.roleGeneration.Load()) | |
| 239 | - | require.True(t, first.transportSnapshotCurrent(firstSnapshot)) | |
| 240 | - | require.True(t, blocked.transportSnapshotCurrent(blockedSnapshot)) | |
| 241 | - | require.True(t, later.transportSnapshotCurrent(laterSnapshot)) | |
| 242 | - | require.False(t, firstTransport.Closed()) | |
| 243 | - | require.False(t, blockedTransport.Closed()) | |
| 244 | - | require.False(t, laterTransport.Closed()) | |
| 245 | - | ||
| 246 | - | firstPhase, gotFirstCapability := roleGatePublicationSnapshot(first) | |
| 247 | - | blockedPhase, gotBlockedCapability := roleGatePublicationSnapshot(blocked) | |
| 248 | - | laterPhase, gotLaterCapability := roleGatePublicationSnapshot(later) | |
| 249 | - | require.Equal(t, roleEffectsStable, firstPhase, "partially acquired gate was not rolled back") | |
| 250 | - | require.Equal(t, roleEffectsFrozen, blockedPhase, "teardown released a gate owned by another transition") | |
| 251 | - | require.Equal(t, roleEffectsStable, laterPhase, "unacquired later gate was frozen or invalidated") | |
| 252 | - | require.Equal(t, firstCapability, gotFirstCapability) | |
| 253 | - | require.Equal(t, blockedCapability, gotBlockedCapability) | |
| 254 | - | require.Equal(t, laterCapability, gotLaterCapability) | |
| 255 | - | ||
| 256 | - | blockedOwner.unfreeze() | |
| 257 | - | blockedOwnerHeld = false | |
| 258 | - | complete := freezeRoleEffectGates(first, blocked, later) | |
| 259 | - | require.True(t, complete.acquired, "rolled-back gate set could not be acquired again") | |
| 260 | - | require.True(t, complete.drained) | |
| 261 | - | complete.unfreeze() | |
| 262 | - | for _, token := range []attachmentRoleToken{firstToken, blockedToken, laterToken} { | |
| 263 | - | ticket, admitted := token.ac.beginRoleEffect(token) | |
| 264 | - | require.True(t, admitted, "pre-publication abort changed a role capability") | |
| 265 | - | ticket.End() | |
| 266 | - | requireRoleGateRetired(t, token.ac) | |
| 267 | - | } | |
| 268 | - | } | |
| 269 | - | ||
| 270 | - | func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) { | |
| 271 | - | d, sess, snatched, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 272 | - | snatchedTransport := newTeardownBlockingTransport() | |
| 273 | - | snatched.replaceTransport(snatchedTransport) | |
| 274 | - | d.attachCoordinator(sess, nil, snatched, true) | |
| 275 | - | snatched.publishRoleCapability(sess.attachmentToken(snatched, snatchedTransport)) | |
| 276 | - | ||
| 277 | - | activeTransport := newTeardownBlockingTransport() | |
| 278 | - | active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: snatched.size} | |
| 279 | - | active.initOverlays() | |
| 280 | - | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 281 | - | target: sess, next: active, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 282 | - | expectedTransport: active.transportSnapshot(), ready: true, | |
| 283 | - | }) | |
| 284 | - | require.NoError(t, err) | |
| 285 | - | defer d.deferAttachmentTransitionCleanups(transition) | |
| 286 | - | ||
| 287 | - | unrelatedTransport := &closeTrackingTransport{} | |
| 288 | - | unrelatedClient := &attachedClient{tr: unrelatedTransport, output: newOutputStateStream(), size: active.size} | |
| 289 | - | unrelatedClient.initOverlays() | |
| 290 | - | unrelated := &session{sessionCore: sessionCore{id: "unrelated", name: "unrelated", client: unrelatedClient}, ctx: sess.ctx, cancel: func() {}} | |
| 291 | - | unrelatedClient.setSession(unrelated) | |
| 292 | - | d.mu.Lock() | |
| 293 | - | d.sessions[unrelated.id] = unrelated | |
| 294 | - | d.mu.Unlock() | |
| 295 | - | d.attachCoordinator(unrelated, nil, unrelatedClient, true) | |
| 296 | - | ||
| 297 | - | snatchedToken := sess.attachmentToken(snatched, snatchedTransport) | |
| 298 | - | activeToken := sess.attachmentToken(active, activeTransport) | |
| 299 | - | snatchedDone := startBlockedRoleSend(t, snatchedToken) | |
| 300 | - | activeDone := startBlockedRoleSend(t, activeToken) | |
| 301 | - | awaitTestCompletion(t, snatchedTransport.sendEntered, "snatched send did not block") | |
| 302 | - | awaitTestCompletion(t, activeTransport.sendEntered, "active send did not block") | |
| 303 | - | ||
| 304 | - | killDone := make(chan error, 1) | |
| 305 | - | go func() { killDone <- d.killSession(sess, ports.ReasonSessionKilled, false) }() | |
| 306 | - | select { | |
| 307 | - | case err := <-killDone: | |
| 308 | - | require.NoError(t, err) | |
| 309 | - | case <-time.After(500 * time.Millisecond): | |
| 310 | - | _ = snatchedTransport.Close() | |
| 311 | - | _ = activeTransport.Close() | |
| 312 | - | t.Fatal("session kill remained pinned behind admitted participant sends") | |
| 313 | - | } | |
| 314 | - | awaitTestCompletion(t, snatchedDone, "snatched role ticket did not retire") | |
| 315 | - | awaitTestCompletion(t, activeDone, "active role ticket did not retire") | |
| 316 | - | d.waitNotifies() | |
| 317 | - | ||
| 318 | - | require.True(t, snatchedTransport.Closed()) | |
| 319 | - | require.True(t, activeTransport.Closed()) | |
| 320 | - | require.False(t, unrelatedTransport.Closed(), "unrelated session transport was interrupted") | |
| 321 | - | require.Same(t, unrelatedClient, unrelated.client) | |
| 322 | - | require.Same(t, unrelated, unrelatedClient.currentSession()) | |
| 323 | - | requireRoleGateRetired(t, snatched) | |
| 324 | - | requireRoleGateRetired(t, active) | |
| 325 | - | } | |
| 326 | - | ||
| 327 | - | func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *testing.T) { | |
| 328 | - | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 329 | - | stale := newTeardownBlockingTransport() | |
| 330 | - | ac.replaceTransport(stale) | |
| 331 | - | d.attachCoordinator(sess, nil, ac, true) | |
| 332 | - | ac.publishRoleCapability(sess.attachmentToken(ac, stale)) | |
| 333 | - | ||
| 334 | - | token := sess.attachmentToken(ac, stale) | |
| 335 | - | sendDone := startBlockedRoleSend(t, token) | |
| 336 | - | awaitTestCompletion(t, stale.sendEntered, "captured incarnation send did not block") | |
| 337 | - | fresh := &closeTrackingTransport{} | |
| 338 | - | d.afterRoleEffectParticipantsSnapshotted = func(string, []*attachedClient) { | |
| 339 | - | ac.replaceTransport(fresh) | |
| 340 | - | } | |
| 341 | - | ||
| 342 | - | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |
| 343 | - | awaitTestCompletion(t, sendDone, "captured incarnation role ticket did not retire") | |
| 344 | - | require.True(t, stale.Closed(), "exact captured in-flight transport was not interrupted") | |
| 345 | - | require.False(t, fresh.Closed(), "newer transport incarnation was interrupted") | |
| 346 | - | require.Same(t, fresh, ac.transport()) | |
| 347 | - | d.mu.Lock() | |
| 348 | - | require.Same(t, sess, d.sessions[sess.id], "transport incarnation change should abort stale teardown publication") | |
| 349 | - | d.mu.Unlock() | |
| 350 | - | sess.mu.Lock() | |
| 351 | - | require.Same(t, ac, sess.client) | |
| 352 | - | sess.mu.Unlock() | |
| 353 | - | requireRoleGateRetired(t, ac) | |
| 354 | - | } | |
| 355 | - | ||
| 356 | - | func TestShutdownSignalsServeWhenParticipantGateAcquisitionTimesOut(t *testing.T) { | |
| 357 | - | d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 358 | - | transport := &closeTrackingTransport{} | |
| 359 | - | active.replaceTransport(transport) | |
| 360 | - | d.attachCoordinator(sess, nil, active, true) | |
| 361 | - | active.publishRoleCapability(sess.attachmentToken(active, transport)) | |
| 362 | - | ||
| 363 | - | owner := freezeRoleEffectGates(active) | |
| 364 | - | require.True(t, owner.acquired) | |
| 365 | - | defer owner.unfreeze() | |
| 366 | - | ||
| 367 | - | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 368 | - | d.clock = clock | |
| 369 | - | shutdownDone := make(chan bool, 1) | |
| 370 | - | go func() { shutdownDone <- d.shutdownAll(ports.ReasonServerShutdown) }() | |
| 371 | - | ||
| 372 | - | deadline := awaitTestValue(t, clock.timers, "shutdown did not arm the gate acquisition deadline") | |
| 373 | - | deadline.ch <- clock.Now() | |
| 374 | - | require.False(t, awaitTestValue(t, shutdownDone, "shutdown did not return after its gate acquisition deadline")) | |
| 375 | - | ||
| 376 | - | d.mu.Lock() | |
| 377 | - | registered := d.sessions[sess.id] | |
| 378 | - | d.mu.Unlock() | |
| 379 | - | require.Same(t, sess, registered, "timed-out initial teardown should leave the session for Serve's shutdown pass") | |
| 380 | - | select { | |
| 381 | - | case <-d.done: | |
| 382 | - | case <-time.After(time.Second): | |
| 383 | - | t.Fatal("shutdown request did not signal Serve after the initial teardown pass timed out") | |
| 384 | - | } | |
| 385 | - | } | |
| 386 | - | ||
| 387 | - | func TestShutdownInterruptsBlockedParticipantSend(t *testing.T) { | |
| 388 | - | d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 389 | - | blocked := newTeardownBlockingTransport() | |
| 390 | - | active.replaceTransport(blocked) | |
| 391 | - | d.attachCoordinator(sess, nil, active, true) | |
| 392 | - | active.publishRoleCapability(sess.attachmentToken(active, blocked)) | |
| 393 | - | ||
| 394 | - | token := sess.attachmentToken(active, blocked) | |
| 395 | - | sendDone := startBlockedRoleSend(t, token) | |
| 396 | - | awaitTestCompletion(t, blocked.sendEntered, "active send did not block") | |
| 397 | - | ||
| 398 | - | shutdownDone := make(chan bool, 1) | |
| 399 | - | go func() { shutdownDone <- d.shutdownAll(ports.ReasonServerShutdown) }() | |
| 400 | - | select { | |
| 401 | - | case incomplete := <-shutdownDone: | |
| 402 | - | require.False(t, incomplete) | |
| 403 | - | case <-time.After(500 * time.Millisecond): | |
| 404 | - | _ = blocked.Close() | |
| 405 | - | t.Fatal("shutdown remained pinned behind admitted participant send") | |
| 406 | - | } | |
| 407 | - | awaitTestCompletion(t, sendDone, "shutdown-interrupted role ticket did not retire") | |
| 408 | - | d.waitNotifies() | |
| 409 | - | require.True(t, blocked.Closed()) | |
| 410 | - | requireRoleGateRetired(t, active) | |
| 411 | - | } | |
| 412 | - | ||
| 413 | - | type renderFailureTransport struct { | |
| 414 | - | mu sync.Mutex | |
| 415 | - | closed bool | |
| 416 | - | } | |
| 417 | - | ||
| 418 | - | func (*renderFailureTransport) Send(ports.Frame) error { return errors.New("render send failed") } | |
| 419 | - | func (*renderFailureTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |
| 420 | - | func (t *renderFailureTransport) Close() error { | |
| 421 | - | t.mu.Lock() | |
| 422 | - | t.closed = true | |
| 423 | - | t.mu.Unlock() | |
| 424 | - | return nil | |
| 425 | - | } | |
| 426 | - | func (t *renderFailureTransport) Closed() bool { | |
| 427 | - | t.mu.Lock() | |
| 428 | - | defer t.mu.Unlock() | |
| 429 | - | return t.closed | |
| 430 | - | } | |
| 431 | - | ||
| 432 | - | func TestRenderSendFailureCleanupRejectsReplacedAndResumedIncarnation(t *testing.T) { | |
| 433 | - | d, sess, original, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 434 | - | failedTransport := &renderFailureTransport{} | |
| 435 | - | original.replaceTransport(failedTransport) | |
| 436 | - | rc := d.attachCoordinator(sess, nil, original, true) | |
| 437 | - | originalToken := sess.attachmentToken(original, failedTransport) | |
| 438 | - | originalToken.lease = rc.attachmentLease(original) | |
| 439 | - | original.publishRoleCapability(originalToken) | |
| 440 | - | ||
| 441 | - | cleanupStarted := make(chan struct{}) | |
| 442 | - | releaseCleanup := make(chan struct{}) | |
| 443 | - | cleanupDone := make(chan struct{}) | |
| 444 | - | d.beforeRoleSendErrorCleanup = func(token attachmentRoleToken) { | |
| 445 | - | require.Equal(t, originalToken.generation, token.generation) | |
| 446 | - | require.Equal(t, originalToken.transport, token.transport) | |
| 447 | - | require.Same(t, originalToken.lease, token.lease) | |
| 448 | - | close(cleanupStarted) | |
| 449 | - | <-releaseCleanup | |
| 450 | - | } | |
| 451 | - | d.afterRoleSendErrorCleanup = func() { close(cleanupDone) } | |
| 452 | - | ||
| 453 | - | d.paint(sess, original, true, originalToken.lease) | |
| 454 | - | awaitTestCompletion(t, cleanupStarted, "render failure cleanup did not retain the captured role token") | |
| 455 | - | cleanupTracked := waitGroupDone(&d.attachmentCleanupWg) | |
| 456 | - | select { | |
| 457 | - | case <-cleanupTracked: | |
| 458 | - | t.Fatal("paused render failure cleanup was not tracked by attachmentCleanupWg") | |
| 459 | - | default: | |
| 460 | - | } | |
| 461 | - | ||
| 462 | - | replacementTransport := &closeTrackingTransport{} | |
| 463 | - | replacement := &attachedClient{tr: replacementTransport, output: newOutputStateStream(), size: original.size} | |
| 464 | - | replacement.initOverlays() | |
| 465 | - | displaced, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 466 | - | target: sess, next: replacement, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 467 | - | expectedTransport: replacement.transportSnapshot(), ready: true, | |
| 468 | - | }) | |
| 469 | - | require.NoError(t, err) | |
| 470 | - | ||
| 471 | - | resumed, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 472 | - | target: sess, next: original, expectedRole: attachmentSnatched, targetRole: attachmentActive, | |
| 473 | - | expectedTransport: original.transportSnapshot(), ready: true, | |
| 474 | - | }) | |
| 475 | - | require.NoError(t, err) | |
| 476 | - | resumedGeneration := original.roleGeneration.Load() | |
| 477 | - | resumedLease := resumed.published.lease | |
| 478 | - | require.NotSame(t, originalToken.lease, resumedLease) | |
| 479 | - | ||
| 480 | - | close(releaseCleanup) | |
| 481 | - | awaitTestCompletion(t, cleanupDone, "render failure cleanup did not finish") | |
| 482 | - | awaitTestCompletion(t, cleanupTracked, "tracked render failure cleanup did not retire") | |
| 483 | - | ||
| 484 | - | sess.mu.Lock() | |
| 485 | - | require.Same(t, original, sess.client, "stale render cleanup detached the resumed incarnation") | |
| 486 | - | sess.mu.Unlock() | |
| 487 | - | require.Equal(t, resumedGeneration, original.roleGeneration.Load()) | |
| 488 | - | require.True(t, resumed.published.activeCurrent()) | |
| 489 | - | require.False(t, failedTransport.Closed(), "stale cleanup closed the resumed incarnation's transport") | |
| 490 | - | ||
| 491 | - | d.deferAttachmentTransitionCleanups(displaced) | |
| 492 | - | d.deferAttachmentTransitionCleanups(resumed) | |
| 493 | - | d.attachmentCleanupWg.Wait() | |
| 494 | - | } | |
| 495 | - | ||
| 496 | - | func TestRenderSendFailureCleanupIsDeadlineBoundedWhileRoleGateIsBusy(t *testing.T) { | |
| 497 | - | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 498 | - | transport := &closeTrackingTransport{} | |
| 499 | - | ac.replaceTransport(transport) | |
| 500 | - | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 501 | - | token := sess.attachmentToken(ac, transport) | |
| 502 | - | token.lease = rc.attachmentLease(ac) | |
| 503 | - | ac.publishRoleCapability(token) | |
| 504 | - | ||
| 505 | - | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 506 | - | d.clock = clock | |
| 507 | - | frozen := freezeRoleEffectGates(ac) | |
| 508 | - | defer frozen.unfreeze() | |
| 509 | - | cleanupDone := make(chan struct{}) | |
| 510 | - | d.afterRoleSendErrorCleanup = func() { close(cleanupDone) } | |
| 511 | - | ||
| 512 | - | launchCleanup := d.reserveRoleSendErrorCleanup(token, transport) | |
| 513 | - | launchCleanup() | |
| 514 | - | deadline := awaitTestValue(t, clock.timers, "render cleanup did not arm its deadline") | |
| 515 | - | require.Equal(t, detachNotifyTimeout, deadline.duration) | |
| 516 | - | deadline.ch <- time.Now() | |
| 517 | - | awaitTestCompletion(t, cleanupDone, "deadline did not release blocked render cleanup") | |
| 518 | - | awaitTestCompletion(t, waitGroupDone(&d.attachmentCleanupWg), "bounded render cleanup leaked its tracked goroutine") | |
| 519 | - | ||
| 520 | - | sess.mu.Lock() | |
| 521 | - | require.Same(t, ac, sess.client) | |
| 522 | - | sess.mu.Unlock() | |
| 523 | - | require.False(t, transport.Closed()) | |
| 524 | - | } | |
| 525 | - | ||
| 526 | - | func TestRoleEffectGateReplacementInterruptsBlockedOldRenderBeforePublication(t *testing.T) { | |
| 527 | - | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 528 | - | oldTransport := newBlockedRenderReplacementTransport(d, sess) | |
| 529 | - | old.replaceTransport(oldTransport) | |
| 530 | - | rc := d.attachCoordinator(sess, nil, old, true) | |
| 531 | - | lease := rc.attachmentLease(old) | |
| 532 | - | token := sess.attachmentToken(old, oldTransport) | |
| 533 | - | token.lease = lease | |
| 534 | - | old.publishRoleCapability(token) | |
| 535 | - | ||
| 536 | - | paintDone := make(chan struct{}) | |
| 537 | - | go func() { | |
| 538 | - | d.paint(sess, old, true, lease) | |
| 539 | - | close(paintDone) | |
| 540 | - | }() | |
| 541 | - | <-oldTransport.sendEntered | |
| 542 | - | ||
| 543 | - | newTransport := &closeTrackingTransport{} | |
| 544 | - | next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size} | |
| 545 | - | next.initOverlays() | |
| 546 | - | transitionDone := make(chan attachmentTransitionResult, 1) | |
| 547 | - | transitionErr := make(chan error, 1) | |
| 548 | - | go func() { | |
| 549 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 550 | - | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 551 | - | expectedTransport: next.transportSnapshot(), ready: true, | |
| 552 | - | }) | |
| 553 | - | transitionDone <- result | |
| 554 | - | transitionErr <- err | |
| 555 | - | }() | |
| 556 | - | ||
| 557 | - | var result attachmentTransitionResult | |
| 558 | - | select { | |
| 559 | - | case result = <-transitionDone: | |
| 560 | - | case <-time.After(time.Second): | |
| 561 | - | t.Fatal("replacement publication remained blocked behind the old render send") | |
| 562 | - | } | |
| 563 | - | require.NoError(t, <-transitionErr) | |
| 564 | - | require.True(t, <-oldTransport.locksFree, "transport close ran while an architecture lock was held") | |
| 565 | - | select { | |
| 566 | - | case <-paintDone: | |
| 567 | - | case <-time.After(time.Second): | |
| 568 | - | t.Fatal("closing the exact old transport did not unblock its render") | |
| 569 | - | } | |
| 570 | - | require.Same(t, next, sess.client) | |
| 571 | - | require.False(t, newTransport.Closed(), "replacement transport was affected by old-link interruption") | |
| 572 | - | ||
| 573 | - | d.deferAttachmentTransitionCleanups(result) | |
| 574 | - | d.attachmentCleanupWg.Wait() | |
| 575 | - | require.Nil(t, old.transport(), "the unhealthy old link was not retired") | |
| 576 | - | require.Same(t, next, sess.client) | |
| 577 | - | require.False(t, newTransport.Closed()) | |
| 578 | - | } | |
| 579 | - | ||
| 580 | - | func TestDisplacedBlockedRenderCleanupWaitsForStaleDetachFreeze(t *testing.T) { | |
| 581 | - | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 582 | - | oldTransport := newBlockedRenderReplacementTransport(d, sess) | |
| 583 | - | old.replaceTransport(oldTransport) | |
| 584 | - | rc := d.attachCoordinator(sess, nil, old, true) | |
| 585 | - | lease := rc.attachmentLease(old) | |
| 586 | - | token := sess.attachmentToken(old, oldTransport) | |
| 587 | - | token.lease = lease | |
| 588 | - | old.publishRoleCapability(token) | |
| 589 | - | old.sendMu.Lock() | |
| 590 | - | old.captureFrames = map[*pane]capturedPaneRenderState{sess.activeTab().focusedPane(): {}} | |
| 591 | - | old.sendMu.Unlock() | |
| 592 | - | ||
| 593 | - | staleDetachFrozen := make(chan struct{}) | |
| 594 | - | releaseStaleDetach := make(chan struct{}) | |
| 595 | - | var releaseOnce sync.Once | |
| 596 | - | t.Cleanup(func() { releaseOnce.Do(func() { close(releaseStaleDetach) }) }) | |
| 597 | - | d.afterDetachRoleEffectsFrozen = func() { | |
| 598 | - | close(staleDetachFrozen) | |
| 599 | - | <-releaseStaleDetach | |
| 600 | - | } | |
| 601 | - | cleanupStarted := make(chan struct{}) | |
| 602 | - | d.afterDisplacedCleanupStarted = func() { close(cleanupStarted) } | |
| 603 | - | ||
| 604 | - | paintDone := make(chan struct{}) | |
| 605 | - | go func() { | |
| 606 | - | d.paint(sess, old, true, lease) | |
| 607 | - | close(paintDone) | |
| 608 | - | }() | |
| 609 | - | awaitTestCompletion(t, oldTransport.sendEntered, "old render did not block in transport send") | |
| 610 | - | ||
| 611 | - | newTransport := &closeTrackingTransport{} | |
| 612 | - | next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size} | |
| 613 | - | next.initOverlays() | |
| 614 | - | transitionDone := make(chan attachmentTransitionResult, 1) | |
| 615 | - | transitionErr := make(chan error, 1) | |
| 616 | - | go func() { | |
| 617 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 618 | - | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 619 | - | expectedTransport: next.transportSnapshot(), ready: true, | |
| 620 | - | }) | |
| 621 | - | transitionDone <- result | |
| 622 | - | transitionErr <- err | |
| 623 | - | }() | |
| 624 | - | ||
| 625 | - | var result attachmentTransitionResult | |
| 626 | - | select { | |
| 627 | - | case result = <-transitionDone: | |
| 628 | - | require.NoError(t, <-transitionErr) | |
| 629 | - | case <-time.After(time.Second): | |
| 630 | - | t.Fatal("replacement did not interrupt the blocked render") | |
| 631 | - | } | |
| 632 | - | awaitTestCompletion(t, paintDone, "interrupted render did not retire") | |
| 633 | - | awaitTestCompletion(t, staleDetachFrozen, "stale send-error detach did not freeze the role gate") | |
| 634 | - | ||
| 635 | - | d.deferAttachmentTransitionCleanups(result) | |
| 636 | - | awaitTestCompletion(t, cleanupStarted, "displaced cleanup did not start during stale detach freeze") | |
| 637 | - | releaseOnce.Do(func() { close(releaseStaleDetach) }) | |
| 638 | - | select { | |
| 639 | - | case <-waitGroupDone(&d.attachmentCleanupWg): | |
| 640 | - | case <-time.After(time.Second): | |
| 641 | - | t.Fatal("displaced cleanup deadlocked behind stale detach") | |
| 642 | - | } | |
| 643 | - | ||
| 644 | - | sess.mu.Lock() | |
| 645 | - | require.Same(t, next, sess.client) | |
| 646 | - | require.NotContains(t, sess.snatched, old, "closed displaced incarnation remained registered") | |
| 647 | - | sess.mu.Unlock() | |
| 648 | - | require.Equal(t, attachmentActive, sess.attachmentRole(next)) | |
| 649 | - | require.Nil(t, old.transport(), "closed displaced transport remained attached") | |
| 650 | - | require.Nil(t, old.currentSession(), "terminal displaced cleanup retained session ownership") | |
| 651 | - | old.sendMu.Lock() | |
| 652 | - | require.Empty(t, old.captureFrames, "terminal displaced cleanup retained captures") | |
| 653 | - | old.sendMu.Unlock() | |
| 654 | - | require.False(t, newTransport.Closed(), "stale cleanup affected the new active owner") | |
| 655 | - | } | |
| 656 | - | ||
| 657 | - | func TestInterruptedDisplacedCleanupRejectsNewerReclaimedIncarnation(t *testing.T) { | |
| 658 | - | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 659 | - | staleTransport := &closeTrackingTransport{} | |
| 660 | - | old.replaceTransport(staleTransport) | |
| 661 | - | d.attachCoordinator(sess, nil, old, true) | |
| 662 | - | ||
| 663 | - | nextTransport := &closeTrackingTransport{} | |
| 664 | - | next := &attachedClient{tr: nextTransport, output: newOutputStateStream(), size: old.size} | |
| 665 | - | next.initOverlays() | |
| 666 | - | displaced, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 667 | - | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 668 | - | expectedTransport: next.transportSnapshot(), ready: true, | |
| 669 | - | }) | |
| 670 | - | require.NoError(t, err) | |
| 671 | - | stale := displaced.displaced | |
| 672 | - | ||
| 673 | - | _, err = d.transitionAttachment(attachmentTransitionRequest{ | |
| 674 | - | target: sess, next: old, expectedRole: attachmentSnatched, targetRole: attachmentActive, | |
| 675 | - | expectedTransport: old.transportSnapshot(), ready: true, | |
| 676 | - | }) | |
| 677 | - | require.NoError(t, err) | |
| 678 | - | freshTransport := &closeTrackingTransport{} | |
| 679 | - | old.replaceTransport(freshTransport) | |
| 680 | - | _, err = d.transitionAttachment(attachmentTransitionRequest{ | |
| 681 | - | target: sess, next: old, expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 682 | - | expectedTransport: old.transportSnapshot(), ready: true, | |
| 683 | - | }) | |
| 684 | - | require.NoError(t, err) | |
| 685 | - | require.NoError(t, staleTransport.Close()) | |
| 686 | - | ||
| 687 | - | require.False(t, d.cleanupInterruptedSnatchedAttachment(stale)) | |
| 688 | - | sess.mu.Lock() | |
| 689 | - | require.Same(t, old, sess.client) | |
| 690 | - | require.Contains(t, sess.snatched, next) | |
| 691 | - | sess.mu.Unlock() | |
| 692 | - | require.Equal(t, attachmentActive, sess.attachmentRole(old)) | |
| 693 | - | require.Same(t, freshTransport, old.transport()) | |
| 694 | - | require.False(t, freshTransport.Closed(), "stale terminal cleanup closed the newer transport") | |
| 695 | - | require.False(t, nextTransport.Closed(), "stale terminal cleanup affected the newer displaced owner") | |
| 696 | - | } | |
| 697 | - | ||
| 698 | - | func TestRoleEffectGateAdmittedEffectCompletesBeforeConflictingTransition(t *testing.T) { | |
| 699 | - | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 700 | - | oldTransport := newDatagramTestTransport() | |
| 701 | - | old.replaceTransport(oldTransport) | |
| 702 | - | rc := d.attachCoordinator(sess, nil, old, true) | |
| 703 | - | token := sess.attachmentToken(old, oldTransport) | |
| 704 | - | token.lease = rc.attachmentLease(old) | |
| 705 | - | old.publishRoleCapability(token) | |
| 706 | - | ||
| 707 | - | ticket, ok := old.beginRoleEffect(token) | |
| 708 | - | require.True(t, ok) | |
| 709 | - | ||
| 710 | - | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 711 | - | next.initOverlays() | |
| 712 | - | done := make(chan error, 1) | |
| 713 | - | go func() { | |
| 714 | - | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 715 | - | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 716 | - | expectedTransport: next.transportSnapshot(), ready: true, | |
| 717 | - | }) | |
| 718 | - | done <- err | |
| 719 | - | }() | |
| 720 | - | ||
| 721 | - | select { | |
| 722 | - | case err := <-done: | |
| 723 | - | t.Fatalf("transition committed before admitted effect ended: %v", err) | |
| 724 | - | case <-time.After(20 * time.Millisecond): | |
| 725 | - | } | |
| 726 | - | ticket.End() | |
| 727 | - | require.NoError(t, <-done) | |
| 728 | - | } | |
| 729 | - | ||
| 730 | - | func TestRoleEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *testing.T) { | |
| 731 | - | tests := []struct { | |
| 732 | - | name string | |
| 733 | - | setup func(*Daemon, *session, *attachedClient, *transactionalResizePTY) | |
| 734 | - | frame ports.Frame | |
| 735 | - | assert func(*testing.T, *attachedClient, *transactionalResizePTY) | |
| 736 | - | }{ | |
| 737 | - | { | |
| 738 | - | name: "PTY input", | |
| 739 | - | frame: frameInput([]byte("reserved")), | |
| 740 | - | assert: func(t *testing.T, _ *attachedClient, pty *transactionalResizePTY) { | |
| 741 | - | require.Equal(t, [][]byte{[]byte("reserved")}, pty.writes()) | |
| 742 | - | }, | |
| 743 | - | }, | |
| 744 | - | { | |
| 745 | - | name: "mouse input", | |
| 746 | - | setup: func(_ *Daemon, sess *session, _ *attachedClient, _ *transactionalResizePTY) { | |
| 747 | - | p := sess.activeTab().focusedPane() | |
| 748 | - | p.mu.Lock() | |
| 749 | - | p.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |
| 750 | - | p.mu.Unlock() | |
| 751 | - | }, | |
| 752 | - | frame: frameInput([]byte("\x1b[<0;1;2M")), | |
| 753 | - | assert: func(t *testing.T, _ *attachedClient, pty *transactionalResizePTY) { | |
| 754 | - | require.NotEmpty(t, pty.writes()) | |
| 755 | - | }, | |
| 756 | - | }, | |
| 757 | - | { | |
| 758 | - | name: "key action", | |
| 759 | - | frame: frameInput([]byte("\x1b ")), | |
| 760 | - | assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) { | |
| 761 | - | require.True(t, ac.overlays.paletteActive()) | |
| 762 | - | }, | |
| 763 | - | }, | |
| 764 | - | { | |
| 765 | - | name: "overlay mutation", | |
| 766 | - | setup: func(d *Daemon, sess *session, ac *attachedClient, _ *transactionalResizePTY) { | |
| 767 | - | d.enterPrompt(sess, ac, "Rename", "", func(string) error { return nil }) | |
| 768 | - | }, | |
| 769 | - | frame: frameInput([]byte("x")), | |
| 770 | - | assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) { | |
| 771 | - | ac.overlays.promptMu.Lock() | |
| 772 | - | defer ac.overlays.promptMu.Unlock() | |
| 773 | - | require.Equal(t, "x", ac.overlays.prompt.Value()) | |
| 774 | - | }, | |
| 775 | - | }, | |
| 776 | - | { | |
| 777 | - | name: "theme", | |
| 778 | - | frame: ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(ports.Theme{ | |
| 779 | - | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 780 | - | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 781 | - | })}, | |
| 782 | - | assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) { | |
| 783 | - | require.True(t, ac.getClientTheme().HasFG) | |
| 784 | - | }, | |
| 785 | - | }, | |
| 786 | - | { | |
| 787 | - | name: "ACK", | |
| 788 | - | setup: func(_ *Daemon, _ *session, ac *attachedClient, _ *transactionalResizePTY) { | |
| 789 | - | ac.output.next = 3 | |
| 790 | - | }, | |
| 791 | - | frame: ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: 1})}, | |
| 792 | - | assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) { | |
| 793 | - | ac.sendMu.Lock() | |
| 794 | - | defer ac.sendMu.Unlock() | |
| 795 | - | require.Equal(t, uint64(1), ac.output.acked) | |
| 796 | - | }, | |
| 797 | - | }, | |
| 798 | - | } | |
| 799 | - | ||
| 800 | - | for _, tt := range tests { | |
| 801 | - | t.Run(tt.name, func(t *testing.T) { | |
| 802 | - | pty := &transactionalResizePTY{} | |
| 803 | - | d, sess, old, _ := newManualSessionWithPTYs(t, pty) | |
| 804 | - | oldTransport := newDatagramTestTransport() | |
| 805 | - | old.replaceTransport(oldTransport) | |
| 806 | - | old.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: old}, &d.bindings) | |
| 807 | - | rc := d.attachCoordinator(sess, nil, old, true) | |
| 808 | - | token := sess.attachmentToken(old, oldTransport) | |
| 809 | - | token.lease = rc.attachmentLease(old) | |
| 810 | - | if tt.setup != nil { | |
| 811 | - | tt.setup(d, sess, old, pty) | |
| 812 | - | } | |
| 813 | - | ||
| 814 | - | admitted := make(chan struct{}) | |
| 815 | - | release := make(chan struct{}) | |
| 816 | - | d.afterRoleEffectAdmitted = func(attachmentRoleToken) { | |
| 817 | - | close(admitted) | |
| 818 | - | <-release | |
| 819 | - | } | |
| 820 | - | effectDone := make(chan struct{}) | |
| 821 | - | go func() { | |
| 822 | - | d.handleActiveClientFrame(token, tt.frame) | |
| 823 | - | close(effectDone) | |
| 824 | - | }() | |
| 825 | - | <-admitted | |
| 826 | - | ||
| 827 | - | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 828 | - | next.initOverlays() | |
| 829 | - | transitionDone := make(chan error, 1) | |
| 830 | - | go func() { | |
| 831 | - | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 832 | - | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 833 | - | expectedTransport: next.transportSnapshot(), ready: true, | |
| 834 | - | }) | |
| 835 | - | transitionDone <- err | |
| 836 | - | }() | |
| 837 | - | select { | |
| 838 | - | case err := <-transitionDone: | |
| 839 | - | t.Fatalf("transition overtook admitted effect: %v", err) | |
| 840 | - | case <-time.After(20 * time.Millisecond): | |
| 841 | - | } | |
| 842 | - | close(release) | |
| 843 | - | <-effectDone | |
| 844 | - | require.NoError(t, <-transitionDone) | |
| 845 | - | tt.assert(t, old, pty) | |
| 846 | - | }) | |
| 847 | - | } | |
| 848 | - | } | |
| 849 | - | ||
| 850 | - | func TestJumpAttentionDoesNotMutateSourceAfterInitiatorReplacement(t *testing.T) { | |
| 851 | - | d, sess, old, _, releases := newManualTabSession(t, 2) | |
| 852 | - | defer releaseAll(releases) | |
| 853 | - | sess.mu.Lock() | |
| 854 | - | sess.tabs[1].attention = true | |
| 855 | - | sess.tabs[1].attentionAt = time.Unix(1, 0) | |
| 856 | - | sess.mu.Unlock() | |
| 857 | - | ||
| 858 | - | oldTransport := old.transport() | |
| 859 | - | rc := d.attachCoordinator(sess, nil, old, true) | |
| 860 | - | token := sess.attachmentToken(old, oldTransport) | |
| 861 | - | token.lease = rc.attachmentLease(old) | |
| 862 | - | old.publishRoleCapability(token) | |
| 863 | - | ||
| 864 | - | admissionEnded := make(chan struct{}) | |
| 865 | - | releaseAction := make(chan struct{}) | |
| 866 | - | release := releaseTestGate(t, releaseAction) | |
| 867 | - | var admissionEndedOnce sync.Once | |
| 868 | - | d.afterActionRoleEffectEnded = func(action string) { | |
| 869 | - | if action != "jump-attention" { | |
| 870 | - | return | |
| 871 | - | } | |
| 872 | - | admissionEndedOnce.Do(func() { close(admissionEnded) }) | |
| 873 | - | <-releaseAction | |
| 874 | - | } | |
| 875 | - | actionDone := make(chan struct{}) | |
| 876 | - | go func() { | |
| 877 | - | d.handleActiveClientFrame(token, frameInput([]byte("\x1ba"))) | |
| 878 | - | close(actionDone) | |
| 879 | - | }() | |
| 880 | - | <-admissionEnded | |
| 881 | - | ||
| 882 | - | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 883 | - | next.initOverlays() | |
| 884 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 885 | - | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 886 | - | expectedTransport: next.transportSnapshot(), ready: true, | |
| 887 | - | }) | |
| 888 | - | require.NoError(t, err) | |
| 889 | - | sess.mu.Lock() | |
| 890 | - | sess.active = 0 | |
| 891 | - | sess.mu.Unlock() | |
| 892 | - | release() | |
| 893 | - | <-actionDone | |
| 894 | - | ||
| 895 | - | require.Equal(t, 0, activeTabIndex(sess), "the replaced initiator mutated source focus after losing its role") | |
| 896 | - | d.deferAttachmentTransitionCleanups(result) | |
| 897 | - | } | |
| 898 | - | ||
| 899 | - | func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) { | |
| 900 | - | p1, release1 := newBlockingPTY(t) | |
| 901 | - | p2, release2 := newBlockingPTY(t) | |
| 902 | - | p3, release3 := newBlockingPTY(t) | |
| 903 | - | defer release1() | |
| 904 | - | defer release2() | |
| 905 | - | defer release3() | |
| 906 | - | d, source, ac, _ := newManualSessionWithPTYs(t, p1) | |
| 907 | - | target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{ | |
| 908 | - | newTab(p2, domain.Size{Cols: 80, Rows: 23}), | |
| 909 | - | newTab(p3, domain.Size{Cols: 80, Rows: 23}), | |
| 910 | - | }} | |
| 911 | - | target.tabs[1].attention = true | |
| 912 | - | target.tabs[1].attentionAt = time.Unix(1, 0) | |
| 913 | - | d.sessions[target.id] = target | |
| 914 | - | ||
| 915 | - | rc := d.attachCoordinator(source, nil, ac, true) | |
| 916 | - | token := source.attachmentToken(ac, ac.transport()) | |
| 917 | - | token.lease = rc.attachmentLease(ac) | |
| 918 | - | ac.publishRoleCapability(token) | |
| 919 | - | ||
| 920 | - | d.handleActiveClientFrame(token, frameInput([]byte("\x1ba"))) | |
| 921 | - | ||
| 922 | - | require.Same(t, target, ac.currentSession()) | |
| 923 | - | require.Equal(t, 1, activeTabIndex(target)) | |
| 924 | - | } | |
| 925 | - | ||
| 926 | - | func TestJumpAttentionHandoffRevalidatesInitiatorAfterAdmissionEnds(t *testing.T) { | |
| 927 | - | p1, release1 := newBlockingPTY(t) | |
| 928 | - | p2, release2 := newBlockingPTY(t) | |
| 929 | - | p3, release3 := newBlockingPTY(t) | |
| 930 | - | defer release1() | |
| 931 | - | defer release2() | |
| 932 | - | defer release3() | |
| 933 | - | d, source, old, _ := newManualSessionWithPTYs(t, p1) | |
| 934 | - | target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{ | |
| 935 | - | newTab(p2, domain.Size{Cols: 80, Rows: 23}), | |
| 936 | - | newTab(p3, domain.Size{Cols: 80, Rows: 23}), | |
| 937 | - | }} | |
| 938 | - | target.tabs[1].attention = true | |
| 939 | - | target.tabs[1].attentionAt = time.Unix(1, 0) | |
| 940 | - | d.sessions[target.id] = target | |
| 941 | - | ||
| 942 | - | oldTransport := old.transport() | |
| 943 | - | rc := d.attachCoordinator(source, nil, old, true) | |
| 944 | - | token := source.attachmentToken(old, oldTransport) | |
| 945 | - | token.lease = rc.attachmentLease(old) | |
| 946 | - | old.publishRoleCapability(token) | |
| 947 | - | ||
| 948 | - | admissionEnded := make(chan struct{}) | |
| 949 | - | releaseAction := make(chan struct{}) | |
| 950 | - | release := releaseTestGate(t, releaseAction) | |
| 951 | - | var admissionEndedOnce sync.Once | |
| 952 | - | d.afterActionRoleEffectEnded = func(action string) { | |
| 953 | - | if action == "jump-attention" { | |
| 954 | - | admissionEndedOnce.Do(func() { close(admissionEnded) }) | |
| 955 | - | <-releaseAction | |
| 956 | - | } | |
| 957 | - | } | |
| 958 | - | actionDone := make(chan struct{}) | |
| 959 | - | go func() { | |
| 960 | - | d.handleActiveClientFrame(token, frameInput([]byte("\x1ba"))) | |
| 961 | - | close(actionDone) | |
| 962 | - | }() | |
| 963 | - | <-admissionEnded | |
| 964 | - | ||
| 965 | - | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 966 | - | next.initOverlays() | |
| 967 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 968 | - | target: source, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 969 | - | expectedTransport: next.transportSnapshot(), ready: true, | |
| 970 | - | }) | |
| 971 | - | require.NoError(t, err) | |
| 972 | - | release() | |
| 973 | - | <-actionDone | |
| 974 | - | ||
| 975 | - | require.Equal(t, 0, activeTabIndex(target), "stale handoff changed target focus") | |
| 976 | - | target.mu.Lock() | |
| 977 | - | targetClient := target.client | |
| 978 | - | target.mu.Unlock() | |
| 979 | - | require.Nil(t, targetClient, "stale handoff attached the replaced initiator") | |
| 980 | - | d.deferAttachmentTransitionCleanups(result) | |
| 981 | - | } | |
| 982 | - | ||
| 983 | - | func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T) { | |
| 984 | - | p, releasePTY := newBlockingPTY(t) | |
| 985 | - | defer releasePTY() | |
| 986 | - | d, sess, old, _ := newManualSessionWithPTYs(t, p) | |
| 987 | - | d.enterPicker(sess, old) | |
| 988 | - | ||
| 989 | - | oldTransport := old.transport() | |
| 990 | - | rc := d.attachCoordinator(sess, nil, old, true) | |
| 991 | - | token := sess.attachmentToken(old, oldTransport) | |
| 992 | - | token.lease = rc.attachmentLease(old) | |
| 993 | - | old.publishRoleCapability(token) | |
| 994 | - | ||
| 995 | - | admissionEnded := make(chan struct{}) | |
| 996 | - | releaseAction := make(chan struct{}) | |
| 997 | - | release := releaseTestGate(t, releaseAction) | |
| 998 | - | var admissionEndedOnce sync.Once | |
| 999 | - | d.afterActionRoleEffectEnded = func(action string) { | |
| 1000 | - | if action != "picker-delete" { | |
| 1001 | - | return | |
| 1002 | - | } | |
| 1003 | - | admissionEndedOnce.Do(func() { close(admissionEnded) }) | |
| 1004 | - | <-releaseAction | |
| 1005 | - | } | |
| 1006 | - | actionDone := make(chan struct{}) | |
| 1007 | - | go func() { | |
| 1008 | - | d.handleActiveClientFrame(token, frameInput([]byte("x"))) | |
| 1009 | - | close(actionDone) | |
| 1010 | - | }() | |
| 1011 | - | <-admissionEnded | |
| 1012 | - | ||
| 1013 | - | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 1014 | - | next.initOverlays() | |
| 1015 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1016 | - | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 1017 | - | expectedTransport: next.transportSnapshot(), ready: true, | |
| 1018 | - | }) | |
| 1019 | - | require.NoError(t, err) | |
| 1020 | - | release() | |
| 1021 | - | <-actionDone | |
| 1022 | - | ||
| 1023 | - | d.mu.Lock() | |
| 1024 | - | registered := d.sessions[sess.id] | |
| 1025 | - | d.mu.Unlock() | |
| 1026 | - | require.Same(t, sess, registered, "the replaced initiator deleted its former source session") | |
| 1027 | - | d.deferAttachmentTransitionCleanups(result) | |
| 1028 | - | } | |
| 1029 | - | ||
| 1030 | - | func TestPickerDeleteOtherSessionRevalidatesInitiatorAfterAdmissionEnds(t *testing.T) { | |
| 1031 | - | p1, release1 := newBlockingPTY(t) | |
| 1032 | - | p2, release2 := newBlockingPTY(t) | |
| 1033 | - | defer release1() | |
| 1034 | - | defer release2() | |
| 1035 | - | d, source, old, _ := newManualSessionWithPTYs(t, p1) | |
| 1036 | - | source.name = "source" | |
| 1037 | - | target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}} | |
| 1038 | - | d.sessions[target.id] = target | |
| 1039 | - | d.enterPicker(source, old) | |
| 1040 | - | old.overlays.pickerMu.Lock() | |
| 1041 | - | for { | |
| 1042 | - | selected, ok := old.overlays.picker.Selected() | |
| 1043 | - | if ok && selected.Session == target.id { | |
| 1044 | - | break | |
| 1045 | - | } | |
| 1046 | - | old.overlays.picker.Down() | |
| 1047 | - | } | |
| 1048 | - | old.overlays.pickerMu.Unlock() | |
| 1049 | - | ||
| 1050 | - | oldTransport := old.transport() | |
| 1051 | - | rc := d.attachCoordinator(source, nil, old, true) | |
| 1052 | - | token := source.attachmentToken(old, oldTransport) | |
| 1053 | - | token.lease = rc.attachmentLease(old) | |
| 1054 | - | old.publishRoleCapability(token) | |
| 1055 | - | ||
| 1056 | - | admissionEnded := make(chan struct{}) | |
| 1057 | - | releaseAction := make(chan struct{}) | |
| 1058 | - | release := releaseTestGate(t, releaseAction) | |
| 1059 | - | var admissionEndedOnce sync.Once | |
| 1060 | - | d.afterActionRoleEffectEnded = func(action string) { | |
| 1061 | - | if action == "picker-delete" { | |
| 1062 | - | admissionEndedOnce.Do(func() { close(admissionEnded) }) | |
| 1063 | - | <-releaseAction | |
| 1064 | - | } | |
| 1065 | - | } | |
| 1066 | - | actionDone := make(chan struct{}) | |
| 1067 | - | go func() { | |
| 1068 | - | d.handleActiveClientFrame(token, frameInput([]byte("x"))) | |
| 1069 | - | close(actionDone) | |
| 1070 | - | }() | |
| 1071 | - | <-admissionEnded | |
| 1072 | - | ||
| 1073 | - | d.mu.Lock() | |
| 1074 | - | _, targetStillRegistered := d.sessions[target.id] | |
| 1075 | - | d.mu.Unlock() | |
| 1076 | - | require.True(t, targetStillRegistered, "picker deletion mutated the target before source-token preflight") | |
| 1077 | - | ||
| 1078 | - | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 1079 | - | next.initOverlays() | |
| 1080 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1081 | - | target: source, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 1082 | - | expectedTransport: next.transportSnapshot(), ready: true, | |
| 1083 | - | }) | |
| 1084 | - | require.NoError(t, err) | |
| 1085 | - | release() | |
| 1086 | - | <-actionDone | |
| 1087 | - | d.mu.Lock() | |
| 1088 | - | _, targetStillRegistered = d.sessions[target.id] | |
| 1089 | - | d.mu.Unlock() | |
| 1090 | - | require.True(t, targetStillRegistered, "stale picker deletion mutated the target session") | |
| 1091 | - | d.deferAttachmentTransitionCleanups(result) | |
| 1092 | - | } | |
| 1093 | - | ||
| 1094 | - | func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) { | |
| 1095 | - | p, release := newBlockingPTY(t) | |
| 1096 | - | defer release() | |
| 1097 | - | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 1098 | - | d.enterPicker(sess, ac) | |
| 1099 | - | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 1100 | - | token := sess.attachmentToken(ac, ac.transport()) | |
| 1101 | - | token.lease = rc.attachmentLease(ac) | |
| 1102 | - | ac.publishRoleCapability(token) | |
| 1103 | - | ||
| 1104 | - | d.handleActiveClientFrame(token, frameInput([]byte("x"))) | |
| 1105 | - | ||
| 1106 | - | d.mu.Lock() | |
| 1107 | - | _, registered := d.sessions[sess.id] | |
| 1108 | - | d.mu.Unlock() | |
| 1109 | - | require.False(t, registered) | |
| 1110 | - | } | |
| 1111 | - | ||
| 1112 | - | func TestDelayedKeyCallbackAdmittedBeforeReplacementCompletesFirst(t *testing.T) { | |
| 1113 | - | writes := make(chan []byte, 1) | |
| 1114 | - | pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }} | |
| 1115 | - | d, sess, old, _ := newManualSessionWithPTYs(t, pty) | |
| 1116 | - | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 1117 | - | d.clock = clock | |
| 1118 | - | oldTransport := newDatagramTestTransport() | |
| 1119 | - | old.replaceTransport(oldTransport) | |
| 1120 | - | old.keys = keys.NewRouter(clock, daemonKeyHandler{d: d, ac: old}, &d.bindings) | |
| 1121 | - | rc := d.attachCoordinator(sess, nil, old, true) | |
| 1122 | - | token := sess.attachmentToken(old, oldTransport) | |
| 1123 | - | token.lease = rc.attachmentLease(old) | |
| 1124 | - | old.publishRoleCapability(token) | |
| 1125 | - | ||
| 1126 | - | d.handleActiveClientFrame(token, frameInput([]byte{keys.ESC})) | |
| 1127 | - | timer := <-clock.timers | |
| 1128 | - | admitted := make(chan struct{}) | |
| 1129 | - | release := make(chan struct{}) | |
| 1130 | - | d.afterRoleEffectAdmitted = func(attachmentRoleToken) { | |
| 1131 | - | close(admitted) | |
| 1132 | - | <-release | |
| 1133 | - | } | |
| 1134 | - | timer.ch <- time.Time{} | |
| 1135 | - | select { | |
| 1136 | - | case <-admitted: | |
| 1137 | - | case <-time.After(time.Second): | |
| 1138 | - | t.Fatal("delayed key callback did not acquire a fresh role-effect ticket") | |
| 1139 | - | } | |
| 1140 | - | ||
| 1141 | - | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 1142 | - | next.initOverlays() | |
| 1143 | - | transitionDone := make(chan error, 1) | |
| 1144 | - | go func() { | |
| 1145 | - | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1146 | - | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 1147 | - | expectedTransport: next.transportSnapshot(), ready: true, | |
| 1148 | - | }) | |
| 1149 | - | transitionDone <- err | |
| 1150 | - | }() | |
| 1151 | - | select { | |
| 1152 | - | case err := <-transitionDone: | |
| 1153 | - | t.Fatalf("replacement overtook admitted delayed key callback: %v", err) | |
| 1154 | - | case <-time.After(20 * time.Millisecond): | |
| 1155 | - | } | |
| 1156 | - | close(release) | |
| 1157 | - | require.Equal(t, []byte{keys.ESC}, <-writes) | |
| 1158 | - | require.NoError(t, <-transitionDone) | |
| 1159 | - | } | |
| 1160 | - | ||
| 1161 | - | func TestDelayedKeyCallbackAfterReplacementDropsStaleWork(t *testing.T) { | |
| 1162 | - | writes := make(chan []byte, 1) | |
| 1163 | - | pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }} | |
| 1164 | - | d, sess, old, _ := newManualSessionWithPTYs(t, pty) | |
| 1165 | - | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 1166 | - | d.clock = clock | |
| 1167 | - | oldTransport := newDatagramTestTransport() | |
| 1168 | - | old.replaceTransport(oldTransport) | |
| 1169 | - | old.keys = keys.NewRouter(clock, daemonKeyHandler{d: d, ac: old}, &d.bindings) | |
| 1170 | - | rc := d.attachCoordinator(sess, nil, old, true) | |
| 1171 | - | token := sess.attachmentToken(old, oldTransport) | |
| 1172 | - | token.lease = rc.attachmentLease(old) | |
| 1173 | - | old.publishRoleCapability(token) | |
| 1174 | - | ||
| 1175 | - | d.handleActiveClientFrame(token, frameInput([]byte{keys.ESC})) | |
| 1176 | - | timer := <-clock.timers | |
| 1177 | - | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 1178 | - | next.initOverlays() | |
| 1179 | - | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1180 | - | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 1181 | - | expectedTransport: next.transportSnapshot(), ready: true, | |
| 1182 | - | }) | |
| 1183 | - | require.NoError(t, err) | |
| 1184 | - | ||
| 1185 | - | attempted := make(chan bool, 1) | |
| 1186 | - | d.afterDelayedKeyEffectAttempt = func(admitted bool) { attempted <- admitted } | |
| 1187 | - | timer.ch <- time.Time{} | |
| 1188 | - | require.False(t, <-attempted, "stale delayed callback acquired authority after replacement") | |
| 1189 | - | select { | |
| 1190 | - | case got := <-writes: | |
| 1191 | - | t.Fatalf("stale delayed key callback reached the PTY: %q", got) | |
| 1192 | - | default: | |
| 1193 | - | } | |
| 1194 | - | } | |
| 1195 | - | ||
| 1196 | - | func TestRoleEffectGateAdmittedSnatchedPanelFinishesBeforePromotion(t *testing.T) { | |
| 1197 | - | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 1198 | - | oldTransport := newDatagramTestTransport() | |
| 1199 | - | old.replaceTransport(oldTransport) | |
| 1200 | - | d.attachCoordinator(sess, nil, old, true) | |
| 1201 | - | ||
| 1202 | - | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 1203 | - | next.initOverlays() | |
| 1204 | - | replaced, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1205 | - | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 1206 | - | expectedTransport: next.transportSnapshot(), ready: true, | |
| 1207 | - | }) | |
| 1208 | - | require.NoError(t, err) | |
| 1209 | - | token := replaced.displaced | |
| 1210 | - | ||
| 1211 | - | admitted := make(chan struct{}) | |
| 1212 | - | releaseGate := make(chan struct{}) | |
| 1213 | - | release := releaseTestGate(t, releaseGate) | |
| 1214 | - | afterFrozen := make(chan struct{}) | |
| 1215 | - | d.afterRoleEffectAdmitted = func(attachmentRoleToken) { | |
| 1216 | - | close(admitted) | |
| 1217 | - | <-releaseGate | |
| 1218 | - | } | |
| 1219 | - | d.afterRoleEffectGateFrozen = func(action string, ac *attachedClient) { | |
| 1220 | - | if action == "" && ac == old { | |
| 1221 | - | close(afterFrozen) | |
| 1222 | - | } | |
| 1223 | - | } | |
| 1224 | - | effectDone := make(chan struct{}) | |
| 1225 | - | wantSize := domain.Size{Cols: 100, Rows: 30} | |
| 1226 | - | go func() { | |
| 1227 | - | d.handleSnatchedClientFrame(token, ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: wantSize})}) | |
| 1228 | - | close(effectDone) | |
| 1229 | - | }() | |
| 1230 | - | <-admitted | |
| 1231 | - | ||
| 1232 | - | transitionDone := make(chan error, 1) | |
| 1233 | - | go func() { | |
| 1234 | - | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1235 | - | target: sess, next: old, expectedRole: attachmentSnatched, targetRole: attachmentActive, | |
| 1236 | - | expectedTransport: old.transportSnapshot(), ready: true, | |
| 1237 | - | }) | |
| 1238 | - | transitionDone <- err | |
| 1239 | - | }() | |
| 1240 | - | <-afterFrozen | |
| 1241 | - | select { | |
| 1242 | - | case err := <-transitionDone: | |
| 1243 | - | t.Fatalf("promotion overtook admitted snatched panel: %v", err) | |
| 1244 | - | default: | |
| 1245 | - | } | |
| 1246 | - | release() | |
| 1247 | - | <-effectDone | |
| 1248 | - | require.NoError(t, <-transitionDone) | |
| 1249 | - | old.sendMu.Lock() | |
| 1250 | - | gotSize := old.size | |
| 1251 | - | old.sendMu.Unlock() | |
| 1252 | - | require.Equal(t, wantSize, gotSize) | |
| 1253 | - | } | |
| 1254 | - | ||
| 1255 | - | func TestRoleEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *testing.T) { | |
| 1256 | - | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 1257 | - | oldTransport := newDatagramTestTransport() | |
| 1258 | - | old.replaceTransport(oldTransport) | |
| 1259 | - | rc := d.attachCoordinator(sess, nil, old, true) | |
| 1260 | - | token := sess.attachmentToken(old, oldTransport) | |
| 1261 | - | token.lease = rc.attachmentLease(old) | |
| 1262 | - | ||
| 1263 | - | admitted := make(chan struct{}) | |
| 1264 | - | release := make(chan struct{}) | |
| 1265 | - | d.afterRoleEffectAdmitted = func(attachmentRoleToken) { | |
| 1266 | - | close(admitted) | |
| 1267 | - | <-release | |
| 1268 | - | } | |
| 1269 | - | paintDone := make(chan bool, 1) | |
| 1270 | - | go func() { paintDone <- d.firstPaintForTransition(token) }() | |
| 1271 | - | <-admitted | |
| 1272 | - | ||
| 1273 | - | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 1274 | - | next.initOverlays() | |
| 1275 | - | transitionDone := make(chan error, 1) | |
| 1276 | - | go func() { | |
| 1277 | - | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1278 | - | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 1279 | - | expectedTransport: next.transportSnapshot(), ready: true, | |
| 1280 | - | }) | |
| 1281 | - | transitionDone <- err | |
| 1282 | - | }() | |
| 1283 | - | select { | |
| 1284 | - | case err := <-transitionDone: | |
| 1285 | - | t.Fatalf("replacement overtook admitted first paint: %v", err) | |
| 1286 | - | case <-time.After(20 * time.Millisecond): | |
| 1287 | - | } | |
| 1288 | - | close(release) | |
| 1289 | - | require.True(t, <-paintDone) | |
| 1290 | - | require.NoError(t, <-transitionDone) | |
| 1291 | - | } | |
| 1292 | - | ||
| 1293 | - | func TestRoleEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testing.T) { | |
| 1294 | - | d, first, a, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 1295 | - | aTransport := newDatagramTestTransport() | |
| 1296 | - | a.replaceTransport(aTransport) | |
| 1297 | - | d.attachCoordinator(first, nil, a, true) | |
| 1298 | - | first.attachmentToken(a, aTransport) | |
| 1299 | - | ||
| 1300 | - | bTransport := newDatagramTestTransport() | |
| 1301 | - | b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size} | |
| 1302 | - | b.initOverlays() | |
| 1303 | - | second := &session{sessionCore: sessionCore{id: "second", name: "second", client: b, snatched: make(map[*attachedClient]struct{})}} | |
| 1304 | - | b.setSession(second) | |
| 1305 | - | d.mu.Lock() | |
| 1306 | - | d.sessions[second.id] = second | |
| 1307 | - | d.mu.Unlock() | |
| 1308 | - | d.attachCoordinator(second, nil, b, true) | |
| 1309 | - | second.attachmentToken(b, bTransport) | |
| 1310 | - | ||
| 1311 | - | start := make(chan struct{}) | |
| 1312 | - | results := make(chan error, 2) | |
| 1313 | - | go func() { | |
| 1314 | - | <-start | |
| 1315 | - | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1316 | - | source: first, target: second, next: a, | |
| 1317 | - | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 1318 | - | expectedTransport: a.transportSnapshot(), ready: true, | |
| 1319 | - | }) | |
| 1320 | - | results <- err | |
| 1321 | - | }() | |
| 1322 | - | go func() { | |
| 1323 | - | <-start | |
| 1324 | - | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1325 | - | source: second, target: first, next: b, | |
| 1326 | - | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 1327 | - | expectedTransport: b.transportSnapshot(), ready: true, | |
| 1328 | - | }) | |
| 1329 | - | results <- err | |
| 1330 | - | }() | |
| 1331 | - | close(start) | |
| 1332 | - | ||
| 1333 | - | errs := make([]error, 0, 2) | |
| 1334 | - | for range 2 { | |
| 1335 | - | select { | |
| 1336 | - | case err := <-results: | |
| 1337 | - | errs = append(errs, err) | |
| 1338 | - | case <-time.After(time.Second): | |
| 1339 | - | t.Fatal("reversed transitions deadlocked") | |
| 1340 | - | } | |
| 1341 | - | } | |
| 1342 | - | successes := 0 | |
| 1343 | - | for _, err := range errs { | |
| 1344 | - | if err == nil { | |
| 1345 | - | successes++ | |
| 1346 | - | } | |
| 1347 | - | } | |
| 1348 | - | require.Equal(t, 1, successes) | |
| 1349 | - | } | |
| 1350 | - | ||
| 1351 | - | func TestPickerDeleteReversedWithTargetTransitionDoesNotDeadlock(t *testing.T) { | |
| 1352 | - | d, source, a, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 1353 | - | aTransport := newDatagramTestTransport() | |
| 1354 | - | a.replaceTransport(aTransport) | |
| 1355 | - | aCoordinator := d.attachCoordinator(source, nil, a, true) | |
| 1356 | - | ||
| 1357 | - | bTransport := newDatagramTestTransport() | |
| 1358 | - | b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size} | |
| 1359 | - | b.initOverlays() | |
| 1360 | - | target := &session{sessionCore: sessionCore{id: "picker-delete-target", name: "picker-delete-target", | |
| 1361 | - | client: b, snatched: make(map[*attachedClient]struct{})}, ctx: source.ctx, cancel: func() {}, | |
| 1362 | - | } | |
| 1363 | - | b.setSession(target) | |
| 1364 | - | d.mu.Lock() | |
| 1365 | - | d.sessions[target.id] = target | |
| 1366 | - | d.mu.Unlock() | |
| 1367 | - | d.attachCoordinator(target, nil, b, true) | |
| 1368 | - | ||
| 1369 | - | // Give B the earlier immutable identity. The reverse transition therefore | |
| 1370 | - | // freezes B then A, while the old picker-delete path froze A separately | |
| 1371 | - | // before discovering and freezing B. | |
| 1372 | - | b.roleEffects.immutableOrder() | |
| 1373 | - | a.roleEffects.immutableOrder() | |
| 1374 | - | require.Less(t, b.roleEffects.order.Load(), a.roleEffects.order.Load()) | |
| 1375 | - | ||
| 1376 | - | token := source.attachmentToken(a, aTransport) | |
| 1377 | - | token.lease = aCoordinator.attachmentLease(a) | |
| 1378 | - | a.publishRoleCapability(token) | |
| 1379 | - | effect, admitted := a.beginRoleEffect(token) | |
| 1380 | - | require.True(t, admitted) | |
| 1381 | - | token = effect.roleToken() | |
| 1382 | - | ||
| 1383 | - | deleteDiscovered := make(chan []*attachedClient, 1) | |
| 1384 | - | transitionDiscovered := make(chan []*attachedClient, 1) | |
| 1385 | - | releaseDelete := make(chan struct{}) | |
| 1386 | - | releaseTransition := make(chan struct{}) | |
| 1387 | - | transitionBFrozen := make(chan struct{}) | |
| 1388 | - | deleteFrozen := make(chan *attachedClient, 2) | |
| 1389 | - | d.afterRoleEffectParticipantsSnapshotted = func(action string, participants []*attachedClient) { | |
| 1390 | - | snapshot := append([]*attachedClient(nil), participants...) | |
| 1391 | - | switch action { | |
| 1392 | - | case "picker-delete": | |
| 1393 | - | deleteDiscovered <- snapshot | |
| 1394 | - | <-releaseDelete | |
| 1395 | - | case "reverse-delete-transition": | |
| 1396 | - | transitionDiscovered <- snapshot | |
| 1397 | - | <-releaseTransition | |
| 1398 | - | } | |
| 1399 | - | } | |
| 1400 | - | d.afterRoleEffectGateFrozen = func(action string, ac *attachedClient) { | |
| 1401 | - | switch action { | |
| 1402 | - | case "reverse-delete-transition": | |
| 1403 | - | if ac == b { | |
| 1404 | - | close(transitionBFrozen) | |
| 1405 | - | } | |
| 1406 | - | case "picker-delete": | |
| 1407 | - | deleteFrozen <- ac | |
| 1408 | - | } | |
| 1409 | - | } | |
| 1410 | - | ||
| 1411 | - | deleteDone := make(chan error, 1) | |
| 1412 | - | go func() { | |
| 1413 | - | deleteDone <- d.killPickerTargetForRole(picker.Target{Session: target.id}, token) | |
| 1414 | - | }() | |
| 1415 | - | deleteParticipants := <-deleteDiscovered | |
| 1416 | - | require.ElementsMatch(t, []*attachedClient{a, b}, deleteParticipants) | |
| 1417 | - | ||
| 1418 | - | transitionDone := make(chan error, 1) | |
| 1419 | - | go func() { | |
| 1420 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1421 | - | source: target, target: source, next: b, | |
| 1422 | - | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 1423 | - | expectedTransport: b.transportSnapshot(), action: "reverse-delete-transition", ready: true, | |
| 1424 | - | }) | |
| 1425 | - | if err == nil { | |
| 1426 | - | d.deferAttachmentTransitionCleanups(result) | |
| 1427 | - | } | |
| 1428 | - | transitionDone <- err | |
| 1429 | - | }() | |
| 1430 | - | transitionParticipants := <-transitionDiscovered | |
| 1431 | - | require.ElementsMatch(t, []*attachedClient{a, b}, transitionParticipants) | |
| 1432 | - | ||
| 1433 | - | close(releaseTransition) | |
| 1434 | - | select { | |
| 1435 | - | case <-transitionBFrozen: | |
| 1436 | - | case <-time.After(time.Second): | |
| 1437 | - | t.Fatal("reverse transition did not freeze B first") | |
| 1438 | - | } | |
| 1439 | - | close(releaseDelete) | |
| 1440 | - | ||
| 1441 | - | select { | |
| 1442 | - | case err := <-transitionDone: | |
| 1443 | - | require.NoError(t, err) | |
| 1444 | - | case <-time.After(time.Second): | |
| 1445 | - | t.Fatal("reverse transition deadlocked with picker deletion") | |
| 1446 | - | } | |
| 1447 | - | select { | |
| 1448 | - | case err := <-deleteDone: | |
| 1449 | - | require.NoError(t, err) | |
| 1450 | - | case <-time.After(time.Second): | |
| 1451 | - | t.Fatal("picker deletion deadlocked with reverse transition") | |
| 1452 | - | } | |
| 1453 | - | frozenByDelete := make([]*attachedClient, 0, 2) | |
| 1454 | - | for range 2 { | |
| 1455 | - | select { | |
| 1456 | - | case ac := <-deleteFrozen: | |
| 1457 | - | frozenByDelete = append(frozenByDelete, ac) | |
| 1458 | - | case <-time.After(time.Second): | |
| 1459 | - | t.Fatal("picker deletion did not freeze every participant") | |
| 1460 | - | } | |
| 1461 | - | } | |
| 1462 | - | require.ElementsMatch(t, []*attachedClient{a, b}, frozenByDelete, "picker deletion must freeze each deduplicated participant exactly once") | |
| 1463 | - | require.Empty(t, deleteFrozen) | |
| 1464 | - | ||
| 1465 | - | d.mu.Lock() | |
| 1466 | - | registered := d.sessions[target.id] | |
| 1467 | - | d.mu.Unlock() | |
| 1468 | - | require.Same(t, target, registered, "stale picker deletion mutated its target lifecycle") | |
| 1469 | - | require.Equal(t, attachmentSnatched, source.attachmentRole(a)) | |
| 1470 | - | require.Equal(t, attachmentActive, source.attachmentRole(b)) | |
| 1471 | - | } | |
| 1472 | - | ||
| 1473 | - | func TestRoleEffectGateFrozenTransitionRejectsLateEffect(t *testing.T) { | |
| 1474 | - | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 1475 | - | oldTransport := newDatagramTestTransport() | |
| 1476 | - | old.replaceTransport(oldTransport) | |
| 1477 | - | rc := d.attachCoordinator(sess, nil, old, true) | |
| 1478 | - | token := sess.attachmentToken(old, oldTransport) | |
| 1479 | - | token.lease = rc.attachmentLease(old) | |
| 1480 | - | old.publishRoleCapability(token) | |
| 1481 | - | ||
| 1482 | - | frozen := make(chan struct{}) | |
| 1483 | - | release := make(chan struct{}) | |
| 1484 | - | d.afterRoleEffectsFrozen = func() { | |
| 1485 | - | close(frozen) | |
| 1486 | - | <-release | |
| 1487 | - | } | |
| 1488 | - | ||
| 1489 | - | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 1490 | - | next.initOverlays() | |
| 1491 | - | done := make(chan error, 1) | |
| 1492 | - | go func() { | |
| 1493 | - | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1494 | - | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 1495 | - | expectedTransport: next.transportSnapshot(), ready: true, | |
| 1496 | - | }) | |
| 1497 | - | done <- err | |
| 1498 | - | }() | |
| 1499 | - | <-frozen | |
| 1500 | - | ||
| 1501 | - | _, ok := old.beginRoleEffect(token) | |
| 1502 | - | require.False(t, ok, "a frozen capability must admit no new effect") | |
| 1503 | - | close(release) | |
| 1504 | - | require.NoError(t, <-done) | |
| 1505 | - | } | |
M internal/usecase/daemon/screen_output_stream.go +1 -0
| @@ -184,6 +184,7 @@ func (p *preparedStructuredOutput) send(sender func(ports.Frame) error) error { | |||
| 184 | 184 | } | |
| 185 | 185 | p.commit() | |
| 186 | 186 | p.stream.state.next = p.next | |
| 187 | + | p.stream.state.publishOutstanding() | |
| 187 | 188 | return nil | |
| 188 | 189 | } | |
| 189 | 190 | ||
M internal/usecase/daemon/session.go +171 -166
| @@ -51,14 +51,13 @@ type session struct { | |||
| 51 | 51 | teardownWaiters uint | |
| 52 | 52 | teardownChanged chan struct{} | |
| 53 | 53 | lifecycleStopOnce sync.Once | |
| 54 | - | // sessionCore.mu guards tabs, active, attachment membership, restoreDone, | |
| 55 | - | // clipFiles, and clipboard queue state. | |
| 54 | + | // sessionCore.mu guards tabs, attachment membership, restoreDone, clipFiles, | |
| 55 | + | // and clipboard queue state. | |
| 56 | 56 | // restoreDone remains attached to a restored session after it is published in | |
| 57 | 57 | // the live registry, so racing attaches cannot bypass restoration completion. | |
| 58 | 58 | restoreDone chan struct{} | |
| 59 | 59 | themeMu sync.Mutex | |
| 60 | 60 | tabs []*tab | |
| 61 | - | active int | |
| 62 | 61 | clipboardQueue []clipboardForward | |
| 63 | 62 | clipboardWorkerRunning bool | |
| 64 | 63 | cwd string | |
| @@ -242,6 +241,9 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st | |||
| 242 | 241 | } | |
| 243 | 242 | } | |
| 244 | 243 | ||
| 244 | + | if d.ptys == nil { | |
| 245 | + | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("daemon: PTY factory is unavailable")) | |
| 246 | + | } | |
| 245 | 247 | tbSize := contentSize(sz, proxied) | |
| 246 | 248 | var names []string | |
| 247 | 249 | if len(restoredTabNames) > 0 { | |
| @@ -416,27 +418,26 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||
| 416 | 418 | cwd := from.cwd | |
| 417 | 419 | term := from.terminal | |
| 418 | 420 | env := copyEnvironment(from.env) | |
| 419 | - | if from.client != ac { | |
| 420 | - | from.mu.Unlock() | |
| 421 | + | _, attached := from.attachments[ac] | |
| 422 | + | from.mu.Unlock() | |
| 423 | + | if !attached { | |
| 421 | 424 | d.mu.Unlock() | |
| 422 | 425 | return errors.New("client detached") | |
| 423 | 426 | } | |
| 424 | - | from.mu.Unlock() | |
| 425 | 427 | ||
| 426 | 428 | newSess, err := d.createSessionLockedWithMode(name, false, cwd, sz, term, env, ac.proxied) | |
| 427 | 429 | d.mu.Unlock() | |
| 428 | 430 | if err != nil { | |
| 429 | 431 | return err | |
| 430 | 432 | } | |
| 431 | - | // The newly created target is registered before the role transition, but | |
| 433 | + | // The newly created target is registered before the attachment transition, but | |
| 432 | 434 | // remains headless. Freeze/drain must occur with no daemon/routing/session | |
| 433 | 435 | // lock held; the transition revalidates both lifecycles before publication. | |
| 434 | 436 | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 435 | - | source: from, | |
| 436 | - | target: newSess, | |
| 437 | - | next: ac, | |
| 438 | - | expectedRole: attachmentActive, | |
| 439 | - | targetRole: attachmentActive, | |
| 437 | + | source: from, | |
| 438 | + | target: newSess, | |
| 439 | + | next: ac, | |
| 440 | + | ||
| 440 | 441 | expectedTransport: ac.transportSnapshot(), | |
| 441 | 442 | ready: true, | |
| 442 | 443 | }) | |
| @@ -453,16 +454,16 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||
| 453 | 454 | return nil | |
| 454 | 455 | } | |
| 455 | 456 | ||
| 456 | - | // createSessionAndSwitchForRole creates no target until the initiating role | |
| 457 | + | // createSessionAndSwitchForAttachment creates no target until the initiating role | |
| 457 | 458 | // ticket has ended and the exact source capability is frozen and revalidated. | |
| 458 | - | func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name string) error { | |
| 459 | + | func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionToken, name string) error { | |
| 459 | 460 | if name == "" { | |
| 460 | 461 | return errSessionNameRequired | |
| 461 | 462 | } | |
| 462 | 463 | if err := domain.ValidateSessionName(name); err != nil { | |
| 463 | 464 | return err | |
| 464 | 465 | } | |
| 465 | - | if token.sess == nil || token.ac == nil || token.effect == nil || token.role != attachmentActive { | |
| 466 | + | if token.sess == nil || token.ac == nil || token.effect == nil { | |
| 466 | 467 | return errAttachmentTransition | |
| 467 | 468 | } | |
| 468 | 469 | token.effect.bindActionEnd(d, "create-session") | |
| @@ -471,7 +472,7 @@ func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name s | |||
| 471 | 472 | var created *session | |
| 472 | 473 | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 473 | 474 | source: token.sess, next: token.ac, | |
| 474 | - | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 475 | + | ||
| 475 | 476 | expectedTransport: token.transport, sourceToken: &token, action: "create-session", | |
| 476 | 477 | copySourceEnvironment: true, ready: true, | |
| 477 | 478 | createTargetLocked: func() (*session, error) { | |
| @@ -511,14 +512,26 @@ func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name s | |||
| 511 | 512 | } | |
| 512 | 513 | ||
| 513 | 514 | func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |
| 515 | + | return d.createTabForAttachment(sess, nil, sz) | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, sz domain.Size) error { | |
| 514 | 519 | sess.mu.Lock() | |
| 515 | 520 | name := sess.name | |
| 516 | 521 | cwd := sess.cwd | |
| 517 | - | client := sess.client | |
| 518 | 522 | term := sess.terminal | |
| 519 | 523 | env := copyEnvironment(sess.env) | |
| 524 | + | attachments := sess.snapshotAttachmentsLocked() | |
| 520 | 525 | sess.mu.Unlock() | |
| 521 | - | tbSize := contentSize(sz, client != nil && client.proxied) | |
| 526 | + | // A client-requested tab must use that client's negotiated viewport and | |
| 527 | + | // theme. Only the explicitly headless helper path falls back to the stable | |
| 528 | + | // first attachment snapshot. | |
| 529 | + | themeClient := ac | |
| 530 | + | if themeClient == nil && len(attachments) != 0 { | |
| 531 | + | themeClient = attachments[0] | |
| 532 | + | } | |
| 533 | + | proxied := themeClient != nil && themeClient.proxied | |
| 534 | + | tbSize := contentSize(sz, proxied) | |
| 522 | 535 | tabStableID, paneStableID, err := d.newTabPaneStableIDs() | |
| 523 | 536 | if err != nil { | |
| 524 | 537 | return err | |
| @@ -535,8 +548,8 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||
| 535 | 548 | return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err) | |
| 536 | 549 | } | |
| 537 | 550 | tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize) | |
| 538 | - | if client != nil { | |
| 539 | - | t := d.effectiveTheme(client.getClientTheme()) | |
| 551 | + | if themeClient != nil { | |
| 552 | + | t := d.effectiveTheme(themeClient.getClientTheme()) | |
| 540 | 553 | tb.mu.Lock() | |
| 541 | 554 | p := tb.focusedPane() | |
| 542 | 555 | tb.mu.Unlock() | |
| @@ -566,11 +579,13 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||
| 566 | 579 | // Publish ownership before appending the tab to the visible session. | |
| 567 | 580 | publishPaneOwner(p, sess, tb, 0) | |
| 568 | 581 | sess.tabs = append(sess.tabs, tb) | |
| 569 | - | sess.active = len(sess.tabs) - 1 | |
| 570 | - | tabIndex := sess.active | |
| 582 | + | tabIndex := len(sess.tabs) - 1 | |
| 571 | 583 | if !sess.ephemeral { | |
| 572 | 584 | d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate()) | |
| 573 | 585 | } | |
| 586 | + | if ac != nil { | |
| 587 | + | sess.activateAttachmentViewLocked(ac, tabIndex) | |
| 588 | + | } | |
| 574 | 589 | sess.mu.Unlock() | |
| 575 | 590 | d.mu.Unlock() | |
| 576 | 591 | d.log.Info("tab created", "session", name, "tab", tabIndex) | |
| @@ -661,8 +676,8 @@ func contentSize(clientSize domain.Size, proxied bool) domain.Size { | |||
| 661 | 676 | func (s *session) fullViewportSize() domain.Size { | |
| 662 | 677 | s.mu.Lock() | |
| 663 | 678 | var tb *tab | |
| 664 | - | if s.active >= 0 && s.active < len(s.tabs) { | |
| 665 | - | tb = s.tabs[s.active] | |
| 679 | + | if len(s.tabs) != 0 { | |
| 680 | + | tb = s.tabs[0] | |
| 666 | 681 | } | |
| 667 | 682 | s.mu.Unlock() | |
| 668 | 683 | ||
| @@ -698,7 +713,8 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) { | |||
| 698 | 713 | return | |
| 699 | 714 | } | |
| 700 | 715 | if owner.session != sess || owner.tab != tb { | |
| 701 | - | panic("daemon: starting pane reader for a different published owner") | |
| 716 | + | d.log.Warn("pane owner mismatch", "pane", p.id) | |
| 717 | + | return | |
| 702 | 718 | } | |
| 703 | 719 | sess.mu.Lock() | |
| 704 | 720 | name := sess.name | |
| @@ -709,21 +725,18 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) { | |||
| 709 | 725 | go d.readPanePTY(p) | |
| 710 | 726 | } | |
| 711 | 727 | ||
| 712 | - | // attachClient makes ac the session's current client, displacing any prior one | |
| 713 | - | // (which is notified with ReasonDetach and disconnected — its own conn handler | |
| 714 | - | ||
| 715 | 728 | func (s *session) detachIfCurrent(ac *attachedClient) bool { | |
| 716 | 729 | s.mu.Lock() | |
| 717 | 730 | defer s.mu.Unlock() | |
| 718 | - | if s.client == ac { | |
| 719 | - | s.client = nil | |
| 720 | - | ac.setSession(nil) | |
| 721 | - | return true | |
| 731 | + | if !attachmentRegisteredLocked(s, ac) || ac.currentAttachmentSession() != s { | |
| 732 | + | return false | |
| 722 | 733 | } | |
| 723 | - | return false | |
| 734 | + | s.unregisterAttachmentLocked(ac) | |
| 735 | + | ac.setSession(nil) | |
| 736 | + | return true | |
| 724 | 737 | } | |
| 725 | 738 | ||
| 726 | - | // detachIfCurrent publishes terminal role invalidation through the attachment | |
| 739 | + | // detachIfCurrent publishes terminal attachment invalidation through the attachment | |
| 727 | 740 | // gate. Freeze/drain happens before routing or session locks are acquired. | |
| 728 | 741 | func (d *Daemon) detachIfCurrent(sess *session, ac *attachedClient) bool { | |
| 729 | 742 | return d.detachIfCurrentTransport(sess, ac, transportSnapshot{}) | |
| @@ -736,22 +749,22 @@ func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, exp | |||
| 736 | 749 | if sess == nil || ac == nil { | |
| 737 | 750 | return false | |
| 738 | 751 | } | |
| 739 | - | frozen := freezeRoleEffectGates(ac) | |
| 752 | + | frozen := freezeAttachmentEffectGates(ac) | |
| 740 | 753 | defer frozen.unfreeze() | |
| 741 | - | if d.afterDetachRoleEffectsFrozen != nil { | |
| 742 | - | d.afterDetachRoleEffectsFrozen() | |
| 754 | + | if d.afterDetachAttachmentEffectsFrozen != nil { | |
| 755 | + | d.afterDetachAttachmentEffectsFrozen() | |
| 743 | 756 | } | |
| 744 | 757 | d.notices.routingMu.Lock() | |
| 745 | 758 | sess.mu.Lock() | |
| 746 | - | current := sess.client == ac && ac.currentSession() == sess | |
| 759 | + | current := attachmentRegisteredLocked(sess, ac) && ac.currentAttachmentSession() == sess | |
| 747 | 760 | if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) { | |
| 748 | 761 | current = false | |
| 749 | 762 | } | |
| 750 | 763 | if current { | |
| 751 | - | sess.client = nil | |
| 752 | - | ac.roleGeneration.Add(1) | |
| 764 | + | sess.unregisterAttachmentLocked(ac) | |
| 765 | + | ac.connectionGeneration.Add(1) | |
| 753 | 766 | ac.setSession(nil) | |
| 754 | - | ac.invalidateFrozenRoleCapability() | |
| 767 | + | ac.invalidateFrozenAttachmentCapability() | |
| 755 | 768 | } | |
| 756 | 769 | sess.mu.Unlock() | |
| 757 | 770 | d.notices.routingMu.Unlock() | |
| @@ -766,22 +779,22 @@ func (d *Daemon) detachProxyIfCurrentTransport(p *proxySession, ac *attachedClie | |||
| 766 | 779 | if p == nil || ac == nil { | |
| 767 | 780 | return false | |
| 768 | 781 | } | |
| 769 | - | frozen := freezeRoleEffectGates(ac) | |
| 782 | + | frozen := freezeAttachmentEffectGates(ac) | |
| 770 | 783 | defer frozen.unfreeze() | |
| 771 | - | if d.afterDetachRoleEffectsFrozen != nil { | |
| 772 | - | d.afterDetachRoleEffectsFrozen() | |
| 784 | + | if d.afterDetachAttachmentEffectsFrozen != nil { | |
| 785 | + | d.afterDetachAttachmentEffectsFrozen() | |
| 773 | 786 | } | |
| 774 | 787 | d.notices.routingMu.Lock() | |
| 775 | 788 | p.sessionCore.mu.Lock() | |
| 776 | - | current := p.client == ac && ac.currentAttachmentSession() == attachmentSession(p) | |
| 789 | + | current := attachmentRegisteredLocked(attachmentSession(p), ac) && ac.currentAttachmentSession() == attachmentSession(p) | |
| 777 | 790 | if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) { | |
| 778 | 791 | current = false | |
| 779 | 792 | } | |
| 780 | 793 | if current { | |
| 781 | - | p.client = nil | |
| 782 | - | ac.roleGeneration.Add(1) | |
| 794 | + | unregisterAttachmentSessionLocked(attachmentSession(p), ac) | |
| 795 | + | ac.connectionGeneration.Add(1) | |
| 783 | 796 | ac.setSession(nil) | |
| 784 | - | ac.invalidateFrozenRoleCapability() | |
| 797 | + | ac.invalidateFrozenAttachmentCapability() | |
| 785 | 798 | } | |
| 786 | 799 | p.sessionCore.mu.Unlock() | |
| 787 | 800 | d.notices.routingMu.Unlock() | |
| @@ -791,24 +804,36 @@ func (d *Daemon) detachProxyIfCurrentTransport(p *proxySession, ac *attachedClie | |||
| 791 | 804 | return current | |
| 792 | 805 | } | |
| 793 | 806 | ||
| 794 | - | // detachIfRoleCurrent invalidates only the exact role generation and transport | |
| 795 | - | // whose admitted send failed. A queued stale error cannot detach a later role | |
| 796 | - | // publication or a rebound transport on the same attachment object. | |
| 797 | - | func (d *Daemon) detachIfRoleCurrent(token attachmentRoleToken) bool { | |
| 798 | - | return d.detachIfRoleCurrentUntil(token, nil) | |
| 807 | + | // detachIfAttachmentCurrent invalidates only the exact attachment generation and transport | |
| 808 | + | // whose admitted send failed. A queued stale error cannot detach a later | |
| 809 | + | // attachment publication or a rebound transport on the same attachment object. | |
| 810 | + | func (d *Daemon) detachIfAttachmentCurrent(token attachmentConnectionToken) bool { | |
| 811 | + | return d.detachIfAttachmentCurrentUntil(token, nil) | |
| 799 | 812 | } | |
| 800 | 813 | ||
| 801 | - | func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func() <-chan struct{}) bool { | |
| 802 | - | if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.transport.transport == nil { | |
| 814 | + | func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken, done func() <-chan struct{}) bool { | |
| 815 | + | if token.sess == nil || token.ac == nil || token.transport.transport == nil { | |
| 803 | 816 | return false | |
| 804 | 817 | } | |
| 805 | - | frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{done: done}, token.ac) | |
| 818 | + | // Attachments without the session's optional primary render lease still | |
| 819 | + | // own an independent connection lifecycle and detach through the same exact | |
| 820 | + | // transport/generation fence. | |
| 821 | + | if token.lease == nil { | |
| 822 | + | if sess, ok := localSession(token.sess); ok { | |
| 823 | + | return d.detachIfCurrentTransport(sess, token.ac, token.transport) | |
| 824 | + | } | |
| 825 | + | if proxy, ok := token.sess.(*proxySession); ok { | |
| 826 | + | return d.detachProxyIfCurrentTransport(proxy, token.ac, token.transport) | |
| 827 | + | } | |
| 828 | + | return false | |
| 829 | + | } | |
| 830 | + | frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac) | |
| 806 | 831 | defer frozen.unfreeze() | |
| 807 | 832 | if !frozen.acquired || !frozen.drained { | |
| 808 | 833 | return false | |
| 809 | 834 | } | |
| 810 | - | if d.afterDetachRoleEffectsFrozen != nil { | |
| 811 | - | d.afterDetachRoleEffectsFrozen() | |
| 835 | + | if d.afterDetachAttachmentEffectsFrozen != nil { | |
| 836 | + | d.afterDetachAttachmentEffectsFrozen() | |
| 812 | 837 | } | |
| 813 | 838 | d.mu.Lock() | |
| 814 | 839 | core := token.sess.core() | |
| @@ -823,15 +848,14 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func() | |||
| 823 | 848 | coordinator.mu.Lock() | |
| 824 | 849 | } | |
| 825 | 850 | req := attachmentTransitionRequest{ | |
| 826 | - | source: token.sess, next: token.ac, expectedRole: token.role, | |
| 827 | - | expectedTransport: token.transport, | |
| 851 | + | source: token.sess, next: token.ac, expectedTransport: token.transport, | |
| 828 | 852 | } | |
| 829 | 853 | current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req) | |
| 830 | 854 | if current { | |
| 831 | - | core.client = nil | |
| 832 | - | token.ac.roleGeneration.Add(1) | |
| 855 | + | unregisterAttachmentSessionLocked(token.sess, token.ac) | |
| 856 | + | token.ac.connectionGeneration.Add(1) | |
| 833 | 857 | token.ac.setSession(nil) | |
| 834 | - | token.ac.invalidateFrozenRoleCapability() | |
| 858 | + | token.ac.invalidateFrozenAttachmentCapability() | |
| 835 | 859 | } | |
| 836 | 860 | if coordinator != nil { | |
| 837 | 861 | coordinator.mu.Unlock() | |
| @@ -842,39 +866,6 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func() | |||
| 842 | 866 | return current | |
| 843 | 867 | } | |
| 844 | 868 | ||
| 845 | - | func (s *session) activeTab() *tab { | |
| 846 | - | s.mu.Lock() | |
| 847 | - | defer s.mu.Unlock() | |
| 848 | - | if s.active < 0 || s.active >= len(s.tabs) { | |
| 849 | - | return nil | |
| 850 | - | } | |
| 851 | - | return s.tabs[s.active] | |
| 852 | - | } | |
| 853 | - | ||
| 854 | - | func (s *session) switchTab(idx int) bool { | |
| 855 | - | s.mu.Lock() | |
| 856 | - | if idx < 0 || idx >= len(s.tabs) || idx == s.active { | |
| 857 | - | s.mu.Unlock() | |
| 858 | - | return false | |
| 859 | - | } | |
| 860 | - | s.active = idx | |
| 861 | - | s.mu.Unlock() | |
| 862 | - | markSnapshotDirty(s) | |
| 863 | - | return true | |
| 864 | - | } | |
| 865 | - | ||
| 866 | - | func (s *session) switchRelative(delta int) bool { | |
| 867 | - | s.mu.Lock() | |
| 868 | - | if len(s.tabs) < 2 { | |
| 869 | - | s.mu.Unlock() | |
| 870 | - | return false | |
| 871 | - | } | |
| 872 | - | s.active = (s.active + delta + len(s.tabs)) % len(s.tabs) | |
| 873 | - | s.mu.Unlock() | |
| 874 | - | markSnapshotDirty(s) | |
| 875 | - | return true | |
| 876 | - | } | |
| 877 | - | ||
| 878 | 869 | func (d *Daemon) renameSession(sess *session, name string) error { | |
| 879 | 870 | if name == "" { | |
| 880 | 871 | return errSessionNameRequired | |
| @@ -1007,7 +998,24 @@ func (s *session) persistRecordLocked(updatedAt int64) domain.CatalogueRecord { | |||
| 1007 | 998 | return domain.CatalogueRecord{Name: s.name, IncarnationID: s.incarnation, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames} | |
| 1008 | 999 | } | |
| 1009 | 1000 | ||
| 1001 | + | // closeTab is the public close boundary for callers that are not already | |
| 1002 | + | // inside session.runMutation. Mutation dispatchers use closeTabLocked. | |
| 1010 | 1003 | func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error { | |
| 1004 | + | if sess == nil { | |
| 1005 | + | return layout.ErrNotFound | |
| 1006 | + | } | |
| 1007 | + | sess.dispatchMu.Lock() | |
| 1008 | + | defer sess.dispatchMu.Unlock() | |
| 1009 | + | return d.closeTabLocked(sess, tb, repaint) | |
| 1010 | + | } | |
| 1011 | + | ||
| 1012 | + | // closeTabLocked requires sess.dispatchMu before entering daemon or session | |
| 1013 | + | // locks. Existing runMutation callers use it to avoid nested dispatch locking. | |
| 1014 | + | func (d *Daemon) closeTabLocked(sess *session, tb *tab, repaint bool) error { | |
| 1015 | + | return d.closeTabLockedWithEffect(sess, tb, repaint, nil) | |
| 1016 | + | } | |
| 1017 | + | ||
| 1018 | + | func (d *Daemon) closeTabLockedWithEffect(sess *session, tb *tab, repaint bool, effect *attachmentEffectTicket) error { | |
| 1011 | 1019 | if sess == nil || tb == nil { | |
| 1012 | 1020 | return layout.ErrNotFound | |
| 1013 | 1021 | } | |
| @@ -1033,7 +1041,14 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error { | |||
| 1033 | 1041 | name := sess.name | |
| 1034 | 1042 | sess.mu.Unlock() | |
| 1035 | 1043 | d.mu.Unlock() | |
| 1036 | - | if err := d.killSession(sess, ports.ReasonSessionKilled, false); err != nil { | |
| 1044 | + | var err error | |
| 1045 | + | if effect != nil { | |
| 1046 | + | effect.bindActionEnd(d, "close-tab") | |
| 1047 | + | err = d.killSessionForAttachment(sess, ports.ReasonSessionKilled, false, effect.connectionToken(), "close-tab") | |
| 1048 | + | } else { | |
| 1049 | + | err = d.killSession(sess, ports.ReasonSessionKilled, false) | |
| 1050 | + | } | |
| 1051 | + | if err != nil { | |
| 1037 | 1052 | d.log.Warn("closing last tab failed", "session", name, "err", err) | |
| 1038 | 1053 | d.reportError(sess, domain.UserErr(domain.NoticeSnapshotSaturated, | |
| 1039 | 1054 | "couldn't close tab: session state not yet saved; try again", err)) | |
| @@ -1043,19 +1058,14 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error { | |||
| 1043 | 1058 | return nil | |
| 1044 | 1059 | } | |
| 1045 | 1060 | ringing := tb.attention | |
| 1046 | - | wasActive := idx == sess.active | |
| 1061 | + | sess.prepareAttachmentViewsForRemovedTabLocked(tb, idx) | |
| 1047 | 1062 | sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...) | |
| 1048 | - | if sess.active >= len(sess.tabs) { | |
| 1049 | - | sess.active = len(sess.tabs) - 1 | |
| 1050 | - | } else if idx < sess.active { | |
| 1051 | - | sess.active-- | |
| 1052 | - | } | |
| 1053 | - | destination := sess.tabs[sess.active] | |
| 1054 | - | ac := sess.client | |
| 1063 | + | attachments := sess.snapshotAttachmentsLocked() | |
| 1055 | 1064 | name := sess.name | |
| 1056 | 1065 | if !sess.ephemeral { | |
| 1057 | 1066 | d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate()) | |
| 1058 | 1067 | } | |
| 1068 | + | sess.invalidateViewsLocked() | |
| 1059 | 1069 | sess.mu.Unlock() | |
| 1060 | 1070 | d.mu.Unlock() | |
| 1061 | 1071 | d.log.Info("tab closed", "session", name) | |
| @@ -1065,24 +1075,25 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error { | |||
| 1065 | 1075 | tb.mu.Lock() | |
| 1066 | 1076 | panes := tb.panesSnapshot() | |
| 1067 | 1077 | tb.mu.Unlock() | |
| 1068 | - | if ac != nil { | |
| 1078 | + | for _, ac := range attachments { | |
| 1069 | 1079 | ac.pruneCaptureFrames(panes...) | |
| 1070 | 1080 | } | |
| 1071 | 1081 | if tb.cancel != nil { | |
| 1072 | 1082 | tb.cancel() | |
| 1073 | 1083 | } | |
| 1074 | - | d.teardownFloating(tb, ac) | |
| 1084 | + | for _, ac := range attachments { | |
| 1085 | + | d.teardownFloating(tb, ac) | |
| 1086 | + | } | |
| 1075 | 1087 | if rc := sess.renderCoordinator(); rc != nil { | |
| 1076 | 1088 | for _, p := range panes { | |
| 1077 | 1089 | rc.noteSyncPaneRemoved(p) | |
| 1078 | 1090 | } | |
| 1079 | 1091 | } | |
| 1080 | 1092 | tb.closeAllPanes() | |
| 1081 | - | if wasActive { | |
| 1082 | - | d.activateTab(sess, destination) | |
| 1083 | - | } | |
| 1084 | - | if repaint && ac != nil { | |
| 1085 | - | d.invalidateRender(sess, ac, true, "session.go") | |
| 1093 | + | if repaint { | |
| 1094 | + | for _, ac := range attachments { | |
| 1095 | + | d.invalidateRender(sess, ac, true, "session.go") | |
| 1096 | + | } | |
| 1086 | 1097 | } | |
| 1087 | 1098 | if ringing { | |
| 1088 | 1099 | d.repaintAllAttachedClients() | |
| @@ -1160,34 +1171,33 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||
| 1160 | 1171 | } | |
| 1161 | 1172 | ||
| 1162 | 1173 | type sessionKillAdmission struct { | |
| 1163 | - | token attachmentRoleToken | |
| 1174 | + | token attachmentConnectionToken | |
| 1164 | 1175 | action string | |
| 1165 | 1176 | } | |
| 1166 | 1177 | ||
| 1167 | 1178 | type sessionKillParticipants struct { | |
| 1168 | - | active *attachedClient | |
| 1169 | - | snatched map[*attachedClient]struct{} | |
| 1179 | + | attachments map[*attachedClient]struct{} | |
| 1170 | 1180 | transports map[*attachedClient]transportSnapshot | |
| 1171 | - | roleGates []*attachedClient | |
| 1172 | - | interrupts []roleTransportInterrupt | |
| 1181 | + | effectGates []*attachedClient | |
| 1182 | + | interrupts []attachmentTransportInterrupt | |
| 1173 | 1183 | source *session | |
| 1174 | - | sourceToken *attachmentRoleToken | |
| 1184 | + | sourceToken *attachmentConnectionToken | |
| 1175 | 1185 | target *session | |
| 1176 | 1186 | } | |
| 1177 | 1187 | ||
| 1178 | - | func (d *Daemon) killSessionForRole(sess *session, reason uint8, purge bool, token attachmentRoleToken, action string) error { | |
| 1188 | + | func (d *Daemon) killSessionForAttachment(sess *session, reason uint8, purge bool, token attachmentConnectionToken, action string) error { | |
| 1179 | 1189 | return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil, &sessionKillAdmission{token: token, action: action}) | |
| 1180 | 1190 | } | |
| 1181 | 1191 | ||
| 1182 | - | // snapshotSessionKillParticipants captures role membership under the canonical | |
| 1183 | - | // architecture locks, but releases every lock before any role gate is frozen or | |
| 1192 | + | // snapshotSessionKillParticipants captures attachment membership under the canonical | |
| 1193 | + | // architecture locks, but releases every lock before any attachment gate is frozen or | |
| 1184 | 1194 | // drained. The initiator is part of the same deduplicated global gate set even | |
| 1185 | 1195 | // when it belongs to a different source session. | |
| 1186 | 1196 | func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *sessionKillAdmission) (sessionKillParticipants, bool) { | |
| 1187 | 1197 | snapshot := sessionKillParticipants{target: target} | |
| 1188 | 1198 | if admission != nil { | |
| 1189 | 1199 | token := admission.token | |
| 1190 | - | if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.transport.transport == nil { | |
| 1200 | + | if token.sess == nil || token.ac == nil || token.transport.transport == nil { | |
| 1191 | 1201 | return sessionKillParticipants{}, false | |
| 1192 | 1202 | } | |
| 1193 | 1203 | var ok bool | |
| @@ -1209,21 +1219,18 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses | |||
| 1209 | 1219 | source = target | |
| 1210 | 1220 | } | |
| 1211 | 1221 | unlockSessions := lockAttachmentSessions(source, target) | |
| 1212 | - | snapshot.active = target.client | |
| 1213 | - | snapshot.snatched = make(map[*attachedClient]struct{}, len(target.snatched)) | |
| 1214 | - | snapshot.transports = make(map[*attachedClient]transportSnapshot, 1+len(target.snatched)) | |
| 1215 | - | if snapshot.active != nil { | |
| 1216 | - | snapshot.transports[snapshot.active] = snapshot.active.transportSnapshot() | |
| 1217 | - | } | |
| 1218 | - | for ac := range target.snatched { | |
| 1219 | - | snapshot.snatched[ac] = struct{}{} | |
| 1222 | + | snapshot.attachments = make(map[*attachedClient]struct{}, len(target.attachments)) | |
| 1223 | + | snapshot.transports = make(map[*attachedClient]transportSnapshot, len(target.attachments)) | |
| 1224 | + | for ac := range target.attachments { | |
| 1225 | + | snapshot.attachments[ac] = struct{}{} | |
| 1220 | 1226 | snapshot.transports[ac] = ac.transportSnapshot() | |
| 1221 | 1227 | } | |
| 1228 | + | ||
| 1222 | 1229 | unlockSessions() | |
| 1223 | 1230 | d.notices.routingMu.Unlock() | |
| 1224 | 1231 | d.mu.Unlock() | |
| 1225 | 1232 | ||
| 1226 | - | seen := make(map[*attachedClient]struct{}, 2+len(snapshot.snatched)) | |
| 1233 | + | seen := make(map[*attachedClient]struct{}, 1+len(snapshot.attachments)) | |
| 1227 | 1234 | add := func(ac *attachedClient) { | |
| 1228 | 1235 | if ac == nil { | |
| 1229 | 1236 | return | |
| @@ -1232,25 +1239,24 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses | |||
| 1232 | 1239 | return | |
| 1233 | 1240 | } | |
| 1234 | 1241 | seen[ac] = struct{}{} | |
| 1235 | - | snapshot.roleGates = append(snapshot.roleGates, ac) | |
| 1242 | + | snapshot.effectGates = append(snapshot.effectGates, ac) | |
| 1236 | 1243 | } | |
| 1237 | 1244 | if admission != nil { | |
| 1238 | 1245 | add(admission.token.ac) | |
| 1239 | 1246 | } | |
| 1240 | - | add(snapshot.active) | |
| 1241 | - | for ac := range snapshot.snatched { | |
| 1247 | + | for ac := range snapshot.attachments { | |
| 1242 | 1248 | add(ac) | |
| 1243 | 1249 | } | |
| 1244 | 1250 | for ac, transport := range snapshot.transports { | |
| 1245 | 1251 | if transport.transport != nil { | |
| 1246 | - | snapshot.interrupts = append(snapshot.interrupts, roleTransportInterrupt{ac: ac, transport: transport}) | |
| 1252 | + | snapshot.interrupts = append(snapshot.interrupts, attachmentTransportInterrupt{ac: ac, transport: transport}) | |
| 1247 | 1253 | } | |
| 1248 | 1254 | } | |
| 1249 | 1255 | return snapshot, true | |
| 1250 | 1256 | } | |
| 1251 | 1257 | ||
| 1252 | 1258 | // sessionKillParticipantsCurrent repeats lifecycle, source-token, and exact | |
| 1253 | - | // target-membership validation while every discovered role gate is frozen. | |
| 1259 | + | // target-membership validation while every discovered attachment gate is frozen. | |
| 1254 | 1260 | func (d *Daemon) sessionKillParticipantsCurrent(snapshot sessionKillParticipants) bool { | |
| 1255 | 1261 | d.mu.Lock() | |
| 1256 | 1262 | defer d.mu.Unlock() | |
| @@ -1277,14 +1283,15 @@ func (d *Daemon) sessionKillParticipantsCurrent(snapshot sessionKillParticipants | |||
| 1277 | 1283 | // session and coordinator locks. | |
| 1278 | 1284 | func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sourceCoordinator *renderCoordinator) bool { | |
| 1279 | 1285 | target := snapshot.target | |
| 1280 | - | if target.client != snapshot.active || len(target.snatched) != len(snapshot.snatched) { | |
| 1286 | + | if len(target.attachments) != len(snapshot.attachments) { | |
| 1281 | 1287 | return false | |
| 1282 | 1288 | } | |
| 1283 | - | for ac := range snapshot.snatched { | |
| 1284 | - | if _, current := target.snatched[ac]; !current { | |
| 1289 | + | for ac := range snapshot.attachments { | |
| 1290 | + | if _, current := target.attachments[ac]; !current { | |
| 1285 | 1291 | return false | |
| 1286 | 1292 | } | |
| 1287 | 1293 | } | |
| 1294 | + | ||
| 1288 | 1295 | for ac, transport := range snapshot.transports { | |
| 1289 | 1296 | if !ac.transportSnapshotCurrent(transport) { | |
| 1290 | 1297 | return false | |
| @@ -1295,8 +1302,7 @@ func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sour | |||
| 1295 | 1302 | } | |
| 1296 | 1303 | token := *snapshot.sourceToken | |
| 1297 | 1304 | req := attachmentTransitionRequest{ | |
| 1298 | - | source: token.sess, next: token.ac, expectedRole: token.role, | |
| 1299 | - | expectedTransport: token.transport, | |
| 1305 | + | source: token.sess, next: token.ac, expectedTransport: token.transport, | |
| 1300 | 1306 | } | |
| 1301 | 1307 | return transitionSourceTokenCurrentLocked(token, token.sess, sourceCoordinator, req) | |
| 1302 | 1308 | } | |
| @@ -1406,7 +1412,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1406 | 1412 | if sess == nil { | |
| 1407 | 1413 | return nil | |
| 1408 | 1414 | } | |
| 1409 | - | // Discover every role whose membership can be invalidated before freezing | |
| 1415 | + | // Discover every attachment whose membership can be invalidated before freezing | |
| 1410 | 1416 | // any one of them. The common gate helper deduplicates and freezes the whole | |
| 1411 | 1417 | // immutable identity set in the same global order used by transitions. | |
| 1412 | 1418 | participants, ok := d.snapshotSessionKillParticipants(sess, admission) | |
| @@ -1414,29 +1420,29 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1414 | 1420 | return nil | |
| 1415 | 1421 | } | |
| 1416 | 1422 | if admission != nil { | |
| 1417 | - | d.endActionRoleEffect(admission.token.effect, admission.action) | |
| 1423 | + | d.endActionAttachmentEffect(admission.token.effect, admission.action) | |
| 1418 | 1424 | } | |
| 1419 | 1425 | action := "" | |
| 1420 | 1426 | if admission != nil { | |
| 1421 | 1427 | action = admission.action | |
| 1422 | 1428 | } | |
| 1423 | - | if d.afterRoleEffectParticipantsSnapshotted != nil { | |
| 1424 | - | d.afterRoleEffectParticipantsSnapshotted(action, participants.roleGates) | |
| 1429 | + | if d.afterAttachmentEffectParticipantsSnapshotted != nil { | |
| 1430 | + | d.afterAttachmentEffectParticipantsSnapshotted(action, participants.effectGates) | |
| 1425 | 1431 | } | |
| 1426 | 1432 | var drainDone func() <-chan struct{} | |
| 1427 | - | var drainDeadline *roleEffectDrainDeadline | |
| 1433 | + | var drainDeadline *attachmentEffectDrainDeadline | |
| 1428 | 1434 | if deadline != nil { | |
| 1429 | 1435 | drainDone = deadline.Done | |
| 1430 | 1436 | } else { | |
| 1431 | - | drainDeadline = newRoleEffectDrainDeadline(d.clock) | |
| 1437 | + | drainDeadline = newAttachmentEffectDrainDeadline(d.clock) | |
| 1432 | 1438 | drainDone = drainDeadline.Done | |
| 1433 | 1439 | defer drainDeadline.stop() | |
| 1434 | 1440 | } | |
| 1435 | - | frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, done: drainDone, afterFrozen: func(ac *attachedClient) { | |
| 1436 | - | if d.afterRoleEffectGateFrozen != nil { | |
| 1437 | - | d.afterRoleEffectGateFrozen(action, ac) | |
| 1441 | + | frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: participants.interrupts, done: drainDone, afterFrozen: func(ac *attachedClient) { | |
| 1442 | + | if d.afterAttachmentEffectGateFrozen != nil { | |
| 1443 | + | d.afterAttachmentEffectGateFrozen(action, ac) | |
| 1438 | 1444 | } | |
| 1439 | - | }}, participants.roleGates...) | |
| 1445 | + | }}, participants.effectGates...) | |
| 1440 | 1446 | defer func() { frozen.unfreeze() }() | |
| 1441 | 1447 | if !frozen.acquired { | |
| 1442 | 1448 | // The acquisition helper has already rolled back exactly the gates it | |
| @@ -1540,8 +1546,8 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1540 | 1546 | sess.snapshotMu.Unlock() | |
| 1541 | 1547 | } | |
| 1542 | 1548 | } | |
| 1543 | - | // Revalidate the exact source capability, target lifecycle, and active plus | |
| 1544 | - | // snatched memberships under canonical architecture locks immediately before | |
| 1549 | + | // Revalidate the exact source capability, target lifecycle, and attachment | |
| 1550 | + | // memberships under canonical architecture locks immediately before | |
| 1545 | 1551 | // publication. A changed preflight aborts without mutating either lifecycle. | |
| 1546 | 1552 | d.mu.Lock() | |
| 1547 | 1553 | if d.sessions[sess.id] != sess || participants.source != nil && d.sessions[participants.source.id] != participants.source { | |
| @@ -1566,7 +1572,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1566 | 1572 | } | |
| 1567 | 1573 | ||
| 1568 | 1574 | // d.mu -> routingMu -> ordered sessions -> ordered coordinators are held | |
| 1569 | - | // here. Registry removal and every role invalidation are one publication | |
| 1575 | + | // here. Registry removal and every attachment invalidation are one publication | |
| 1570 | 1576 | // while the complete immutable gate set remains frozen. | |
| 1571 | 1577 | if !d.unregisterSessionLocked(sess) { | |
| 1572 | 1578 | unlockCoordinators() | |
| @@ -1578,23 +1584,20 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1578 | 1584 | d.clearBarScriptsForSession(sess.id) | |
| 1579 | 1585 | d.purgeParkingForSessionLocked(sess) | |
| 1580 | 1586 | parkedRetirements := d.purgeParkedForSessionLocked(sess) | |
| 1581 | - | detachedActive := sess.client | |
| 1582 | - | attachments := make([]detachedAttachmentSnapshot, 0, len(participants.roleGates)) | |
| 1587 | + | attachments := make([]detachedAttachmentSnapshot, 0, len(participants.effectGates)) | |
| 1583 | 1588 | capture := func(attached *attachedClient) { | |
| 1584 | 1589 | if attached == nil { | |
| 1585 | 1590 | return | |
| 1586 | 1591 | } | |
| 1587 | 1592 | attachments = append(attachments, detachedAttachmentSnapshot{ac: attached, transport: participants.transports[attached]}) | |
| 1588 | - | attached.roleGeneration.Add(1) | |
| 1593 | + | attached.connectionGeneration.Add(1) | |
| 1589 | 1594 | attached.setSession(nil) | |
| 1590 | - | attached.invalidateFrozenRoleCapability() | |
| 1595 | + | attached.invalidateFrozenAttachmentCapability() | |
| 1591 | 1596 | } | |
| 1592 | - | capture(sess.client) | |
| 1593 | - | for snatched := range sess.snatched { | |
| 1594 | - | capture(snatched) | |
| 1597 | + | for attached := range sess.attachments { | |
| 1598 | + | capture(attached) | |
| 1595 | 1599 | } | |
| 1596 | - | sess.client = nil | |
| 1597 | - | clear(sess.snatched) | |
| 1600 | + | clear(sess.attachments) | |
| 1598 | 1601 | stoppedName := sess.name | |
| 1599 | 1602 | stoppedCwd := sess.cwd | |
| 1600 | 1603 | createdAt := sess.createdAt | |
| @@ -1617,7 +1620,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1617 | 1620 | d.mu.Unlock() | |
| 1618 | 1621 | d.finishParkedAttachmentRetirements(parkedRetirements) | |
| 1619 | 1622 | frozen.unfreeze() | |
| 1620 | - | frozen = frozenRoleEffectGates{} | |
| 1623 | + | frozen = frozenAttachmentEffectGates{} | |
| 1621 | 1624 | d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason) | |
| 1622 | 1625 | ||
| 1623 | 1626 | var purgeErr error | |
| @@ -1645,7 +1648,9 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1645 | 1648 | sess.mu.Unlock() | |
| 1646 | 1649 | for _, tb := range tabs { | |
| 1647 | 1650 | d.clearDestroyedTabPreview(tb) | |
| 1648 | - | d.teardownFloating(tb, detachedActive) | |
| 1651 | + | for _, attachment := range attachments { | |
| 1652 | + | d.teardownFloating(tb, attachment.ac) | |
| 1653 | + | } | |
| 1649 | 1654 | } | |
| 1650 | 1655 | for _, path := range clipFiles { | |
| 1651 | 1656 | if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| @@ -1758,7 +1763,7 @@ func (d *Daemon) refreshSessionCwd(sess *session) { | |||
| 1758 | 1763 | if d.procCwd == nil { | |
| 1759 | 1764 | return | |
| 1760 | 1765 | } | |
| 1761 | - | tb := sess.activeTab() | |
| 1766 | + | tb := sess.firstTab() | |
| 1762 | 1767 | if tb == nil { | |
| 1763 | 1768 | return | |
| 1764 | 1769 | } | |
M internal/usecase/daemon/session_back.go +4 -4
| @@ -7,7 +7,7 @@ import ( | |||
| 7 | 7 | ||
| 8 | 8 | // backSession toggles this attachment between its current session and the | |
| 9 | 9 | // immediately preceding successfully activated session. | |
| 10 | - | func (d *Daemon) backSessionForRole(token attachmentRoleToken) error { | |
| 10 | + | func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error { | |
| 11 | 11 | if d == nil || token.sess == nil || token.ac == nil { | |
| 12 | 12 | return nil | |
| 13 | 13 | } | |
| @@ -21,7 +21,7 @@ func (d *Daemon) backSessionForRole(token attachmentRoleToken) error { | |||
| 21 | 21 | key := proxy.key | |
| 22 | 22 | pickerTarget.RemoteKey = &key | |
| 23 | 23 | } | |
| 24 | - | return d.switchToTargetForRole(token, pickerTarget, sessionHandoffGuard{}, "back-session") | |
| 24 | + | return d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{}, "back-session") | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | 27 | func (d *Daemon) backSession(current *session, ac *attachedClient) { | |
| @@ -35,14 +35,14 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) { | |||
| 35 | 35 | return | |
| 36 | 36 | } | |
| 37 | 37 | token := attachmentToken(current, ac, ac.transport()) | |
| 38 | - | effect, admitted := ac.beginRoleEffect(token) | |
| 38 | + | effect, admitted := ac.beginAttachmentEffect(token) | |
| 39 | 39 | if !admitted { | |
| 40 | 40 | d.reportError(current, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", errAttachmentTransition)) | |
| 41 | 41 | return | |
| 42 | 42 | } | |
| 43 | 43 | token.effect = effect | |
| 44 | 44 | defer effect.End() | |
| 45 | - | if err := d.backSessionForRole(token); err != nil { | |
| 45 | + | if err := d.backSessionForAttachment(token); err != nil { | |
| 46 | 46 | d.reportError(current, err) | |
| 47 | 47 | } | |
| 48 | 48 | } | |
M internal/usecase/daemon/session_core.go +23 -21
| @@ -13,8 +13,8 @@ type attachmentSession interface { | |||
| 13 | 13 | core() *sessionCore | |
| 14 | 14 | snapshotView(viewOptions) sessionView | |
| 15 | 15 | statusSegments(includeTerminalTitle bool) statusSnapshot | |
| 16 | - | capturePrimary(*attachedClient, primaryCaptureRequest) (*capturedRenderState, bool) | |
| 17 | - | activateTargetLocked(tabIndex int) bool | |
| 16 | + | captureRenderState(*attachedClient, renderCaptureRequest) (*capturedRenderState, bool) | |
| 17 | + | validTargetTabLocked(tabIndex int) bool | |
| 18 | 18 | isProxy() bool | |
| 19 | 19 | } | |
| 20 | 20 | ||
| @@ -24,16 +24,20 @@ type attachmentSession interface { | |||
| 24 | 24 | type sessionCore struct { | |
| 25 | 25 | mu sync.Mutex | |
| 26 | 26 | ||
| 27 | - | id domain.SessionID | |
| 28 | - | name string | |
| 29 | - | ephemeral bool | |
| 30 | - | caps sessionCapabilities | |
| 31 | - | client *attachedClient | |
| 32 | - | snatched map[*attachedClient]struct{} | |
| 33 | - | createdAt int64 | |
| 34 | - | incarnation domain.IncarnationID | |
| 35 | - | mruAt atomic.Uint64 | |
| 36 | - | coordinator atomic.Pointer[renderCoordinator] | |
| 27 | + | id domain.SessionID | |
| 28 | + | name string | |
| 29 | + | ephemeral bool | |
| 30 | + | caps sessionCapabilities | |
| 31 | + | // attachments is the session-owned membership registry. Connection | |
| 32 | + | // generation and effect state remain attachment-local; admission and view | |
| 33 | + | // code use this collection as their membership source. | |
| 34 | + | attachments map[*attachedClient]struct{} | |
| 35 | + | attachmentOrder map[*attachedClient]uint64 | |
| 36 | + | nextAttachmentID uint64 | |
| 37 | + | createdAt int64 | |
| 38 | + | incarnation domain.IncarnationID | |
| 39 | + | mruAt atomic.Uint64 | |
| 40 | + | coordinator atomic.Pointer[renderCoordinator] | |
| 37 | 41 | } | |
| 38 | 42 | ||
| 39 | 43 | func (s *session) core() *sessionCore { | |
| @@ -45,20 +49,18 @@ func (s *session) core() *sessionCore { | |||
| 45 | 49 | ||
| 46 | 50 | func (s *session) isProxy() bool { return false } | |
| 47 | 51 | ||
| 48 | - | func (s *session) capturePrimary(ac *attachedClient, req primaryCaptureRequest) (*capturedRenderState, bool) { | |
| 49 | - | return captureLocalPrimaryRenderState(s, ac, req) | |
| 52 | + | func (s *session) captureRenderState(ac *attachedClient, req renderCaptureRequest) (*capturedRenderState, bool) { | |
| 53 | + | return captureLocalRenderState(s, ac, req) | |
| 50 | 54 | } | |
| 51 | 55 | ||
| 52 | - | // activateTargetLocked validates and selects a local tab. Caller holds s.mu. | |
| 53 | - | func (s *session) activateTargetLocked(tabIndex int) bool { | |
| 56 | + | // validTargetTabLocked validates a target tab index without changing any | |
| 57 | + | // attachment view. Client-facing navigation updates attachment views through | |
| 58 | + | // selectAttachmentTab. | |
| 59 | + | func (s *session) validTargetTabLocked(tabIndex int) bool { | |
| 54 | 60 | if tabIndex < 0 { | |
| 55 | 61 | return true | |
| 56 | 62 | } | |
| 57 | - | if tabIndex >= len(s.tabs) { | |
| 58 | - | return false | |
| 59 | - | } | |
| 60 | - | s.active = tabIndex | |
| 61 | - | return true | |
| 63 | + | return tabIndex < len(s.tabs) | |
| 62 | 64 | } | |
| 63 | 65 | ||
| 64 | 66 | // localSession narrows an attachment entry for operations that own local PTYs, | |
M internal/usecase/daemon/session_core_test.go +21 -47
| @@ -9,6 +9,18 @@ import ( | |||
| 9 | 9 | "github.com/stretchr/testify/require" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | + | func clearAttachmentsForTest(sess *session) { | |
| 13 | + | for _, ac := range sess.snapshotAttachments() { | |
| 14 | + | sess.unregisterAttachment(ac) | |
| 15 | + | } | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func clearAttachmentsForTestLocked(sess *session) { | |
| 19 | + | for _, ac := range sess.snapshotAttachmentsLocked() { | |
| 20 | + | sess.unregisterAttachmentLocked(ac) | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | ||
| 12 | 24 | func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) { | |
| 13 | 25 | sess := &session{} | |
| 14 | 26 | sess.id = domain.SessionID("local") | |
| @@ -24,8 +36,8 @@ func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) { | |||
| 24 | 36 | ||
| 25 | 37 | core.mu.Lock() | |
| 26 | 38 | require.False(t, sess.mu.TryLock(), "sessionCore.mu must be the mutex promoted as session.mu") | |
| 27 | - | core.client = ac | |
| 28 | - | require.Equal(t, attachmentActive, sess.attachmentRoleLocked(ac)) | |
| 39 | + | core.registerAttachmentLocked(ac) | |
| 40 | + | require.Equal(t, true, sess.attachmentRegisteredLocked(ac)) | |
| 29 | 41 | sess.tabs = append(sess.tabs, &tab{name: "logs"}) | |
| 30 | 42 | require.Len(t, sess.tabs, 2) | |
| 31 | 43 | core.mu.Unlock() | |
| @@ -37,7 +49,7 @@ func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) { | |||
| 37 | 49 | func TestSessionSnapshotViewUsesPromotedMutexForLocalTabAndRoleState(t *testing.T) { | |
| 38 | 50 | sess := &session{ | |
| 39 | 51 | sessionCore: sessionCore{id: domain.SessionID("local"), name: "work"}, | |
| 40 | - | tabs: []*tab{{name: "shell"}}, | |
| 52 | + | tabs: []*tab{{stableID: "tab-shell", name: "shell"}}, | |
| 41 | 53 | } | |
| 42 | 54 | ac := &attachedClient{} | |
| 43 | 55 | ||
| @@ -45,9 +57,9 @@ func TestSessionSnapshotViewUsesPromotedMutexForLocalTabAndRoleState(t *testing. | |||
| 45 | 57 | // promoted core mutex. snapshotView must observe the same mutex before it | |
| 46 | 58 | // can publish either field. | |
| 47 | 59 | sess.core().mu.Lock() | |
| 48 | - | sess.tabs = append(sess.tabs, &tab{name: "logs"}) | |
| 49 | - | sess.active = 1 | |
| 50 | - | sess.client = ac | |
| 60 | + | sess.tabs = append(sess.tabs, &tab{stableID: "tab-logs", name: "logs"}) | |
| 61 | + | sess.registerAttachmentLocked(ac) | |
| 62 | + | sess.activateAttachmentViewLocked(ac, 1) | |
| 51 | 63 | ||
| 52 | 64 | preCall := make(chan struct{}) | |
| 53 | 65 | allowCall := make(chan struct{}) | |
| @@ -71,13 +83,14 @@ func TestSessionSnapshotViewUsesPromotedMutexForLocalTabAndRoleState(t *testing. | |||
| 71 | 83 | t.Fatal("snapshotView completed while sessionCore.mu guarded local session state") | |
| 72 | 84 | case <-timer.C: | |
| 73 | 85 | } | |
| 74 | - | require.Equal(t, attachmentActive, sess.attachmentRoleLocked(ac)) | |
| 86 | + | require.Equal(t, true, sess.attachmentRegisteredLocked(ac)) | |
| 75 | 87 | sess.core().mu.Unlock() | |
| 76 | 88 | ||
| 77 | 89 | view := awaitTestValue(t, snapshotted, "snapshotView did not complete after sessionCore.mu was released") | |
| 78 | 90 | require.True(t, view.attached) | |
| 79 | 91 | require.Equal(t, 2, view.tabCount) | |
| 80 | - | require.Equal(t, 1, view.active) | |
| 92 | + | require.Equal(t, 0, view.defaultTab) | |
| 93 | + | require.Equal(t, 1, testAttachmentTabIndex(sess)) | |
| 81 | 94 | } | |
| 82 | 95 | ||
| 83 | 96 | func TestLocalCreateThenKillRemovesLiveRegistryEntry(t *testing.T) { | |
| @@ -134,45 +147,6 @@ func TestSessionCoreLockOrderUsesImmutableIDs(t *testing.T) { | |||
| 134 | 147 | second.core().mu.Unlock() | |
| 135 | 148 | } | |
| 136 | 149 | ||
| 137 | - | func TestInitialMetadataSkipsInvalidSnapshot(t *testing.T) { | |
| 138 | - | d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{}) | |
| 139 | - | hello := ports.Hello{ | |
| 140 | - | Version: ports.ProtocolVersion, | |
| 141 | - | Intent: ports.IntentNew, | |
| 142 | - | Name: "remote-work", | |
| 143 | - | Size: defaultSize, | |
| 144 | - | } | |
| 145 | - | tr := newWelcomeBlockingTransport(t) | |
| 146 | - | done := make(chan struct{}) | |
| 147 | - | go func() { | |
| 148 | - | d.handleHello(tr.tr, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}) | |
| 149 | - | close(done) | |
| 150 | - | }() | |
| 151 | - | ||
| 152 | - | awaitTestCompletion(t, tr.welcomeEntered, "handshake did not send Welcome") | |
| 153 | - | sess := firstSession(d) | |
| 154 | - | require.NotNil(t, sess) | |
| 155 | - | sess.mu.Lock() | |
| 156 | - | sess.active = len(sess.tabs) | |
| 157 | - | sess.mu.Unlock() | |
| 158 | - | // finish (not just release) also closes recvDone, so runConnLoop's Recv | |
| 159 | - | // fails immediately after the first paint and the handshake goroutine | |
| 160 | - | // returns instead of blocking on further input. | |
| 161 | - | tr.finish() | |
| 162 | - | awaitTestCompletion(t, done, "invalid metadata snapshot did not end the handshake") | |
| 163 | - | ||
| 164 | - | welcome := <-tr.sends | |
| 165 | - | require.Equal(t, ports.MsgWelcome, welcome.Type) | |
| 166 | - | select { | |
| 167 | - | case frame := <-tr.sends: | |
| 168 | - | t.Fatalf("invalid metadata snapshot was sent as frame type %d", frame.Type) | |
| 169 | - | default: | |
| 170 | - | } | |
| 171 | - | ||
| 172 | - | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |
| 173 | - | d.sessWg.Wait() | |
| 174 | - | } | |
| 175 | - | ||
| 176 | 150 | func mustLocalSession(t *testing.T, entry attachmentSession) *session { | |
| 177 | 151 | t.Helper() | |
| 178 | 152 | sess, ok := localSession(entry) | |
M internal/usecase/daemon/session_meta.go +20 -5
| @@ -3,6 +3,7 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | 5 | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 6 | 7 | "github.com/bnema/vev/internal/ports" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| @@ -11,17 +12,31 @@ var errSessionMetaUnavailable = errors.New("session metadata is unavailable") | |||
| 11 | 12 | // sessionMetaSnapshot captures the authoritative tab order while session.mu | |
| 12 | 13 | // owns every field represented on the wire. | |
| 13 | 14 | func (s *session) sessionMetaSnapshot() (ports.SessionMeta, bool) { | |
| 15 | + | return s.sessionMetaSnapshotFor(nil) | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func (s *session) sessionMetaSnapshotFor(ac *attachedClient) (ports.SessionMeta, bool) { | |
| 14 | 19 | if s == nil { | |
| 15 | 20 | return ports.SessionMeta{}, false | |
| 16 | 21 | } | |
| 17 | 22 | s.mu.Lock() | |
| 18 | 23 | defer s.mu.Unlock() | |
| 19 | - | if len(s.tabs) == 0 || s.active < 0 || s.active >= len(s.tabs) { | |
| 24 | + | if len(s.tabs) == 0 { | |
| 20 | 25 | return ports.SessionMeta{}, false | |
| 21 | 26 | } | |
| 27 | + | active := 0 | |
| 28 | + | if ac != nil { | |
| 29 | + | view := ac.viewSnapshot() | |
| 30 | + | for i, tb := range s.tabs { | |
| 31 | + | if domain.TabStableID(tb.stableID) == view.tabID { | |
| 32 | + | active = i | |
| 33 | + | break | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | } | |
| 22 | 37 | meta := ports.SessionMeta{ | |
| 23 | 38 | SessionName: s.name, | |
| 24 | - | Active: uint16(s.active), | |
| 39 | + | Active: uint16(active), | |
| 25 | 40 | Tabs: make([]ports.SessionTabMeta, len(s.tabs)), | |
| 26 | 41 | } | |
| 27 | 42 | for i, tb := range s.tabs { | |
| @@ -63,11 +78,11 @@ func cloneSessionMeta(meta ports.SessionMeta) ports.SessionMeta { | |||
| 63 | 78 | // caller holds ac.sendMu, which serializes this snapshot shadow with every | |
| 64 | 79 | // output write. The shadow is committed only after the transport accepts the | |
| 65 | 80 | // metadata frame. | |
| 66 | - | func (ac *attachedClient) sendSessionMetaIfChanged(sess *session, expected transportSnapshot, ticket *roleEffectTicket) error { | |
| 81 | + | func (ac *attachedClient) sendSessionMetaIfChanged(sess *session, expected transportSnapshot, ticket *attachmentEffectTicket) error { | |
| 67 | 82 | if ac == nil || sess == nil || !ac.proxied { | |
| 68 | 83 | return nil | |
| 69 | 84 | } | |
| 70 | - | meta, ok := sess.sessionMetaSnapshot() | |
| 85 | + | meta, ok := sess.sessionMetaSnapshotFor(ac) | |
| 71 | 86 | if !ok { | |
| 72 | 87 | return errSessionMetaUnavailable | |
| 73 | 88 | } | |
| @@ -76,7 +91,7 @@ func (ac *attachedClient) sendSessionMetaIfChanged(sess *session, expected trans | |||
| 76 | 91 | ||
| 77 | 92 | // sendSessionMetaSnapshot sends an already captured metadata snapshot while | |
| 78 | 93 | // the caller holds ac.sendMu. | |
| 79 | - | func (ac *attachedClient) sendSessionMetaSnapshot(meta ports.SessionMeta, expected transportSnapshot, ticket *roleEffectTicket) error { | |
| 94 | + | func (ac *attachedClient) sendSessionMetaSnapshot(meta ports.SessionMeta, expected transportSnapshot, ticket *attachmentEffectTicket) error { | |
| 80 | 95 | if ac == nil || !ac.proxied { | |
| 81 | 96 | return nil | |
| 82 | 97 | } | |
M internal/usecase/daemon/session_meta_test.go +21 -2
| @@ -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,12 +211,12 @@ 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 | ||
| 199 | 218 | token := sess.attachmentToken(ac, ac.transport()) | |
| 200 | - | require.False(t, d.handleActiveClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload})) | |
| 219 | + | require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload})) | |
| 201 | 220 | ||
| 202 | 221 | reply := awaitFrame(t, sends, ports.MsgCommandResult) | |
| 203 | 222 | result, err := ports.UnmarshalCommandResult(reply.Payload) | |
M internal/usecase/daemon/session_size_test.go +3 -3
| @@ -14,12 +14,12 @@ func TestFullViewportSize(t *testing.T) { | |||
| 14 | 14 | }{ | |
| 15 | 15 | { | |
| 16 | 16 | name: "derives from active tab content size", | |
| 17 | - | sess: &session{tabs: []*tab{{size: domain.Size{Cols: 120, Rows: 38}}}, active: 0}, | |
| 17 | + | sess: &session{tabs: []*tab{{size: domain.Size{Cols: 120, Rows: 38}}}}, | |
| 18 | 18 | want: domain.Size{Cols: 120, Rows: 40}, | |
| 19 | 19 | }, | |
| 20 | 20 | { | |
| 21 | 21 | name: "round-trips through tabSize", | |
| 22 | - | sess: &session{tabs: []*tab{{size: tabSize(domain.Size{Cols: 80, Rows: 24})}}, active: 0}, | |
| 22 | + | sess: &session{tabs: []*tab{{size: tabSize(domain.Size{Cols: 80, Rows: 24})}}}, | |
| 23 | 23 | want: domain.Size{Cols: 80, Rows: 24}, | |
| 24 | 24 | }, | |
| 25 | 25 | { | |
| @@ -29,7 +29,7 @@ func TestFullViewportSize(t *testing.T) { | |||
| 29 | 29 | }, | |
| 30 | 30 | { | |
| 31 | 31 | name: "falls back to defaultSize with invalid tab size", | |
| 32 | - | sess: &session{tabs: []*tab{{}}, active: 0}, | |
| 32 | + | sess: &session{tabs: []*tab{{}}}, | |
| 33 | 33 | want: defaultSize, | |
| 34 | 34 | }, | |
| 35 | 35 | } | |
M internal/usecase/daemon/session_test.go +56 -124
| @@ -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 ----------------------------------------------------------- | |
| @@ -32,55 +33,6 @@ func expectFloatingPrewarmOpen(factory *portsmocks.MockPTYFactory, normalSize do | |||
| 32 | 33 | // stubClock returns timers whose channel never fires, so a scheduler under it | |
| 33 | 34 | // blocks in its debounce loop until the session context is cancelled. Used by | |
| 34 | 35 | ||
| 35 | - | func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) { | |
| 36 | - | sz := domain.Size{Cols: 80, Rows: 24} | |
| 37 | - | first, releaseFirst := newBlockingPTY(t) | |
| 38 | - | defer releaseFirst() | |
| 39 | - | second, releaseSecond := newBlockingPTY(t) | |
| 40 | - | defer releaseSecond() | |
| 41 | - | f := portsmocks.NewMockPTYFactory(t) | |
| 42 | - | var dirs []string | |
| 43 | - | normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2} | |
| 44 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn( | |
| 45 | - | func(_ context.Context, _ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) { | |
| 46 | - | dirs = append(dirs, dir) | |
| 47 | - | if len(dirs) == 1 { | |
| 48 | - | return first, nil | |
| 49 | - | } | |
| 50 | - | return second, nil | |
| 51 | - | }, | |
| 52 | - | ).Twice() | |
| 53 | - | floating := newQuietPTY() | |
| 54 | - | expectFloatingPrewarmOpen(f, normalSize, floating) | |
| 55 | - | ||
| 56 | - | d := newTestDaemon(t, f, stubClock{}) | |
| 57 | - | tr := portsmocks.NewMockTransport(t) | |
| 58 | - | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |
| 59 | - | tr.EXPECT().Close().Return(nil).Maybe() | |
| 60 | - | ||
| 61 | - | sess, ac, err := d.route(ports.Hello{ | |
| 62 | - | Version: ports.ProtocolVersion, | |
| 63 | - | Intent: ports.IntentNew, | |
| 64 | - | Name: "work", | |
| 65 | - | Size: sz, | |
| 66 | - | TermEnv: "xterm-256color", | |
| 67 | - | Cwd: "/tmp/work", | |
| 68 | - | }, tr) | |
| 69 | - | require.NoError(t, err) | |
| 70 | - | require.NotNil(t, ac) | |
| 71 | - | require.Equal(t, "/tmp/work", sess.cwd) | |
| 72 | - | ||
| 73 | - | require.NoError(t, d.createTab(sess, sz)) | |
| 74 | - | require.Equal(t, []string{"/tmp/work", "/tmp/work"}, dirs) | |
| 75 | - | requireFloatingInitialized(t, sess.activeTab()) | |
| 76 | - | _ = d.killSession(sess, ports.ReasonSessionKilled, false) | |
| 77 | - | releaseFirst() | |
| 78 | - | releaseSecond() | |
| 79 | - | d.sessWg.Wait() | |
| 80 | - | // Teardown may cancel a queued prewarm before it enters Open; an opened | |
| 81 | - | // prewarm is closed by teardownFloating. | |
| 82 | - | } | |
| 83 | - | ||
| 84 | 36 | func TestHandshakeEphemeralHappy(t *testing.T) { | |
| 85 | 37 | p, releasePTY := newBlockingPTY(t) | |
| 86 | 38 | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| @@ -200,6 +152,53 @@ func TestKillAllEmptyDaemonSignalsShutdown(t *testing.T) { | |||
| 200 | 152 | } | |
| 201 | 153 | } | |
| 202 | 154 | ||
| 155 | + | func TestCreateTabForAttachmentUsesRequestingAttachmentViewportAndTheme(t *testing.T) { | |
| 156 | + | p, release := newBlockingPTY(t) | |
| 157 | + | defer release() | |
| 158 | + | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 159 | + | firstTransport := &closeTrackingTransport{} | |
| 160 | + | sess, first, err := d.route(ports.Hello{ | |
| 161 | + | Version: ports.ProtocolVersion, | |
| 162 | + | Intent: ports.IntentNew, | |
| 163 | + | Name: "work", | |
| 164 | + | ClientID: [16]byte{1}, | |
| 165 | + | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 166 | + | }, firstTransport) | |
| 167 | + | require.NoError(t, err) | |
| 168 | + | secondTransport := &closeTrackingTransport{} | |
| 169 | + | _, second, err := d.route(ports.Hello{ | |
| 170 | + | Version: ports.ProtocolVersion, | |
| 171 | + | Intent: ports.IntentAttach, | |
| 172 | + | Name: "work", | |
| 173 | + | ClientID: [16]byte{2}, | |
| 174 | + | Proxied: true, | |
| 175 | + | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 176 | + | }, secondTransport) | |
| 177 | + | require.NoError(t, err) | |
| 178 | + | require.NotSame(t, first, second) | |
| 179 | + | ||
| 180 | + | requesterTheme := themeui.Theme{ | |
| 181 | + | Foreground: themeui.BuiltinLight.Foreground, | |
| 182 | + | Background: themeui.BuiltinLight.Background, | |
| 183 | + | HasFG: true, | |
| 184 | + | HasBG: true, | |
| 185 | + | Known: true, | |
| 186 | + | } | |
| 187 | + | second.setClientTheme(requesterTheme) | |
| 188 | + | require.NoError(t, d.createTabForAttachment(sess, second, domain.Size{Cols: 80, Rows: 24})) | |
| 189 | + | ||
| 190 | + | tb := sess.tabs[len(sess.tabs)-1] | |
| 191 | + | tb.mu.Lock() | |
| 192 | + | gotSize := tb.size | |
| 193 | + | pane := tb.focusedPane() | |
| 194 | + | tb.mu.Unlock() | |
| 195 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 24}, gotSize, "proxied sizing must come from the requesting attachment") | |
| 196 | + | assertPaneDefaultColors(t, pane, requesterTheme.Foreground, requesterTheme.Background) | |
| 197 | + | ||
| 198 | + | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |
| 199 | + | d.sessWg.Wait() | |
| 200 | + | } | |
| 201 | + | ||
| 203 | 202 | func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) { | |
| 204 | 203 | p1, release1 := newBlockingPTY(t) | |
| 205 | 204 | defer release1() | |
| @@ -283,53 +282,6 @@ func TestEphemeralNumberingReuse(t *testing.T) { | |||
| 283 | 282 | ||
| 284 | 283 | // --- attach replace --------------------------------------------------------- | |
| 285 | 284 | ||
| 286 | - | func TestAttachReplaceKeepsOldClientSnatched(t *testing.T) { | |
| 287 | - | p, releasePTY := newBlockingPTY(t) | |
| 288 | - | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 289 | - | ||
| 290 | - | // Client A creates and attaches to ephemeral "0". | |
| 291 | - | trA, sendsA, releaseA := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24})) | |
| 292 | - | var hg sync.WaitGroup | |
| 293 | - | hg.Go(func() { d.handleConn(trA) }) | |
| 294 | - | awaitFrame(t, sendsA, ports.MsgWelcome) | |
| 295 | - | awaitFrame(t, sendsA, ports.MsgOutput) | |
| 296 | - | sess := firstSession(d) | |
| 297 | - | require.NotNil(t, sess) | |
| 298 | - | sess.mu.Lock() | |
| 299 | - | acA := sess.client | |
| 300 | - | sess.mu.Unlock() | |
| 301 | - | require.NotNil(t, acA) | |
| 302 | - | // The transport observes Send before the render transaction releases sendMu. | |
| 303 | - | // Wait for that contract boundary before replacing A; otherwise the test can | |
| 304 | - | // intentionally classify the in-flight first paint as an interrupted render. | |
| 305 | - | acA.sendMu.Lock() | |
| 306 | - | require.Same(t, trA, acA.transportSnapshot().transport) | |
| 307 | - | acA.sendMu.Unlock() | |
| 308 | - | ||
| 309 | - | // Client B attaches to the same session, displacing A. | |
| 310 | - | trB, sendsB, releaseB := newConn(t, mustHello(ports.IntentAttach, "0", domain.Size{Cols: 80, Rows: 24})) | |
| 311 | - | hg.Go(func() { d.handleConn(trB) }) | |
| 312 | - | awaitFrame(t, sendsB, ports.MsgWelcome) | |
| 313 | - | d.attachmentCleanupWg.Wait() | |
| 314 | - | ||
| 315 | - | // B is now the sole active client while A remains a live snatched member. | |
| 316 | - | sess.mu.Lock() | |
| 317 | - | require.NotNil(t, sess.client) | |
| 318 | - | require.NotSame(t, acA, sess.client) | |
| 319 | - | _, oldSnatched := sess.snatched[acA] | |
| 320 | - | sess.mu.Unlock() | |
| 321 | - | require.True(t, oldSnatched) | |
| 322 | - | require.Same(t, sess, acA.currentSession()) | |
| 323 | - | ||
| 324 | - | releaseA() | |
| 325 | - | releaseB() | |
| 326 | - | _ = d.killSession(sess, ports.ReasonServerShutdown, false) | |
| 327 | - | releasePTY() | |
| 328 | - | hg.Wait() | |
| 329 | - | d.sessWg.Wait() | |
| 330 | - | d.waitNotifies() | |
| 331 | - | } | |
| 332 | - | ||
| 333 | 285 | // --- detach semantics ------------------------------------------------------- | |
| 334 | 286 | ||
| 335 | 287 | func TestDetachKeepsEphemeralHeadless(t *testing.T) { | |
| @@ -351,7 +303,7 @@ func TestDetachKeepsEphemeralHeadless(t *testing.T) { | |||
| 351 | 303 | require.NotNil(t, sess) | |
| 352 | 304 | sess.mu.Lock() | |
| 353 | 305 | require.True(t, sess.ephemeral) | |
| 354 | - | require.Nil(t, sess.client, "ephemeral session is headless after detach") | |
| 306 | + | require.Empty(t, sess.snapshotAttachmentsLocked(), "ephemeral session is headless after detach") | |
| 355 | 307 | sess.mu.Unlock() | |
| 356 | 308 | ||
| 357 | 309 | _ = d.killSession(sess, ports.ReasonServerShutdown, false) | |
| @@ -398,7 +350,7 @@ func TestDetachKeepsNamed(t *testing.T) { | |||
| 398 | 350 | require.Equal(t, 1, sessionCount(d), "named session survives detach") | |
| 399 | 351 | sess := firstSession(d) | |
| 400 | 352 | sess.mu.Lock() | |
| 401 | - | require.Nil(t, sess.client, "named session is headless after detach") | |
| 353 | + | require.Empty(t, sess.snapshotAttachmentsLocked(), "named session is headless after detach") | |
| 402 | 354 | sess.mu.Unlock() | |
| 403 | 355 | ||
| 404 | 356 | _ = d.killSession(sess, ports.ReasonServerShutdown, false) | |
| @@ -1035,29 +987,6 @@ func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) { | |||
| 1035 | 987 | } | |
| 1036 | 988 | } | |
| 1037 | 989 | ||
| 1038 | - | func TestCloseActiveTabActivatesDestinationFloatingPane(t *testing.T) { | |
| 1039 | - | d, sess, ac, _, releases := newManualTabSession(t, 2) | |
| 1040 | - | sess.mu.Lock() | |
| 1041 | - | sess.client = ac | |
| 1042 | - | sess.mu.Unlock() | |
| 1043 | - | d.ptys = newBlockingOpenFactory(t, d) | |
| 1044 | - | defer releases[0]() | |
| 1045 | - | defer releases[1]() | |
| 1046 | - | sess.mu.Lock() | |
| 1047 | - | first, closing := sess.tabs[0], sess.tabs[1] | |
| 1048 | - | sess.active = 1 | |
| 1049 | - | sess.mu.Unlock() | |
| 1050 | - | first.mu.Lock() | |
| 1051 | - | stale := first.takeFloatingLocked() | |
| 1052 | - | first.mu.Unlock() | |
| 1053 | - | closeFloatingPane(stale) | |
| 1054 | - | ||
| 1055 | - | require.NoError(t, d.closeTab(sess, closing, false)) | |
| 1056 | - | ||
| 1057 | - | require.Same(t, first, sess.activeTab()) | |
| 1058 | - | requireFloatingInitialized(t, first) | |
| 1059 | - | } | |
| 1060 | - | ||
| 1061 | 990 | func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) { | |
| 1062 | 991 | sz := domain.Size{Cols: 80, Rows: 24} | |
| 1063 | 992 | p, release := newBlockingPTY(t) | |
| @@ -1619,13 +1548,16 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) { | |||
| 1619 | 1548 | got := ac.currentSession() | |
| 1620 | 1549 | require.NotNil(t, got) | |
| 1621 | 1550 | sourceCoordinator.mu.Lock() | |
| 1622 | - | require.NotNil(t, sourceCoordinator.lease) | |
| 1623 | - | require.False(t, sourceCoordinator.lease.active, "source callbacks must be stale after handoff") | |
| 1551 | + | require.Empty(t, sourceCoordinator.attachments, "source callbacks must be stale after handoff") | |
| 1552 | + | require.Empty(t, sourceCoordinator.leases, "source leases must be retired after handoff") | |
| 1624 | 1553 | sourceCoordinator.mu.Unlock() | |
| 1625 | 1554 | destinationCoordinator := got.renderCoordinator() | |
| 1626 | 1555 | require.NotNil(t, destinationCoordinator, "destination coordinator precedes first paint") | |
| 1627 | 1556 | destinationCoordinator.mu.Lock() | |
| 1628 | - | require.Same(t, ac, destinationCoordinator.lease.attachment) | |
| 1557 | + | require.Contains(t, destinationCoordinator.attachments, ac) | |
| 1558 | + | lease := destinationCoordinator.leases[ac] | |
| 1559 | + | require.NotNil(t, lease) | |
| 1560 | + | require.Same(t, ac, lease.attachment) | |
| 1629 | 1561 | destinationCoordinator.mu.Unlock() | |
| 1630 | 1562 | ac.sendMu.Lock() | |
| 1631 | 1563 | require.Equal(t, uint64(1), ac.output.next-ac.output.acked, "only the destination first paint may follow the rebase") | |
M internal/usecase/daemon/session_view.go +8 -5
| @@ -34,7 +34,7 @@ type sessionView struct { | |||
| 34 | 34 | name string | |
| 35 | 35 | ephemeral bool | |
| 36 | 36 | createdAt int64 | |
| 37 | - | active int | |
| 37 | + | defaultTab int | |
| 38 | 38 | mruAt uint64 | |
| 39 | 39 | attached bool | |
| 40 | 40 | tabCount int | |
| @@ -53,16 +53,18 @@ func (s *session) snapshotView(opts viewOptions) sessionView { | |||
| 53 | 53 | opts.tabDetails = true | |
| 54 | 54 | } | |
| 55 | 55 | s.mu.Lock() | |
| 56 | - | defer s.mu.Unlock() | |
| 56 | + | // Session-level listings use the first ordered tab as their deterministic | |
| 57 | + | // default. Interactive callers pass an attachment and resolve its stable | |
| 58 | + | // view separately. | |
| 57 | 59 | view := sessionView{ | |
| 58 | 60 | id: s.id, | |
| 59 | 61 | incarnation: s.incarnation, | |
| 60 | 62 | name: s.name, | |
| 61 | 63 | ephemeral: s.ephemeral, | |
| 62 | 64 | createdAt: s.createdAt, | |
| 63 | - | active: s.active, | |
| 65 | + | defaultTab: 0, | |
| 64 | 66 | mruAt: s.mruAt.Load(), | |
| 65 | - | attached: s.client != nil, | |
| 67 | + | attached: len(s.attachments) != 0, | |
| 66 | 68 | tabCount: len(s.tabs), | |
| 67 | 69 | cannotAcceptMoves: s.capabilities().cannotAcceptMoves, | |
| 68 | 70 | } | |
| @@ -90,6 +92,7 @@ func (s *session) snapshotView(opts viewOptions) sessionView { | |||
| 90 | 92 | } | |
| 91 | 93 | view.tabs = append(view.tabs, entry) | |
| 92 | 94 | } | |
| 95 | + | s.mu.Unlock() | |
| 93 | 96 | return view | |
| 94 | 97 | } | |
| 95 | 98 | ||
| @@ -101,7 +104,7 @@ func (view sessionView) pickerView() picker.SessionView { | |||
| 101 | 104 | Incarnation: view.incarnation, | |
| 102 | 105 | Name: view.name, | |
| 103 | 106 | TargetName: view.name, | |
| 104 | - | Active: view.active, | |
| 107 | + | Active: view.defaultTab, | |
| 105 | 108 | Tabs: make([]picker.TabEntry, 0, len(view.tabs)), | |
| 106 | 109 | CannotAcceptMoves: view.cannotAcceptMoves, | |
| 107 | 110 | } | |
M internal/usecase/daemon/session_view_test.go +1 -2
| @@ -22,7 +22,7 @@ func TestSnapshotViewCapturesSessionFields(t *testing.T) { | |||
| 22 | 22 | name: "alpha", | |
| 23 | 23 | createdAt: 42, | |
| 24 | 24 | ||
| 25 | - | client: &attachedClient{}}, active: 1, | |
| 25 | + | attachments: map[*attachedClient]struct{}{&attachedClient{}: {}}}, | |
| 26 | 26 | ||
| 27 | 27 | tabs: []*tab{ | |
| 28 | 28 | {stableID: "t1", name: "build"}, | |
| @@ -38,7 +38,6 @@ func TestSnapshotViewCapturesSessionFields(t *testing.T) { | |||
| 38 | 38 | incarnation: domain.IncarnationID{1}, | |
| 39 | 39 | name: "alpha", | |
| 40 | 40 | createdAt: 42, | |
| 41 | - | active: 1, | |
| 42 | 41 | mruAt: 7, | |
| 43 | 42 | attached: true, | |
| 44 | 43 | tabCount: 2, | |
M internal/usecase/daemon/snapshot.go +1 -1
| @@ -42,7 +42,7 @@ type snapshotCapture struct { | |||
| 42 | 42 | name string | |
| 43 | 43 | incarnation domain.IncarnationID | |
| 44 | 44 | createdAt uint64 | |
| 45 | - | active uint16 | |
| 45 | + | defaultTab uint16 | |
| 46 | 46 | tabs []snapshotCaptureTab | |
| 47 | 47 | publicationContext context.Context | |
| 48 | 48 | sealedRefs map[*vt.HistoryChunk]snapcodec.ObjectRef // set by the single encoder worker | |
M internal/usecase/daemon/snapshot_capture.go +3 -1
| @@ -13,7 +13,9 @@ func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapsh | |||
| 13 | 13 | name: sess.name, | |
| 14 | 14 | incarnation: sess.incarnation, | |
| 15 | 15 | createdAt: uint64(sess.createdAt), | |
| 16 | - | active: uint16(max(sess.active, 0)), | |
| 16 | + | // Snapshot format retains a deterministic first-tab default; it is not | |
| 17 | + | // an interactive attachment selection. | |
| 18 | + | defaultTab: 0, | |
| 17 | 19 | } | |
| 18 | 20 | ephemeral := sess.ephemeral | |
| 19 | 21 | fallbackCwd := sess.cwd | |
M internal/usecase/daemon/snapshot_incremental.go +1 -1
| @@ -114,7 +114,7 @@ func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.Snapsho | |||
| 114 | 114 | if err := prepareSnapshotChunkCache(capture); err != nil { | |
| 115 | 115 | return ports.SnapshotPublication{}, err | |
| 116 | 116 | } | |
| 117 | - | manifest := snapcodec.Manifest{Generation: capture.generation, IncarnationID: capture.incarnation, ParentCheckpoint: capture.parentCheckpoint, Name: capture.name, CreatedAt: capture.createdAt, Active: capture.active, Tabs: make([]snapcodec.ManifestTab, 0, len(capture.tabs))} | |
| 117 | + | manifest := snapcodec.Manifest{Generation: capture.generation, IncarnationID: capture.incarnation, ParentCheckpoint: capture.parentCheckpoint, Name: capture.name, CreatedAt: capture.createdAt, Active: capture.defaultTab, Tabs: make([]snapcodec.ManifestTab, 0, len(capture.tabs))} | |
| 118 | 118 | objects := make([]ports.SnapshotObject, 0) | |
| 119 | 119 | capture.sealedRefs = make(map[*vt.HistoryChunk]snapcodec.ObjectRef) | |
| 120 | 120 | for _, tab := range capture.tabs { | |
M internal/usecase/daemon/snapshot_restore.go +3 -1
| @@ -290,7 +290,9 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable | |||
| 290 | 290 | } | |
| 291 | 291 | ||
| 292 | 292 | func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab, term terminalEnv) *session { | |
| 293 | - | sess := &session{sessionCore: sessionCore{name: snap.Name, createdAt: int64(snap.CreatedAt)}, ctx: sctx, cancel: cancel, tabs: tabs, active: int(snap.Active), terminal: term, env: copyEnvironment(d.baseEnv), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)} | |
| 293 | + | // Restored sessions keep ordered tabs; attachment repair deterministically | |
| 294 | + | // selects the first tab instead of restoring an interactive client view. | |
| 295 | + | sess := &session{sessionCore: sessionCore{name: snap.Name, createdAt: int64(snap.CreatedAt)}, ctx: sctx, cancel: cancel, tabs: tabs, terminal: term, env: copyEnvironment(d.baseEnv), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)} | |
| 294 | 296 | // Restored tabs remain private until persistAndRegisterRestoredSession. | |
| 295 | 297 | // Initialize owners now so registration and reader startup cannot expose an | |
| 296 | 298 | // ownerless pane. | |
M internal/usecase/daemon/snapshot_restore_test.go +1 -1
| @@ -233,7 +233,7 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) { | |||
| 233 | 233 | require.False(t, restored.snapDirty.Load(), "a loaded generation must begin clean") | |
| 234 | 234 | require.Equal(t, uint64(9), restored.snapshotPublishedGeneration, "the loaded manifest is the repository generation head") | |
| 235 | 235 | require.True(t, restored.terminal.TrueColor, "restored sessions must retain startup terminal capability for future panes") | |
| 236 | - | require.Equal(t, 0, restored.active) | |
| 236 | + | require.Equal(t, 0, testAttachmentTabIndex(restored)) | |
| 237 | 237 | require.Equal(t, "/snapshot/cwd", restored.cwd) | |
| 238 | 238 | require.Len(t, restored.tabs, 1) | |
| 239 | 239 | tab := restored.tabs[0] | |
M internal/usecase/daemon/snapshot_resume_publication_test.go +2 -61
| @@ -141,65 +141,6 @@ func cloneCheckpointRef(ref *domain.CheckpointRef) *domain.CheckpointRef { | |||
| 141 | 141 | return &clone | |
| 142 | 142 | } | |
| 143 | 143 | ||
| 144 | - | func TestNamedSessionPublishesNextCheckpointAfterClientSnatch(t *testing.T) { | |
| 145 | - | pty, release := newBlockingPTY(t) | |
| 146 | - | repository := newSnapshotLifecycleRepository(t) | |
| 147 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 148 | - | WithSnapshotRepository(repository)(d) | |
| 149 | - | store, _ := newMockStore(t) | |
| 150 | - | WithStore(t, store)(d) | |
| 151 | - | startSnapshotEncodeWorker(t, d) | |
| 152 | - | t.Cleanup(func() { | |
| 153 | - | d.snapsEnabled = false | |
| 154 | - | release() | |
| 155 | - | d.sessWg.Wait() | |
| 156 | - | }) | |
| 157 | - | ||
| 158 | - | firstTransport := &closeTrackingTransport{} | |
| 159 | - | sess, firstClient, err := d.route(snapshotLifecycleHello(ports.IntentNew, "work"), firstTransport) | |
| 160 | - | require.NoError(t, err) | |
| 161 | - | ||
| 162 | - | sess.activeTab().focusedPane().screen.Write([]byte("first checkpoint")) | |
| 163 | - | markSnapshotDirty(sess) | |
| 164 | - | require.True(t, d.scheduleSnapshot(sess)) | |
| 165 | - | awaitSnapshotIdle(t, sess) | |
| 166 | - | d.snapshotNoticeMu.Lock() | |
| 167 | - | firstFailure := d.snapshotActiveFailureSignature | |
| 168 | - | d.snapshotNoticeMu.Unlock() | |
| 169 | - | attempts, publicationErrors := repository.results() | |
| 170 | - | require.Len(t, attempts, 1) | |
| 171 | - | require.NoError(t, publicationErrors[0]) | |
| 172 | - | require.Empty(t, firstFailure) | |
| 173 | - | require.False(t, sess.snapDirty.Load()) | |
| 174 | - | firstRecord := snapshotLifecycleRecord(t, d, sess.name) | |
| 175 | - | require.NotNil(t, firstRecord.Committed) | |
| 176 | - | require.Equal(t, uint64(1), firstRecord.Committed.Generation) | |
| 177 | - | ||
| 178 | - | secondTransport := &closeTrackingTransport{} | |
| 179 | - | snatchedSession, secondClient, err := d.route(snapshotLifecycleHello(ports.IntentAttach, "work"), secondTransport) | |
| 180 | - | require.NoError(t, err) | |
| 181 | - | require.Same(t, sess, snatchedSession, "client snatch must retain the live session") | |
| 182 | - | require.NotSame(t, firstClient, secondClient, "client B must displace client A") | |
| 183 | - | ||
| 184 | - | sess.activeTab().focusedPane().screen.Write([]byte("second checkpoint")) | |
| 185 | - | markSnapshotDirty(sess) | |
| 186 | - | require.True(t, d.scheduleFinalSnapshot(sess)) | |
| 187 | - | awaitSnapshotIdle(t, sess) | |
| 188 | - | attempts, publicationErrors = repository.results() | |
| 189 | - | require.Len(t, attempts, 2) | |
| 190 | - | require.NoError(t, publicationErrors[1]) | |
| 191 | - | require.False(t, sess.snapDirty.Load()) | |
| 192 | - | ||
| 193 | - | secondRecord := snapshotLifecycleRecord(t, d, sess.name) | |
| 194 | - | require.NotNil(t, secondRecord.Committed) | |
| 195 | - | require.Equal(t, firstRecord.Committed.Generation+1, secondRecord.Committed.Generation, | |
| 196 | - | "client snatch must not reset or conflict with the repository generation") | |
| 197 | - | generation, err := repository.LoadCheckpoint(context.Background(), sess.incarnation, sess.name, *secondRecord.Committed) | |
| 198 | - | require.NoError(t, err) | |
| 199 | - | require.Equal(t, firstRecord.Committed, generation.ParentCheckpoint, | |
| 200 | - | "the post-snatch checkpoint must retain the committed parent") | |
| 201 | - | } | |
| 202 | - | ||
| 203 | 144 | func TestStoppedNamedSessionResumePublishesNextCheckpointInSameDaemon(t *testing.T) { | |
| 204 | 145 | keeperPTY, releaseKeeper := newBlockingPTY(t) | |
| 205 | 146 | firstPTY, releaseFirst := newBlockingPTY(t) | |
| @@ -223,7 +164,7 @@ func TestStoppedNamedSessionResumePublishesNextCheckpointInSameDaemon(t *testing | |||
| 223 | 164 | sess, _, err := d.route(snapshotLifecycleHello(ports.IntentNew, "work"), &closeTrackingTransport{}) | |
| 224 | 165 | require.NoError(t, err) | |
| 225 | 166 | ||
| 226 | - | sess.activeTab().focusedPane().screen.Write([]byte("first checkpoint")) | |
| 167 | + | testAttachmentTab(sess).focusedPane().screen.Write([]byte("first checkpoint")) | |
| 227 | 168 | markSnapshotDirty(sess) | |
| 228 | 169 | require.True(t, d.scheduleSnapshot(sess)) | |
| 229 | 170 | awaitSnapshotClean(t, sess) | |
| @@ -247,7 +188,7 @@ func TestStoppedNamedSessionResumePublishesNextCheckpointInSameDaemon(t *testing | |||
| 247 | 188 | require.NotSame(t, sess, resumed, "stopped-session attach must create a new live runtime") | |
| 248 | 189 | require.Equal(t, sess.incarnation, resumed.incarnation, "resume must retain durable incarnation authority") | |
| 249 | 190 | ||
| 250 | - | resumed.activeTab().focusedPane().screen.Write([]byte("checkpoint after resume")) | |
| 191 | + | testAttachmentTab(resumed).focusedPane().screen.Write([]byte("checkpoint after resume")) | |
| 251 | 192 | markSnapshotDirty(resumed) | |
| 252 | 193 | require.True(t, d.scheduleSnapshot(resumed)) | |
| 253 | 194 | awaitSnapshotIdle(t, resumed) | |
M internal/usecase/daemon/snapshot_test.go +1 -1
| @@ -89,7 +89,7 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin | |||
| 89 | 89 | p := tb.panes["pane-1"] | |
| 90 | 90 | p.screen.Write([]byte("hello")) | |
| 91 | 91 | appendHistoryRow(t, p.history, []renderer.Cell{{Rune: 'h'}, {Rune: 'i'}}) | |
| 92 | - | sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, createdAt: 42}, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, active: 0, cwd: cwd} | |
| 92 | + | sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, createdAt: 42}, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, cwd: cwd} | |
| 93 | 93 | if !ephemeral { | |
| 94 | 94 | sess.incarnation = domain.IncarnationID{1} | |
| 95 | 95 | } | |
D internal/usecase/daemon/snatched_cleanup.go +0 -221
| @@ -1,221 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import "github.com/bnema/vev/internal/ports" | |
| 4 | - | ||
| 5 | - | // removeSnatchedAttachment removes exactly one role generation and transport | |
| 6 | - | // from session routing. It never touches the active attachment or closes a | |
| 7 | - | // transport, allowing callers that already interrupted a blocked send to avoid | |
| 8 | - | // closing the same link twice. | |
| 9 | - | func (d *Daemon) removeSnatchedAttachment(token attachmentRoleToken) bool { | |
| 10 | - | return d.unrouteSnatchedAttachment(token, true) | |
| 11 | - | } | |
| 12 | - | ||
| 13 | - | func (d *Daemon) unrouteSnatchedAttachment(token attachmentRoleToken, terminal bool) bool { | |
| 14 | - | if token.sess == nil || token.ac == nil || token.transport.transport == nil { | |
| 15 | - | return false | |
| 16 | - | } | |
| 17 | - | frozen := freezeRoleEffectGates(token.ac) | |
| 18 | - | defer frozen.unfreeze() | |
| 19 | - | d.notices.routingMu.Lock() | |
| 20 | - | core := token.sess.core() | |
| 21 | - | core.mu.Lock() | |
| 22 | - | current := attachmentSessionRoleLocked(token.sess, token.ac) == attachmentSnatched && | |
| 23 | - | token.ac.roleGeneration.Load() == token.generation && | |
| 24 | - | token.ac.transportSnapshotCurrent(token.transport) | |
| 25 | - | if current { | |
| 26 | - | delete(core.snatched, token.ac) | |
| 27 | - | token.ac.roleGeneration.Add(1) | |
| 28 | - | token.ac.invalidateFrozenRoleCapability() | |
| 29 | - | } | |
| 30 | - | core.mu.Unlock() | |
| 31 | - | d.notices.routingMu.Unlock() | |
| 32 | - | if !current { | |
| 33 | - | return false | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | token.ac.clearSnatchedInput() | |
| 37 | - | d.unregisterPreview(token.ac) | |
| 38 | - | if terminal { | |
| 39 | - | token.ac.clearPreviousSession() | |
| 40 | - | } | |
| 41 | - | token.ac.setSession(nil) | |
| 42 | - | token.ac.clearCaptureFrames() | |
| 43 | - | if terminal { | |
| 44 | - | // A losing park path may have published a same-attachment marker before | |
| 45 | - | // this terminal unroute won. Retire only that attachment's marker so | |
| 46 | - | // waiters fail closed instead of hanging; park paths (terminal=false) | |
| 47 | - | // keep the marker until parkAttachmentAs or abandonment cleanup. | |
| 48 | - | d.clearParkingInFlight(token.ac.resumeToken, token.ac) | |
| 49 | - | } | |
| 50 | - | return true | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | // parkSnatchedAttachment removes only the exact lost snatched incarnation and | |
| 54 | - | // retains its resume identity for a role-preserving reconnect. | |
| 55 | - | func (d *Daemon) parkSnatchedAttachment(token attachmentRoleToken) bool { | |
| 56 | - | sess, ok := localSession(token.sess) | |
| 57 | - | if !ok || token.ac == nil || !token.ac.resumeCapable { | |
| 58 | - | return false | |
| 59 | - | } | |
| 60 | - | // Advertise parking before unroute so IntentResume never observes both the | |
| 61 | - | // snatched seat and parking/parked registries empty for a still-valid token. | |
| 62 | - | parkingToken := d.markParkingInFlight(sess, token.ac) | |
| 63 | - | if !d.unrouteSnatchedAttachment(token, false) { | |
| 64 | - | d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken) | |
| 65 | - | return false | |
| 66 | - | } | |
| 67 | - | if d.afterSnatchedUnrouteBeforePark != nil { | |
| 68 | - | d.afterSnatchedUnrouteBeforePark() | |
| 69 | - | } | |
| 70 | - | if d.parkAttachmentAs(sess, token.ac, attachmentSnatched) { | |
| 71 | - | captured := token.transport.transport | |
| 72 | - | _ = token.ac.revokeTransport(captured) | |
| 73 | - | _ = token.ac.closeCapturedTransport(captured) | |
| 74 | - | return true | |
| 75 | - | } | |
| 76 | - | d.clearParkingInFlight(parkingToken, token.ac) | |
| 77 | - | token.ac.clearPreviousSession() | |
| 78 | - | captured := token.transport.transport | |
| 79 | - | _ = token.ac.revokeTransport(captured) | |
| 80 | - | _ = token.ac.closeCapturedTransport(captured) | |
| 81 | - | return false | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | func (d *Daemon) parkOrDropSnatchedAttachment(token attachmentRoleToken) bool { | |
| 85 | - | if d.parkSnatchedAttachment(token) { | |
| 86 | - | return true | |
| 87 | - | } | |
| 88 | - | return d.dropSnatchedAttachment(token) | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | // cleanupInterruptedSnatchedAttachment owns terminal cleanup for a displaced | |
| 92 | - | // transport that was closed to drain an admitted send. It waits on the gate's | |
| 93 | - | // condition rather than ordinary effect admission, then removes only the exact | |
| 94 | - | // published snatched incarnation. Attachment-local cleanup runs without routing | |
| 95 | - | // or session locks while the terminal capability remains frozen. | |
| 96 | - | func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken) bool { | |
| 97 | - | sess, ok := localSession(token.sess) | |
| 98 | - | if !ok || token.ac == nil || token.transport.transport == nil { | |
| 99 | - | return false | |
| 100 | - | } | |
| 101 | - | frozen := freezeRoleEffectGates(token.ac) | |
| 102 | - | defer frozen.unfreeze() | |
| 103 | - | ||
| 104 | - | d.notices.routingMu.Lock() | |
| 105 | - | sess.mu.Lock() | |
| 106 | - | current := attachmentSessionRoleLocked(token.sess, token.ac) == attachmentSnatched && | |
| 107 | - | token.ac.currentAttachmentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation && | |
| 108 | - | token.ac.transportSnapshotCurrent(token.transport) | |
| 109 | - | sess.mu.Unlock() | |
| 110 | - | d.notices.routingMu.Unlock() | |
| 111 | - | if !current { | |
| 112 | - | return false | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | // Advertise parking before ownership removal so IntentResume can wait out | |
| 116 | - | // the unroute→park gap for a still-valid snatched credential. | |
| 117 | - | var parkingToken uint64 | |
| 118 | - | if token.ac.resumeCapable { | |
| 119 | - | parkingToken = d.markParkingInFlight(sess, token.ac) | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | // The frozen gate prevents a promotion from publishing between overlay | |
| 123 | - | // cleanup and terminal registry publication. | |
| 124 | - | d.clearForSnatch(token) | |
| 125 | - | ||
| 126 | - | d.notices.routingMu.Lock() | |
| 127 | - | sess.mu.Lock() | |
| 128 | - | current = attachmentSessionRoleLocked(token.sess, token.ac) == attachmentSnatched && | |
| 129 | - | token.ac.currentAttachmentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation && | |
| 130 | - | token.ac.transportSnapshotCurrent(token.transport) | |
| 131 | - | if current { | |
| 132 | - | delete(sess.snatched, token.ac) | |
| 133 | - | token.ac.roleGeneration.Add(1) | |
| 134 | - | token.ac.invalidateFrozenRoleCapability() | |
| 135 | - | } | |
| 136 | - | sess.mu.Unlock() | |
| 137 | - | d.notices.routingMu.Unlock() | |
| 138 | - | if !current { | |
| 139 | - | d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken) | |
| 140 | - | return false | |
| 141 | - | } | |
| 142 | - | ||
| 143 | - | token.ac.setSession(nil) | |
| 144 | - | if d.afterSnatchedUnrouteBeforePark != nil { | |
| 145 | - | d.afterSnatchedUnrouteBeforePark() | |
| 146 | - | } | |
| 147 | - | if token.ac.resumeCapable && d.parkAttachmentAs(sess, token.ac, attachmentSnatched) { | |
| 148 | - | captured := token.transport.transport | |
| 149 | - | _ = token.ac.revokeTransport(captured) | |
| 150 | - | _ = token.ac.closeCapturedTransport(captured) | |
| 151 | - | token.ac.clearSnatchedInput() | |
| 152 | - | d.unregisterPreview(token.ac) | |
| 153 | - | return true | |
| 154 | - | } | |
| 155 | - | d.clearParkingInFlight(parkingToken, token.ac) | |
| 156 | - | captured := token.transport.transport | |
| 157 | - | _ = token.ac.revokeTransport(captured) | |
| 158 | - | _ = token.ac.closeCapturedTransport(captured) | |
| 159 | - | token.ac.clearSnatchedInput() | |
| 160 | - | d.unregisterPreview(token.ac) | |
| 161 | - | token.ac.clearPreviousSession() | |
| 162 | - | token.ac.clearCaptureFrames() | |
| 163 | - | return true | |
| 164 | - | } | |
| 165 | - | ||
| 166 | - | // dropSnatchedAttachment removes and closes only the captured snatched link. | |
| 167 | - | func (d *Daemon) dropSnatchedAttachment(token attachmentRoleToken) bool { | |
| 168 | - | if !d.removeSnatchedAttachment(token) { | |
| 169 | - | return false | |
| 170 | - | } | |
| 171 | - | _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport)) | |
| 172 | - | return true | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | // deferAttachmentTransitionCleanups retires coordinator timers and accounting | |
| 176 | - | // after ownership publication. Cleanup never gates the attaching handshake. A | |
| 177 | - | // displaced render that already owns sendMu is interrupted by closing only its | |
| 178 | - | // captured transport; an idle healthy snatched transport remains connected and | |
| 179 | - | // receives a dependency-free reset panel. | |
| 180 | - | func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionResult) { | |
| 181 | - | if terminal := result.terminalDisplaced; terminal.ac != nil && terminal.transport.transport != nil { | |
| 182 | - | terminal.ac.clearSnatchedInput() | |
| 183 | - | d.unregisterPreview(terminal.ac) | |
| 184 | - | terminal.ac.clearPreviousSession() | |
| 185 | - | terminal.ac.clearCaptureFrames() | |
| 186 | - | d.notifyDetachedSnapshotAsync(terminal, ports.ReasonReplaced) | |
| 187 | - | } | |
| 188 | - | if token := result.displaced; token.ac != nil && token.transport.transport != nil { | |
| 189 | - | blockedRender := result.displacedInterrupted | |
| 190 | - | if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) { | |
| 191 | - | blockedRender = !token.ac.sendMu.TryLock() | |
| 192 | - | if !blockedRender { | |
| 193 | - | token.ac.sendMu.Unlock() | |
| 194 | - | } | |
| 195 | - | } | |
| 196 | - | d.attachmentCleanupWg.Go(func() { | |
| 197 | - | if d.afterDisplacedCleanupStarted != nil { | |
| 198 | - | d.afterDisplacedCleanupStarted() | |
| 199 | - | } | |
| 200 | - | if blockedRender { | |
| 201 | - | // This path owns terminal cleanup independently of ordinary role | |
| 202 | - | // admission: a stale send-error detach may transiently freeze the gate. | |
| 203 | - | _ = token.ac.closeCapturedTransport(token.transport.transport) | |
| 204 | - | d.cleanupInterruptedSnatchedAttachment(token) | |
| 205 | - | return | |
| 206 | - | } | |
| 207 | - | ticket, admitted := token.ac.beginRoleEffect(token) | |
| 208 | - | if !admitted { | |
| 209 | - | return | |
| 210 | - | } | |
| 211 | - | defer ticket.End() | |
| 212 | - | if err := d.sendInitialSnatchedPanel(token, ticket); err != nil { | |
| 213 | - | ticket.End() | |
| 214 | - | d.parkOrDropSnatchedAttachment(token) | |
| 215 | - | } | |
| 216 | - | }) | |
| 217 | - | } | |
| 218 | - | for _, cleanup := range result.cleanups { | |
| 219 | - | d.attachmentCleanupWg.Go(cleanup.finish) | |
| 220 | - | } | |
| 221 | - | } | |
D internal/usecase/daemon/snatched_client.go +0 -107
| @@ -1,107 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/vev/internal/ports" | |
| 7 | - | ) | |
| 8 | - | ||
| 9 | - | type initialSnatchedPanelAttempt struct { | |
| 10 | - | done chan struct{} | |
| 11 | - | err error | |
| 12 | - | } | |
| 13 | - | ||
| 14 | - | func (ac *attachedClient) claimInitialSnatchedPanel(generation uint64) (*initialSnatchedPanelAttempt, bool) { | |
| 15 | - | ac.initialSnatchedMu.Lock() | |
| 16 | - | defer ac.initialSnatchedMu.Unlock() | |
| 17 | - | if generation < ac.initialSnatchedGeneration { | |
| 18 | - | return nil, false | |
| 19 | - | } | |
| 20 | - | if generation == ac.initialSnatchedGeneration && ac.initialSnatchedAttempt != nil { | |
| 21 | - | return ac.initialSnatchedAttempt, false | |
| 22 | - | } | |
| 23 | - | attempt := &initialSnatchedPanelAttempt{done: make(chan struct{})} | |
| 24 | - | ac.initialSnatchedGeneration = generation | |
| 25 | - | ac.initialSnatchedAttempt = attempt | |
| 26 | - | return attempt, true | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | func (ac *attachedClient) initialSnatchedPanelClaimed(generation uint64) bool { | |
| 30 | - | ac.initialSnatchedMu.Lock() | |
| 31 | - | defer ac.initialSnatchedMu.Unlock() | |
| 32 | - | return ac.initialSnatchedGeneration == generation && ac.initialSnatchedAttempt != nil | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | func (ac *attachedClient) completeInitialSnatchedPanel(attempt *initialSnatchedPanelAttempt, err error) { | |
| 36 | - | ac.initialSnatchedMu.Lock() | |
| 37 | - | attempt.err = err | |
| 38 | - | close(attempt.done) | |
| 39 | - | ac.initialSnatchedMu.Unlock() | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | func (d *Daemon) sendInitialSnatchedPanel(token attachmentRoleToken, ticket *roleEffectTicket) error { | |
| 43 | - | attempt, owner := token.ac.claimInitialSnatchedPanel(token.generation) | |
| 44 | - | if attempt == nil { | |
| 45 | - | return errSnatchedOutputStale | |
| 46 | - | } | |
| 47 | - | if !owner { | |
| 48 | - | <-attempt.done | |
| 49 | - | return attempt.err | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | if !token.current() || !d.clearForSnatch(token) || !d.clearCaptureFramesForSnatch(token) { | |
| 53 | - | token.ac.completeInitialSnatchedPanel(attempt, errSnatchedOutputStale) | |
| 54 | - | return errSnatchedOutputStale | |
| 55 | - | } | |
| 56 | - | err := d.sendSnatchedPanel(token.ac, token.transport, token.generation, "", ticket) | |
| 57 | - | token.ac.completeInitialSnatchedPanel(attempt, err) | |
| 58 | - | return err | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | func (d *Daemon) clearCaptureFramesForSnatch(token attachmentRoleToken) bool { | |
| 62 | - | ac := token.ac | |
| 63 | - | ac.sendMu.Lock() | |
| 64 | - | defer ac.sendMu.Unlock() | |
| 65 | - | if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) { | |
| 66 | - | return false | |
| 67 | - | } | |
| 68 | - | ac.captureFrames = nil | |
| 69 | - | return true | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | func (d *Daemon) sendSnatchedControl(token attachmentRoleToken, frame ports.Frame) error { | |
| 73 | - | ac := token.ac | |
| 74 | - | ac.sendMu.Lock() | |
| 75 | - | defer ac.sendMu.Unlock() | |
| 76 | - | if ac.roleGeneration.Load() != token.generation { | |
| 77 | - | return errSnatchedOutputStale | |
| 78 | - | } | |
| 79 | - | if !ac.transportSnapshotCurrent(token.transport) { | |
| 80 | - | return errTransportReplaced | |
| 81 | - | } | |
| 82 | - | if token.effect == nil || !token.effect.beginTransportSend(token.transport) { | |
| 83 | - | return errAttachmentTransition | |
| 84 | - | } | |
| 85 | - | err := token.transport.transport.Send(frame) | |
| 86 | - | if err != nil { | |
| 87 | - | token.effect.reportTransportFailure(token.transport) | |
| 88 | - | } | |
| 89 | - | token.effect.endTransportSend() | |
| 90 | - | return err | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | // boundedSendSnatchedControl keeps a best-effort control acknowledgement bound | |
| 94 | - | // to the exact admitted role and link incarnation. On deadline it closes only | |
| 95 | - | // that captured link, releasing either the transport Send or a wait on sendMu. | |
| 96 | - | func (d *Daemon) boundedSendSnatchedControl(token attachmentRoleToken, frame ports.Frame) error { | |
| 97 | - | if token.ac == nil || token.effect == nil || token.transport.transport == nil { | |
| 98 | - | return errAttachmentTransition | |
| 99 | - | } | |
| 100 | - | _, err := d.boundedSendWith(token.transport.transport, func() error { | |
| 101 | - | return d.sendSnatchedControl(token, frame) | |
| 102 | - | }) | |
| 103 | - | if errors.Is(err, errSendTimedOut) { | |
| 104 | - | _ = token.ac.closeCapturedTransport(token.transport.transport) | |
| 105 | - | } | |
| 106 | - | return err | |
| 107 | - | } | |
D internal/usecase/daemon/snatched_client_test.go +0 -1145
| @@ -1,1145 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "errors" | |
| 6 | - | "io" | |
| 7 | - | "sync" | |
| 8 | - | "testing" | |
| 9 | - | "time" | |
| 10 | - | ||
| 11 | - | "github.com/stretchr/testify/require" | |
| 12 | - | ||
| 13 | - | "github.com/bnema/vev/internal/domain" | |
| 14 | - | "github.com/bnema/vev/internal/ports" | |
| 15 | - | "github.com/bnema/vev/internal/usecase/keys" | |
| 16 | - | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 17 | - | "github.com/bnema/vev/pkg/renderer" | |
| 18 | - | ) | |
| 19 | - | ||
| 20 | - | func TestSessionAttachmentRole(t *testing.T) { | |
| 21 | - | active := &attachedClient{} | |
| 22 | - | waiting := &attachedClient{} | |
| 23 | - | unknown := &attachedClient{} | |
| 24 | - | sess := &session{sessionCore: sessionCore{client: active, snatched: map[*attachedClient]struct{}{waiting: {}}}} | |
| 25 | - | ||
| 26 | - | require.Equal(t, attachmentActive, sess.attachmentRole(active)) | |
| 27 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting)) | |
| 28 | - | require.Equal(t, attachmentDetached, sess.attachmentRole(unknown)) | |
| 29 | - | } | |
| 30 | - | ||
| 31 | - | func TestTransitionAttachmentReplacesActiveMembership(t *testing.T) { | |
| 32 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 33 | - | sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}} | |
| 34 | - | oldTransport := &closeTrackingTransport{} | |
| 35 | - | old := &attachedClient{tr: oldTransport} | |
| 36 | - | old.setSession(sess) | |
| 37 | - | sess.client = old | |
| 38 | - | d.sessions[sess.id] = sess | |
| 39 | - | ||
| 40 | - | newTransport := &closeTrackingTransport{} | |
| 41 | - | next := &attachedClient{tr: newTransport} | |
| 42 | - | expected := next.transportSnapshot() | |
| 43 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 44 | - | target: sess, | |
| 45 | - | next: next, | |
| 46 | - | expectedRole: attachmentDetached, | |
| 47 | - | targetRole: attachmentActive, | |
| 48 | - | expectedTransport: expected, | |
| 49 | - | }) | |
| 50 | - | require.NoError(t, err) | |
| 51 | - | ||
| 52 | - | sess.mu.Lock() | |
| 53 | - | require.Same(t, next, sess.client) | |
| 54 | - | require.Contains(t, sess.snatched, old) | |
| 55 | - | _, nextSnatched := sess.snatched[next] | |
| 56 | - | require.False(t, nextSnatched) | |
| 57 | - | sess.mu.Unlock() | |
| 58 | - | require.Equal(t, attachmentActive, sess.attachmentRole(next)) | |
| 59 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(old)) | |
| 60 | - | require.True(t, result.published.current()) | |
| 61 | - | require.True(t, result.displaced.current()) | |
| 62 | - | require.Equal(t, uint64(1), result.published.generation) | |
| 63 | - | require.Equal(t, uint64(1), result.displaced.generation) | |
| 64 | - | require.Same(t, sess, next.currentSession()) | |
| 65 | - | require.Same(t, sess, old.currentSession()) | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | func TestDeferredAttachmentCleanupKeepsReplacementOpenAndSendsResetPanel(t *testing.T) { | |
| 69 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 70 | - | sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}} | |
| 71 | - | oldTransport := &closeTrackingTransport{} | |
| 72 | - | old := &attachedClient{ | |
| 73 | - | tr: oldTransport, | |
| 74 | - | output: newOutputStateStream(), | |
| 75 | - | size: domain.Size{Cols: 80, Rows: 24}, | |
| 76 | - | } | |
| 77 | - | old.initOverlays() | |
| 78 | - | old.setThemeForTest(themeui.Theme{}) | |
| 79 | - | old.setSession(sess) | |
| 80 | - | sess.client = old | |
| 81 | - | d.sessions[sess.id] = sess | |
| 82 | - | ||
| 83 | - | next := &attachedClient{tr: &closeTrackingTransport{}} | |
| 84 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 85 | - | target: sess, | |
| 86 | - | next: next, | |
| 87 | - | expectedRole: attachmentDetached, | |
| 88 | - | targetRole: attachmentActive, | |
| 89 | - | expectedTransport: next.transportSnapshot(), | |
| 90 | - | }) | |
| 91 | - | require.NoError(t, err) | |
| 92 | - | ||
| 93 | - | d.deferAttachmentTransitionCleanups(result) | |
| 94 | - | d.attachmentCleanupWg.Wait() | |
| 95 | - | ||
| 96 | - | require.False(t, oldTransport.Closed(), "snatched transport must stay open") | |
| 97 | - | frames := oldTransport.Sends() | |
| 98 | - | require.Len(t, frames, 1) | |
| 99 | - | require.Equal(t, ports.MsgOutput, frames[0].Type) | |
| 100 | - | output, err := ports.UnmarshalOutput(frames[0].Payload) | |
| 101 | - | require.NoError(t, err) | |
| 102 | - | require.Contains(t, string(output.Data), "Session snatched") | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | func TestActiveFrameRevalidatesExactRoleBeforeEveryEffect(t *testing.T) { | |
| 106 | - | tests := []struct { | |
| 107 | - | name string | |
| 108 | - | setup func(*attachedClient, *session, *transactionalResizePTY) | |
| 109 | - | frame func() ports.Frame | |
| 110 | - | assert func(*testing.T, *Daemon, *session, *attachedClient, *attachedClient, *transactionalResizePTY, *datagramTestTransport) | |
| 111 | - | }{ | |
| 112 | - | { | |
| 113 | - | name: "forwarded input", | |
| 114 | - | frame: func() ports.Frame { return frameInput([]byte("stale")) }, | |
| 115 | - | assert: func(t *testing.T, _ *Daemon, _ *session, _ *attachedClient, _ *attachedClient, pty *transactionalResizePTY, _ *datagramTestTransport) { | |
| 116 | - | require.Empty(t, pty.writes(), "stale input reached the PTY") | |
| 117 | - | }, | |
| 118 | - | }, | |
| 119 | - | { | |
| 120 | - | name: "key action", | |
| 121 | - | frame: func() ports.Frame { return frameInput([]byte("\x1b ")) }, | |
| 122 | - | assert: func(t *testing.T, _ *Daemon, _ *session, old, next *attachedClient, _ *transactionalResizePTY, _ *datagramTestTransport) { | |
| 123 | - | require.False(t, old.overlays.paletteActive(), "stale key action mutated the displaced overlay") | |
| 124 | - | require.False(t, next.overlays.paletteActive(), "stale key action mutated the active overlay") | |
| 125 | - | }, | |
| 126 | - | }, | |
| 127 | - | { | |
| 128 | - | name: "mouse input", | |
| 129 | - | setup: func(_ *attachedClient, sess *session, _ *transactionalResizePTY) { | |
| 130 | - | pane := sess.activeTab().focusedPane() | |
| 131 | - | pane.mu.Lock() | |
| 132 | - | pane.screen.Write([]byte("\x1b[?1000h")) | |
| 133 | - | pane.mu.Unlock() | |
| 134 | - | }, | |
| 135 | - | frame: func() ports.Frame { return frameInput([]byte("\x1b[<0;1;2M")) }, | |
| 136 | - | assert: func(t *testing.T, _ *Daemon, _ *session, _ *attachedClient, _ *attachedClient, pty *transactionalResizePTY, _ *datagramTestTransport) { | |
| 137 | - | require.Empty(t, pty.writes(), "stale mouse input reached the PTY") | |
| 138 | - | }, | |
| 139 | - | }, | |
| 140 | - | { | |
| 141 | - | name: "image push", | |
| 142 | - | frame: func() ports.Frame { | |
| 143 | - | return ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{Mime: "image/png", Data: []byte("stale-image")})} | |
| 144 | - | }, | |
| 145 | - | assert: func(t *testing.T, _ *Daemon, sess *session, _ *attachedClient, _ *attachedClient, pty *transactionalResizePTY, _ *datagramTestTransport) { | |
| 146 | - | sess.mu.Lock() | |
| 147 | - | files := append([]string(nil), sess.clipFiles...) | |
| 148 | - | sess.mu.Unlock() | |
| 149 | - | require.Empty(t, files, "stale image push created a session clipboard file") | |
| 150 | - | require.Empty(t, pty.writes(), "stale image path reached the PTY") | |
| 151 | - | }, | |
| 152 | - | }, | |
| 153 | - | { | |
| 154 | - | name: "resize", | |
| 155 | - | frame: func() ports.Frame { | |
| 156 | - | return ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 120, Rows: 40}})} | |
| 157 | - | }, | |
| 158 | - | assert: func(t *testing.T, _ *Daemon, sess *session, old, _ *attachedClient, pty *transactionalResizePTY, _ *datagramTestTransport) { | |
| 159 | - | require.Empty(t, pty.requested(), "stale resize reached a session PTY") | |
| 160 | - | require.Nil(t, sess.renderCoordinator().attachmentLease(old), "stale resize rebound the coordinator to the displaced client") | |
| 161 | - | }, | |
| 162 | - | }, | |
| 163 | - | { | |
| 164 | - | name: "theme", | |
| 165 | - | frame: func() ports.Frame { | |
| 166 | - | return ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(ports.Theme{ | |
| 167 | - | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 168 | - | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 169 | - | })} | |
| 170 | - | }, | |
| 171 | - | assert: func(t *testing.T, _ *Daemon, _ *session, old, _ *attachedClient, _ *transactionalResizePTY, _ *datagramTestTransport) { | |
| 172 | - | require.False(t, old.getClientTheme().HasFG, "stale theme mutated the displaced client") | |
| 173 | - | }, | |
| 174 | - | }, | |
| 175 | - | { | |
| 176 | - | name: "client notice", | |
| 177 | - | frame: func() ports.Frame { | |
| 178 | - | return ports.Frame{Type: ports.MsgClientNotice, Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: ports.ClientNoticeClipboardFallback})} | |
| 179 | - | }, | |
| 180 | - | assert: func(t *testing.T, d *Daemon, _ *session, old, next *attachedClient, _ *transactionalResizePTY, _ *datagramTestTransport) { | |
| 181 | - | require.Empty(t, d.notices.history(), "stale notice mutated daemon history") | |
| 182 | - | oldToasts, _ := visibleToasts(old) | |
| 183 | - | nextToasts, _ := visibleToasts(next) | |
| 184 | - | require.Empty(t, oldToasts) | |
| 185 | - | require.Empty(t, nextToasts) | |
| 186 | - | }, | |
| 187 | - | }, | |
| 188 | - | { | |
| 189 | - | name: "ack", | |
| 190 | - | setup: func(old *attachedClient, _ *session, _ *transactionalResizePTY) { | |
| 191 | - | old.output.next = 2 | |
| 192 | - | }, | |
| 193 | - | frame: func() ports.Frame { | |
| 194 | - | return ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: 1})} | |
| 195 | - | }, | |
| 196 | - | assert: func(t *testing.T, _ *Daemon, _ *session, old, _ *attachedClient, _ *transactionalResizePTY, _ *datagramTestTransport) { | |
| 197 | - | require.Equal(t, uint64(2), old.output.outstanding(), "stale ack retired displaced output state") | |
| 198 | - | }, | |
| 199 | - | }, | |
| 200 | - | { | |
| 201 | - | name: "ping", | |
| 202 | - | frame: func() ports.Frame { return ports.Frame{Type: ports.MsgPing} }, | |
| 203 | - | assert: func(t *testing.T, _ *Daemon, _ *session, _ *attachedClient, _ *attachedClient, _ *transactionalResizePTY, tr *datagramTestTransport) { | |
| 204 | - | select { | |
| 205 | - | case frame := <-tr.sends: | |
| 206 | - | t.Fatalf("stale ping wrote frame type %d", frame.Type) | |
| 207 | - | default: | |
| 208 | - | } | |
| 209 | - | }, | |
| 210 | - | }, | |
| 211 | - | } | |
| 212 | - | ||
| 213 | - | for _, tt := range tests { | |
| 214 | - | t.Run(tt.name, func(t *testing.T) { | |
| 215 | - | pty := &transactionalResizePTY{} | |
| 216 | - | d, sess, old, _ := newManualSessionWithPTYs(t, pty) | |
| 217 | - | d.tempDir = t.TempDir() | |
| 218 | - | oldTransport := newDatagramTestTransport() | |
| 219 | - | old.replaceTransport(oldTransport) | |
| 220 | - | old.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: old}, &d.bindings) | |
| 221 | - | rc := d.attachCoordinator(sess, nil, old, true) | |
| 222 | - | token := sess.attachmentToken(old, oldTransport) | |
| 223 | - | token.lease = rc.attachmentLease(old) | |
| 224 | - | require.True(t, token.activeCurrent()) | |
| 225 | - | if tt.setup != nil { | |
| 226 | - | tt.setup(old, sess, pty) | |
| 227 | - | } | |
| 228 | - | ||
| 229 | - | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 230 | - | next.initOverlays() | |
| 231 | - | var transition attachmentTransitionResult | |
| 232 | - | d.afterActiveFrameDispatch = func(attachmentRoleToken) { | |
| 233 | - | d.afterActiveFrameDispatch = nil | |
| 234 | - | var err error | |
| 235 | - | transition, err = d.transitionAttachment(attachmentTransitionRequest{ | |
| 236 | - | target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 237 | - | expectedTransport: next.transportSnapshot(), ready: true, | |
| 238 | - | }) | |
| 239 | - | require.NoError(t, err) | |
| 240 | - | } | |
| 241 | - | ||
| 242 | - | d.handleActiveClientFrame(token, tt.frame()) | |
| 243 | - | for _, cleanup := range transition.cleanups { | |
| 244 | - | cleanup.finish() | |
| 245 | - | } | |
| 246 | - | tt.assert(t, d, sess, old, next, pty, oldTransport) | |
| 247 | - | }) | |
| 248 | - | } | |
| 249 | - | } | |
| 250 | - | ||
| 251 | - | func TestKillSessionClosesActiveAndAllSnatchedClients(t *testing.T) { | |
| 252 | - | pty := &transactionalResizePTY{} | |
| 253 | - | d, sess, first, _ := newManualSessionWithPTYs(t, pty) | |
| 254 | - | firstTransport := newDatagramTestTransport() | |
| 255 | - | first.replaceTransport(firstTransport) | |
| 256 | - | ||
| 257 | - | secondTransport := newDatagramTestTransport() | |
| 258 | - | second := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: first.size} | |
| 259 | - | second.initOverlays() | |
| 260 | - | firstReplacement, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 261 | - | target: sess, next: second, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 262 | - | expectedTransport: second.transportSnapshot(), ready: true, | |
| 263 | - | }) | |
| 264 | - | require.NoError(t, err) | |
| 265 | - | for _, cleanup := range firstReplacement.cleanups { | |
| 266 | - | cleanup.finish() | |
| 267 | - | } | |
| 268 | - | ||
| 269 | - | thirdTransport := newDatagramTestTransport() | |
| 270 | - | third := &attachedClient{tr: thirdTransport, output: newOutputStateStream(), size: first.size} | |
| 271 | - | third.initOverlays() | |
| 272 | - | secondReplacement, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 273 | - | target: sess, next: third, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 274 | - | expectedTransport: third.transportSnapshot(), ready: true, | |
| 275 | - | }) | |
| 276 | - | require.NoError(t, err) | |
| 277 | - | for _, cleanup := range secondReplacement.cleanups { | |
| 278 | - | cleanup.finish() | |
| 279 | - | } | |
| 280 | - | ||
| 281 | - | otherCtx, otherCancel := context.WithCancel(d.serveCtx) | |
| 282 | - | defer otherCancel() | |
| 283 | - | otherTransport := newDatagramTestTransport() | |
| 284 | - | otherClient := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: first.size} | |
| 285 | - | otherClient.initOverlays() | |
| 286 | - | other := &session{sessionCore: sessionCore{id: "other", name: "other", client: otherClient}, ctx: otherCtx, cancel: otherCancel} | |
| 287 | - | otherClient.setSession(other) | |
| 288 | - | d.mu.Lock() | |
| 289 | - | d.sessions[other.id] = other | |
| 290 | - | d.mu.Unlock() | |
| 291 | - | d.attachCoordinator(other, nil, otherClient, true) | |
| 292 | - | ||
| 293 | - | roles := []*attachedClient{first, second, third} | |
| 294 | - | generations := make([]uint64, len(roles)) | |
| 295 | - | for i, ac := range roles { | |
| 296 | - | generations[i] = ac.roleGeneration.Load() | |
| 297 | - | } | |
| 298 | - | var killedLoops sync.WaitGroup | |
| 299 | - | for _, ac := range roles { | |
| 300 | - | killedLoops.Go(func() { d.runConnLoop(ac) }) | |
| 301 | - | } | |
| 302 | - | otherDone := make(chan struct{}) | |
| 303 | - | go func() { | |
| 304 | - | d.runConnLoop(otherClient) | |
| 305 | - | close(otherDone) | |
| 306 | - | }() | |
| 307 | - | defer func() { | |
| 308 | - | _ = firstTransport.Close() | |
| 309 | - | _ = secondTransport.Close() | |
| 310 | - | _ = thirdTransport.Close() | |
| 311 | - | _ = otherTransport.Close() | |
| 312 | - | killedLoops.Wait() | |
| 313 | - | <-otherDone | |
| 314 | - | }() | |
| 315 | - | ||
| 316 | - | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |
| 317 | - | d.waitNotifies() | |
| 318 | - | select { | |
| 319 | - | case <-waitGroupDone(&killedLoops): | |
| 320 | - | case <-time.After(2 * time.Second): | |
| 321 | - | t.Fatal("killed active and snatched connection loops did not retire") | |
| 322 | - | } | |
| 323 | - | ||
| 324 | - | sess.mu.Lock() | |
| 325 | - | require.Nil(t, sess.client) | |
| 326 | - | require.Empty(t, sess.snatched) | |
| 327 | - | sess.mu.Unlock() | |
| 328 | - | for i, ac := range roles { | |
| 329 | - | require.Nil(t, ac.currentSession()) | |
| 330 | - | require.Greater(t, ac.roleGeneration.Load(), generations[i], "session kill did not invalidate role generation") | |
| 331 | - | } | |
| 332 | - | for _, transport := range []*datagramTestTransport{firstTransport, secondTransport, thirdTransport} { | |
| 333 | - | detached := awaitFrame(t, transport.sends, ports.MsgDetached) | |
| 334 | - | message, err := ports.UnmarshalDetached(detached.Payload) | |
| 335 | - | require.NoError(t, err) | |
| 336 | - | require.Equal(t, ports.ReasonSessionKilled, message.Reason) | |
| 337 | - | _, recvErr := transport.Recv() | |
| 338 | - | require.ErrorIs(t, recvErr, io.EOF) | |
| 339 | - | } | |
| 340 | - | require.Same(t, otherClient, other.client) | |
| 341 | - | require.Same(t, other, otherClient.currentSession()) | |
| 342 | - | require.Equal(t, attachmentActive, other.attachmentRole(otherClient)) | |
| 343 | - | ||
| 344 | - | otherTransport.recv <- ports.Frame{Type: ports.MsgPing} | |
| 345 | - | require.Equal(t, ports.MsgPong, awaitFrame(t, otherTransport.sends, ports.MsgPong).Type) | |
| 346 | - | select { | |
| 347 | - | case <-otherDone: | |
| 348 | - | t.Fatal("killing another session retired the active client's connection") | |
| 349 | - | default: | |
| 350 | - | } | |
| 351 | - | } | |
| 352 | - | ||
| 353 | - | func TestKillSessionRetiresParkedSnatchedAttachment(t *testing.T) { | |
| 354 | - | pty, release := newBlockingPTY(t) | |
| 355 | - | defer release() | |
| 356 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 357 | - | ||
| 358 | - | parkedTransport := &closeTrackingTransport{} | |
| 359 | - | sess, parkedClient, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), parkedTransport) | |
| 360 | - | require.NoError(t, err) | |
| 361 | - | parkedToken := parkedClient.resumeToken | |
| 362 | - | d.clientGone(sess, parkedClient, parkedTransport, false) | |
| 363 | - | ||
| 364 | - | activeTransport := &closeTrackingTransport{} | |
| 365 | - | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport) | |
| 366 | - | require.NoError(t, err) | |
| 367 | - | d.mu.Lock() | |
| 368 | - | parked := d.parked[parkedToken] | |
| 369 | - | d.mu.Unlock() | |
| 370 | - | require.NotNil(t, parked) | |
| 371 | - | require.Equal(t, attachmentSnatched, parked.role) | |
| 372 | - | parkedGeneration := parkedClient.roleGeneration.Load() | |
| 373 | - | ||
| 374 | - | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |
| 375 | - | d.waitNotifies() | |
| 376 | - | ||
| 377 | - | require.Nil(t, parkedClient.transport(), "terminal teardown must revoke the parked link ownership") | |
| 378 | - | require.Greater(t, parkedClient.roleGeneration.Load(), parkedGeneration, "terminal teardown must invalidate the parked role generation") | |
| 379 | - | require.Zero(t, parkedClient.resumeToken) | |
| 380 | - | require.False(t, parkedClient.parked) | |
| 381 | - | select { | |
| 382 | - | case <-parked.done: | |
| 383 | - | default: | |
| 384 | - | t.Fatal("terminal teardown left the parked expiry goroutine live") | |
| 385 | - | } | |
| 386 | - | require.True(t, parkedTransport.Closed()) | |
| 387 | - | require.Nil(t, parkedClient.currentSession()) | |
| 388 | - | require.Nil(t, active.currentSession()) | |
| 389 | - | require.True(t, activeTransport.Closed()) | |
| 390 | - | } | |
| 391 | - | ||
| 392 | - | func TestDaemonShutdownClosesSnatchedClients(t *testing.T) { | |
| 393 | - | pty, release := newBlockingPTY(t) | |
| 394 | - | defer release() | |
| 395 | - | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 396 | - | ||
| 397 | - | parkedTransport := &closeTrackingTransport{} | |
| 398 | - | sess, parkedClient, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), parkedTransport) | |
| 399 | - | require.NoError(t, err) | |
| 400 | - | parkedToken := parkedClient.resumeToken | |
| 401 | - | d.clientGone(sess, parkedClient, parkedTransport, false) | |
| 402 | - | ||
| 403 | - | waitingTransport := &closeTrackingTransport{} | |
| 404 | - | _, waiting, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), waitingTransport) | |
| 405 | - | require.NoError(t, err) | |
| 406 | - | activeTransport := &closeTrackingTransport{} | |
| 407 | - | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport) | |
| 408 | - | require.NoError(t, err) | |
| 409 | - | d.attachmentCleanupWg.Wait() | |
| 410 | - | ||
| 411 | - | d.mu.Lock() | |
| 412 | - | parked := d.parked[parkedToken] | |
| 413 | - | d.mu.Unlock() | |
| 414 | - | require.NotNil(t, parked) | |
| 415 | - | require.Equal(t, attachmentSnatched, parked.role) | |
| 416 | - | parkedGeneration := parkedClient.roleGeneration.Load() | |
| 417 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting)) | |
| 418 | - | require.Equal(t, attachmentActive, sess.attachmentRole(active)) | |
| 419 | - | ||
| 420 | - | require.False(t, d.shutdownAll(ports.ReasonServerShutdown)) | |
| 421 | - | d.waitNotifies() | |
| 422 | - | d.attachmentCleanupWg.Wait() | |
| 423 | - | ||
| 424 | - | require.Nil(t, parkedClient.transport(), "shutdown must revoke parked attachment link ownership") | |
| 425 | - | require.Greater(t, parkedClient.roleGeneration.Load(), parkedGeneration) | |
| 426 | - | select { | |
| 427 | - | case <-parked.done: | |
| 428 | - | default: | |
| 429 | - | t.Fatal("shutdown left the parked expiry goroutine live") | |
| 430 | - | } | |
| 431 | - | for _, client := range []*attachedClient{parkedClient, waiting, active} { | |
| 432 | - | require.Nil(t, client.currentSession()) | |
| 433 | - | } | |
| 434 | - | for _, transport := range []*closeTrackingTransport{parkedTransport, waitingTransport, activeTransport} { | |
| 435 | - | require.True(t, transport.Closed()) | |
| 436 | - | } | |
| 437 | - | for _, transport := range []*closeTrackingTransport{waitingTransport, activeTransport} { | |
| 438 | - | frames := transport.Sends() | |
| 439 | - | require.NotEmpty(t, frames) | |
| 440 | - | detached, err := ports.UnmarshalDetached(frames[len(frames)-1].Payload) | |
| 441 | - | require.NoError(t, err) | |
| 442 | - | require.Equal(t, ports.ReasonServerShutdown, detached.Reason) | |
| 443 | - | } | |
| 444 | - | } | |
| 445 | - | ||
| 446 | - | func TestSnatchedQuitDoesNotResetActivePaneThemeOrSize(t *testing.T) { | |
| 447 | - | pty := &transactionalResizePTY{} | |
| 448 | - | d, sess, waiting, _ := newManualSessionWithPTYs(t, pty) | |
| 449 | - | waitingTransport := &closeTrackingTransport{} | |
| 450 | - | waiting.replaceTransport(waitingTransport) | |
| 451 | - | ||
| 452 | - | activeTransport := &closeTrackingTransport{} | |
| 453 | - | active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size} | |
| 454 | - | active.initOverlays() | |
| 455 | - | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 456 | - | target: sess, next: active, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 457 | - | expectedTransport: active.transportSnapshot(), ready: true, | |
| 458 | - | }) | |
| 459 | - | require.NoError(t, err) | |
| 460 | - | for _, cleanup := range transition.cleanups { | |
| 461 | - | cleanup.finish() | |
| 462 | - | } | |
| 463 | - | ||
| 464 | - | activeTheme := ports.Theme{ | |
| 465 | - | HasForeground: true, Foreground: renderer.RGB{R: 12, G: 34, B: 56}, | |
| 466 | - | HasBackground: true, Background: renderer.RGB{R: 65, G: 43, B: 21}, | |
| 467 | - | TrueColor: true, | |
| 468 | - | } | |
| 469 | - | d.applyTheme(sess, active, activeTheme) | |
| 470 | - | activeSize := domain.Size{Cols: 101, Rows: 37} | |
| 471 | - | d.resize(sess, active, activeSize) | |
| 472 | - | require.Equal(t, activeSize, active.size) | |
| 473 | - | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |
| 474 | - | requestedBeforeQuit := pty.requested() | |
| 475 | - | ||
| 476 | - | token := sess.attachmentToken(waiting, waitingTransport) | |
| 477 | - | require.True(t, d.handleSnatchedClientFrame(token, ports.Frame{ | |
| 478 | - | Type: ports.MsgInput, | |
| 479 | - | Payload: ports.MarshalInput(ports.Input{ | |
| 480 | - | Data: []byte{'q'}, | |
| 481 | - | }), | |
| 482 | - | })) | |
| 483 | - | ||
| 484 | - | require.Equal(t, attachmentDetached, sess.attachmentRole(waiting)) | |
| 485 | - | require.Same(t, active, sess.client) | |
| 486 | - | require.False(t, activeTransport.Closed()) | |
| 487 | - | require.True(t, waitingTransport.Closed()) | |
| 488 | - | require.Equal(t, activeSize, active.size) | |
| 489 | - | require.Equal(t, requestedBeforeQuit, pty.requested(), "snatched quit must not resize active panes") | |
| 490 | - | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |
| 491 | - | } | |
| 492 | - | ||
| 493 | - | func TestSnatchedConnectionInputCannotReachPTY(t *testing.T) { | |
| 494 | - | writes := make(chan []byte, 1) | |
| 495 | - | pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }} | |
| 496 | - | d, sess, old, _ := newManualSessionWithPTYs(t, pty) | |
| 497 | - | oldTransport := newDatagramTestTransport() | |
| 498 | - | old.replaceTransport(oldTransport) | |
| 499 | - | ||
| 500 | - | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 501 | - | next.initOverlays() | |
| 502 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 503 | - | target: sess, | |
| 504 | - | next: next, | |
| 505 | - | expectedRole: attachmentDetached, | |
| 506 | - | targetRole: attachmentActive, | |
| 507 | - | expectedTransport: next.transportSnapshot(), | |
| 508 | - | }) | |
| 509 | - | require.NoError(t, err) | |
| 510 | - | d.deferAttachmentTransitionCleanups(result) | |
| 511 | - | d.attachmentCleanupWg.Wait() | |
| 512 | - | awaitFrame(t, oldTransport.sends, ports.MsgOutput) | |
| 513 | - | ||
| 514 | - | oldTransport.recv <- frameInput([]byte("restricted")) | |
| 515 | - | require.NoError(t, oldTransport.Close()) | |
| 516 | - | d.runConnLoop(old) | |
| 517 | - | ||
| 518 | - | select { | |
| 519 | - | case got := <-writes: | |
| 520 | - | t.Fatalf("snatched input reached PTY: %q", got) | |
| 521 | - | default: | |
| 522 | - | } | |
| 523 | - | } | |
| 524 | - | ||
| 525 | - | func TestStaleActiveKeyForwardCannotReachPTYAfterSnatch(t *testing.T) { | |
| 526 | - | writes := make(chan []byte, 1) | |
| 527 | - | pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }} | |
| 528 | - | d, sess, old, _ := newManualSessionWithPTYs(t, pty) | |
| 529 | - | old.replaceTransport(&closeTrackingTransport{}) | |
| 530 | - | ||
| 531 | - | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 532 | - | next.initOverlays() | |
| 533 | - | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 534 | - | target: sess, | |
| 535 | - | next: next, | |
| 536 | - | expectedRole: attachmentDetached, | |
| 537 | - | targetRole: attachmentActive, | |
| 538 | - | expectedTransport: next.transportSnapshot(), | |
| 539 | - | }) | |
| 540 | - | require.NoError(t, err) | |
| 541 | - | ||
| 542 | - | (daemonKeyHandler{d: d, ac: old}).Forward([]byte("stale")) | |
| 543 | - | ||
| 544 | - | select { | |
| 545 | - | case got := <-writes: | |
| 546 | - | t.Fatalf("stale active key forward reached PTY: %q", got) | |
| 547 | - | default: | |
| 548 | - | } | |
| 549 | - | } | |
| 550 | - | ||
| 551 | - | func TestSnatchedConnectionResizeUpdatesPanelWithoutResizingSession(t *testing.T) { | |
| 552 | - | pty := &transactionalResizePTY{} | |
| 553 | - | d, sess, old, _ := newManualSessionWithPTYs(t, pty) | |
| 554 | - | oldTransport := newDatagramTestTransport() | |
| 555 | - | old.replaceTransport(oldTransport) | |
| 556 | - | ||
| 557 | - | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 558 | - | next.initOverlays() | |
| 559 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 560 | - | target: sess, | |
| 561 | - | next: next, | |
| 562 | - | expectedRole: attachmentDetached, | |
| 563 | - | targetRole: attachmentActive, | |
| 564 | - | expectedTransport: next.transportSnapshot(), | |
| 565 | - | }) | |
| 566 | - | require.NoError(t, err) | |
| 567 | - | d.deferAttachmentTransitionCleanups(result) | |
| 568 | - | d.attachmentCleanupWg.Wait() | |
| 569 | - | awaitFrame(t, oldTransport.sends, ports.MsgOutput) | |
| 570 | - | ||
| 571 | - | want := domain.Size{Cols: 100, Rows: 30} | |
| 572 | - | oldTransport.recv <- ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: want})} | |
| 573 | - | require.NoError(t, oldTransport.Close()) | |
| 574 | - | d.runConnLoop(old) | |
| 575 | - | ||
| 576 | - | require.Equal(t, want, old.size) | |
| 577 | - | require.Empty(t, pty.requested(), "snatched resize must not resize session PTYs") | |
| 578 | - | panel := awaitFrame(t, oldTransport.sends, ports.MsgOutput) | |
| 579 | - | output, err := ports.UnmarshalOutput(panel.Payload) | |
| 580 | - | require.NoError(t, err) | |
| 581 | - | require.Contains(t, string(output.Data), "Session snatched") | |
| 582 | - | } | |
| 583 | - | ||
| 584 | - | func TestSnatchedConnectionAcknowledgesPanelAndAnswersPing(t *testing.T) { | |
| 585 | - | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 586 | - | oldTransport := newDatagramTestTransport() | |
| 587 | - | old.replaceTransport(oldTransport) | |
| 588 | - | ||
| 589 | - | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 590 | - | next.initOverlays() | |
| 591 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 592 | - | target: sess, | |
| 593 | - | next: next, | |
| 594 | - | expectedRole: attachmentDetached, | |
| 595 | - | targetRole: attachmentActive, | |
| 596 | - | expectedTransport: next.transportSnapshot(), | |
| 597 | - | }) | |
| 598 | - | require.NoError(t, err) | |
| 599 | - | d.deferAttachmentTransitionCleanups(result) | |
| 600 | - | d.attachmentCleanupWg.Wait() | |
| 601 | - | panelFrame := awaitFrame(t, oldTransport.sends, ports.MsgOutput) | |
| 602 | - | panel, err := ports.UnmarshalOutput(panelFrame.Payload) | |
| 603 | - | require.NoError(t, err) | |
| 604 | - | require.Equal(t, uint64(1), old.output.outstanding()) | |
| 605 | - | ||
| 606 | - | oldTransport.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: panel.NewStateNum})} | |
| 607 | - | oldTransport.recv <- ports.Frame{Type: ports.MsgPing} | |
| 608 | - | require.NoError(t, oldTransport.Close()) | |
| 609 | - | d.runConnLoop(old) | |
| 610 | - | ||
| 611 | - | require.Zero(t, old.output.outstanding()) | |
| 612 | - | require.Equal(t, ports.MsgPong, awaitFrame(t, oldTransport.sends, ports.MsgPong).Type) | |
| 613 | - | } | |
| 614 | - | ||
| 615 | - | func TestSnatchedConnectionThemeRepaintsPanelWithoutRecoloringSession(t *testing.T) { | |
| 616 | - | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 617 | - | oldTransport := newDatagramTestTransport() | |
| 618 | - | old.replaceTransport(oldTransport) | |
| 619 | - | ||
| 620 | - | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 621 | - | next.initOverlays() | |
| 622 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 623 | - | target: sess, | |
| 624 | - | next: next, | |
| 625 | - | expectedRole: attachmentDetached, | |
| 626 | - | targetRole: attachmentActive, | |
| 627 | - | expectedTransport: next.transportSnapshot(), | |
| 628 | - | }) | |
| 629 | - | require.NoError(t, err) | |
| 630 | - | d.deferAttachmentTransitionCleanups(result) | |
| 631 | - | d.attachmentCleanupWg.Wait() | |
| 632 | - | awaitFrame(t, oldTransport.sends, ports.MsgOutput) | |
| 633 | - | ||
| 634 | - | activeTheme := ports.Theme{ | |
| 635 | - | HasForeground: true, Foreground: renderer.RGB{R: 10, G: 20, B: 30}, | |
| 636 | - | HasBackground: true, Background: renderer.RGB{R: 40, G: 50, B: 60}, | |
| 637 | - | TrueColor: true, | |
| 638 | - | } | |
| 639 | - | d.applyTheme(sess, next, activeTheme) | |
| 640 | - | snatchedTheme := ports.Theme{ | |
| 641 | - | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 642 | - | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 643 | - | TrueColor: true, | |
| 644 | - | } | |
| 645 | - | oldTransport.recv <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(snatchedTheme)} | |
| 646 | - | require.NoError(t, oldTransport.Close()) | |
| 647 | - | d.runConnLoop(old) | |
| 648 | - | ||
| 649 | - | require.Equal(t, snatchedTheme.Foreground, old.getAppliedTheme().Raw.Foreground) | |
| 650 | - | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |
| 651 | - | panel := awaitFrame(t, oldTransport.sends, ports.MsgOutput) | |
| 652 | - | output, err := ports.UnmarshalOutput(panel.Payload) | |
| 653 | - | require.NoError(t, err) | |
| 654 | - | require.Contains(t, string(output.Data), "Session snatched") | |
| 655 | - | } | |
| 656 | - | ||
| 657 | - | func TestSnatchedConnectionDetachRemovesOnlySnatchedAttachment(t *testing.T) { | |
| 658 | - | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 659 | - | oldTransport := newDatagramTestTransport() | |
| 660 | - | old.replaceTransport(oldTransport) | |
| 661 | - | ||
| 662 | - | activeTransport := &closeTrackingTransport{} | |
| 663 | - | next := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: old.size} | |
| 664 | - | next.initOverlays() | |
| 665 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 666 | - | target: sess, | |
| 667 | - | next: next, | |
| 668 | - | expectedRole: attachmentDetached, | |
| 669 | - | targetRole: attachmentActive, | |
| 670 | - | expectedTransport: next.transportSnapshot(), | |
| 671 | - | }) | |
| 672 | - | require.NoError(t, err) | |
| 673 | - | d.deferAttachmentTransitionCleanups(result) | |
| 674 | - | d.attachmentCleanupWg.Wait() | |
| 675 | - | awaitFrame(t, oldTransport.sends, ports.MsgOutput) | |
| 676 | - | ||
| 677 | - | oldTransport.recv <- ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})} | |
| 678 | - | d.runConnLoop(old) | |
| 679 | - | ||
| 680 | - | detached := awaitFrame(t, oldTransport.sends, ports.MsgDetached) | |
| 681 | - | message, err := ports.UnmarshalDetached(detached.Payload) | |
| 682 | - | require.NoError(t, err) | |
| 683 | - | require.Equal(t, ports.ReasonDetach, message.Reason) | |
| 684 | - | require.Equal(t, attachmentDetached, sess.attachmentRole(old)) | |
| 685 | - | require.Equal(t, attachmentActive, sess.attachmentRole(next)) | |
| 686 | - | require.Same(t, next, sess.client) | |
| 687 | - | require.False(t, activeTransport.Closed()) | |
| 688 | - | } | |
| 689 | - | ||
| 690 | - | func TestReplacementClearsSnatchedOverlaysPreviewAndCaptures(t *testing.T) { | |
| 691 | - | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 692 | - | oldTransport := &closeTrackingTransport{} | |
| 693 | - | old.replaceTransport(oldTransport) | |
| 694 | - | ||
| 695 | - | d.enterPicker(sess, old) | |
| 696 | - | d.enterPalette(sess, old) | |
| 697 | - | d.enterPrompt(sess, old, " Rename ", "work", func(string) error { return nil }) | |
| 698 | - | d.enterCopyMode(sess, old) | |
| 699 | - | d.enterNotices(sess, old) | |
| 700 | - | old.overlays.resizeMu.Lock() | |
| 701 | - | old.overlays.resizeActive = true | |
| 702 | - | old.overlays.resizePending = []byte("pending") | |
| 703 | - | old.overlays.resizeMu.Unlock() | |
| 704 | - | old.sendMu.Lock() | |
| 705 | - | old.captureFrames = map[*pane]capturedPaneRenderState{sess.activeTab().focusedPane(): {}} | |
| 706 | - | old.sendMu.Unlock() | |
| 707 | - | require.True(t, old.overlays.Active()) | |
| 708 | - | ||
| 709 | - | next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size} | |
| 710 | - | next.initOverlays() | |
| 711 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 712 | - | target: sess, | |
| 713 | - | next: next, | |
| 714 | - | expectedRole: attachmentDetached, | |
| 715 | - | targetRole: attachmentActive, | |
| 716 | - | expectedTransport: next.transportSnapshot(), | |
| 717 | - | }) | |
| 718 | - | require.NoError(t, err) | |
| 719 | - | d.deferAttachmentTransitionCleanups(result) | |
| 720 | - | d.attachmentCleanupWg.Wait() | |
| 721 | - | ||
| 722 | - | require.False(t, old.overlays.Active()) | |
| 723 | - | old.overlays.pickerMu.Lock() | |
| 724 | - | require.Nil(t, old.overlays.pickerPreview) | |
| 725 | - | require.Nil(t, old.overlays.pickerPreviewSession) | |
| 726 | - | old.overlays.pickerMu.Unlock() | |
| 727 | - | old.sendMu.Lock() | |
| 728 | - | require.Empty(t, old.captureFrames) | |
| 729 | - | old.sendMu.Unlock() | |
| 730 | - | require.False(t, oldTransport.Closed()) | |
| 731 | - | } | |
| 732 | - | ||
| 733 | - | type failingSnatchedTransport struct { | |
| 734 | - | mu sync.Mutex | |
| 735 | - | closed bool | |
| 736 | - | } | |
| 737 | - | ||
| 738 | - | func (*failingSnatchedTransport) Send(ports.Frame) error { return errors.New("send failed") } | |
| 739 | - | func (*failingSnatchedTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |
| 740 | - | func (t *failingSnatchedTransport) Close() error { | |
| 741 | - | t.mu.Lock() | |
| 742 | - | t.closed = true | |
| 743 | - | t.mu.Unlock() | |
| 744 | - | return nil | |
| 745 | - | } | |
| 746 | - | func (t *failingSnatchedTransport) Closed() bool { | |
| 747 | - | t.mu.Lock() | |
| 748 | - | defer t.mu.Unlock() | |
| 749 | - | return t.closed | |
| 750 | - | } | |
| 751 | - | ||
| 752 | - | func TestSnatchedPanelSendFailureRemovesOnlyWaitingClient(t *testing.T) { | |
| 753 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 754 | - | oldTransport := &failingSnatchedTransport{} | |
| 755 | - | old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 756 | - | old.initOverlays() | |
| 757 | - | sess := &session{sessionCore: sessionCore{id: "work", client: old}} | |
| 758 | - | old.setSession(sess) | |
| 759 | - | d.sessions[sess.id] = sess | |
| 760 | - | ||
| 761 | - | activeTransport := &closeTrackingTransport{} | |
| 762 | - | next := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: old.size} | |
| 763 | - | next.initOverlays() | |
| 764 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 765 | - | target: sess, | |
| 766 | - | next: next, | |
| 767 | - | expectedRole: attachmentDetached, | |
| 768 | - | targetRole: attachmentActive, | |
| 769 | - | expectedTransport: next.transportSnapshot(), | |
| 770 | - | }) | |
| 771 | - | require.NoError(t, err) | |
| 772 | - | d.deferAttachmentTransitionCleanups(result) | |
| 773 | - | d.attachmentCleanupWg.Wait() | |
| 774 | - | ||
| 775 | - | require.True(t, oldTransport.Closed()) | |
| 776 | - | require.Equal(t, attachmentDetached, sess.attachmentRole(old)) | |
| 777 | - | require.Equal(t, attachmentActive, sess.attachmentRole(next)) | |
| 778 | - | require.Same(t, next, sess.client) | |
| 779 | - | require.False(t, activeTransport.Closed()) | |
| 780 | - | } | |
| 781 | - | ||
| 782 | - | func TestSnatchedPanelFailurePreservesActivePaneThemeAndSize(t *testing.T) { | |
| 783 | - | pty := &transactionalResizePTY{} | |
| 784 | - | d, sess, waiting, _ := newManualSessionWithPTYs(t, pty) | |
| 785 | - | failed := &failingSnatchedTransport{} | |
| 786 | - | waiting.replaceTransport(failed) | |
| 787 | - | ||
| 788 | - | activeTransport := &closeTrackingTransport{} | |
| 789 | - | active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size} | |
| 790 | - | active.initOverlays() | |
| 791 | - | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 792 | - | target: sess, next: active, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 793 | - | expectedTransport: active.transportSnapshot(), ready: true, | |
| 794 | - | }) | |
| 795 | - | require.NoError(t, err) | |
| 796 | - | ||
| 797 | - | activeTheme := ports.Theme{ | |
| 798 | - | HasForeground: true, Foreground: renderer.RGB{R: 12, G: 34, B: 56}, | |
| 799 | - | HasBackground: true, Background: renderer.RGB{R: 65, G: 43, B: 21}, | |
| 800 | - | TrueColor: true, | |
| 801 | - | } | |
| 802 | - | d.applyTheme(sess, active, activeTheme) | |
| 803 | - | activeSize := domain.Size{Cols: 103, Rows: 39} | |
| 804 | - | d.resize(sess, active, activeSize) | |
| 805 | - | require.Equal(t, activeSize, active.size) | |
| 806 | - | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |
| 807 | - | requestedBeforeFailure := pty.requested() | |
| 808 | - | ||
| 809 | - | d.deferAttachmentTransitionCleanups(transition) | |
| 810 | - | d.attachmentCleanupWg.Wait() | |
| 811 | - | ||
| 812 | - | require.True(t, failed.Closed()) | |
| 813 | - | require.Equal(t, attachmentDetached, sess.attachmentRole(waiting)) | |
| 814 | - | require.Same(t, active, sess.client) | |
| 815 | - | require.False(t, activeTransport.Closed()) | |
| 816 | - | require.Equal(t, activeSize, active.size) | |
| 817 | - | require.Equal(t, requestedBeforeFailure, pty.requested(), "snatched panel failure must not resize active panes") | |
| 818 | - | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |
| 819 | - | } | |
| 820 | - | ||
| 821 | - | func TestSnatchedThemePanelFailureRemovesOnlySnatchedAttachment(t *testing.T) { | |
| 822 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 823 | - | oldTransport := &failingSnatchedTransport{} | |
| 824 | - | old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 825 | - | old.initOverlays() | |
| 826 | - | sess := &session{sessionCore: sessionCore{id: "work", client: old}} | |
| 827 | - | old.setSession(sess) | |
| 828 | - | d.sessions[sess.id] = sess | |
| 829 | - | ||
| 830 | - | activeTransport := &closeTrackingTransport{} | |
| 831 | - | next := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: old.size} | |
| 832 | - | next.initOverlays() | |
| 833 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 834 | - | target: sess, | |
| 835 | - | next: next, | |
| 836 | - | expectedRole: attachmentDetached, | |
| 837 | - | targetRole: attachmentActive, | |
| 838 | - | expectedTransport: next.transportSnapshot(), | |
| 839 | - | }) | |
| 840 | - | require.NoError(t, err) | |
| 841 | - | for _, cleanup := range result.cleanups { | |
| 842 | - | cleanup.finish() | |
| 843 | - | } | |
| 844 | - | ||
| 845 | - | theme := ports.Theme{ | |
| 846 | - | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 847 | - | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 848 | - | TrueColor: true, | |
| 849 | - | } | |
| 850 | - | require.True(t, d.handleSnatchedClientFrame(result.displaced, ports.Frame{ | |
| 851 | - | Type: ports.MsgTheme, Payload: ports.MarshalTheme(theme), | |
| 852 | - | })) | |
| 853 | - | ||
| 854 | - | require.True(t, oldTransport.Closed()) | |
| 855 | - | require.Equal(t, attachmentDetached, sess.attachmentRole(old)) | |
| 856 | - | require.Equal(t, attachmentActive, sess.attachmentRole(next)) | |
| 857 | - | require.Same(t, next, sess.client) | |
| 858 | - | require.False(t, activeTransport.Closed()) | |
| 859 | - | } | |
| 860 | - | ||
| 861 | - | func TestClearForSnatchReleasesEachOverlayFamilyBeforeTakingNext(t *testing.T) { | |
| 862 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 863 | - | tr := &closeTrackingTransport{} | |
| 864 | - | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 865 | - | ac.initOverlays() | |
| 866 | - | ac.roleGeneration.Store(1) | |
| 867 | - | sess := &session{sessionCore: sessionCore{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}}} | |
| 868 | - | ac.setSession(sess) | |
| 869 | - | token := attachmentRoleToken{ | |
| 870 | - | sess: sess, ac: ac, role: attachmentSnatched, | |
| 871 | - | generation: 1, transport: ac.transportSnapshot(), | |
| 872 | - | } | |
| 873 | - | ||
| 874 | - | pickerCleared := make(chan struct{}) | |
| 875 | - | d.afterSnatchOverlayFamily = func(family string) { | |
| 876 | - | if family == "picker" { | |
| 877 | - | close(pickerCleared) | |
| 878 | - | } | |
| 879 | - | } | |
| 880 | - | ac.overlays.resizeMu.Lock() | |
| 881 | - | resizeLocked := true | |
| 882 | - | defer func() { | |
| 883 | - | if resizeLocked { | |
| 884 | - | ac.overlays.resizeMu.Unlock() | |
| 885 | - | } | |
| 886 | - | }() | |
| 887 | - | cleared := make(chan bool, 1) | |
| 888 | - | go func() { cleared <- d.clearForSnatch(token) }() | |
| 889 | - | select { | |
| 890 | - | case <-pickerCleared: | |
| 891 | - | case <-time.After(2 * time.Second): | |
| 892 | - | t.Fatal("picker family did not clear before blocked resize family") | |
| 893 | - | } | |
| 894 | - | require.True(t, ac.overlays.pickerMu.TryLock(), "clear retained pickerMu while waiting for resizeMu") | |
| 895 | - | ac.overlays.pickerMu.Unlock() | |
| 896 | - | ||
| 897 | - | ac.overlays.resizeMu.Unlock() | |
| 898 | - | resizeLocked = false | |
| 899 | - | require.True(t, <-cleared) | |
| 900 | - | } | |
| 901 | - | ||
| 902 | - | func TestClearForSnatchRejectsStaleGenerationWithoutClearingNewOverlay(t *testing.T) { | |
| 903 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 904 | - | tr := &closeTrackingTransport{} | |
| 905 | - | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 906 | - | ac.initOverlays() | |
| 907 | - | ac.roleGeneration.Store(1) | |
| 908 | - | sess := &session{sessionCore: sessionCore{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}}} | |
| 909 | - | ac.setSession(sess) | |
| 910 | - | token := attachmentRoleToken{ | |
| 911 | - | sess: sess, ac: ac, role: attachmentSnatched, | |
| 912 | - | generation: 1, transport: ac.transportSnapshot(), | |
| 913 | - | } | |
| 914 | - | ||
| 915 | - | ac.overlays.pickerMu.Lock() | |
| 916 | - | started := make(chan struct{}) | |
| 917 | - | cleared := make(chan bool, 1) | |
| 918 | - | go func() { | |
| 919 | - | close(started) | |
| 920 | - | cleared <- d.clearForSnatch(token) | |
| 921 | - | }() | |
| 922 | - | <-started | |
| 923 | - | ac.roleGeneration.Add(1) | |
| 924 | - | ac.overlays.resizeMu.Lock() | |
| 925 | - | ac.overlays.resizeActive = true | |
| 926 | - | ac.overlays.resizeMu.Unlock() | |
| 927 | - | ac.overlays.pickerMu.Unlock() | |
| 928 | - | ||
| 929 | - | require.False(t, <-cleared) | |
| 930 | - | require.True(t, ac.overlays.resizeModeActive(), "stale cleanup cleared a newer generation") | |
| 931 | - | } | |
| 932 | - | ||
| 933 | - | func TestTransitionAttachmentRejectsBeforeOwnershipCommit(t *testing.T) { | |
| 934 | - | tests := []struct { | |
| 935 | - | name string | |
| 936 | - | mutate func(*Daemon, *session, *attachedClient) | |
| 937 | - | }{ | |
| 938 | - | { | |
| 939 | - | name: "stale transport", | |
| 940 | - | mutate: func(_ *Daemon, _ *session, next *attachedClient) { | |
| 941 | - | next.replaceTransport(&closeTrackingTransport{}) | |
| 942 | - | }, | |
| 943 | - | }, | |
| 944 | - | { | |
| 945 | - | name: "session lifecycle ended", | |
| 946 | - | mutate: func(d *Daemon, sess *session, _ *attachedClient) { | |
| 947 | - | delete(d.sessions, sess.id) | |
| 948 | - | }, | |
| 949 | - | }, | |
| 950 | - | { | |
| 951 | - | name: "unexpected role", | |
| 952 | - | mutate: func(_ *Daemon, sess *session, next *attachedClient) { | |
| 953 | - | sess.mu.Lock() | |
| 954 | - | sess.addSnatchedLocked(next) | |
| 955 | - | sess.mu.Unlock() | |
| 956 | - | }, | |
| 957 | - | }, | |
| 958 | - | } | |
| 959 | - | ||
| 960 | - | for _, tt := range tests { | |
| 961 | - | t.Run(tt.name, func(t *testing.T) { | |
| 962 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 963 | - | sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}} | |
| 964 | - | old := &attachedClient{tr: &closeTrackingTransport{}} | |
| 965 | - | old.setSession(sess) | |
| 966 | - | sess.client = old | |
| 967 | - | d.sessions[sess.id] = sess | |
| 968 | - | next := &attachedClient{tr: &closeTrackingTransport{}} | |
| 969 | - | expected := next.transportSnapshot() | |
| 970 | - | tt.mutate(d, sess, next) | |
| 971 | - | ||
| 972 | - | _, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 973 | - | target: sess, | |
| 974 | - | next: next, | |
| 975 | - | expectedRole: attachmentDetached, | |
| 976 | - | targetRole: attachmentActive, | |
| 977 | - | expectedTransport: expected, | |
| 978 | - | }) | |
| 979 | - | require.ErrorIs(t, err, errAttachmentTransition) | |
| 980 | - | require.Same(t, old, sess.client) | |
| 981 | - | require.Zero(t, old.roleGeneration.Load()) | |
| 982 | - | require.Zero(t, next.roleGeneration.Load()) | |
| 983 | - | }) | |
| 984 | - | } | |
| 985 | - | } | |
| 986 | - | ||
| 987 | - | func TestCrossSessionTransitionPublishesCoherentOwnershipBeforeBlockedFirstPaint(t *testing.T) { | |
| 988 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 989 | - | from := &session{sessionCore: sessionCore{id: "z-source"}} | |
| 990 | - | target := &session{sessionCore: sessionCore{id: "a-target"}} | |
| 991 | - | moving := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 992 | - | displaced := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: moving.size} | |
| 993 | - | moving.initOverlays() | |
| 994 | - | displaced.initOverlays() | |
| 995 | - | moving.setSession(from) | |
| 996 | - | displaced.setSession(target) | |
| 997 | - | from.client = moving | |
| 998 | - | target.client = displaced | |
| 999 | - | d.sessions[from.id] = from | |
| 1000 | - | d.sessions[target.id] = target | |
| 1001 | - | sourceCoordinator := d.attachCoordinator(from, nil, moving, true) | |
| 1002 | - | targetCoordinator := d.attachCoordinator(target, nil, displaced, true) | |
| 1003 | - | ||
| 1004 | - | moving.sendMu.Lock() | |
| 1005 | - | locked := true | |
| 1006 | - | defer func() { | |
| 1007 | - | if locked { | |
| 1008 | - | moving.sendMu.Unlock() | |
| 1009 | - | } | |
| 1010 | - | }() | |
| 1011 | - | transitionDone := make(chan attachmentTransitionResult, 1) | |
| 1012 | - | transitionErr := make(chan error, 1) | |
| 1013 | - | go func() { | |
| 1014 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1015 | - | source: from, target: target, next: moving, | |
| 1016 | - | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 1017 | - | expectedTransport: moving.transportSnapshot(), ready: true, | |
| 1018 | - | }) | |
| 1019 | - | transitionDone <- result | |
| 1020 | - | transitionErr <- err | |
| 1021 | - | }() | |
| 1022 | - | ||
| 1023 | - | var result attachmentTransitionResult | |
| 1024 | - | select { | |
| 1025 | - | case result = <-transitionDone: | |
| 1026 | - | require.NoError(t, <-transitionErr) | |
| 1027 | - | case <-time.After(2 * time.Second): | |
| 1028 | - | t.Fatal("ownership publication waited for blocked sendMu") | |
| 1029 | - | } | |
| 1030 | - | ||
| 1031 | - | from.mu.Lock() | |
| 1032 | - | require.Nil(t, from.client) | |
| 1033 | - | from.mu.Unlock() | |
| 1034 | - | target.mu.Lock() | |
| 1035 | - | require.Same(t, moving, target.client) | |
| 1036 | - | require.Contains(t, target.snatched, displaced) | |
| 1037 | - | target.mu.Unlock() | |
| 1038 | - | require.Same(t, target, moving.currentSession()) | |
| 1039 | - | sourceCoordinator.mu.Lock() | |
| 1040 | - | require.NotNil(t, sourceCoordinator.lease) | |
| 1041 | - | require.False(t, sourceCoordinator.lease.active) | |
| 1042 | - | sourceCoordinator.mu.Unlock() | |
| 1043 | - | targetCoordinator.mu.Lock() | |
| 1044 | - | require.Same(t, result.published.lease, targetCoordinator.lease) | |
| 1045 | - | require.True(t, targetCoordinator.lease.active) | |
| 1046 | - | require.Same(t, moving, targetCoordinator.lease.attachment) | |
| 1047 | - | targetCoordinator.mu.Unlock() | |
| 1048 | - | require.True(t, result.published.activeCurrent()) | |
| 1049 | - | ||
| 1050 | - | beforeSendWait := make(chan struct{}) | |
| 1051 | - | d.beforeFirstPaintSendWait = func(token attachmentRoleToken) { | |
| 1052 | - | if token.ac == moving { | |
| 1053 | - | close(beforeSendWait) | |
| 1054 | - | } | |
| 1055 | - | } | |
| 1056 | - | paintDone := make(chan bool, 1) | |
| 1057 | - | go func() { | |
| 1058 | - | paintDone <- d.firstPaintForTransition(result.published) | |
| 1059 | - | }() | |
| 1060 | - | <-beforeSendWait | |
| 1061 | - | require.True(t, d.mu.TryLock(), "first paint retained daemon lock while waiting for sendMu") | |
| 1062 | - | d.mu.Unlock() | |
| 1063 | - | require.True(t, d.notices.routingMu.TryLock(), "first paint retained routing lock while waiting for sendMu") | |
| 1064 | - | d.notices.routingMu.Unlock() | |
| 1065 | - | require.True(t, from.mu.TryLock(), "first paint retained source session lock while waiting for sendMu") | |
| 1066 | - | from.mu.Unlock() | |
| 1067 | - | require.True(t, target.mu.TryLock(), "first paint retained target session lock while waiting for sendMu") | |
| 1068 | - | target.mu.Unlock() | |
| 1069 | - | require.True(t, sourceCoordinator.mu.TryLock(), "first paint retained source coordinator lock while waiting for sendMu") | |
| 1070 | - | sourceCoordinator.mu.Unlock() | |
| 1071 | - | require.True(t, targetCoordinator.mu.TryLock(), "first paint retained target coordinator lock while waiting for sendMu") | |
| 1072 | - | targetCoordinator.mu.Unlock() | |
| 1073 | - | ||
| 1074 | - | moving.sendMu.Unlock() | |
| 1075 | - | locked = false | |
| 1076 | - | require.True(t, <-paintDone) | |
| 1077 | - | } | |
| 1078 | - | ||
| 1079 | - | func TestFirstPaintForTransitionRejectsStaleRoleTokenBeforeRebaseOrReset(t *testing.T) { | |
| 1080 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 1081 | - | tr := &closeTrackingTransport{} | |
| 1082 | - | ac := &attachedClient{ | |
| 1083 | - | tr: tr, | |
| 1084 | - | output: newOutputStateStream(), | |
| 1085 | - | size: domain.Size{Cols: 80, Rows: 24}, | |
| 1086 | - | } | |
| 1087 | - | ac.initOverlays() | |
| 1088 | - | ac.roleGeneration.Store(2) | |
| 1089 | - | ac.output.next = 4 | |
| 1090 | - | ac.output.acked = 1 | |
| 1091 | - | sess := &session{sessionCore: sessionCore{id: "work", client: ac}} | |
| 1092 | - | ac.setSession(sess) | |
| 1093 | - | d.sessions[sess.id] = sess | |
| 1094 | - | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 1095 | - | rebased := false | |
| 1096 | - | ac.renderStages.handoffRebase = func() { rebased = true } | |
| 1097 | - | ||
| 1098 | - | stale := attachmentRoleToken{ | |
| 1099 | - | sess: sess, ac: ac, role: attachmentActive, | |
| 1100 | - | generation: 1, transport: ac.transportSnapshot(), lease: rc.attachmentLease(ac), rebase: true, | |
| 1101 | - | } | |
| 1102 | - | require.False(t, d.firstPaintForTransition(stale)) | |
| 1103 | - | ||
| 1104 | - | require.False(t, rebased) | |
| 1105 | - | require.Equal(t, uint64(4), ac.output.next) | |
| 1106 | - | require.Equal(t, uint64(1), ac.output.acked) | |
| 1107 | - | require.Empty(t, tr.Sends(), "stale first paint emitted a reset") | |
| 1108 | - | } | |
| 1109 | - | ||
| 1110 | - | func TestTransitionAttachmentMovesActiveBetweenSessions(t *testing.T) { | |
| 1111 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 1112 | - | from := &session{sessionCore: sessionCore{id: domain.SessionID("z-source")}} | |
| 1113 | - | target := &session{sessionCore: sessionCore{id: domain.SessionID("a-target")}} | |
| 1114 | - | moving := &attachedClient{tr: &closeTrackingTransport{}} | |
| 1115 | - | displaced := &attachedClient{tr: &closeTrackingTransport{}} | |
| 1116 | - | moving.setSession(from) | |
| 1117 | - | displaced.setSession(target) | |
| 1118 | - | from.client = moving | |
| 1119 | - | target.client = displaced | |
| 1120 | - | d.sessions[from.id] = from | |
| 1121 | - | d.sessions[target.id] = target | |
| 1122 | - | ||
| 1123 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1124 | - | source: from, | |
| 1125 | - | target: target, | |
| 1126 | - | next: moving, | |
| 1127 | - | expectedRole: attachmentActive, | |
| 1128 | - | targetRole: attachmentActive, | |
| 1129 | - | expectedTransport: moving.transportSnapshot(), | |
| 1130 | - | ready: true, | |
| 1131 | - | }) | |
| 1132 | - | require.NoError(t, err) | |
| 1133 | - | ||
| 1134 | - | from.mu.Lock() | |
| 1135 | - | require.Nil(t, from.client) | |
| 1136 | - | require.NotContains(t, from.snatched, moving) | |
| 1137 | - | from.mu.Unlock() | |
| 1138 | - | target.mu.Lock() | |
| 1139 | - | require.Same(t, moving, target.client) | |
| 1140 | - | require.Contains(t, target.snatched, displaced) | |
| 1141 | - | target.mu.Unlock() | |
| 1142 | - | require.True(t, result.published.current()) | |
| 1143 | - | require.True(t, result.displaced.current()) | |
| 1144 | - | require.Same(t, target, moving.currentSession()) | |
| 1145 | - | } | |
D internal/usecase/daemon/snatched_input.go +0 -261
| @@ -1,261 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/vev/internal/ports" | |
| 7 | - | "github.com/bnema/vev/internal/usecase/keys" | |
| 8 | - | ) | |
| 9 | - | ||
| 10 | - | const snatchedInputSequenceLimit = 64 | |
| 11 | - | ||
| 12 | - | type snatchedInputAction uint8 | |
| 13 | - | ||
| 14 | - | const ( | |
| 15 | - | snatchedInputNone snatchedInputAction = iota | |
| 16 | - | snatchedInputReclaim | |
| 17 | - | snatchedInputQuit | |
| 18 | - | ) | |
| 19 | - | ||
| 20 | - | // clearSnatchedInput invalidates every parser continuation before stopping its | |
| 21 | - | // timer. Callers must not hold daemon, routing, session, coordinator, send, or | |
| 22 | - | // transport locks because Timer.Stop is an external call. | |
| 23 | - | func (ac *attachedClient) clearSnatchedInput() { | |
| 24 | - | if ac == nil { | |
| 25 | - | return | |
| 26 | - | } | |
| 27 | - | ac.snatchedInputMu.Lock() | |
| 28 | - | ac.snatchedInputPending = nil | |
| 29 | - | ac.snatchedInputDrain = false | |
| 30 | - | pendingESC := ac.snatchedInputESC | |
| 31 | - | ac.snatchedInputESC = pendingByteTimer{} | |
| 32 | - | ac.snatchedInputMu.Unlock() | |
| 33 | - | pendingESC.stop() | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | // handleSnatchedInput accepts actions only as exact one-byte input events. | |
| 37 | - | // Terminal escape sequences and arbitrary coalesced input are consumed as | |
| 38 | - | // indivisible events so a byte in a paste, sequence final, or frame tail can | |
| 39 | - | // never become an action. | |
| 40 | - | func (d *Daemon) handleSnatchedInput(token attachmentRoleToken, input ports.Input) bool { | |
| 41 | - | action := d.parseSnatchedInput(token, input.Data) | |
| 42 | - | switch action { | |
| 43 | - | case snatchedInputReclaim: | |
| 44 | - | d.reclaimSnatchedAttachment(token) | |
| 45 | - | case snatchedInputQuit: | |
| 46 | - | return d.quitSnatchedAttachment(token) | |
| 47 | - | } | |
| 48 | - | return false | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | func (d *Daemon) parseSnatchedInput(token attachmentRoleToken, data []byte) snatchedInputAction { | |
| 52 | - | ac := token.ac | |
| 53 | - | if ac == nil || len(data) == 0 { | |
| 54 | - | return snatchedInputNone | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | ac.snatchedInputMu.Lock() | |
| 58 | - | ||
| 59 | - | if ac.snatchedInputDrain { | |
| 60 | - | if snatchedSequenceFinal(data) >= 0 { | |
| 61 | - | ac.snatchedInputDrain = false | |
| 62 | - | } | |
| 63 | - | ac.snatchedInputMu.Unlock() | |
| 64 | - | return snatchedInputNone | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | if len(ac.snatchedInputPending) != 0 { | |
| 68 | - | pendingESC := ac.snatchedInputESC | |
| 69 | - | ac.snatchedInputESC = pendingByteTimer{} | |
| 70 | - | combined := make([]byte, 0, len(ac.snatchedInputPending)+len(data)) | |
| 71 | - | combined = append(combined, ac.snatchedInputPending...) | |
| 72 | - | combined = append(combined, data...) | |
| 73 | - | ac.snatchedInputPending = nil | |
| 74 | - | d.retainSnatchedSequenceLocked(ac, combined) | |
| 75 | - | ac.snatchedInputMu.Unlock() | |
| 76 | - | pendingESC.stop() | |
| 77 | - | return snatchedInputNone | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | if len(data) == 1 { | |
| 81 | - | var action snatchedInputAction | |
| 82 | - | switch data[0] { | |
| 83 | - | case 'r', 'R': | |
| 84 | - | action = snatchedInputReclaim | |
| 85 | - | case 'q', 'Q': | |
| 86 | - | action = snatchedInputQuit | |
| 87 | - | case keys.ESC: | |
| 88 | - | ac.snatchedInputPending = append(ac.snatchedInputPending[:0], keys.ESC) | |
| 89 | - | ac.snatchedInputESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) { | |
| 90 | - | d.fireSnatchedEscape(token, timer) | |
| 91 | - | }) | |
| 92 | - | } | |
| 93 | - | ac.snatchedInputMu.Unlock() | |
| 94 | - | return action | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | // A complete/coalesced event is always ignored. Only an incomplete CSI or | |
| 98 | - | // SS3 prefix is retained to prevent its future final byte from acting. | |
| 99 | - | d.retainSnatchedSequenceLocked(ac, data) | |
| 100 | - | ac.snatchedInputMu.Unlock() | |
| 101 | - | return snatchedInputNone | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | func (d *Daemon) retainSnatchedSequenceLocked(ac *attachedClient, data []byte) { | |
| 105 | - | if !snatchedSequencePrefix(data) || snatchedSequenceFinal(data[2:]) >= 0 { | |
| 106 | - | return | |
| 107 | - | } | |
| 108 | - | if len(data) > snatchedInputSequenceLimit { | |
| 109 | - | ac.snatchedInputDrain = true | |
| 110 | - | return | |
| 111 | - | } | |
| 112 | - | ac.snatchedInputPending = append(ac.snatchedInputPending[:0], data...) | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | func snatchedSequencePrefix(data []byte) bool { | |
| 116 | - | return len(data) >= 2 && data[0] == keys.ESC && (data[1] == '[' || data[1] == 'O') | |
| 117 | - | } | |
| 118 | - | ||
| 119 | - | func snatchedSequenceFinal(data []byte) int { | |
| 120 | - | for i, b := range data { | |
| 121 | - | if b >= 0x40 && b <= 0x7e { | |
| 122 | - | return i | |
| 123 | - | } | |
| 124 | - | } | |
| 125 | - | return -1 | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | func (d *Daemon) fireSnatchedEscape(token attachmentRoleToken, timer ports.Timer) { | |
| 129 | - | ac := token.ac | |
| 130 | - | if ac == nil { | |
| 131 | - | return | |
| 132 | - | } | |
| 133 | - | ac.snatchedInputMu.Lock() | |
| 134 | - | if ac.snatchedInputESC.timer != timer || len(ac.snatchedInputPending) != 1 || ac.snatchedInputPending[0] != keys.ESC { | |
| 135 | - | ac.snatchedInputMu.Unlock() | |
| 136 | - | return | |
| 137 | - | } | |
| 138 | - | ac.snatchedInputPending = nil | |
| 139 | - | ac.snatchedInputESC.timer = nil | |
| 140 | - | ac.snatchedInputESC.done = nil | |
| 141 | - | ac.snatchedInputMu.Unlock() | |
| 142 | - | ||
| 143 | - | if d.afterSnatchedEscapeAccepted != nil { | |
| 144 | - | d.afterSnatchedEscapeAccepted() | |
| 145 | - | } | |
| 146 | - | if !token.current() { | |
| 147 | - | if d.afterSnatchedEscapeAttempt != nil { | |
| 148 | - | d.afterSnatchedEscapeAttempt(false) | |
| 149 | - | } | |
| 150 | - | return | |
| 151 | - | } | |
| 152 | - | ticket, admitted := ac.beginRoleEffect(token) | |
| 153 | - | if d.afterSnatchedEscapeAttempt != nil { | |
| 154 | - | d.afterSnatchedEscapeAttempt(admitted) | |
| 155 | - | } | |
| 156 | - | if !admitted { | |
| 157 | - | return | |
| 158 | - | } | |
| 159 | - | token.effect = ticket | |
| 160 | - | defer token.endRoleEffect() | |
| 161 | - | d.quitSnatchedAttachment(token) | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | // acquireActivationBarrier waits for the requester's serialized output path | |
| 165 | - | // without retaining role, daemon, routing, session, coordinator, or pane locks. | |
| 166 | - | // The caller has already frozen and drained the requester's role gate, so no new | |
| 167 | - | // role-bound sender can race this acquisition. A deadline closes only the exact | |
| 168 | - | // captured link, which releases a transport Send currently holding sendMu. | |
| 169 | - | func (d *Daemon) acquireActivationBarrier(token attachmentRoleToken) (func(), error) { | |
| 170 | - | if token.ac == nil || token.transport.transport == nil || !token.current() { | |
| 171 | - | return nil, errAttachmentTransition | |
| 172 | - | } | |
| 173 | - | ||
| 174 | - | acquired := make(chan struct{}) | |
| 175 | - | cancel := make(chan struct{}) | |
| 176 | - | go func() { | |
| 177 | - | token.ac.sendMu.Lock() | |
| 178 | - | select { | |
| 179 | - | case acquired <- struct{}{}: | |
| 180 | - | // The receiver now owns the matching Unlock obligation. | |
| 181 | - | case <-cancel: | |
| 182 | - | token.ac.sendMu.Unlock() | |
| 183 | - | } | |
| 184 | - | }() | |
| 185 | - | ||
| 186 | - | timer := d.clock.NewTimer(detachNotifyTimeout) | |
| 187 | - | select { | |
| 188 | - | case <-acquired: | |
| 189 | - | timer.Stop() | |
| 190 | - | if token.ac.roleGeneration.Load() != token.generation || | |
| 191 | - | !token.ac.transportSnapshotCurrent(token.transport) { | |
| 192 | - | token.ac.sendMu.Unlock() | |
| 193 | - | return nil, errAttachmentTransition | |
| 194 | - | } | |
| 195 | - | return token.ac.sendMu.Unlock, nil | |
| 196 | - | case <-timer.C(): | |
| 197 | - | close(cancel) | |
| 198 | - | _ = token.ac.closeCapturedTransport(token.transport.transport) | |
| 199 | - | return nil, errSendTimedOut | |
| 200 | - | } | |
| 201 | - | } | |
| 202 | - | ||
| 203 | - | func (d *Daemon) reclaimSnatchedAttachment(token attachmentRoleToken) bool { | |
| 204 | - | if token.role != attachmentSnatched || token.effect == nil { | |
| 205 | - | return false | |
| 206 | - | } | |
| 207 | - | token.effect.bindActionEnd(d, "reclaim-snatched") | |
| 208 | - | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 209 | - | source: token.sess, target: token.sess, next: token.ac, | |
| 210 | - | expectedRole: attachmentSnatched, targetRole: attachmentActive, | |
| 211 | - | expectedTransport: token.transport, sourceToken: &token, | |
| 212 | - | action: "reclaim-snatched", ready: true, activationBarrier: true, | |
| 213 | - | }) | |
| 214 | - | if err != nil { | |
| 215 | - | if errors.Is(err, errSendTimedOut) { | |
| 216 | - | d.attachmentCleanupWg.Go(func() { d.parkOrDropSnatchedAttachment(token) }) | |
| 217 | - | } else { | |
| 218 | - | d.showSnatchedUnavailable(token) | |
| 219 | - | } | |
| 220 | - | return false | |
| 221 | - | } | |
| 222 | - | d.deferAttachmentTransitionCleanups(transition) | |
| 223 | - | if d.beforeReclaimFirstPaint != nil { | |
| 224 | - | d.beforeReclaimFirstPaint(transition.published) | |
| 225 | - | } | |
| 226 | - | d.firstPaintForTransition(transition.published) | |
| 227 | - | return true | |
| 228 | - | } | |
| 229 | - | ||
| 230 | - | // showSnatchedUnavailable is a best-effort precommit failure panel. Fresh role | |
| 231 | - | // admission and the generation-aware sender make it inert after a concurrent | |
| 232 | - | // reclaim, quit, transport replacement, or session teardown. | |
| 233 | - | func (d *Daemon) showSnatchedUnavailable(token attachmentRoleToken) { | |
| 234 | - | if !token.current() { | |
| 235 | - | return | |
| 236 | - | } | |
| 237 | - | fresh := token | |
| 238 | - | fresh.effect = nil | |
| 239 | - | ticket, admitted := token.ac.beginRoleEffect(fresh) | |
| 240 | - | if !admitted { | |
| 241 | - | return | |
| 242 | - | } | |
| 243 | - | fresh.effect = ticket | |
| 244 | - | defer fresh.endRoleEffect() | |
| 245 | - | if err := d.sendSnatchedPanel(token.ac, token.transport, token.generation, "Session is no longer available.", ticket); err != nil { | |
| 246 | - | fresh.endRoleEffect() | |
| 247 | - | d.parkOrDropSnatchedAttachment(token) | |
| 248 | - | } | |
| 249 | - | } | |
| 250 | - | ||
| 251 | - | func (d *Daemon) quitSnatchedAttachment(token attachmentRoleToken) bool { | |
| 252 | - | if token.role != attachmentSnatched || token.effect == nil { | |
| 253 | - | return false | |
| 254 | - | } | |
| 255 | - | token.effect.bindActionEnd(d, "quit-snatched") | |
| 256 | - | _ = d.boundedSendSnatchedControl(token, frameDetached(ports.ReasonDetach)) | |
| 257 | - | // Terminal cleanup freezes the same role gate, so release this action's | |
| 258 | - | // admission before attempting the generation-bound removal. | |
| 259 | - | token.endRoleEffect() | |
| 260 | - | return d.dropSnatchedAttachment(token) | |
| 261 | - | } | |
D internal/usecase/daemon/snatched_input_test.go +0 -363
| @@ -1,363 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | "io" | |
| 6 | - | "strings" | |
| 7 | - | "sync" | |
| 8 | - | "testing" | |
| 9 | - | "time" | |
| 10 | - | ||
| 11 | - | "github.com/stretchr/testify/require" | |
| 12 | - | ||
| 13 | - | "github.com/bnema/vev/internal/domain" | |
| 14 | - | "github.com/bnema/vev/internal/ports" | |
| 15 | - | "github.com/bnema/vev/internal/usecase/keys" | |
| 16 | - | ) | |
| 17 | - | ||
| 18 | - | type snatchedInputTimer struct { | |
| 19 | - | ch chan time.Time | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | func newSnatchedInputTimer() *snatchedInputTimer { | |
| 23 | - | return &snatchedInputTimer{ch: make(chan time.Time, 1)} | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | func (t *snatchedInputTimer) C() <-chan time.Time { return t.ch } | |
| 27 | - | func (*snatchedInputTimer) Reset(time.Duration) bool { return false } | |
| 28 | - | func (*snatchedInputTimer) Stop() bool { return true } | |
| 29 | - | func (t *snatchedInputTimer) Fire() { t.ch <- time.Time{} } | |
| 30 | - | ||
| 31 | - | type snatchedInputClock struct { | |
| 32 | - | timers chan *snatchedInputTimer | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | func newSnatchedInputClock() *snatchedInputClock { | |
| 36 | - | return &snatchedInputClock{timers: make(chan *snatchedInputTimer, 8)} | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | func (*snatchedInputClock) Now() time.Time { return time.Time{} } | |
| 40 | - | func (c *snatchedInputClock) NewTimer(delay time.Duration) ports.Timer { | |
| 41 | - | if delay != keys.ESCDelay { | |
| 42 | - | return stubTimer{} | |
| 43 | - | } | |
| 44 | - | timer := newSnatchedInputTimer() | |
| 45 | - | c.timers <- timer | |
| 46 | - | return timer | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | type snatchedCloseSignalTransport struct { | |
| 50 | - | mu sync.Mutex | |
| 51 | - | closed bool | |
| 52 | - | closedCh chan struct{} | |
| 53 | - | closeOnce sync.Once | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | func newSnatchedCloseSignalTransport() *snatchedCloseSignalTransport { | |
| 57 | - | return &snatchedCloseSignalTransport{closedCh: make(chan struct{})} | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | func (*snatchedCloseSignalTransport) Send(ports.Frame) error { return nil } | |
| 61 | - | func (*snatchedCloseSignalTransport) Recv() (ports.Frame, error) { | |
| 62 | - | return ports.Frame{}, errors.New("closed") | |
| 63 | - | } | |
| 64 | - | func (t *snatchedCloseSignalTransport) Close() error { | |
| 65 | - | t.mu.Lock() | |
| 66 | - | t.closed = true | |
| 67 | - | t.mu.Unlock() | |
| 68 | - | t.closeOnce.Do(func() { close(t.closedCh) }) | |
| 69 | - | return nil | |
| 70 | - | } | |
| 71 | - | func (t *snatchedCloseSignalTransport) Closed() bool { | |
| 72 | - | t.mu.Lock() | |
| 73 | - | defer t.mu.Unlock() | |
| 74 | - | return t.closed | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | type blockedSnatchedQuitTransport struct { | |
| 78 | - | recv chan ports.Frame | |
| 79 | - | sendStarted chan struct{} | |
| 80 | - | sendDone chan struct{} | |
| 81 | - | closed chan struct{} | |
| 82 | - | closeOnce sync.Once | |
| 83 | - | sendOnce sync.Once | |
| 84 | - | mu sync.Mutex | |
| 85 | - | isClosed bool | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | func newBlockedSnatchedQuitTransport() *blockedSnatchedQuitTransport { | |
| 89 | - | return &blockedSnatchedQuitTransport{ | |
| 90 | - | recv: make(chan ports.Frame, 1), | |
| 91 | - | sendStarted: make(chan struct{}), | |
| 92 | - | sendDone: make(chan struct{}), | |
| 93 | - | closed: make(chan struct{}), | |
| 94 | - | } | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | func (t *blockedSnatchedQuitTransport) Send(frame ports.Frame) error { | |
| 98 | - | if frame.Type != ports.MsgDetached { | |
| 99 | - | return nil | |
| 100 | - | } | |
| 101 | - | t.sendOnce.Do(func() { close(t.sendStarted) }) | |
| 102 | - | <-t.closed | |
| 103 | - | close(t.sendDone) | |
| 104 | - | return io.ErrClosedPipe | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | func (t *blockedSnatchedQuitTransport) Recv() (ports.Frame, error) { | |
| 108 | - | select { | |
| 109 | - | case frame := <-t.recv: | |
| 110 | - | return frame, nil | |
| 111 | - | case <-t.closed: | |
| 112 | - | return ports.Frame{}, io.ErrClosedPipe | |
| 113 | - | } | |
| 114 | - | } | |
| 115 | - | ||
| 116 | - | func (t *blockedSnatchedQuitTransport) Close() error { | |
| 117 | - | t.mu.Lock() | |
| 118 | - | t.isClosed = true | |
| 119 | - | t.mu.Unlock() | |
| 120 | - | t.closeOnce.Do(func() { close(t.closed) }) | |
| 121 | - | return nil | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | func (t *blockedSnatchedQuitTransport) Closed() bool { | |
| 125 | - | t.mu.Lock() | |
| 126 | - | defer t.mu.Unlock() | |
| 127 | - | return t.isClosed | |
| 128 | - | } | |
| 129 | - | ||
| 130 | - | func newSnatchedInputFixture(t *testing.T, clock ports.Clock) (*Daemon, *session, *attachedClient, *attachedClient, *snatchedCloseSignalTransport) { | |
| 131 | - | t.Helper() | |
| 132 | - | d := newTestDaemon(t, nil, clock) | |
| 133 | - | waitingTransport := newSnatchedCloseSignalTransport() | |
| 134 | - | waiting := &attachedClient{ | |
| 135 | - | tr: waitingTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}, | |
| 136 | - | } | |
| 137 | - | active := &attachedClient{ | |
| 138 | - | tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: waiting.size, | |
| 139 | - | } | |
| 140 | - | waiting.initOverlays() | |
| 141 | - | active.initOverlays() | |
| 142 | - | sess := &session{sessionCore: sessionCore{id: "strict-input", client: active, snatched: map[*attachedClient]struct{}{waiting: {}}}} | |
| 143 | - | waiting.setSession(sess) | |
| 144 | - | active.setSession(sess) | |
| 145 | - | d.sessions[sess.id] = sess | |
| 146 | - | d.attachCoordinator(sess, nil, active, true) | |
| 147 | - | return d, sess, waiting, active, waitingTransport | |
| 148 | - | } | |
| 149 | - | ||
| 150 | - | func sendSnatchedInput(d *Daemon, sess *session, ac *attachedClient, data string) bool { | |
| 151 | - | tr := ac.transport() | |
| 152 | - | token := sess.attachmentToken(ac, tr) | |
| 153 | - | return d.handleSnatchedClientFrame(token, ports.Frame{ | |
| 154 | - | Type: ports.MsgInput, | |
| 155 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte(data)}), | |
| 156 | - | }) | |
| 157 | - | } | |
| 158 | - | ||
| 159 | - | func TestSnatchedInputStrictActions(t *testing.T) { | |
| 160 | - | tests := []struct { | |
| 161 | - | name string | |
| 162 | - | chunks []string | |
| 163 | - | fireEscape bool | |
| 164 | - | wantRole attachmentRole | |
| 165 | - | wantClosed bool | |
| 166 | - | }{ | |
| 167 | - | {name: "lowercase reclaim", chunks: []string{"r"}, wantRole: attachmentActive}, | |
| 168 | - | {name: "uppercase reclaim", chunks: []string{"R"}, wantRole: attachmentActive}, | |
| 169 | - | {name: "lowercase quit", chunks: []string{"q"}, wantRole: attachmentDetached, wantClosed: true}, | |
| 170 | - | {name: "uppercase quit", chunks: []string{"Q"}, wantRole: attachmentDetached, wantClosed: true}, | |
| 171 | - | {name: "standalone escape quits after delay", chunks: []string{"\x1b"}, fireEscape: true, wantRole: attachmentDetached, wantClosed: true}, | |
| 172 | - | {name: "coalesced reclaim ignored", chunks: []string{"rr"}, wantRole: attachmentSnatched}, | |
| 173 | - | {name: "paste containing quit ignored", chunks: []string{"paste q"}, wantRole: attachmentSnatched}, | |
| 174 | - | {name: "complete CSI ignored", chunks: []string{"\x1b[r"}, wantRole: attachmentSnatched}, | |
| 175 | - | {name: "complete SS3 ignored", chunks: []string{"\x1bOR"}, wantRole: attachmentSnatched}, | |
| 176 | - | {name: "complete sequence tail ignored", chunks: []string{"\x1b[Aq"}, wantRole: attachmentSnatched}, | |
| 177 | - | {name: "split CSI final reclaim ignored", chunks: []string{"\x1b[", "r"}, wantRole: attachmentSnatched}, | |
| 178 | - | {name: "split SS3 final reclaim ignored", chunks: []string{"\x1bO", "R"}, wantRole: attachmentSnatched}, | |
| 179 | - | {name: "split sequence frame tail ignored", chunks: []string{"\x1b[", "Aq"}, wantRole: attachmentSnatched}, | |
| 180 | - | {name: "escape followed by action ignored as alt chord", chunks: []string{"\x1b", "q"}, wantRole: attachmentSnatched}, | |
| 181 | - | {name: "CSI prefix remains inert", chunks: []string{"\x1b[", "12;"}, wantRole: attachmentSnatched}, | |
| 182 | - | {name: "overflow drain consumes final and frame tail", chunks: []string{"\x1b[", strings.Repeat(";", 80), "rQ"}, wantRole: attachmentSnatched}, | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | for _, tt := range tests { | |
| 186 | - | t.Run(tt.name, func(t *testing.T) { | |
| 187 | - | clock := newSnatchedInputClock() | |
| 188 | - | d, sess, waiting, active, waitingTransport := newSnatchedInputFixture(t, clock) | |
| 189 | - | for _, chunk := range tt.chunks { | |
| 190 | - | sendSnatchedInput(d, sess, waiting, chunk) | |
| 191 | - | } | |
| 192 | - | if tt.fireEscape { | |
| 193 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting), "escape acted before its delay") | |
| 194 | - | require.False(t, waitingTransport.Closed(), "escape closed the transport before its delay") | |
| 195 | - | timer := awaitTestValue(t, clock.timers, "standalone escape did not create a timer") | |
| 196 | - | timer.Fire() | |
| 197 | - | awaitTestCompletion(t, waitingTransport.closedCh, "standalone escape did not quit") | |
| 198 | - | } | |
| 199 | - | d.attachmentCleanupWg.Wait() | |
| 200 | - | ||
| 201 | - | require.Equal(t, tt.wantRole, sess.attachmentRole(waiting)) | |
| 202 | - | require.Equal(t, tt.wantClosed, waitingTransport.Closed()) | |
| 203 | - | if tt.wantRole == attachmentActive { | |
| 204 | - | require.Same(t, waiting, sess.client) | |
| 205 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(active)) | |
| 206 | - | } else { | |
| 207 | - | require.Same(t, active, sess.client) | |
| 208 | - | } | |
| 209 | - | }) | |
| 210 | - | } | |
| 211 | - | } | |
| 212 | - | ||
| 213 | - | func TestBlockedSnatchedQuitDeadlineRetiresOnlyExactWaiter(t *testing.T) { | |
| 214 | - | clock := &signalClock{timers: make(chan *signalTimer, 2)} | |
| 215 | - | waitingTransport := newBlockedSnatchedQuitTransport() | |
| 216 | - | defer func() { _ = waitingTransport.Close() }() | |
| 217 | - | activeTransport := &closeTrackingTransport{} | |
| 218 | - | d, sess, waiting, active := newAtomicReclaimFixture(t, clock, waitingTransport, activeTransport) | |
| 219 | - | ||
| 220 | - | otherTransport := &closeTrackingTransport{} | |
| 221 | - | other := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: waiting.size} | |
| 222 | - | other.initOverlays() | |
| 223 | - | other.setSession(sess) | |
| 224 | - | sess.mu.Lock() | |
| 225 | - | sess.addSnatchedLocked(other) | |
| 226 | - | sess.mu.Unlock() | |
| 227 | - | otherToken := sess.attachmentToken(other, otherTransport) | |
| 228 | - | require.Equal(t, attachmentSnatched, otherToken.role) | |
| 229 | - | ||
| 230 | - | ownerLease := sess.renderCoordinator().attachmentLease(active) | |
| 231 | - | require.NotNil(t, ownerLease) | |
| 232 | - | activeGeneration := active.roleGeneration.Load() | |
| 233 | - | otherGeneration := other.roleGeneration.Load() | |
| 234 | - | ||
| 235 | - | loopDone := make(chan struct{}) | |
| 236 | - | go func() { | |
| 237 | - | d.runConnLoop(waiting) | |
| 238 | - | close(loopDone) | |
| 239 | - | }() | |
| 240 | - | waitingTransport.recv <- ports.Frame{ | |
| 241 | - | Type: ports.MsgInput, | |
| 242 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte{'q'}}), | |
| 243 | - | } | |
| 244 | - | awaitTestCompletion(t, waitingTransport.sendStarted, "snatched quit did not attempt its detach acknowledgement") | |
| 245 | - | select { | |
| 246 | - | case <-loopDone: | |
| 247 | - | t.Fatal("snatched quit returned before its acknowledgement deadline") | |
| 248 | - | default: | |
| 249 | - | } | |
| 250 | - | ||
| 251 | - | timer := awaitTestValue(t, clock.timers, "snatched quit did not install its acknowledgement deadline") | |
| 252 | - | require.Equal(t, detachNotifyTimeout, timer.duration) | |
| 253 | - | timer.ch <- time.Time{} | |
| 254 | - | awaitTestCompletion(t, waitingTransport.sendDone, "closing the captured link did not release its blocked send") | |
| 255 | - | awaitTestCompletion(t, loopDone, "snatched connection parser did not retire after the acknowledgement deadline") | |
| 256 | - | ||
| 257 | - | require.True(t, waitingTransport.Closed()) | |
| 258 | - | requireRoleGateRetired(t, waiting) | |
| 259 | - | require.Equal(t, attachmentDetached, sess.attachmentRole(waiting)) | |
| 260 | - | require.Nil(t, waiting.currentSession()) | |
| 261 | - | require.Nil(t, waiting.transport()) | |
| 262 | - | require.Same(t, active, sess.client) | |
| 263 | - | require.Equal(t, attachmentActive, sess.attachmentRole(active)) | |
| 264 | - | require.Same(t, ownerLease, sess.renderCoordinator().attachmentLease(active)) | |
| 265 | - | require.Equal(t, activeGeneration, active.roleGeneration.Load()) | |
| 266 | - | require.False(t, activeTransport.Closed()) | |
| 267 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(other)) | |
| 268 | - | require.Equal(t, otherGeneration, other.roleGeneration.Load()) | |
| 269 | - | require.Same(t, otherTransport, other.transport()) | |
| 270 | - | require.False(t, otherTransport.Closed()) | |
| 271 | - | } | |
| 272 | - | ||
| 273 | - | func TestBlockedSnatchedQuitCleanupRejectsReclaimedTransportGeneration(t *testing.T) { | |
| 274 | - | clock := &signalClock{timers: make(chan *signalTimer, 2)} | |
| 275 | - | staleTransport := newBlockedSnatchedQuitTransport() | |
| 276 | - | defer func() { _ = staleTransport.Close() }() | |
| 277 | - | ownerTransport := &closeTrackingTransport{} | |
| 278 | - | d, sess, waiting, owner := newAtomicReclaimFixture(t, clock, staleTransport, ownerTransport) | |
| 279 | - | ||
| 280 | - | ticketEnded := make(chan struct{}) | |
| 281 | - | releaseQuit := make(chan struct{}) | |
| 282 | - | d.afterActionRoleEffectEnded = func(action string) { | |
| 283 | - | if action == "quit-snatched" { | |
| 284 | - | close(ticketEnded) | |
| 285 | - | <-releaseQuit | |
| 286 | - | } | |
| 287 | - | } | |
| 288 | - | loopDone := make(chan struct{}) | |
| 289 | - | go func() { | |
| 290 | - | d.runConnLoop(waiting) | |
| 291 | - | close(loopDone) | |
| 292 | - | }() | |
| 293 | - | staleTransport.recv <- ports.Frame{ | |
| 294 | - | Type: ports.MsgInput, | |
| 295 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte{'Q'}}), | |
| 296 | - | } | |
| 297 | - | awaitTestCompletion(t, staleTransport.sendStarted, "snatched quit did not block in its captured transport") | |
| 298 | - | timer := awaitTestValue(t, clock.timers, "snatched quit did not install its acknowledgement deadline") | |
| 299 | - | timer.ch <- time.Time{} | |
| 300 | - | awaitTestCompletion(t, staleTransport.sendDone, "deadline close did not retire the captured send") | |
| 301 | - | awaitTestCompletion(t, ticketEnded, "snatched quit did not end its role ticket before cleanup") | |
| 302 | - | ||
| 303 | - | freshTransport := &closeTrackingTransport{} | |
| 304 | - | waiting.replaceTransport(freshTransport) | |
| 305 | - | reclaimed, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 306 | - | target: sess, next: waiting, expectedRole: attachmentSnatched, targetRole: attachmentActive, | |
| 307 | - | expectedTransport: waiting.transportSnapshot(), ready: true, | |
| 308 | - | }) | |
| 309 | - | require.NoError(t, err) | |
| 310 | - | d.deferAttachmentTransitionCleanups(reclaimed) | |
| 311 | - | freshGeneration := reclaimed.published.generation | |
| 312 | - | freshLease := reclaimed.published.lease | |
| 313 | - | ||
| 314 | - | close(releaseQuit) | |
| 315 | - | awaitTestCompletion(t, loopDone, "stale quit parser did not retire after reclaim") | |
| 316 | - | d.attachmentCleanupWg.Wait() | |
| 317 | - | ||
| 318 | - | require.True(t, staleTransport.Closed()) | |
| 319 | - | requireRoleGateRetired(t, waiting) | |
| 320 | - | require.Same(t, waiting, sess.client) | |
| 321 | - | require.Equal(t, attachmentActive, sess.attachmentRole(waiting)) | |
| 322 | - | require.Equal(t, freshGeneration, waiting.roleGeneration.Load()) | |
| 323 | - | require.Same(t, freshTransport, waiting.transport()) | |
| 324 | - | require.False(t, freshTransport.Closed(), "stale quit cleanup closed the reclaimed transport") | |
| 325 | - | require.Same(t, freshLease, sess.renderCoordinator().attachmentLease(waiting)) | |
| 326 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(owner)) | |
| 327 | - | require.False(t, ownerTransport.Closed(), "stale quit cleanup affected the displaced owner") | |
| 328 | - | } | |
| 329 | - | ||
| 330 | - | func TestSnatchedInputTimerCannotAffectNewRoleOrTransport(t *testing.T) { | |
| 331 | - | clock := newSnatchedInputClock() | |
| 332 | - | d, sess, waiting, _, oldTransport := newSnatchedInputFixture(t, clock) | |
| 333 | - | ||
| 334 | - | sendSnatchedInput(d, sess, waiting, "\x1b") | |
| 335 | - | timer := awaitTestValue(t, clock.timers, "standalone escape did not create a timer") | |
| 336 | - | accepted := make(chan struct{}) | |
| 337 | - | release := make(chan struct{}) | |
| 338 | - | attempted := make(chan bool, 1) | |
| 339 | - | d.afterSnatchedEscapeAccepted = func() { | |
| 340 | - | close(accepted) | |
| 341 | - | <-release | |
| 342 | - | } | |
| 343 | - | d.afterSnatchedEscapeAttempt = func(admitted bool) { attempted <- admitted } | |
| 344 | - | timer.Fire() | |
| 345 | - | awaitTestCompletion(t, accepted, "escape timer callback did not consume parser state") | |
| 346 | - | ||
| 347 | - | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 348 | - | target: sess, next: waiting, expectedRole: attachmentSnatched, targetRole: attachmentActive, | |
| 349 | - | expectedTransport: waiting.transportSnapshot(), ready: true, | |
| 350 | - | }) | |
| 351 | - | require.NoError(t, err) | |
| 352 | - | d.deferAttachmentTransitionCleanups(result) | |
| 353 | - | newTransport := newSnatchedCloseSignalTransport() | |
| 354 | - | waiting.replaceTransport(newTransport) | |
| 355 | - | close(release) | |
| 356 | - | require.False(t, awaitTestValue(t, attempted, "escape callback did not revalidate its stale role")) | |
| 357 | - | d.attachmentCleanupWg.Wait() | |
| 358 | - | ||
| 359 | - | require.Equal(t, attachmentActive, sess.attachmentRole(waiting)) | |
| 360 | - | require.Same(t, newTransport, waiting.transport()) | |
| 361 | - | require.False(t, newTransport.Closed(), "stale timer closed the replacement transport") | |
| 362 | - | require.False(t, oldTransport.Closed(), "stale timer closed the old transport after role activation") | |
| 363 | - | } | |
D internal/usecase/daemon/snatched_reclaim_test.go +0 -444
| @@ -1,444 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | "sync" | |
| 6 | - | "testing" | |
| 7 | - | "time" | |
| 8 | - | ||
| 9 | - | "github.com/stretchr/testify/require" | |
| 10 | - | ||
| 11 | - | "github.com/bnema/vev/internal/domain" | |
| 12 | - | "github.com/bnema/vev/internal/ports" | |
| 13 | - | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 14 | - | "github.com/bnema/vev/pkg/renderer" | |
| 15 | - | ) | |
| 16 | - | ||
| 17 | - | type failingReclaimTransport struct { | |
| 18 | - | mu sync.Mutex | |
| 19 | - | closed bool | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | func (*failingReclaimTransport) Send(ports.Frame) error { return errors.New("send failed") } | |
| 23 | - | func (*failingReclaimTransport) Recv() (ports.Frame, error) { | |
| 24 | - | return ports.Frame{}, errors.New("closed") | |
| 25 | - | } | |
| 26 | - | func (t *failingReclaimTransport) Close() error { | |
| 27 | - | t.mu.Lock() | |
| 28 | - | t.closed = true | |
| 29 | - | t.mu.Unlock() | |
| 30 | - | return nil | |
| 31 | - | } | |
| 32 | - | func (t *failingReclaimTransport) Closed() bool { | |
| 33 | - | t.mu.Lock() | |
| 34 | - | defer t.mu.Unlock() | |
| 35 | - | return t.closed | |
| 36 | - | } | |
| 37 | - | ||
| 38 | - | func newAtomicReclaimFixture(t *testing.T, clock ports.Clock, waitingTransport, activeTransport ports.Transport) (*Daemon, *session, *attachedClient, *attachedClient) { | |
| 39 | - | t.Helper() | |
| 40 | - | d := newTestDaemon(t, nil, clock) | |
| 41 | - | waiting := &attachedClient{tr: waitingTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 42 | - | active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size} | |
| 43 | - | waiting.initOverlays() | |
| 44 | - | active.initOverlays() | |
| 45 | - | sess := &session{sessionCore: sessionCore{id: "atomic-reclaim", client: active, | |
| 46 | - | snatched: map[*attachedClient]struct{}{waiting: {}}}, | |
| 47 | - | } | |
| 48 | - | waiting.setSession(sess) | |
| 49 | - | active.setSession(sess) | |
| 50 | - | d.sessions[sess.id] = sess | |
| 51 | - | d.attachCoordinator(sess, nil, active, true) | |
| 52 | - | return d, sess, waiting, active | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | func requireSingleOwnerAndLease(t *testing.T, sess *session, clients ...*attachedClient) { | |
| 56 | - | t.Helper() | |
| 57 | - | sess.mu.Lock() | |
| 58 | - | owner := sess.client | |
| 59 | - | snatched := make(map[*attachedClient]struct{}, len(sess.snatched)) | |
| 60 | - | for ac := range sess.snatched { | |
| 61 | - | snatched[ac] = struct{}{} | |
| 62 | - | } | |
| 63 | - | sess.mu.Unlock() | |
| 64 | - | require.NotNil(t, owner) | |
| 65 | - | ||
| 66 | - | activeCount := 0 | |
| 67 | - | for _, ac := range clients { | |
| 68 | - | if ac == owner { | |
| 69 | - | activeCount++ | |
| 70 | - | continue | |
| 71 | - | } | |
| 72 | - | _, waiting := snatched[ac] | |
| 73 | - | require.True(t, waiting, "every non-owner must remain snatched") | |
| 74 | - | } | |
| 75 | - | require.Equal(t, 1, activeCount) | |
| 76 | - | ||
| 77 | - | rc := sess.renderCoordinator() | |
| 78 | - | rc.mu.Lock() | |
| 79 | - | lease := rc.lease | |
| 80 | - | rc.mu.Unlock() | |
| 81 | - | require.NotNil(t, lease) | |
| 82 | - | require.True(t, lease.active) | |
| 83 | - | require.Same(t, owner, lease.attachment) | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | func TestReclaimSnatchedSwapsOwnerLeaseRebasesAndAppliesTheme(t *testing.T) { | |
| 87 | - | d, sess, requester, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 88 | - | requesterTransport := &closeTrackingTransport{} | |
| 89 | - | requester.replaceTransport(requesterTransport) | |
| 90 | - | d.attachCoordinator(sess, nil, requester, true) | |
| 91 | - | ||
| 92 | - | active := &attachedClient{ | |
| 93 | - | tr: &closeTrackingTransport{}, | |
| 94 | - | output: newOutputStateStream(), | |
| 95 | - | size: requester.size, | |
| 96 | - | } | |
| 97 | - | active.initOverlays() | |
| 98 | - | displaced, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 99 | - | target: sess, next: active, | |
| 100 | - | expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 101 | - | expectedTransport: active.transportSnapshot(), ready: true, | |
| 102 | - | }) | |
| 103 | - | require.NoError(t, err) | |
| 104 | - | d.deferAttachmentTransitionCleanups(displaced) | |
| 105 | - | d.attachmentCleanupWg.Wait() | |
| 106 | - | ||
| 107 | - | requester.sendMu.Lock() | |
| 108 | - | beforeReclaimState := requester.output.next | |
| 109 | - | requester.sendMu.Unlock() | |
| 110 | - | requesterTheme := themeui.Theme{ | |
| 111 | - | Foreground: renderer.RGB{R: 11, G: 22, B: 33}, | |
| 112 | - | Background: renderer.RGB{R: 44, G: 55, B: 66}, | |
| 113 | - | HasFG: true, HasBG: true, Known: true, TrueColor: true, | |
| 114 | - | } | |
| 115 | - | requester.setClientTheme(requesterTheme) | |
| 116 | - | ||
| 117 | - | token := sess.attachmentToken(requester, requesterTransport) | |
| 118 | - | require.Equal(t, attachmentSnatched, token.role) | |
| 119 | - | require.False(t, d.handleSnatchedClientFrame(token, ports.Frame{ | |
| 120 | - | Type: ports.MsgInput, | |
| 121 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 122 | - | })) | |
| 123 | - | ||
| 124 | - | sess.mu.Lock() | |
| 125 | - | require.Same(t, requester, sess.client) | |
| 126 | - | _, activeIsSnatched := sess.snatched[active] | |
| 127 | - | _, requesterIsSnatched := sess.snatched[requester] | |
| 128 | - | sess.mu.Unlock() | |
| 129 | - | require.True(t, activeIsSnatched) | |
| 130 | - | require.False(t, requesterIsSnatched) | |
| 131 | - | rc := sess.renderCoordinator() | |
| 132 | - | lease := rc.attachmentLease(requester) | |
| 133 | - | require.NotNil(t, lease) | |
| 134 | - | require.Nil(t, rc.attachmentLease(active)) | |
| 135 | - | require.True(t, lease.active) | |
| 136 | - | ||
| 137 | - | requester.sendMu.Lock() | |
| 138 | - | require.Greater(t, requester.output.next, beforeReclaimState, "reclaim must emit a reset first paint") | |
| 139 | - | require.GreaterOrEqual(t, requester.output.acked, beforeReclaimState, "reclaim must rebase the old panel output chain") | |
| 140 | - | requester.sendMu.Unlock() | |
| 141 | - | assertSessionDefaultColors(t, sess, requesterTheme.Foreground, requesterTheme.Background) | |
| 142 | - | ||
| 143 | - | d.attachmentCleanupWg.Wait() | |
| 144 | - | require.Equal(t, attachmentSnatched, sess.attachmentRole(active)) | |
| 145 | - | activeTransport, ok := active.transport().(*closeTrackingTransport) | |
| 146 | - | require.True(t, ok, "active transport has unexpected type") | |
| 147 | - | require.False(t, activeTransport.Closed()) | |
| 148 | - | require.Equal(t, domain.Size{Cols: 80, Rows: 24}, requester.size) | |
| 149 | - | } | |
| 150 | - | ||
| 151 | - | func TestFailedReclaimBeforeCommitPreservesOwnerAndShowsUnavailable(t *testing.T) { | |
| 152 | - | waitingTransport := &closeTrackingTransport{} | |
| 153 | - | activeTransport := &closeTrackingTransport{} | |
| 154 | - | d, sess, waiting, active := newAtomicReclaimFixture(t, stubClock{}, waitingTransport, activeTransport) | |
| 155 | - | token := sess.attachmentToken(waiting, waitingTransport) | |
| 156 | - | ||
| 157 | - | d.mu.Lock() | |
| 158 | - | delete(d.sessions, sess.id) | |
| 159 | - | d.mu.Unlock() | |
| 160 | - | d.handleSnatchedClientFrame(token, ports.Frame{ | |
| 161 | - | Type: ports.MsgInput, | |
| 162 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 163 | - | }) | |
| 164 | - | ||
| 165 | - | requireSingleOwnerAndLease(t, sess, waiting, active) | |
| 166 | - | require.Same(t, active, sess.client) | |
| 167 | - | require.False(t, waitingTransport.Closed()) | |
| 168 | - | frames := waitingTransport.Sends() | |
| 169 | - | require.Len(t, frames, 1) | |
| 170 | - | output, err := ports.UnmarshalOutput(frames[0].Payload) | |
| 171 | - | require.NoError(t, err) | |
| 172 | - | require.Contains(t, string(output.Data), "Session is no longer available.") | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | func TestPostcommitPanelFailureRetiresOnlyDisplacedClient(t *testing.T) { | |
| 176 | - | waitingTransport := &closeTrackingTransport{} | |
| 177 | - | activeTransport := &failingReclaimTransport{} | |
| 178 | - | d, sess, waiting, active := newAtomicReclaimFixture(t, stubClock{}, waitingTransport, activeTransport) | |
| 179 | - | token := sess.attachmentToken(waiting, waitingTransport) | |
| 180 | - | ||
| 181 | - | d.handleSnatchedClientFrame(token, ports.Frame{ | |
| 182 | - | Type: ports.MsgInput, | |
| 183 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 184 | - | }) | |
| 185 | - | d.attachmentCleanupWg.Wait() | |
| 186 | - | ||
| 187 | - | require.Same(t, waiting, sess.client) | |
| 188 | - | require.Equal(t, attachmentActive, sess.attachmentRole(waiting)) | |
| 189 | - | require.Equal(t, attachmentDetached, sess.attachmentRole(active)) | |
| 190 | - | require.NotNil(t, sess.renderCoordinator().attachmentLease(waiting)) | |
| 191 | - | require.Nil(t, sess.renderCoordinator().attachmentLease(active)) | |
| 192 | - | require.False(t, waitingTransport.Closed()) | |
| 193 | - | require.True(t, activeTransport.Closed()) | |
| 194 | - | } | |
| 195 | - | ||
| 196 | - | func TestBlockedRequesterActivationDeadlinePreservesOwnerLease(t *testing.T) { | |
| 197 | - | clock := &signalClock{timers: make(chan *signalTimer, 2)} | |
| 198 | - | waitingTransport := newSnatchedCloseSignalTransport() | |
| 199 | - | activeTransport := &closeTrackingTransport{} | |
| 200 | - | d, sess, waiting, active := newAtomicReclaimFixture(t, clock, waitingTransport, activeTransport) | |
| 201 | - | ||
| 202 | - | waiting.sendMu.Lock() | |
| 203 | - | locked := true | |
| 204 | - | defer func() { | |
| 205 | - | if locked { | |
| 206 | - | waiting.sendMu.Unlock() | |
| 207 | - | } | |
| 208 | - | }() | |
| 209 | - | done := make(chan struct{}) | |
| 210 | - | go func() { | |
| 211 | - | token := sess.attachmentToken(waiting, waitingTransport) | |
| 212 | - | d.handleSnatchedClientFrame(token, ports.Frame{ | |
| 213 | - | Type: ports.MsgInput, | |
| 214 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 215 | - | }) | |
| 216 | - | close(done) | |
| 217 | - | }() | |
| 218 | - | ||
| 219 | - | timer := awaitTestValue(t, clock.timers, "reclaim did not install its activation deadline") | |
| 220 | - | require.Equal(t, detachNotifyTimeout, timer.duration) | |
| 221 | - | timer.ch <- time.Time{} | |
| 222 | - | awaitTestCompletion(t, done, "deadline did not release blocked reclaim") | |
| 223 | - | require.Same(t, active, sess.client) | |
| 224 | - | require.Same(t, active, sess.renderCoordinator().attachmentLease(active).attachment) | |
| 225 | - | require.True(t, waitingTransport.Closed()) | |
| 226 | - | ||
| 227 | - | waiting.sendMu.Unlock() | |
| 228 | - | locked = false | |
| 229 | - | d.attachmentCleanupWg.Wait() | |
| 230 | - | require.Equal(t, attachmentDetached, sess.attachmentRole(waiting)) | |
| 231 | - | require.Equal(t, attachmentActive, sess.attachmentRole(active)) | |
| 232 | - | } | |
| 233 | - | ||
| 234 | - | func TestQuitInvalidatesPausedSnatchedFinalizerWithoutLatePanel(t *testing.T) { | |
| 235 | - | waitingTransport := &closeTrackingTransport{} | |
| 236 | - | activeTransport := &closeTrackingTransport{} | |
| 237 | - | d, sess, waiting, active := newAtomicReclaimFixture(t, stubClock{}, waitingTransport, activeTransport) | |
| 238 | - | ||
| 239 | - | finalizerStarted := make(chan struct{}) | |
| 240 | - | releaseFinalizer := make(chan struct{}) | |
| 241 | - | d.afterDisplacedCleanupStarted = func() { | |
| 242 | - | close(finalizerStarted) | |
| 243 | - | <-releaseFinalizer | |
| 244 | - | } | |
| 245 | - | waitingToken := sess.attachmentToken(waiting, waitingTransport) | |
| 246 | - | d.handleSnatchedClientFrame(waitingToken, ports.Frame{ | |
| 247 | - | Type: ports.MsgInput, | |
| 248 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 249 | - | }) | |
| 250 | - | awaitTestCompletion(t, finalizerStarted, "displaced finalizer did not pause") | |
| 251 | - | ||
| 252 | - | activeToken := sess.attachmentToken(active, activeTransport) | |
| 253 | - | require.Equal(t, attachmentSnatched, activeToken.role) | |
| 254 | - | require.True(t, d.handleSnatchedClientFrame(activeToken, ports.Frame{ | |
| 255 | - | Type: ports.MsgInput, | |
| 256 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte{'q'}}), | |
| 257 | - | })) | |
| 258 | - | close(releaseFinalizer) | |
| 259 | - | d.attachmentCleanupWg.Wait() | |
| 260 | - | ||
| 261 | - | require.Equal(t, attachmentDetached, sess.attachmentRole(active)) | |
| 262 | - | frames := activeTransport.Sends() | |
| 263 | - | require.Len(t, frames, 1, "stale finalizer emitted output after quit") | |
| 264 | - | require.Equal(t, ports.MsgDetached, frames[0].Type) | |
| 265 | - | } | |
| 266 | - | ||
| 267 | - | func TestReclaimOrdersDisplacedPanelAfterAdmittedOldPaint(t *testing.T) { | |
| 268 | - | d, sess, requester, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 269 | - | requesterTransport := &closeTrackingTransport{} | |
| 270 | - | requester.replaceTransport(requesterTransport) | |
| 271 | - | d.attachCoordinator(sess, nil, requester, true) | |
| 272 | - | ||
| 273 | - | activeTransport := &closeTrackingTransport{} | |
| 274 | - | active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: requester.size} | |
| 275 | - | active.initOverlays() | |
| 276 | - | initial, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 277 | - | target: sess, next: active, | |
| 278 | - | expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 279 | - | expectedTransport: active.transportSnapshot(), ready: true, | |
| 280 | - | }) | |
| 281 | - | require.NoError(t, err) | |
| 282 | - | d.deferAttachmentTransitionCleanups(initial) | |
| 283 | - | d.attachmentCleanupWg.Wait() | |
| 284 | - | baselineActiveFrames := len(activeTransport.Sends()) | |
| 285 | - | ||
| 286 | - | paintAdmitted := make(chan struct{}) | |
| 287 | - | releasePaint := make(chan struct{}) | |
| 288 | - | d.afterRoleEffectAdmitted = func(token attachmentRoleToken) { | |
| 289 | - | if token.ac == active && token.role == attachmentActive { | |
| 290 | - | close(paintAdmitted) | |
| 291 | - | <-releasePaint | |
| 292 | - | } | |
| 293 | - | } | |
| 294 | - | activeToken := sess.attachmentToken(active, activeTransport) | |
| 295 | - | paintDone := make(chan bool, 1) | |
| 296 | - | go func() { paintDone <- d.firstPaintForTransition(activeToken) }() | |
| 297 | - | awaitTestCompletion(t, paintAdmitted, "old active paint was not admitted") | |
| 298 | - | ||
| 299 | - | reclaimFrozen := make(chan struct{}) | |
| 300 | - | var frozenOnce sync.Once | |
| 301 | - | d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) { | |
| 302 | - | if action == "reclaim-snatched" { | |
| 303 | - | frozenOnce.Do(func() { close(reclaimFrozen) }) | |
| 304 | - | } | |
| 305 | - | } | |
| 306 | - | reclaimDone := make(chan struct{}) | |
| 307 | - | go func() { | |
| 308 | - | token := sess.attachmentToken(requester, requesterTransport) | |
| 309 | - | d.handleSnatchedClientFrame(token, ports.Frame{ | |
| 310 | - | Type: ports.MsgInput, | |
| 311 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 312 | - | }) | |
| 313 | - | close(reclaimDone) | |
| 314 | - | }() | |
| 315 | - | awaitTestCompletion(t, reclaimFrozen, "reclaim did not freeze role effects behind old paint") | |
| 316 | - | close(releasePaint) | |
| 317 | - | require.True(t, awaitTestValue(t, paintDone, "old paint did not finish")) | |
| 318 | - | awaitTestCompletion(t, reclaimDone, "reclaim did not finish after old paint") | |
| 319 | - | d.attachmentCleanupWg.Wait() | |
| 320 | - | ||
| 321 | - | frames := activeTransport.Sends()[baselineActiveFrames:] | |
| 322 | - | require.NotEmpty(t, frames) | |
| 323 | - | last := frames[len(frames)-1] | |
| 324 | - | require.Equal(t, ports.MsgOutput, last.Type) | |
| 325 | - | output, err := ports.UnmarshalOutput(last.Payload) | |
| 326 | - | require.NoError(t, err) | |
| 327 | - | require.Contains(t, string(output.Data), "Session snatched", "old paint overtook the displaced reset panel") | |
| 328 | - | requireSingleOwnerAndLease(t, sess, requester, active) | |
| 329 | - | } | |
| 330 | - | ||
| 331 | - | func TestLaterReclaimMakesPausedFirstPaintStale(t *testing.T) { | |
| 332 | - | d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 333 | - | firstTransport := &closeTrackingTransport{} | |
| 334 | - | first.replaceTransport(firstTransport) | |
| 335 | - | d.attachCoordinator(sess, nil, first, true) | |
| 336 | - | ||
| 337 | - | secondTransport := &closeTrackingTransport{} | |
| 338 | - | second := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: first.size} | |
| 339 | - | second.initOverlays() | |
| 340 | - | initial, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 341 | - | target: sess, next: second, | |
| 342 | - | expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 343 | - | expectedTransport: second.transportSnapshot(), ready: true, | |
| 344 | - | }) | |
| 345 | - | require.NoError(t, err) | |
| 346 | - | d.deferAttachmentTransitionCleanups(initial) | |
| 347 | - | d.attachmentCleanupWg.Wait() | |
| 348 | - | baselineFirstFrames := len(firstTransport.Sends()) | |
| 349 | - | ||
| 350 | - | firstPaintPaused := make(chan struct{}) | |
| 351 | - | releaseFirstPaint := make(chan struct{}) | |
| 352 | - | d.beforeReclaimFirstPaint = func(token attachmentRoleToken) { | |
| 353 | - | if token.ac != first { | |
| 354 | - | return | |
| 355 | - | } | |
| 356 | - | close(firstPaintPaused) | |
| 357 | - | <-releaseFirstPaint | |
| 358 | - | } | |
| 359 | - | firstDone := make(chan struct{}) | |
| 360 | - | go func() { | |
| 361 | - | token := sess.attachmentToken(first, firstTransport) | |
| 362 | - | d.handleSnatchedClientFrame(token, ports.Frame{ | |
| 363 | - | Type: ports.MsgInput, | |
| 364 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 365 | - | }) | |
| 366 | - | close(firstDone) | |
| 367 | - | }() | |
| 368 | - | awaitTestCompletion(t, firstPaintPaused, "first reclaim did not pause before first paint") | |
| 369 | - | require.Same(t, first, sess.client, "first reclaim was not committed before its paint") | |
| 370 | - | ||
| 371 | - | secondToken := sess.attachmentToken(second, secondTransport) | |
| 372 | - | require.Equal(t, attachmentSnatched, secondToken.role) | |
| 373 | - | d.handleSnatchedClientFrame(secondToken, ports.Frame{ | |
| 374 | - | Type: ports.MsgInput, | |
| 375 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 376 | - | }) | |
| 377 | - | require.Same(t, second, sess.client) | |
| 378 | - | close(releaseFirstPaint) | |
| 379 | - | awaitTestCompletion(t, firstDone, "stale first paint did not return") | |
| 380 | - | d.attachmentCleanupWg.Wait() | |
| 381 | - | ||
| 382 | - | newFirstFrames := firstTransport.Sends()[baselineFirstFrames:] | |
| 383 | - | require.Len(t, newFirstFrames, 1, "stale first paint emitted after a later reclaim") | |
| 384 | - | require.Equal(t, ports.MsgOutput, newFirstFrames[0].Type) | |
| 385 | - | output, err := ports.UnmarshalOutput(newFirstFrames[0].Payload) | |
| 386 | - | require.NoError(t, err) | |
| 387 | - | require.Contains(t, string(output.Data), "Session snatched") | |
| 388 | - | requireSingleOwnerAndLease(t, sess, first, second) | |
| 389 | - | } | |
| 390 | - | ||
| 391 | - | func TestAnyWaiterCanReclaimAndConcurrentRequestsKeepSingleOwnerLease(t *testing.T) { | |
| 392 | - | d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 393 | - | firstTransport := &closeTrackingTransport{} | |
| 394 | - | first.replaceTransport(firstTransport) | |
| 395 | - | d.attachCoordinator(sess, nil, first, true) | |
| 396 | - | ||
| 397 | - | secondTransport := &closeTrackingTransport{} | |
| 398 | - | second := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: first.size} | |
| 399 | - | second.initOverlays() | |
| 400 | - | toSecond, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 401 | - | target: sess, next: second, | |
| 402 | - | expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 403 | - | expectedTransport: second.transportSnapshot(), ready: true, | |
| 404 | - | }) | |
| 405 | - | require.NoError(t, err) | |
| 406 | - | d.deferAttachmentTransitionCleanups(toSecond) | |
| 407 | - | d.attachmentCleanupWg.Wait() | |
| 408 | - | ||
| 409 | - | thirdTransport := &closeTrackingTransport{} | |
| 410 | - | third := &attachedClient{tr: thirdTransport, output: newOutputStateStream(), size: first.size} | |
| 411 | - | third.initOverlays() | |
| 412 | - | toThird, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 413 | - | target: sess, next: third, | |
| 414 | - | expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 415 | - | expectedTransport: third.transportSnapshot(), ready: true, | |
| 416 | - | }) | |
| 417 | - | require.NoError(t, err) | |
| 418 | - | d.deferAttachmentTransitionCleanups(toThird) | |
| 419 | - | d.attachmentCleanupWg.Wait() | |
| 420 | - | requireSingleOwnerAndLease(t, sess, first, second, third) | |
| 421 | - | ||
| 422 | - | firstToken := sess.attachmentToken(first, firstTransport) | |
| 423 | - | secondToken := sess.attachmentToken(second, secondTransport) | |
| 424 | - | require.Equal(t, attachmentSnatched, firstToken.role) | |
| 425 | - | require.Equal(t, attachmentSnatched, secondToken.role) | |
| 426 | - | start := make(chan struct{}) | |
| 427 | - | done := make(chan struct{}, 2) | |
| 428 | - | for _, token := range []attachmentRoleToken{firstToken, secondToken} { | |
| 429 | - | go func() { | |
| 430 | - | <-start | |
| 431 | - | d.handleSnatchedClientFrame(token, ports.Frame{ | |
| 432 | - | Type: ports.MsgInput, | |
| 433 | - | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 434 | - | }) | |
| 435 | - | done <- struct{}{} | |
| 436 | - | }() | |
| 437 | - | } | |
| 438 | - | close(start) | |
| 439 | - | awaitTestCompletion(t, done, "first concurrent reclaim did not return") | |
| 440 | - | awaitTestCompletion(t, done, "second concurrent reclaim did not return") | |
| 441 | - | d.attachmentCleanupWg.Wait() | |
| 442 | - | ||
| 443 | - | requireSingleOwnerAndLease(t, sess, first, second, third) | |
| 444 | - | } | |
D internal/usecase/daemon/snatched_render.go +0 -169
| @@ -1,169 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/vev/internal/domain" | |
| 7 | - | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 8 | - | "github.com/bnema/vev/internal/usecase/ui" | |
| 9 | - | "github.com/bnema/vev/pkg/renderer" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | var snatchedModal = ui.Modal{ | |
| 13 | - | FixedWidth: 42, FixedHeight: 7, | |
| 14 | - | Title: " Session snatched ", Anchor: domain.AnchorCenter, | |
| 15 | - | } | |
| 16 | - | ||
| 17 | - | func snatchedPanelFrame(size domain.Size, styles themeui.Styles, feedback string) renderer.Frame { | |
| 18 | - | width, height := max(size.Cols, 1), max(size.Rows, 1) | |
| 19 | - | frame := renderer.NewFrame(width, height) | |
| 20 | - | ui.FillRect(frame, domain.Rect{Width: width, Height: height}, renderer.Cell{Rune: ' ', Style: styles.PickerBase}) | |
| 21 | - | if width < snatchedModal.FixedWidth || height < snatchedModal.FixedHeight { | |
| 22 | - | drawSnatchedCompact(frame, styles, feedback) | |
| 23 | - | return frame | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | presentation := snatchedModal.Resolve(domain.Size{Cols: width, Rows: height}) | |
| 27 | - | inner := snatchedModal.CompositePresentation(frame, presentation, styles.BorderMuted, styles.PickerBase) | |
| 28 | - | message := "This session is now active elsewhere." | |
| 29 | - | if feedback != "" { | |
| 30 | - | message = feedback | |
| 31 | - | } | |
| 32 | - | drawCenteredLine(frame, inner, inner.Y+1, message, styles.PickerBase) | |
| 33 | - | drawCenteredSegments(frame, inner, inner.Y+3, []textSegment{ | |
| 34 | - | {" r ", styles.HintKey}, | |
| 35 | - | {" Resume here", styles.PickerBase}, | |
| 36 | - | {" ", styles.PickerBase}, | |
| 37 | - | {" q ", styles.HintKey}, | |
| 38 | - | {" / ", styles.PickerDescription}, | |
| 39 | - | {" Esc ", styles.HintKey}, | |
| 40 | - | {" Quit", styles.PickerBase}, | |
| 41 | - | }) | |
| 42 | - | return frame | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | func drawSnatchedCompact(frame renderer.Frame, styles themeui.Styles, feedback string) { | |
| 46 | - | status := "Session snatched" | |
| 47 | - | if feedback != "" { | |
| 48 | - | status = "Session unavailable" | |
| 49 | - | } | |
| 50 | - | bounds := domain.Rect{Width: frame.Width, Height: frame.Height} | |
| 51 | - | startY := max((frame.Height-3)/2, 0) | |
| 52 | - | drawCenteredLine(frame, bounds, startY, status, styles.PickerBase) | |
| 53 | - | drawCenteredSegments(frame, bounds, startY+1, []textSegment{ | |
| 54 | - | {" r ", styles.HintKey}, | |
| 55 | - | {" Resume", styles.PickerBase}, | |
| 56 | - | }) | |
| 57 | - | drawCenteredSegments(frame, bounds, startY+2, []textSegment{ | |
| 58 | - | {" q ", styles.HintKey}, | |
| 59 | - | {" Quit", styles.PickerBase}, | |
| 60 | - | }) | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | // textSegment is one differently-styled run within a centered hint line. | |
| 64 | - | type textSegment struct { | |
| 65 | - | text string | |
| 66 | - | style renderer.Style | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | func drawCenteredLine(frame renderer.Frame, bounds domain.Rect, y int, text string, style renderer.Style) { | |
| 70 | - | drawCenteredSegments(frame, bounds, y, []textSegment{{text, style}}) | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | // drawCenteredSegments draws consecutive differently-styled segments as one | |
| 74 | - | // logical line, centered as a whole within bounds. | |
| 75 | - | func drawCenteredSegments(frame renderer.Frame, bounds domain.Rect, y int, segments []textSegment) { | |
| 76 | - | total := 0 | |
| 77 | - | for _, seg := range segments { | |
| 78 | - | for _, r := range seg.text { | |
| 79 | - | total += renderer.RuneWidth(r) | |
| 80 | - | } | |
| 81 | - | } | |
| 82 | - | x := bounds.X + max((bounds.Width-total)/2, 0) | |
| 83 | - | limit := bounds.X + bounds.Width | |
| 84 | - | for _, seg := range segments { | |
| 85 | - | ui.DrawText(frame, x, y, limit, seg.text, seg.style) | |
| 86 | - | for _, r := range seg.text { | |
| 87 | - | x += renderer.RuneWidth(r) | |
| 88 | - | } | |
| 89 | - | } | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | var errSnatchedOutputStale = errors.New("snatched output role changed") | |
| 93 | - | ||
| 94 | - | // sendSnatchedPanel serializes a dependency-free panel reset onto one exact | |
| 95 | - | // attachment role and transport lifetime. It does not inspect session state or | |
| 96 | - | // capture pane content. | |
| 97 | - | func (d *Daemon) sendSnatchedPanel(ac *attachedClient, expected transportSnapshot, generation uint64, feedback string, effects ...*roleEffectTicket) error { | |
| 98 | - | if expected.transport == nil { | |
| 99 | - | return errors.New("snatched client transport is nil") | |
| 100 | - | } | |
| 101 | - | var effect *roleEffectTicket | |
| 102 | - | if len(effects) != 0 { | |
| 103 | - | effect = effects[0] | |
| 104 | - | } | |
| 105 | - | tr, err := d.boundedSendWith(expected.transport, func() error { | |
| 106 | - | ac.sendMu.Lock() | |
| 107 | - | defer ac.sendMu.Unlock() | |
| 108 | - | if !ac.transportSnapshotCurrent(expected) { | |
| 109 | - | return errTransportReplaced | |
| 110 | - | } | |
| 111 | - | if ac.roleGeneration.Load() != generation { | |
| 112 | - | return errSnatchedOutputStale | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | applied := ac.getAppliedTheme() | |
| 116 | - | frame := snatchedPanelFrame(ac.size, applied.Resolved.Styles, feedback) | |
| 117 | - | ac.rebaseOutput() | |
| 118 | - | var data []byte | |
| 119 | - | var cursor cursorCandidate | |
| 120 | - | var preparedANSI *preparedOutput | |
| 121 | - | var preparedScreen *preparedStructuredOutput | |
| 122 | - | var sendErr error | |
| 123 | - | if ac.proxied { | |
| 124 | - | screenOutput := ac.ensureScreenOutput() | |
| 125 | - | if screenOutput == nil { | |
| 126 | - | return errors.New("proxied attachment has no structured output stream") | |
| 127 | - | } | |
| 128 | - | preparedScreen, sendErr = screenOutput.prepare(frame, []renderer.Damage{renderer.FullRedraw()}, cursorOut{hidden: true}, true, ac.echoAck.Load()) | |
| 129 | - | if sendErr == nil { | |
| 130 | - | data = preparedScreen.data | |
| 131 | - | } | |
| 132 | - | } else { | |
| 133 | - | preparedANSI, sendErr = ac.output.prepare(frame, []renderer.Damage{renderer.FullRedraw()}, true) | |
| 134 | - | if sendErr == nil { | |
| 135 | - | cursor = ac.prepareCursorTail(cursorOut{hidden: true}, true) | |
| 136 | - | data = append(append([]byte(nil), preparedANSI.data...), cursor.data...) | |
| 137 | - | } | |
| 138 | - | } | |
| 139 | - | if sendErr != nil { | |
| 140 | - | return sendErr | |
| 141 | - | } | |
| 142 | - | if effect != nil && !effect.beginTransportSend(expected) { | |
| 143 | - | return errAttachmentTransition | |
| 144 | - | } | |
| 145 | - | if ac.proxied { | |
| 146 | - | sendErr = preparedScreen.send(expected.transport.Send) | |
| 147 | - | } else { | |
| 148 | - | sendErr = preparedANSI.send(data, ac.echoAck.Load(), expected.transport.Send) | |
| 149 | - | } | |
| 150 | - | if effect != nil { | |
| 151 | - | if sendErr != nil { | |
| 152 | - | effect.reportTransportFailure(expected) | |
| 153 | - | } | |
| 154 | - | effect.endTransportSend() | |
| 155 | - | } | |
| 156 | - | if sendErr == nil { | |
| 157 | - | if ac.proxied { | |
| 158 | - | ac.lastCursor = cursorOut{hidden: true} | |
| 159 | - | } else { | |
| 160 | - | ac.lastCursor = cursor.next | |
| 161 | - | } | |
| 162 | - | } | |
| 163 | - | return sendErr | |
| 164 | - | }) | |
| 165 | - | if errors.Is(err, errSendTimedOut) { | |
| 166 | - | _ = ac.closeCapturedTransport(tr) | |
| 167 | - | } | |
| 168 | - | return err | |
| 169 | - | } | |
D internal/usecase/daemon/snatched_render_test.go +0 -340
| @@ -1,340 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | "strings" | |
| 6 | - | "sync" | |
| 7 | - | "testing" | |
| 8 | - | "time" | |
| 9 | - | ||
| 10 | - | "github.com/stretchr/testify/require" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/vev/internal/domain" | |
| 13 | - | "github.com/bnema/vev/internal/ports" | |
| 14 | - | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 15 | - | "github.com/bnema/vev/pkg/renderer" | |
| 16 | - | ) | |
| 17 | - | ||
| 18 | - | func TestSnatchedPanelFrame(t *testing.T) { | |
| 19 | - | styles := themeui.Styles{ | |
| 20 | - | PickerBase: renderer.Style{Foreground: 1}, | |
| 21 | - | PickerSelection: renderer.Style{Foreground: 2}, | |
| 22 | - | BorderMuted: renderer.Style{Foreground: 3}, | |
| 23 | - | HintKey: renderer.Style{Foreground: 4}, | |
| 24 | - | PickerDescription: renderer.Style{Foreground: 5}, | |
| 25 | - | } | |
| 26 | - | tests := []struct { | |
| 27 | - | name string | |
| 28 | - | size domain.Size | |
| 29 | - | feedback string | |
| 30 | - | wantSize domain.Size | |
| 31 | - | want []string | |
| 32 | - | wantKeys string | |
| 33 | - | wantSep string | |
| 34 | - | wantBounds domain.Rect | |
| 35 | - | compact bool | |
| 36 | - | }{ | |
| 37 | - | { | |
| 38 | - | name: "centered bordered panel", | |
| 39 | - | size: domain.Size{Cols: 80, Rows: 24}, | |
| 40 | - | wantSize: domain.Size{Cols: 80, Rows: 24}, | |
| 41 | - | want: []string{"Session snatched", "This session is now active elsewhere.", "r Resume here", "q / Esc Quit"}, | |
| 42 | - | wantKeys: " r q Esc ", | |
| 43 | - | wantSep: " / ", | |
| 44 | - | wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7}, | |
| 45 | - | }, | |
| 46 | - | { | |
| 47 | - | name: "compact fallback", | |
| 48 | - | size: domain.Size{Cols: 24, Rows: 4}, | |
| 49 | - | wantSize: domain.Size{Cols: 24, Rows: 4}, | |
| 50 | - | want: []string{"Session snatched", "r Resume", "q Quit"}, | |
| 51 | - | wantKeys: " r q ", | |
| 52 | - | compact: true, | |
| 53 | - | }, | |
| 54 | - | { | |
| 55 | - | name: "compact unavailable feedback", | |
| 56 | - | size: domain.Size{Cols: 24, Rows: 4}, | |
| 57 | - | feedback: "Session is no longer available.", | |
| 58 | - | wantSize: domain.Size{Cols: 24, Rows: 4}, | |
| 59 | - | want: []string{"Session unavailable", "r Resume", "q Quit"}, | |
| 60 | - | wantKeys: " r q ", | |
| 61 | - | compact: true, | |
| 62 | - | }, | |
| 63 | - | { | |
| 64 | - | name: "bordered unavailable feedback", | |
| 65 | - | size: domain.Size{Cols: 80, Rows: 24}, | |
| 66 | - | feedback: "Session is no longer available.", | |
| 67 | - | wantSize: domain.Size{Cols: 80, Rows: 24}, | |
| 68 | - | want: []string{"Session snatched", "Session is no longer available.", "r Resume here", "q / Esc Quit"}, | |
| 69 | - | wantKeys: " r q Esc ", | |
| 70 | - | wantSep: " / ", | |
| 71 | - | wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7}, | |
| 72 | - | }, | |
| 73 | - | { | |
| 74 | - | name: "zero dimensions clamp", | |
| 75 | - | size: domain.Size{}, | |
| 76 | - | wantSize: domain.Size{Cols: 1, Rows: 1}, | |
| 77 | - | compact: true, | |
| 78 | - | }, | |
| 79 | - | { | |
| 80 | - | name: "negative dimensions clamp", | |
| 81 | - | size: domain.Size{Cols: -10, Rows: -20}, | |
| 82 | - | wantSize: domain.Size{Cols: 1, Rows: 1}, | |
| 83 | - | compact: true, | |
| 84 | - | }, | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | for _, tt := range tests { | |
| 88 | - | t.Run(tt.name, func(t *testing.T) { | |
| 89 | - | frame := snatchedPanelFrame(tt.size, styles, tt.feedback) | |
| 90 | - | require.Equal(t, tt.wantSize.Cols, frame.Width) | |
| 91 | - | require.Equal(t, tt.wantSize.Rows, frame.Height) | |
| 92 | - | require.NoError(t, frame.Validate()) | |
| 93 | - | ||
| 94 | - | text := snatchedFrameText(frame) | |
| 95 | - | for _, want := range tt.want { | |
| 96 | - | require.Contains(t, text, want) | |
| 97 | - | } | |
| 98 | - | require.Equal(t, tt.wantKeys, snatchedStyledText(frame, styles.HintKey), "keycap badges must use the HintKey style") | |
| 99 | - | require.Equal(t, tt.wantSep, snatchedStyledText(frame, styles.PickerDescription), "hint separator must use the muted description style") | |
| 100 | - | ||
| 101 | - | if tt.compact { | |
| 102 | - | require.NotContains(t, text, "┌") | |
| 103 | - | require.NotContains(t, text, "┘") | |
| 104 | - | return | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | require.Equal(t, tt.wantBounds, snatchedModal.Resolve(tt.wantSize).Bounds) | |
| 108 | - | topLeft := frame.At(tt.wantBounds.X, tt.wantBounds.Y) | |
| 109 | - | bottomRight := frame.At(tt.wantBounds.X+tt.wantBounds.Width-1, tt.wantBounds.Y+tt.wantBounds.Height-1) | |
| 110 | - | require.Equal(t, '┌', topLeft.Rune) | |
| 111 | - | require.Equal(t, '┘', bottomRight.Rune) | |
| 112 | - | require.True(t, topLeft.Style.Equal(styles.BorderMuted), "panel border must use captured chrome style") | |
| 113 | - | require.True(t, bottomRight.Style.Equal(styles.BorderMuted), "panel border must use captured chrome style") | |
| 114 | - | requireFreshSnatchedBackground(t, frame, tt.wantBounds, styles.PickerBase) | |
| 115 | - | }) | |
| 116 | - | } | |
| 117 | - | } | |
| 118 | - | ||
| 119 | - | func TestSendSnatchedPanelRebasesOutput(t *testing.T) { | |
| 120 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 121 | - | tr := &closeTrackingTransport{} | |
| 122 | - | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 123 | - | ac.output.next = 7 | |
| 124 | - | ac.setAppliedTheme(appliedTheme{Resolved: themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})}) | |
| 125 | - | ac.roleGeneration.Store(1) | |
| 126 | - | ||
| 127 | - | expected := ac.transportSnapshot() | |
| 128 | - | require.NoError(t, d.sendSnatchedPanel(ac, expected, 1, "")) | |
| 129 | - | ||
| 130 | - | frames := tr.Sends() | |
| 131 | - | require.Len(t, frames, 1) | |
| 132 | - | require.Equal(t, ports.MsgOutput, frames[0].Type) | |
| 133 | - | out, err := ports.UnmarshalOutput(frames[0].Payload) | |
| 134 | - | require.NoError(t, err) | |
| 135 | - | require.Zero(t, out.BaseStateNum) | |
| 136 | - | require.Equal(t, uint64(8), out.NewStateNum) | |
| 137 | - | require.Contains(t, string(out.Data), "Session snatched") | |
| 138 | - | require.True(t, strings.HasSuffix(string(out.Data), "\x1b[?25l"), "panel must force the cursor hidden") | |
| 139 | - | } | |
| 140 | - | ||
| 141 | - | func TestSendSnatchedPanelUsesStructuredSnapshotForProxied(t *testing.T) { | |
| 142 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 143 | - | tr := &closeTrackingTransport{} | |
| 144 | - | ac := &attachedClient{tr: tr, output: newOutputStateStream(), proxied: true, size: domain.Size{Cols: 80, Rows: 24}} | |
| 145 | - | ac.output.next = 7 | |
| 146 | - | ac.setAppliedTheme(appliedTheme{Resolved: themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})}) | |
| 147 | - | ac.roleGeneration.Store(1) | |
| 148 | - | ||
| 149 | - | require.NoError(t, d.sendSnatchedPanel(ac, ac.transportSnapshot(), 1, "")) | |
| 150 | - | frames := tr.Sends() | |
| 151 | - | require.Len(t, frames, 1) | |
| 152 | - | require.Equal(t, ports.MsgScreenUpdate, frames[0].Type) | |
| 153 | - | update, err := ports.UnmarshalScreenUpdate(frames[0].Payload) | |
| 154 | - | require.NoError(t, err) | |
| 155 | - | require.Equal(t, ports.ScreenUpdateSnapshot, update.Kind) | |
| 156 | - | require.Zero(t, update.BaseStateNum) | |
| 157 | - | require.Equal(t, uint64(8), update.NewStateNum, "snatched panel must advance the shared output state") | |
| 158 | - | require.False(t, update.Cursor.Visible, "structured snatched panel must hide the cursor semantically") | |
| 159 | - | require.Len(t, update.Spans, ac.size.Rows, "structured snapshots must carry every full row") | |
| 160 | - | } | |
| 161 | - | ||
| 162 | - | func TestSendSnatchedPanelPublishesCursorOnlyAfterSendSuccess(t *testing.T) { | |
| 163 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 164 | - | failed := cacheFailTransport{} | |
| 165 | - | ac := &attachedClient{ | |
| 166 | - | tr: failed, | |
| 167 | - | output: newOutputStateStream(), | |
| 168 | - | size: domain.Size{Cols: 80, Rows: 24}, | |
| 169 | - | lastCursor: cursorOut{valid: true, row: 2, col: 3, style: 1, hasStyle: true}, | |
| 170 | - | } | |
| 171 | - | ac.output.next = 7 | |
| 172 | - | ac.setAppliedTheme(appliedTheme{Resolved: themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})}) | |
| 173 | - | ac.roleGeneration.Store(1) | |
| 174 | - | beforeCursor := ac.lastCursor | |
| 175 | - | ||
| 176 | - | require.Error(t, d.sendSnatchedPanel(ac, ac.transportSnapshot(), 1, "")) | |
| 177 | - | require.Equal(t, beforeCursor, ac.lastCursor) | |
| 178 | - | require.Equal(t, uint64(7), ac.output.next) | |
| 179 | - | ||
| 180 | - | healthy := &closeTrackingTransport{} | |
| 181 | - | ac.replaceTransport(healthy) | |
| 182 | - | require.NoError(t, d.sendSnatchedPanel(ac, ac.transportSnapshot(), 1, "")) | |
| 183 | - | require.Equal(t, cursorOut{valid: true, hidden: true}, ac.lastCursor) | |
| 184 | - | require.Equal(t, uint64(8), ac.output.next) | |
| 185 | - | frames := healthy.Sends() | |
| 186 | - | require.Len(t, frames, 1) | |
| 187 | - | out, err := ports.UnmarshalOutput(frames[0].Payload) | |
| 188 | - | require.NoError(t, err) | |
| 189 | - | require.Zero(t, out.BaseStateNum) | |
| 190 | - | require.Equal(t, uint64(8), out.NewStateNum) | |
| 191 | - | } | |
| 192 | - | ||
| 193 | - | func TestSendSnatchedPanelTimeoutClosesCapturedTransportOnly(t *testing.T) { | |
| 194 | - | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 195 | - | d := newTestDaemon(t, nil, clock) | |
| 196 | - | old := newBlockingSnatchedTransport() | |
| 197 | - | fresh := &closeTrackingTransport{} | |
| 198 | - | ac := &attachedClient{tr: old, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 199 | - | ac.roleGeneration.Store(1) | |
| 200 | - | expected := ac.transportSnapshot() | |
| 201 | - | ||
| 202 | - | result := make(chan error, 1) | |
| 203 | - | go func() { | |
| 204 | - | result <- d.sendSnatchedPanel(ac, expected, 1, "") | |
| 205 | - | }() | |
| 206 | - | ||
| 207 | - | timer := awaitTestValue(t, clock.timers, "snatched send did not install its timeout") | |
| 208 | - | awaitTestCompletion(t, old.sendEntered, "snatched send did not block in the captured transport") | |
| 209 | - | ac.replaceTransport(fresh) | |
| 210 | - | timer.ch <- time.Time{} | |
| 211 | - | ||
| 212 | - | require.ErrorIs(t, awaitTestValue(t, result, "snatched send did not time out"), errSendTimedOut) | |
| 213 | - | defer func() { _ = old.Close() }() // Release the blocked worker if the assertion below fails. | |
| 214 | - | require.True(t, old.Closed(), "timed-out captured transport was not closed") | |
| 215 | - | require.False(t, fresh.Closed(), "replacement transport must remain open") | |
| 216 | - | ||
| 217 | - | sendMuReleased := make(chan uint64, 1) | |
| 218 | - | go func() { | |
| 219 | - | ac.sendMu.Lock() | |
| 220 | - | generation := ac.roleGeneration.Load() | |
| 221 | - | ac.sendMu.Unlock() | |
| 222 | - | sendMuReleased <- generation | |
| 223 | - | }() | |
| 224 | - | require.Equal(t, uint64(1), awaitTestValue(t, sendMuReleased, "closing the timed-out transport did not release sendMu")) | |
| 225 | - | } | |
| 226 | - | ||
| 227 | - | type blockingSnatchedTransport struct { | |
| 228 | - | sendEntered chan struct{} | |
| 229 | - | closed chan struct{} | |
| 230 | - | sendOnce sync.Once | |
| 231 | - | closeOnce sync.Once | |
| 232 | - | } | |
| 233 | - | ||
| 234 | - | func newBlockingSnatchedTransport() *blockingSnatchedTransport { | |
| 235 | - | return &blockingSnatchedTransport{ | |
| 236 | - | sendEntered: make(chan struct{}), | |
| 237 | - | closed: make(chan struct{}), | |
| 238 | - | } | |
| 239 | - | } | |
| 240 | - | ||
| 241 | - | func (t *blockingSnatchedTransport) Send(ports.Frame) error { | |
| 242 | - | t.sendOnce.Do(func() { close(t.sendEntered) }) | |
| 243 | - | <-t.closed | |
| 244 | - | return errors.New("transport closed") | |
| 245 | - | } | |
| 246 | - | ||
| 247 | - | func (*blockingSnatchedTransport) Recv() (ports.Frame, error) { | |
| 248 | - | return ports.Frame{}, errors.New("not implemented") | |
| 249 | - | } | |
| 250 | - | ||
| 251 | - | func (t *blockingSnatchedTransport) Close() error { | |
| 252 | - | t.closeOnce.Do(func() { close(t.closed) }) | |
| 253 | - | return nil | |
| 254 | - | } | |
| 255 | - | ||
| 256 | - | func (t *blockingSnatchedTransport) Closed() bool { | |
| 257 | - | select { | |
| 258 | - | case <-t.closed: | |
| 259 | - | return true | |
| 260 | - | default: | |
| 261 | - | return false | |
| 262 | - | } | |
| 263 | - | } | |
| 264 | - | ||
| 265 | - | func TestSendSnatchedPanelRejectsStaleTransport(t *testing.T) { | |
| 266 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 267 | - | old := &closeTrackingTransport{} | |
| 268 | - | fresh := &closeTrackingTransport{} | |
| 269 | - | ac := &attachedClient{tr: old, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 270 | - | ac.roleGeneration.Store(1) | |
| 271 | - | stale := ac.transportSnapshot() | |
| 272 | - | ac.replaceTransport(fresh) | |
| 273 | - | ||
| 274 | - | require.ErrorIs(t, d.sendSnatchedPanel(ac, stale, 1, ""), errTransportReplaced) | |
| 275 | - | require.Empty(t, old.Sends()) | |
| 276 | - | require.Empty(t, fresh.Sends()) | |
| 277 | - | } | |
| 278 | - | ||
| 279 | - | func TestSendSnatchedPanelRejectsStaleRole(t *testing.T) { | |
| 280 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 281 | - | tr := &closeTrackingTransport{} | |
| 282 | - | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 283 | - | ac.roleGeneration.Store(2) | |
| 284 | - | expected := ac.transportSnapshot() | |
| 285 | - | ||
| 286 | - | require.ErrorIs(t, d.sendSnatchedPanel(ac, expected, 1, ""), errSnatchedOutputStale) | |
| 287 | - | require.Empty(t, tr.Sends()) | |
| 288 | - | } | |
| 289 | - | ||
| 290 | - | func requireFreshSnatchedBackground(t *testing.T, frame renderer.Frame, panel domain.Rect, style renderer.Style) { | |
| 291 | - | t.Helper() | |
| 292 | - | for y := range frame.Height { | |
| 293 | - | for x := range frame.Width { | |
| 294 | - | if x >= panel.X && x < panel.X+panel.Width && y >= panel.Y && y < panel.Y+panel.Height { | |
| 295 | - | continue | |
| 296 | - | } | |
| 297 | - | cell := frame.At(x, y) | |
| 298 | - | require.Equal(t, ' ', cell.Rune, "cell (%d,%d) outside panel retained content", x, y) | |
| 299 | - | require.True(t, cell.Style.Equal(style), "cell (%d,%d) outside panel did not use captured base style", x, y) | |
| 300 | - | } | |
| 301 | - | } | |
| 302 | - | } | |
| 303 | - | ||
| 304 | - | // snatchedStyledText concatenates, in reading order, the runes of every cell | |
| 305 | - | // drawn with exactly the given style, so tests can assert which text runs a | |
| 306 | - | // style covers without depending on layout coordinates. | |
| 307 | - | func snatchedStyledText(frame renderer.Frame, style renderer.Style) string { | |
| 308 | - | var out strings.Builder | |
| 309 | - | for y := 0; y < frame.Height; y++ { | |
| 310 | - | for _, cell := range frame.Row(y) { | |
| 311 | - | if cell.Continuation || !cell.Style.Equal(style) { | |
| 312 | - | continue | |
| 313 | - | } | |
| 314 | - | r := cell.Rune | |
| 315 | - | if r == 0 { | |
| 316 | - | r = ' ' | |
| 317 | - | } | |
| 318 | - | out.WriteRune(r) | |
| 319 | - | } | |
| 320 | - | } | |
| 321 | - | return out.String() | |
| 322 | - | } | |
| 323 | - | ||
| 324 | - | func snatchedFrameText(frame renderer.Frame) string { | |
| 325 | - | var out strings.Builder | |
| 326 | - | for y := 0; y < frame.Height; y++ { | |
| 327 | - | for _, cell := range frame.Row(y) { | |
| 328 | - | if cell.Continuation { | |
| 329 | - | continue | |
| 330 | - | } | |
| 331 | - | r := cell.Rune | |
| 332 | - | if r == 0 { | |
| 333 | - | r = ' ' | |
| 334 | - | } | |
| 335 | - | out.WriteRune(r) | |
| 336 | - | } | |
| 337 | - | out.WriteByte('\n') | |
| 338 | - | } | |
| 339 | - | return out.String() | |
| 340 | - | } | |
D internal/usecase/daemon/stalled_attachment_test.go +0 -89
| @@ -1,89 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "testing" | |
| 5 | - | "time" | |
| 6 | - | ||
| 7 | - | "github.com/stretchr/testify/mock" | |
| 8 | - | "github.com/stretchr/testify/require" | |
| 9 | - | ||
| 10 | - | "github.com/bnema/vev/internal/domain" | |
| 11 | - | "github.com/bnema/vev/internal/ports" | |
| 12 | - | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 13 | - | ) | |
| 14 | - | ||
| 15 | - | // TestFinishAttachWaitsForAdmittedOldRender verifies role-effect | |
| 16 | - | // linearization: an old render already admitted and blocked in Send completes | |
| 17 | - | // before replacement publication can change its capability. | |
| 18 | - | func TestFinishAttachWaitsForAdmittedOldRender(t *testing.T) { | |
| 19 | - | pty, releasePTY := newBlockingPTY(t) | |
| 20 | - | defer releasePTY() | |
| 21 | - | clock := newCoordinatorMockClock(t, 4) | |
| 22 | - | d := newTestDaemon(t, newFactory(t, pty), clock.clock) | |
| 23 | - | ||
| 24 | - | oldTransport := portsmocks.NewMockTransport(t) | |
| 25 | - | oldSendEntered := make(chan struct{}) | |
| 26 | - | releaseOldSend := make(chan struct{}) | |
| 27 | - | oldTransport.EXPECT().Send(mock.Anything).RunAndReturn(func(ports.Frame) error { | |
| 28 | - | close(oldSendEntered) | |
| 29 | - | <-releaseOldSend | |
| 30 | - | return nil | |
| 31 | - | }).Once() | |
| 32 | - | oldTransport.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |
| 33 | - | oldTransport.EXPECT().Close().Return(nil).Maybe() | |
| 34 | - | ||
| 35 | - | hello := ports.Hello{ | |
| 36 | - | Version: ports.ProtocolVersion, | |
| 37 | - | Intent: ports.IntentNew, | |
| 38 | - | Name: "stalled-attachment", | |
| 39 | - | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 40 | - | TermEnv: "xterm-256color", | |
| 41 | - | } | |
| 42 | - | sess, old, err := d.route(hello, oldTransport) | |
| 43 | - | require.NoError(t, err) | |
| 44 | - | rc := sess.renderCoordinator() | |
| 45 | - | lease := rc.attachmentLease(old) | |
| 46 | - | require.True(t, rc.markAttachmentReady(lease)) | |
| 47 | - | ||
| 48 | - | rc.invalidateForAttachment(old, renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"}) | |
| 49 | - | awaitCoordinatorScheduledTimer(t, clock).ch <- time.Time{} | |
| 50 | - | <-oldSendEntered | |
| 51 | - | ||
| 52 | - | replacement := portsmocks.NewMockTransport(t) | |
| 53 | - | replacement.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |
| 54 | - | replacement.EXPECT().Close().Return(nil).Maybe() | |
| 55 | - | type attachResult struct { | |
| 56 | - | client *attachedClient | |
| 57 | - | err error | |
| 58 | - | } | |
| 59 | - | attached := make(chan attachResult, 1) | |
| 60 | - | go func() { | |
| 61 | - | d.mu.Lock() | |
| 62 | - | ac, err := d.finishAttach(sess, replacement, hello.Size, terminalEnv{}, hello) | |
| 63 | - | attached <- attachResult{client: ac, err: err} | |
| 64 | - | }() | |
| 65 | - | ||
| 66 | - | select { | |
| 67 | - | case <-attached: | |
| 68 | - | t.Fatal("replacement published before the admitted old render completed") | |
| 69 | - | case <-time.After(20 * time.Millisecond): | |
| 70 | - | } | |
| 71 | - | close(releaseOldSend) | |
| 72 | - | select { | |
| 73 | - | case result := <-attached: | |
| 74 | - | require.NoError(t, result.err) | |
| 75 | - | require.Same(t, replacement, result.client.transport()) | |
| 76 | - | case <-time.After(time.Second): | |
| 77 | - | t.Fatal("replacement did not publish after the admitted render completed") | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | d.attachmentCleanupWg.Wait() | |
| 81 | - | require.Same(t, oldTransport, old.transport(), "the displaced attachment remains snatched") | |
| 82 | - | replacement.AssertNotCalled(t, "Close") | |
| 83 | - | ||
| 84 | - | // Retire the session before mock cleanup so asynchronous terminal Detached | |
| 85 | - | // notification cannot race the clock's C expectation after the test returns. | |
| 86 | - | releasePTY() | |
| 87 | - | awaitTestCompletion(t, d.done, "session did not retire after its final PTY closed") | |
| 88 | - | d.waitNotifies() | |
| 89 | - | } | |
M internal/usecase/daemon/status.go +30 -3
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "strconv" | |
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | 10 | "github.com/bnema/vev/internal/usecase/command" | |
| 10 | 11 | "github.com/bnema/vev/internal/usecase/palette" | |
| 11 | 12 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| @@ -181,6 +182,10 @@ type statusTab struct { | |||
| 181 | 182 | } | |
| 182 | 183 | ||
| 183 | 184 | func (s *session) statusSegments(includeTerminalTitle bool) statusSnapshot { | |
| 185 | + | return s.statusSegmentsFor(nil, includeTerminalTitle) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle bool) statusSnapshot { | |
| 184 | 189 | s.mu.Lock() | |
| 185 | 190 | defer s.mu.Unlock() | |
| 186 | 191 | name := s.name | |
| @@ -188,9 +193,19 @@ func (s *session) statusSegments(includeTerminalTitle bool) statusSnapshot { | |||
| 188 | 193 | name += "*" | |
| 189 | 194 | } | |
| 190 | 195 | snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))} | |
| 196 | + | activeIndex := 0 | |
| 197 | + | if ac != nil { | |
| 198 | + | view := ac.viewSnapshot() | |
| 199 | + | for i, tb := range s.tabs { | |
| 200 | + | if domain.TabStableID(tb.stableID) == view.tabID { | |
| 201 | + | activeIndex = i | |
| 202 | + | break | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | } | |
| 191 | 206 | for i, tb := range s.tabs { | |
| 192 | 207 | name := tabDisplayName(tb, i) | |
| 193 | - | active := i == s.active | |
| 208 | + | active := i == activeIndex | |
| 194 | 209 | attention := tb.attention && (!active || tb.attentionVisiblePaint) | |
| 195 | 210 | snap.tabs[i] = statusTab{name: name, paneTitle: tb.focusedPaneTitle(includeTerminalTitle), active: active, attention: attention} | |
| 196 | 211 | } | |
| @@ -236,6 +251,10 @@ func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hi | |||
| 236 | 251 | // attachment implementation. Bar scripts remain local-session-only because | |
| 237 | 252 | // their existing execution contract depends on local tabs and PTYs. | |
| 238 | 253 | func (d *Daemon) barStateForAttachmentPaletteHints(cur attachmentSession, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState { | |
| 254 | + | return d.barStateForAttachmentPaletteHintsFor(cur, nil, statusFeedback, hints, recent) | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur attachmentSession, ac *attachedClient, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState { | |
| 239 | 258 | if attachmentSessionCore(cur) == nil { | |
| 240 | 259 | cur = nil | |
| 241 | 260 | } | |
| @@ -249,7 +268,11 @@ func (d *Daemon) barStateForAttachmentPaletteHints(cur attachmentSession, status | |||
| 249 | 268 | if d != nil { | |
| 250 | 269 | includeTerminalTitle = d.currentTabsConfig().TerminalTitle | |
| 251 | 270 | } | |
| 252 | - | state.status = cur.statusSegments(includeTerminalTitle) | |
| 271 | + | if local, ok := localSession(cur); ok { | |
| 272 | + | state.status = local.statusSegmentsFor(ac, includeTerminalTitle) | |
| 273 | + | } else { | |
| 274 | + | state.status = cur.statusSegments(includeTerminalTitle) | |
| 275 | + | } | |
| 253 | 276 | } | |
| 254 | 277 | if local, ok := localSession(cur); ok && d != nil { | |
| 255 | 278 | state.topRight, state.bottomRight = d.barScriptSnapshot(local) | |
| @@ -269,6 +292,10 @@ func (d *Daemon) barStateFor(cur *session, statusFeedback string) barState { | |||
| 269 | 292 | } | |
| 270 | 293 | ||
| 271 | 294 | func (d *Daemon) barStateBase(cur *session, statusFeedback string) barState { | |
| 295 | + | return d.barStateBaseFor(cur, nil, statusFeedback) | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | func (d *Daemon) barStateBaseFor(cur *session, ac *attachedClient, statusFeedback string) barState { | |
| 272 | 299 | state := barState{statusFeedback: statusFeedback} | |
| 273 | 300 | if d != nil { | |
| 274 | 301 | state.attentionFrame = d.attentionFrame() | |
| @@ -278,7 +305,7 @@ func (d *Daemon) barStateBase(cur *session, statusFeedback string) barState { | |||
| 278 | 305 | if d != nil { | |
| 279 | 306 | includeTerminalTitle = d.currentTabsConfig().TerminalTitle | |
| 280 | 307 | } | |
| 281 | - | state.status = cur.statusSegments(includeTerminalTitle) | |
| 308 | + | state.status = cur.statusSegmentsFor(ac, includeTerminalTitle) | |
| 282 | 309 | } | |
| 283 | 310 | if d != nil { | |
| 284 | 311 | state.topRight, state.bottomRight = d.barScriptSnapshot(cur) | |
M internal/usecase/daemon/status_test.go +20 -74
| @@ -31,10 +31,10 @@ func (*coreNilAttachment) snapshotView(viewOptions) sessionView { | |||
| 31 | 31 | return sessionView{} | |
| 32 | 32 | } | |
| 33 | 33 | func (*coreNilAttachment) statusSegments(bool) statusSnapshot { return statusSnapshot{} } | |
| 34 | - | func (*coreNilAttachment) capturePrimary(*attachedClient, primaryCaptureRequest) (*capturedRenderState, bool) { | |
| 34 | + | func (*coreNilAttachment) captureRenderState(*attachedClient, renderCaptureRequest) (*capturedRenderState, bool) { | |
| 35 | 35 | return nil, false | |
| 36 | 36 | } | |
| 37 | - | func (*coreNilAttachment) activateTargetLocked(int) bool { return false } | |
| 37 | + | func (*coreNilAttachment) validTargetTabLocked(int) bool { return false } | |
| 38 | 38 | func (*coreNilAttachment) isProxy() bool { return false } | |
| 39 | 39 | ||
| 40 | 40 | // stubClock returns timers whose channel never fires, so a scheduler under it | |
| @@ -46,7 +46,7 @@ func TestApplyConfigThemeRepaintInvalidatesComposedFrameCache(t *testing.T) { | |||
| 46 | 46 | defer releasePTY() | |
| 47 | 47 | d.ApplyConfig(domain.Config{Theme: domain.ThemeLight}) | |
| 48 | 48 | ||
| 49 | - | win := sess.activeTab() | |
| 49 | + | win := testAttachmentTab(sess) | |
| 50 | 50 | left := win.focusedPane() | |
| 51 | 51 | right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4}) | |
| 52 | 52 | win.mu.Lock() | |
| @@ -89,13 +89,13 @@ func TestApplyConfigThemeRepaintInvalidatesComposedFrameCache(t *testing.T) { | |||
| 89 | 89 | func TestStatusCompositionGolden(t *testing.T) { | |
| 90 | 90 | p1, releasePTY1 := newBlockingPTY(t) | |
| 91 | 91 | p2, releasePTY2 := newBlockingPTY(t) | |
| 92 | - | _, sess, _, _ := newManualSessionWithPTYs(t, p1, p2) | |
| 92 | + | _, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2) | |
| 93 | 93 | defer releasePTY1() | |
| 94 | 94 | defer releasePTY2() | |
| 95 | - | sess.active = 1 | |
| 95 | + | selectTestAttachmentTab(sess, 1) | |
| 96 | 96 | sess.name = "work" | |
| 97 | 97 | ||
| 98 | - | win := sess.activeTab() | |
| 98 | + | win := testAttachmentTab(sess) | |
| 99 | 99 | win.focusedPane().screen.Resize(12, 2) | |
| 100 | 100 | win.size = domain.Size{Cols: 12, Rows: 2} | |
| 101 | 101 | win.focusedPane().screen.Write([]byte("hello")) | |
| @@ -106,7 +106,7 @@ func TestStatusCompositionGolden(t *testing.T) { | |||
| 106 | 106 | panes: []capturedPaneRenderState{{ | |
| 107 | 107 | id: win.focusedPane().id, frame: win.focusedPane().screen.Frame.Clone(), placement: layout.Placement{ID: win.focusedPane().id, Content: domain.Rect{Width: 12, Height: 2}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}, | |
| 108 | 108 | }}, | |
| 109 | - | bars: barState{status: sess.statusSegments(true)}, | |
| 109 | + | bars: barState{status: sess.statusSegmentsFor(ac, true)}, | |
| 110 | 110 | } | |
| 111 | 111 | composed := composeFrame(frameState, composeCacheInput{}) | |
| 112 | 112 | frame, damage := composed.frame, composed.damage | |
| @@ -418,7 +418,7 @@ func TestStatusCompositionUsesTruecolorTheme(t *testing.T) { | |||
| 418 | 418 | p, release := newBlockingPTY(t) | |
| 419 | 419 | _, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 420 | 420 | defer release() | |
| 421 | - | win := sess.activeTab() | |
| 421 | + | win := testAttachmentTab(sess) | |
| 422 | 422 | win.focusedPane().screen.Resize(12, 2) | |
| 423 | 423 | win.size = domain.Size{Cols: 12, Rows: 2} | |
| 424 | 424 | ac.setThemeForTest(themeui.Theme{ | |
| @@ -462,7 +462,7 @@ func TestApplyThemePropagatesToFloatingPane(t *testing.T) { | |||
| 462 | 462 | p, releasePTY := newBlockingPTY(t) | |
| 463 | 463 | defer releasePTY() | |
| 464 | 464 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 465 | - | tb := sess.activeTab() | |
| 465 | + | tb := testAttachmentTab(sess) | |
| 466 | 466 | floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}) | |
| 467 | 467 | installTestFloating(tb, floating, true) | |
| 468 | 468 | clientTheme := ports.Theme{ | |
| @@ -541,7 +541,7 @@ func TestAttachClientAppliesForcedThemeBeforeMsgTheme(t *testing.T) { | |||
| 541 | 541 | } | |
| 542 | 542 | tr, _ := newCapturingTransport(t) | |
| 543 | 543 | ||
| 544 | - | ac, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 544 | + | ac, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 545 | 545 | require.NoError(t, err) | |
| 546 | 546 | ||
| 547 | 547 | require.Equal(t, tc.want, ac.getAppliedTheme().Raw) | |
| @@ -581,7 +581,7 @@ func TestAttachClientClearsStaleColorSchemeOnReplacement(t *testing.T) { | |||
| 581 | 581 | assertSessionColorScheme(t, sess, true) | |
| 582 | 582 | ||
| 583 | 583 | tr, _ := newCapturingTransport(t) | |
| 584 | - | _, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 584 | + | _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 585 | 585 | require.NoError(t, err) | |
| 586 | 586 | ||
| 587 | 587 | assertSessionColorSchemeUnknown(t, sess) | |
| @@ -690,11 +690,11 @@ func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) { | |||
| 690 | 690 | }) | |
| 691 | 691 | tr, _ := newCapturingTransport(t) | |
| 692 | 692 | ||
| 693 | - | _, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 693 | + | _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 694 | 694 | require.NoError(t, err) | |
| 695 | 695 | ||
| 696 | 696 | var got []byte | |
| 697 | - | tb := sess.activeTab() | |
| 697 | + | tb := testAttachmentTab(sess) | |
| 698 | 698 | tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) } | |
| 699 | 699 | tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | |
| 700 | 700 | require.Empty(t, got) | |
| @@ -710,7 +710,7 @@ func TestClientGoneResetsScreenDefaultColors(t *testing.T) { | |||
| 710 | 710 | }) | |
| 711 | 711 | ||
| 712 | 712 | // Sanity: the tab answers OSC 10/11 while ac is attached. | |
| 713 | - | tb := sess.activeTab() | |
| 713 | + | tb := testAttachmentTab(sess) | |
| 714 | 714 | var before []byte | |
| 715 | 715 | tb.focusedPane().screen.OnResponse = func(b []byte) { before = append(before, b...) } | |
| 716 | 716 | tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | |
| @@ -724,40 +724,6 @@ func TestClientGoneResetsScreenDefaultColors(t *testing.T) { | |||
| 724 | 724 | require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone") | |
| 725 | 725 | } | |
| 726 | 726 | ||
| 727 | - | func TestClientGoneResetDoesNotClobberNewlyAttachedClient(t *testing.T) { | |
| 728 | - | p, release := newBlockingPTY(t) | |
| 729 | - | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 730 | - | defer release() | |
| 731 | - | d.applyTheme(sess, ac, ports.Theme{ | |
| 732 | - | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 733 | - | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 734 | - | }) | |
| 735 | - | ||
| 736 | - | // Simulate the race window inside clientGone/detachOnSendError: the old | |
| 737 | - | // client has already been detached (detachIfCurrent succeeded), but | |
| 738 | - | // before resetScreenDefaultColors runs a new client attaches and applies | |
| 739 | - | // its own theme. | |
| 740 | - | require.True(t, sess.detachIfCurrent(ac)) | |
| 741 | - | ||
| 742 | - | tr, _ := newCapturingTransport(t) | |
| 743 | - | newAC, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 744 | - | require.NoError(t, err) | |
| 745 | - | d.applyTheme(sess, newAC, ports.Theme{ | |
| 746 | - | HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22}, | |
| 747 | - | HasBackground: true, Background: renderer.RGB{R: 23, G: 24, B: 25}, | |
| 748 | - | }) | |
| 749 | - | ||
| 750 | - | // The late reset from the old client's detach path must not wipe the | |
| 751 | - | // new client's freshly applied colors. | |
| 752 | - | d.resetScreenDefaultColors(sess) | |
| 753 | - | ||
| 754 | - | tb := sess.activeTab() | |
| 755 | - | var got []byte | |
| 756 | - | tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) } | |
| 757 | - | tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | |
| 758 | - | require.NotEmpty(t, got, "a newer client's screen default colors must survive a stale detach's reset") | |
| 759 | - | } | |
| 760 | - | ||
| 761 | 727 | func TestDetachOnSendErrorResetsScreenDefaultColors(t *testing.T) { | |
| 762 | 728 | p, release := newBlockingPTY(t) | |
| 763 | 729 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| @@ -769,7 +735,7 @@ func TestDetachOnSendErrorResetsScreenDefaultColors(t *testing.T) { | |||
| 769 | 735 | ||
| 770 | 736 | d.detachOnSendError(sess, ac, ac.transport()) | |
| 771 | 737 | ||
| 772 | - | tb := sess.activeTab() | |
| 738 | + | tb := testAttachmentTab(sess) | |
| 773 | 739 | var got []byte | |
| 774 | 740 | tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) } | |
| 775 | 741 | tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | |
| @@ -788,40 +754,20 @@ func TestDetachOnSendErrorParkPreservesScreenDefaultColors(t *testing.T) { | |||
| 788 | 754 | ||
| 789 | 755 | d.detachOnSendError(sess, ac, ac.transport()) | |
| 790 | 756 | ||
| 791 | - | tb := sess.activeTab() | |
| 757 | + | tb := testAttachmentTab(sess) | |
| 792 | 758 | var got []byte | |
| 793 | 759 | tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) } | |
| 794 | 760 | tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | |
| 795 | 761 | require.NotEmpty(t, got, "parked clients resume the same attachment, so screen default colors must be preserved") | |
| 796 | 762 | } | |
| 797 | 763 | ||
| 798 | - | func TestApplyThemeIgnoresReplacedClient(t *testing.T) { | |
| 799 | - | p, release := newBlockingPTY(t) | |
| 800 | - | d, sess, old, _ := newManualSessionWithPTYs(t, p) | |
| 801 | - | defer release() | |
| 802 | - | tr, _ := newCapturingTransport(t) | |
| 803 | - | _, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 804 | - | require.NoError(t, err) | |
| 805 | - | ||
| 806 | - | d.applyTheme(sess, old, ports.Theme{ | |
| 807 | - | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 808 | - | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 809 | - | }) | |
| 810 | - | ||
| 811 | - | var got []byte | |
| 812 | - | tb := sess.activeTab() | |
| 813 | - | tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) } | |
| 814 | - | tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | |
| 815 | - | require.Empty(t, got) | |
| 816 | - | } | |
| 817 | - | ||
| 818 | 764 | func TestStatusMarksEphemeralSession(t *testing.T) { | |
| 819 | 765 | p, release := newBlockingPTY(t) | |
| 820 | 766 | _, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 821 | 767 | defer release() | |
| 822 | 768 | sess.name = "0" | |
| 823 | 769 | sess.ephemeral = true | |
| 824 | - | win := sess.activeTab() | |
| 770 | + | win := testAttachmentTab(sess) | |
| 825 | 771 | win.focusedPane().screen.Resize(12, 2) | |
| 826 | 772 | win.size = domain.Size{Cols: 12, Rows: 2} | |
| 827 | 773 | ||
| @@ -888,7 +834,7 @@ func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) { | |||
| 888 | 834 | _, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 889 | 835 | defer releasePTY() | |
| 890 | 836 | sess.name = "work" | |
| 891 | - | win := sess.activeTab() | |
| 837 | + | win := testAttachmentTab(sess) | |
| 892 | 838 | win.focusedPane().screen.Resize(30, 2) | |
| 893 | 839 | win.size = domain.Size{Cols: 30, Rows: 2} | |
| 894 | 840 | ||
| @@ -960,7 +906,7 @@ func TestTopBarRendersAttentionBell(t *testing.T) { | |||
| 960 | 906 | _, sess, _, _ := newManualSessionWithPTYs(t, p1, p2) | |
| 961 | 907 | defer releasePTY1() | |
| 962 | 908 | defer releasePTY2() | |
| 963 | - | win := sess.activeTab() | |
| 909 | + | win := testAttachmentTab(sess) | |
| 964 | 910 | win.focusedPane().screen.Resize(18, 2) | |
| 965 | 911 | win.size = domain.Size{Cols: 18, Rows: 2} | |
| 966 | 912 | sess.mu.Lock() | |
| @@ -1139,7 +1085,7 @@ func TestCapturePrimaryRenderStatePreservesContextualMRUModeThroughScratchReuse( | |||
| 1139 | 1085 | _, sess, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 1140 | 1086 | capture := func(bars barState) capturedRenderState { | |
| 1141 | 1087 | t.Helper() | |
| 1142 | - | state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{ | |
| 1088 | + | state, ok := captureRenderState(sess, ac, renderCaptureRequest{ | |
| 1143 | 1089 | bars: bars, | |
| 1144 | 1090 | overlays: capturedOverlayRenderState{}, | |
| 1145 | 1091 | preview: picker.Preview{}, | |
M internal/usecase/daemon/structured_diagnostics_test.go +3 -3
| @@ -25,7 +25,7 @@ func TestP4StructuredRuntimeDiagnosticsRecordAcceptedSnapshotAndDelta(t *testing | |||
| 25 | 25 | emit := func(reset bool) ports.ScreenUpdate { | |
| 26 | 26 | t.Helper() | |
| 27 | 27 | ac.sendMu.Lock() | |
| 28 | - | state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: reset}) | |
| 28 | + | state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: reset}) | |
| 29 | 29 | require.True(t, ok) | |
| 30 | 30 | composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch) | |
| 31 | 31 | require.True(t, d.emitFrame(proxy, ac, state, composed)) | |
| @@ -75,7 +75,7 @@ func TestP4StructuredRuntimeDiagnosticsDoNotRecordFailedSend(t *testing.T) { | |||
| 75 | 75 | require.True(t, applyTestScreenText(proxy, 0, 0, "failed")) | |
| 76 | 76 | ||
| 77 | 77 | ac.sendMu.Lock() | |
| 78 | - | state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: true}) | |
| 78 | + | state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: true}) | |
| 79 | 79 | require.True(t, ok) | |
| 80 | 80 | composed := composeFrame(*state, composeCacheInput{}) | |
| 81 | 81 | require.True(t, d.emitFrame(proxy, ac, state, composed)) | |
| @@ -122,7 +122,7 @@ func TestP4StructuredRuntimeDiagnosticsObserverRunsOutsideAttachmentAndProxyLock | |||
| 122 | 122 | done := make(chan struct{}) | |
| 123 | 123 | go func() { | |
| 124 | 124 | ac.sendMu.Lock() | |
| 125 | - | state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: true}) | |
| 125 | + | state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: true}) | |
| 126 | 126 | if !ok { | |
| 127 | 127 | ac.sendMu.Unlock() | |
| 128 | 128 | close(done) | |
M internal/usecase/daemon/theme_resolve.go +1 -12
| @@ -12,14 +12,6 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme | |||
| 12 | 12 | return d.applyHostThemeLocked(sess, ac, t, clearUnknownScheme) | |
| 13 | 13 | } | |
| 14 | 14 | ||
| 15 | - | // applyHostThemeSendLocked applies a theme when the caller already owns | |
| 16 | - | // ac.sendMu, preserving the usual sendMu -> themeMu lock order. | |
| 17 | - | func (d *Daemon) applyHostThemeSendLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool { | |
| 18 | - | sess.themeMu.Lock() | |
| 19 | - | defer sess.themeMu.Unlock() | |
| 20 | - | return d.applyHostThemeLocked(sess, ac, t, clearUnknownScheme) | |
| 21 | - | } | |
| 22 | - | ||
| 23 | 15 | func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool { | |
| 24 | 16 | // Resolve while applying, never while composing. The theme mutex only | |
| 25 | 17 | // publishes the completed value and is not held across session/tab/pane | |
| @@ -28,13 +20,10 @@ func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme | |||
| 28 | 20 | t = applied.Raw | |
| 29 | 21 | sess.mu.Lock() | |
| 30 | 22 | if ac != nil { | |
| 31 | - | if sess.client != ac { | |
| 23 | + | if _, attached := sess.attachments[ac]; !attached { | |
| 32 | 24 | sess.mu.Unlock() | |
| 33 | 25 | return false | |
| 34 | 26 | } | |
| 35 | - | } else if sess.client != nil { | |
| 36 | - | sess.mu.Unlock() | |
| 37 | - | return false | |
| 38 | 27 | } | |
| 39 | 28 | tabs := append([]*tab(nil), sess.tabs...) | |
| 40 | 29 | sess.mu.Unlock() | |
M internal/usecase/daemon/transactional_resize.go +38 -10
| @@ -362,10 +362,7 @@ func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) { | |||
| 362 | 362 | return | |
| 363 | 363 | } | |
| 364 | 364 | markSnapshotDirty(sess) | |
| 365 | - | sess.mu.Lock() | |
| 366 | - | ac := sess.client | |
| 367 | - | sess.mu.Unlock() | |
| 368 | - | if ac != nil { | |
| 365 | + | for _, ac := range sess.snapshotAttachments() { | |
| 369 | 366 | d.invalidateRender(sess, ac, true, "transactional_resize.go") | |
| 370 | 367 | } | |
| 371 | 368 | } | |
| @@ -558,7 +555,6 @@ func (d *Daemon) applySessionLayoutWithMode(sess *session, size domain.Size, pro | |||
| 558 | 555 | } | |
| 559 | 556 | sess.mu.Lock() | |
| 560 | 557 | tabs := append([]*tab(nil), sess.tabs...) | |
| 561 | - | active := sess.active | |
| 562 | 558 | sess.mu.Unlock() | |
| 563 | 559 | plans := make([]*preparedTabLayout, 0, len(tabs)) | |
| 564 | 560 | for _, tb := range tabs { | |
| @@ -633,14 +629,17 @@ func (d *Daemon) applySessionLayoutWithMode(sess *session, size domain.Size, pro | |||
| 633 | 629 | } | |
| 634 | 630 | } | |
| 635 | 631 | } | |
| 636 | - | if active >= 0 && active < len(tabs) { | |
| 637 | - | floatingFailed, ok := d.applyVisibleFloatingLayout(sess, tabs[active], current) | |
| 632 | + | for _, tb := range tabs { | |
| 633 | + | floatingFailed, ok := d.applyVisibleFloatingLayout(sess, tb, current) | |
| 638 | 634 | if !ok { | |
| 639 | 635 | return nil, false | |
| 640 | 636 | } | |
| 641 | 637 | failed = append(failed, floatingFailed...) | |
| 642 | 638 | } | |
| 643 | 639 | failedCurrent := len(failed) == 0 || resizeMembersOwnerCurrent(failed) | |
| 640 | + | if current != nil && !current() { | |
| 641 | + | return nil, false | |
| 642 | + | } | |
| 644 | 643 | if len(failed) != 0 && failedCurrent { | |
| 645 | 644 | d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed, | |
| 646 | 645 | "pane resize failed; retrying in background", failed[len(failed)-1].err) | |
| @@ -648,6 +647,12 @@ func (d *Daemon) applySessionLayoutWithMode(sess *session, size domain.Size, pro | |||
| 648 | 647 | if len(failed) != 0 && !failedCurrent { | |
| 649 | 648 | return nil, false | |
| 650 | 649 | } | |
| 650 | + | if current != nil { | |
| 651 | + | d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostSnapshotDirty) | |
| 652 | + | if !current() { | |
| 653 | + | return nil, false | |
| 654 | + | } | |
| 655 | + | } | |
| 651 | 656 | markSnapshotDirty(sess) | |
| 652 | 657 | d.observeRuntime(ports.RuntimeResizeCommitted, 0, true) | |
| 653 | 658 | return failed, true | |
| @@ -660,7 +665,13 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease * | |||
| 660 | 665 | return false | |
| 661 | 666 | } | |
| 662 | 667 | snap := rc.resizeSnapshot() | |
| 663 | - | current := func() bool { return rc.resizeCurrentForLease(epoch, ac, lease, false) } | |
| 668 | + | connectionGeneration := ac.connectionGeneration.Load() | |
| 669 | + | connection := ac.transportSnapshot() | |
| 670 | + | current := func() bool { | |
| 671 | + | return ac.connectionGeneration.Load() == connectionGeneration && | |
| 672 | + | ac.transportSnapshotCurrent(connection) && | |
| 673 | + | rc.resizeCurrentForLease(epoch, ac, lease, false) | |
| 674 | + | } | |
| 664 | 675 | if !current() { | |
| 665 | 676 | return false | |
| 666 | 677 | } | |
| @@ -671,21 +682,38 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease * | |||
| 671 | 682 | if !ok { | |
| 672 | 683 | return false | |
| 673 | 684 | } | |
| 685 | + | // Revalidate while holding the attachment output boundary. A lease can be | |
| 686 | + | // retired after the layout commit but before this attachment-local size is | |
| 687 | + | // published; stale generations must not publish any remaining effects. | |
| 688 | + | d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication) | |
| 674 | 689 | ac.sendMu.Lock() | |
| 690 | + | if !current() { | |
| 691 | + | ac.sendMu.Unlock() | |
| 692 | + | return false | |
| 693 | + | } | |
| 675 | 694 | ac.size = snap.size | |
| 676 | 695 | ac.sendMu.Unlock() | |
| 696 | + | if !current() { | |
| 697 | + | return false | |
| 698 | + | } | |
| 677 | 699 | if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) { | |
| 678 | 700 | rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) }) | |
| 679 | 701 | } | |
| 702 | + | if !current() { | |
| 703 | + | return false | |
| 704 | + | } | |
| 680 | 705 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |
| 681 | 706 | // A successful transaction publishes exactly one full S2 frame. The | |
| 682 | 707 | // coordinator is the only emission route and stale epochs never reach it. | |
| 683 | - | if !rc.invalidateForLeaseAtResizeEpoch(ac, lease, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) { | |
| 708 | + | if !current() || !rc.invalidateForLeaseAtResizeEpoch(ac, lease, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) { | |
| 684 | 709 | return false | |
| 685 | 710 | } | |
| 686 | 711 | // The resize debounce has already elapsed. Consume this sticky reset now; | |
| 687 | 712 | // fire preserves ACK and synchronized-output gates rather than scheduling a | |
| 688 | 713 | // second urgent deadline. | |
| 714 | + | if !current() { | |
| 715 | + | return false | |
| 716 | + | } | |
| 689 | 717 | rc.fireCurrent(false) | |
| 690 | 718 | return true | |
| 691 | 719 | } | |
| @@ -824,7 +852,7 @@ func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, s | |||
| 824 | 852 | if ac == nil { | |
| 825 | 853 | return d.requestTransactionalResizeForLease(sess, nil, nil, size, immediate) | |
| 826 | 854 | } | |
| 827 | - | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 855 | + | rc := d.attachCoordinator(sess, ac, true) | |
| 828 | 856 | return d.requestTransactionalResizeForLease(sess, ac, rc.attachmentLease(ac), size, immediate) | |
| 829 | 857 | } | |
| 830 | 858 | ||
M internal/usecase/daemon/transactional_resize_test.go +60 -21
| @@ -131,7 +131,7 @@ func TestReplayResizePendingBuffersSuccessFailureAndBatchOrder(t *testing.T) { | |||
| 131 | 131 | t.Run("success resizes then replays", func(t *testing.T) { | |
| 132 | 132 | pty := &transactionalResizePTY{} | |
| 133 | 133 | d, sess, _, _ := newManualSessionWithPTYs(t, pty) | |
| 134 | - | tb, p := sess.activeTab(), sess.activeTab().focusedPane() | |
| 134 | + | tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane() | |
| 135 | 135 | p.resizeApplying = true | |
| 136 | 136 | p.resizePending = []byte("A\x1b[1;81HB") | |
| 137 | 137 | d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 120, Height: 23}) | |
| @@ -142,7 +142,7 @@ func TestReplayResizePendingBuffersSuccessFailureAndBatchOrder(t *testing.T) { | |||
| 142 | 142 | t.Run("failure retains old parser width", func(t *testing.T) { | |
| 143 | 143 | pty := &transactionalResizePTY{} | |
| 144 | 144 | d, sess, _, _ := newManualSessionWithPTYs(t, pty) | |
| 145 | - | tb, p := sess.activeTab(), sess.activeTab().focusedPane() | |
| 145 | + | tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane() | |
| 146 | 146 | p.resizeApplying = true | |
| 147 | 147 | p.resizePending = []byte("\x1b[1;81HB") | |
| 148 | 148 | d.replayResizePending(sess, tb, p, false, domain.Rect{Width: 120, Height: 23}) | |
| @@ -152,7 +152,7 @@ func TestReplayResizePendingBuffersSuccessFailureAndBatchOrder(t *testing.T) { | |||
| 152 | 152 | t.Run("batches retain read order", func(t *testing.T) { | |
| 153 | 153 | pty := &transactionalResizePTY{} | |
| 154 | 154 | d, sess, _, _ := newManualSessionWithPTYs(t, pty) | |
| 155 | - | tb, p := sess.activeTab(), sess.activeTab().focusedPane() | |
| 155 | + | tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane() | |
| 156 | 156 | p.screen.OnResponse = func([]byte) { p.ptyResponses = append(p.ptyResponses, 'r') } | |
| 157 | 157 | pty.onWrite = func([]byte) { | |
| 158 | 158 | p.mu.Lock() | |
| @@ -171,7 +171,7 @@ func TestProcessPTYDataRetainsCallbacksDuringResizeReplay(t *testing.T) { | |||
| 171 | 171 | pty := &transactionalResizePTY{onWrite: func(b []byte) { responses <- b }} | |
| 172 | 172 | d, sess, ac, sends := newManualSessionWithPTYs(t, pty) | |
| 173 | 173 | publishActiveClipboardCapability(d, sess, ac, ac.transport()) | |
| 174 | - | tb, p := sess.activeTab(), sess.activeTab().focusedPane() | |
| 174 | + | tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane() | |
| 175 | 175 | p.screen.OnResponse = func(b []byte) { p.ptyResponses = append(p.ptyResponses, b...) } | |
| 176 | 176 | p.screen.OnBell = func() { p.ptyAttention = true } | |
| 177 | 177 | p.screen.OnClipboard = func(b64 string) { p.ptyClipboards = append(p.ptyClipboards, b64) } | |
| @@ -207,7 +207,7 @@ func TestProcessPTYDataRetainsCallbacksDuringResizeReplay(t *testing.T) { | |||
| 207 | 207 | func TestApplySessionLayoutStopsRetryWhenSessionCanceled(t *testing.T) { | |
| 208 | 208 | pty := &transactionalResizePTY{} | |
| 209 | 209 | d, sess, _, _ := newManualSessionWithPTYs(t, pty) | |
| 210 | - | tb, p := sess.activeTab(), sess.activeTab().focusedPane() | |
| 210 | + | tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane() | |
| 211 | 211 | d.beforeSessionResizePublication = sess.cancel | |
| 212 | 212 | ||
| 213 | 213 | _, ok := d.applySessionLayout(sess, domain.Size{Cols: 100, Rows: 30}, nil, nil) | |
| @@ -226,7 +226,7 @@ func TestApplySessionLayoutStopsRetryWhenSessionCanceled(t *testing.T) { | |||
| 226 | 226 | func TestTransactionalResizeApplyDoesNotHoldTabOrPaneLocks(t *testing.T) { | |
| 227 | 227 | pty := &transactionalResizePTY{} | |
| 228 | 228 | d, sess, _, _ := newManualSessionWithPTYs(t, pty) | |
| 229 | - | tb := sess.activeTab() | |
| 229 | + | tb := testAttachmentTab(sess) | |
| 230 | 230 | p := tb.focusedPane() | |
| 231 | 231 | var tabUnlocked, paneUnlocked bool | |
| 232 | 232 | pty.onResize = func() { | |
| @@ -291,7 +291,7 @@ func TestTransactionalResizePartialFailureCommitsOnlySuccessfulPTYState(t *testi | |||
| 291 | 291 | ok := &transactionalResizePTY{} | |
| 292 | 292 | failed := &transactionalResizePTY{errs: []error{errors.New("scripted resize failure")}} | |
| 293 | 293 | d, sess, ac, _ := newManualSessionWithPTYs(t, ok) | |
| 294 | - | tb := sess.activeTab() | |
| 294 | + | tb := testAttachmentTab(sess) | |
| 295 | 295 | second := newPane("pane-2", failed, domain.Size{Cols: 80, Rows: 23}) | |
| 296 | 296 | tb.mu.Lock() | |
| 297 | 297 | tb.panes[second.id] = second | |
| @@ -332,7 +332,7 @@ func TestTransactionalResizeRetryMarksNamedSnapshotDirtyOnlyOnSuccess(t *testing | |||
| 332 | 332 | pty := &transactionalResizePTY{errs: tc.errs} | |
| 333 | 333 | d, sess, ac, _ := newManualSessionWithPTYs(t, pty) | |
| 334 | 334 | sess.snapEligible.Store(true) | |
| 335 | - | tb, p := sess.activeTab(), sess.activeTab().focusedPane() | |
| 335 | + | tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane() | |
| 336 | 336 | ||
| 337 | 337 | require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 100, Rows: 30}, true)) | |
| 338 | 338 | rc := sess.renderCoordinator() | |
| @@ -356,7 +356,7 @@ func TestTransactionalResizeRetryTargetsNewestCommittedEpoch(t *testing.T) { | |||
| 356 | 356 | ok := &transactionalResizePTY{} | |
| 357 | 357 | failed := &transactionalResizePTY{errs: []error{errors.New("first epoch fails"), nil}} | |
| 358 | 358 | d, sess, ac, _ := newManualSessionWithPTYs(t, ok) | |
| 359 | - | tb := sess.activeTab() | |
| 359 | + | tb := testAttachmentTab(sess) | |
| 360 | 360 | retry := newPane("retry", failed, domain.Size{Cols: 80, Rows: 23}) | |
| 361 | 361 | tb.mu.Lock() | |
| 362 | 362 | tb.panes[retry.id] = retry | |
| @@ -386,10 +386,8 @@ func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) { | |||
| 386 | 386 | name string | |
| 387 | 387 | run func(*renderCoordinator, *attachedClient, *attachedClient) | |
| 388 | 388 | }{ | |
| 389 | - | {"replace", func(rc *renderCoordinator, old, next *attachedClient) { rc.noteReplace(old, next) }}, | |
| 389 | + | {"detach and attach", func(rc *renderCoordinator, old, next *attachedClient) { rc.noteDetach(old); rc.attach(next) }}, | |
| 390 | 390 | {"detach", func(rc *renderCoordinator, old, _ *attachedClient) { rc.noteDetach(old) }}, | |
| 391 | - | {"park", func(rc *renderCoordinator, old, _ *attachedClient) { rc.notePark(old) }}, | |
| 392 | - | {"resume", func(rc *renderCoordinator, old, next *attachedClient) { rc.notePark(old); rc.attach(next) }}, | |
| 393 | 391 | } { | |
| 394 | 392 | t.Run(transition.name, func(t *testing.T) { | |
| 395 | 393 | rc := newRenderCoordinator(renderCoordinatorOptions{}) | |
| @@ -412,7 +410,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing | |||
| 412 | 410 | d, sess, ac, _ := newManualSessionWithPTYs(t, first, second) | |
| 413 | 411 | observer := &daemonRuntimeObserver{} | |
| 414 | 412 | d.runtimeObserver = observer | |
| 415 | - | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 413 | + | rc := d.attachCoordinator(sess, ac, true) | |
| 416 | 414 | lease := rc.attachmentLease(ac) | |
| 417 | 415 | var newer uint64 | |
| 418 | 416 | d.beforeSessionResizePublication = func() { | |
| @@ -462,10 +460,51 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing | |||
| 462 | 460 | require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}}, second.requested()) | |
| 463 | 461 | } | |
| 464 | 462 | ||
| 463 | + | func TestTransactionalResizeRechecksLeaseAtAttachmentPublication(t *testing.T) { | |
| 464 | + | for _, tc := range []struct { | |
| 465 | + | name string | |
| 466 | + | effect resizeOwnerPostEffect | |
| 467 | + | }{ | |
| 468 | + | {name: "before snapshot effects", effect: resizeOwnerPostSnapshotDirty}, | |
| 469 | + | {name: "before attachment size", effect: resizeOwnerPostCommitPublication}, | |
| 470 | + | } { | |
| 471 | + | t.Run(tc.name, func(t *testing.T) { | |
| 472 | + | pty := &transactionalResizePTY{} | |
| 473 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, pty) | |
| 474 | + | rc := d.attachCoordinator(sess, ac, true) | |
| 475 | + | lease := rc.attachmentLease(ac) | |
| 476 | + | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease) | |
| 477 | + | require.NotZero(t, epoch) | |
| 478 | + | initialSize := ac.size | |
| 479 | + | ||
| 480 | + | entered := make(chan struct{}) | |
| 481 | + | release := make(chan struct{}) | |
| 482 | + | d.beforeResizeOwnerPostEffect = func(effect resizeOwnerPostEffect) { | |
| 483 | + | if effect != tc.effect { | |
| 484 | + | return | |
| 485 | + | } | |
| 486 | + | close(entered) | |
| 487 | + | <-release | |
| 488 | + | } | |
| 489 | + | done := make(chan bool, 1) | |
| 490 | + | go func() { done <- d.runResizeTransaction(sess, ac, lease, epoch) }() | |
| 491 | + | <-entered | |
| 492 | + | newer := rc.recordResizeRequestForLease(domain.Size{Cols: 120, Rows: 34}, ac, lease) | |
| 493 | + | require.NotZero(t, newer) | |
| 494 | + | close(release) | |
| 495 | + | require.False(t, <-done, "stale resize generation must be rejected") | |
| 496 | + | require.Equal(t, initialSize, ac.size, "stale resize must not publish attachment size") | |
| 497 | + | if tc.effect == resizeOwnerPostSnapshotDirty { | |
| 498 | + | require.False(t, sess.snapDirty.Load(), "stale resize must not publish snapshot dirtiness") | |
| 499 | + | } | |
| 500 | + | }) | |
| 501 | + | } | |
| 502 | + | } | |
| 503 | + | ||
| 465 | 504 | func TestStaleRemovedMemberGateIsCanceledOnceByFreshPlan(t *testing.T) { | |
| 466 | 505 | pty := &transactionalResizePTY{} | |
| 467 | 506 | d, sess, _, _ := newManualSessionWithPTYs(t, pty) | |
| 468 | - | tb := sess.activeTab() | |
| 507 | + | tb := testAttachmentTab(sess) | |
| 469 | 508 | p := tb.focusedPane() | |
| 470 | 509 | p.resizeApplying = true | |
| 471 | 510 | p.resizePending = []byte("x") | |
| @@ -489,7 +528,7 @@ func TestStaleRemovedMemberGateIsCanceledOnceByFreshPlan(t *testing.T) { | |||
| 489 | 528 | func TestHeadlessResizeRetriesInvalidatedPlan(t *testing.T) { | |
| 490 | 529 | pty := &transactionalResizePTY{} | |
| 491 | 530 | d, sess, _, _ := newManualSessionWithPTYs(t, pty) | |
| 492 | - | tb := sess.activeTab() | |
| 531 | + | tb := testAttachmentTab(sess) | |
| 493 | 532 | var invalidate sync.Once | |
| 494 | 533 | pty.onResize = func() { | |
| 495 | 534 | invalidate.Do(func() { | |
| @@ -509,14 +548,14 @@ func TestHeadlessResizeRetriesInvalidatedPlan(t *testing.T) { | |||
| 509 | 548 | func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) { | |
| 510 | 549 | popupPTY := &transactionalResizePTY{errs: []error{errors.New("initial popup failure")}} | |
| 511 | 550 | d, source, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 512 | - | tb := source.activeTab() | |
| 551 | + | tb := testAttachmentTab(source) | |
| 513 | 552 | popup := newPane("popup", popupPTY, domain.Size{Cols: 80, Rows: 23}) | |
| 514 | 553 | tb.mu.Lock() | |
| 515 | 554 | tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7} | |
| 516 | 555 | tb.mu.Unlock() | |
| 517 | 556 | owner := publishPaneOwner(popup, source, tb, 7) | |
| 518 | 557 | ||
| 519 | - | rc := d.attachCoordinator(source, nil, ac, true) | |
| 558 | + | rc := d.attachCoordinator(source, ac, true) | |
| 520 | 559 | lease := rc.attachmentLease(ac) | |
| 521 | 560 | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease) | |
| 522 | 561 | require.True(t, d.runResizeTransaction(source, ac, lease, epoch)) | |
| @@ -561,7 +600,7 @@ func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) { | |||
| 561 | 600 | d.ApplyConfig(domain.Config{Floating: cfg}) | |
| 562 | 601 | require.True(t, d.requestTransactionalResize(sess, nil, tc.from, true)) | |
| 563 | 602 | ||
| 564 | - | tb := sess.activeTab() | |
| 603 | + | tb := testAttachmentTab(sess) | |
| 565 | 604 | fromGeometry := calculateContentFloatingGeometry(tabSize(tc.from), cfg) | |
| 566 | 605 | toGeometry := calculateContentFloatingGeometry(tabSize(tc.to), cfg) | |
| 567 | 606 | entered, release := make(chan struct{}), make(chan struct{}) | |
| @@ -735,13 +774,13 @@ func TestZeroInnerFloatingDrawerRejectsStaleRequestWithoutPhysicalPublication(t | |||
| 735 | 774 | func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) { | |
| 736 | 775 | popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}} | |
| 737 | 776 | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 738 | - | tb := sess.activeTab() | |
| 777 | + | tb := testAttachmentTab(sess) | |
| 739 | 778 | popup := newPane("popup", popupPTY, domain.Size{Cols: 80, Rows: 23}) | |
| 740 | 779 | tb.mu.Lock() | |
| 741 | 780 | tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7} | |
| 742 | 781 | tb.mu.Unlock() | |
| 743 | 782 | ||
| 744 | - | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 783 | + | rc := d.attachCoordinator(sess, ac, true) | |
| 745 | 784 | lease := rc.attachmentLease(ac) | |
| 746 | 785 | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease) | |
| 747 | 786 | require.NotZero(t, epoch) | |
| @@ -823,7 +862,7 @@ func TestTransactionalResizeFloatingMouseCopyAndSearchContract(t *testing.T) { | |||
| 823 | 862 | entered, release := make(chan struct{}), make(chan struct{}) | |
| 824 | 863 | pty := &transactionalResizePTY{errs: tc.errs, onResize: func() { close(entered); <-release }} | |
| 825 | 864 | d, sess, ac, _ := newManualSessionWithPTYs(t, pty) | |
| 826 | - | tb := sess.activeTab() | |
| 865 | + | tb := testAttachmentTab(sess) | |
| 827 | 866 | p := tb.focusedPane() | |
| 828 | 867 | old := p.rect | |
| 829 | 868 | d.enterCopyMode(sess, ac) | |
M internal/usecase/daemon/transport_observability_test.go +3 -3
| @@ -20,7 +20,7 @@ func TestTransportObservabilityDaemonBoundaries(t *testing.T) { | |||
| 20 | 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") | |
| @@ -125,7 +125,7 @@ func TestTransportObservabilityBlockedRenderObserverReleasesArchitectureLocks(t | |||
| 125 | 125 | } | |
| 126 | 126 | ac.ackOutputState(output.NewStateNum) | |
| 127 | 127 | ||
| 128 | - | pane := sess.activeTab().focusedPane() | |
| 128 | + | pane := testAttachmentTab(sess).focusedPane() | |
| 129 | 129 | for _, lock := range []struct { | |
| 130 | 130 | name string | |
| 131 | 131 | fn func() | |
| @@ -141,7 +141,7 @@ func TestTransportObservabilityBlockedRenderObserverReleasesArchitectureLocks(t | |||
| 141 | 141 | ||
| 142 | 142 | livePTY := make(chan struct{}) | |
| 143 | 143 | go func() { | |
| 144 | - | d.processPTYData(sess, sess.activeTab(), pane, []byte("live"), false) | |
| 144 | + | d.processPTYData(sess, testAttachmentTab(sess), pane, []byte("live"), false) | |
| 145 | 145 | close(livePTY) | |
| 146 | 146 | }() | |
| 147 | 147 | awaitDaemonObserver(t, livePTY, "live PTY processing") | |