feat(daemon): move tabs between live sessions
- commit
- ac63f7437dfb3885129a0c551a469385e1a1d264
- author
- brice · · 2026-07-28 10:29 UTC
- parent
- bf32e20
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +1434 -4
M internal/usecase/daemon/attachment_transition.go +6 -2
| @@ -19,8 +19,12 @@ type attachmentTransitionRequest struct { | |||
| 19 | 19 | // preserveRole commits navigation state without replacing attachment | |
| 20 | 20 | // ownership. It is used for same-session picker selections after the exact | |
| 21 | 21 | // initiating capability has been frozen and revalidated. | |
| 22 | - | preserveRole bool | |
| 23 | - | expectedSourceTab *tab | |
| 22 | + | preserveRole bool | |
| 23 | + | expectedSourceTab *tab | |
| 24 | + | // transferExpectedSourceTab permits an installed visible floating pane only | |
| 25 | + | // when the caller atomically transfers this exact tab under its own tab and | |
| 26 | + | // floating-generation fences. Ordinary navigation still rejects visibility. | |
| 27 | + | transferExpectedSourceTab bool | |
| 24 | 28 | createTargetLocked func() (*session, error) | |
| 25 | 29 | ready bool | |
| 26 | 30 | expectedTargetCurrent *attachedClient | |
| @@ -19,8 +19,12 @@ type attachmentTransitionRequest struct { | |||||
| 19 | // preserveRole commits navigation state without replacing attachment | 19 | // preserveRole commits navigation state without replacing attachment | ||
| 20 | // ownership. It is used for same-session picker selections after the exact | 20 | // ownership. It is used for same-session picker selections after the exact | ||
| 21 | // initiating capability has been frozen and revalidated. | 21 | // initiating capability has been frozen and revalidated. | ||
| 22 | - | preserveRole bool | 22 | + | preserveRole bool |
| 23 | - | expectedSourceTab *tab | 23 | + | expectedSourceTab *tab |
| 24 | + | // transferExpectedSourceTab permits an installed visible floating pane only | |||
| 25 | + | // when the caller atomically transfers this exact tab under its own tab and | |||
| 26 | + | // floating-generation fences. Ordinary navigation still rejects visibility. | |||
| 27 | + | transferExpectedSourceTab bool | |||
| 24 | createTargetLocked func() (*session, error) | 28 | createTargetLocked func() (*session, error) | ||
| 25 | ready bool | 29 | ready bool | ||
| 26 | expectedTargetCurrent *attachedClient | 30 | expectedTargetCurrent *attachedClient | ||
M internal/usecase/daemon/attachment_transition_publish.go +6 -2
| @@ -50,6 +50,10 @@ func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool { | |||
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | 52 | func transitionSourceTabCurrentLocked(source *session, expected *tab) bool { | |
| 53 | + | return transitionSourceTabCurrentForRequestLocked(source, expected, false) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func transitionSourceTabCurrentForRequestLocked(source *session, expected *tab, transferExpected bool) bool { | |
| 53 | 57 | if expected == nil { | |
| 54 | 58 | return true | |
| 55 | 59 | } | |
| @@ -58,7 +62,7 @@ func transitionSourceTabCurrentLocked(source *session, expected *tab) bool { | |||
| 58 | 62 | } | |
| 59 | 63 | expected.mu.Lock() | |
| 60 | 64 | defer expected.mu.Unlock() | |
| 61 | - | return expected.floating.state != floatingVisible | |
| 65 | + | return transferExpected || expected.floating.state != floatingVisible | |
| 62 | 66 | } | |
| 63 | 67 | ||
| 64 | 68 | // transitionAttachmentLocked is the publication half of transitionAttachment. | |
| @@ -127,7 +131,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR | |||
| 127 | 131 | releaseCoordinators: lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator), | |
| 128 | 132 | } | |
| 129 | 133 | invalid := req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) || | |
| 130 | - | !transitionSourceTabCurrentLocked(source, req.expectedSourceTab)) | |
| 134 | + | !transitionSourceTabCurrentForRequestLocked(source, req.expectedSourceTab, req.transferExpectedSourceTab)) | |
| 131 | 135 | invalid = invalid || targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next) | |
| 132 | 136 | invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil && | |
| 133 | 137 | (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next) | |
| @@ -50,6 +50,10 @@ func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool { | |||||
| 50 | } | 50 | } | ||
| 51 | 51 | ||||
| 52 | func transitionSourceTabCurrentLocked(source *session, expected *tab) bool { | 52 | func transitionSourceTabCurrentLocked(source *session, expected *tab) bool { | ||
| 53 | + | return transitionSourceTabCurrentForRequestLocked(source, expected, false) | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func transitionSourceTabCurrentForRequestLocked(source *session, expected *tab, transferExpected bool) bool { | |||
| 53 | if expected == nil { | 57 | if expected == nil { | ||
| 54 | return true | 58 | return true | ||
| 55 | } | 59 | } | ||
| @@ -58,7 +62,7 @@ func transitionSourceTabCurrentLocked(source *session, expected *tab) bool { | |||||
| 58 | } | 62 | } | ||
| 59 | expected.mu.Lock() | 63 | expected.mu.Lock() | ||
| 60 | defer expected.mu.Unlock() | 64 | defer expected.mu.Unlock() | ||
| 61 | - | return expected.floating.state != floatingVisible | 65 | + | return transferExpected || expected.floating.state != floatingVisible |
| 62 | } | 66 | } | ||
| 63 | 67 | ||||
| 64 | // transitionAttachmentLocked is the publication half of transitionAttachment. | 68 | // transitionAttachmentLocked is the publication half of transitionAttachment. | ||
| @@ -127,7 +131,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR | |||||
| 127 | releaseCoordinators: lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator), | 131 | releaseCoordinators: lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator), | ||
| 128 | } | 132 | } | ||
| 129 | invalid := req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) || | 133 | invalid := req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) || | ||
| 130 | - | !transitionSourceTabCurrentLocked(source, req.expectedSourceTab)) | 134 | + | !transitionSourceTabCurrentForRequestLocked(source, req.expectedSourceTab, req.transferExpectedSourceTab)) |
| 131 | invalid = invalid || targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next) | 135 | invalid = invalid || targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next) | ||
| 132 | invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil && | 136 | invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil && | ||
| 133 | (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next) | 137 | (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next) | ||
M internal/usecase/daemon/daemon.go +1 -0
| @@ -163,6 +163,7 @@ type Daemon struct { | |||
| 163 | 163 | afterMoveLifecycleReserved func() | |
| 164 | 164 | afterMovePaneSourceSnapshot func() | |
| 165 | 165 | beforeMovePaneCommit func() | |
| 166 | + | beforeMoveTabCommit func() | |
| 166 | 167 | // afterDetachRoleEffectsFrozen observes terminal detach after it wins the | |
| 167 | 168 | // attachment gate but before it checks session ownership. | |
| 168 | 169 | afterDetachRoleEffectsFrozen func() | |
| @@ -163,6 +163,7 @@ type Daemon struct { | |||||
| 163 | afterMoveLifecycleReserved func() | 163 | afterMoveLifecycleReserved func() | ||
| 164 | afterMovePaneSourceSnapshot func() | 164 | afterMovePaneSourceSnapshot func() | ||
| 165 | beforeMovePaneCommit func() | 165 | beforeMovePaneCommit func() | ||
| 166 | + | beforeMoveTabCommit func() | |||
| 166 | // afterDetachRoleEffectsFrozen observes terminal detach after it wins the | 167 | // afterDetachRoleEffectsFrozen observes terminal detach after it wins the | ||
| 167 | // attachment gate but before it checks session ownership. | 168 | // attachment gate but before it checks session ownership. | ||
| 168 | afterDetachRoleEffectsFrozen func() | 169 | afterDetachRoleEffectsFrozen func() | ||
M internal/usecase/daemon/move.go +6 -0
| @@ -15,6 +15,12 @@ type movePaneRequest struct { | |||
| 15 | 15 | DestinationTabID domain.TabStableID | |
| 16 | 16 | } | |
| 17 | 17 | ||
| 18 | + | type moveTabRequest struct { | |
| 19 | + | Source moveSessionLocator | |
| 20 | + | SourceTabID domain.TabStableID | |
| 21 | + | Destination moveSessionLocator | |
| 22 | + | } | |
| 23 | + | ||
| 18 | 24 | // movePane resolves, reserves, validates, and commits one pane relocation. All | |
| 19 | 25 | // fallible work happens before the membership publication section. Once the | |
| 20 | 26 | // section starts, the caller holds the ordered architecture locks and only | |
| @@ -15,6 +15,12 @@ type movePaneRequest struct { | |||||
| 15 | DestinationTabID domain.TabStableID | 15 | DestinationTabID domain.TabStableID | ||
| 16 | } | 16 | } | ||
| 17 | 17 | ||||
| 18 | + | type moveTabRequest struct { | |||
| 19 | + | Source moveSessionLocator | |||
| 20 | + | SourceTabID domain.TabStableID | |||
| 21 | + | Destination moveSessionLocator | |||
| 22 | + | } | |||
| 23 | + | ||||
| 18 | // movePane resolves, reserves, validates, and commits one pane relocation. All | 24 | // movePane resolves, reserves, validates, and commits one pane relocation. All | ||
| 19 | // fallible work happens before the membership publication section. Once the | 25 | // fallible work happens before the membership publication section. Once the | ||
| 20 | // section starts, the caller holds the ordered architecture locks and only | 26 | // section starts, the caller holds the ordered architecture locks and only | ||
M internal/usecase/daemon/move_handoff_failure_test.go +77 -0
| @@ -251,3 +251,80 @@ func TestMovePaneFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) { | |||
| 251 | 251 | after := captureMoveState(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient) | |
| 252 | 252 | require.Equal(t, before, after, "stale handoff validation must reject before any move publication") | |
| 253 | 253 | } | |
| 254 | + | ||
| 255 | + | type moveTabHandoffState struct { | |
| 256 | + | closing bool | |
| 257 | + | registry map[domain.SessionID]*session | |
| 258 | + | source moveSessionState | |
| 259 | + | destination moveSessionState | |
| 260 | + | movedTab moveTabState | |
| 261 | + | follower moveClientState | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func captureMoveTabHandoffState(d *Daemon, source, destination *session, moved *tab, follower *attachedClient) moveTabHandoffState { | |
| 265 | + | d.mu.Lock() | |
| 266 | + | d.notices.routingMu.Lock() | |
| 267 | + | unlockSessions := lockAttachmentSessions(source, destination) | |
| 268 | + | moved.mu.Lock() | |
| 269 | + | ||
| 270 | + | registry := make(map[domain.SessionID]*session, len(d.sessions)) | |
| 271 | + | maps.Copy(registry, d.sessions) | |
| 272 | + | state := moveTabHandoffState{ | |
| 273 | + | closing: d.closing, | |
| 274 | + | registry: registry, | |
| 275 | + | source: captureMoveSessionStateLocked(source), | |
| 276 | + | destination: captureMoveSessionStateLocked(destination), | |
| 277 | + | movedTab: captureMoveTabStateLocked(moved), | |
| 278 | + | follower: captureMoveClientStateLocked(source, destination, follower), | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | moved.mu.Unlock() | |
| 282 | + | unlockSessions() | |
| 283 | + | d.notices.routingMu.Unlock() | |
| 284 | + | d.mu.Unlock() | |
| 285 | + | return state | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func TestMoveTabFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) { | |
| 289 | + | d, source, follower, destination, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 290 | + | floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8}) | |
| 291 | + | floating.ctx, floating.cancel = context.WithCancel(d.paneProcessCtx) | |
| 292 | + | t.Cleanup(floating.cancel) | |
| 293 | + | moved.mu.Lock() | |
| 294 | + | moved.floating = floatingSlot{state: floatingVisible, pane: floating, desiredVisible: true, generation: 9} | |
| 295 | + | moved.mu.Unlock() | |
| 296 | + | publishPaneOwner(floating, source, moved, 9) | |
| 297 | + | ||
| 298 | + | before := captureMoveTabHandoffState(d, source, destination, moved, follower) | |
| 299 | + | originalTransport := before.follower.transport | |
| 300 | + | staleTransport := &closeTrackingTransport{} | |
| 301 | + | staleInjected := make(chan struct{}) | |
| 302 | + | d.afterRoleEffectGateFrozen = func(action string, client *attachedClient) { | |
| 303 | + | if action != "move-tab" || client != follower { | |
| 304 | + | return | |
| 305 | + | } | |
| 306 | + | client.replaceTransport(staleTransport) | |
| 307 | + | close(staleInjected) | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | moveDone := make(chan error, 1) | |
| 311 | + | go func() { | |
| 312 | + | moveDone <- d.moveTab(moveTabRequest{ | |
| 313 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 314 | + | SourceTabID: domain.TabStableID(moved.stableID), | |
| 315 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 316 | + | }) | |
| 317 | + | }() | |
| 318 | + | <-staleInjected | |
| 319 | + | err := <-moveDone | |
| 320 | + | ||
| 321 | + | follower.linkMu.Lock() | |
| 322 | + | follower.tr = originalTransport.transport | |
| 323 | + | follower.transportIncarnation = originalTransport.incarnation | |
| 324 | + | follower.linkMu.Unlock() | |
| 325 | + | d.afterRoleEffectGateFrozen = nil | |
| 326 | + | ||
| 327 | + | require.ErrorIs(t, err, errAttachmentTransition) | |
| 328 | + | after := captureMoveTabHandoffState(d, source, destination, moved, follower) | |
| 329 | + | require.Equal(t, before, after, "stale move-tab handoff validation must reject before any move publication") | |
| 330 | + | } | |
| @@ -251,3 +251,80 @@ func TestMovePaneFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) { | |||||
| 251 | after := captureMoveState(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient) | 251 | after := captureMoveState(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient) | ||
| 252 | require.Equal(t, before, after, "stale handoff validation must reject before any move publication") | 252 | require.Equal(t, before, after, "stale handoff validation must reject before any move publication") | ||
| 253 | } | 253 | } | ||
| 254 | + | ||||
| 255 | + | type moveTabHandoffState struct { | |||
| 256 | + | closing bool | |||
| 257 | + | registry map[domain.SessionID]*session | |||
| 258 | + | source moveSessionState | |||
| 259 | + | destination moveSessionState | |||
| 260 | + | movedTab moveTabState | |||
| 261 | + | follower moveClientState | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | func captureMoveTabHandoffState(d *Daemon, source, destination *session, moved *tab, follower *attachedClient) moveTabHandoffState { | |||
| 265 | + | d.mu.Lock() | |||
| 266 | + | d.notices.routingMu.Lock() | |||
| 267 | + | unlockSessions := lockAttachmentSessions(source, destination) | |||
| 268 | + | moved.mu.Lock() | |||
| 269 | + | ||||
| 270 | + | registry := make(map[domain.SessionID]*session, len(d.sessions)) | |||
| 271 | + | maps.Copy(registry, d.sessions) | |||
| 272 | + | state := moveTabHandoffState{ | |||
| 273 | + | closing: d.closing, | |||
| 274 | + | registry: registry, | |||
| 275 | + | source: captureMoveSessionStateLocked(source), | |||
| 276 | + | destination: captureMoveSessionStateLocked(destination), | |||
| 277 | + | movedTab: captureMoveTabStateLocked(moved), | |||
| 278 | + | follower: captureMoveClientStateLocked(source, destination, follower), | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | moved.mu.Unlock() | |||
| 282 | + | unlockSessions() | |||
| 283 | + | d.notices.routingMu.Unlock() | |||
| 284 | + | d.mu.Unlock() | |||
| 285 | + | return state | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | func TestMoveTabFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) { | |||
| 289 | + | d, source, follower, destination, _, moved := setupMoveTabFinalSourceHandoff(t) | |||
| 290 | + | floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8}) | |||
| 291 | + | floating.ctx, floating.cancel = context.WithCancel(d.paneProcessCtx) | |||
| 292 | + | t.Cleanup(floating.cancel) | |||
| 293 | + | moved.mu.Lock() | |||
| 294 | + | moved.floating = floatingSlot{state: floatingVisible, pane: floating, desiredVisible: true, generation: 9} | |||
| 295 | + | moved.mu.Unlock() | |||
| 296 | + | publishPaneOwner(floating, source, moved, 9) | |||
| 297 | + | ||||
| 298 | + | before := captureMoveTabHandoffState(d, source, destination, moved, follower) | |||
| 299 | + | originalTransport := before.follower.transport | |||
| 300 | + | staleTransport := &closeTrackingTransport{} | |||
| 301 | + | staleInjected := make(chan struct{}) | |||
| 302 | + | d.afterRoleEffectGateFrozen = func(action string, client *attachedClient) { | |||
| 303 | + | if action != "move-tab" || client != follower { | |||
| 304 | + | return | |||
| 305 | + | } | |||
| 306 | + | client.replaceTransport(staleTransport) | |||
| 307 | + | close(staleInjected) | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | moveDone := make(chan error, 1) | |||
| 311 | + | go func() { | |||
| 312 | + | moveDone <- d.moveTab(moveTabRequest{ | |||
| 313 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |||
| 314 | + | SourceTabID: domain.TabStableID(moved.stableID), | |||
| 315 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |||
| 316 | + | }) | |||
| 317 | + | }() | |||
| 318 | + | <-staleInjected | |||
| 319 | + | err := <-moveDone | |||
| 320 | + | ||||
| 321 | + | follower.linkMu.Lock() | |||
| 322 | + | follower.tr = originalTransport.transport | |||
| 323 | + | follower.transportIncarnation = originalTransport.incarnation | |||
| 324 | + | follower.linkMu.Unlock() | |||
| 325 | + | d.afterRoleEffectGateFrozen = nil | |||
| 326 | + | ||||
| 327 | + | require.ErrorIs(t, err, errAttachmentTransition) | |||
| 328 | + | after := captureMoveTabHandoffState(d, source, destination, moved, follower) | |||
| 329 | + | require.Equal(t, before, after, "stale move-tab handoff validation must reject before any move publication") | |||
| 330 | + | } | |||
A internal/usecase/daemon/move_tab.go +439 -0
| @@ -0,0 +1,439 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | type moveTabAdmission struct { | |
| 12 | + | tab *tab | |
| 13 | + | sourceIndex int | |
| 14 | + | sourceClient *attachedClient | |
| 15 | + | sourceSnatched int | |
| 16 | + | layoutGeneration uint64 | |
| 17 | + | floatingState floatingState | |
| 18 | + | floatingPane *pane | |
| 19 | + | floatingGeneration uint64 | |
| 20 | + | panes []*pane | |
| 21 | + | destinationActive *tab | |
| 22 | + | destinationSize domain.Size | |
| 23 | + | finalSource bool | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | type moveTabCommit struct { | |
| 27 | + | req moveTabRequest | |
| 28 | + | source *session | |
| 29 | + | destination *session | |
| 30 | + | admission moveTabAdmission | |
| 31 | + | handoffFrozen bool | |
| 32 | + | sourceRolesFrozen bool | |
| 33 | + | handoffReq attachmentTransitionRequest | |
| 34 | + | handoffPublication *attachmentPublication | |
| 35 | + | handoffResult attachmentTransitionResult | |
| 36 | + | syncCleanup syncTimerCleanup | |
| 37 | + | sourceCleanupToken attachmentRoleToken | |
| 38 | + | oldTabCancel context.CancelFunc | |
| 39 | + | sourceEmpty bool | |
| 40 | + | retiredParked []parkedAttachmentRetirement | |
| 41 | + | retiredAttachments []detachedAttachmentSnapshot | |
| 42 | + | sourceMetadata domain.CatalogueMetadataUpdate | |
| 43 | + | sourceMetadataValid bool | |
| 44 | + | destMetadata domain.CatalogueMetadataUpdate | |
| 45 | + | destMetadataValid bool | |
| 46 | + | err error | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func (d *Daemon) moveTab(req moveTabRequest) error { | |
| 50 | + | if d == nil || req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID { | |
| 51 | + | return errMovePaneInvalid | |
| 52 | + | } | |
| 53 | + | d.mu.Lock() | |
| 54 | + | source := moveSessionForLocatorLocked(d, req.Source) | |
| 55 | + | destination := moveSessionForLocatorLocked(d, req.Destination) | |
| 56 | + | d.mu.Unlock() | |
| 57 | + | if source == nil || destination == nil || source == destination { | |
| 58 | + | return errMovePaneInvalid | |
| 59 | + | } | |
| 60 | + | reservation, err := d.reserveMoveLifecycles(source, destination) | |
| 61 | + | if err != nil { | |
| 62 | + | return err | |
| 63 | + | } | |
| 64 | + | reservationHeld := true | |
| 65 | + | defer func() { | |
| 66 | + | if reservationHeld { | |
| 67 | + | reservation.Release() | |
| 68 | + | } | |
| 69 | + | }() | |
| 70 | + | if d.afterMoveLifecycleReserved != nil { | |
| 71 | + | d.afterMoveLifecycleReserved() | |
| 72 | + | } | |
| 73 | + | unlockDispatch := lockMoveDispatch(source, destination) | |
| 74 | + | dispatchHeld := true | |
| 75 | + | defer func() { | |
| 76 | + | if dispatchHeld { | |
| 77 | + | unlockDispatch() | |
| 78 | + | } | |
| 79 | + | }() | |
| 80 | + | ||
| 81 | + | admission, err := d.snapshotMoveTabAdmission(req, source, destination) | |
| 82 | + | if err != nil { | |
| 83 | + | return err | |
| 84 | + | } | |
| 85 | + | commit := moveTabCommit{req: req, source: source, destination: destination, admission: *admission} | |
| 86 | + | var frozen frozenRoleEffectGates | |
| 87 | + | if admission.finalSource && admission.sourceClient != nil { | |
| 88 | + | transport := admission.sourceClient.transportSnapshot() | |
| 89 | + | token := source.attachmentToken(admission.sourceClient, transport.transport) | |
| 90 | + | if token.role != attachmentActive || token.transport.transport == nil { | |
| 91 | + | return errMovePaneInvalid | |
| 92 | + | } | |
| 93 | + | commit.handoffReq = attachmentTransitionRequest{ | |
| 94 | + | source: source, target: destination, next: admission.sourceClient, | |
| 95 | + | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 96 | + | expectedTransport: token.transport, sourceToken: &token, | |
| 97 | + | expectedSourceTab: admission.tab, transferExpectedSourceTab: true, ready: true, action: "move-tab", | |
| 98 | + | } | |
| 99 | + | var participants attachmentTransitionParticipants | |
| 100 | + | commit.handoffReq, participants, err = d.snapshotAttachmentTransition(commit.handoffReq) | |
| 101 | + | if err != nil { | |
| 102 | + | return err | |
| 103 | + | } | |
| 104 | + | d.mu.Lock() | |
| 105 | + | source.mu.Lock() | |
| 106 | + | for ac := range source.snatched { | |
| 107 | + | participants.clients = append(participants.clients, ac) | |
| 108 | + | if tr := ac.transportSnapshot(); tr.transport != nil { | |
| 109 | + | participants.interrupts = append(participants.interrupts, roleTransportInterrupt{ac: ac, transport: tr}) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | source.mu.Unlock() | |
| 113 | + | d.mu.Unlock() | |
| 114 | + | frozen = tryFreezeRoleEffectGatesInterruptingObserved(participants.interrupts, func(ac *attachedClient) { | |
| 115 | + | if d.afterRoleEffectGateFrozen != nil { | |
| 116 | + | d.afterRoleEffectGateFrozen("move-tab", ac) | |
| 117 | + | } | |
| 118 | + | }, participants.clients...) | |
| 119 | + | if !frozen.acquired || !frozen.drained { | |
| 120 | + | frozen.unfreeze() | |
| 121 | + | return errMovePaneInvalid | |
| 122 | + | } | |
| 123 | + | commit.handoffFrozen = true | |
| 124 | + | commit.sourceRolesFrozen = true | |
| 125 | + | commit.handoffReq.roleEffectsFrozen = true | |
| 126 | + | } else if admission.finalSource && admission.sourceSnatched > 0 { | |
| 127 | + | d.mu.Lock() | |
| 128 | + | source.mu.Lock() | |
| 129 | + | clients := make([]*attachedClient, 0, len(source.snatched)) | |
| 130 | + | interrupts := make([]roleTransportInterrupt, 0, len(source.snatched)) | |
| 131 | + | for ac := range source.snatched { | |
| 132 | + | clients = append(clients, ac) | |
| 133 | + | if tr := ac.transportSnapshot(); tr.transport != nil { | |
| 134 | + | interrupts = append(interrupts, roleTransportInterrupt{ac: ac, transport: tr}) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | source.mu.Unlock() | |
| 138 | + | d.mu.Unlock() | |
| 139 | + | frozen = tryFreezeRoleEffectGatesInterruptingObserved(interrupts, nil, clients...) | |
| 140 | + | if !frozen.acquired || !frozen.drained { | |
| 141 | + | frozen.unfreeze() | |
| 142 | + | return errMovePaneInvalid | |
| 143 | + | } | |
| 144 | + | commit.sourceRolesFrozen = true | |
| 145 | + | } | |
| 146 | + | rolesFrozen := commit.sourceRolesFrozen | |
| 147 | + | defer func() { | |
| 148 | + | if rolesFrozen { | |
| 149 | + | frozen.unfreeze() | |
| 150 | + | } | |
| 151 | + | }() | |
| 152 | + | ||
| 153 | + | fences := newMoveTabResizeFences(source, destination, admission.tab) | |
| 154 | + | if !fences.acquire(func() bool { return commit.publishLocked(d, fences.panes) }) { | |
| 155 | + | commit.releasePublication() | |
| 156 | + | if commit.err != nil { | |
| 157 | + | return commit.err | |
| 158 | + | } | |
| 159 | + | return errMovePaneInvalid | |
| 160 | + | } | |
| 161 | + | fences.Release() | |
| 162 | + | if commit.oldTabCancel != nil { | |
| 163 | + | commit.oldTabCancel() | |
| 164 | + | } | |
| 165 | + | for _, p := range admission.panes { | |
| 166 | + | if commit.sourceCleanupToken.current() { | |
| 167 | + | commit.sourceCleanupToken.ac.overlays.clearCopyModeForPane(p) | |
| 168 | + | commit.sourceCleanupToken.ac.pruneCaptureFrames(p) | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | markSnapshotDirty(destination) | |
| 172 | + | if !commit.sourceEmpty { | |
| 173 | + | markSnapshotDirty(source) | |
| 174 | + | } | |
| 175 | + | commit.syncCleanup.finish() | |
| 176 | + | if rolesFrozen { | |
| 177 | + | frozen.unfreeze() | |
| 178 | + | rolesFrozen = false | |
| 179 | + | } | |
| 180 | + | unlockDispatch() | |
| 181 | + | dispatchHeld = false | |
| 182 | + | reservation.Release() | |
| 183 | + | reservationHeld = false | |
| 184 | + | ||
| 185 | + | if !commit.sourceEmpty { | |
| 186 | + | if sourceTab := source.activeTab(); sourceTab != nil { | |
| 187 | + | d.activateTab(source, sourceTab) | |
| 188 | + | d.applyTabLayout(source, sourceTab) | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | d.applyTabLayout(destination, admission.tab) | |
| 192 | + | if commit.sourceMetadataValid && !commit.sourceEmpty { | |
| 193 | + | d.markCatalogueDirty(commit.sourceMetadata) | |
| 194 | + | } | |
| 195 | + | if commit.destMetadataValid { | |
| 196 | + | d.markCatalogueDirty(commit.destMetadata) | |
| 197 | + | } | |
| 198 | + | if commit.handoffResult.published.ac != nil { | |
| 199 | + | follower := commit.handoffResult.published.ac | |
| 200 | + | d.applyHostTheme(destination, follower, follower.getClientTheme(), false) | |
| 201 | + | follower.recordPreviousSession(source) | |
| 202 | + | d.deferAttachmentTransitionCleanups(commit.handoffResult) | |
| 203 | + | d.firstPaintForTransition(commit.handoffResult.published) | |
| 204 | + | } | |
| 205 | + | if commit.sourceEmpty { | |
| 206 | + | retireEmptySessionAfterMove(d, source) | |
| 207 | + | } | |
| 208 | + | for _, attachment := range commit.retiredAttachments { | |
| 209 | + | d.unregisterPreview(attachment.ac) | |
| 210 | + | attachment.ac.clearPreviousSession() | |
| 211 | + | attachment.ac.clearCaptureFrames() | |
| 212 | + | d.notifyDetachedSnapshotAsync(attachment, ports.ReasonSessionKilled) | |
| 213 | + | } | |
| 214 | + | for _, retirement := range commit.retiredParked { | |
| 215 | + | if retirement.parked != nil && retirement.parked.ac != nil { | |
| 216 | + | d.unregisterPreview(retirement.parked.ac) | |
| 217 | + | retirement.parked.ac.clearCaptureFrames() | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | finishParkedAttachmentRetirements(commit.retiredParked) | |
| 221 | + | if commit.sourceEmpty && commit.sourceMetadataValid && d.persistEnabled { | |
| 222 | + | if err := d.beginSnapshotPurge(source.name, source.incarnation); err == nil { | |
| 223 | + | if err := d.finishSnapshotPurge(d.serveCtx, source.name, source.incarnation); err != nil { | |
| 224 | + | d.log.Warn("moving final tab source purge failed", "err", err, "session", source.name) | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | return nil | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destination *session) (*moveTabAdmission, error) { | |
| 232 | + | d.mu.Lock() | |
| 233 | + | defer d.mu.Unlock() | |
| 234 | + | if d.closing || d.sessions[source.id] != source || d.sessions[destination.id] != destination { | |
| 235 | + | return nil, errMovePaneInvalid | |
| 236 | + | } | |
| 237 | + | unlockSessions := lockAttachmentSessions(source, destination) | |
| 238 | + | defer unlockSessions() | |
| 239 | + | if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) { | |
| 240 | + | return nil, errMovePaneInvalid | |
| 241 | + | } | |
| 242 | + | moved := findMoveTabLocked(source, req.SourceTabID) | |
| 243 | + | if moved == nil || destination.active < 0 || destination.active >= len(destination.tabs) { | |
| 244 | + | return nil, errMovePaneInvalid | |
| 245 | + | } | |
| 246 | + | destinationActive := destination.tabs[destination.active] | |
| 247 | + | destinationActive.mu.Lock() | |
| 248 | + | destinationSize := destinationActive.size | |
| 249 | + | destinationActive.mu.Unlock() | |
| 250 | + | moved.mu.Lock() | |
| 251 | + | defer moved.mu.Unlock() | |
| 252 | + | if !moved.floatingTransferableLocked() { | |
| 253 | + | return nil, errMovePaneInvalid | |
| 254 | + | } | |
| 255 | + | if _, ok := layout.Solve(moved.tree.Root, domain.Rect{Width: destinationSize.Cols, Height: destinationSize.Rows}); !ok { | |
| 256 | + | return nil, errMovePaneInvalid | |
| 257 | + | } | |
| 258 | + | panes := make([]*pane, 0, len(moved.panes)+1) | |
| 259 | + | for _, p := range moved.panes { | |
| 260 | + | panes = append(panes, p) | |
| 261 | + | } | |
| 262 | + | if moved.floating.pane != nil && (moved.floating.state == floatingHidden || moved.floating.state == floatingVisible) { | |
| 263 | + | panes = append(panes, moved.floating.pane) | |
| 264 | + | } | |
| 265 | + | idx := indexMoveTabLocked(source, moved) | |
| 266 | + | return &moveTabAdmission{ | |
| 267 | + | tab: moved, sourceIndex: idx, | |
| 268 | + | sourceClient: source.client, sourceSnatched: len(source.snatched), | |
| 269 | + | layoutGeneration: moved.layoutGeneration, floatingState: moved.floating.state, | |
| 270 | + | floatingPane: moved.floating.pane, floatingGeneration: moved.floating.generation, | |
| 271 | + | panes: panes, destinationActive: destinationActive, destinationSize: destinationSize, | |
| 272 | + | finalSource: len(source.tabs) == 1, | |
| 273 | + | }, nil | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | func (c *moveTabCommit) releasePublication() { | |
| 277 | + | if c.handoffPublication != nil { | |
| 278 | + | c.handoffPublication.unlockCoordinators() | |
| 279 | + | c.handoffPublication = nil | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool { | |
| 284 | + | d.mu.Lock() | |
| 285 | + | if d.closing || d.sessions[c.source.id] != c.source || d.sessions[c.destination.id] != c.destination { | |
| 286 | + | d.mu.Unlock() | |
| 287 | + | return false | |
| 288 | + | } | |
| 289 | + | d.notices.routingMu.Lock() | |
| 290 | + | unlockSessions := lockAttachmentSessions(c.source, c.destination) | |
| 291 | + | defer unlockSessions() | |
| 292 | + | defer d.notices.routingMu.Unlock() | |
| 293 | + | defer d.mu.Unlock() | |
| 294 | + | if !moveSessionLocatorCurrentLocked(c.source, c.req.Source) || !moveSessionLocatorCurrentLocked(c.destination, c.req.Destination) { | |
| 295 | + | return false | |
| 296 | + | } | |
| 297 | + | moved := c.admission.tab | |
| 298 | + | if indexMoveTabLocked(c.source, moved) != c.admission.sourceIndex || findMoveTabLocked(c.destination, c.req.SourceTabID) != nil || | |
| 299 | + | len(c.source.tabs) == 1 != c.admission.finalSource { | |
| 300 | + | return false | |
| 301 | + | } | |
| 302 | + | // Attachment validation inspects the expected source tab and therefore must | |
| 303 | + | // precede moved.mu. The resize fences and ordered session locks keep topology | |
| 304 | + | // stable until the tab/pane checks below complete. | |
| 305 | + | if c.handoffFrozen { | |
| 306 | + | if c.source.client != c.admission.sourceClient || len(c.source.snatched) != c.admission.sourceSnatched { | |
| 307 | + | return false | |
| 308 | + | } | |
| 309 | + | publication, err := d.validateAttachmentTransitionPrelocked(c.handoffReq) | |
| 310 | + | if err != nil { | |
| 311 | + | c.err = err | |
| 312 | + | return false | |
| 313 | + | } | |
| 314 | + | c.handoffPublication = publication | |
| 315 | + | defer c.releasePublication() | |
| 316 | + | } | |
| 317 | + | if c.sourceRolesFrozen && len(c.source.snatched) != c.admission.sourceSnatched { | |
| 318 | + | return false | |
| 319 | + | } | |
| 320 | + | if c.destination.active < 0 || c.destination.active >= len(c.destination.tabs) || c.destination.tabs[c.destination.active] != c.admission.destinationActive { | |
| 321 | + | return false | |
| 322 | + | } | |
| 323 | + | c.admission.destinationActive.mu.Lock() | |
| 324 | + | destinationSizeCurrent := c.admission.destinationActive.size == c.admission.destinationSize | |
| 325 | + | c.admission.destinationActive.mu.Unlock() | |
| 326 | + | if !destinationSizeCurrent { | |
| 327 | + | return false | |
| 328 | + | } | |
| 329 | + | moved.mu.Lock() | |
| 330 | + | defer moved.mu.Unlock() | |
| 331 | + | if moved.layoutGeneration != c.admission.layoutGeneration || !moved.floatingTransferableLocked() || | |
| 332 | + | moved.floating.state != c.admission.floatingState || moved.floating.pane != c.admission.floatingPane || | |
| 333 | + | moved.floating.generation != c.admission.floatingGeneration || !sameMoveTabPanesLocked(moved, c.admission.panes, fencedPanes) { | |
| 334 | + | return false | |
| 335 | + | } | |
| 336 | + | for _, p := range fencedPanes { | |
| 337 | + | p.mu.Lock() | |
| 338 | + | defer p.mu.Unlock() | |
| 339 | + | owner := p.ownerSnapshot() | |
| 340 | + | if owner == nil || owner.session != c.source || owner.tab != moved { | |
| 341 | + | return false | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | idx := c.admission.sourceIndex | |
| 346 | + | c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...) | |
| 347 | + | if len(c.source.tabs) == 0 { | |
| 348 | + | c.source.active = -1 | |
| 349 | + | } else if c.source.active > idx { | |
| 350 | + | c.source.active-- | |
| 351 | + | } else if c.source.active == idx { | |
| 352 | + | if idx >= len(c.source.tabs) { | |
| 353 | + | idx = len(c.source.tabs) - 1 | |
| 354 | + | } | |
| 355 | + | c.source.active = idx | |
| 356 | + | } | |
| 357 | + | c.destination.tabs = append(c.destination.tabs, moved) | |
| 358 | + | moved.size = c.admission.destinationSize | |
| 359 | + | moved.bumpLayoutGenerationLocked() | |
| 360 | + | c.oldTabCancel = moved.cancel | |
| 361 | + | parent := c.destination.ctx | |
| 362 | + | if parent == nil { | |
| 363 | + | parent = d.serveCtx | |
| 364 | + | } | |
| 365 | + | moved.ctx, moved.cancel = context.WithCancel(parent) | |
| 366 | + | type ownerChange struct { | |
| 367 | + | pane *pane | |
| 368 | + | oldOwner paneEffectLease | |
| 369 | + | newOwner paneEffectLease | |
| 370 | + | } | |
| 371 | + | ownerChanges := make([]ownerChange, 0, len(fencedPanes)) | |
| 372 | + | for _, p := range fencedPanes { | |
| 373 | + | floatingGeneration := uint64(0) | |
| 374 | + | if p == moved.floating.pane { | |
| 375 | + | floatingGeneration = moved.floating.generation | |
| 376 | + | } | |
| 377 | + | oldOwner, newOwner := p.publishOwnerLocked(c.destination, moved, floatingGeneration) | |
| 378 | + | ownerChanges = append(ownerChanges, ownerChange{pane: p, oldOwner: oldOwner, newOwner: newOwner}) | |
| 379 | + | } | |
| 380 | + | if d.beforeMoveTabCommit != nil { | |
| 381 | + | d.beforeMoveTabCommit() | |
| 382 | + | } | |
| 383 | + | if c.handoffPublication != nil { | |
| 384 | + | c.destination.active = len(c.destination.tabs) - 1 | |
| 385 | + | c.handoffResult = d.publishAttachmentTransitionPrelocked(c.handoffPublication) | |
| 386 | + | c.handoffPublication.unlockCoordinators() | |
| 387 | + | c.handoffPublication = nil | |
| 388 | + | } | |
| 389 | + | // Attachment publication retains source/destination coordinator locks. | |
| 390 | + | // Release those first, then migrate synchronized-output batches exactly as | |
| 391 | + | // Move pane does, while pane parsing remains fenced. | |
| 392 | + | for _, change := range ownerChanges { | |
| 393 | + | c.syncCleanup.append(d.migratePaneSyncOwnerLocked(change.pane, change.oldOwner, change.newOwner)) | |
| 394 | + | } | |
| 395 | + | if c.handoffResult.published.ac != nil { | |
| 396 | + | c.sourceCleanupToken = c.handoffResult.published | |
| 397 | + | } else if c.source.client != nil { | |
| 398 | + | c.sourceCleanupToken = c.source.attachmentTokenLocked(c.source.client) | |
| 399 | + | } | |
| 400 | + | if len(c.source.tabs) == 0 { | |
| 401 | + | delete(d.sessions, c.source.id) | |
| 402 | + | c.retiredAttachments = retireEmptyMoveSessionLocked(c.source) | |
| 403 | + | c.retiredParked = d.purgeParkedForSessionLocked(c.source) | |
| 404 | + | } | |
| 405 | + | c.sourceEmpty = len(c.source.tabs) == 0 | |
| 406 | + | if !c.source.ephemeral { | |
| 407 | + | c.sourceMetadata = c.source.persistRecordLocked(max(d.nowUnixNano(), c.source.createdAt, int64(1))).MetadataUpdate() | |
| 408 | + | c.sourceMetadataValid = true | |
| 409 | + | } | |
| 410 | + | if !c.destination.ephemeral { | |
| 411 | + | c.destMetadata = c.destination.persistRecordLocked(max(d.nowUnixNano(), c.destination.createdAt, int64(1))).MetadataUpdate() | |
| 412 | + | c.destMetadataValid = true | |
| 413 | + | } | |
| 414 | + | return true | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | func sameMoveTabPanesLocked(tb *tab, admitted, fenced []*pane) bool { | |
| 418 | + | current := make(map[*pane]struct{}, len(tb.panes)+1) | |
| 419 | + | for _, p := range tb.panes { | |
| 420 | + | current[p] = struct{}{} | |
| 421 | + | } | |
| 422 | + | if tb.floating.pane != nil && (tb.floating.state == floatingHidden || tb.floating.state == floatingVisible) { | |
| 423 | + | current[tb.floating.pane] = struct{}{} | |
| 424 | + | } | |
| 425 | + | if len(current) != len(admitted) || len(current) != len(fenced) { | |
| 426 | + | return false | |
| 427 | + | } | |
| 428 | + | for _, p := range admitted { | |
| 429 | + | if _, ok := current[p]; !ok { | |
| 430 | + | return false | |
| 431 | + | } | |
| 432 | + | } | |
| 433 | + | for _, p := range fenced { | |
| 434 | + | if _, ok := current[p]; !ok { | |
| 435 | + | return false | |
| 436 | + | } | |
| 437 | + | } | |
| 438 | + | return true | |
| 439 | + | } | |
| @@ -0,0 +1,439 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/bnema/vev/internal/ports" | |||
| 8 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | type moveTabAdmission struct { | |||
| 12 | + | tab *tab | |||
| 13 | + | sourceIndex int | |||
| 14 | + | sourceClient *attachedClient | |||
| 15 | + | sourceSnatched int | |||
| 16 | + | layoutGeneration uint64 | |||
| 17 | + | floatingState floatingState | |||
| 18 | + | floatingPane *pane | |||
| 19 | + | floatingGeneration uint64 | |||
| 20 | + | panes []*pane | |||
| 21 | + | destinationActive *tab | |||
| 22 | + | destinationSize domain.Size | |||
| 23 | + | finalSource bool | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | type moveTabCommit struct { | |||
| 27 | + | req moveTabRequest | |||
| 28 | + | source *session | |||
| 29 | + | destination *session | |||
| 30 | + | admission moveTabAdmission | |||
| 31 | + | handoffFrozen bool | |||
| 32 | + | sourceRolesFrozen bool | |||
| 33 | + | handoffReq attachmentTransitionRequest | |||
| 34 | + | handoffPublication *attachmentPublication | |||
| 35 | + | handoffResult attachmentTransitionResult | |||
| 36 | + | syncCleanup syncTimerCleanup | |||
| 37 | + | sourceCleanupToken attachmentRoleToken | |||
| 38 | + | oldTabCancel context.CancelFunc | |||
| 39 | + | sourceEmpty bool | |||
| 40 | + | retiredParked []parkedAttachmentRetirement | |||
| 41 | + | retiredAttachments []detachedAttachmentSnapshot | |||
| 42 | + | sourceMetadata domain.CatalogueMetadataUpdate | |||
| 43 | + | sourceMetadataValid bool | |||
| 44 | + | destMetadata domain.CatalogueMetadataUpdate | |||
| 45 | + | destMetadataValid bool | |||
| 46 | + | err error | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func (d *Daemon) moveTab(req moveTabRequest) error { | |||
| 50 | + | if d == nil || req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID { | |||
| 51 | + | return errMovePaneInvalid | |||
| 52 | + | } | |||
| 53 | + | d.mu.Lock() | |||
| 54 | + | source := moveSessionForLocatorLocked(d, req.Source) | |||
| 55 | + | destination := moveSessionForLocatorLocked(d, req.Destination) | |||
| 56 | + | d.mu.Unlock() | |||
| 57 | + | if source == nil || destination == nil || source == destination { | |||
| 58 | + | return errMovePaneInvalid | |||
| 59 | + | } | |||
| 60 | + | reservation, err := d.reserveMoveLifecycles(source, destination) | |||
| 61 | + | if err != nil { | |||
| 62 | + | return err | |||
| 63 | + | } | |||
| 64 | + | reservationHeld := true | |||
| 65 | + | defer func() { | |||
| 66 | + | if reservationHeld { | |||
| 67 | + | reservation.Release() | |||
| 68 | + | } | |||
| 69 | + | }() | |||
| 70 | + | if d.afterMoveLifecycleReserved != nil { | |||
| 71 | + | d.afterMoveLifecycleReserved() | |||
| 72 | + | } | |||
| 73 | + | unlockDispatch := lockMoveDispatch(source, destination) | |||
| 74 | + | dispatchHeld := true | |||
| 75 | + | defer func() { | |||
| 76 | + | if dispatchHeld { | |||
| 77 | + | unlockDispatch() | |||
| 78 | + | } | |||
| 79 | + | }() | |||
| 80 | + | ||||
| 81 | + | admission, err := d.snapshotMoveTabAdmission(req, source, destination) | |||
| 82 | + | if err != nil { | |||
| 83 | + | return err | |||
| 84 | + | } | |||
| 85 | + | commit := moveTabCommit{req: req, source: source, destination: destination, admission: *admission} | |||
| 86 | + | var frozen frozenRoleEffectGates | |||
| 87 | + | if admission.finalSource && admission.sourceClient != nil { | |||
| 88 | + | transport := admission.sourceClient.transportSnapshot() | |||
| 89 | + | token := source.attachmentToken(admission.sourceClient, transport.transport) | |||
| 90 | + | if token.role != attachmentActive || token.transport.transport == nil { | |||
| 91 | + | return errMovePaneInvalid | |||
| 92 | + | } | |||
| 93 | + | commit.handoffReq = attachmentTransitionRequest{ | |||
| 94 | + | source: source, target: destination, next: admission.sourceClient, | |||
| 95 | + | expectedRole: attachmentActive, targetRole: attachmentActive, | |||
| 96 | + | expectedTransport: token.transport, sourceToken: &token, | |||
| 97 | + | expectedSourceTab: admission.tab, transferExpectedSourceTab: true, ready: true, action: "move-tab", | |||
| 98 | + | } | |||
| 99 | + | var participants attachmentTransitionParticipants | |||
| 100 | + | commit.handoffReq, participants, err = d.snapshotAttachmentTransition(commit.handoffReq) | |||
| 101 | + | if err != nil { | |||
| 102 | + | return err | |||
| 103 | + | } | |||
| 104 | + | d.mu.Lock() | |||
| 105 | + | source.mu.Lock() | |||
| 106 | + | for ac := range source.snatched { | |||
| 107 | + | participants.clients = append(participants.clients, ac) | |||
| 108 | + | if tr := ac.transportSnapshot(); tr.transport != nil { | |||
| 109 | + | participants.interrupts = append(participants.interrupts, roleTransportInterrupt{ac: ac, transport: tr}) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | source.mu.Unlock() | |||
| 113 | + | d.mu.Unlock() | |||
| 114 | + | frozen = tryFreezeRoleEffectGatesInterruptingObserved(participants.interrupts, func(ac *attachedClient) { | |||
| 115 | + | if d.afterRoleEffectGateFrozen != nil { | |||
| 116 | + | d.afterRoleEffectGateFrozen("move-tab", ac) | |||
| 117 | + | } | |||
| 118 | + | }, participants.clients...) | |||
| 119 | + | if !frozen.acquired || !frozen.drained { | |||
| 120 | + | frozen.unfreeze() | |||
| 121 | + | return errMovePaneInvalid | |||
| 122 | + | } | |||
| 123 | + | commit.handoffFrozen = true | |||
| 124 | + | commit.sourceRolesFrozen = true | |||
| 125 | + | commit.handoffReq.roleEffectsFrozen = true | |||
| 126 | + | } else if admission.finalSource && admission.sourceSnatched > 0 { | |||
| 127 | + | d.mu.Lock() | |||
| 128 | + | source.mu.Lock() | |||
| 129 | + | clients := make([]*attachedClient, 0, len(source.snatched)) | |||
| 130 | + | interrupts := make([]roleTransportInterrupt, 0, len(source.snatched)) | |||
| 131 | + | for ac := range source.snatched { | |||
| 132 | + | clients = append(clients, ac) | |||
| 133 | + | if tr := ac.transportSnapshot(); tr.transport != nil { | |||
| 134 | + | interrupts = append(interrupts, roleTransportInterrupt{ac: ac, transport: tr}) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | source.mu.Unlock() | |||
| 138 | + | d.mu.Unlock() | |||
| 139 | + | frozen = tryFreezeRoleEffectGatesInterruptingObserved(interrupts, nil, clients...) | |||
| 140 | + | if !frozen.acquired || !frozen.drained { | |||
| 141 | + | frozen.unfreeze() | |||
| 142 | + | return errMovePaneInvalid | |||
| 143 | + | } | |||
| 144 | + | commit.sourceRolesFrozen = true | |||
| 145 | + | } | |||
| 146 | + | rolesFrozen := commit.sourceRolesFrozen | |||
| 147 | + | defer func() { | |||
| 148 | + | if rolesFrozen { | |||
| 149 | + | frozen.unfreeze() | |||
| 150 | + | } | |||
| 151 | + | }() | |||
| 152 | + | ||||
| 153 | + | fences := newMoveTabResizeFences(source, destination, admission.tab) | |||
| 154 | + | if !fences.acquire(func() bool { return commit.publishLocked(d, fences.panes) }) { | |||
| 155 | + | commit.releasePublication() | |||
| 156 | + | if commit.err != nil { | |||
| 157 | + | return commit.err | |||
| 158 | + | } | |||
| 159 | + | return errMovePaneInvalid | |||
| 160 | + | } | |||
| 161 | + | fences.Release() | |||
| 162 | + | if commit.oldTabCancel != nil { | |||
| 163 | + | commit.oldTabCancel() | |||
| 164 | + | } | |||
| 165 | + | for _, p := range admission.panes { | |||
| 166 | + | if commit.sourceCleanupToken.current() { | |||
| 167 | + | commit.sourceCleanupToken.ac.overlays.clearCopyModeForPane(p) | |||
| 168 | + | commit.sourceCleanupToken.ac.pruneCaptureFrames(p) | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | markSnapshotDirty(destination) | |||
| 172 | + | if !commit.sourceEmpty { | |||
| 173 | + | markSnapshotDirty(source) | |||
| 174 | + | } | |||
| 175 | + | commit.syncCleanup.finish() | |||
| 176 | + | if rolesFrozen { | |||
| 177 | + | frozen.unfreeze() | |||
| 178 | + | rolesFrozen = false | |||
| 179 | + | } | |||
| 180 | + | unlockDispatch() | |||
| 181 | + | dispatchHeld = false | |||
| 182 | + | reservation.Release() | |||
| 183 | + | reservationHeld = false | |||
| 184 | + | ||||
| 185 | + | if !commit.sourceEmpty { | |||
| 186 | + | if sourceTab := source.activeTab(); sourceTab != nil { | |||
| 187 | + | d.activateTab(source, sourceTab) | |||
| 188 | + | d.applyTabLayout(source, sourceTab) | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | d.applyTabLayout(destination, admission.tab) | |||
| 192 | + | if commit.sourceMetadataValid && !commit.sourceEmpty { | |||
| 193 | + | d.markCatalogueDirty(commit.sourceMetadata) | |||
| 194 | + | } | |||
| 195 | + | if commit.destMetadataValid { | |||
| 196 | + | d.markCatalogueDirty(commit.destMetadata) | |||
| 197 | + | } | |||
| 198 | + | if commit.handoffResult.published.ac != nil { | |||
| 199 | + | follower := commit.handoffResult.published.ac | |||
| 200 | + | d.applyHostTheme(destination, follower, follower.getClientTheme(), false) | |||
| 201 | + | follower.recordPreviousSession(source) | |||
| 202 | + | d.deferAttachmentTransitionCleanups(commit.handoffResult) | |||
| 203 | + | d.firstPaintForTransition(commit.handoffResult.published) | |||
| 204 | + | } | |||
| 205 | + | if commit.sourceEmpty { | |||
| 206 | + | retireEmptySessionAfterMove(d, source) | |||
| 207 | + | } | |||
| 208 | + | for _, attachment := range commit.retiredAttachments { | |||
| 209 | + | d.unregisterPreview(attachment.ac) | |||
| 210 | + | attachment.ac.clearPreviousSession() | |||
| 211 | + | attachment.ac.clearCaptureFrames() | |||
| 212 | + | d.notifyDetachedSnapshotAsync(attachment, ports.ReasonSessionKilled) | |||
| 213 | + | } | |||
| 214 | + | for _, retirement := range commit.retiredParked { | |||
| 215 | + | if retirement.parked != nil && retirement.parked.ac != nil { | |||
| 216 | + | d.unregisterPreview(retirement.parked.ac) | |||
| 217 | + | retirement.parked.ac.clearCaptureFrames() | |||
| 218 | + | } | |||
| 219 | + | } | |||
| 220 | + | finishParkedAttachmentRetirements(commit.retiredParked) | |||
| 221 | + | if commit.sourceEmpty && commit.sourceMetadataValid && d.persistEnabled { | |||
| 222 | + | if err := d.beginSnapshotPurge(source.name, source.incarnation); err == nil { | |||
| 223 | + | if err := d.finishSnapshotPurge(d.serveCtx, source.name, source.incarnation); err != nil { | |||
| 224 | + | d.log.Warn("moving final tab source purge failed", "err", err, "session", source.name) | |||
| 225 | + | } | |||
| 226 | + | } | |||
| 227 | + | } | |||
| 228 | + | return nil | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destination *session) (*moveTabAdmission, error) { | |||
| 232 | + | d.mu.Lock() | |||
| 233 | + | defer d.mu.Unlock() | |||
| 234 | + | if d.closing || d.sessions[source.id] != source || d.sessions[destination.id] != destination { | |||
| 235 | + | return nil, errMovePaneInvalid | |||
| 236 | + | } | |||
| 237 | + | unlockSessions := lockAttachmentSessions(source, destination) | |||
| 238 | + | defer unlockSessions() | |||
| 239 | + | if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) { | |||
| 240 | + | return nil, errMovePaneInvalid | |||
| 241 | + | } | |||
| 242 | + | moved := findMoveTabLocked(source, req.SourceTabID) | |||
| 243 | + | if moved == nil || destination.active < 0 || destination.active >= len(destination.tabs) { | |||
| 244 | + | return nil, errMovePaneInvalid | |||
| 245 | + | } | |||
| 246 | + | destinationActive := destination.tabs[destination.active] | |||
| 247 | + | destinationActive.mu.Lock() | |||
| 248 | + | destinationSize := destinationActive.size | |||
| 249 | + | destinationActive.mu.Unlock() | |||
| 250 | + | moved.mu.Lock() | |||
| 251 | + | defer moved.mu.Unlock() | |||
| 252 | + | if !moved.floatingTransferableLocked() { | |||
| 253 | + | return nil, errMovePaneInvalid | |||
| 254 | + | } | |||
| 255 | + | if _, ok := layout.Solve(moved.tree.Root, domain.Rect{Width: destinationSize.Cols, Height: destinationSize.Rows}); !ok { | |||
| 256 | + | return nil, errMovePaneInvalid | |||
| 257 | + | } | |||
| 258 | + | panes := make([]*pane, 0, len(moved.panes)+1) | |||
| 259 | + | for _, p := range moved.panes { | |||
| 260 | + | panes = append(panes, p) | |||
| 261 | + | } | |||
| 262 | + | if moved.floating.pane != nil && (moved.floating.state == floatingHidden || moved.floating.state == floatingVisible) { | |||
| 263 | + | panes = append(panes, moved.floating.pane) | |||
| 264 | + | } | |||
| 265 | + | idx := indexMoveTabLocked(source, moved) | |||
| 266 | + | return &moveTabAdmission{ | |||
| 267 | + | tab: moved, sourceIndex: idx, | |||
| 268 | + | sourceClient: source.client, sourceSnatched: len(source.snatched), | |||
| 269 | + | layoutGeneration: moved.layoutGeneration, floatingState: moved.floating.state, | |||
| 270 | + | floatingPane: moved.floating.pane, floatingGeneration: moved.floating.generation, | |||
| 271 | + | panes: panes, destinationActive: destinationActive, destinationSize: destinationSize, | |||
| 272 | + | finalSource: len(source.tabs) == 1, | |||
| 273 | + | }, nil | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | func (c *moveTabCommit) releasePublication() { | |||
| 277 | + | if c.handoffPublication != nil { | |||
| 278 | + | c.handoffPublication.unlockCoordinators() | |||
| 279 | + | c.handoffPublication = nil | |||
| 280 | + | } | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool { | |||
| 284 | + | d.mu.Lock() | |||
| 285 | + | if d.closing || d.sessions[c.source.id] != c.source || d.sessions[c.destination.id] != c.destination { | |||
| 286 | + | d.mu.Unlock() | |||
| 287 | + | return false | |||
| 288 | + | } | |||
| 289 | + | d.notices.routingMu.Lock() | |||
| 290 | + | unlockSessions := lockAttachmentSessions(c.source, c.destination) | |||
| 291 | + | defer unlockSessions() | |||
| 292 | + | defer d.notices.routingMu.Unlock() | |||
| 293 | + | defer d.mu.Unlock() | |||
| 294 | + | if !moveSessionLocatorCurrentLocked(c.source, c.req.Source) || !moveSessionLocatorCurrentLocked(c.destination, c.req.Destination) { | |||
| 295 | + | return false | |||
| 296 | + | } | |||
| 297 | + | moved := c.admission.tab | |||
| 298 | + | if indexMoveTabLocked(c.source, moved) != c.admission.sourceIndex || findMoveTabLocked(c.destination, c.req.SourceTabID) != nil || | |||
| 299 | + | len(c.source.tabs) == 1 != c.admission.finalSource { | |||
| 300 | + | return false | |||
| 301 | + | } | |||
| 302 | + | // Attachment validation inspects the expected source tab and therefore must | |||
| 303 | + | // precede moved.mu. The resize fences and ordered session locks keep topology | |||
| 304 | + | // stable until the tab/pane checks below complete. | |||
| 305 | + | if c.handoffFrozen { | |||
| 306 | + | if c.source.client != c.admission.sourceClient || len(c.source.snatched) != c.admission.sourceSnatched { | |||
| 307 | + | return false | |||
| 308 | + | } | |||
| 309 | + | publication, err := d.validateAttachmentTransitionPrelocked(c.handoffReq) | |||
| 310 | + | if err != nil { | |||
| 311 | + | c.err = err | |||
| 312 | + | return false | |||
| 313 | + | } | |||
| 314 | + | c.handoffPublication = publication | |||
| 315 | + | defer c.releasePublication() | |||
| 316 | + | } | |||
| 317 | + | if c.sourceRolesFrozen && len(c.source.snatched) != c.admission.sourceSnatched { | |||
| 318 | + | return false | |||
| 319 | + | } | |||
| 320 | + | if c.destination.active < 0 || c.destination.active >= len(c.destination.tabs) || c.destination.tabs[c.destination.active] != c.admission.destinationActive { | |||
| 321 | + | return false | |||
| 322 | + | } | |||
| 323 | + | c.admission.destinationActive.mu.Lock() | |||
| 324 | + | destinationSizeCurrent := c.admission.destinationActive.size == c.admission.destinationSize | |||
| 325 | + | c.admission.destinationActive.mu.Unlock() | |||
| 326 | + | if !destinationSizeCurrent { | |||
| 327 | + | return false | |||
| 328 | + | } | |||
| 329 | + | moved.mu.Lock() | |||
| 330 | + | defer moved.mu.Unlock() | |||
| 331 | + | if moved.layoutGeneration != c.admission.layoutGeneration || !moved.floatingTransferableLocked() || | |||
| 332 | + | moved.floating.state != c.admission.floatingState || moved.floating.pane != c.admission.floatingPane || | |||
| 333 | + | moved.floating.generation != c.admission.floatingGeneration || !sameMoveTabPanesLocked(moved, c.admission.panes, fencedPanes) { | |||
| 334 | + | return false | |||
| 335 | + | } | |||
| 336 | + | for _, p := range fencedPanes { | |||
| 337 | + | p.mu.Lock() | |||
| 338 | + | defer p.mu.Unlock() | |||
| 339 | + | owner := p.ownerSnapshot() | |||
| 340 | + | if owner == nil || owner.session != c.source || owner.tab != moved { | |||
| 341 | + | return false | |||
| 342 | + | } | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | idx := c.admission.sourceIndex | |||
| 346 | + | c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...) | |||
| 347 | + | if len(c.source.tabs) == 0 { | |||
| 348 | + | c.source.active = -1 | |||
| 349 | + | } else if c.source.active > idx { | |||
| 350 | + | c.source.active-- | |||
| 351 | + | } else if c.source.active == idx { | |||
| 352 | + | if idx >= len(c.source.tabs) { | |||
| 353 | + | idx = len(c.source.tabs) - 1 | |||
| 354 | + | } | |||
| 355 | + | c.source.active = idx | |||
| 356 | + | } | |||
| 357 | + | c.destination.tabs = append(c.destination.tabs, moved) | |||
| 358 | + | moved.size = c.admission.destinationSize | |||
| 359 | + | moved.bumpLayoutGenerationLocked() | |||
| 360 | + | c.oldTabCancel = moved.cancel | |||
| 361 | + | parent := c.destination.ctx | |||
| 362 | + | if parent == nil { | |||
| 363 | + | parent = d.serveCtx | |||
| 364 | + | } | |||
| 365 | + | moved.ctx, moved.cancel = context.WithCancel(parent) | |||
| 366 | + | type ownerChange struct { | |||
| 367 | + | pane *pane | |||
| 368 | + | oldOwner paneEffectLease | |||
| 369 | + | newOwner paneEffectLease | |||
| 370 | + | } | |||
| 371 | + | ownerChanges := make([]ownerChange, 0, len(fencedPanes)) | |||
| 372 | + | for _, p := range fencedPanes { | |||
| 373 | + | floatingGeneration := uint64(0) | |||
| 374 | + | if p == moved.floating.pane { | |||
| 375 | + | floatingGeneration = moved.floating.generation | |||
| 376 | + | } | |||
| 377 | + | oldOwner, newOwner := p.publishOwnerLocked(c.destination, moved, floatingGeneration) | |||
| 378 | + | ownerChanges = append(ownerChanges, ownerChange{pane: p, oldOwner: oldOwner, newOwner: newOwner}) | |||
| 379 | + | } | |||
| 380 | + | if d.beforeMoveTabCommit != nil { | |||
| 381 | + | d.beforeMoveTabCommit() | |||
| 382 | + | } | |||
| 383 | + | if c.handoffPublication != nil { | |||
| 384 | + | c.destination.active = len(c.destination.tabs) - 1 | |||
| 385 | + | c.handoffResult = d.publishAttachmentTransitionPrelocked(c.handoffPublication) | |||
| 386 | + | c.handoffPublication.unlockCoordinators() | |||
| 387 | + | c.handoffPublication = nil | |||
| 388 | + | } | |||
| 389 | + | // Attachment publication retains source/destination coordinator locks. | |||
| 390 | + | // Release those first, then migrate synchronized-output batches exactly as | |||
| 391 | + | // Move pane does, while pane parsing remains fenced. | |||
| 392 | + | for _, change := range ownerChanges { | |||
| 393 | + | c.syncCleanup.append(d.migratePaneSyncOwnerLocked(change.pane, change.oldOwner, change.newOwner)) | |||
| 394 | + | } | |||
| 395 | + | if c.handoffResult.published.ac != nil { | |||
| 396 | + | c.sourceCleanupToken = c.handoffResult.published | |||
| 397 | + | } else if c.source.client != nil { | |||
| 398 | + | c.sourceCleanupToken = c.source.attachmentTokenLocked(c.source.client) | |||
| 399 | + | } | |||
| 400 | + | if len(c.source.tabs) == 0 { | |||
| 401 | + | delete(d.sessions, c.source.id) | |||
| 402 | + | c.retiredAttachments = retireEmptyMoveSessionLocked(c.source) | |||
| 403 | + | c.retiredParked = d.purgeParkedForSessionLocked(c.source) | |||
| 404 | + | } | |||
| 405 | + | c.sourceEmpty = len(c.source.tabs) == 0 | |||
| 406 | + | if !c.source.ephemeral { | |||
| 407 | + | c.sourceMetadata = c.source.persistRecordLocked(max(d.nowUnixNano(), c.source.createdAt, int64(1))).MetadataUpdate() | |||
| 408 | + | c.sourceMetadataValid = true | |||
| 409 | + | } | |||
| 410 | + | if !c.destination.ephemeral { | |||
| 411 | + | c.destMetadata = c.destination.persistRecordLocked(max(d.nowUnixNano(), c.destination.createdAt, int64(1))).MetadataUpdate() | |||
| 412 | + | c.destMetadataValid = true | |||
| 413 | + | } | |||
| 414 | + | return true | |||
| 415 | + | } | |||
| 416 | + | ||||
| 417 | + | func sameMoveTabPanesLocked(tb *tab, admitted, fenced []*pane) bool { | |||
| 418 | + | current := make(map[*pane]struct{}, len(tb.panes)+1) | |||
| 419 | + | for _, p := range tb.panes { | |||
| 420 | + | current[p] = struct{}{} | |||
| 421 | + | } | |||
| 422 | + | if tb.floating.pane != nil && (tb.floating.state == floatingHidden || tb.floating.state == floatingVisible) { | |||
| 423 | + | current[tb.floating.pane] = struct{}{} | |||
| 424 | + | } | |||
| 425 | + | if len(current) != len(admitted) || len(current) != len(fenced) { | |||
| 426 | + | return false | |||
| 427 | + | } | |||
| 428 | + | for _, p := range admitted { | |||
| 429 | + | if _, ok := current[p]; !ok { | |||
| 430 | + | return false | |||
| 431 | + | } | |||
| 432 | + | } | |||
| 433 | + | for _, p := range fenced { | |||
| 434 | + | if _, ok := current[p]; !ok { | |||
| 435 | + | return false | |||
| 436 | + | } | |||
| 437 | + | } | |||
| 438 | + | return true | |||
| 439 | + | } | |||
A internal/usecase/daemon/move_tab_handoff_test.go +341 -0
| @@ -0,0 +1,341 @@ | |||
| 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, *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, 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, moved := setupMoveTabFinalSourceHandoff(t) | |
| 60 | + | displacedTransport := displaced.tr.(*closeTrackingTransport) | |
| 61 | + | ||
| 62 | + | moveTabFinalSource(t, d, source, destination, moved) | |
| 63 | + | d.attachmentCleanupWg.Wait() | |
| 64 | + | ||
| 65 | + | destination.mu.Lock() | |
| 66 | + | require.Same(t, follower, destination.client) | |
| 67 | + | require.Contains(t, destination.snatched, displaced) | |
| 68 | + | require.Same(t, moved, destination.tabs[destination.active]) | |
| 69 | + | destination.mu.Unlock() | |
| 70 | + | require.Same(t, destination, follower.currentSession()) | |
| 71 | + | require.Equal(t, attachmentActive, destination.attachmentRole(follower)) | |
| 72 | + | require.Equal(t, attachmentSnatched, destination.attachmentRole(displaced)) | |
| 73 | + | ||
| 74 | + | frames := displacedTransport.Sends() | |
| 75 | + | require.Len(t, frames, 1) | |
| 76 | + | require.Equal(t, ports.MsgOutput, frames[0].Type) | |
| 77 | + | output, err := ports.UnmarshalOutput(frames[0].Payload) | |
| 78 | + | require.NoError(t, err) | |
| 79 | + | require.Contains(t, string(output.Data), "Session snatched") | |
| 80 | + | require.False(t, detachedWithReason(frames, ports.ReasonReplaced)) | |
| 81 | + | require.False(t, displacedTransport.Closed()) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestMoveTabFinalSourcePreservesDestinationSnatchedWaiters(t *testing.T) { | |
| 85 | + | d, source, follower, destination, displaced, moved := setupMoveTabFinalSourceHandoff(t) | |
| 86 | + | ||
| 87 | + | waitingTransport := &closeTrackingTransport{} | |
| 88 | + | waiting := &attachedClient{tr: waitingTransport, output: newOutputStateStream(), size: follower.size} | |
| 89 | + | waiting.initOverlays() | |
| 90 | + | waiting.setSession(destination) | |
| 91 | + | destination.mu.Lock() | |
| 92 | + | destination.addSnatchedLocked(waiting) | |
| 93 | + | destination.mu.Unlock() | |
| 94 | + | ||
| 95 | + | moveTabFinalSource(t, d, source, destination, moved) | |
| 96 | + | d.attachmentCleanupWg.Wait() | |
| 97 | + | ||
| 98 | + | destination.mu.Lock() | |
| 99 | + | require.Contains(t, destination.snatched, displaced) | |
| 100 | + | require.Contains(t, destination.snatched, waiting) | |
| 101 | + | require.Len(t, destination.snatched, 2) | |
| 102 | + | destination.mu.Unlock() | |
| 103 | + | requireSingleOwnerAndLease(t, destination, follower, displaced, waiting) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestMoveTabFinalSourceRetiresSourceParkedAndSnatchedClients(t *testing.T) { | |
| 107 | + | d, source, follower, destination, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 108 | + | ||
| 109 | + | parkedTransport := &closeTrackingTransport{} | |
| 110 | + | parked := &attachedClient{tr: parkedTransport, output: newOutputStateStream(), size: follower.size, resumeCapable: true} | |
| 111 | + | parked.initOverlays() | |
| 112 | + | require.True(t, d.parkAttachment(source, parked)) | |
| 113 | + | ||
| 114 | + | sourceSnatchedTransport := &closeTrackingTransport{} | |
| 115 | + | sourceSnatched := &attachedClient{tr: sourceSnatchedTransport, output: newOutputStateStream(), size: follower.size} | |
| 116 | + | sourceSnatched.initOverlays() | |
| 117 | + | sourceSnatched.setSession(source) | |
| 118 | + | source.mu.Lock() | |
| 119 | + | source.addSnatchedLocked(sourceSnatched) | |
| 120 | + | source.mu.Unlock() | |
| 121 | + | ||
| 122 | + | moveTabFinalSource(t, d, source, destination, moved) | |
| 123 | + | d.attachmentCleanupWg.Wait() | |
| 124 | + | d.waitNotifies() | |
| 125 | + | ||
| 126 | + | d.mu.Lock() | |
| 127 | + | require.Empty(t, d.parked) | |
| 128 | + | d.mu.Unlock() | |
| 129 | + | require.True(t, parkedTransport.Closed()) | |
| 130 | + | require.True(t, sourceSnatchedTransport.Closed()) | |
| 131 | + | require.Nil(t, sourceSnatched.currentSession()) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func TestMoveTabFinalSourceClearsFollowerCopyCapturePreservesDestinationWaiterOverlays(t *testing.T) { | |
| 135 | + | d, source, follower, destination, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 136 | + | movedPane := moved.focusedPane() | |
| 137 | + | ||
| 138 | + | follower.overlays.copyMu.Lock() | |
| 139 | + | follower.overlays.copyPane = movedPane | |
| 140 | + | follower.overlays.copyMode = &scopy.Mode{} | |
| 141 | + | follower.overlays.copyMu.Unlock() | |
| 142 | + | follower.sendMu.Lock() | |
| 143 | + | follower.captureFrames = map[*pane]capturedPaneRenderState{movedPane: {title: "stale-source-capture"}} | |
| 144 | + | follower.sendMu.Unlock() | |
| 145 | + | ||
| 146 | + | otherPane := destination.tabs[0].focusedPane() | |
| 147 | + | waiting := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: follower.size} | |
| 148 | + | waiting.initOverlays() | |
| 149 | + | waiting.setSession(destination) | |
| 150 | + | waiting.overlays.copyMu.Lock() | |
| 151 | + | waiting.overlays.copyPane = otherPane | |
| 152 | + | waiting.overlays.copyMode = &scopy.Mode{} | |
| 153 | + | waiting.overlays.copyMu.Unlock() | |
| 154 | + | waiting.sendMu.Lock() | |
| 155 | + | waiting.captureFrames = map[*pane]capturedPaneRenderState{otherPane: {}} | |
| 156 | + | waiting.sendMu.Unlock() | |
| 157 | + | destination.mu.Lock() | |
| 158 | + | destination.addSnatchedLocked(waiting) | |
| 159 | + | destination.mu.Unlock() | |
| 160 | + | ||
| 161 | + | moveTabFinalSource(t, d, source, destination, moved) | |
| 162 | + | ||
| 163 | + | follower.overlays.copyMu.Lock() | |
| 164 | + | require.Nil(t, follower.overlays.copyMode) | |
| 165 | + | require.Nil(t, follower.overlays.copyPane) | |
| 166 | + | follower.overlays.copyMu.Unlock() | |
| 167 | + | follower.sendMu.Lock() | |
| 168 | + | followerCapture, followerCaptured := follower.captureFrames[movedPane] | |
| 169 | + | follower.sendMu.Unlock() | |
| 170 | + | if followerCaptured { | |
| 171 | + | require.NotEqual(t, "stale-source-capture", followerCapture.title, "source capture survived destination first paint") | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | waiting.overlays.copyMu.Lock() | |
| 175 | + | require.NotNil(t, waiting.overlays.copyMode) | |
| 176 | + | require.Same(t, otherPane, waiting.overlays.copyPane) | |
| 177 | + | waiting.overlays.copyMu.Unlock() | |
| 178 | + | waiting.sendMu.Lock() | |
| 179 | + | _, waitingCaptured := waiting.captureFrames[otherPane] | |
| 180 | + | waiting.sendMu.Unlock() | |
| 181 | + | require.True(t, waitingCaptured) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func TestMoveTabFinalSourceFirstPaintNotBlockedByDisplacedPanelCleanup(t *testing.T) { | |
| 185 | + | d, source, follower, destination, displaced, moved := setupMoveTabFinalSourceHandoff(t) | |
| 186 | + | followerRebased := make(chan struct{}) | |
| 187 | + | follower.renderStages.handoffRebase = func() { close(followerRebased) } | |
| 188 | + | ||
| 189 | + | displaced.sendMu.Lock() | |
| 190 | + | panelCleanupStarted := make(chan struct{}) | |
| 191 | + | d.afterDisplacedCleanupStarted = func() { close(panelCleanupStarted) } | |
| 192 | + | defer func() { d.afterDisplacedCleanupStarted = nil }() | |
| 193 | + | ||
| 194 | + | moveDone := make(chan struct{}) | |
| 195 | + | go func() { | |
| 196 | + | moveTabFinalSource(t, d, source, destination, moved) | |
| 197 | + | close(moveDone) | |
| 198 | + | }() | |
| 199 | + | ||
| 200 | + | awaitTestCompletion(t, panelCleanupStarted, "displaced panel cleanup did not start") | |
| 201 | + | awaitTestCompletion(t, followerRebased, "follower reset first paint waited for displaced panel cleanup") | |
| 202 | + | awaitTestCompletion(t, moveDone, "move-tab handoff did not finish after follower first paint") | |
| 203 | + | ||
| 204 | + | displaced.sendMu.Unlock() | |
| 205 | + | d.attachmentCleanupWg.Wait() | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func TestMoveTabFinalSourceConcurrentReclaimKeepsNewestOwnerAndLease(t *testing.T) { | |
| 209 | + | d, source, follower, destination, displaced, moved := setupMoveTabFinalSourceHandoff(t) | |
| 210 | + | ||
| 211 | + | paintAdmitted := make(chan struct{}) | |
| 212 | + | releasePaint := make(chan struct{}) | |
| 213 | + | d.afterRoleEffectAdmitted = func(token attachmentRoleToken) { | |
| 214 | + | if token.ac == follower && token.role == attachmentActive { | |
| 215 | + | close(paintAdmitted) | |
| 216 | + | <-releasePaint | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | defer func() { d.afterRoleEffectAdmitted = nil }() | |
| 220 | + | ||
| 221 | + | moveDone := make(chan error, 1) | |
| 222 | + | go func() { | |
| 223 | + | moveDone <- d.moveTab(moveTabRequest{ | |
| 224 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 225 | + | SourceTabID: domain.TabStableID(moved.stableID), | |
| 226 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 227 | + | }) | |
| 228 | + | }() | |
| 229 | + | awaitTestCompletion(t, paintAdmitted, "move-tab follower first paint was not admitted") | |
| 230 | + | ||
| 231 | + | displacedToken := destination.attachmentToken(displaced, displaced.transportSnapshot().transport) | |
| 232 | + | reclaimDone := make(chan struct{}) | |
| 233 | + | go func() { | |
| 234 | + | d.handleSnatchedClientFrame(displacedToken, ports.Frame{ | |
| 235 | + | Type: ports.MsgInput, | |
| 236 | + | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |
| 237 | + | }) | |
| 238 | + | close(reclaimDone) | |
| 239 | + | }() | |
| 240 | + | ||
| 241 | + | close(releasePaint) | |
| 242 | + | require.NoError(t, <-moveDone) | |
| 243 | + | awaitTestCompletion(t, reclaimDone, "concurrent destination reclaim did not finish") | |
| 244 | + | d.attachmentCleanupWg.Wait() | |
| 245 | + | ||
| 246 | + | requireSingleOwnerAndLease(t, destination, follower, displaced) | |
| 247 | + | destination.mu.Lock() | |
| 248 | + | require.Same(t, displaced, destination.client) | |
| 249 | + | destination.mu.Unlock() | |
| 250 | + | require.Equal(t, attachmentSnatched, destination.attachmentRole(follower)) | |
| 251 | + | require.Equal(t, attachmentActive, destination.attachmentRole(displaced)) | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func TestMoveTabFinalSourcePostCommitPaintFailureDoesNotRollback(t *testing.T) { | |
| 255 | + | d, source, follower, destination, _, moved := setupMoveTabFinalSourceHandoff(t) | |
| 256 | + | ||
| 257 | + | d.afterRoleEffectAdmitted = func(token attachmentRoleToken) { | |
| 258 | + | if token.ac == follower { | |
| 259 | + | token.ac.roleGeneration.Add(1) | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | defer func() { d.afterRoleEffectAdmitted = nil }() | |
| 263 | + | ||
| 264 | + | beforeTabs := append([]*tab(nil), destination.tabs...) | |
| 265 | + | moveTabFinalSource(t, d, source, destination, moved) | |
| 266 | + | ||
| 267 | + | require.Nil(t, source.tabs) | |
| 268 | + | require.Same(t, destination, follower.currentSession()) | |
| 269 | + | require.Same(t, follower, destination.client) | |
| 270 | + | require.Equal(t, append(beforeTabs, moved), destination.tabs) | |
| 271 | + | require.Same(t, moved, destination.tabs[destination.active]) | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | func TestMoveTabFinalSourceHandoffCommitHidesPartialPublication(t *testing.T) { | |
| 275 | + | d, source, follower, destination, displaced, moved := setupMoveTabFinalSourceHandoff(t) | |
| 276 | + | _ = displaced | |
| 277 | + | ||
| 278 | + | commitEntered := make(chan struct{}) | |
| 279 | + | releaseCommit := make(chan struct{}) | |
| 280 | + | d.beforeMoveTabCommit = func() { | |
| 281 | + | close(commitEntered) | |
| 282 | + | <-releaseCommit | |
| 283 | + | } | |
| 284 | + | defer func() { d.beforeMoveTabCommit = nil }() | |
| 285 | + | ||
| 286 | + | moveDone := make(chan error, 1) | |
| 287 | + | go func() { | |
| 288 | + | moveDone <- d.moveTab(moveTabRequest{ | |
| 289 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 290 | + | SourceTabID: domain.TabStableID(moved.stableID), | |
| 291 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 292 | + | }) | |
| 293 | + | }() | |
| 294 | + | <-commitEntered | |
| 295 | + | ||
| 296 | + | type observation struct { | |
| 297 | + | sourceRegistered bool | |
| 298 | + | movedInSource bool | |
| 299 | + | movedInDestination bool | |
| 300 | + | followerOnDestination bool | |
| 301 | + | displacedSnatched bool | |
| 302 | + | movedActive bool | |
| 303 | + | } | |
| 304 | + | read := func() observation { | |
| 305 | + | d.mu.Lock() | |
| 306 | + | _, sourceLive := d.sessions[source.id] | |
| 307 | + | d.mu.Unlock() | |
| 308 | + | source.mu.Lock() | |
| 309 | + | movedInSource := indexMoveTabLocked(source, moved) >= 0 | |
| 310 | + | source.mu.Unlock() | |
| 311 | + | destination.mu.Lock() | |
| 312 | + | obs := observation{ | |
| 313 | + | sourceRegistered: sourceLive, | |
| 314 | + | movedInSource: movedInSource, | |
| 315 | + | movedInDestination: indexMoveTabLocked(destination, moved) >= 0, | |
| 316 | + | followerOnDestination: destination.client == follower, | |
| 317 | + | displacedSnatched: false, | |
| 318 | + | movedActive: destination.active >= 0 && destination.tabs[destination.active] == moved, | |
| 319 | + | } | |
| 320 | + | if displaced != nil { | |
| 321 | + | _, obs.displacedSnatched = destination.snatched[displaced] | |
| 322 | + | } | |
| 323 | + | destination.mu.Unlock() | |
| 324 | + | return obs | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | require.False(t, d.mu.TryLock(), "daemon observers must be excluded from partial move-tab publication") | |
| 328 | + | require.False(t, source.mu.TryLock(), "source observers must be excluded from partial move-tab publication") | |
| 329 | + | require.False(t, destination.mu.TryLock(), "destination observers must be excluded from partial move-tab publication") | |
| 330 | + | ||
| 331 | + | close(releaseCommit) | |
| 332 | + | require.NoError(t, <-moveDone) | |
| 333 | + | ||
| 334 | + | final := read() | |
| 335 | + | require.False(t, final.sourceRegistered) | |
| 336 | + | require.False(t, final.movedInSource) | |
| 337 | + | require.True(t, final.movedInDestination) | |
| 338 | + | require.True(t, final.followerOnDestination) | |
| 339 | + | require.True(t, final.displacedSnatched) | |
| 340 | + | require.True(t, final.movedActive) | |
| 341 | + | } | |
| @@ -0,0 +1,341 @@ | |||||
| 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, *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, 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, moved := setupMoveTabFinalSourceHandoff(t) | |||
| 60 | + | displacedTransport := displaced.tr.(*closeTrackingTransport) | |||
| 61 | + | ||||
| 62 | + | moveTabFinalSource(t, d, source, destination, moved) | |||
| 63 | + | d.attachmentCleanupWg.Wait() | |||
| 64 | + | ||||
| 65 | + | destination.mu.Lock() | |||
| 66 | + | require.Same(t, follower, destination.client) | |||
| 67 | + | require.Contains(t, destination.snatched, displaced) | |||
| 68 | + | require.Same(t, moved, destination.tabs[destination.active]) | |||
| 69 | + | destination.mu.Unlock() | |||
| 70 | + | require.Same(t, destination, follower.currentSession()) | |||
| 71 | + | require.Equal(t, attachmentActive, destination.attachmentRole(follower)) | |||
| 72 | + | require.Equal(t, attachmentSnatched, destination.attachmentRole(displaced)) | |||
| 73 | + | ||||
| 74 | + | frames := displacedTransport.Sends() | |||
| 75 | + | require.Len(t, frames, 1) | |||
| 76 | + | require.Equal(t, ports.MsgOutput, frames[0].Type) | |||
| 77 | + | output, err := ports.UnmarshalOutput(frames[0].Payload) | |||
| 78 | + | require.NoError(t, err) | |||
| 79 | + | require.Contains(t, string(output.Data), "Session snatched") | |||
| 80 | + | require.False(t, detachedWithReason(frames, ports.ReasonReplaced)) | |||
| 81 | + | require.False(t, displacedTransport.Closed()) | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func TestMoveTabFinalSourcePreservesDestinationSnatchedWaiters(t *testing.T) { | |||
| 85 | + | d, source, follower, destination, displaced, moved := setupMoveTabFinalSourceHandoff(t) | |||
| 86 | + | ||||
| 87 | + | waitingTransport := &closeTrackingTransport{} | |||
| 88 | + | waiting := &attachedClient{tr: waitingTransport, output: newOutputStateStream(), size: follower.size} | |||
| 89 | + | waiting.initOverlays() | |||
| 90 | + | waiting.setSession(destination) | |||
| 91 | + | destination.mu.Lock() | |||
| 92 | + | destination.addSnatchedLocked(waiting) | |||
| 93 | + | destination.mu.Unlock() | |||
| 94 | + | ||||
| 95 | + | moveTabFinalSource(t, d, source, destination, moved) | |||
| 96 | + | d.attachmentCleanupWg.Wait() | |||
| 97 | + | ||||
| 98 | + | destination.mu.Lock() | |||
| 99 | + | require.Contains(t, destination.snatched, displaced) | |||
| 100 | + | require.Contains(t, destination.snatched, waiting) | |||
| 101 | + | require.Len(t, destination.snatched, 2) | |||
| 102 | + | destination.mu.Unlock() | |||
| 103 | + | requireSingleOwnerAndLease(t, destination, follower, displaced, waiting) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestMoveTabFinalSourceRetiresSourceParkedAndSnatchedClients(t *testing.T) { | |||
| 107 | + | d, source, follower, destination, _, moved := setupMoveTabFinalSourceHandoff(t) | |||
| 108 | + | ||||
| 109 | + | parkedTransport := &closeTrackingTransport{} | |||
| 110 | + | parked := &attachedClient{tr: parkedTransport, output: newOutputStateStream(), size: follower.size, resumeCapable: true} | |||
| 111 | + | parked.initOverlays() | |||
| 112 | + | require.True(t, d.parkAttachment(source, parked)) | |||
| 113 | + | ||||
| 114 | + | sourceSnatchedTransport := &closeTrackingTransport{} | |||
| 115 | + | sourceSnatched := &attachedClient{tr: sourceSnatchedTransport, output: newOutputStateStream(), size: follower.size} | |||
| 116 | + | sourceSnatched.initOverlays() | |||
| 117 | + | sourceSnatched.setSession(source) | |||
| 118 | + | source.mu.Lock() | |||
| 119 | + | source.addSnatchedLocked(sourceSnatched) | |||
| 120 | + | source.mu.Unlock() | |||
| 121 | + | ||||
| 122 | + | moveTabFinalSource(t, d, source, destination, moved) | |||
| 123 | + | d.attachmentCleanupWg.Wait() | |||
| 124 | + | d.waitNotifies() | |||
| 125 | + | ||||
| 126 | + | d.mu.Lock() | |||
| 127 | + | require.Empty(t, d.parked) | |||
| 128 | + | d.mu.Unlock() | |||
| 129 | + | require.True(t, parkedTransport.Closed()) | |||
| 130 | + | require.True(t, sourceSnatchedTransport.Closed()) | |||
| 131 | + | require.Nil(t, sourceSnatched.currentSession()) | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func TestMoveTabFinalSourceClearsFollowerCopyCapturePreservesDestinationWaiterOverlays(t *testing.T) { | |||
| 135 | + | d, source, follower, destination, _, moved := setupMoveTabFinalSourceHandoff(t) | |||
| 136 | + | movedPane := moved.focusedPane() | |||
| 137 | + | ||||
| 138 | + | follower.overlays.copyMu.Lock() | |||
| 139 | + | follower.overlays.copyPane = movedPane | |||
| 140 | + | follower.overlays.copyMode = &scopy.Mode{} | |||
| 141 | + | follower.overlays.copyMu.Unlock() | |||
| 142 | + | follower.sendMu.Lock() | |||
| 143 | + | follower.captureFrames = map[*pane]capturedPaneRenderState{movedPane: {title: "stale-source-capture"}} | |||
| 144 | + | follower.sendMu.Unlock() | |||
| 145 | + | ||||
| 146 | + | otherPane := destination.tabs[0].focusedPane() | |||
| 147 | + | waiting := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: follower.size} | |||
| 148 | + | waiting.initOverlays() | |||
| 149 | + | waiting.setSession(destination) | |||
| 150 | + | waiting.overlays.copyMu.Lock() | |||
| 151 | + | waiting.overlays.copyPane = otherPane | |||
| 152 | + | waiting.overlays.copyMode = &scopy.Mode{} | |||
| 153 | + | waiting.overlays.copyMu.Unlock() | |||
| 154 | + | waiting.sendMu.Lock() | |||
| 155 | + | waiting.captureFrames = map[*pane]capturedPaneRenderState{otherPane: {}} | |||
| 156 | + | waiting.sendMu.Unlock() | |||
| 157 | + | destination.mu.Lock() | |||
| 158 | + | destination.addSnatchedLocked(waiting) | |||
| 159 | + | destination.mu.Unlock() | |||
| 160 | + | ||||
| 161 | + | moveTabFinalSource(t, d, source, destination, moved) | |||
| 162 | + | ||||
| 163 | + | follower.overlays.copyMu.Lock() | |||
| 164 | + | require.Nil(t, follower.overlays.copyMode) | |||
| 165 | + | require.Nil(t, follower.overlays.copyPane) | |||
| 166 | + | follower.overlays.copyMu.Unlock() | |||
| 167 | + | follower.sendMu.Lock() | |||
| 168 | + | followerCapture, followerCaptured := follower.captureFrames[movedPane] | |||
| 169 | + | follower.sendMu.Unlock() | |||
| 170 | + | if followerCaptured { | |||
| 171 | + | require.NotEqual(t, "stale-source-capture", followerCapture.title, "source capture survived destination first paint") | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | waiting.overlays.copyMu.Lock() | |||
| 175 | + | require.NotNil(t, waiting.overlays.copyMode) | |||
| 176 | + | require.Same(t, otherPane, waiting.overlays.copyPane) | |||
| 177 | + | waiting.overlays.copyMu.Unlock() | |||
| 178 | + | waiting.sendMu.Lock() | |||
| 179 | + | _, waitingCaptured := waiting.captureFrames[otherPane] | |||
| 180 | + | waiting.sendMu.Unlock() | |||
| 181 | + | require.True(t, waitingCaptured) | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func TestMoveTabFinalSourceFirstPaintNotBlockedByDisplacedPanelCleanup(t *testing.T) { | |||
| 185 | + | d, source, follower, destination, displaced, moved := setupMoveTabFinalSourceHandoff(t) | |||
| 186 | + | followerRebased := make(chan struct{}) | |||
| 187 | + | follower.renderStages.handoffRebase = func() { close(followerRebased) } | |||
| 188 | + | ||||
| 189 | + | displaced.sendMu.Lock() | |||
| 190 | + | panelCleanupStarted := make(chan struct{}) | |||
| 191 | + | d.afterDisplacedCleanupStarted = func() { close(panelCleanupStarted) } | |||
| 192 | + | defer func() { d.afterDisplacedCleanupStarted = nil }() | |||
| 193 | + | ||||
| 194 | + | moveDone := make(chan struct{}) | |||
| 195 | + | go func() { | |||
| 196 | + | moveTabFinalSource(t, d, source, destination, moved) | |||
| 197 | + | close(moveDone) | |||
| 198 | + | }() | |||
| 199 | + | ||||
| 200 | + | awaitTestCompletion(t, panelCleanupStarted, "displaced panel cleanup did not start") | |||
| 201 | + | awaitTestCompletion(t, followerRebased, "follower reset first paint waited for displaced panel cleanup") | |||
| 202 | + | awaitTestCompletion(t, moveDone, "move-tab handoff did not finish after follower first paint") | |||
| 203 | + | ||||
| 204 | + | displaced.sendMu.Unlock() | |||
| 205 | + | d.attachmentCleanupWg.Wait() | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | func TestMoveTabFinalSourceConcurrentReclaimKeepsNewestOwnerAndLease(t *testing.T) { | |||
| 209 | + | d, source, follower, destination, displaced, moved := setupMoveTabFinalSourceHandoff(t) | |||
| 210 | + | ||||
| 211 | + | paintAdmitted := make(chan struct{}) | |||
| 212 | + | releasePaint := make(chan struct{}) | |||
| 213 | + | d.afterRoleEffectAdmitted = func(token attachmentRoleToken) { | |||
| 214 | + | if token.ac == follower && token.role == attachmentActive { | |||
| 215 | + | close(paintAdmitted) | |||
| 216 | + | <-releasePaint | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 219 | + | defer func() { d.afterRoleEffectAdmitted = nil }() | |||
| 220 | + | ||||
| 221 | + | moveDone := make(chan error, 1) | |||
| 222 | + | go func() { | |||
| 223 | + | moveDone <- d.moveTab(moveTabRequest{ | |||
| 224 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |||
| 225 | + | SourceTabID: domain.TabStableID(moved.stableID), | |||
| 226 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |||
| 227 | + | }) | |||
| 228 | + | }() | |||
| 229 | + | awaitTestCompletion(t, paintAdmitted, "move-tab follower first paint was not admitted") | |||
| 230 | + | ||||
| 231 | + | displacedToken := destination.attachmentToken(displaced, displaced.transportSnapshot().transport) | |||
| 232 | + | reclaimDone := make(chan struct{}) | |||
| 233 | + | go func() { | |||
| 234 | + | d.handleSnatchedClientFrame(displacedToken, ports.Frame{ | |||
| 235 | + | Type: ports.MsgInput, | |||
| 236 | + | Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}), | |||
| 237 | + | }) | |||
| 238 | + | close(reclaimDone) | |||
| 239 | + | }() | |||
| 240 | + | ||||
| 241 | + | close(releasePaint) | |||
| 242 | + | require.NoError(t, <-moveDone) | |||
| 243 | + | awaitTestCompletion(t, reclaimDone, "concurrent destination reclaim did not finish") | |||
| 244 | + | d.attachmentCleanupWg.Wait() | |||
| 245 | + | ||||
| 246 | + | requireSingleOwnerAndLease(t, destination, follower, displaced) | |||
| 247 | + | destination.mu.Lock() | |||
| 248 | + | require.Same(t, displaced, destination.client) | |||
| 249 | + | destination.mu.Unlock() | |||
| 250 | + | require.Equal(t, attachmentSnatched, destination.attachmentRole(follower)) | |||
| 251 | + | require.Equal(t, attachmentActive, destination.attachmentRole(displaced)) | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | func TestMoveTabFinalSourcePostCommitPaintFailureDoesNotRollback(t *testing.T) { | |||
| 255 | + | d, source, follower, destination, _, moved := setupMoveTabFinalSourceHandoff(t) | |||
| 256 | + | ||||
| 257 | + | d.afterRoleEffectAdmitted = func(token attachmentRoleToken) { | |||
| 258 | + | if token.ac == follower { | |||
| 259 | + | token.ac.roleGeneration.Add(1) | |||
| 260 | + | } | |||
| 261 | + | } | |||
| 262 | + | defer func() { d.afterRoleEffectAdmitted = nil }() | |||
| 263 | + | ||||
| 264 | + | beforeTabs := append([]*tab(nil), destination.tabs...) | |||
| 265 | + | moveTabFinalSource(t, d, source, destination, moved) | |||
| 266 | + | ||||
| 267 | + | require.Nil(t, source.tabs) | |||
| 268 | + | require.Same(t, destination, follower.currentSession()) | |||
| 269 | + | require.Same(t, follower, destination.client) | |||
| 270 | + | require.Equal(t, append(beforeTabs, moved), destination.tabs) | |||
| 271 | + | require.Same(t, moved, destination.tabs[destination.active]) | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | func TestMoveTabFinalSourceHandoffCommitHidesPartialPublication(t *testing.T) { | |||
| 275 | + | d, source, follower, destination, displaced, moved := setupMoveTabFinalSourceHandoff(t) | |||
| 276 | + | _ = displaced | |||
| 277 | + | ||||
| 278 | + | commitEntered := make(chan struct{}) | |||
| 279 | + | releaseCommit := make(chan struct{}) | |||
| 280 | + | d.beforeMoveTabCommit = func() { | |||
| 281 | + | close(commitEntered) | |||
| 282 | + | <-releaseCommit | |||
| 283 | + | } | |||
| 284 | + | defer func() { d.beforeMoveTabCommit = nil }() | |||
| 285 | + | ||||
| 286 | + | moveDone := make(chan error, 1) | |||
| 287 | + | go func() { | |||
| 288 | + | moveDone <- d.moveTab(moveTabRequest{ | |||
| 289 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |||
| 290 | + | SourceTabID: domain.TabStableID(moved.stableID), | |||
| 291 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |||
| 292 | + | }) | |||
| 293 | + | }() | |||
| 294 | + | <-commitEntered | |||
| 295 | + | ||||
| 296 | + | type observation struct { | |||
| 297 | + | sourceRegistered bool | |||
| 298 | + | movedInSource bool | |||
| 299 | + | movedInDestination bool | |||
| 300 | + | followerOnDestination bool | |||
| 301 | + | displacedSnatched bool | |||
| 302 | + | movedActive bool | |||
| 303 | + | } | |||
| 304 | + | read := func() observation { | |||
| 305 | + | d.mu.Lock() | |||
| 306 | + | _, sourceLive := d.sessions[source.id] | |||
| 307 | + | d.mu.Unlock() | |||
| 308 | + | source.mu.Lock() | |||
| 309 | + | movedInSource := indexMoveTabLocked(source, moved) >= 0 | |||
| 310 | + | source.mu.Unlock() | |||
| 311 | + | destination.mu.Lock() | |||
| 312 | + | obs := observation{ | |||
| 313 | + | sourceRegistered: sourceLive, | |||
| 314 | + | movedInSource: movedInSource, | |||
| 315 | + | movedInDestination: indexMoveTabLocked(destination, moved) >= 0, | |||
| 316 | + | followerOnDestination: destination.client == follower, | |||
| 317 | + | displacedSnatched: false, | |||
| 318 | + | movedActive: destination.active >= 0 && destination.tabs[destination.active] == moved, | |||
| 319 | + | } | |||
| 320 | + | if displaced != nil { | |||
| 321 | + | _, obs.displacedSnatched = destination.snatched[displaced] | |||
| 322 | + | } | |||
| 323 | + | destination.mu.Unlock() | |||
| 324 | + | return obs | |||
| 325 | + | } | |||
| 326 | + | ||||
| 327 | + | require.False(t, d.mu.TryLock(), "daemon observers must be excluded from partial move-tab publication") | |||
| 328 | + | require.False(t, source.mu.TryLock(), "source observers must be excluded from partial move-tab publication") | |||
| 329 | + | require.False(t, destination.mu.TryLock(), "destination observers must be excluded from partial move-tab publication") | |||
| 330 | + | ||||
| 331 | + | close(releaseCommit) | |||
| 332 | + | require.NoError(t, <-moveDone) | |||
| 333 | + | ||||
| 334 | + | final := read() | |||
| 335 | + | require.False(t, final.sourceRegistered) | |||
| 336 | + | require.False(t, final.movedInSource) | |||
| 337 | + | require.True(t, final.movedInDestination) | |||
| 338 | + | require.True(t, final.followerOnDestination) | |||
| 339 | + | require.True(t, final.displacedSnatched) | |||
| 340 | + | require.True(t, final.movedActive) | |||
| 341 | + | } | |||
A internal/usecase/daemon/move_tab_test.go +558 -0
| @@ -0,0 +1,558 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 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 | + | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | |
| 15 | + | "github.com/bnema/vev/pkg/renderer" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) { | |
| 19 | + | for _, destinationEphemeral := range []bool{false, true} { | |
| 20 | + | t.Run(map[bool]string{false: "named", true: "ephemeral"}[destinationEphemeral], func(t *testing.T) { | |
| 21 | + | movedPTY := newQuietPTY() | |
| 22 | + | remainingPTY := newQuietPTY() | |
| 23 | + | destinationPTY := newQuietPTY() | |
| 24 | + | floatingPTY := newQuietPTY() | |
| 25 | + | d, source, _, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY) | |
| 26 | + | moved := source.tabs[0] | |
| 27 | + | moved.stableID = "moved-tab" | |
| 28 | + | moved.name = "preserved-name" | |
| 29 | + | remaining := source.tabs[1] | |
| 30 | + | remaining.stableID = "remaining-tab" | |
| 31 | + | source.active = 0 | |
| 32 | + | ||
| 33 | + | destinationCtx, destinationCancel := context.WithCancel(d.serveCtx) | |
| 34 | + | t.Cleanup(destinationCancel) | |
| 35 | + | destinationTab := newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 100, Rows: 30}) | |
| 36 | + | destinationTab.ctx, destinationTab.cancel = context.WithCancel(destinationCtx) | |
| 37 | + | destination := &session{ | |
| 38 | + | id: "destination", name: "destination", incarnation: domain.IncarnationID{9}, ephemeral: destinationEphemeral, | |
| 39 | + | ctx: destinationCtx, cancel: destinationCancel, tabs: []*tab{destinationTab}, active: 0, | |
| 40 | + | } | |
| 41 | + | publishTiledPaneOwners(destination, destinationTab) | |
| 42 | + | ||
| 43 | + | floating := newPaneWithStableID("floating", "floating-stable", floatingPTY, domain.Size{Cols: 20, Rows: 8}) | |
| 44 | + | moved.mu.Lock() | |
| 45 | + | moved.floating = floatingSlot{state: floatingHidden, pane: floating, generation: 7} | |
| 46 | + | moved.mu.Unlock() | |
| 47 | + | publishPaneOwner(floating, source, moved, 7) | |
| 48 | + | ||
| 49 | + | d.mu.Lock() | |
| 50 | + | d.sessions[destination.id] = destination | |
| 51 | + | d.mu.Unlock() | |
| 52 | + | ||
| 53 | + | tiled := moved.focusedPane() | |
| 54 | + | tiledScreen := tiled.screen | |
| 55 | + | tiledOwnerGeneration := tiled.ownerGeneration | |
| 56 | + | floatingOwnerGeneration := floating.ownerGeneration | |
| 57 | + | oldWorkerCtx := moved.ctx | |
| 58 | + | oldTree := moved.tree | |
| 59 | + | oldPanes := moved.panes | |
| 60 | + | beforeDestinationActive := destination.tabs[destination.active] | |
| 61 | + | ||
| 62 | + | err := d.moveTab(moveTabRequest{ | |
| 63 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 64 | + | SourceTabID: domain.TabStableID(moved.stableID), | |
| 65 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 66 | + | }) | |
| 67 | + | require.NoError(t, err) | |
| 68 | + | ||
| 69 | + | require.Equal(t, []*tab{remaining}, source.tabs) | |
| 70 | + | require.Zero(t, source.active) | |
| 71 | + | require.Same(t, remaining, source.tabs[source.active]) | |
| 72 | + | require.Equal(t, []*tab{destinationTab, moved}, destination.tabs) | |
| 73 | + | require.Same(t, beforeDestinationActive, destination.tabs[destination.active], "moved tab stays in the background") | |
| 74 | + | require.Equal(t, destinationTab.size, moved.size, "destination geometry becomes authoritative") | |
| 75 | + | require.Equal(t, "preserved-name", moved.name) | |
| 76 | + | require.Same(t, oldTree, moved.tree) | |
| 77 | + | require.Equal(t, oldPanes, moved.panes) | |
| 78 | + | require.Same(t, movedPTY, tiled.pty) | |
| 79 | + | require.Same(t, tiledScreen, tiled.screen) | |
| 80 | + | require.Same(t, floating, moved.floating.pane) | |
| 81 | + | require.Same(t, floatingPTY, floating.pty) | |
| 82 | + | require.Equal(t, floatingState(floatingHidden), moved.floating.state) | |
| 83 | + | require.Equal(t, uint64(7), moved.floating.generation) | |
| 84 | + | require.Equal(t, tiledOwnerGeneration+1, tiled.ownerGeneration) | |
| 85 | + | require.Equal(t, floatingOwnerGeneration+1, floating.ownerGeneration) | |
| 86 | + | require.Same(t, destination, tiled.ownerSnapshot().session) | |
| 87 | + | require.Same(t, destination, floating.ownerSnapshot().session) | |
| 88 | + | require.Error(t, oldWorkerCtx.Err(), "source-derived tab worker context is cancelled") | |
| 89 | + | require.NoError(t, moved.ctx.Err(), "replacement tab worker context belongs to live destination") | |
| 90 | + | }) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func TestMoveTabFinalSourceVisibleFloatingPreservesRuntimeAndFollowsClient(t *testing.T) { | |
| 95 | + | d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 96 | + | moved := source.tabs[0] | |
| 97 | + | moved.stableID = "moved-tab" | |
| 98 | + | require.NotNil(t, d.attachCoordinator(source, nil, client, true)) | |
| 99 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 100 | + | ||
| 101 | + | floatingPTY := newQuietPTY() | |
| 102 | + | floating := newPaneWithStableID("floating", "floating-stable", floatingPTY, domain.Size{Cols: 20, Rows: 8}) | |
| 103 | + | floatingCtx, floatingCancel := context.WithCancel(d.paneProcessCtx) | |
| 104 | + | t.Cleanup(floatingCancel) | |
| 105 | + | floating.ctx, floating.cancel = floatingCtx, floatingCancel | |
| 106 | + | moved.mu.Lock() | |
| 107 | + | moved.floating = floatingSlot{state: floatingVisible, pane: floating, desiredVisible: true, generation: 7} | |
| 108 | + | moved.mu.Unlock() | |
| 109 | + | publishPaneOwner(floating, source, moved, 7) | |
| 110 | + | d.applyTabLayout(source, moved) | |
| 111 | + | ||
| 112 | + | beforeScreen := floating.screen | |
| 113 | + | beforeHistory := floating.history | |
| 114 | + | beforeContext := floating.ctx | |
| 115 | + | beforeOwnerGeneration := floating.ownerGeneration | |
| 116 | + | ||
| 117 | + | require.NoError(t, d.moveTab(moveTabRequest{ | |
| 118 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID), | |
| 119 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 120 | + | })) | |
| 121 | + | ||
| 122 | + | require.Same(t, moved, destination.tabs[destination.active]) | |
| 123 | + | require.Same(t, client, destination.client) | |
| 124 | + | require.Same(t, destination, client.currentSession()) | |
| 125 | + | moved.mu.Lock() | |
| 126 | + | require.Equal(t, floatingVisible, moved.floating.state) | |
| 127 | + | require.True(t, moved.floating.desiredVisible) | |
| 128 | + | require.Equal(t, uint64(7), moved.floating.generation) | |
| 129 | + | require.Same(t, floating, moved.floating.pane) | |
| 130 | + | moved.mu.Unlock() | |
| 131 | + | expectedGeometry := calculateContentFloatingGeometry(moved.size, d.currentFloatingConfig()) | |
| 132 | + | floating.mu.Lock() | |
| 133 | + | require.Same(t, floatingPTY, floating.pty) | |
| 134 | + | require.Same(t, beforeScreen, floating.screen) | |
| 135 | + | require.Same(t, beforeHistory, floating.history) | |
| 136 | + | require.Equal(t, beforeContext, floating.ctx) | |
| 137 | + | require.Equal(t, expectedGeometry, floating.popupGeometry) | |
| 138 | + | require.Equal(t, expectedGeometry.Inner, floating.rect) | |
| 139 | + | require.Equal(t, beforeOwnerGeneration+1, floating.ownerGeneration) | |
| 140 | + | floating.mu.Unlock() | |
| 141 | + | owner := floating.ownerSnapshot() | |
| 142 | + | require.Same(t, destination, owner.session) | |
| 143 | + | require.Same(t, moved, owner.tab) | |
| 144 | + | require.Equal(t, floating.ownerGeneration, owner.generation) | |
| 145 | + | require.Equal(t, uint64(7), owner.floatingSlotGeneration) | |
| 146 | + | require.NoError(t, floating.ctx.Err(), "floating process context remains live") | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func TestMoveTabFinalSourceClientFollowsAndActivatesMovedTab(t *testing.T) { | |
| 150 | + | d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 151 | + | moved := source.tabs[0] | |
| 152 | + | moved.stableID = "moved-tab" | |
| 153 | + | require.NotNil(t, d.attachCoordinator(source, nil, client, true)) | |
| 154 | + | rebased := false | |
| 155 | + | client.renderStages.handoffRebase = func() { rebased = true } | |
| 156 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 157 | + | oldDestinationActive := destination.tabs[0] | |
| 158 | + | ||
| 159 | + | require.NoError(t, d.moveTab(moveTabRequest{ | |
| 160 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID), | |
| 161 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 162 | + | })) | |
| 163 | + | ||
| 164 | + | require.Nil(t, source.tabs) | |
| 165 | + | require.Same(t, destination, client.currentSession()) | |
| 166 | + | require.Same(t, client, destination.client) | |
| 167 | + | require.Equal(t, attachmentActive, destination.attachmentRole(client)) | |
| 168 | + | require.Equal(t, []*tab{oldDestinationActive, moved}, destination.tabs) | |
| 169 | + | require.Same(t, moved, destination.tabs[destination.active], "final-source follower activates the moved tab") | |
| 170 | + | require.True(t, rebased) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func TestMoveTabRejectsSameSessionStaleMembershipAndWarming(t *testing.T) { | |
| 174 | + | tests := []struct { | |
| 175 | + | name string | |
| 176 | + | mutate func(source, destination *session, moved *tab, req *moveTabRequest) | |
| 177 | + | }{ | |
| 178 | + | {name: "same session", mutate: func(source, _ *session, _ *tab, req *moveTabRequest) { | |
| 179 | + | req.Destination = req.Source | |
| 180 | + | }}, | |
| 181 | + | {name: "stale incarnation", mutate: func(_, _ *session, _ *tab, req *moveTabRequest) { | |
| 182 | + | req.Destination.Incarnation[0]++ | |
| 183 | + | }}, | |
| 184 | + | {name: "missing membership", mutate: func(source, _ *session, moved *tab, _ *moveTabRequest) { | |
| 185 | + | source.tabs = source.tabs[1:] | |
| 186 | + | _ = moved | |
| 187 | + | }}, | |
| 188 | + | {name: "warming floating", mutate: func(_, _ *session, moved *tab, _ *moveTabRequest) { | |
| 189 | + | moved.floating.state = floatingWarming | |
| 190 | + | moved.floating.generation = 3 | |
| 191 | + | }}, | |
| 192 | + | {name: "destination teardown", mutate: func(_, destination *session, _ *tab, _ *moveTabRequest) { | |
| 193 | + | destination.teardownMu.Lock() | |
| 194 | + | destination.teardownActive = true | |
| 195 | + | destination.teardownMu.Unlock() | |
| 196 | + | }}, | |
| 197 | + | } | |
| 198 | + | for _, tt := range tests { | |
| 199 | + | t.Run(tt.name, func(t *testing.T) { | |
| 200 | + | d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY()) | |
| 201 | + | moved := source.tabs[0] | |
| 202 | + | moved.stableID = "moved-tab" | |
| 203 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 204 | + | req := moveTabRequest{ | |
| 205 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID), | |
| 206 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 207 | + | } | |
| 208 | + | tt.mutate(source, destination, moved, &req) | |
| 209 | + | beforeDestination := append([]*tab(nil), destination.tabs...) | |
| 210 | + | beforeOwner := moved.focusedPane().ownerSnapshot() | |
| 211 | + | ||
| 212 | + | err := d.moveTab(req) | |
| 213 | + | destination.teardownMu.Lock() | |
| 214 | + | destination.teardownActive = false | |
| 215 | + | destination.teardownMu.Unlock() | |
| 216 | + | require.Error(t, err) | |
| 217 | + | require.Equal(t, beforeDestination, destination.tabs) | |
| 218 | + | require.Same(t, beforeOwner, moved.focusedPane().ownerSnapshot()) | |
| 219 | + | }) | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | func TestMoveTabFinalSourceVisibleFloatingRejectsStaleSlotTransitions(t *testing.T) { | |
| 224 | + | for _, tt := range []struct { | |
| 225 | + | name string | |
| 226 | + | mutate func(*tab) | |
| 227 | + | }{ | |
| 228 | + | {name: "generation", mutate: func(moved *tab) { moved.floating.generation++ }}, | |
| 229 | + | {name: "warming", mutate: func(moved *tab) { moved.floating.state = floatingWarming }}, | |
| 230 | + | } { | |
| 231 | + | t.Run(tt.name, func(t *testing.T) { | |
| 232 | + | d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 233 | + | moved := source.tabs[0] | |
| 234 | + | moved.stableID = "moved-tab" | |
| 235 | + | require.NotNil(t, d.attachCoordinator(source, nil, client, true)) | |
| 236 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 237 | + | floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8}) | |
| 238 | + | moved.mu.Lock() | |
| 239 | + | moved.floating = floatingSlot{state: floatingVisible, pane: floating, desiredVisible: true, generation: 7} | |
| 240 | + | moved.mu.Unlock() | |
| 241 | + | publishPaneOwner(floating, source, moved, 7) | |
| 242 | + | ||
| 243 | + | source.layoutApplyMu.Lock() | |
| 244 | + | admitted := make(chan struct{}) | |
| 245 | + | d.afterRoleEffectGateFrozen = func(action string, ac *attachedClient) { | |
| 246 | + | if action == "move-tab" && ac == client { | |
| 247 | + | close(admitted) | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | moveDone := make(chan error, 1) | |
| 251 | + | go func() { | |
| 252 | + | moveDone <- d.moveTab(moveTabRequest{ | |
| 253 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID), | |
| 254 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 255 | + | }) | |
| 256 | + | }() | |
| 257 | + | awaitTestCompletion(t, admitted, "move-tab did not finish admission") | |
| 258 | + | moved.mu.Lock() | |
| 259 | + | tt.mutate(moved) | |
| 260 | + | moved.mu.Unlock() | |
| 261 | + | source.layoutApplyMu.Unlock() | |
| 262 | + | ||
| 263 | + | require.Error(t, awaitTestValue(t, moveDone, "stale floating transfer did not return")) | |
| 264 | + | require.NotContains(t, destination.tabs, moved) | |
| 265 | + | require.Same(t, source, floating.ownerSnapshot().session) | |
| 266 | + | d.afterRoleEffectGateFrozen = nil | |
| 267 | + | }) | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | func TestMoveTabWaitsForSourceResizeAndAppliesDestinationGeometryLast(t *testing.T) { | |
| 272 | + | pty := &transactionalResizePTY{} | |
| 273 | + | d, source, _, _ := newManualSessionWithPTYs(t, pty, newQuietPTY()) | |
| 274 | + | moved := source.tabs[0] | |
| 275 | + | moved.stableID = "moved-tab" | |
| 276 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 277 | + | destination.tabs[0].mu.Lock() | |
| 278 | + | destination.tabs[0].size = domain.Size{Cols: 100, Rows: 30} | |
| 279 | + | destination.tabs[0].mu.Unlock() | |
| 280 | + | moved.mu.Lock() | |
| 281 | + | moved.size = domain.Size{Cols: 90, Rows: 20} | |
| 282 | + | moved.bumpLayoutGenerationLocked() | |
| 283 | + | moved.mu.Unlock() | |
| 284 | + | ||
| 285 | + | resizeEntered := make(chan struct{}) | |
| 286 | + | releaseResize := make(chan struct{}) | |
| 287 | + | var once sync.Once | |
| 288 | + | pty.onResize = func() { | |
| 289 | + | once.Do(func() { | |
| 290 | + | close(resizeEntered) | |
| 291 | + | <-releaseResize | |
| 292 | + | }) | |
| 293 | + | } | |
| 294 | + | layoutDone := make(chan bool, 1) | |
| 295 | + | go func() { layoutDone <- d.applyTabLayout(source, moved) }() | |
| 296 | + | <-resizeEntered | |
| 297 | + | reserved := make(chan struct{}) | |
| 298 | + | d.afterMoveLifecycleReserved = func() { close(reserved) } | |
| 299 | + | defer func() { d.afterMoveLifecycleReserved = nil }() | |
| 300 | + | moveDone := make(chan error, 1) | |
| 301 | + | go func() { | |
| 302 | + | moveDone <- d.moveTab(moveTabRequest{ | |
| 303 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID), | |
| 304 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 305 | + | }) | |
| 306 | + | }() | |
| 307 | + | <-reserved | |
| 308 | + | select { | |
| 309 | + | case err := <-moveDone: | |
| 310 | + | t.Fatalf("move crossed blocked source resize: %v", err) | |
| 311 | + | default: | |
| 312 | + | } | |
| 313 | + | close(releaseResize) | |
| 314 | + | <-layoutDone // the old transaction may lose owner publication after its PTY call | |
| 315 | + | require.NoError(t, <-moveDone) | |
| 316 | + | ||
| 317 | + | pty.mu.Lock() | |
| 318 | + | sizes := append([]domain.Size(nil), pty.sizes...) | |
| 319 | + | pty.mu.Unlock() | |
| 320 | + | require.GreaterOrEqual(t, len(sizes), 2) | |
| 321 | + | require.Equal(t, domain.Size{Cols: 100, Rows: 30}, sizes[len(sizes)-1], "destination geometry must be the final external resize") | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | func TestMoveTabMigratesSyncAndInvalidatesSourceRetryOwner(t *testing.T) { | |
| 325 | + | d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY()) | |
| 326 | + | moved := source.tabs[0] | |
| 327 | + | moved.stableID = "moved-tab" | |
| 328 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 329 | + | p := moved.focusedPane() | |
| 330 | + | sourceCoordinator := d.ensureRenderCoordinator(source) | |
| 331 | + | destinationCoordinator := d.ensureRenderCoordinator(destination) | |
| 332 | + | ||
| 333 | + | p.mu.Lock() | |
| 334 | + | p.screen.Write([]byte(renderer.SyncStartCSI)) | |
| 335 | + | generation := source.syncGen.Add(1) | |
| 336 | + | p.syncGen = generation | |
| 337 | + | oldLease := p.effectLeaseLocked() | |
| 338 | + | p.resizeRetry = true | |
| 339 | + | p.mu.Unlock() | |
| 340 | + | sourceCoordinator.noteSyncBeginWithRenderability(p, generation, oldLease.Current, nil) | |
| 341 | + | oldRetry := newAcceptedTabLayoutRetryToken(source, moved, []resizeMember{{session: source, tab: moved, pane: p, owner: oldLease}}) | |
| 342 | + | ||
| 343 | + | require.NoError(t, d.moveTab(moveTabRequest{ | |
| 344 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID), | |
| 345 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 346 | + | })) | |
| 347 | + | ||
| 348 | + | require.False(t, oldLease.Current(), "source effects are revoked at owner publication") | |
| 349 | + | require.False(t, oldRetry.current(), "source delayed retry cannot run after transfer") | |
| 350 | + | require.Same(t, destination, p.ownerSnapshot().session) | |
| 351 | + | sourceCoordinator.mu.Lock() | |
| 352 | + | require.NotContains(t, sourceCoordinator.syncBatches, p) | |
| 353 | + | sourceCoordinator.mu.Unlock() | |
| 354 | + | destinationCoordinator.mu.Lock() | |
| 355 | + | require.Contains(t, destinationCoordinator.syncBatches, p) | |
| 356 | + | destinationCoordinator.mu.Unlock() | |
| 357 | + | require.Equal(t, destination.syncGen.Load(), p.syncGen) | |
| 358 | + | p.mu.Lock() | |
| 359 | + | require.False(t, p.resizeRetry, "required resize work is completed under the destination owner") | |
| 360 | + | p.mu.Unlock() | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | func TestMoveTabExitReapsOnlyDestination(t *testing.T) { | |
| 364 | + | d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY()) | |
| 365 | + | moved := source.tabs[0] | |
| 366 | + | moved.stableID = "moved-tab" | |
| 367 | + | remaining := source.tabs[1] | |
| 368 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 369 | + | p := moved.focusedPane() | |
| 370 | + | ||
| 371 | + | require.NoError(t, d.moveTab(moveTabRequest{ | |
| 372 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID), | |
| 373 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 374 | + | })) | |
| 375 | + | d.reapPaneOwner(p) | |
| 376 | + | ||
| 377 | + | require.Equal(t, []*tab{remaining}, source.tabs) | |
| 378 | + | require.NotContains(t, destination.tabs, moved, "exit removes the tab from its destination owner") | |
| 379 | + | require.Nil(t, p.ownerSnapshot()) | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | func TestMoveTabOppositeDirectionsDoNotDeadlock(t *testing.T) { | |
| 383 | + | d, left, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY()) | |
| 384 | + | left.tabs[0].stableID = "left-moved" | |
| 385 | + | right := addMoveTabTestSession(d, "right", "right-stays") | |
| 386 | + | rightMoved := newTabWithStableID("right-moved", "right-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23}) | |
| 387 | + | rightMoved.ctx, rightMoved.cancel = context.WithCancel(right.ctx) | |
| 388 | + | publishTiledPaneOwners(right, rightMoved) | |
| 389 | + | right.tabs = append(right.tabs, rightMoved) | |
| 390 | + | ||
| 391 | + | start := make(chan struct{}) | |
| 392 | + | done := make(chan error, 2) | |
| 393 | + | go func() { | |
| 394 | + | <-start | |
| 395 | + | done <- d.moveTab(moveTabRequest{Source: moveSessionLocator{ID: left.id, Incarnation: left.incarnation}, SourceTabID: domain.TabStableID("left-moved"), Destination: moveSessionLocator{ID: right.id, Incarnation: right.incarnation}}) | |
| 396 | + | }() | |
| 397 | + | go func() { | |
| 398 | + | <-start | |
| 399 | + | done <- d.moveTab(moveTabRequest{Source: moveSessionLocator{ID: right.id, Incarnation: right.incarnation}, SourceTabID: domain.TabStableID("right-moved"), Destination: moveSessionLocator{ID: left.id, Incarnation: left.incarnation}}) | |
| 400 | + | }() | |
| 401 | + | close(start) | |
| 402 | + | for range 2 { | |
| 403 | + | select { | |
| 404 | + | case err := <-done: | |
| 405 | + | require.NoError(t, err) | |
| 406 | + | case <-time.After(5 * time.Second): | |
| 407 | + | t.Fatal("opposite-direction move tabs deadlocked") | |
| 408 | + | } | |
| 409 | + | } | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | type moveTabPurgeCatalogue struct { | |
| 413 | + | ports.Catalogue | |
| 414 | + | mu sync.Mutex | |
| 415 | + | records map[string]domain.CatalogueRecord | |
| 416 | + | events []string | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | func (c *moveTabPurgeCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) { | |
| 420 | + | c.mu.Lock() | |
| 421 | + | defer c.mu.Unlock() | |
| 422 | + | record, ok := c.records[name] | |
| 423 | + | return record, ok, nil | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | func (c *moveTabPurgeCatalogue) Delete(name string) error { | |
| 427 | + | c.mu.Lock() | |
| 428 | + | defer c.mu.Unlock() | |
| 429 | + | delete(c.records, name) | |
| 430 | + | c.events = append(c.events, "catalogue-delete:"+name) | |
| 431 | + | return nil | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | func (c *moveTabPurgeCatalogue) UpdateMetadata(update domain.CatalogueMetadataUpdate) error { | |
| 435 | + | c.mu.Lock() | |
| 436 | + | defer c.mu.Unlock() | |
| 437 | + | c.events = append(c.events, "metadata:"+update.Name) | |
| 438 | + | return nil | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | type moveTabPurgeRepository struct { | |
| 442 | + | noOpSnapshotRepository | |
| 443 | + | d *Daemon | |
| 444 | + | source *session | |
| 445 | + | destination *session | |
| 446 | + | moved *tab | |
| 447 | + | pane *pane | |
| 448 | + | client *attachedClient | |
| 449 | + | catalogue *moveTabPurgeCatalogue | |
| 450 | + | err error | |
| 451 | + | deleted []domain.IncarnationID | |
| 452 | + | outside bool | |
| 453 | + | dirty bool | |
| 454 | + | } | |
| 455 | + | ||
| 456 | + | func (r *moveTabPurgeRepository) DeleteIncarnation(_ context.Context, id domain.IncarnationID) error { | |
| 457 | + | r.deleted = append(r.deleted, id) | |
| 458 | + | daemonUnlocked := r.d.mu.TryLock() | |
| 459 | + | routingUnlocked := r.d.notices.routingMu.TryLock() | |
| 460 | + | sourceUnlocked := r.source.mu.TryLock() | |
| 461 | + | destinationUnlocked := r.destination.mu.TryLock() | |
| 462 | + | tabUnlocked := r.moved.mu.TryLock() | |
| 463 | + | paneUnlocked := r.pane.mu.TryLock() | |
| 464 | + | sourceFenceUnlocked := r.source.layoutApplyMu.TryLock() | |
| 465 | + | destinationFenceUnlocked := r.destination.layoutApplyMu.TryLock() | |
| 466 | + | tabFenceUnlocked := r.moved.layoutApplyMu.TryLock() | |
| 467 | + | roleUnlocked := r.client.roleEffects.mu.TryLock() | |
| 468 | + | roleStable := roleUnlocked && r.client.roleEffects.phase == roleEffectsStable | |
| 469 | + | r.outside = daemonUnlocked && routingUnlocked && sourceUnlocked && destinationUnlocked && tabUnlocked && paneUnlocked && | |
| 470 | + | sourceFenceUnlocked && destinationFenceUnlocked && tabFenceUnlocked && roleStable | |
| 471 | + | if roleUnlocked { | |
| 472 | + | r.client.roleEffects.mu.Unlock() | |
| 473 | + | } | |
| 474 | + | if tabFenceUnlocked { | |
| 475 | + | r.moved.layoutApplyMu.Unlock() | |
| 476 | + | } | |
| 477 | + | if destinationFenceUnlocked { | |
| 478 | + | r.destination.layoutApplyMu.Unlock() | |
| 479 | + | } | |
| 480 | + | if sourceFenceUnlocked { | |
| 481 | + | r.source.layoutApplyMu.Unlock() | |
| 482 | + | } | |
| 483 | + | if paneUnlocked { | |
| 484 | + | r.pane.mu.Unlock() | |
| 485 | + | } | |
| 486 | + | if tabUnlocked { | |
| 487 | + | r.moved.mu.Unlock() | |
| 488 | + | } | |
| 489 | + | if destinationUnlocked { | |
| 490 | + | r.destination.mu.Unlock() | |
| 491 | + | } | |
| 492 | + | if sourceUnlocked { | |
| 493 | + | r.source.mu.Unlock() | |
| 494 | + | } | |
| 495 | + | if routingUnlocked { | |
| 496 | + | r.d.notices.routingMu.Unlock() | |
| 497 | + | } | |
| 498 | + | if daemonUnlocked { | |
| 499 | + | r.d.mu.Unlock() | |
| 500 | + | } | |
| 501 | + | r.destination.snapshotMu.Lock() | |
| 502 | + | r.dirty = r.destination.snapDirty.Load() && r.destination.snapshotGeneration > 0 | |
| 503 | + | r.destination.snapshotMu.Unlock() | |
| 504 | + | r.catalogue.mu.Lock() | |
| 505 | + | r.catalogue.events = append(r.catalogue.events, "repository-delete") | |
| 506 | + | r.catalogue.mu.Unlock() | |
| 507 | + | return r.err | |
| 508 | + | } | |
| 509 | + | ||
| 510 | + | func TestMoveTabFinalNamedSourcePurgesAfterDestinationAdmissionAndFailureDoesNotRollback(t *testing.T) { | |
| 511 | + | d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 512 | + | require.NotNil(t, d.attachCoordinator(source, nil, client, true)) | |
| 513 | + | source.incarnation = domain.IncarnationID{4} | |
| 514 | + | moved := source.tabs[0] | |
| 515 | + | moved.stableID = "moved-tab" | |
| 516 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 517 | + | destination.ephemeral = false | |
| 518 | + | destination.snapEligible.Store(true) | |
| 519 | + | ||
| 520 | + | catalogue := &moveTabPurgeCatalogue{records: map[string]domain.CatalogueRecord{ | |
| 521 | + | source.name: {Name: source.name, IncarnationID: source.incarnation}, | |
| 522 | + | destination.name: {Name: destination.name, IncarnationID: destination.incarnation}, | |
| 523 | + | }} | |
| 524 | + | failure := errors.New("repository delete failed") | |
| 525 | + | repository := &moveTabPurgeRepository{ | |
| 526 | + | d: d, source: source, destination: destination, moved: moved, pane: moved.focusedPane(), client: client, | |
| 527 | + | catalogue: catalogue, err: failure, | |
| 528 | + | } | |
| 529 | + | WithCatalogue(catalogue, nil)(d) | |
| 530 | + | WithSnapshotRepository(repository)(d) | |
| 531 | + | WithRecoveryCoordinator(recoveryusecase.NewCoordinator(catalogue, repository, nil))(d) | |
| 532 | + | ||
| 533 | + | require.NoError(t, d.moveTab(moveTabRequest{ | |
| 534 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID), | |
| 535 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 536 | + | })) | |
| 537 | + | ||
| 538 | + | require.Equal(t, []domain.IncarnationID{source.incarnation}, repository.deleted) | |
| 539 | + | require.True(t, repository.outside, "repository purge must run outside architecture and owner locks") | |
| 540 | + | require.True(t, repository.dirty, "destination snapshot dirtiness must be admitted before purge") | |
| 541 | + | catalogue.mu.Lock() | |
| 542 | + | require.Equal(t, []string{"metadata:destination", "catalogue-delete:work", "repository-delete"}, catalogue.events) | |
| 543 | + | catalogue.mu.Unlock() | |
| 544 | + | require.Same(t, moved, destination.tabs[len(destination.tabs)-1], "best-effort purge failure cannot roll back committed membership") | |
| 545 | + | require.Same(t, destination, moved.focusedPane().ownerSnapshot().session) | |
| 546 | + | } | |
| 547 | + | ||
| 548 | + | func addMoveTabTestSession(d *Daemon, id domain.SessionID, tabID string) *session { | |
| 549 | + | ctx, cancel := context.WithCancel(d.serveCtx) | |
| 550 | + | tb := newTabWithStableID(tabID, tabID+"-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23}) | |
| 551 | + | tb.ctx, tb.cancel = context.WithCancel(ctx) | |
| 552 | + | sess := &session{id: id, name: string(id), incarnation: domain.IncarnationID{5}, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0} | |
| 553 | + | publishTiledPaneOwners(sess, tb) | |
| 554 | + | d.mu.Lock() | |
| 555 | + | d.sessions[sess.id] = sess | |
| 556 | + | d.mu.Unlock() | |
| 557 | + | return sess | |
| 558 | + | } | |
| @@ -0,0 +1,558 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 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 | + | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | |||
| 15 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) { | |||
| 19 | + | for _, destinationEphemeral := range []bool{false, true} { | |||
| 20 | + | t.Run(map[bool]string{false: "named", true: "ephemeral"}[destinationEphemeral], func(t *testing.T) { | |||
| 21 | + | movedPTY := newQuietPTY() | |||
| 22 | + | remainingPTY := newQuietPTY() | |||
| 23 | + | destinationPTY := newQuietPTY() | |||
| 24 | + | floatingPTY := newQuietPTY() | |||
| 25 | + | d, source, _, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY) | |||
| 26 | + | moved := source.tabs[0] | |||
| 27 | + | moved.stableID = "moved-tab" | |||
| 28 | + | moved.name = "preserved-name" | |||
| 29 | + | remaining := source.tabs[1] | |||
| 30 | + | remaining.stableID = "remaining-tab" | |||
| 31 | + | source.active = 0 | |||
| 32 | + | ||||
| 33 | + | destinationCtx, destinationCancel := context.WithCancel(d.serveCtx) | |||
| 34 | + | t.Cleanup(destinationCancel) | |||
| 35 | + | destinationTab := newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 100, Rows: 30}) | |||
| 36 | + | destinationTab.ctx, destinationTab.cancel = context.WithCancel(destinationCtx) | |||
| 37 | + | destination := &session{ | |||
| 38 | + | id: "destination", name: "destination", incarnation: domain.IncarnationID{9}, ephemeral: destinationEphemeral, | |||
| 39 | + | ctx: destinationCtx, cancel: destinationCancel, tabs: []*tab{destinationTab}, active: 0, | |||
| 40 | + | } | |||
| 41 | + | publishTiledPaneOwners(destination, destinationTab) | |||
| 42 | + | ||||
| 43 | + | floating := newPaneWithStableID("floating", "floating-stable", floatingPTY, domain.Size{Cols: 20, Rows: 8}) | |||
| 44 | + | moved.mu.Lock() | |||
| 45 | + | moved.floating = floatingSlot{state: floatingHidden, pane: floating, generation: 7} | |||
| 46 | + | moved.mu.Unlock() | |||
| 47 | + | publishPaneOwner(floating, source, moved, 7) | |||
| 48 | + | ||||
| 49 | + | d.mu.Lock() | |||
| 50 | + | d.sessions[destination.id] = destination | |||
| 51 | + | d.mu.Unlock() | |||
| 52 | + | ||||
| 53 | + | tiled := moved.focusedPane() | |||
| 54 | + | tiledScreen := tiled.screen | |||
| 55 | + | tiledOwnerGeneration := tiled.ownerGeneration | |||
| 56 | + | floatingOwnerGeneration := floating.ownerGeneration | |||
| 57 | + | oldWorkerCtx := moved.ctx | |||
| 58 | + | oldTree := moved.tree | |||
| 59 | + | oldPanes := moved.panes | |||
| 60 | + | beforeDestinationActive := destination.tabs[destination.active] | |||
| 61 | + | ||||
| 62 | + | err := d.moveTab(moveTabRequest{ | |||
| 63 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |||
| 64 | + | SourceTabID: domain.TabStableID(moved.stableID), | |||
| 65 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |||
| 66 | + | }) | |||
| 67 | + | require.NoError(t, err) | |||
| 68 | + | ||||
| 69 | + | require.Equal(t, []*tab{remaining}, source.tabs) | |||
| 70 | + | require.Zero(t, source.active) | |||
| 71 | + | require.Same(t, remaining, source.tabs[source.active]) | |||
| 72 | + | require.Equal(t, []*tab{destinationTab, moved}, destination.tabs) | |||
| 73 | + | require.Same(t, beforeDestinationActive, destination.tabs[destination.active], "moved tab stays in the background") | |||
| 74 | + | require.Equal(t, destinationTab.size, moved.size, "destination geometry becomes authoritative") | |||
| 75 | + | require.Equal(t, "preserved-name", moved.name) | |||
| 76 | + | require.Same(t, oldTree, moved.tree) | |||
| 77 | + | require.Equal(t, oldPanes, moved.panes) | |||
| 78 | + | require.Same(t, movedPTY, tiled.pty) | |||
| 79 | + | require.Same(t, tiledScreen, tiled.screen) | |||
| 80 | + | require.Same(t, floating, moved.floating.pane) | |||
| 81 | + | require.Same(t, floatingPTY, floating.pty) | |||
| 82 | + | require.Equal(t, floatingState(floatingHidden), moved.floating.state) | |||
| 83 | + | require.Equal(t, uint64(7), moved.floating.generation) | |||
| 84 | + | require.Equal(t, tiledOwnerGeneration+1, tiled.ownerGeneration) | |||
| 85 | + | require.Equal(t, floatingOwnerGeneration+1, floating.ownerGeneration) | |||
| 86 | + | require.Same(t, destination, tiled.ownerSnapshot().session) | |||
| 87 | + | require.Same(t, destination, floating.ownerSnapshot().session) | |||
| 88 | + | require.Error(t, oldWorkerCtx.Err(), "source-derived tab worker context is cancelled") | |||
| 89 | + | require.NoError(t, moved.ctx.Err(), "replacement tab worker context belongs to live destination") | |||
| 90 | + | }) | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func TestMoveTabFinalSourceVisibleFloatingPreservesRuntimeAndFollowsClient(t *testing.T) { | |||
| 95 | + | d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 96 | + | moved := source.tabs[0] | |||
| 97 | + | moved.stableID = "moved-tab" | |||
| 98 | + | require.NotNil(t, d.attachCoordinator(source, nil, client, true)) | |||
| 99 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |||
| 100 | + | ||||
| 101 | + | floatingPTY := newQuietPTY() | |||
| 102 | + | floating := newPaneWithStableID("floating", "floating-stable", floatingPTY, domain.Size{Cols: 20, Rows: 8}) | |||
| 103 | + | floatingCtx, floatingCancel := context.WithCancel(d.paneProcessCtx) | |||
| 104 | + | t.Cleanup(floatingCancel) | |||
| 105 | + | floating.ctx, floating.cancel = floatingCtx, floatingCancel | |||
| 106 | + | moved.mu.Lock() | |||
| 107 | + | moved.floating = floatingSlot{state: floatingVisible, pane: floating, desiredVisible: true, generation: 7} | |||
| 108 | + | moved.mu.Unlock() | |||
| 109 | + | publishPaneOwner(floating, source, moved, 7) | |||
| 110 | + | d.applyTabLayout(source, moved) | |||
| 111 | + | ||||
| 112 | + | beforeScreen := floating.screen | |||
| 113 | + | beforeHistory := floating.history | |||
| 114 | + | beforeContext := floating.ctx | |||
| 115 | + | beforeOwnerGeneration := floating.ownerGeneration | |||
| 116 | + | ||||
| 117 | + | require.NoError(t, d.moveTab(moveTabRequest{ | |||
| 118 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID), | |||
| 119 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |||
| 120 | + | })) | |||
| 121 | + | ||||
| 122 | + | require.Same(t, moved, destination.tabs[destination.active]) | |||
| 123 | + | require.Same(t, client, destination.client) | |||
| 124 | + | require.Same(t, destination, client.currentSession()) | |||
| 125 | + | moved.mu.Lock() | |||
| 126 | + | require.Equal(t, floatingVisible, moved.floating.state) | |||
| 127 | + | require.True(t, moved.floating.desiredVisible) | |||
| 128 | + | require.Equal(t, uint64(7), moved.floating.generation) | |||
| 129 | + | require.Same(t, floating, moved.floating.pane) | |||
| 130 | + | moved.mu.Unlock() | |||
| 131 | + | expectedGeometry := calculateContentFloatingGeometry(moved.size, d.currentFloatingConfig()) | |||
| 132 | + | floating.mu.Lock() | |||
| 133 | + | require.Same(t, floatingPTY, floating.pty) | |||
| 134 | + | require.Same(t, beforeScreen, floating.screen) | |||
| 135 | + | require.Same(t, beforeHistory, floating.history) | |||
| 136 | + | require.Equal(t, beforeContext, floating.ctx) | |||
| 137 | + | require.Equal(t, expectedGeometry, floating.popupGeometry) | |||
| 138 | + | require.Equal(t, expectedGeometry.Inner, floating.rect) | |||
| 139 | + | require.Equal(t, beforeOwnerGeneration+1, floating.ownerGeneration) | |||
| 140 | + | floating.mu.Unlock() | |||
| 141 | + | owner := floating.ownerSnapshot() | |||
| 142 | + | require.Same(t, destination, owner.session) | |||
| 143 | + | require.Same(t, moved, owner.tab) | |||
| 144 | + | require.Equal(t, floating.ownerGeneration, owner.generation) | |||
| 145 | + | require.Equal(t, uint64(7), owner.floatingSlotGeneration) | |||
| 146 | + | require.NoError(t, floating.ctx.Err(), "floating process context remains live") | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func TestMoveTabFinalSourceClientFollowsAndActivatesMovedTab(t *testing.T) { | |||
| 150 | + | d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 151 | + | moved := source.tabs[0] | |||
| 152 | + | moved.stableID = "moved-tab" | |||
| 153 | + | require.NotNil(t, d.attachCoordinator(source, nil, client, true)) | |||
| 154 | + | rebased := false | |||
| 155 | + | client.renderStages.handoffRebase = func() { rebased = true } | |||
| 156 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |||
| 157 | + | oldDestinationActive := destination.tabs[0] | |||
| 158 | + | ||||
| 159 | + | require.NoError(t, d.moveTab(moveTabRequest{ | |||
| 160 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID), | |||
| 161 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |||
| 162 | + | })) | |||
| 163 | + | ||||
| 164 | + | require.Nil(t, source.tabs) | |||
| 165 | + | require.Same(t, destination, client.currentSession()) | |||
| 166 | + | require.Same(t, client, destination.client) | |||
| 167 | + | require.Equal(t, attachmentActive, destination.attachmentRole(client)) | |||
| 168 | + | require.Equal(t, []*tab{oldDestinationActive, moved}, destination.tabs) | |||
| 169 | + | require.Same(t, moved, destination.tabs[destination.active], "final-source follower activates the moved tab") | |||
| 170 | + | require.True(t, rebased) | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func TestMoveTabRejectsSameSessionStaleMembershipAndWarming(t *testing.T) { | |||
| 174 | + | tests := []struct { | |||
| 175 | + | name string | |||
| 176 | + | mutate func(source, destination *session, moved *tab, req *moveTabRequest) | |||
| 177 | + | }{ | |||
| 178 | + | {name: "same session", mutate: func(source, _ *session, _ *tab, req *moveTabRequest) { | |||
| 179 | + | req.Destination = req.Source | |||
| 180 | + | }}, | |||
| 181 | + | {name: "stale incarnation", mutate: func(_, _ *session, _ *tab, req *moveTabRequest) { | |||
| 182 | + | req.Destination.Incarnation[0]++ | |||
| 183 | + | }}, | |||
| 184 | + | {name: "missing membership", mutate: func(source, _ *session, moved *tab, _ *moveTabRequest) { | |||
| 185 | + | source.tabs = source.tabs[1:] | |||
| 186 | + | _ = moved | |||
| 187 | + | }}, | |||
| 188 | + | {name: "warming floating", mutate: func(_, _ *session, moved *tab, _ *moveTabRequest) { | |||
| 189 | + | moved.floating.state = floatingWarming | |||
| 190 | + | moved.floating.generation = 3 | |||
| 191 | + | }}, | |||
| 192 | + | {name: "destination teardown", mutate: func(_, destination *session, _ *tab, _ *moveTabRequest) { | |||
| 193 | + | destination.teardownMu.Lock() | |||
| 194 | + | destination.teardownActive = true | |||
| 195 | + | destination.teardownMu.Unlock() | |||
| 196 | + | }}, | |||
| 197 | + | } | |||
| 198 | + | for _, tt := range tests { | |||
| 199 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 200 | + | d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY()) | |||
| 201 | + | moved := source.tabs[0] | |||
| 202 | + | moved.stableID = "moved-tab" | |||
| 203 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |||
| 204 | + | req := moveTabRequest{ | |||
| 205 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID), | |||
| 206 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |||
| 207 | + | } | |||
| 208 | + | tt.mutate(source, destination, moved, &req) | |||
| 209 | + | beforeDestination := append([]*tab(nil), destination.tabs...) | |||
| 210 | + | beforeOwner := moved.focusedPane().ownerSnapshot() | |||
| 211 | + | ||||
| 212 | + | err := d.moveTab(req) | |||
| 213 | + | destination.teardownMu.Lock() | |||
| 214 | + | destination.teardownActive = false | |||
| 215 | + | destination.teardownMu.Unlock() | |||
| 216 | + | require.Error(t, err) | |||
| 217 | + | require.Equal(t, beforeDestination, destination.tabs) | |||
| 218 | + | require.Same(t, beforeOwner, moved.focusedPane().ownerSnapshot()) | |||
| 219 | + | }) | |||
| 220 | + | } | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | func TestMoveTabFinalSourceVisibleFloatingRejectsStaleSlotTransitions(t *testing.T) { | |||
| 224 | + | for _, tt := range []struct { | |||
| 225 | + | name string | |||
| 226 | + | mutate func(*tab) | |||
| 227 | + | }{ | |||
| 228 | + | {name: "generation", mutate: func(moved *tab) { moved.floating.generation++ }}, | |||
| 229 | + | {name: "warming", mutate: func(moved *tab) { moved.floating.state = floatingWarming }}, | |||
| 230 | + | } { | |||
| 231 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 232 | + | d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 233 | + | moved := source.tabs[0] | |||
| 234 | + | moved.stableID = "moved-tab" | |||
| 235 | + | require.NotNil(t, d.attachCoordinator(source, nil, client, true)) | |||
| 236 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |||
| 237 | + | floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8}) | |||
| 238 | + | moved.mu.Lock() | |||
| 239 | + | moved.floating = floatingSlot{state: floatingVisible, pane: floating, desiredVisible: true, generation: 7} | |||
| 240 | + | moved.mu.Unlock() | |||
| 241 | + | publishPaneOwner(floating, source, moved, 7) | |||
| 242 | + | ||||
| 243 | + | source.layoutApplyMu.Lock() | |||
| 244 | + | admitted := make(chan struct{}) | |||
| 245 | + | d.afterRoleEffectGateFrozen = func(action string, ac *attachedClient) { | |||
| 246 | + | if action == "move-tab" && ac == client { | |||
| 247 | + | close(admitted) | |||
| 248 | + | } | |||
| 249 | + | } | |||
| 250 | + | moveDone := make(chan error, 1) | |||
| 251 | + | go func() { | |||
| 252 | + | moveDone <- d.moveTab(moveTabRequest{ | |||
| 253 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID), | |||
| 254 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |||
| 255 | + | }) | |||
| 256 | + | }() | |||
| 257 | + | awaitTestCompletion(t, admitted, "move-tab did not finish admission") | |||
| 258 | + | moved.mu.Lock() | |||
| 259 | + | tt.mutate(moved) | |||
| 260 | + | moved.mu.Unlock() | |||
| 261 | + | source.layoutApplyMu.Unlock() | |||
| 262 | + | ||||
| 263 | + | require.Error(t, awaitTestValue(t, moveDone, "stale floating transfer did not return")) | |||
| 264 | + | require.NotContains(t, destination.tabs, moved) | |||
| 265 | + | require.Same(t, source, floating.ownerSnapshot().session) | |||
| 266 | + | d.afterRoleEffectGateFrozen = nil | |||
| 267 | + | }) | |||
| 268 | + | } | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | func TestMoveTabWaitsForSourceResizeAndAppliesDestinationGeometryLast(t *testing.T) { | |||
| 272 | + | pty := &transactionalResizePTY{} | |||
| 273 | + | d, source, _, _ := newManualSessionWithPTYs(t, pty, newQuietPTY()) | |||
| 274 | + | moved := source.tabs[0] | |||
| 275 | + | moved.stableID = "moved-tab" | |||
| 276 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |||
| 277 | + | destination.tabs[0].mu.Lock() | |||
| 278 | + | destination.tabs[0].size = domain.Size{Cols: 100, Rows: 30} | |||
| 279 | + | destination.tabs[0].mu.Unlock() | |||
| 280 | + | moved.mu.Lock() | |||
| 281 | + | moved.size = domain.Size{Cols: 90, Rows: 20} | |||
| 282 | + | moved.bumpLayoutGenerationLocked() | |||
| 283 | + | moved.mu.Unlock() | |||
| 284 | + | ||||
| 285 | + | resizeEntered := make(chan struct{}) | |||
| 286 | + | releaseResize := make(chan struct{}) | |||
| 287 | + | var once sync.Once | |||
| 288 | + | pty.onResize = func() { | |||
| 289 | + | once.Do(func() { | |||
| 290 | + | close(resizeEntered) | |||
| 291 | + | <-releaseResize | |||
| 292 | + | }) | |||
| 293 | + | } | |||
| 294 | + | layoutDone := make(chan bool, 1) | |||
| 295 | + | go func() { layoutDone <- d.applyTabLayout(source, moved) }() | |||
| 296 | + | <-resizeEntered | |||
| 297 | + | reserved := make(chan struct{}) | |||
| 298 | + | d.afterMoveLifecycleReserved = func() { close(reserved) } | |||
| 299 | + | defer func() { d.afterMoveLifecycleReserved = nil }() | |||
| 300 | + | moveDone := make(chan error, 1) | |||
| 301 | + | go func() { | |||
| 302 | + | moveDone <- d.moveTab(moveTabRequest{ | |||
| 303 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID), | |||
| 304 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |||
| 305 | + | }) | |||
| 306 | + | }() | |||
| 307 | + | <-reserved | |||
| 308 | + | select { | |||
| 309 | + | case err := <-moveDone: | |||
| 310 | + | t.Fatalf("move crossed blocked source resize: %v", err) | |||
| 311 | + | default: | |||
| 312 | + | } | |||
| 313 | + | close(releaseResize) | |||
| 314 | + | <-layoutDone // the old transaction may lose owner publication after its PTY call | |||
| 315 | + | require.NoError(t, <-moveDone) | |||
| 316 | + | ||||
| 317 | + | pty.mu.Lock() | |||
| 318 | + | sizes := append([]domain.Size(nil), pty.sizes...) | |||
| 319 | + | pty.mu.Unlock() | |||
| 320 | + | require.GreaterOrEqual(t, len(sizes), 2) | |||
| 321 | + | require.Equal(t, domain.Size{Cols: 100, Rows: 30}, sizes[len(sizes)-1], "destination geometry must be the final external resize") | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | func TestMoveTabMigratesSyncAndInvalidatesSourceRetryOwner(t *testing.T) { | |||
| 325 | + | d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY()) | |||
| 326 | + | moved := source.tabs[0] | |||
| 327 | + | moved.stableID = "moved-tab" | |||
| 328 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |||
| 329 | + | p := moved.focusedPane() | |||
| 330 | + | sourceCoordinator := d.ensureRenderCoordinator(source) | |||
| 331 | + | destinationCoordinator := d.ensureRenderCoordinator(destination) | |||
| 332 | + | ||||
| 333 | + | p.mu.Lock() | |||
| 334 | + | p.screen.Write([]byte(renderer.SyncStartCSI)) | |||
| 335 | + | generation := source.syncGen.Add(1) | |||
| 336 | + | p.syncGen = generation | |||
| 337 | + | oldLease := p.effectLeaseLocked() | |||
| 338 | + | p.resizeRetry = true | |||
| 339 | + | p.mu.Unlock() | |||
| 340 | + | sourceCoordinator.noteSyncBeginWithRenderability(p, generation, oldLease.Current, nil) | |||
| 341 | + | oldRetry := newAcceptedTabLayoutRetryToken(source, moved, []resizeMember{{session: source, tab: moved, pane: p, owner: oldLease}}) | |||
| 342 | + | ||||
| 343 | + | require.NoError(t, d.moveTab(moveTabRequest{ | |||
| 344 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID), | |||
| 345 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |||
| 346 | + | })) | |||
| 347 | + | ||||
| 348 | + | require.False(t, oldLease.Current(), "source effects are revoked at owner publication") | |||
| 349 | + | require.False(t, oldRetry.current(), "source delayed retry cannot run after transfer") | |||
| 350 | + | require.Same(t, destination, p.ownerSnapshot().session) | |||
| 351 | + | sourceCoordinator.mu.Lock() | |||
| 352 | + | require.NotContains(t, sourceCoordinator.syncBatches, p) | |||
| 353 | + | sourceCoordinator.mu.Unlock() | |||
| 354 | + | destinationCoordinator.mu.Lock() | |||
| 355 | + | require.Contains(t, destinationCoordinator.syncBatches, p) | |||
| 356 | + | destinationCoordinator.mu.Unlock() | |||
| 357 | + | require.Equal(t, destination.syncGen.Load(), p.syncGen) | |||
| 358 | + | p.mu.Lock() | |||
| 359 | + | require.False(t, p.resizeRetry, "required resize work is completed under the destination owner") | |||
| 360 | + | p.mu.Unlock() | |||
| 361 | + | } | |||
| 362 | + | ||||
| 363 | + | func TestMoveTabExitReapsOnlyDestination(t *testing.T) { | |||
| 364 | + | d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY()) | |||
| 365 | + | moved := source.tabs[0] | |||
| 366 | + | moved.stableID = "moved-tab" | |||
| 367 | + | remaining := source.tabs[1] | |||
| 368 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |||
| 369 | + | p := moved.focusedPane() | |||
| 370 | + | ||||
| 371 | + | require.NoError(t, d.moveTab(moveTabRequest{ | |||
| 372 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID), | |||
| 373 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |||
| 374 | + | })) | |||
| 375 | + | d.reapPaneOwner(p) | |||
| 376 | + | ||||
| 377 | + | require.Equal(t, []*tab{remaining}, source.tabs) | |||
| 378 | + | require.NotContains(t, destination.tabs, moved, "exit removes the tab from its destination owner") | |||
| 379 | + | require.Nil(t, p.ownerSnapshot()) | |||
| 380 | + | } | |||
| 381 | + | ||||
| 382 | + | func TestMoveTabOppositeDirectionsDoNotDeadlock(t *testing.T) { | |||
| 383 | + | d, left, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY()) | |||
| 384 | + | left.tabs[0].stableID = "left-moved" | |||
| 385 | + | right := addMoveTabTestSession(d, "right", "right-stays") | |||
| 386 | + | rightMoved := newTabWithStableID("right-moved", "right-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23}) | |||
| 387 | + | rightMoved.ctx, rightMoved.cancel = context.WithCancel(right.ctx) | |||
| 388 | + | publishTiledPaneOwners(right, rightMoved) | |||
| 389 | + | right.tabs = append(right.tabs, rightMoved) | |||
| 390 | + | ||||
| 391 | + | start := make(chan struct{}) | |||
| 392 | + | done := make(chan error, 2) | |||
| 393 | + | go func() { | |||
| 394 | + | <-start | |||
| 395 | + | done <- d.moveTab(moveTabRequest{Source: moveSessionLocator{ID: left.id, Incarnation: left.incarnation}, SourceTabID: domain.TabStableID("left-moved"), Destination: moveSessionLocator{ID: right.id, Incarnation: right.incarnation}}) | |||
| 396 | + | }() | |||
| 397 | + | go func() { | |||
| 398 | + | <-start | |||
| 399 | + | done <- d.moveTab(moveTabRequest{Source: moveSessionLocator{ID: right.id, Incarnation: right.incarnation}, SourceTabID: domain.TabStableID("right-moved"), Destination: moveSessionLocator{ID: left.id, Incarnation: left.incarnation}}) | |||
| 400 | + | }() | |||
| 401 | + | close(start) | |||
| 402 | + | for range 2 { | |||
| 403 | + | select { | |||
| 404 | + | case err := <-done: | |||
| 405 | + | require.NoError(t, err) | |||
| 406 | + | case <-time.After(5 * time.Second): | |||
| 407 | + | t.Fatal("opposite-direction move tabs deadlocked") | |||
| 408 | + | } | |||
| 409 | + | } | |||
| 410 | + | } | |||
| 411 | + | ||||
| 412 | + | type moveTabPurgeCatalogue struct { | |||
| 413 | + | ports.Catalogue | |||
| 414 | + | mu sync.Mutex | |||
| 415 | + | records map[string]domain.CatalogueRecord | |||
| 416 | + | events []string | |||
| 417 | + | } | |||
| 418 | + | ||||
| 419 | + | func (c *moveTabPurgeCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) { | |||
| 420 | + | c.mu.Lock() | |||
| 421 | + | defer c.mu.Unlock() | |||
| 422 | + | record, ok := c.records[name] | |||
| 423 | + | return record, ok, nil | |||
| 424 | + | } | |||
| 425 | + | ||||
| 426 | + | func (c *moveTabPurgeCatalogue) Delete(name string) error { | |||
| 427 | + | c.mu.Lock() | |||
| 428 | + | defer c.mu.Unlock() | |||
| 429 | + | delete(c.records, name) | |||
| 430 | + | c.events = append(c.events, "catalogue-delete:"+name) | |||
| 431 | + | return nil | |||
| 432 | + | } | |||
| 433 | + | ||||
| 434 | + | func (c *moveTabPurgeCatalogue) UpdateMetadata(update domain.CatalogueMetadataUpdate) error { | |||
| 435 | + | c.mu.Lock() | |||
| 436 | + | defer c.mu.Unlock() | |||
| 437 | + | c.events = append(c.events, "metadata:"+update.Name) | |||
| 438 | + | return nil | |||
| 439 | + | } | |||
| 440 | + | ||||
| 441 | + | type moveTabPurgeRepository struct { | |||
| 442 | + | noOpSnapshotRepository | |||
| 443 | + | d *Daemon | |||
| 444 | + | source *session | |||
| 445 | + | destination *session | |||
| 446 | + | moved *tab | |||
| 447 | + | pane *pane | |||
| 448 | + | client *attachedClient | |||
| 449 | + | catalogue *moveTabPurgeCatalogue | |||
| 450 | + | err error | |||
| 451 | + | deleted []domain.IncarnationID | |||
| 452 | + | outside bool | |||
| 453 | + | dirty bool | |||
| 454 | + | } | |||
| 455 | + | ||||
| 456 | + | func (r *moveTabPurgeRepository) DeleteIncarnation(_ context.Context, id domain.IncarnationID) error { | |||
| 457 | + | r.deleted = append(r.deleted, id) | |||
| 458 | + | daemonUnlocked := r.d.mu.TryLock() | |||
| 459 | + | routingUnlocked := r.d.notices.routingMu.TryLock() | |||
| 460 | + | sourceUnlocked := r.source.mu.TryLock() | |||
| 461 | + | destinationUnlocked := r.destination.mu.TryLock() | |||
| 462 | + | tabUnlocked := r.moved.mu.TryLock() | |||
| 463 | + | paneUnlocked := r.pane.mu.TryLock() | |||
| 464 | + | sourceFenceUnlocked := r.source.layoutApplyMu.TryLock() | |||
| 465 | + | destinationFenceUnlocked := r.destination.layoutApplyMu.TryLock() | |||
| 466 | + | tabFenceUnlocked := r.moved.layoutApplyMu.TryLock() | |||
| 467 | + | roleUnlocked := r.client.roleEffects.mu.TryLock() | |||
| 468 | + | roleStable := roleUnlocked && r.client.roleEffects.phase == roleEffectsStable | |||
| 469 | + | r.outside = daemonUnlocked && routingUnlocked && sourceUnlocked && destinationUnlocked && tabUnlocked && paneUnlocked && | |||
| 470 | + | sourceFenceUnlocked && destinationFenceUnlocked && tabFenceUnlocked && roleStable | |||
| 471 | + | if roleUnlocked { | |||
| 472 | + | r.client.roleEffects.mu.Unlock() | |||
| 473 | + | } | |||
| 474 | + | if tabFenceUnlocked { | |||
| 475 | + | r.moved.layoutApplyMu.Unlock() | |||
| 476 | + | } | |||
| 477 | + | if destinationFenceUnlocked { | |||
| 478 | + | r.destination.layoutApplyMu.Unlock() | |||
| 479 | + | } | |||
| 480 | + | if sourceFenceUnlocked { | |||
| 481 | + | r.source.layoutApplyMu.Unlock() | |||
| 482 | + | } | |||
| 483 | + | if paneUnlocked { | |||
| 484 | + | r.pane.mu.Unlock() | |||
| 485 | + | } | |||
| 486 | + | if tabUnlocked { | |||
| 487 | + | r.moved.mu.Unlock() | |||
| 488 | + | } | |||
| 489 | + | if destinationUnlocked { | |||
| 490 | + | r.destination.mu.Unlock() | |||
| 491 | + | } | |||
| 492 | + | if sourceUnlocked { | |||
| 493 | + | r.source.mu.Unlock() | |||
| 494 | + | } | |||
| 495 | + | if routingUnlocked { | |||
| 496 | + | r.d.notices.routingMu.Unlock() | |||
| 497 | + | } | |||
| 498 | + | if daemonUnlocked { | |||
| 499 | + | r.d.mu.Unlock() | |||
| 500 | + | } | |||
| 501 | + | r.destination.snapshotMu.Lock() | |||
| 502 | + | r.dirty = r.destination.snapDirty.Load() && r.destination.snapshotGeneration > 0 | |||
| 503 | + | r.destination.snapshotMu.Unlock() | |||
| 504 | + | r.catalogue.mu.Lock() | |||
| 505 | + | r.catalogue.events = append(r.catalogue.events, "repository-delete") | |||
| 506 | + | r.catalogue.mu.Unlock() | |||
| 507 | + | return r.err | |||
| 508 | + | } | |||
| 509 | + | ||||
| 510 | + | func TestMoveTabFinalNamedSourcePurgesAfterDestinationAdmissionAndFailureDoesNotRollback(t *testing.T) { | |||
| 511 | + | d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 512 | + | require.NotNil(t, d.attachCoordinator(source, nil, client, true)) | |||
| 513 | + | source.incarnation = domain.IncarnationID{4} | |||
| 514 | + | moved := source.tabs[0] | |||
| 515 | + | moved.stableID = "moved-tab" | |||
| 516 | + | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |||
| 517 | + | destination.ephemeral = false | |||
| 518 | + | destination.snapEligible.Store(true) | |||
| 519 | + | ||||
| 520 | + | catalogue := &moveTabPurgeCatalogue{records: map[string]domain.CatalogueRecord{ | |||
| 521 | + | source.name: {Name: source.name, IncarnationID: source.incarnation}, | |||
| 522 | + | destination.name: {Name: destination.name, IncarnationID: destination.incarnation}, | |||
| 523 | + | }} | |||
| 524 | + | failure := errors.New("repository delete failed") | |||
| 525 | + | repository := &moveTabPurgeRepository{ | |||
| 526 | + | d: d, source: source, destination: destination, moved: moved, pane: moved.focusedPane(), client: client, | |||
| 527 | + | catalogue: catalogue, err: failure, | |||
| 528 | + | } | |||
| 529 | + | WithCatalogue(catalogue, nil)(d) | |||
| 530 | + | WithSnapshotRepository(repository)(d) | |||
| 531 | + | WithRecoveryCoordinator(recoveryusecase.NewCoordinator(catalogue, repository, nil))(d) | |||
| 532 | + | ||||
| 533 | + | require.NoError(t, d.moveTab(moveTabRequest{ | |||
| 534 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID), | |||
| 535 | + | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |||
| 536 | + | })) | |||
| 537 | + | ||||
| 538 | + | require.Equal(t, []domain.IncarnationID{source.incarnation}, repository.deleted) | |||
| 539 | + | require.True(t, repository.outside, "repository purge must run outside architecture and owner locks") | |||
| 540 | + | require.True(t, repository.dirty, "destination snapshot dirtiness must be admitted before purge") | |||
| 541 | + | catalogue.mu.Lock() | |||
| 542 | + | require.Equal(t, []string{"metadata:destination", "catalogue-delete:work", "repository-delete"}, catalogue.events) | |||
| 543 | + | catalogue.mu.Unlock() | |||
| 544 | + | require.Same(t, moved, destination.tabs[len(destination.tabs)-1], "best-effort purge failure cannot roll back committed membership") | |||
| 545 | + | require.Same(t, destination, moved.focusedPane().ownerSnapshot().session) | |||
| 546 | + | } | |||
| 547 | + | ||||
| 548 | + | func addMoveTabTestSession(d *Daemon, id domain.SessionID, tabID string) *session { | |||
| 549 | + | ctx, cancel := context.WithCancel(d.serveCtx) | |||
| 550 | + | tb := newTabWithStableID(tabID, tabID+"-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23}) | |||
| 551 | + | tb.ctx, tb.cancel = context.WithCancel(ctx) | |||
| 552 | + | sess := &session{id: id, name: string(id), incarnation: domain.IncarnationID{5}, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0} | |||
| 553 | + | publishTiledPaneOwners(sess, tb) | |||
| 554 | + | d.mu.Lock() | |||
| 555 | + | d.sessions[sess.id] = sess | |||
| 556 | + | d.mu.Unlock() | |||
| 557 | + | return sess | |||
| 558 | + | } | |||