refactor(daemon): fence transferable pane resizes
- commit
- f0438ea40a692fb01306b6ef6d7fd664efbb3db1
- author
- brice · · 2026-07-28 04:46 UTC
- parent
- 64cc0d8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +1607 -193
M internal/usecase/daemon/copymode_test.go +2 -1
| @@ -845,9 +845,10 @@ func TestFloatingExitClearsCopyModeBeforeRepaint(t *testing.T) { | |||
| 845 | 845 | awaitFrame(t, sends, ports.MsgOutput) | |
| 846 | 846 | tb.mu.Lock() | |
| 847 | 847 | generation := tb.floating.generation | |
| 848 | + | publishPaneOwner(fp, sess, tb, generation) | |
| 848 | 849 | tb.mu.Unlock() | |
| 849 | 850 | ||
| 850 | - | d.reapFloating(sess, tb, fp, generation) | |
| 851 | + | d.reapInstalledFloating(fp) | |
| 851 | 852 | ||
| 852 | 853 | ac.overlays.copyMu.Lock() | |
| 853 | 854 | require.Nil(t, ac.overlays.copyMode) | |
| @@ -845,9 +845,10 @@ func TestFloatingExitClearsCopyModeBeforeRepaint(t *testing.T) { | |||||
| 845 | awaitFrame(t, sends, ports.MsgOutput) | 845 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 846 | tb.mu.Lock() | 846 | tb.mu.Lock() | ||
| 847 | generation := tb.floating.generation | 847 | generation := tb.floating.generation | ||
| 848 | + | publishPaneOwner(fp, sess, tb, generation) | |||
| 848 | tb.mu.Unlock() | 849 | tb.mu.Unlock() | ||
| 849 | 850 | ||||
| 850 | - | d.reapFloating(sess, tb, fp, generation) | 851 | + | d.reapInstalledFloating(fp) |
| 851 | 852 | ||||
| 852 | ac.overlays.copyMu.Lock() | 853 | ac.overlays.copyMu.Lock() | ||
| 853 | require.Nil(t, ac.overlays.copyMode) | 854 | require.Nil(t, ac.overlays.copyMode) | ||
M internal/usecase/daemon/daemon.go +4 -0
| @@ -125,6 +125,10 @@ type Daemon struct { | |||
| 125 | 125 | // and before coordinator epoch admission. It is a deterministic regression | |
| 126 | 126 | // seam for stale resize publication. | |
| 127 | 127 | beforeSessionResizePublication func() | |
| 128 | + | // beforeResizeOwnerPostEffect pauses after a resize's optimistic owner check | |
| 129 | + | // and immediately before a post-commit effect is published. Tests use it to | |
| 130 | + | // move a pane through the ordered resize fences at the former TOCTOU window. | |
| 131 | + | beforeResizeOwnerPostEffect func(resizeOwnerPostEffect) | |
| 128 | 132 | // afterSnatchOverlayFamily is a deterministic structural lock seam. It runs | |
| 129 | 133 | // after one overlay-family mutex is released and before the next is taken. | |
| 130 | 134 | afterSnatchOverlayFamily func(string) | |
| @@ -125,6 +125,10 @@ type Daemon struct { | |||||
| 125 | // and before coordinator epoch admission. It is a deterministic regression | 125 | // and before coordinator epoch admission. It is a deterministic regression | ||
| 126 | // seam for stale resize publication. | 126 | // seam for stale resize publication. | ||
| 127 | beforeSessionResizePublication func() | 127 | beforeSessionResizePublication func() | ||
| 128 | + | // beforeResizeOwnerPostEffect pauses after a resize's optimistic owner check | |||
| 129 | + | // and immediately before a post-commit effect is published. Tests use it to | |||
| 130 | + | // move a pane through the ordered resize fences at the former TOCTOU window. | |||
| 131 | + | beforeResizeOwnerPostEffect func(resizeOwnerPostEffect) | |||
| 128 | // afterSnatchOverlayFamily is a deterministic structural lock seam. It runs | 132 | // afterSnatchOverlayFamily is a deterministic structural lock seam. It runs | ||
| 129 | // after one overlay-family mutex is released and before the next is taken. | 133 | // after one overlay-family mutex is released and before the next is taken. | ||
| 130 | afterSnatchOverlayFamily func(string) | 134 | afterSnatchOverlayFamily func(string) | ||
M internal/usecase/daemon/floating.go +48 -20
| @@ -42,6 +42,14 @@ func (tb *tab) beginFloatingWarmLocked(desiredVisible bool) uint64 { | |||
| 42 | 42 | return tb.floating.generation | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | + | // floatingTransferableLocked reports whether a tab's floating lifecycle can | |
| 46 | + | // cross an ownership commit. A warming Open remains registered to its source | |
| 47 | + | // session until installation or failure, so moves must reject only that state. | |
| 48 | + | // The caller must hold tb.mu. | |
| 49 | + | func (tb *tab) floatingTransferableLocked() bool { | |
| 50 | + | return tb != nil && tb.floating.state != floatingWarming | |
| 51 | + | } | |
| 52 | + | ||
| 45 | 53 | // toggleFloatingLocked applies the user action. Its result says whether a new | |
| 46 | 54 | // PTY launch is required and returns the generation associated with that launch | |
| 47 | 55 | // or current slot. | |
| @@ -417,8 +425,10 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau | |||
| 417 | 425 | } | |
| 418 | 426 | ownedByPane = true | |
| 419 | 427 | // The reader may run as soon as install returns; make its exit policy | |
| 420 | - | // immutable before publishing the pane to the slot. | |
| 421 | - | p.onExit = func() { d.reapFloating(sess, tb, p, generation) } | |
| 428 | + | // immutable before publishing the pane to the slot. Installed panes resolve | |
| 429 | + | // their owner dynamically so a transferred tab never reaps through source | |
| 430 | + | // session pointers captured by its launch. | |
| 431 | + | p.onExit = func() { d.reapInstalledFloating(p) } | |
| 422 | 432 | d.installFloating(sess, tb, p, generation) | |
| 423 | 433 | } | |
| 424 | 434 | ||
| @@ -478,26 +488,44 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin | |||
| 478 | 488 | } | |
| 479 | 489 | } | |
| 480 | 490 | ||
| 481 | - | // reapFloating ignores old readers and restores the background only when the | |
| 482 | - | // exiting popup was visible. | |
| 483 | - | func (d *Daemon) reapFloating(sess *session, tb *tab, p *pane, generation uint64) { | |
| 484 | - | tb.mu.Lock() | |
| 485 | - | visible := tb.floating.state == floatingVisible && tb.floating.pane == p && tb.floating.generation == generation | |
| 486 | - | cleared := tb.clearFloatingLocked(p, generation) | |
| 487 | - | tb.mu.Unlock() | |
| 488 | - | if !cleared { | |
| 491 | + | // reapInstalledFloating resolves the pane's current immutable owner on every | |
| 492 | + | // attempt. If ownership changes before the tab lock is acquired, retrying the | |
| 493 | + | // lookup routes exit through the destination. The owner pointer and floating | |
| 494 | + | // slot generation are then checked together under tb.mu so an old exit can | |
| 495 | + | // never clear a reused slot. | |
| 496 | + | func (d *Daemon) reapInstalledFloating(p *pane) { | |
| 497 | + | if p == nil { | |
| 489 | 498 | return | |
| 490 | 499 | } | |
| 491 | - | closeFloatingPane(p) | |
| 492 | - | sess.mu.Lock() | |
| 493 | - | ac := sess.client | |
| 494 | - | sess.mu.Unlock() | |
| 495 | - | if ac != nil { | |
| 496 | - | ac.pruneCaptureFrames(p) | |
| 497 | - | } | |
| 498 | - | copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p) | |
| 499 | - | if ac != nil && (visible || copyCleared) { | |
| 500 | - | d.invalidateRender(sess, ac, true, "floating.go") | |
| 500 | + | for { | |
| 501 | + | owner := p.ownerSnapshot() | |
| 502 | + | if owner == nil || owner.session == nil || owner.tab == nil || owner.floatingSlotGeneration == 0 { | |
| 503 | + | return | |
| 504 | + | } | |
| 505 | + | sess, tb, generation := owner.session, owner.tab, owner.floatingSlotGeneration | |
| 506 | + | tb.mu.Lock() | |
| 507 | + | if p.ownerSnapshot() != owner { | |
| 508 | + | tb.mu.Unlock() | |
| 509 | + | continue | |
| 510 | + | } | |
| 511 | + | visible := tb.floating.state == floatingVisible && tb.floating.pane == p && tb.floating.generation == generation | |
| 512 | + | cleared := tb.clearFloatingLocked(p, generation) | |
| 513 | + | tb.mu.Unlock() | |
| 514 | + | if !cleared { | |
| 515 | + | return | |
| 516 | + | } | |
| 517 | + | closeFloatingPane(p) | |
| 518 | + | sess.mu.Lock() | |
| 519 | + | ac := sess.client | |
| 520 | + | sess.mu.Unlock() | |
| 521 | + | if ac != nil { | |
| 522 | + | ac.pruneCaptureFrames(p) | |
| 523 | + | } | |
| 524 | + | copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p) | |
| 525 | + | if ac != nil && (visible || copyCleared) { | |
| 526 | + | d.invalidateRender(sess, ac, true, "floating.go") | |
| 527 | + | } | |
| 528 | + | return | |
| 501 | 529 | } | |
| 502 | 530 | } | |
| 503 | 531 | ||
| @@ -42,6 +42,14 @@ func (tb *tab) beginFloatingWarmLocked(desiredVisible bool) uint64 { | |||||
| 42 | return tb.floating.generation | 42 | return tb.floating.generation | ||
| 43 | } | 43 | } | ||
| 44 | 44 | ||||
| 45 | + | // floatingTransferableLocked reports whether a tab's floating lifecycle can | |||
| 46 | + | // cross an ownership commit. A warming Open remains registered to its source | |||
| 47 | + | // session until installation or failure, so moves must reject only that state. | |||
| 48 | + | // The caller must hold tb.mu. | |||
| 49 | + | func (tb *tab) floatingTransferableLocked() bool { | |||
| 50 | + | return tb != nil && tb.floating.state != floatingWarming | |||
| 51 | + | } | |||
| 52 | + | ||||
| 45 | // toggleFloatingLocked applies the user action. Its result says whether a new | 53 | // toggleFloatingLocked applies the user action. Its result says whether a new | ||
| 46 | // PTY launch is required and returns the generation associated with that launch | 54 | // PTY launch is required and returns the generation associated with that launch | ||
| 47 | // or current slot. | 55 | // or current slot. | ||
| @@ -417,8 +425,10 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau | |||||
| 417 | } | 425 | } | ||
| 418 | ownedByPane = true | 426 | ownedByPane = true | ||
| 419 | // The reader may run as soon as install returns; make its exit policy | 427 | // The reader may run as soon as install returns; make its exit policy | ||
| 420 | - | // immutable before publishing the pane to the slot. | 428 | + | // immutable before publishing the pane to the slot. Installed panes resolve |
| 421 | - | p.onExit = func() { d.reapFloating(sess, tb, p, generation) } | 429 | + | // their owner dynamically so a transferred tab never reaps through source |
| 430 | + | // session pointers captured by its launch. | |||
| 431 | + | p.onExit = func() { d.reapInstalledFloating(p) } | |||
| 422 | d.installFloating(sess, tb, p, generation) | 432 | d.installFloating(sess, tb, p, generation) | ||
| 423 | } | 433 | } | ||
| 424 | 434 | ||||
| @@ -478,26 +488,44 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin | |||||
| 478 | } | 488 | } | ||
| 479 | } | 489 | } | ||
| 480 | 490 | ||||
| 481 | - | // reapFloating ignores old readers and restores the background only when the | 491 | + | // reapInstalledFloating resolves the pane's current immutable owner on every |
| 482 | - | // exiting popup was visible. | 492 | + | // attempt. If ownership changes before the tab lock is acquired, retrying the |
| 483 | - | func (d *Daemon) reapFloating(sess *session, tb *tab, p *pane, generation uint64) { | 493 | + | // lookup routes exit through the destination. The owner pointer and floating |
| 484 | - | tb.mu.Lock() | 494 | + | // slot generation are then checked together under tb.mu so an old exit can |
| 485 | - | visible := tb.floating.state == floatingVisible && tb.floating.pane == p && tb.floating.generation == generation | 495 | + | // never clear a reused slot. |
| 486 | - | cleared := tb.clearFloatingLocked(p, generation) | 496 | + | func (d *Daemon) reapInstalledFloating(p *pane) { |
| 487 | - | tb.mu.Unlock() | 497 | + | if p == nil { |
| 488 | - | if !cleared { | |||
| 489 | return | 498 | return | ||
| 490 | } | 499 | } | ||
| 491 | - | closeFloatingPane(p) | 500 | + | for { |
| 492 | - | sess.mu.Lock() | 501 | + | owner := p.ownerSnapshot() |
| 493 | - | ac := sess.client | 502 | + | if owner == nil || owner.session == nil || owner.tab == nil || owner.floatingSlotGeneration == 0 { |
| 494 | - | sess.mu.Unlock() | 503 | + | return |
| 495 | - | if ac != nil { | 504 | + | } |
| 496 | - | ac.pruneCaptureFrames(p) | 505 | + | sess, tb, generation := owner.session, owner.tab, owner.floatingSlotGeneration |
| 497 | - | } | 506 | + | tb.mu.Lock() |
| 498 | - | copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p) | 507 | + | if p.ownerSnapshot() != owner { |
| 499 | - | if ac != nil && (visible || copyCleared) { | 508 | + | tb.mu.Unlock() |
| 500 | - | d.invalidateRender(sess, ac, true, "floating.go") | 509 | + | continue |
| 510 | + | } | |||
| 511 | + | visible := tb.floating.state == floatingVisible && tb.floating.pane == p && tb.floating.generation == generation | |||
| 512 | + | cleared := tb.clearFloatingLocked(p, generation) | |||
| 513 | + | tb.mu.Unlock() | |||
| 514 | + | if !cleared { | |||
| 515 | + | return | |||
| 516 | + | } | |||
| 517 | + | closeFloatingPane(p) | |||
| 518 | + | sess.mu.Lock() | |||
| 519 | + | ac := sess.client | |||
| 520 | + | sess.mu.Unlock() | |||
| 521 | + | if ac != nil { | |||
| 522 | + | ac.pruneCaptureFrames(p) | |||
| 523 | + | } | |||
| 524 | + | copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p) | |||
| 525 | + | if ac != nil && (visible || copyCleared) { | |||
| 526 | + | d.invalidateRender(sess, ac, true, "floating.go") | |||
| 527 | + | } | |||
| 528 | + | return | |||
| 501 | } | 529 | } | ||
| 502 | } | 530 | } | ||
| 503 | 531 | ||||
M internal/usecase/daemon/floating_open_test.go +10 -7
| @@ -73,23 +73,26 @@ func (f *contextAwareFloatingFactory) Open(ctx context.Context, _ string, _ []st | |||
| 73 | 73 | ||
| 74 | 74 | func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) { | |
| 75 | 75 | teardowns := []struct { | |
| 76 | - | name string | |
| 77 | - | teardown func(*Daemon, *session, *tab) | |
| 76 | + | name string | |
| 77 | + | desiredVisible bool | |
| 78 | + | teardown func(*Daemon, *session, *tab) | |
| 78 | 79 | }{ | |
| 79 | 80 | { | |
| 80 | - | name: "floating pane", | |
| 81 | + | name: "hidden floating pane", | |
| 81 | 82 | teardown: func(d *Daemon, _ *session, tb *tab) { | |
| 82 | 83 | d.teardownFloating(tb, nil) | |
| 83 | 84 | }, | |
| 84 | 85 | }, | |
| 85 | 86 | { | |
| 86 | - | name: "tab", | |
| 87 | + | name: "visible tab", | |
| 88 | + | desiredVisible: true, | |
| 87 | 89 | teardown: func(_ *Daemon, _ *session, tb *tab) { | |
| 88 | 90 | tb.closeAllPanes() | |
| 89 | 91 | }, | |
| 90 | 92 | }, | |
| 91 | 93 | { | |
| 92 | - | name: "session", | |
| 94 | + | name: "visible session", | |
| 95 | + | desiredVisible: true, | |
| 93 | 96 | teardown: func(d *Daemon, sess *session, _ *tab) { | |
| 94 | 97 | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |
| 95 | 98 | }, | |
| @@ -109,7 +112,7 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) { | |||
| 109 | 112 | d.sessions[sess.id] = sess | |
| 110 | 113 | d.mu.Unlock() | |
| 111 | 114 | tb.mu.Lock() | |
| 112 | - | generation := tb.beginFloatingWarmLocked(true) | |
| 115 | + | generation := tb.beginFloatingWarmLocked(tt.desiredVisible) | |
| 113 | 116 | tb.mu.Unlock() | |
| 114 | 117 | ||
| 115 | 118 | d.openAndInstallFloating(sess, tb, floatingLaunchSpec{ | |
| @@ -129,7 +132,7 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) { | |||
| 129 | 132 | sess.mu.Lock() | |
| 130 | 133 | sess.client = &attachedClient{} | |
| 131 | 134 | sess.mu.Unlock() | |
| 132 | - | require.True(t, d.paneRenderable(sess, tb, floating), "installed visible popup must remain renderable") | |
| 135 | + | require.Equal(t, tt.desiredVisible, d.paneRenderable(sess, tb, floating), "installed popup renderability must follow its retained visibility") | |
| 133 | 136 | sess.mu.Lock() | |
| 134 | 137 | sess.client = nil | |
| 135 | 138 | sess.mu.Unlock() | |
| @@ -73,23 +73,26 @@ func (f *contextAwareFloatingFactory) Open(ctx context.Context, _ string, _ []st | |||||
| 73 | 73 | ||||
| 74 | func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) { | 74 | func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) { | ||
| 75 | teardowns := []struct { | 75 | teardowns := []struct { | ||
| 76 | - | name string | 76 | + | name string |
| 77 | - | teardown func(*Daemon, *session, *tab) | 77 | + | desiredVisible bool |
| 78 | + | teardown func(*Daemon, *session, *tab) | |||
| 78 | }{ | 79 | }{ | ||
| 79 | { | 80 | { | ||
| 80 | - | name: "floating pane", | 81 | + | name: "hidden floating pane", |
| 81 | teardown: func(d *Daemon, _ *session, tb *tab) { | 82 | teardown: func(d *Daemon, _ *session, tb *tab) { | ||
| 82 | d.teardownFloating(tb, nil) | 83 | d.teardownFloating(tb, nil) | ||
| 83 | }, | 84 | }, | ||
| 84 | }, | 85 | }, | ||
| 85 | { | 86 | { | ||
| 86 | - | name: "tab", | 87 | + | name: "visible tab", |
| 88 | + | desiredVisible: true, | |||
| 87 | teardown: func(_ *Daemon, _ *session, tb *tab) { | 89 | teardown: func(_ *Daemon, _ *session, tb *tab) { | ||
| 88 | tb.closeAllPanes() | 90 | tb.closeAllPanes() | ||
| 89 | }, | 91 | }, | ||
| 90 | }, | 92 | }, | ||
| 91 | { | 93 | { | ||
| 92 | - | name: "session", | 94 | + | name: "visible session", |
| 95 | + | desiredVisible: true, | |||
| 93 | teardown: func(d *Daemon, sess *session, _ *tab) { | 96 | teardown: func(d *Daemon, sess *session, _ *tab) { | ||
| 94 | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | 97 | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | ||
| 95 | }, | 98 | }, | ||
| @@ -109,7 +112,7 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) { | |||||
| 109 | d.sessions[sess.id] = sess | 112 | d.sessions[sess.id] = sess | ||
| 110 | d.mu.Unlock() | 113 | d.mu.Unlock() | ||
| 111 | tb.mu.Lock() | 114 | tb.mu.Lock() | ||
| 112 | - | generation := tb.beginFloatingWarmLocked(true) | 115 | + | generation := tb.beginFloatingWarmLocked(tt.desiredVisible) |
| 113 | tb.mu.Unlock() | 116 | tb.mu.Unlock() | ||
| 114 | 117 | ||||
| 115 | d.openAndInstallFloating(sess, tb, floatingLaunchSpec{ | 118 | d.openAndInstallFloating(sess, tb, floatingLaunchSpec{ | ||
| @@ -129,7 +132,7 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) { | |||||
| 129 | sess.mu.Lock() | 132 | sess.mu.Lock() | ||
| 130 | sess.client = &attachedClient{} | 133 | sess.client = &attachedClient{} | ||
| 131 | sess.mu.Unlock() | 134 | sess.mu.Unlock() | ||
| 132 | - | require.True(t, d.paneRenderable(sess, tb, floating), "installed visible popup must remain renderable") | 135 | + | require.Equal(t, tt.desiredVisible, d.paneRenderable(sess, tb, floating), "installed popup renderability must follow its retained visibility") |
| 133 | sess.mu.Lock() | 136 | sess.mu.Lock() | ||
| 134 | sess.client = nil | 137 | sess.client = nil | ||
| 135 | sess.mu.Unlock() | 138 | sess.mu.Unlock() | ||
M internal/usecase/daemon/floating_test.go +83 -3
| @@ -131,6 +131,30 @@ func TestFloatingSlotTransitions(t *testing.T) { | |||
| 131 | 131 | } | |
| 132 | 132 | } | |
| 133 | 133 | ||
| 134 | + | func TestFloatingTransferabilityRejectsOnlyWarmingLaunch(t *testing.T) { | |
| 135 | + | tests := []struct { | |
| 136 | + | name string | |
| 137 | + | state floatingState | |
| 138 | + | want bool | |
| 139 | + | }{ | |
| 140 | + | {name: "uninitialized", state: floatingUninitialized, want: true}, | |
| 141 | + | {name: "warming", state: floatingWarming, want: false}, | |
| 142 | + | {name: "hidden installed", state: floatingHidden, want: true}, | |
| 143 | + | {name: "visible installed", state: floatingVisible, want: true}, | |
| 144 | + | } | |
| 145 | + | for _, tt := range tests { | |
| 146 | + | t.Run(tt.name, func(t *testing.T) { | |
| 147 | + | tb := &tab{floating: floatingSlot{state: tt.state, generation: 7}} | |
| 148 | + | tb.mu.Lock() | |
| 149 | + | got := tb.floatingTransferableLocked() | |
| 150 | + | tb.mu.Unlock() | |
| 151 | + | require.Equal(t, tt.want, got) | |
| 152 | + | require.Equal(t, tt.state, tb.floating.state, "validation must not change launch state") | |
| 153 | + | require.Equal(t, uint64(7), tb.floating.generation, "validation must preserve retry generation") | |
| 154 | + | }) | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 134 | 158 | func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testing.T) { | |
| 135 | 159 | pty := portsmocks.NewMockPTY(t) | |
| 136 | 160 | readerStarted := make(chan struct{}) | |
| @@ -272,28 +296,83 @@ func TestFloatingLifecycleStaleSuccessAndOldExitCannotReplaceCurrentSlot(t *test | |||
| 272 | 296 | second := &pane{} | |
| 273 | 297 | tb.mu.Lock() | |
| 274 | 298 | g1 := tb.beginFloatingWarmLocked(true) | |
| 299 | + | publishPaneOwner(first, sess, tb, g1) | |
| 275 | 300 | require.True(t, tb.installFloatingLocked(first, g1)) | |
| 276 | 301 | g2 := tb.clearFloatingLocked(first, g1) | |
| 277 | 302 | require.True(t, g2) | |
| 278 | 303 | generation := tb.beginFloatingWarmLocked(true) | |
| 279 | 304 | require.True(t, tb.installFloatingLocked(second, generation)) | |
| 280 | 305 | tb.mu.Unlock() | |
| 281 | - | d.reapFloating(sess, tb, first, g1) | |
| 306 | + | d.reapInstalledFloating(first) | |
| 282 | 307 | tb.mu.Lock() | |
| 283 | 308 | require.Same(t, second, tb.floating.pane, "old EOF must not clear replacement") | |
| 284 | 309 | tb.mu.Unlock() | |
| 285 | 310 | } | |
| 286 | 311 | ||
| 312 | + | func TestFloatingExitUsesCurrentOwnerAfterTabTransfer(t *testing.T) { | |
| 313 | + | factory := &lifetimePTYFactory{} | |
| 314 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 315 | + | tb := newFloatingTestTab(t) | |
| 316 | + | source := &session{id: "source", name: "source", tabs: []*tab{tb}, ctx: t.Context()} | |
| 317 | + | destination := &session{id: "destination", name: "destination", tabs: []*tab{tb}, ctx: t.Context()} | |
| 318 | + | ||
| 319 | + | tb.mu.Lock() | |
| 320 | + | generation := tb.beginFloatingWarmLocked(false) | |
| 321 | + | tb.mu.Unlock() | |
| 322 | + | d.openAndInstallFloating(source, tb, floatingLaunchSpec{ | |
| 323 | + | sessionName: source.name, | |
| 324 | + | size: domain.Size{Cols: 20, Rows: 8}, | |
| 325 | + | geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}}, | |
| 326 | + | paneStableID: "p_floating", | |
| 327 | + | parentCtx: tb.ctx, | |
| 328 | + | }, generation) | |
| 329 | + | ||
| 330 | + | tb.mu.Lock() | |
| 331 | + | floating := tb.floating.pane | |
| 332 | + | tb.mu.Unlock() | |
| 333 | + | require.NotNil(t, floating) | |
| 334 | + | tb.mu.Lock() | |
| 335 | + | floating.mu.Lock() | |
| 336 | + | floating.publishOwnerLocked(destination, tb, generation) | |
| 337 | + | floating.mu.Unlock() | |
| 338 | + | tb.mu.Unlock() | |
| 339 | + | ||
| 340 | + | sourceClient := &attachedClient{captureFrames: map[*pane]capturedPaneRenderState{floating: {}}} | |
| 341 | + | sourceClient.initOverlays() | |
| 342 | + | destinationClient := &attachedClient{captureFrames: map[*pane]capturedPaneRenderState{floating: {}}} | |
| 343 | + | destinationClient.initOverlays() | |
| 344 | + | source.client = sourceClient | |
| 345 | + | destination.client = destinationClient | |
| 346 | + | ||
| 347 | + | floating.onExit() | |
| 348 | + | d.sessWg.Wait() | |
| 349 | + | ||
| 350 | + | tb.mu.Lock() | |
| 351 | + | require.Equal(t, floatingUninitialized, tb.floating.state) | |
| 352 | + | require.Nil(t, tb.floating.pane) | |
| 353 | + | tb.mu.Unlock() | |
| 354 | + | sourceClient.sendMu.Lock() | |
| 355 | + | _, sourceRetained := sourceClient.captureFrames[floating] | |
| 356 | + | sourceClient.sendMu.Unlock() | |
| 357 | + | destinationClient.sendMu.Lock() | |
| 358 | + | _, destinationRetained := destinationClient.captureFrames[floating] | |
| 359 | + | destinationClient.sendMu.Unlock() | |
| 360 | + | require.True(t, sourceRetained, "exit must not clean up the retired source owner") | |
| 361 | + | require.False(t, destinationRetained, "exit must clean up the current destination owner") | |
| 362 | + | } | |
| 363 | + | ||
| 287 | 364 | func TestFloatingReapAndTeardownCloseMatchingPaneOnce(t *testing.T) { | |
| 288 | 365 | pty, _ := newBlockingPTY(t) | |
| 289 | 366 | tb := newFloatingTestTab(t) | |
| 290 | 367 | p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10}) | |
| 368 | + | sess := &session{} | |
| 291 | 369 | tb.mu.Lock() | |
| 292 | 370 | g := tb.beginFloatingWarmLocked(true) | |
| 371 | + | publishPaneOwner(p, sess, tb, g) | |
| 293 | 372 | require.True(t, tb.installFloatingLocked(p, g)) | |
| 294 | 373 | tb.mu.Unlock() | |
| 295 | 374 | d := newTestDaemon(t, nil, stubClock{}) | |
| 296 | - | d.reapFloating(&session{}, tb, p, g) | |
| 375 | + | d.reapInstalledFloating(p) | |
| 297 | 376 | d.teardownFloating(tb, nil) | |
| 298 | 377 | pty.AssertNumberOfCalls(t, "Close", 1) | |
| 299 | 378 | } | |
| @@ -358,6 +437,7 @@ func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) { | |||
| 358 | 437 | }).Once() | |
| 359 | 438 | floating := newPane(layout.PaneID("floating"), floatingPTY, domain.Size{Cols: 20, Rows: 8}) | |
| 360 | 439 | generation := tb.beginFloatingWarmLocked(state == floatingVisible) | |
| 440 | + | publishPaneOwner(floating, sess, tb, generation) | |
| 361 | 441 | require.True(t, tb.installFloatingLocked(floating, generation)) | |
| 362 | 442 | if state == floatingHidden { | |
| 363 | 443 | require.Equal(t, floatingHidden, tb.floating.state) | |
| @@ -374,7 +454,7 @@ func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) { | |||
| 374 | 454 | ||
| 375 | 455 | reaped := make(chan struct{}) | |
| 376 | 456 | floating.onExit = func() { | |
| 377 | - | d.reapFloating(sess, tb, floating, generation) | |
| 457 | + | d.reapInstalledFloating(floating) | |
| 378 | 458 | close(reaped) | |
| 379 | 459 | } | |
| 380 | 460 | d.sessWg.Add(1) | |
| @@ -131,6 +131,30 @@ func TestFloatingSlotTransitions(t *testing.T) { | |||||
| 131 | } | 131 | } | ||
| 132 | } | 132 | } | ||
| 133 | 133 | ||||
| 134 | + | func TestFloatingTransferabilityRejectsOnlyWarmingLaunch(t *testing.T) { | |||
| 135 | + | tests := []struct { | |||
| 136 | + | name string | |||
| 137 | + | state floatingState | |||
| 138 | + | want bool | |||
| 139 | + | }{ | |||
| 140 | + | {name: "uninitialized", state: floatingUninitialized, want: true}, | |||
| 141 | + | {name: "warming", state: floatingWarming, want: false}, | |||
| 142 | + | {name: "hidden installed", state: floatingHidden, want: true}, | |||
| 143 | + | {name: "visible installed", state: floatingVisible, want: true}, | |||
| 144 | + | } | |||
| 145 | + | for _, tt := range tests { | |||
| 146 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 147 | + | tb := &tab{floating: floatingSlot{state: tt.state, generation: 7}} | |||
| 148 | + | tb.mu.Lock() | |||
| 149 | + | got := tb.floatingTransferableLocked() | |||
| 150 | + | tb.mu.Unlock() | |||
| 151 | + | require.Equal(t, tt.want, got) | |||
| 152 | + | require.Equal(t, tt.state, tb.floating.state, "validation must not change launch state") | |||
| 153 | + | require.Equal(t, uint64(7), tb.floating.generation, "validation must preserve retry generation") | |||
| 154 | + | }) | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | ||||
| 134 | func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testing.T) { | 158 | func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testing.T) { | ||
| 135 | pty := portsmocks.NewMockPTY(t) | 159 | pty := portsmocks.NewMockPTY(t) | ||
| 136 | readerStarted := make(chan struct{}) | 160 | readerStarted := make(chan struct{}) | ||
| @@ -272,28 +296,83 @@ func TestFloatingLifecycleStaleSuccessAndOldExitCannotReplaceCurrentSlot(t *test | |||||
| 272 | second := &pane{} | 296 | second := &pane{} | ||
| 273 | tb.mu.Lock() | 297 | tb.mu.Lock() | ||
| 274 | g1 := tb.beginFloatingWarmLocked(true) | 298 | g1 := tb.beginFloatingWarmLocked(true) | ||
| 299 | + | publishPaneOwner(first, sess, tb, g1) | |||
| 275 | require.True(t, tb.installFloatingLocked(first, g1)) | 300 | require.True(t, tb.installFloatingLocked(first, g1)) | ||
| 276 | g2 := tb.clearFloatingLocked(first, g1) | 301 | g2 := tb.clearFloatingLocked(first, g1) | ||
| 277 | require.True(t, g2) | 302 | require.True(t, g2) | ||
| 278 | generation := tb.beginFloatingWarmLocked(true) | 303 | generation := tb.beginFloatingWarmLocked(true) | ||
| 279 | require.True(t, tb.installFloatingLocked(second, generation)) | 304 | require.True(t, tb.installFloatingLocked(second, generation)) | ||
| 280 | tb.mu.Unlock() | 305 | tb.mu.Unlock() | ||
| 281 | - | d.reapFloating(sess, tb, first, g1) | 306 | + | d.reapInstalledFloating(first) |
| 282 | tb.mu.Lock() | 307 | tb.mu.Lock() | ||
| 283 | require.Same(t, second, tb.floating.pane, "old EOF must not clear replacement") | 308 | require.Same(t, second, tb.floating.pane, "old EOF must not clear replacement") | ||
| 284 | tb.mu.Unlock() | 309 | tb.mu.Unlock() | ||
| 285 | } | 310 | } | ||
| 286 | 311 | ||||
| 312 | + | func TestFloatingExitUsesCurrentOwnerAfterTabTransfer(t *testing.T) { | |||
| 313 | + | factory := &lifetimePTYFactory{} | |||
| 314 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 315 | + | tb := newFloatingTestTab(t) | |||
| 316 | + | source := &session{id: "source", name: "source", tabs: []*tab{tb}, ctx: t.Context()} | |||
| 317 | + | destination := &session{id: "destination", name: "destination", tabs: []*tab{tb}, ctx: t.Context()} | |||
| 318 | + | ||||
| 319 | + | tb.mu.Lock() | |||
| 320 | + | generation := tb.beginFloatingWarmLocked(false) | |||
| 321 | + | tb.mu.Unlock() | |||
| 322 | + | d.openAndInstallFloating(source, tb, floatingLaunchSpec{ | |||
| 323 | + | sessionName: source.name, | |||
| 324 | + | size: domain.Size{Cols: 20, Rows: 8}, | |||
| 325 | + | geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}}, | |||
| 326 | + | paneStableID: "p_floating", | |||
| 327 | + | parentCtx: tb.ctx, | |||
| 328 | + | }, generation) | |||
| 329 | + | ||||
| 330 | + | tb.mu.Lock() | |||
| 331 | + | floating := tb.floating.pane | |||
| 332 | + | tb.mu.Unlock() | |||
| 333 | + | require.NotNil(t, floating) | |||
| 334 | + | tb.mu.Lock() | |||
| 335 | + | floating.mu.Lock() | |||
| 336 | + | floating.publishOwnerLocked(destination, tb, generation) | |||
| 337 | + | floating.mu.Unlock() | |||
| 338 | + | tb.mu.Unlock() | |||
| 339 | + | ||||
| 340 | + | sourceClient := &attachedClient{captureFrames: map[*pane]capturedPaneRenderState{floating: {}}} | |||
| 341 | + | sourceClient.initOverlays() | |||
| 342 | + | destinationClient := &attachedClient{captureFrames: map[*pane]capturedPaneRenderState{floating: {}}} | |||
| 343 | + | destinationClient.initOverlays() | |||
| 344 | + | source.client = sourceClient | |||
| 345 | + | destination.client = destinationClient | |||
| 346 | + | ||||
| 347 | + | floating.onExit() | |||
| 348 | + | d.sessWg.Wait() | |||
| 349 | + | ||||
| 350 | + | tb.mu.Lock() | |||
| 351 | + | require.Equal(t, floatingUninitialized, tb.floating.state) | |||
| 352 | + | require.Nil(t, tb.floating.pane) | |||
| 353 | + | tb.mu.Unlock() | |||
| 354 | + | sourceClient.sendMu.Lock() | |||
| 355 | + | _, sourceRetained := sourceClient.captureFrames[floating] | |||
| 356 | + | sourceClient.sendMu.Unlock() | |||
| 357 | + | destinationClient.sendMu.Lock() | |||
| 358 | + | _, destinationRetained := destinationClient.captureFrames[floating] | |||
| 359 | + | destinationClient.sendMu.Unlock() | |||
| 360 | + | require.True(t, sourceRetained, "exit must not clean up the retired source owner") | |||
| 361 | + | require.False(t, destinationRetained, "exit must clean up the current destination owner") | |||
| 362 | + | } | |||
| 363 | + | ||||
| 287 | func TestFloatingReapAndTeardownCloseMatchingPaneOnce(t *testing.T) { | 364 | func TestFloatingReapAndTeardownCloseMatchingPaneOnce(t *testing.T) { | ||
| 288 | pty, _ := newBlockingPTY(t) | 365 | pty, _ := newBlockingPTY(t) | ||
| 289 | tb := newFloatingTestTab(t) | 366 | tb := newFloatingTestTab(t) | ||
| 290 | p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10}) | 367 | p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10}) | ||
| 368 | + | sess := &session{} | |||
| 291 | tb.mu.Lock() | 369 | tb.mu.Lock() | ||
| 292 | g := tb.beginFloatingWarmLocked(true) | 370 | g := tb.beginFloatingWarmLocked(true) | ||
| 371 | + | publishPaneOwner(p, sess, tb, g) | |||
| 293 | require.True(t, tb.installFloatingLocked(p, g)) | 372 | require.True(t, tb.installFloatingLocked(p, g)) | ||
| 294 | tb.mu.Unlock() | 373 | tb.mu.Unlock() | ||
| 295 | d := newTestDaemon(t, nil, stubClock{}) | 374 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 296 | - | d.reapFloating(&session{}, tb, p, g) | 375 | + | d.reapInstalledFloating(p) |
| 297 | d.teardownFloating(tb, nil) | 376 | d.teardownFloating(tb, nil) | ||
| 298 | pty.AssertNumberOfCalls(t, "Close", 1) | 377 | pty.AssertNumberOfCalls(t, "Close", 1) | ||
| 299 | } | 378 | } | ||
| @@ -358,6 +437,7 @@ func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) { | |||||
| 358 | }).Once() | 437 | }).Once() | ||
| 359 | floating := newPane(layout.PaneID("floating"), floatingPTY, domain.Size{Cols: 20, Rows: 8}) | 438 | floating := newPane(layout.PaneID("floating"), floatingPTY, domain.Size{Cols: 20, Rows: 8}) | ||
| 360 | generation := tb.beginFloatingWarmLocked(state == floatingVisible) | 439 | generation := tb.beginFloatingWarmLocked(state == floatingVisible) | ||
| 440 | + | publishPaneOwner(floating, sess, tb, generation) | |||
| 361 | require.True(t, tb.installFloatingLocked(floating, generation)) | 441 | require.True(t, tb.installFloatingLocked(floating, generation)) | ||
| 362 | if state == floatingHidden { | 442 | if state == floatingHidden { | ||
| 363 | require.Equal(t, floatingHidden, tb.floating.state) | 443 | require.Equal(t, floatingHidden, tb.floating.state) | ||
| @@ -374,7 +454,7 @@ func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) { | |||||
| 374 | 454 | ||||
| 375 | reaped := make(chan struct{}) | 455 | reaped := make(chan struct{}) | ||
| 376 | floating.onExit = func() { | 456 | floating.onExit = func() { | ||
| 377 | - | d.reapFloating(sess, tb, floating, generation) | 457 | + | d.reapInstalledFloating(floating) |
| 378 | close(reaped) | 458 | close(reaped) | ||
| 379 | } | 459 | } | ||
| 380 | d.sessWg.Add(1) | 460 | d.sessWg.Add(1) | ||
A internal/usecase/daemon/move_resize_fences.go +203 -0
| @@ -0,0 +1,203 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "cmp" | |
| 5 | + | "slices" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // moveResizeFences excludes concurrent layout publishers while a move | |
| 9 | + | // revalidates and publishes ownership. It is not an architecture lock. Fences | |
| 10 | + | // are always acquired session -> tab -> pane by immutable stable identity and | |
| 11 | + | // released in exact reverse order. | |
| 12 | + | type moveResizeFences struct { | |
| 13 | + | sessions []*session | |
| 14 | + | tabs []*tab | |
| 15 | + | panes []*pane | |
| 16 | + | held bool | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // newMovePaneResizeFences covers both sessions, both affected layouts, and | |
| 20 | + | // only the pane whose owner will change. Stable-identity deduplication also | |
| 21 | + | // handles moves between tabs in the same session. | |
| 22 | + | func newMovePaneResizeFences(source, destination *session, sourceTab, destinationTab *tab, moved *pane) *moveResizeFences { | |
| 23 | + | return newMoveResizeFences( | |
| 24 | + | []*session{source, destination}, | |
| 25 | + | []*tab{sourceTab, destinationTab}, | |
| 26 | + | []*pane{moved}, | |
| 27 | + | ) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // newMoveTabResizeFences snapshots every process-bearing pane transferred with | |
| 31 | + | // a tab. The acquire callback must revalidate this exact tiled membership and | |
| 32 | + | // installed floating slot after all fences are held, because this snapshot is | |
| 33 | + | // intentionally taken before waiting on any resize transaction. | |
| 34 | + | func newMoveTabResizeFences(source, destination *session, moved *tab) *moveResizeFences { | |
| 35 | + | if moved == nil { | |
| 36 | + | return newMoveResizeFences([]*session{source, destination}, nil, nil) | |
| 37 | + | } | |
| 38 | + | moved.mu.Lock() | |
| 39 | + | panes := make([]*pane, 0, len(moved.panes)+1) | |
| 40 | + | for _, p := range moved.panes { | |
| 41 | + | panes = append(panes, p) | |
| 42 | + | } | |
| 43 | + | if moved.floating.pane != nil && (moved.floating.state == floatingHidden || moved.floating.state == floatingVisible) { | |
| 44 | + | panes = append(panes, moved.floating.pane) | |
| 45 | + | } | |
| 46 | + | moved.mu.Unlock() | |
| 47 | + | return newMoveResizeFences([]*session{source, destination}, []*tab{moved}, panes) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func newMoveResizeFences(sessions []*session, tabs []*tab, panes []*pane) *moveResizeFences { | |
| 51 | + | byID := make(map[string]*session, len(sessions)) | |
| 52 | + | for _, sess := range sessions { | |
| 53 | + | if sess == nil { | |
| 54 | + | continue | |
| 55 | + | } | |
| 56 | + | if _, exists := byID[string(sess.id)]; !exists { | |
| 57 | + | byID[string(sess.id)] = sess | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ordered := make([]*session, 0, len(byID)) | |
| 61 | + | for _, sess := range byID { | |
| 62 | + | ordered = append(ordered, sess) | |
| 63 | + | } | |
| 64 | + | slices.SortFunc(ordered, func(left, right *session) int { | |
| 65 | + | return cmp.Compare(left.id, right.id) | |
| 66 | + | }) | |
| 67 | + | ||
| 68 | + | tabsByID := make(map[string]*tab, len(tabs)) | |
| 69 | + | for _, tb := range tabs { | |
| 70 | + | if tb == nil { | |
| 71 | + | continue | |
| 72 | + | } | |
| 73 | + | if _, exists := tabsByID[tb.stableID]; !exists { | |
| 74 | + | tabsByID[tb.stableID] = tb | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | orderedTabs := make([]*tab, 0, len(tabsByID)) | |
| 78 | + | for _, tb := range tabsByID { | |
| 79 | + | orderedTabs = append(orderedTabs, tb) | |
| 80 | + | } | |
| 81 | + | slices.SortFunc(orderedTabs, func(left, right *tab) int { | |
| 82 | + | return cmp.Compare(left.stableID, right.stableID) | |
| 83 | + | }) | |
| 84 | + | ||
| 85 | + | panesByID := make(map[string]*pane, len(panes)) | |
| 86 | + | for _, p := range panes { | |
| 87 | + | if p == nil { | |
| 88 | + | continue | |
| 89 | + | } | |
| 90 | + | if _, exists := panesByID[p.stableID]; !exists { | |
| 91 | + | panesByID[p.stableID] = p | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | orderedPanes := make([]*pane, 0, len(panesByID)) | |
| 95 | + | for _, p := range panesByID { | |
| 96 | + | orderedPanes = append(orderedPanes, p) | |
| 97 | + | } | |
| 98 | + | slices.SortFunc(orderedPanes, func(left, right *pane) int { | |
| 99 | + | return cmp.Compare(left.stableID, right.stableID) | |
| 100 | + | }) | |
| 101 | + | return &moveResizeFences{sessions: ordered, tabs: orderedTabs, panes: orderedPanes} | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // acquire waits for every resize fence before invoking revalidateAndPublish. | |
| 105 | + | // Callers must enter with no daemon, session, tab, or pane architecture lock | |
| 106 | + | // held. The callback is the only architecture-lock window: under the canonical | |
| 107 | + | // architecture lock order it must revalidate the sessions, tabs, tiled pane | |
| 108 | + | // memberships, and installed floating slot captured before this wait; publish | |
| 109 | + | // every changed pane owner; and bump each affected tab's layoutGeneration. A | |
| 110 | + | // false result (or panic) releases every acquired fence in reverse order. A true | |
| 111 | + | // result retains all fences so no resize can observe half-published ownership; | |
| 112 | + | // the caller must Release after architecture locks are dropped and before PTY | |
| 113 | + | // I/O, applyTabLayout, or applySessionLayout. | |
| 114 | + | func (f *moveResizeFences) acquire(revalidateAndPublish func() bool) (accepted bool) { | |
| 115 | + | if f == nil || f.held { | |
| 116 | + | return false | |
| 117 | + | } | |
| 118 | + | for _, sess := range f.sessions { | |
| 119 | + | sess.layoutApplyMu.Lock() | |
| 120 | + | } | |
| 121 | + | for _, tb := range f.tabs { | |
| 122 | + | tb.layoutApplyMu.Lock() | |
| 123 | + | } | |
| 124 | + | for _, p := range f.panes { | |
| 125 | + | p.resizeMu.Lock() | |
| 126 | + | } | |
| 127 | + | f.held = true | |
| 128 | + | defer func() { | |
| 129 | + | if !accepted { | |
| 130 | + | f.Release() | |
| 131 | + | } | |
| 132 | + | }() | |
| 133 | + | if revalidateAndPublish == nil { | |
| 134 | + | return false | |
| 135 | + | } | |
| 136 | + | accepted = revalidateAndPublish() | |
| 137 | + | return accepted | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func (d *Daemon) observeBeforeResizeOwnerPostEffect(effect resizeOwnerPostEffect) { | |
| 141 | + | if d != nil && d.beforeResizeOwnerPostEffect != nil { | |
| 142 | + | d.beforeResizeOwnerPostEffect(effect) | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // acquireResizeOwnerPostEffectFences acquires and validates the complete | |
| 147 | + | // canonical session/tab/pane fence set. It performs no callbacks or external | |
| 148 | + | // operations and returns the fences held on success. | |
| 149 | + | func acquireResizeOwnerPostEffectFences(members []resizeMember) *moveResizeFences { | |
| 150 | + | if len(members) == 0 { | |
| 151 | + | return nil | |
| 152 | + | } | |
| 153 | + | sessions := make([]*session, 0, len(members)) | |
| 154 | + | tabs := make([]*tab, 0, len(members)) | |
| 155 | + | panes := make([]*pane, 0, len(members)) | |
| 156 | + | for i := range members { | |
| 157 | + | member := &members[i] | |
| 158 | + | sessions = append(sessions, member.session) | |
| 159 | + | tabs = append(tabs, member.tab) | |
| 160 | + | panes = append(panes, member.pane) | |
| 161 | + | } | |
| 162 | + | fences := newMoveResizeFences(sessions, tabs, panes) | |
| 163 | + | if !fences.acquire(func() bool { return resizeMembersOwnerCurrent(members) }) { | |
| 164 | + | return nil | |
| 165 | + | } | |
| 166 | + | return fences | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // publishResizeOwnerPostEffect linearizes an in-memory resize effect with pane | |
| 170 | + | // ownership publication. The deterministic seam runs before fence acquisition, | |
| 171 | + | // then the complete canonical session/tab/pane fence set is acquired and every | |
| 172 | + | // immutable owner lease is revalidated. The callback must publish in-memory | |
| 173 | + | // state only; timer, PTY, transport, repository, and renderer work belongs | |
| 174 | + | // after this method returns. | |
| 175 | + | func (d *Daemon) publishResizeOwnerPostEffect(members []resizeMember, effect resizeOwnerPostEffect, publish func()) bool { | |
| 176 | + | if publish == nil { | |
| 177 | + | return false | |
| 178 | + | } | |
| 179 | + | d.observeBeforeResizeOwnerPostEffect(effect) | |
| 180 | + | fences := acquireResizeOwnerPostEffectFences(members) | |
| 181 | + | if fences == nil { | |
| 182 | + | return false | |
| 183 | + | } | |
| 184 | + | publish() | |
| 185 | + | fences.Release() | |
| 186 | + | return true | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func (f *moveResizeFences) Release() { | |
| 190 | + | if f == nil || !f.held { | |
| 191 | + | return | |
| 192 | + | } | |
| 193 | + | for i := len(f.panes) - 1; i >= 0; i-- { | |
| 194 | + | f.panes[i].resizeMu.Unlock() | |
| 195 | + | } | |
| 196 | + | for i := len(f.tabs) - 1; i >= 0; i-- { | |
| 197 | + | f.tabs[i].layoutApplyMu.Unlock() | |
| 198 | + | } | |
| 199 | + | for i := len(f.sessions) - 1; i >= 0; i-- { | |
| 200 | + | f.sessions[i].layoutApplyMu.Unlock() | |
| 201 | + | } | |
| 202 | + | f.held = false | |
| 203 | + | } | |
| @@ -0,0 +1,203 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "cmp" | |||
| 5 | + | "slices" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // moveResizeFences excludes concurrent layout publishers while a move | |||
| 9 | + | // revalidates and publishes ownership. It is not an architecture lock. Fences | |||
| 10 | + | // are always acquired session -> tab -> pane by immutable stable identity and | |||
| 11 | + | // released in exact reverse order. | |||
| 12 | + | type moveResizeFences struct { | |||
| 13 | + | sessions []*session | |||
| 14 | + | tabs []*tab | |||
| 15 | + | panes []*pane | |||
| 16 | + | held bool | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // newMovePaneResizeFences covers both sessions, both affected layouts, and | |||
| 20 | + | // only the pane whose owner will change. Stable-identity deduplication also | |||
| 21 | + | // handles moves between tabs in the same session. | |||
| 22 | + | func newMovePaneResizeFences(source, destination *session, sourceTab, destinationTab *tab, moved *pane) *moveResizeFences { | |||
| 23 | + | return newMoveResizeFences( | |||
| 24 | + | []*session{source, destination}, | |||
| 25 | + | []*tab{sourceTab, destinationTab}, | |||
| 26 | + | []*pane{moved}, | |||
| 27 | + | ) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // newMoveTabResizeFences snapshots every process-bearing pane transferred with | |||
| 31 | + | // a tab. The acquire callback must revalidate this exact tiled membership and | |||
| 32 | + | // installed floating slot after all fences are held, because this snapshot is | |||
| 33 | + | // intentionally taken before waiting on any resize transaction. | |||
| 34 | + | func newMoveTabResizeFences(source, destination *session, moved *tab) *moveResizeFences { | |||
| 35 | + | if moved == nil { | |||
| 36 | + | return newMoveResizeFences([]*session{source, destination}, nil, nil) | |||
| 37 | + | } | |||
| 38 | + | moved.mu.Lock() | |||
| 39 | + | panes := make([]*pane, 0, len(moved.panes)+1) | |||
| 40 | + | for _, p := range moved.panes { | |||
| 41 | + | panes = append(panes, p) | |||
| 42 | + | } | |||
| 43 | + | if moved.floating.pane != nil && (moved.floating.state == floatingHidden || moved.floating.state == floatingVisible) { | |||
| 44 | + | panes = append(panes, moved.floating.pane) | |||
| 45 | + | } | |||
| 46 | + | moved.mu.Unlock() | |||
| 47 | + | return newMoveResizeFences([]*session{source, destination}, []*tab{moved}, panes) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func newMoveResizeFences(sessions []*session, tabs []*tab, panes []*pane) *moveResizeFences { | |||
| 51 | + | byID := make(map[string]*session, len(sessions)) | |||
| 52 | + | for _, sess := range sessions { | |||
| 53 | + | if sess == nil { | |||
| 54 | + | continue | |||
| 55 | + | } | |||
| 56 | + | if _, exists := byID[string(sess.id)]; !exists { | |||
| 57 | + | byID[string(sess.id)] = sess | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ordered := make([]*session, 0, len(byID)) | |||
| 61 | + | for _, sess := range byID { | |||
| 62 | + | ordered = append(ordered, sess) | |||
| 63 | + | } | |||
| 64 | + | slices.SortFunc(ordered, func(left, right *session) int { | |||
| 65 | + | return cmp.Compare(left.id, right.id) | |||
| 66 | + | }) | |||
| 67 | + | ||||
| 68 | + | tabsByID := make(map[string]*tab, len(tabs)) | |||
| 69 | + | for _, tb := range tabs { | |||
| 70 | + | if tb == nil { | |||
| 71 | + | continue | |||
| 72 | + | } | |||
| 73 | + | if _, exists := tabsByID[tb.stableID]; !exists { | |||
| 74 | + | tabsByID[tb.stableID] = tb | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | orderedTabs := make([]*tab, 0, len(tabsByID)) | |||
| 78 | + | for _, tb := range tabsByID { | |||
| 79 | + | orderedTabs = append(orderedTabs, tb) | |||
| 80 | + | } | |||
| 81 | + | slices.SortFunc(orderedTabs, func(left, right *tab) int { | |||
| 82 | + | return cmp.Compare(left.stableID, right.stableID) | |||
| 83 | + | }) | |||
| 84 | + | ||||
| 85 | + | panesByID := make(map[string]*pane, len(panes)) | |||
| 86 | + | for _, p := range panes { | |||
| 87 | + | if p == nil { | |||
| 88 | + | continue | |||
| 89 | + | } | |||
| 90 | + | if _, exists := panesByID[p.stableID]; !exists { | |||
| 91 | + | panesByID[p.stableID] = p | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | orderedPanes := make([]*pane, 0, len(panesByID)) | |||
| 95 | + | for _, p := range panesByID { | |||
| 96 | + | orderedPanes = append(orderedPanes, p) | |||
| 97 | + | } | |||
| 98 | + | slices.SortFunc(orderedPanes, func(left, right *pane) int { | |||
| 99 | + | return cmp.Compare(left.stableID, right.stableID) | |||
| 100 | + | }) | |||
| 101 | + | return &moveResizeFences{sessions: ordered, tabs: orderedTabs, panes: orderedPanes} | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // acquire waits for every resize fence before invoking revalidateAndPublish. | |||
| 105 | + | // Callers must enter with no daemon, session, tab, or pane architecture lock | |||
| 106 | + | // held. The callback is the only architecture-lock window: under the canonical | |||
| 107 | + | // architecture lock order it must revalidate the sessions, tabs, tiled pane | |||
| 108 | + | // memberships, and installed floating slot captured before this wait; publish | |||
| 109 | + | // every changed pane owner; and bump each affected tab's layoutGeneration. A | |||
| 110 | + | // false result (or panic) releases every acquired fence in reverse order. A true | |||
| 111 | + | // result retains all fences so no resize can observe half-published ownership; | |||
| 112 | + | // the caller must Release after architecture locks are dropped and before PTY | |||
| 113 | + | // I/O, applyTabLayout, or applySessionLayout. | |||
| 114 | + | func (f *moveResizeFences) acquire(revalidateAndPublish func() bool) (accepted bool) { | |||
| 115 | + | if f == nil || f.held { | |||
| 116 | + | return false | |||
| 117 | + | } | |||
| 118 | + | for _, sess := range f.sessions { | |||
| 119 | + | sess.layoutApplyMu.Lock() | |||
| 120 | + | } | |||
| 121 | + | for _, tb := range f.tabs { | |||
| 122 | + | tb.layoutApplyMu.Lock() | |||
| 123 | + | } | |||
| 124 | + | for _, p := range f.panes { | |||
| 125 | + | p.resizeMu.Lock() | |||
| 126 | + | } | |||
| 127 | + | f.held = true | |||
| 128 | + | defer func() { | |||
| 129 | + | if !accepted { | |||
| 130 | + | f.Release() | |||
| 131 | + | } | |||
| 132 | + | }() | |||
| 133 | + | if revalidateAndPublish == nil { | |||
| 134 | + | return false | |||
| 135 | + | } | |||
| 136 | + | accepted = revalidateAndPublish() | |||
| 137 | + | return accepted | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func (d *Daemon) observeBeforeResizeOwnerPostEffect(effect resizeOwnerPostEffect) { | |||
| 141 | + | if d != nil && d.beforeResizeOwnerPostEffect != nil { | |||
| 142 | + | d.beforeResizeOwnerPostEffect(effect) | |||
| 143 | + | } | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // acquireResizeOwnerPostEffectFences acquires and validates the complete | |||
| 147 | + | // canonical session/tab/pane fence set. It performs no callbacks or external | |||
| 148 | + | // operations and returns the fences held on success. | |||
| 149 | + | func acquireResizeOwnerPostEffectFences(members []resizeMember) *moveResizeFences { | |||
| 150 | + | if len(members) == 0 { | |||
| 151 | + | return nil | |||
| 152 | + | } | |||
| 153 | + | sessions := make([]*session, 0, len(members)) | |||
| 154 | + | tabs := make([]*tab, 0, len(members)) | |||
| 155 | + | panes := make([]*pane, 0, len(members)) | |||
| 156 | + | for i := range members { | |||
| 157 | + | member := &members[i] | |||
| 158 | + | sessions = append(sessions, member.session) | |||
| 159 | + | tabs = append(tabs, member.tab) | |||
| 160 | + | panes = append(panes, member.pane) | |||
| 161 | + | } | |||
| 162 | + | fences := newMoveResizeFences(sessions, tabs, panes) | |||
| 163 | + | if !fences.acquire(func() bool { return resizeMembersOwnerCurrent(members) }) { | |||
| 164 | + | return nil | |||
| 165 | + | } | |||
| 166 | + | return fences | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | // publishResizeOwnerPostEffect linearizes an in-memory resize effect with pane | |||
| 170 | + | // ownership publication. The deterministic seam runs before fence acquisition, | |||
| 171 | + | // then the complete canonical session/tab/pane fence set is acquired and every | |||
| 172 | + | // immutable owner lease is revalidated. The callback must publish in-memory | |||
| 173 | + | // state only; timer, PTY, transport, repository, and renderer work belongs | |||
| 174 | + | // after this method returns. | |||
| 175 | + | func (d *Daemon) publishResizeOwnerPostEffect(members []resizeMember, effect resizeOwnerPostEffect, publish func()) bool { | |||
| 176 | + | if publish == nil { | |||
| 177 | + | return false | |||
| 178 | + | } | |||
| 179 | + | d.observeBeforeResizeOwnerPostEffect(effect) | |||
| 180 | + | fences := acquireResizeOwnerPostEffectFences(members) | |||
| 181 | + | if fences == nil { | |||
| 182 | + | return false | |||
| 183 | + | } | |||
| 184 | + | publish() | |||
| 185 | + | fences.Release() | |||
| 186 | + | return true | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func (f *moveResizeFences) Release() { | |||
| 190 | + | if f == nil || !f.held { | |||
| 191 | + | return | |||
| 192 | + | } | |||
| 193 | + | for i := len(f.panes) - 1; i >= 0; i-- { | |||
| 194 | + | f.panes[i].resizeMu.Unlock() | |||
| 195 | + | } | |||
| 196 | + | for i := len(f.tabs) - 1; i >= 0; i-- { | |||
| 197 | + | f.tabs[i].layoutApplyMu.Unlock() | |||
| 198 | + | } | |||
| 199 | + | for i := len(f.sessions) - 1; i >= 0; i-- { | |||
| 200 | + | f.sessions[i].layoutApplyMu.Unlock() | |||
| 201 | + | } | |||
| 202 | + | f.held = false | |||
| 203 | + | } | |||
A internal/usecase/daemon/move_resize_fences_test.go +294 -0
| @@ -0,0 +1,294 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func requireMutexLocked(t *testing.T, mu *sync.Mutex, message string) { | |
| 14 | + | t.Helper() | |
| 15 | + | require.Eventually(t, func() bool { | |
| 16 | + | if !mu.TryLock() { | |
| 17 | + | return true | |
| 18 | + | } | |
| 19 | + | mu.Unlock() | |
| 20 | + | return false | |
| 21 | + | }, time.Second, time.Millisecond, message) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestMoveResizeFencesAcquireSessionsByStableID(t *testing.T) { | |
| 25 | + | first := &session{id: "a-first"} | |
| 26 | + | second := &session{id: "z-second"} | |
| 27 | + | fences := newMoveResizeFences([]*session{second, first}, nil, nil) | |
| 28 | + | ||
| 29 | + | first.layoutApplyMu.Lock() | |
| 30 | + | acquired := make(chan bool, 1) | |
| 31 | + | go func() { | |
| 32 | + | acquired <- fences.acquire(func() bool { return true }) | |
| 33 | + | }() | |
| 34 | + | ||
| 35 | + | require.True(t, second.layoutApplyMu.TryLock(), "acquisition reached the later session before the lowest SessionID") | |
| 36 | + | second.layoutApplyMu.Unlock() | |
| 37 | + | first.layoutApplyMu.Unlock() | |
| 38 | + | ||
| 39 | + | require.True(t, awaitTestValue(t, acquired, "ordered resize fences did not acquire")) | |
| 40 | + | fences.Release() | |
| 41 | + | require.True(t, first.layoutApplyMu.TryLock()) | |
| 42 | + | first.layoutApplyMu.Unlock() | |
| 43 | + | require.True(t, second.layoutApplyMu.TryLock()) | |
| 44 | + | second.layoutApplyMu.Unlock() | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestMoveResizeFencesAcquireTabsByStableIDAfterSessions(t *testing.T) { | |
| 48 | + | sess := &session{id: "session"} | |
| 49 | + | first := &tab{stableID: "a-first"} | |
| 50 | + | second := &tab{stableID: "z-second"} | |
| 51 | + | fences := newMoveResizeFences([]*session{sess}, []*tab{second, first}, nil) | |
| 52 | + | ||
| 53 | + | first.layoutApplyMu.Lock() | |
| 54 | + | acquired := make(chan bool, 1) | |
| 55 | + | go func() { | |
| 56 | + | acquired <- fences.acquire(func() bool { return true }) | |
| 57 | + | }() | |
| 58 | + | ||
| 59 | + | requireMutexLocked(t, &sess.layoutApplyMu, "session fence was not acquired before waiting on a tab fence") | |
| 60 | + | require.True(t, second.layoutApplyMu.TryLock(), "acquisition reached the later tab before the lowest stable tab ID") | |
| 61 | + | second.layoutApplyMu.Unlock() | |
| 62 | + | select { | |
| 63 | + | case <-acquired: | |
| 64 | + | t.Fatal("resize fences ignored the blocked first tab") | |
| 65 | + | default: | |
| 66 | + | } | |
| 67 | + | first.layoutApplyMu.Unlock() | |
| 68 | + | ||
| 69 | + | require.True(t, awaitTestValue(t, acquired, "ordered tab resize fences did not acquire")) | |
| 70 | + | fences.Release() | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestMoveResizeFencesAcquirePanesByStableIDAfterTabs(t *testing.T) { | |
| 74 | + | sess := &session{id: "session"} | |
| 75 | + | tb := &tab{stableID: "tab"} | |
| 76 | + | first := &pane{stableID: "a-first"} | |
| 77 | + | second := &pane{stableID: "z-second"} | |
| 78 | + | fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{second, first}) | |
| 79 | + | ||
| 80 | + | first.resizeMu.Lock() | |
| 81 | + | acquired := make(chan bool, 1) | |
| 82 | + | go func() { | |
| 83 | + | acquired <- fences.acquire(func() bool { return true }) | |
| 84 | + | }() | |
| 85 | + | ||
| 86 | + | requireMutexLocked(t, &sess.layoutApplyMu, "session fence was not acquired before pane wait") | |
| 87 | + | requireMutexLocked(t, &tb.layoutApplyMu, "tab fence was not acquired before pane wait") | |
| 88 | + | require.True(t, second.resizeMu.TryLock(), "acquisition reached the later pane before the lowest stable pane ID") | |
| 89 | + | second.resizeMu.Unlock() | |
| 90 | + | select { | |
| 91 | + | case <-acquired: | |
| 92 | + | t.Fatal("resize fences ignored the blocked first pane") | |
| 93 | + | default: | |
| 94 | + | } | |
| 95 | + | first.resizeMu.Unlock() | |
| 96 | + | ||
| 97 | + | require.True(t, awaitTestValue(t, acquired, "ordered pane resize fences did not acquire")) | |
| 98 | + | fences.Release() | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestMoveResizeFencesDeduplicateStableIdentities(t *testing.T) { | |
| 102 | + | sess := &session{id: "session"} | |
| 103 | + | tb := &tab{stableID: "tab"} | |
| 104 | + | p := &pane{stableID: "pane"} | |
| 105 | + | ||
| 106 | + | fences := newMoveResizeFences([]*session{sess, sess}, []*tab{tb, tb}, []*pane{p, p}) | |
| 107 | + | ||
| 108 | + | require.Len(t, fences.sessions, 1) | |
| 109 | + | require.Len(t, fences.tabs, 1) | |
| 110 | + | require.Len(t, fences.panes, 1) | |
| 111 | + | require.True(t, fences.acquire(func() bool { return true })) | |
| 112 | + | fences.Release() | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func TestMoveResizeFencesDeduplicateSessionIDWithoutReplacingResolvedOwner(t *testing.T) { | |
| 116 | + | resolved := &session{id: "same"} | |
| 117 | + | collision := &session{id: "same"} | |
| 118 | + | ||
| 119 | + | fences := newMoveResizeFences([]*session{resolved, collision}, nil, nil) | |
| 120 | + | ||
| 121 | + | require.Len(t, fences.sessions, 1) | |
| 122 | + | require.Same(t, resolved, fences.sessions[0]) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func TestMoveResizeFencesDeduplicateTabIDWithoutReplacingResolvedOwner(t *testing.T) { | |
| 126 | + | resolved := &tab{stableID: "same"} | |
| 127 | + | collision := &tab{stableID: "same"} | |
| 128 | + | ||
| 129 | + | fences := newMoveResizeFences(nil, []*tab{resolved, collision}, nil) | |
| 130 | + | ||
| 131 | + | require.Len(t, fences.tabs, 1) | |
| 132 | + | require.Same(t, resolved, fences.tabs[0]) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func TestMoveResizeFencesDeduplicatePaneIDWithoutReplacingResolvedOwner(t *testing.T) { | |
| 136 | + | resolved := &pane{stableID: "same"} | |
| 137 | + | collision := &pane{stableID: "same"} | |
| 138 | + | ||
| 139 | + | fences := newMoveResizeFences(nil, nil, []*pane{resolved, collision}) | |
| 140 | + | ||
| 141 | + | require.Len(t, fences.panes, 1) | |
| 142 | + | require.Same(t, resolved, fences.panes[0]) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func TestMoveResizeFencesRejectStaleMembershipAndCleanUp(t *testing.T) { | |
| 146 | + | sess := &session{id: "session"} | |
| 147 | + | tb := &tab{stableID: "tab"} | |
| 148 | + | p := &pane{stableID: "pane"} | |
| 149 | + | fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p}) | |
| 150 | + | ||
| 151 | + | membershipCurrent := true | |
| 152 | + | p.resizeMu.Lock() | |
| 153 | + | acquired := make(chan bool, 1) | |
| 154 | + | callbackCalled := make(chan struct{}) | |
| 155 | + | go func() { | |
| 156 | + | acquired <- fences.acquire(func() bool { | |
| 157 | + | close(callbackCalled) | |
| 158 | + | return membershipCurrent | |
| 159 | + | }) | |
| 160 | + | }() | |
| 161 | + | requireMutexLocked(t, &tb.layoutApplyMu, "resize fence acquisition did not reach the blocked pane") | |
| 162 | + | membershipCurrent = false | |
| 163 | + | p.resizeMu.Unlock() | |
| 164 | + | ||
| 165 | + | require.False(t, awaitTestValue(t, acquired, "stale resize fence acquisition did not return")) | |
| 166 | + | awaitTestCompletion(t, callbackCalled, "membership was not revalidated after resize fence acquisition") | |
| 167 | + | for _, lock := range []*sync.Mutex{&sess.layoutApplyMu, &tb.layoutApplyMu, &p.resizeMu} { | |
| 168 | + | require.True(t, lock.TryLock(), "rejected acquisition leaked a resize fence") | |
| 169 | + | lock.Unlock() | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func TestMoveResizeFencesCleanUpWhenPublicationPanics(t *testing.T) { | |
| 174 | + | sess := &session{id: "session"} | |
| 175 | + | tb := &tab{stableID: "tab"} | |
| 176 | + | p := &pane{stableID: "pane"} | |
| 177 | + | fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p}) | |
| 178 | + | ||
| 179 | + | require.Panics(t, func() { | |
| 180 | + | fences.acquire(func() bool { panic("publication failed") }) | |
| 181 | + | }) | |
| 182 | + | for _, lock := range []*sync.Mutex{&sess.layoutApplyMu, &tb.layoutApplyMu, &p.resizeMu} { | |
| 183 | + | require.True(t, lock.TryLock(), "panicked publication leaked a partial resize fence set") | |
| 184 | + | lock.Unlock() | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func TestMoveResizeFencesWaitBeforeTakingArchitectureLocks(t *testing.T) { | |
| 189 | + | d := &Daemon{} | |
| 190 | + | sess := &session{id: "session"} | |
| 191 | + | tb := &tab{stableID: "tab"} | |
| 192 | + | p := &pane{stableID: "pane"} | |
| 193 | + | fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p}) | |
| 194 | + | ||
| 195 | + | p.resizeMu.Lock() | |
| 196 | + | acquired := make(chan bool, 1) | |
| 197 | + | go func() { | |
| 198 | + | acquired <- fences.acquire(func() bool { return true }) | |
| 199 | + | }() | |
| 200 | + | requireMutexLocked(t, &tb.layoutApplyMu, "resize fence acquisition did not reach the blocked pane") | |
| 201 | + | ||
| 202 | + | for name, lock := range map[string]*sync.Mutex{ | |
| 203 | + | "daemon": &d.mu, | |
| 204 | + | "session": &sess.mu, | |
| 205 | + | "tab": &tb.mu, | |
| 206 | + | } { | |
| 207 | + | require.True(t, lock.TryLock(), "%s architecture lock was held while waiting on pane resize", name) | |
| 208 | + | lock.Unlock() | |
| 209 | + | } | |
| 210 | + | p.resizeMu.Unlock() | |
| 211 | + | ||
| 212 | + | require.True(t, awaitTestValue(t, acquired, "resize fences did not acquire after pane resize released")) | |
| 213 | + | fences.Release() | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | func TestMoveResizeFencesHoldThroughPublicationAndGenerationBumps(t *testing.T) { | |
| 217 | + | source := &session{id: "source"} | |
| 218 | + | destination := &session{id: "destination"} | |
| 219 | + | sourceTab := &tab{stableID: "a-source-tab"} | |
| 220 | + | destinationTab := &tab{stableID: "z-destination-tab"} | |
| 221 | + | p := &pane{stableID: "pane"} | |
| 222 | + | fences := newMoveResizeFences( | |
| 223 | + | []*session{source, destination}, | |
| 224 | + | []*tab{sourceTab, destinationTab}, | |
| 225 | + | []*pane{p}, | |
| 226 | + | ) | |
| 227 | + | ||
| 228 | + | require.True(t, fences.acquire(func() bool { | |
| 229 | + | // This callback is the move's architecture-lock window: membership is | |
| 230 | + | // revalidated before owner publication and both affected generations bump. | |
| 231 | + | sourceTab.mu.Lock() | |
| 232 | + | destinationTab.mu.Lock() | |
| 233 | + | sourceTab.bumpLayoutGenerationLocked() | |
| 234 | + | destinationTab.bumpLayoutGenerationLocked() | |
| 235 | + | destinationTab.mu.Unlock() | |
| 236 | + | sourceTab.mu.Unlock() | |
| 237 | + | return true | |
| 238 | + | })) | |
| 239 | + | require.Equal(t, uint64(1), sourceTab.layoutGeneration) | |
| 240 | + | require.Equal(t, uint64(1), destinationTab.layoutGeneration) | |
| 241 | + | for _, lock := range []*sync.Mutex{&source.layoutApplyMu, &destination.layoutApplyMu, &sourceTab.layoutApplyMu, &destinationTab.layoutApplyMu, &p.resizeMu} { | |
| 242 | + | require.False(t, lock.TryLock(), "publication returned without retaining every resize fence") | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | // Move callers release here, before applyTabLayout or any other PTY I/O. | |
| 246 | + | fences.Release() | |
| 247 | + | for _, lock := range []*sync.Mutex{&source.layoutApplyMu, &destination.layoutApplyMu, &sourceTab.layoutApplyMu, &destinationTab.layoutApplyMu, &p.resizeMu} { | |
| 248 | + | require.True(t, lock.TryLock(), "publication fence did not release before layout application") | |
| 249 | + | lock.Unlock() | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func TestMovePaneResizeFencesCoverAffectedOwners(t *testing.T) { | |
| 254 | + | source := &session{id: "source"} | |
| 255 | + | destination := &session{id: "destination"} | |
| 256 | + | sourceTab := &tab{stableID: "source-tab"} | |
| 257 | + | destinationTab := &tab{stableID: "destination-tab"} | |
| 258 | + | moved := &pane{stableID: "moved-pane"} | |
| 259 | + | ||
| 260 | + | fences := newMovePaneResizeFences(source, destination, sourceTab, destinationTab, moved) | |
| 261 | + | ||
| 262 | + | require.ElementsMatch(t, []*session{source, destination}, fences.sessions) | |
| 263 | + | require.ElementsMatch(t, []*tab{sourceTab, destinationTab}, fences.tabs) | |
| 264 | + | require.Equal(t, []*pane{moved}, fences.panes) | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | func TestMoveTabResizeFencesCoverEveryContainedPane(t *testing.T) { | |
| 268 | + | for _, tc := range []struct { | |
| 269 | + | name string | |
| 270 | + | state floatingState | |
| 271 | + | }{{name: "hidden", state: floatingHidden}, {name: "visible", state: floatingVisible}} { | |
| 272 | + | t.Run(tc.name, func(t *testing.T) { | |
| 273 | + | source := &session{id: "source"} | |
| 274 | + | destination := &session{id: "destination"} | |
| 275 | + | first := &pane{stableID: "first"} | |
| 276 | + | second := &pane{stableID: "second"} | |
| 277 | + | floating := &pane{stableID: "floating"} | |
| 278 | + | movedTab := &tab{ | |
| 279 | + | stableID: "moved-tab", | |
| 280 | + | panes: map[layout.PaneID]*pane{ | |
| 281 | + | "pane-1": first, | |
| 282 | + | "pane-2": second, | |
| 283 | + | }, | |
| 284 | + | floating: floatingSlot{state: tc.state, pane: floating, generation: 7}, | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | fences := newMoveTabResizeFences(source, destination, movedTab) | |
| 288 | + | ||
| 289 | + | require.ElementsMatch(t, []*session{source, destination}, fences.sessions) | |
| 290 | + | require.Equal(t, []*tab{movedTab}, fences.tabs) | |
| 291 | + | require.ElementsMatch(t, []*pane{first, second, floating}, fences.panes) | |
| 292 | + | }) | |
| 293 | + | } | |
| 294 | + | } | |
| @@ -0,0 +1,294 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sync" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func requireMutexLocked(t *testing.T, mu *sync.Mutex, message string) { | |||
| 14 | + | t.Helper() | |||
| 15 | + | require.Eventually(t, func() bool { | |||
| 16 | + | if !mu.TryLock() { | |||
| 17 | + | return true | |||
| 18 | + | } | |||
| 19 | + | mu.Unlock() | |||
| 20 | + | return false | |||
| 21 | + | }, time.Second, time.Millisecond, message) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func TestMoveResizeFencesAcquireSessionsByStableID(t *testing.T) { | |||
| 25 | + | first := &session{id: "a-first"} | |||
| 26 | + | second := &session{id: "z-second"} | |||
| 27 | + | fences := newMoveResizeFences([]*session{second, first}, nil, nil) | |||
| 28 | + | ||||
| 29 | + | first.layoutApplyMu.Lock() | |||
| 30 | + | acquired := make(chan bool, 1) | |||
| 31 | + | go func() { | |||
| 32 | + | acquired <- fences.acquire(func() bool { return true }) | |||
| 33 | + | }() | |||
| 34 | + | ||||
| 35 | + | require.True(t, second.layoutApplyMu.TryLock(), "acquisition reached the later session before the lowest SessionID") | |||
| 36 | + | second.layoutApplyMu.Unlock() | |||
| 37 | + | first.layoutApplyMu.Unlock() | |||
| 38 | + | ||||
| 39 | + | require.True(t, awaitTestValue(t, acquired, "ordered resize fences did not acquire")) | |||
| 40 | + | fences.Release() | |||
| 41 | + | require.True(t, first.layoutApplyMu.TryLock()) | |||
| 42 | + | first.layoutApplyMu.Unlock() | |||
| 43 | + | require.True(t, second.layoutApplyMu.TryLock()) | |||
| 44 | + | second.layoutApplyMu.Unlock() | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestMoveResizeFencesAcquireTabsByStableIDAfterSessions(t *testing.T) { | |||
| 48 | + | sess := &session{id: "session"} | |||
| 49 | + | first := &tab{stableID: "a-first"} | |||
| 50 | + | second := &tab{stableID: "z-second"} | |||
| 51 | + | fences := newMoveResizeFences([]*session{sess}, []*tab{second, first}, nil) | |||
| 52 | + | ||||
| 53 | + | first.layoutApplyMu.Lock() | |||
| 54 | + | acquired := make(chan bool, 1) | |||
| 55 | + | go func() { | |||
| 56 | + | acquired <- fences.acquire(func() bool { return true }) | |||
| 57 | + | }() | |||
| 58 | + | ||||
| 59 | + | requireMutexLocked(t, &sess.layoutApplyMu, "session fence was not acquired before waiting on a tab fence") | |||
| 60 | + | require.True(t, second.layoutApplyMu.TryLock(), "acquisition reached the later tab before the lowest stable tab ID") | |||
| 61 | + | second.layoutApplyMu.Unlock() | |||
| 62 | + | select { | |||
| 63 | + | case <-acquired: | |||
| 64 | + | t.Fatal("resize fences ignored the blocked first tab") | |||
| 65 | + | default: | |||
| 66 | + | } | |||
| 67 | + | first.layoutApplyMu.Unlock() | |||
| 68 | + | ||||
| 69 | + | require.True(t, awaitTestValue(t, acquired, "ordered tab resize fences did not acquire")) | |||
| 70 | + | fences.Release() | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func TestMoveResizeFencesAcquirePanesByStableIDAfterTabs(t *testing.T) { | |||
| 74 | + | sess := &session{id: "session"} | |||
| 75 | + | tb := &tab{stableID: "tab"} | |||
| 76 | + | first := &pane{stableID: "a-first"} | |||
| 77 | + | second := &pane{stableID: "z-second"} | |||
| 78 | + | fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{second, first}) | |||
| 79 | + | ||||
| 80 | + | first.resizeMu.Lock() | |||
| 81 | + | acquired := make(chan bool, 1) | |||
| 82 | + | go func() { | |||
| 83 | + | acquired <- fences.acquire(func() bool { return true }) | |||
| 84 | + | }() | |||
| 85 | + | ||||
| 86 | + | requireMutexLocked(t, &sess.layoutApplyMu, "session fence was not acquired before pane wait") | |||
| 87 | + | requireMutexLocked(t, &tb.layoutApplyMu, "tab fence was not acquired before pane wait") | |||
| 88 | + | require.True(t, second.resizeMu.TryLock(), "acquisition reached the later pane before the lowest stable pane ID") | |||
| 89 | + | second.resizeMu.Unlock() | |||
| 90 | + | select { | |||
| 91 | + | case <-acquired: | |||
| 92 | + | t.Fatal("resize fences ignored the blocked first pane") | |||
| 93 | + | default: | |||
| 94 | + | } | |||
| 95 | + | first.resizeMu.Unlock() | |||
| 96 | + | ||||
| 97 | + | require.True(t, awaitTestValue(t, acquired, "ordered pane resize fences did not acquire")) | |||
| 98 | + | fences.Release() | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func TestMoveResizeFencesDeduplicateStableIdentities(t *testing.T) { | |||
| 102 | + | sess := &session{id: "session"} | |||
| 103 | + | tb := &tab{stableID: "tab"} | |||
| 104 | + | p := &pane{stableID: "pane"} | |||
| 105 | + | ||||
| 106 | + | fences := newMoveResizeFences([]*session{sess, sess}, []*tab{tb, tb}, []*pane{p, p}) | |||
| 107 | + | ||||
| 108 | + | require.Len(t, fences.sessions, 1) | |||
| 109 | + | require.Len(t, fences.tabs, 1) | |||
| 110 | + | require.Len(t, fences.panes, 1) | |||
| 111 | + | require.True(t, fences.acquire(func() bool { return true })) | |||
| 112 | + | fences.Release() | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func TestMoveResizeFencesDeduplicateSessionIDWithoutReplacingResolvedOwner(t *testing.T) { | |||
| 116 | + | resolved := &session{id: "same"} | |||
| 117 | + | collision := &session{id: "same"} | |||
| 118 | + | ||||
| 119 | + | fences := newMoveResizeFences([]*session{resolved, collision}, nil, nil) | |||
| 120 | + | ||||
| 121 | + | require.Len(t, fences.sessions, 1) | |||
| 122 | + | require.Same(t, resolved, fences.sessions[0]) | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func TestMoveResizeFencesDeduplicateTabIDWithoutReplacingResolvedOwner(t *testing.T) { | |||
| 126 | + | resolved := &tab{stableID: "same"} | |||
| 127 | + | collision := &tab{stableID: "same"} | |||
| 128 | + | ||||
| 129 | + | fences := newMoveResizeFences(nil, []*tab{resolved, collision}, nil) | |||
| 130 | + | ||||
| 131 | + | require.Len(t, fences.tabs, 1) | |||
| 132 | + | require.Same(t, resolved, fences.tabs[0]) | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func TestMoveResizeFencesDeduplicatePaneIDWithoutReplacingResolvedOwner(t *testing.T) { | |||
| 136 | + | resolved := &pane{stableID: "same"} | |||
| 137 | + | collision := &pane{stableID: "same"} | |||
| 138 | + | ||||
| 139 | + | fences := newMoveResizeFences(nil, nil, []*pane{resolved, collision}) | |||
| 140 | + | ||||
| 141 | + | require.Len(t, fences.panes, 1) | |||
| 142 | + | require.Same(t, resolved, fences.panes[0]) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func TestMoveResizeFencesRejectStaleMembershipAndCleanUp(t *testing.T) { | |||
| 146 | + | sess := &session{id: "session"} | |||
| 147 | + | tb := &tab{stableID: "tab"} | |||
| 148 | + | p := &pane{stableID: "pane"} | |||
| 149 | + | fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p}) | |||
| 150 | + | ||||
| 151 | + | membershipCurrent := true | |||
| 152 | + | p.resizeMu.Lock() | |||
| 153 | + | acquired := make(chan bool, 1) | |||
| 154 | + | callbackCalled := make(chan struct{}) | |||
| 155 | + | go func() { | |||
| 156 | + | acquired <- fences.acquire(func() bool { | |||
| 157 | + | close(callbackCalled) | |||
| 158 | + | return membershipCurrent | |||
| 159 | + | }) | |||
| 160 | + | }() | |||
| 161 | + | requireMutexLocked(t, &tb.layoutApplyMu, "resize fence acquisition did not reach the blocked pane") | |||
| 162 | + | membershipCurrent = false | |||
| 163 | + | p.resizeMu.Unlock() | |||
| 164 | + | ||||
| 165 | + | require.False(t, awaitTestValue(t, acquired, "stale resize fence acquisition did not return")) | |||
| 166 | + | awaitTestCompletion(t, callbackCalled, "membership was not revalidated after resize fence acquisition") | |||
| 167 | + | for _, lock := range []*sync.Mutex{&sess.layoutApplyMu, &tb.layoutApplyMu, &p.resizeMu} { | |||
| 168 | + | require.True(t, lock.TryLock(), "rejected acquisition leaked a resize fence") | |||
| 169 | + | lock.Unlock() | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func TestMoveResizeFencesCleanUpWhenPublicationPanics(t *testing.T) { | |||
| 174 | + | sess := &session{id: "session"} | |||
| 175 | + | tb := &tab{stableID: "tab"} | |||
| 176 | + | p := &pane{stableID: "pane"} | |||
| 177 | + | fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p}) | |||
| 178 | + | ||||
| 179 | + | require.Panics(t, func() { | |||
| 180 | + | fences.acquire(func() bool { panic("publication failed") }) | |||
| 181 | + | }) | |||
| 182 | + | for _, lock := range []*sync.Mutex{&sess.layoutApplyMu, &tb.layoutApplyMu, &p.resizeMu} { | |||
| 183 | + | require.True(t, lock.TryLock(), "panicked publication leaked a partial resize fence set") | |||
| 184 | + | lock.Unlock() | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func TestMoveResizeFencesWaitBeforeTakingArchitectureLocks(t *testing.T) { | |||
| 189 | + | d := &Daemon{} | |||
| 190 | + | sess := &session{id: "session"} | |||
| 191 | + | tb := &tab{stableID: "tab"} | |||
| 192 | + | p := &pane{stableID: "pane"} | |||
| 193 | + | fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p}) | |||
| 194 | + | ||||
| 195 | + | p.resizeMu.Lock() | |||
| 196 | + | acquired := make(chan bool, 1) | |||
| 197 | + | go func() { | |||
| 198 | + | acquired <- fences.acquire(func() bool { return true }) | |||
| 199 | + | }() | |||
| 200 | + | requireMutexLocked(t, &tb.layoutApplyMu, "resize fence acquisition did not reach the blocked pane") | |||
| 201 | + | ||||
| 202 | + | for name, lock := range map[string]*sync.Mutex{ | |||
| 203 | + | "daemon": &d.mu, | |||
| 204 | + | "session": &sess.mu, | |||
| 205 | + | "tab": &tb.mu, | |||
| 206 | + | } { | |||
| 207 | + | require.True(t, lock.TryLock(), "%s architecture lock was held while waiting on pane resize", name) | |||
| 208 | + | lock.Unlock() | |||
| 209 | + | } | |||
| 210 | + | p.resizeMu.Unlock() | |||
| 211 | + | ||||
| 212 | + | require.True(t, awaitTestValue(t, acquired, "resize fences did not acquire after pane resize released")) | |||
| 213 | + | fences.Release() | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | func TestMoveResizeFencesHoldThroughPublicationAndGenerationBumps(t *testing.T) { | |||
| 217 | + | source := &session{id: "source"} | |||
| 218 | + | destination := &session{id: "destination"} | |||
| 219 | + | sourceTab := &tab{stableID: "a-source-tab"} | |||
| 220 | + | destinationTab := &tab{stableID: "z-destination-tab"} | |||
| 221 | + | p := &pane{stableID: "pane"} | |||
| 222 | + | fences := newMoveResizeFences( | |||
| 223 | + | []*session{source, destination}, | |||
| 224 | + | []*tab{sourceTab, destinationTab}, | |||
| 225 | + | []*pane{p}, | |||
| 226 | + | ) | |||
| 227 | + | ||||
| 228 | + | require.True(t, fences.acquire(func() bool { | |||
| 229 | + | // This callback is the move's architecture-lock window: membership is | |||
| 230 | + | // revalidated before owner publication and both affected generations bump. | |||
| 231 | + | sourceTab.mu.Lock() | |||
| 232 | + | destinationTab.mu.Lock() | |||
| 233 | + | sourceTab.bumpLayoutGenerationLocked() | |||
| 234 | + | destinationTab.bumpLayoutGenerationLocked() | |||
| 235 | + | destinationTab.mu.Unlock() | |||
| 236 | + | sourceTab.mu.Unlock() | |||
| 237 | + | return true | |||
| 238 | + | })) | |||
| 239 | + | require.Equal(t, uint64(1), sourceTab.layoutGeneration) | |||
| 240 | + | require.Equal(t, uint64(1), destinationTab.layoutGeneration) | |||
| 241 | + | for _, lock := range []*sync.Mutex{&source.layoutApplyMu, &destination.layoutApplyMu, &sourceTab.layoutApplyMu, &destinationTab.layoutApplyMu, &p.resizeMu} { | |||
| 242 | + | require.False(t, lock.TryLock(), "publication returned without retaining every resize fence") | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | // Move callers release here, before applyTabLayout or any other PTY I/O. | |||
| 246 | + | fences.Release() | |||
| 247 | + | for _, lock := range []*sync.Mutex{&source.layoutApplyMu, &destination.layoutApplyMu, &sourceTab.layoutApplyMu, &destinationTab.layoutApplyMu, &p.resizeMu} { | |||
| 248 | + | require.True(t, lock.TryLock(), "publication fence did not release before layout application") | |||
| 249 | + | lock.Unlock() | |||
| 250 | + | } | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | func TestMovePaneResizeFencesCoverAffectedOwners(t *testing.T) { | |||
| 254 | + | source := &session{id: "source"} | |||
| 255 | + | destination := &session{id: "destination"} | |||
| 256 | + | sourceTab := &tab{stableID: "source-tab"} | |||
| 257 | + | destinationTab := &tab{stableID: "destination-tab"} | |||
| 258 | + | moved := &pane{stableID: "moved-pane"} | |||
| 259 | + | ||||
| 260 | + | fences := newMovePaneResizeFences(source, destination, sourceTab, destinationTab, moved) | |||
| 261 | + | ||||
| 262 | + | require.ElementsMatch(t, []*session{source, destination}, fences.sessions) | |||
| 263 | + | require.ElementsMatch(t, []*tab{sourceTab, destinationTab}, fences.tabs) | |||
| 264 | + | require.Equal(t, []*pane{moved}, fences.panes) | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | func TestMoveTabResizeFencesCoverEveryContainedPane(t *testing.T) { | |||
| 268 | + | for _, tc := range []struct { | |||
| 269 | + | name string | |||
| 270 | + | state floatingState | |||
| 271 | + | }{{name: "hidden", state: floatingHidden}, {name: "visible", state: floatingVisible}} { | |||
| 272 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 273 | + | source := &session{id: "source"} | |||
| 274 | + | destination := &session{id: "destination"} | |||
| 275 | + | first := &pane{stableID: "first"} | |||
| 276 | + | second := &pane{stableID: "second"} | |||
| 277 | + | floating := &pane{stableID: "floating"} | |||
| 278 | + | movedTab := &tab{ | |||
| 279 | + | stableID: "moved-tab", | |||
| 280 | + | panes: map[layout.PaneID]*pane{ | |||
| 281 | + | "pane-1": first, | |||
| 282 | + | "pane-2": second, | |||
| 283 | + | }, | |||
| 284 | + | floating: floatingSlot{state: tc.state, pane: floating, generation: 7}, | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | fences := newMoveTabResizeFences(source, destination, movedTab) | |||
| 288 | + | ||||
| 289 | + | require.ElementsMatch(t, []*session{source, destination}, fences.sessions) | |||
| 290 | + | require.Equal(t, []*tab{movedTab}, fences.tabs) | |||
| 291 | + | require.ElementsMatch(t, []*pane{first, second, floating}, fences.panes) | |||
| 292 | + | }) | |||
| 293 | + | } | |||
| 294 | + | } | |||
M internal/usecase/daemon/render_coordinator.go +71 -59
| @@ -132,35 +132,46 @@ func (c *renderCoordinator) invalidateForAttachmentAtResizeEpoch(source *attache | |||
| 132 | 132 | return c.invalidateForLeaseAtResizeEpoch(source, nil, epoch, inv) | |
| 133 | 133 | } | |
| 134 | 134 | ||
| 135 | - | // invalidateForLeaseAtResizeEpoch is the attachment-callback variant: it | |
| 136 | - | // rejects a same-object replacement whose source pointer still matches but | |
| 137 | - | // whose lifecycle lease has been revoked. | |
| 138 | - | func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) bool { | |
| 135 | + | // renderInvalidationReservation is the in-memory half of an invalidation. | |
| 136 | + | // finish performs observer, timer, and callback work after ownership fences are | |
| 137 | + | // released. | |
| 138 | + | type renderInvalidationReservation struct { | |
| 139 | + | coordinator *renderCoordinator | |
| 140 | + | invalidation renderInvalidation | |
| 141 | + | onInvalidate func(renderInvalidation) | |
| 142 | + | queueStart bool | |
| 143 | + | queueCorrelation ports.RuntimeCorrelation | |
| 144 | + | observer ports.RuntimeObserver | |
| 145 | + | old *timerToken | |
| 146 | + | arm bool | |
| 147 | + | generation uint64 | |
| 148 | + | delay time.Duration | |
| 149 | + | clock ports.Clock | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // reserveInvalidationForLeaseAtResizeEpoch publishes only coordinator memory. | |
| 153 | + | // It is safe inside a short pane-owner fence and performs no timer or observer | |
| 154 | + | // calls. | |
| 155 | + | func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) (*renderInvalidationReservation, bool) { | |
| 139 | 156 | c.mu.Lock() | |
| 140 | - | // An unbound coordinator is the manual/headless harness state; there is no | |
| 141 | - | // published production attachment to protect there. A parked target may | |
| 142 | - | // still publish its own PTY/session mutations to picker observers, but no | |
| 143 | - | // attachment-owned callback may revive that target's render path. | |
| 157 | + | defer c.mu.Unlock() | |
| 144 | 158 | detachedPreviewOnly := c.primaryDetachedLocked() && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0) | |
| 145 | 159 | if c.torndown || (lease != nil && (!c.leaseCurrentLocked(lease, true) || lease.attachment != source)) || | |
| 146 | 160 | (c.primaryDetachedLocked() && !detachedPreviewOnly) || (source != nil && c.lease != nil && c.lease.attachment != source) || | |
| 147 | 161 | (epoch != 0 && (c.resize.epoch != epoch || c.resize.source != source || (lease != nil && c.resize.lease != lease))) { | |
| 148 | - | c.mu.Unlock() | |
| 149 | - | return false | |
| 162 | + | return nil, false | |
| 150 | 163 | } | |
| 151 | - | onInvalidate := c.opts.onInvalidate | |
| 164 | + | reservation := &renderInvalidationReservation{coordinator: c, invalidation: inv, onInvalidate: c.opts.onInvalidate} | |
| 152 | 165 | c.metrics.invalidations.Add(1) | |
| 153 | 166 | wasPending, wasUrgent, wasPreviewPending := c.pending, c.pendingUrgent, c.pendingPreview | |
| 154 | - | queueStart := false | |
| 155 | - | var queueCorrelation ports.RuntimeCorrelation | |
| 156 | 167 | if !wasPending { | |
| 157 | 168 | c.ackDeferred = false | |
| 158 | 169 | c.deadlineDue = false | |
| 159 | 170 | if c.opts.observer != nil { | |
| 160 | 171 | c.queueMarked = true | |
| 161 | 172 | c.queueCorrelation = ports.NewRuntimeCorrelation() | |
| 162 | - | queueCorrelation = c.queueCorrelation | |
| 163 | - | queueStart = true | |
| 173 | + | reservation.queueCorrelation = c.queueCorrelation | |
| 174 | + | reservation.queueStart = true | |
| 164 | 175 | } | |
| 165 | 176 | } | |
| 166 | 177 | c.pending = true | |
| @@ -168,43 +179,43 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie | |||
| 168 | 179 | c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent | |
| 169 | 180 | c.pendingPreview = c.pendingPreview || c.previewWake != nil || len(c.previewWakes) != 0 | |
| 170 | 181 | c.coalesced++ | |
| 171 | - | // A deadline may have expired while synchronized output still gated the | |
| 172 | - | // pending work. Completion republishes urgently and must reserve a fresh | |
| 173 | - | // deadline rather than leaving that already-fired timer as the only arm. | |
| 174 | - | arm := !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue | |
| 175 | - | var old *timerToken | |
| 176 | - | if arm { | |
| 177 | - | _, old = c.normalLane.replaceLocked() | |
| 182 | + | reservation.arm = !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue | |
| 183 | + | if reservation.arm { | |
| 184 | + | _, reservation.old = c.normalLane.replaceLocked() | |
| 178 | 185 | } | |
| 179 | - | gen := c.normalLane.generation | |
| 180 | - | delay := minOutputRenderDeadline + time.Duration(c.outputPressure)*time.Millisecond | |
| 186 | + | reservation.generation = c.normalLane.generation | |
| 187 | + | reservation.delay = minOutputRenderDeadline + time.Duration(c.outputPressure)*time.Millisecond | |
| 181 | 188 | if c.pendingUrgent { | |
| 182 | - | delay = urgentRenderDeadline | |
| 189 | + | reservation.delay = urgentRenderDeadline | |
| 183 | 190 | } | |
| 184 | - | clock := c.opts.clock | |
| 185 | - | c.armed = c.armed || arm | |
| 186 | - | observer := c.opts.observer | |
| 187 | - | c.mu.Unlock() | |
| 188 | - | if queueStart && observer != nil { | |
| 189 | - | observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueEnqueued, 0, true)) | |
| 191 | + | reservation.clock = c.opts.clock | |
| 192 | + | reservation.observer = c.opts.observer | |
| 193 | + | c.armed = c.armed || reservation.arm | |
| 194 | + | return reservation, true | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func (r *renderInvalidationReservation) finish() { | |
| 198 | + | if r == nil || r.coordinator == nil { | |
| 199 | + | return | |
| 190 | 200 | } | |
| 191 | - | stopDetachedTimer(old) | |
| 192 | - | if !arm || clock == nil { | |
| 193 | - | if onInvalidate != nil { | |
| 194 | - | onInvalidate(inv) | |
| 201 | + | c := r.coordinator | |
| 202 | + | if r.queueStart && r.observer != nil { | |
| 203 | + | r.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", r.queueCorrelation, ports.RuntimeQueueEnqueued, 0, true)) | |
| 204 | + | } | |
| 205 | + | stopDetachedTimer(r.old) | |
| 206 | + | if !r.arm || r.clock == nil { | |
| 207 | + | if r.onInvalidate != nil { | |
| 208 | + | r.onInvalidate(r.invalidation) | |
| 195 | 209 | } | |
| 196 | - | return true | |
| 210 | + | return | |
| 197 | 211 | } | |
| 198 | - | ||
| 199 | - | // NewTimer and C may re-enter the coordinator. Publish only if this | |
| 200 | - | // generation is still the reserved deadline after those external calls. | |
| 201 | - | timer := clock.NewTimer(delay) | |
| 212 | + | timer := r.clock.NewTimer(r.delay) | |
| 202 | 213 | timerC := timer.C() | |
| 203 | 214 | c.mu.Lock() | |
| 204 | - | valid := !c.torndown && c.pending && c.normalLane.generation == gen | |
| 215 | + | valid := !c.torndown && c.pending && c.normalLane.generation == r.generation | |
| 205 | 216 | var token *timerToken | |
| 206 | 217 | if valid && timerC != nil { | |
| 207 | - | token = c.normalLane.publishLocked(gen, timer) | |
| 218 | + | token = c.normalLane.publishLocked(r.generation, timer) | |
| 208 | 219 | valid = token != nil | |
| 209 | 220 | if valid { | |
| 210 | 221 | c.supervisor.startLocked(token, timerC, func() { | |
| @@ -216,26 +227,27 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie | |||
| 216 | 227 | c.mu.Unlock() | |
| 217 | 228 | if !valid { | |
| 218 | 229 | timer.Stop() | |
| 219 | - | if onInvalidate != nil { | |
| 220 | - | onInvalidate(inv) | |
| 221 | - | } | |
| 222 | - | return true | |
| 223 | - | } | |
| 224 | - | if timerC == nil { | |
| 230 | + | } else if timerC == nil { | |
| 225 | 231 | timer.Stop() | |
| 226 | - | c.fire(gen, false, true) | |
| 227 | - | if onInvalidate != nil { | |
| 228 | - | onInvalidate(inv) | |
| 229 | - | } | |
| 230 | - | return true | |
| 232 | + | c.fire(r.generation, false, true) | |
| 233 | + | } | |
| 234 | + | if r.onInvalidate != nil { | |
| 235 | + | r.onInvalidate(r.invalidation) | |
| 236 | + | } | |
| 237 | + | if valid && timerC != nil { | |
| 238 | + | runtime.Gosched() | |
| 231 | 239 | } | |
| 232 | - | // Test-visible observation follows deadline publication, so a producer | |
| 233 | - | // callback cannot observe a successful invalidation before its timer owns | |
| 234 | - | // a worker (and race tests cannot finish while a mock expectation remains). | |
| 235 | - | if onInvalidate != nil { | |
| 236 | - | onInvalidate(inv) | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | // invalidateForLeaseAtResizeEpoch is the attachment-callback variant: it | |
| 243 | + | // rejects a same-object replacement whose source pointer still matches but | |
| 244 | + | // whose lifecycle lease has been revoked. | |
| 245 | + | func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) bool { | |
| 246 | + | reservation, ok := c.reserveInvalidationForLeaseAtResizeEpoch(source, lease, epoch, inv) | |
| 247 | + | if !ok { | |
| 248 | + | return false | |
| 237 | 249 | } | |
| 238 | - | runtime.Gosched() | |
| 250 | + | reservation.finish() | |
| 239 | 251 | return true | |
| 240 | 252 | } | |
| 241 | 253 | ||
| @@ -132,35 +132,46 @@ func (c *renderCoordinator) invalidateForAttachmentAtResizeEpoch(source *attache | |||||
| 132 | return c.invalidateForLeaseAtResizeEpoch(source, nil, epoch, inv) | 132 | return c.invalidateForLeaseAtResizeEpoch(source, nil, epoch, inv) | ||
| 133 | } | 133 | } | ||
| 134 | 134 | ||||
| 135 | - | // invalidateForLeaseAtResizeEpoch is the attachment-callback variant: it | 135 | + | // renderInvalidationReservation is the in-memory half of an invalidation. |
| 136 | - | // rejects a same-object replacement whose source pointer still matches but | 136 | + | // finish performs observer, timer, and callback work after ownership fences are |
| 137 | - | // whose lifecycle lease has been revoked. | 137 | + | // released. |
| 138 | - | func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) bool { | 138 | + | type renderInvalidationReservation struct { |
| 139 | + | coordinator *renderCoordinator | |||
| 140 | + | invalidation renderInvalidation | |||
| 141 | + | onInvalidate func(renderInvalidation) | |||
| 142 | + | queueStart bool | |||
| 143 | + | queueCorrelation ports.RuntimeCorrelation | |||
| 144 | + | observer ports.RuntimeObserver | |||
| 145 | + | old *timerToken | |||
| 146 | + | arm bool | |||
| 147 | + | generation uint64 | |||
| 148 | + | delay time.Duration | |||
| 149 | + | clock ports.Clock | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | // reserveInvalidationForLeaseAtResizeEpoch publishes only coordinator memory. | |||
| 153 | + | // It is safe inside a short pane-owner fence and performs no timer or observer | |||
| 154 | + | // calls. | |||
| 155 | + | func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) (*renderInvalidationReservation, bool) { | |||
| 139 | c.mu.Lock() | 156 | c.mu.Lock() | ||
| 140 | - | // An unbound coordinator is the manual/headless harness state; there is no | 157 | + | defer c.mu.Unlock() |
| 141 | - | // published production attachment to protect there. A parked target may | |||
| 142 | - | // still publish its own PTY/session mutations to picker observers, but no | |||
| 143 | - | // attachment-owned callback may revive that target's render path. | |||
| 144 | detachedPreviewOnly := c.primaryDetachedLocked() && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0) | 158 | detachedPreviewOnly := c.primaryDetachedLocked() && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0) | ||
| 145 | if c.torndown || (lease != nil && (!c.leaseCurrentLocked(lease, true) || lease.attachment != source)) || | 159 | if c.torndown || (lease != nil && (!c.leaseCurrentLocked(lease, true) || lease.attachment != source)) || | ||
| 146 | (c.primaryDetachedLocked() && !detachedPreviewOnly) || (source != nil && c.lease != nil && c.lease.attachment != source) || | 160 | (c.primaryDetachedLocked() && !detachedPreviewOnly) || (source != nil && c.lease != nil && c.lease.attachment != source) || | ||
| 147 | (epoch != 0 && (c.resize.epoch != epoch || c.resize.source != source || (lease != nil && c.resize.lease != lease))) { | 161 | (epoch != 0 && (c.resize.epoch != epoch || c.resize.source != source || (lease != nil && c.resize.lease != lease))) { | ||
| 148 | - | c.mu.Unlock() | 162 | + | return nil, false |
| 149 | - | return false | |||
| 150 | } | 163 | } | ||
| 151 | - | onInvalidate := c.opts.onInvalidate | 164 | + | reservation := &renderInvalidationReservation{coordinator: c, invalidation: inv, onInvalidate: c.opts.onInvalidate} |
| 152 | c.metrics.invalidations.Add(1) | 165 | c.metrics.invalidations.Add(1) | ||
| 153 | wasPending, wasUrgent, wasPreviewPending := c.pending, c.pendingUrgent, c.pendingPreview | 166 | wasPending, wasUrgent, wasPreviewPending := c.pending, c.pendingUrgent, c.pendingPreview | ||
| 154 | - | queueStart := false | |||
| 155 | - | var queueCorrelation ports.RuntimeCorrelation | |||
| 156 | if !wasPending { | 167 | if !wasPending { | ||
| 157 | c.ackDeferred = false | 168 | c.ackDeferred = false | ||
| 158 | c.deadlineDue = false | 169 | c.deadlineDue = false | ||
| 159 | if c.opts.observer != nil { | 170 | if c.opts.observer != nil { | ||
| 160 | c.queueMarked = true | 171 | c.queueMarked = true | ||
| 161 | c.queueCorrelation = ports.NewRuntimeCorrelation() | 172 | c.queueCorrelation = ports.NewRuntimeCorrelation() | ||
| 162 | - | queueCorrelation = c.queueCorrelation | 173 | + | reservation.queueCorrelation = c.queueCorrelation |
| 163 | - | queueStart = true | 174 | + | reservation.queueStart = true |
| 164 | } | 175 | } | ||
| 165 | } | 176 | } | ||
| 166 | c.pending = true | 177 | c.pending = true | ||
| @@ -168,43 +179,43 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie | |||||
| 168 | c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent | 179 | c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent | ||
| 169 | c.pendingPreview = c.pendingPreview || c.previewWake != nil || len(c.previewWakes) != 0 | 180 | c.pendingPreview = c.pendingPreview || c.previewWake != nil || len(c.previewWakes) != 0 | ||
| 170 | c.coalesced++ | 181 | c.coalesced++ | ||
| 171 | - | // A deadline may have expired while synchronized output still gated the | 182 | + | reservation.arm = !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue |
| 172 | - | // pending work. Completion republishes urgently and must reserve a fresh | 183 | + | if reservation.arm { |
| 173 | - | // deadline rather than leaving that already-fired timer as the only arm. | 184 | + | _, reservation.old = c.normalLane.replaceLocked() |
| 174 | - | arm := !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue | |||
| 175 | - | var old *timerToken | |||
| 176 | - | if arm { | |||
| 177 | - | _, old = c.normalLane.replaceLocked() | |||
| 178 | } | 185 | } | ||
| 179 | - | gen := c.normalLane.generation | 186 | + | reservation.generation = c.normalLane.generation |
| 180 | - | delay := minOutputRenderDeadline + time.Duration(c.outputPressure)*time.Millisecond | 187 | + | reservation.delay = minOutputRenderDeadline + time.Duration(c.outputPressure)*time.Millisecond |
| 181 | if c.pendingUrgent { | 188 | if c.pendingUrgent { | ||
| 182 | - | delay = urgentRenderDeadline | 189 | + | reservation.delay = urgentRenderDeadline |
| 183 | } | 190 | } | ||
| 184 | - | clock := c.opts.clock | 191 | + | reservation.clock = c.opts.clock |
| 185 | - | c.armed = c.armed || arm | 192 | + | reservation.observer = c.opts.observer |
| 186 | - | observer := c.opts.observer | 193 | + | c.armed = c.armed || reservation.arm |
| 187 | - | c.mu.Unlock() | 194 | + | return reservation, true |
| 188 | - | if queueStart && observer != nil { | 195 | + | } |
| 189 | - | observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueEnqueued, 0, true)) | 196 | + | |
| 197 | + | func (r *renderInvalidationReservation) finish() { | |||
| 198 | + | if r == nil || r.coordinator == nil { | |||
| 199 | + | return | |||
| 190 | } | 200 | } | ||
| 191 | - | stopDetachedTimer(old) | 201 | + | c := r.coordinator |
| 192 | - | if !arm || clock == nil { | 202 | + | if r.queueStart && r.observer != nil { |
| 193 | - | if onInvalidate != nil { | 203 | + | r.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", r.queueCorrelation, ports.RuntimeQueueEnqueued, 0, true)) |
| 194 | - | onInvalidate(inv) | 204 | + | } |
| 205 | + | stopDetachedTimer(r.old) | |||
| 206 | + | if !r.arm || r.clock == nil { | |||
| 207 | + | if r.onInvalidate != nil { | |||
| 208 | + | r.onInvalidate(r.invalidation) | |||
| 195 | } | 209 | } | ||
| 196 | - | return true | 210 | + | return |
| 197 | } | 211 | } | ||
| 198 | - | 212 | + | timer := r.clock.NewTimer(r.delay) | |
| 199 | - | // NewTimer and C may re-enter the coordinator. Publish only if this | |||
| 200 | - | // generation is still the reserved deadline after those external calls. | |||
| 201 | - | timer := clock.NewTimer(delay) | |||
| 202 | timerC := timer.C() | 213 | timerC := timer.C() | ||
| 203 | c.mu.Lock() | 214 | c.mu.Lock() | ||
| 204 | - | valid := !c.torndown && c.pending && c.normalLane.generation == gen | 215 | + | valid := !c.torndown && c.pending && c.normalLane.generation == r.generation |
| 205 | var token *timerToken | 216 | var token *timerToken | ||
| 206 | if valid && timerC != nil { | 217 | if valid && timerC != nil { | ||
| 207 | - | token = c.normalLane.publishLocked(gen, timer) | 218 | + | token = c.normalLane.publishLocked(r.generation, timer) |
| 208 | valid = token != nil | 219 | valid = token != nil | ||
| 209 | if valid { | 220 | if valid { | ||
| 210 | c.supervisor.startLocked(token, timerC, func() { | 221 | c.supervisor.startLocked(token, timerC, func() { | ||
| @@ -216,26 +227,27 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie | |||||
| 216 | c.mu.Unlock() | 227 | c.mu.Unlock() | ||
| 217 | if !valid { | 228 | if !valid { | ||
| 218 | timer.Stop() | 229 | timer.Stop() | ||
| 219 | - | if onInvalidate != nil { | 230 | + | } else if timerC == nil { |
| 220 | - | onInvalidate(inv) | |||
| 221 | - | } | |||
| 222 | - | return true | |||
| 223 | - | } | |||
| 224 | - | if timerC == nil { | |||
| 225 | timer.Stop() | 231 | timer.Stop() | ||
| 226 | - | c.fire(gen, false, true) | 232 | + | c.fire(r.generation, false, true) |
| 227 | - | if onInvalidate != nil { | 233 | + | } |
| 228 | - | onInvalidate(inv) | 234 | + | if r.onInvalidate != nil { |
| 229 | - | } | 235 | + | r.onInvalidate(r.invalidation) |
| 230 | - | return true | 236 | + | } |
| 237 | + | if valid && timerC != nil { | |||
| 238 | + | runtime.Gosched() | |||
| 231 | } | 239 | } | ||
| 232 | - | // Test-visible observation follows deadline publication, so a producer | 240 | + | } |
| 233 | - | // callback cannot observe a successful invalidation before its timer owns | 241 | + | |
| 234 | - | // a worker (and race tests cannot finish while a mock expectation remains). | 242 | + | // invalidateForLeaseAtResizeEpoch is the attachment-callback variant: it |
| 235 | - | if onInvalidate != nil { | 243 | + | // rejects a same-object replacement whose source pointer still matches but |
| 236 | - | onInvalidate(inv) | 244 | + | // whose lifecycle lease has been revoked. |
| 245 | + | func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) bool { | |||
| 246 | + | reservation, ok := c.reserveInvalidationForLeaseAtResizeEpoch(source, lease, epoch, inv) | |||
| 247 | + | if !ok { | |||
| 248 | + | return false | |||
| 237 | } | 249 | } | ||
| 238 | - | runtime.Gosched() | 250 | + | reservation.finish() |
| 239 | return true | 251 | return true | ||
| 240 | } | 252 | } | ||
| 241 | 253 | ||||
M internal/usecase/daemon/render_resize_deadline.go +43 -13
| @@ -1,6 +1,9 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/internal/domain" | |
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/internal/ports" | |
| 6 | + | ) | |
| 4 | 7 | ||
| 5 | 8 | // Resize deadlines and retries retain coordinator-owned attachment, epoch, and | |
| 6 | 9 | // token validation at every callback effect boundary. | |
| @@ -90,40 +93,59 @@ func (c *renderCoordinator) resizeCurrentForLease(epoch uint64, source *attached | |||
| 90 | 93 | return true | |
| 91 | 94 | } | |
| 92 | 95 | ||
| 93 | - | func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *attachedClient, lease *attachmentLease, run func()) { | |
| 96 | + | type resizeRetryReservation struct { | |
| 97 | + | coordinator *renderCoordinator | |
| 98 | + | epoch uint64 | |
| 99 | + | source *attachedClient | |
| 100 | + | lease *attachmentLease | |
| 101 | + | run func() | |
| 102 | + | generation uint64 | |
| 103 | + | old *timerToken | |
| 104 | + | clock ports.Clock | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // reserveResizeRetryForLease publishes the retry-lane generation only. Timer | |
| 108 | + | // calls are deferred to finish so owner fences protect no external operation. | |
| 109 | + | func (c *renderCoordinator) reserveResizeRetryForLease(epoch uint64, source *attachedClient, lease *attachmentLease, run func()) (*resizeRetryReservation, bool) { | |
| 94 | 110 | c.mu.Lock() | |
| 111 | + | defer c.mu.Unlock() | |
| 95 | 112 | if !c.leaseCurrentLocked(lease, false) || lease.attachment != source || c.resize.lease != lease || c.resize.source != source || c.resize.epoch != epoch || c.resize.committed != epoch { | |
| 96 | - | c.mu.Unlock() | |
| 97 | - | return | |
| 113 | + | return nil, false | |
| 98 | 114 | } | |
| 99 | 115 | gen, old := c.retryLane.replaceLocked() | |
| 100 | - | clock := c.opts.clock | |
| 101 | - | c.mu.Unlock() | |
| 102 | - | stopDetachedTimer(old) | |
| 103 | - | if clock == nil { | |
| 116 | + | return &resizeRetryReservation{coordinator: c, epoch: epoch, source: source, lease: lease, run: run, generation: gen, old: old, clock: c.opts.clock}, true | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func (r *resizeRetryReservation) finish() { | |
| 120 | + | if r == nil || r.coordinator == nil { | |
| 104 | 121 | return | |
| 105 | 122 | } | |
| 106 | - | timer := clock.NewTimer(minOutputRenderDeadline) | |
| 123 | + | c := r.coordinator | |
| 124 | + | stopDetachedTimer(r.old) | |
| 125 | + | if r.clock == nil { | |
| 126 | + | return | |
| 127 | + | } | |
| 128 | + | timer := r.clock.NewTimer(minOutputRenderDeadline) | |
| 107 | 129 | timerC := timer.C() | |
| 108 | 130 | if timerC == nil { | |
| 109 | 131 | timer.Stop() | |
| 110 | 132 | return | |
| 111 | 133 | } | |
| 112 | 134 | c.mu.Lock() | |
| 113 | - | valid := !c.torndown && c.retryLane.generation == gen && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.source == source | |
| 135 | + | valid := !c.torndown && c.retryLane.generation == r.generation && c.leaseCurrentLocked(r.lease, false) && c.resize.lease == r.lease && c.resize.epoch == r.epoch && c.resize.committed == r.epoch && c.resize.source == r.source | |
| 114 | 136 | if valid { | |
| 115 | - | token := c.retryLane.publishLocked(gen, timer) | |
| 137 | + | token := c.retryLane.publishLocked(r.generation, timer) | |
| 116 | 138 | valid = token != nil | |
| 117 | 139 | if valid { | |
| 118 | 140 | c.supervisor.startLocked(token, timerC, func() { | |
| 119 | 141 | c.mu.Lock() | |
| 120 | - | valid := !c.torndown && c.retryLane.token == token && c.retryLane.generation == token.generation && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.source == source | |
| 142 | + | valid := !c.torndown && c.retryLane.token == token && c.retryLane.generation == token.generation && c.leaseCurrentLocked(r.lease, false) && c.resize.lease == r.lease && c.resize.epoch == r.epoch && c.resize.committed == r.epoch && c.resize.source == r.source | |
| 121 | 143 | if valid { | |
| 122 | 144 | c.retryLane.clearLocked(token) | |
| 123 | 145 | } | |
| 124 | 146 | c.mu.Unlock() | |
| 125 | 147 | if valid { | |
| 126 | - | run() | |
| 148 | + | r.run() | |
| 127 | 149 | } | |
| 128 | 150 | }) | |
| 129 | 151 | } | |
| @@ -134,6 +156,14 @@ func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *at | |||
| 134 | 156 | } | |
| 135 | 157 | } | |
| 136 | 158 | ||
| 159 | + | func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *attachedClient, lease *attachmentLease, run func()) { | |
| 160 | + | reservation, ok := c.reserveResizeRetryForLease(epoch, source, lease, run) | |
| 161 | + | if !ok { | |
| 162 | + | return | |
| 163 | + | } | |
| 164 | + | reservation.finish() | |
| 165 | + | } | |
| 166 | + | ||
| 137 | 167 | func (c *renderCoordinator) retryCurrentForLease(epoch uint64, source *attachedClient, lease *attachmentLease) bool { | |
| 138 | 168 | c.mu.Lock() | |
| 139 | 169 | defer c.mu.Unlock() | |
| @@ -1,6 +1,9 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | - | import "github.com/bnema/vev/internal/domain" | 3 | + | import ( |
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 5 | + | "github.com/bnema/vev/internal/ports" | |||
| 6 | + | ) | |||
| 4 | 7 | ||||
| 5 | // Resize deadlines and retries retain coordinator-owned attachment, epoch, and | 8 | // Resize deadlines and retries retain coordinator-owned attachment, epoch, and | ||
| 6 | // token validation at every callback effect boundary. | 9 | // token validation at every callback effect boundary. | ||
| @@ -90,40 +93,59 @@ func (c *renderCoordinator) resizeCurrentForLease(epoch uint64, source *attached | |||||
| 90 | return true | 93 | return true | ||
| 91 | } | 94 | } | ||
| 92 | 95 | ||||
| 93 | - | func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *attachedClient, lease *attachmentLease, run func()) { | 96 | + | type resizeRetryReservation struct { |
| 97 | + | coordinator *renderCoordinator | |||
| 98 | + | epoch uint64 | |||
| 99 | + | source *attachedClient | |||
| 100 | + | lease *attachmentLease | |||
| 101 | + | run func() | |||
| 102 | + | generation uint64 | |||
| 103 | + | old *timerToken | |||
| 104 | + | clock ports.Clock | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // reserveResizeRetryForLease publishes the retry-lane generation only. Timer | |||
| 108 | + | // calls are deferred to finish so owner fences protect no external operation. | |||
| 109 | + | func (c *renderCoordinator) reserveResizeRetryForLease(epoch uint64, source *attachedClient, lease *attachmentLease, run func()) (*resizeRetryReservation, bool) { | |||
| 94 | c.mu.Lock() | 110 | c.mu.Lock() | ||
| 111 | + | defer c.mu.Unlock() | |||
| 95 | if !c.leaseCurrentLocked(lease, false) || lease.attachment != source || c.resize.lease != lease || c.resize.source != source || c.resize.epoch != epoch || c.resize.committed != epoch { | 112 | if !c.leaseCurrentLocked(lease, false) || lease.attachment != source || c.resize.lease != lease || c.resize.source != source || c.resize.epoch != epoch || c.resize.committed != epoch { | ||
| 96 | - | c.mu.Unlock() | 113 | + | return nil, false |
| 97 | - | return | |||
| 98 | } | 114 | } | ||
| 99 | gen, old := c.retryLane.replaceLocked() | 115 | gen, old := c.retryLane.replaceLocked() | ||
| 100 | - | clock := c.opts.clock | 116 | + | return &resizeRetryReservation{coordinator: c, epoch: epoch, source: source, lease: lease, run: run, generation: gen, old: old, clock: c.opts.clock}, true |
| 101 | - | c.mu.Unlock() | 117 | + | } |
| 102 | - | stopDetachedTimer(old) | 118 | + | |
| 103 | - | if clock == nil { | 119 | + | func (r *resizeRetryReservation) finish() { |
| 120 | + | if r == nil || r.coordinator == nil { | |||
| 104 | return | 121 | return | ||
| 105 | } | 122 | } | ||
| 106 | - | timer := clock.NewTimer(minOutputRenderDeadline) | 123 | + | c := r.coordinator |
| 124 | + | stopDetachedTimer(r.old) | |||
| 125 | + | if r.clock == nil { | |||
| 126 | + | return | |||
| 127 | + | } | |||
| 128 | + | timer := r.clock.NewTimer(minOutputRenderDeadline) | |||
| 107 | timerC := timer.C() | 129 | timerC := timer.C() | ||
| 108 | if timerC == nil { | 130 | if timerC == nil { | ||
| 109 | timer.Stop() | 131 | timer.Stop() | ||
| 110 | return | 132 | return | ||
| 111 | } | 133 | } | ||
| 112 | c.mu.Lock() | 134 | c.mu.Lock() | ||
| 113 | - | valid := !c.torndown && c.retryLane.generation == gen && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.source == source | 135 | + | valid := !c.torndown && c.retryLane.generation == r.generation && c.leaseCurrentLocked(r.lease, false) && c.resize.lease == r.lease && c.resize.epoch == r.epoch && c.resize.committed == r.epoch && c.resize.source == r.source |
| 114 | if valid { | 136 | if valid { | ||
| 115 | - | token := c.retryLane.publishLocked(gen, timer) | 137 | + | token := c.retryLane.publishLocked(r.generation, timer) |
| 116 | valid = token != nil | 138 | valid = token != nil | ||
| 117 | if valid { | 139 | if valid { | ||
| 118 | c.supervisor.startLocked(token, timerC, func() { | 140 | c.supervisor.startLocked(token, timerC, func() { | ||
| 119 | c.mu.Lock() | 141 | c.mu.Lock() | ||
| 120 | - | valid := !c.torndown && c.retryLane.token == token && c.retryLane.generation == token.generation && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.source == source | 142 | + | valid := !c.torndown && c.retryLane.token == token && c.retryLane.generation == token.generation && c.leaseCurrentLocked(r.lease, false) && c.resize.lease == r.lease && c.resize.epoch == r.epoch && c.resize.committed == r.epoch && c.resize.source == r.source |
| 121 | if valid { | 143 | if valid { | ||
| 122 | c.retryLane.clearLocked(token) | 144 | c.retryLane.clearLocked(token) | ||
| 123 | } | 145 | } | ||
| 124 | c.mu.Unlock() | 146 | c.mu.Unlock() | ||
| 125 | if valid { | 147 | if valid { | ||
| 126 | - | run() | 148 | + | r.run() |
| 127 | } | 149 | } | ||
| 128 | }) | 150 | }) | ||
| 129 | } | 151 | } | ||
| @@ -134,6 +156,14 @@ func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *at | |||||
| 134 | } | 156 | } | ||
| 135 | } | 157 | } | ||
| 136 | 158 | ||||
| 159 | + | func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *attachedClient, lease *attachmentLease, run func()) { | |||
| 160 | + | reservation, ok := c.reserveResizeRetryForLease(epoch, source, lease, run) | |||
| 161 | + | if !ok { | |||
| 162 | + | return | |||
| 163 | + | } | |||
| 164 | + | reservation.finish() | |||
| 165 | + | } | |||
| 166 | + | ||||
| 137 | func (c *renderCoordinator) retryCurrentForLease(epoch uint64, source *attachedClient, lease *attachmentLease) bool { | 167 | func (c *renderCoordinator) retryCurrentForLease(epoch uint64, source *attachedClient, lease *attachmentLease) bool { | ||
| 138 | c.mu.Lock() | 168 | c.mu.Lock() | ||
| 139 | defer c.mu.Unlock() | 169 | defer c.mu.Unlock() | ||
M internal/usecase/daemon/transactional_resize.go +411 -88
| @@ -8,12 +8,22 @@ import ( | |||
| 8 | 8 | "github.com/bnema/vev/internal/usecase/layout" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | + | type resizeOwnerPostEffect uint8 | |
| 12 | + | ||
| 13 | + | const ( | |
| 14 | + | resizeOwnerPostSnapshotDirty resizeOwnerPostEffect = iota + 1 | |
| 15 | + | resizeOwnerPostRetrySchedule | |
| 16 | + | resizeOwnerPostRenderInvalidation | |
| 17 | + | resizeOwnerPostCommitPublication | |
| 18 | + | ) | |
| 19 | + | ||
| 11 | 20 | // resizeMember is an unpublished layout decision. Prepare only reads guarded | |
| 12 | 21 | // state; apply performs the external PTY call; commit is the sole publisher. | |
| 13 | 22 | type resizeMember struct { | |
| 14 | 23 | session *session | |
| 15 | 24 | tab *tab | |
| 16 | 25 | pane *pane | |
| 26 | + | owner paneEffectLease | |
| 17 | 27 | rect domain.Rect | |
| 18 | 28 | floating floatingGeometry | |
| 19 | 29 | isFloating bool | |
| @@ -35,6 +45,11 @@ type preparedTabLayout struct { | |||
| 35 | 45 | members []resizeMember | |
| 36 | 46 | } | |
| 37 | 47 | ||
| 48 | + | type resizeApplyResult struct { | |
| 49 | + | members []resizeMember | |
| 50 | + | failed []resizeMember | |
| 51 | + | } | |
| 52 | + | ||
| 38 | 53 | func prepareTabLayoutLocked(sess *session, tb *tab) preparedTabLayout { | |
| 39 | 54 | return prepareTabLayoutForSizeLocked(sess, tb, tb.size) | |
| 40 | 55 | } | |
| @@ -58,7 +73,10 @@ func prepareTabLayoutForSizeLocked(sess *session, tb *tab, size domain.Size) pre | |||
| 58 | 73 | continue | |
| 59 | 74 | } | |
| 60 | 75 | if p := tb.panes[placement.ID]; p != nil { | |
| 61 | - | plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: p, rect: placement.Content}) | |
| 76 | + | p.mu.Lock() | |
| 77 | + | owner := p.effectLeaseLocked() | |
| 78 | + | p.mu.Unlock() | |
| 79 | + | plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: p, owner: owner, rect: placement.Content}) | |
| 62 | 80 | } | |
| 63 | 81 | } | |
| 64 | 82 | return plan | |
| @@ -72,6 +90,13 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) { | |||
| 72 | 90 | p := member.pane | |
| 73 | 91 | p.resizeMu.Lock() | |
| 74 | 92 | p.mu.Lock() | |
| 93 | + | if !resizeMemberOwnerCurrentLocked(member) { | |
| 94 | + | member.retry = p.owner.Load() != nil | |
| 95 | + | p.resizeRetry = member.retry | |
| 96 | + | p.mu.Unlock() | |
| 97 | + | p.resizeMu.Unlock() | |
| 98 | + | continue | |
| 99 | + | } | |
| 75 | 100 | old := p.rect | |
| 76 | 101 | pty := p.pty | |
| 77 | 102 | // A rejected earlier attempt deliberately leaves its parser gate open. | |
| @@ -88,36 +113,102 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) { | |||
| 88 | 113 | } else if err := pty.Resize(rectSize(member.rect)); err != nil { | |
| 89 | 114 | d.log.Warn("pty resize failed", "err", err) | |
| 90 | 115 | p.mu.Lock() | |
| 116 | + | current := resizeMemberOwnerCurrentLocked(member) | |
| 91 | 117 | p.resizeRetry = true | |
| 92 | 118 | p.mu.Unlock() | |
| 93 | 119 | d.replayResizePending(member.session, member.tab, p, false, member.rect) | |
| 94 | 120 | member.retry = true | |
| 95 | - | member.err = err | |
| 121 | + | if current { | |
| 122 | + | member.err = err | |
| 123 | + | } | |
| 96 | 124 | } else { | |
| 97 | - | member.ok = true | |
| 125 | + | p.mu.Lock() | |
| 126 | + | current := resizeMemberOwnerCurrentLocked(member) | |
| 127 | + | if !current && p.owner.Load() != nil { | |
| 128 | + | p.resizeRetry = true | |
| 129 | + | member.retry = true | |
| 130 | + | } | |
| 131 | + | p.mu.Unlock() | |
| 132 | + | member.ok = current | |
| 98 | 133 | member.screenResized = true // records that this member owns an open gate | |
| 99 | 134 | } | |
| 100 | 135 | p.resizeMu.Unlock() | |
| 101 | 136 | } | |
| 102 | 137 | } | |
| 103 | 138 | ||
| 139 | + | func resizeMemberOwnerCurrentLocked(member *resizeMember) bool { | |
| 140 | + | if member == nil || member.pane == nil { | |
| 141 | + | return false | |
| 142 | + | } | |
| 143 | + | // A zero lease is retained only for focused tests which construct the | |
| 144 | + | // transactional primitive directly. Every prepared production member owns | |
| 145 | + | // an immutable generation lease. | |
| 146 | + | if member.owner.owner == nil { | |
| 147 | + | return true | |
| 148 | + | } | |
| 149 | + | owner := member.pane.owner.Load() | |
| 150 | + | return owner == member.owner.owner && owner.session == member.session && owner.tab == member.tab | |
| 151 | + | } | |
| 152 | + | ||
| 104 | 153 | func validatePreparedTabLayoutLocked(tb *tab, plan *preparedTabLayout) bool { | |
| 105 | 154 | if tb.layoutGeneration != plan.generation || tb.size != plan.previousSize { | |
| 106 | 155 | return false | |
| 107 | 156 | } | |
| 108 | 157 | for i := range plan.members { | |
| 109 | 158 | member := &plan.members[i] | |
| 110 | - | if tb.panes[member.pane.id] != member.pane { | |
| 159 | + | if tb.panes[member.pane.id] != member.pane || !resizeMemberOwnerCurrentLocked(member) { | |
| 111 | 160 | return false | |
| 112 | 161 | } | |
| 113 | 162 | } | |
| 114 | 163 | return true | |
| 115 | 164 | } | |
| 116 | 165 | ||
| 117 | - | func commitPreparedTabLayoutLocked(plan *preparedTabLayout) { | |
| 166 | + | func resizeMembersOwnerCurrent(members []resizeMember) bool { | |
| 167 | + | for i := range members { | |
| 168 | + | if !resizeMemberOwnerCurrentLocked(&members[i]) { | |
| 169 | + | return false | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | return true | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func (d *Daemon) publishResizeOwnerInvalidation(members []resizeMember, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) bool { | |
| 176 | + | var reservation *renderInvalidationReservation | |
| 177 | + | fallback := false | |
| 178 | + | if !d.publishResizeOwnerPostEffect(members, resizeOwnerPostRenderInvalidation, func() { | |
| 179 | + | if rc := sess.renderCoordinator(); rc != nil { | |
| 180 | + | reservation, _ = rc.reserveInvalidationForLeaseAtResizeEpoch(ac, lease, epoch, inv) | |
| 181 | + | return | |
| 182 | + | } | |
| 183 | + | fallback = ac != nil | |
| 184 | + | }) { | |
| 185 | + | return false | |
| 186 | + | } | |
| 187 | + | if reservation != nil { | |
| 188 | + | reservation.finish() | |
| 189 | + | return true | |
| 190 | + | } | |
| 191 | + | if fallback { | |
| 192 | + | d.paint(sess, ac, inv.reset, nil) | |
| 193 | + | return true | |
| 194 | + | } | |
| 195 | + | return ac == nil | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func preparedTabOwnerStale(plan *preparedTabLayout) bool { | |
| 199 | + | return plan != nil && !resizeMembersOwnerCurrent(plan.members) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func commitPreparedTabLayoutLocked(plan *preparedTabLayout) bool { | |
| 118 | 203 | for i := range plan.members { | |
| 119 | 204 | member := &plan.members[i] | |
| 120 | 205 | member.pane.mu.Lock() | |
| 206 | + | if !resizeMemberOwnerCurrentLocked(member) { | |
| 207 | + | member.pane.resizeRetry = member.pane.owner.Load() != nil | |
| 208 | + | member.retry = member.pane.resizeRetry | |
| 209 | + | member.pane.mu.Unlock() | |
| 210 | + | return false | |
| 211 | + | } | |
| 121 | 212 | member.pane.rect = member.rect | |
| 122 | 213 | if member.ok { | |
| 123 | 214 | member.pane.resizeRetry = false | |
| @@ -125,6 +216,7 @@ func commitPreparedTabLayoutLocked(plan *preparedTabLayout) { | |||
| 125 | 216 | } | |
| 126 | 217 | member.pane.mu.Unlock() | |
| 127 | 218 | } | |
| 219 | + | return true | |
| 128 | 220 | } | |
| 129 | 221 | ||
| 130 | 222 | // cancelStalePreparedGates releases gates for members which disappeared from | |
| @@ -173,22 +265,22 @@ func (d *Daemon) finishPreparedTabMembers(plan *preparedTabLayout, accepted bool | |||
| 173 | 265 | // applyTabLayoutTransaction is the canonical tiled-layout publisher. It owns | |
| 174 | 266 | // one per-tab single-writer loop, but never holds tab or pane state locks while | |
| 175 | 267 | // invoking PTY.Resize. | |
| 176 | - | func (d *Daemon) applyTabLayoutTransaction(sess *session, tb *tab, current ...func() bool) ([]resizeMember, bool) { | |
| 268 | + | func (d *Daemon) applyTabLayoutTransaction(sess *session, tb *tab, current ...func() bool) (resizeApplyResult, bool) { | |
| 177 | 269 | return d.applyTabLayoutTransactionWithNotice(sess, tb, true, current...) | |
| 178 | 270 | } | |
| 179 | 271 | ||
| 180 | - | func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, reportFailure bool, current ...func() bool) ([]resizeMember, bool) { | |
| 272 | + | func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, reportFailure bool, current ...func() bool) (resizeApplyResult, bool) { | |
| 181 | 273 | if tb == nil { | |
| 182 | - | return nil, false | |
| 274 | + | return resizeApplyResult{}, false | |
| 183 | 275 | } | |
| 184 | 276 | tb.layoutApplyMu.Lock() | |
| 185 | 277 | defer tb.layoutApplyMu.Unlock() | |
| 186 | 278 | for { | |
| 187 | 279 | if tb.ctx != nil && tb.ctx.Err() != nil { | |
| 188 | - | return nil, false | |
| 280 | + | return resizeApplyResult{}, false | |
| 189 | 281 | } | |
| 190 | 282 | if len(current) != 0 && !current[0]() { | |
| 191 | - | return nil, false | |
| 283 | + | return resizeApplyResult{}, false | |
| 192 | 284 | } | |
| 193 | 285 | tb.mu.Lock() | |
| 194 | 286 | plan := prepareTabLayoutLocked(sess, tb) | |
| @@ -201,12 +293,16 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep | |||
| 201 | 293 | accepted = false | |
| 202 | 294 | } | |
| 203 | 295 | if accepted { | |
| 204 | - | commitPreparedTabLayoutLocked(&plan) | |
| 296 | + | accepted = commitPreparedTabLayoutLocked(&plan) | |
| 205 | 297 | } | |
| 206 | 298 | tb.mu.Unlock() | |
| 207 | 299 | d.finishPreparedTabMembers(&plan, accepted) | |
| 208 | 300 | if !accepted { | |
| 209 | 301 | d.cancelStalePreparedGates(sess, tb, &plan) | |
| 302 | + | if preparedTabOwnerStale(&plan) { | |
| 303 | + | d.releasePreparedSessionGates([]*preparedTabLayout{&plan}) | |
| 304 | + | return resizeApplyResult{}, false | |
| 305 | + | } | |
| 210 | 306 | if len(current) != 0 && !current[0]() { | |
| 211 | 307 | for i := range plan.members { | |
| 212 | 308 | member := &plan.members[i] | |
| @@ -217,7 +313,7 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep | |||
| 217 | 313 | d.replayResizePending(member.session, member.tab, member.pane, false, member.rect) | |
| 218 | 314 | member.pane.resizeMu.Unlock() | |
| 219 | 315 | } | |
| 220 | - | return nil, false | |
| 316 | + | return resizeApplyResult{}, false | |
| 221 | 317 | } | |
| 222 | 318 | continue | |
| 223 | 319 | } | |
| @@ -227,44 +323,104 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep | |||
| 227 | 323 | failed = append(failed, member) | |
| 228 | 324 | } | |
| 229 | 325 | } | |
| 230 | - | if reportFailure && len(failed) != 0 { | |
| 326 | + | if reportFailure && len(failed) != 0 && resizeMembersOwnerCurrent(failed) { | |
| 231 | 327 | d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed, | |
| 232 | 328 | "pane resize failed; retrying in background", failed[len(failed)-1].err) | |
| 233 | 329 | } | |
| 234 | - | return failed, true | |
| 330 | + | return resizeApplyResult{members: append([]resizeMember(nil), plan.members...), failed: failed}, true | |
| 235 | 331 | } | |
| 236 | 332 | } | |
| 237 | 333 | ||
| 238 | 334 | func (d *Daemon) applyTabLayout(sess *session, tb *tab) bool { | |
| 239 | - | failed, ok := d.applyTabLayoutTransaction(sess, tb) | |
| 335 | + | result, ok := d.applyTabLayoutTransaction(sess, tb) | |
| 240 | 336 | if !ok { | |
| 241 | 337 | return false | |
| 242 | 338 | } | |
| 243 | - | markSnapshotDirty(sess) | |
| 244 | - | if len(failed) != 0 { | |
| 245 | - | d.scheduleAcceptedTabLayoutRetry(sess, tb) | |
| 339 | + | if !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() { | |
| 340 | + | markSnapshotDirty(sess) | |
| 341 | + | }) { | |
| 342 | + | return false | |
| 343 | + | } | |
| 344 | + | if len(result.failed) != 0 { | |
| 345 | + | if !d.scheduleAcceptedTabLayoutRetry(sess, tb, result.failed) { | |
| 346 | + | return false | |
| 347 | + | } | |
| 246 | 348 | } | |
| 247 | 349 | return true | |
| 248 | 350 | } | |
| 249 | 351 | ||
| 250 | 352 | const maxAcceptedTabLayoutRetries = 3 | |
| 251 | 353 | ||
| 354 | + | // acceptedTabLayoutRetryToken binds a delayed tiled retry to the exact pane | |
| 355 | + | // owner generations which accepted the failure. A moved pane cannot lend its | |
| 356 | + | // old source worker authority over either the PTY or source publications. | |
| 357 | + | type acceptedTabLayoutRetryToken struct { | |
| 358 | + | session *session | |
| 359 | + | tab *tab | |
| 360 | + | members []resizeMember | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | func newAcceptedTabLayoutRetryToken(sess *session, tb *tab, members []resizeMember) acceptedTabLayoutRetryToken { | |
| 364 | + | return acceptedTabLayoutRetryToken{session: sess, tab: tb, members: append([]resizeMember(nil), members...)} | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | func (t acceptedTabLayoutRetryToken) current() bool { | |
| 368 | + | if t.session == nil || t.tab == nil || len(t.members) == 0 { | |
| 369 | + | return false | |
| 370 | + | } | |
| 371 | + | for i := range t.members { | |
| 372 | + | member := &t.members[i] | |
| 373 | + | if member.session != t.session || member.tab != t.tab || !resizeMemberOwnerCurrentLocked(member) { | |
| 374 | + | return false | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | return true | |
| 378 | + | } | |
| 379 | + | ||
| 252 | 380 | // scheduleAcceptedTabLayoutRetry owns one bounded retry worker per tab. The | |
| 253 | 381 | // worker is deduplicated, derives cancellation from the tab lifecycle, and | |
| 254 | 382 | // suppresses repeat degradation notices after the accepted initial failure. | |
| 255 | - | func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) { | |
| 383 | + | func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab, failed ...[]resizeMember) bool { | |
| 256 | 384 | if d.clock == nil || tb == nil || tb.ctx == nil { | |
| 257 | - | return | |
| 385 | + | return false | |
| 258 | 386 | } | |
| 259 | - | tb.layoutRetryMu.Lock() | |
| 260 | - | if tb.layoutRetryRunning { | |
| 261 | - | tb.layoutRetryMu.Unlock() | |
| 262 | - | return | |
| 387 | + | var members []resizeMember | |
| 388 | + | if len(failed) != 0 { | |
| 389 | + | members = failed[0] | |
| 390 | + | } else { | |
| 391 | + | tb.mu.Lock() | |
| 392 | + | plan := prepareTabLayoutLocked(sess, tb) | |
| 393 | + | tb.mu.Unlock() | |
| 394 | + | for i := range plan.members { | |
| 395 | + | member := plan.members[i] | |
| 396 | + | member.pane.mu.Lock() | |
| 397 | + | pending := member.pane.resizeRetry | |
| 398 | + | member.pane.mu.Unlock() | |
| 399 | + | if pending { | |
| 400 | + | members = append(members, member) | |
| 401 | + | } | |
| 402 | + | } | |
| 403 | + | } | |
| 404 | + | token := newAcceptedTabLayoutRetryToken(sess, tb, members) | |
| 405 | + | var ctx context.Context | |
| 406 | + | var cancel context.CancelFunc | |
| 407 | + | start := false | |
| 408 | + | if !d.publishResizeOwnerPostEffect(members, resizeOwnerPostRetrySchedule, func() { | |
| 409 | + | tb.layoutRetryMu.Lock() | |
| 410 | + | defer tb.layoutRetryMu.Unlock() | |
| 411 | + | if tb.layoutRetryRunning { | |
| 412 | + | return | |
| 413 | + | } | |
| 414 | + | ctx, cancel = context.WithCancel(tb.ctx) | |
| 415 | + | tb.layoutRetryRunning = true | |
| 416 | + | tb.layoutRetryCancel = cancel | |
| 417 | + | start = true | |
| 418 | + | }) { | |
| 419 | + | return false | |
| 420 | + | } | |
| 421 | + | if !start { | |
| 422 | + | return true | |
| 263 | 423 | } | |
| 264 | - | ctx, cancel := context.WithCancel(tb.ctx) | |
| 265 | - | tb.layoutRetryRunning = true | |
| 266 | - | tb.layoutRetryCancel = cancel | |
| 267 | - | tb.layoutRetryMu.Unlock() | |
| 268 | 424 | ||
| 269 | 425 | go func() { | |
| 270 | 426 | defer func() { | |
| @@ -275,6 +431,9 @@ func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) { | |||
| 275 | 431 | tb.layoutRetryMu.Unlock() | |
| 276 | 432 | }() | |
| 277 | 433 | for range maxAcceptedTabLayoutRetries { | |
| 434 | + | if !token.current() { | |
| 435 | + | return | |
| 436 | + | } | |
| 278 | 437 | timer := d.clock.NewTimer(minOutputRenderDeadline) | |
| 279 | 438 | if timer == nil { | |
| 280 | 439 | return | |
| @@ -290,22 +449,37 @@ func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) { | |||
| 290 | 449 | return | |
| 291 | 450 | case <-timerC: | |
| 292 | 451 | } | |
| 293 | - | if ctx.Err() != nil { | |
| 452 | + | if ctx.Err() != nil || !token.current() { | |
| 294 | 453 | return | |
| 295 | 454 | } | |
| 296 | - | failed, ok := d.applyTabLayoutTransactionWithNotice(sess, tb, false, func() bool { return ctx.Err() == nil }) | |
| 297 | - | if !ok || len(failed) == 0 { | |
| 455 | + | result, ok := d.applyTabLayoutTransactionWithNotice(sess, tb, false, func() bool { | |
| 456 | + | return ctx.Err() == nil && token.current() | |
| 457 | + | }) | |
| 458 | + | if !ok || !token.current() { | |
| 459 | + | return | |
| 460 | + | } | |
| 461 | + | if len(result.failed) == 0 { | |
| 462 | + | return | |
| 463 | + | } | |
| 464 | + | token = newAcceptedTabLayoutRetryToken(sess, tb, result.failed) | |
| 465 | + | if !token.current() { | |
| 466 | + | return | |
| 467 | + | } | |
| 468 | + | if !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() { | |
| 469 | + | markSnapshotDirty(sess) | |
| 470 | + | }) { | |
| 298 | 471 | return | |
| 299 | 472 | } | |
| 300 | - | markSnapshotDirty(sess) | |
| 301 | 473 | sess.mu.Lock() | |
| 302 | 474 | ac := sess.client | |
| 303 | 475 | sess.mu.Unlock() | |
| 304 | - | if ac != nil { | |
| 305 | - | d.invalidateRender(sess, ac, true, "transactional_resize.go") | |
| 476 | + | if ac != nil && !d.publishResizeOwnerInvalidation(result.members, sess, ac, nil, 0, | |
| 477 | + | renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) { | |
| 478 | + | return | |
| 306 | 479 | } | |
| 307 | 480 | } | |
| 308 | 481 | }() | |
| 482 | + | return true | |
| 309 | 483 | } | |
| 310 | 484 | ||
| 311 | 485 | // replayResizePending completes a pane's apply epoch. It keeps the gate set | |
| @@ -344,22 +518,22 @@ func (d *Daemon) replayResizePending(sess *session, tb *tab, p *pane, resized bo | |||
| 344 | 518 | // used for retryable PTY work. Floating state is not part of a tiled layout | |
| 345 | 519 | // generation, so its slot generation and exact pane identity validate its | |
| 346 | 520 | // publication instead. | |
| 347 | - | func (d *Daemon) applyVisibleFloatingLayout(sess *session, tb *tab, current func() bool) ([]resizeMember, bool) { | |
| 521 | + | func (d *Daemon) applyVisibleFloatingLayout(sess *session, tb *tab, current func() bool) (resizeApplyResult, bool) { | |
| 348 | 522 | return d.applyVisibleFloatingLayoutForMember(sess, tb, current, nil) | |
| 349 | 523 | } | |
| 350 | 524 | ||
| 351 | 525 | // applyVisibleFloatingLayoutForMember optionally fences a delayed retry to the | |
| 352 | 526 | // exact failed floating slot. Unlike tiled panes, a popup is not in tb.panes, | |
| 353 | 527 | // so accepting a replacement here would silently retry unrelated geometry. | |
| 354 | - | func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, current func() bool, expected *resizeMember) ([]resizeMember, bool) { | |
| 528 | + | func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, current func() bool, expected *resizeMember) (resizeApplyResult, bool) { | |
| 355 | 529 | if tb == nil { | |
| 356 | - | return nil, true | |
| 530 | + | return resizeApplyResult{}, true | |
| 357 | 531 | } | |
| 358 | 532 | tb.mu.Lock() | |
| 359 | 533 | if tb.floating.state != floatingVisible || tb.floating.pane == nil || | |
| 360 | 534 | (expected != nil && (tb.floating.generation != expected.floatingGeneration || tb.floating.pane != expected.pane)) { | |
| 361 | 535 | tb.mu.Unlock() | |
| 362 | - | return nil, true | |
| 536 | + | return resizeApplyResult{}, true | |
| 363 | 537 | } | |
| 364 | 538 | p := tb.floating.pane | |
| 365 | 539 | generation := tb.floating.generation | |
| @@ -367,36 +541,44 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur | |||
| 367 | 541 | geometry := calculateContentFloatingGeometry(size, d.currentFloatingConfig()) | |
| 368 | 542 | tb.mu.Unlock() | |
| 369 | 543 | if !geometry.valid() { | |
| 370 | - | return nil, true | |
| 544 | + | return resizeApplyResult{}, true | |
| 371 | 545 | } | |
| 372 | 546 | ||
| 373 | 547 | // This keeps successful PTY resizes gated until the | |
| 374 | 548 | // floating slot and tab size are revalidated. A newer client resize may | |
| 375 | 549 | // otherwise publish this obsolete popup geometry after its PTY call returns. | |
| 550 | + | p.mu.Lock() | |
| 551 | + | owner := p.effectLeaseLocked() | |
| 552 | + | p.mu.Unlock() | |
| 376 | 553 | plan := preparedTabLayout{members: []resizeMember{{ | |
| 377 | - | session: sess, tab: tb, pane: p, rect: geometry.Inner, floating: geometry, isFloating: true, floatingGeneration: generation, | |
| 554 | + | session: sess, tab: tb, pane: p, owner: owner, rect: geometry.Inner, floating: geometry, isFloating: true, floatingGeneration: generation, | |
| 378 | 555 | }}} | |
| 379 | 556 | d.applyPreparedTabMembers(&plan) | |
| 380 | 557 | ||
| 381 | 558 | // The PTY may have accepted an intermediate size, but a hidden, replaced, | |
| 382 | 559 | // relaunched, or resized slot must never receive this attempt's geometry. | |
| 383 | 560 | tb.mu.Lock() | |
| 384 | - | currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && tb.floating.pane == p && tb.size == size | |
| 561 | + | currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && tb.floating.pane == p && tb.size == size && | |
| 562 | + | resizeMemberOwnerCurrentLocked(&plan.members[0]) | |
| 385 | 563 | if current != nil && !current() { | |
| 386 | 564 | currentSlot = false | |
| 387 | 565 | } | |
| 388 | 566 | if currentSlot { | |
| 389 | 567 | p.mu.Lock() | |
| 390 | - | p.rect = geometry.Inner | |
| 391 | - | p.popupGeometry = geometry | |
| 392 | - | if plan.members[0].ok { | |
| 393 | - | p.resizeRetry = false | |
| 394 | - | p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height) | |
| 568 | + | currentSlot = resizeMemberOwnerCurrentLocked(&plan.members[0]) | |
| 569 | + | if currentSlot { | |
| 570 | + | p.rect = geometry.Inner | |
| 571 | + | p.popupGeometry = geometry | |
| 572 | + | if plan.members[0].ok { | |
| 573 | + | p.resizeRetry = false | |
| 574 | + | p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height) | |
| 575 | + | } | |
| 395 | 576 | } | |
| 396 | 577 | p.mu.Unlock() | |
| 397 | 578 | } | |
| 398 | 579 | tb.mu.Unlock() | |
| 399 | 580 | if !currentSlot { | |
| 581 | + | ownerCurrent := resizeMemberOwnerCurrentLocked(&plan.members[0]) | |
| 400 | 582 | // This attempt has no tiled transaction loop to retain the gate for, so | |
| 401 | 583 | // discard its buffered bytes at the old screen size before returning. | |
| 402 | 584 | for i := range plan.members { | |
| @@ -408,16 +590,17 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur | |||
| 408 | 590 | d.replayResizePending(member.session, member.tab, member.pane, false, member.rect) | |
| 409 | 591 | member.pane.resizeMu.Unlock() | |
| 410 | 592 | } | |
| 411 | - | if current != nil && !current() { | |
| 412 | - | return nil, false | |
| 593 | + | if !ownerCurrent || (current != nil && !current()) { | |
| 594 | + | return resizeApplyResult{}, false | |
| 413 | 595 | } | |
| 414 | - | return nil, true | |
| 596 | + | return resizeApplyResult{}, true | |
| 415 | 597 | } | |
| 416 | 598 | if plan.members[0].ok { | |
| 417 | 599 | d.finishPreparedTabMembers(&plan, true) | |
| 418 | - | return nil, true | |
| 600 | + | return resizeApplyResult{members: append([]resizeMember(nil), plan.members...)}, true | |
| 419 | 601 | } | |
| 420 | - | return plan.members, true | |
| 602 | + | members := append([]resizeMember(nil), plan.members...) | |
| 603 | + | return resizeApplyResult{members: members, failed: members}, true | |
| 421 | 604 | } | |
| 422 | 605 | ||
| 423 | 606 | // releasePreparedSessionGates abandons a session attempt which cannot be | |
| @@ -446,9 +629,9 @@ func (d *Daemon) releasePreparedSessionGates(plans []*preparedTabLayout) { | |||
| 446 | 629 | // transaction: all PTYs are applied and plans validated first, then the | |
| 447 | 630 | // coordinator admits the epoch before any tab size, rectangle, VT screen, | |
| 448 | 631 | // snapshot dirtiness, or resize telemetry becomes visible. | |
| 449 | - | func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, admit func() bool) ([]resizeMember, bool) { | |
| 632 | + | func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, admit func() bool) (resizeApplyResult, bool) { | |
| 450 | 633 | if sess == nil { | |
| 451 | - | return nil, false | |
| 634 | + | return resizeApplyResult{}, false | |
| 452 | 635 | } | |
| 453 | 636 | sess.layoutApplyMu.Lock() | |
| 454 | 637 | defer sess.layoutApplyMu.Unlock() | |
| @@ -456,10 +639,10 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||
| 456 | 639 | target := tabSize(size) | |
| 457 | 640 | for { | |
| 458 | 641 | if sess.ctx != nil && sess.ctx.Err() != nil { | |
| 459 | - | return nil, false | |
| 642 | + | return resizeApplyResult{}, false | |
| 460 | 643 | } | |
| 461 | 644 | if current != nil && !current() { | |
| 462 | - | return nil, false | |
| 645 | + | return resizeApplyResult{}, false | |
| 463 | 646 | } | |
| 464 | 647 | sess.mu.Lock() | |
| 465 | 648 | tabs := append([]*tab(nil), sess.tabs...) | |
| @@ -506,8 +689,15 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||
| 506 | 689 | } | |
| 507 | 690 | if valid { | |
| 508 | 691 | for _, plan := range plans { | |
| 509 | - | plan.tab.size = plan.size | |
| 510 | - | commitPreparedTabLayoutLocked(plan) | |
| 692 | + | if !commitPreparedTabLayoutLocked(plan) { | |
| 693 | + | valid = false | |
| 694 | + | break | |
| 695 | + | } | |
| 696 | + | } | |
| 697 | + | if valid { | |
| 698 | + | for _, plan := range plans { | |
| 699 | + | plan.tab.size = plan.size | |
| 700 | + | } | |
| 511 | 701 | } | |
| 512 | 702 | } | |
| 513 | 703 | for i := len(tabs) - 1; i >= 0; i-- { | |
| @@ -515,20 +705,27 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||
| 515 | 705 | } | |
| 516 | 706 | if !valid { | |
| 517 | 707 | d.releasePreparedSessionGates(plans) | |
| 708 | + | for _, plan := range plans { | |
| 709 | + | if preparedTabOwnerStale(plan) { | |
| 710 | + | return resizeApplyResult{}, false | |
| 711 | + | } | |
| 712 | + | } | |
| 518 | 713 | // Headless requests have no attachment epoch. A live tab mutation | |
| 519 | 714 | // still invalidates their plan, but must trigger a fresh prepare rather | |
| 520 | 715 | // than being mistaken for a canceled request. | |
| 521 | 716 | if current != nil && !current() { | |
| 522 | - | return nil, false | |
| 717 | + | return resizeApplyResult{}, false | |
| 523 | 718 | } | |
| 524 | 719 | // A layout mutation invalidated the plans while this epoch remains | |
| 525 | 720 | // current. Reapply the fresh session geometry before admitting it. | |
| 526 | 721 | continue | |
| 527 | 722 | } | |
| 528 | 723 | ||
| 724 | + | members := make([]resizeMember, 0) | |
| 529 | 725 | failed := make([]resizeMember, 0) | |
| 530 | 726 | for _, plan := range plans { | |
| 531 | 727 | d.finishPreparedTabMembers(plan, true) | |
| 728 | + | members = append(members, plan.members...) | |
| 532 | 729 | for _, member := range plan.members { | |
| 533 | 730 | if !member.ok { | |
| 534 | 731 | failed = append(failed, member) | |
| @@ -536,20 +733,114 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||
| 536 | 733 | } | |
| 537 | 734 | } | |
| 538 | 735 | if active >= 0 && active < len(tabs) { | |
| 539 | - | floatingFailed, ok := d.applyVisibleFloatingLayout(sess, tabs[active], current) | |
| 736 | + | floatingResult, ok := d.applyVisibleFloatingLayout(sess, tabs[active], current) | |
| 540 | 737 | if !ok { | |
| 541 | - | return nil, false | |
| 738 | + | return resizeApplyResult{}, false | |
| 542 | 739 | } | |
| 543 | - | failed = append(failed, floatingFailed...) | |
| 740 | + | members = append(members, floatingResult.members...) | |
| 741 | + | failed = append(failed, floatingResult.failed...) | |
| 544 | 742 | } | |
| 545 | - | if len(failed) != 0 { | |
| 743 | + | if len(failed) != 0 && resizeMembersOwnerCurrent(failed) { | |
| 546 | 744 | d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed, | |
| 547 | 745 | "pane resize failed; retrying in background", failed[len(failed)-1].err) | |
| 548 | 746 | } | |
| 549 | - | markSnapshotDirty(sess) | |
| 550 | - | d.observeRuntime(ports.RuntimeResizeCommitted, 0, true) | |
| 551 | - | return failed, true | |
| 747 | + | if len(failed) != 0 && !resizeMembersOwnerCurrent(failed) { | |
| 748 | + | return resizeApplyResult{}, false | |
| 749 | + | } | |
| 750 | + | return resizeApplyResult{members: members, failed: failed}, true | |
| 751 | + | } | |
| 752 | + | } | |
| 753 | + | ||
| 754 | + | type resizeCommitPublication struct { | |
| 755 | + | members []resizeMember | |
| 756 | + | session *session | |
| 757 | + | attachment *attachedClient | |
| 758 | + | lease *attachmentLease | |
| 759 | + | epoch uint64 | |
| 760 | + | role attachmentRoleToken | |
| 761 | + | coordinator *renderCoordinator | |
| 762 | + | observer ports.RuntimeObserver | |
| 763 | + | mark ports.RuntimeMark | |
| 764 | + | } | |
| 765 | + | ||
| 766 | + | func (p resizeCommitPublication) current() bool { | |
| 767 | + | if !resizeMembersOwnerCurrent(p.members) { | |
| 768 | + | return false | |
| 552 | 769 | } | |
| 770 | + | if p.attachment == nil { | |
| 771 | + | return true | |
| 772 | + | } | |
| 773 | + | if p.role.effect == nil || p.role.effect.ended.Load() || p.role.sess != p.session || | |
| 774 | + | p.role.ac != p.attachment || p.role.role != attachmentActive || p.role.lease != p.lease || | |
| 775 | + | p.role.generation != p.attachment.roleGeneration.Load() || | |
| 776 | + | !p.attachment.transportSnapshotCurrent(p.role.transport) || | |
| 777 | + | p.attachment.currentSession() != p.session || p.coordinator == nil { | |
| 778 | + | return false | |
| 779 | + | } | |
| 780 | + | p.session.mu.Lock() | |
| 781 | + | active := p.session.attachmentRoleLocked(p.attachment) == attachmentActive | |
| 782 | + | p.session.mu.Unlock() | |
| 783 | + | return active && p.coordinator.resizeCurrentForLease(p.epoch, p.attachment, p.lease, false) | |
| 784 | + | } | |
| 785 | + | ||
| 786 | + | // emit performs observer work only after every resize-owner fence and | |
| 787 | + | // attachment sendMu are released. The generation-bound publication is checked | |
| 788 | + | // once more so a move that wins after geometry publication but before observer | |
| 789 | + | // admission suppresses stale source telemetry. | |
| 790 | + | func (p resizeCommitPublication) emit() bool { | |
| 791 | + | if !p.current() { | |
| 792 | + | return false | |
| 793 | + | } | |
| 794 | + | if p.observer != nil { | |
| 795 | + | p.observer.ObserveRuntime(p.mark) | |
| 796 | + | } | |
| 797 | + | return true | |
| 798 | + | } | |
| 799 | + | ||
| 800 | + | // publishResizeCommit linearizes attachment geometry and an immutable telemetry | |
| 801 | + | // reservation with pane ownership. sendMu is acquired before resize fences, so | |
| 802 | + | // the canonical rule never acquires an attachment lock while a pane fence is | |
| 803 | + | // held. Observer callbacks run only after both lock families are released. | |
| 804 | + | func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, ticket *roleEffectTicket, size domain.Size) bool { | |
| 805 | + | d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication) | |
| 806 | + | if ac != nil { | |
| 807 | + | ac.sendMu.Lock() | |
| 808 | + | } | |
| 809 | + | fences := acquireResizeOwnerPostEffectFences(members) | |
| 810 | + | if fences == nil { | |
| 811 | + | if ac != nil { | |
| 812 | + | ac.sendMu.Unlock() | |
| 813 | + | } | |
| 814 | + | return false | |
| 815 | + | } | |
| 816 | + | publication := resizeCommitPublication{ | |
| 817 | + | members: append([]resizeMember(nil), members...), | |
| 818 | + | session: sess, | |
| 819 | + | attachment: ac, | |
| 820 | + | lease: lease, | |
| 821 | + | epoch: epoch, | |
| 822 | + | observer: d.runtimeObserver, | |
| 823 | + | mark: ports.NewRuntimeMark("daemon", ports.RuntimeResizeCommitted, 0, true), | |
| 824 | + | } | |
| 825 | + | if ticket != nil { | |
| 826 | + | publication.role = ticket.roleToken() | |
| 827 | + | publication.coordinator = sess.renderCoordinator() | |
| 828 | + | } | |
| 829 | + | if !publication.current() { | |
| 830 | + | fences.Release() | |
| 831 | + | if ac != nil { | |
| 832 | + | ac.sendMu.Unlock() | |
| 833 | + | } | |
| 834 | + | return false | |
| 835 | + | } | |
| 836 | + | if ac != nil { | |
| 837 | + | ac.size = size | |
| 838 | + | } | |
| 839 | + | fences.Release() | |
| 840 | + | if ac != nil { | |
| 841 | + | ac.sendMu.Unlock() | |
| 842 | + | } | |
| 843 | + | return publication.emit() | |
| 553 | 844 | } | |
| 554 | 845 | ||
| 555 | 846 | func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool { | |
| @@ -568,22 +859,34 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease * | |||
| 568 | 859 | return false | |
| 569 | 860 | } | |
| 570 | 861 | d.exitCopyMode(ac) | |
| 571 | - | failed, ok := d.applySessionLayout(sess, snap.size, current, func() bool { | |
| 862 | + | result, ok := d.applySessionLayout(sess, snap.size, current, func() bool { | |
| 572 | 863 | return rc.resizeCurrentForLease(epoch, ac, lease, true) | |
| 573 | 864 | }) | |
| 574 | 865 | if !ok { | |
| 575 | 866 | return false | |
| 576 | 867 | } | |
| 577 | - | ac.sendMu.Lock() | |
| 578 | - | ac.size = snap.size | |
| 579 | - | ac.sendMu.Unlock() | |
| 580 | - | if len(failed) != 0 { | |
| 581 | - | rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) }) | |
| 868 | + | if !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() { | |
| 869 | + | markSnapshotDirty(sess) | |
| 870 | + | }) { | |
| 871 | + | return false | |
| 872 | + | } | |
| 873 | + | if !d.publishResizeCommit(result.members, sess, ac, lease, epoch, ticket, snap.size) { | |
| 874 | + | return false | |
| 875 | + | } | |
| 876 | + | if len(result.failed) != 0 { | |
| 877 | + | var retry *resizeRetryReservation | |
| 878 | + | if !d.publishResizeOwnerPostEffect(result.failed, resizeOwnerPostRetrySchedule, func() { | |
| 879 | + | retry, _ = rc.reserveResizeRetryForLease(epoch, ac, lease, func() { | |
| 880 | + | d.retryResizeMembers(sess, ac, lease, epoch, result.failed) | |
| 881 | + | }) | |
| 882 | + | }) || retry == nil { | |
| 883 | + | return false | |
| 884 | + | } | |
| 885 | + | retry.finish() | |
| 582 | 886 | } | |
| 583 | 887 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |
| 584 | - | // A successful transaction publishes exactly one full S2 frame. The | |
| 585 | - | // coordinator is the only emission route and stale epochs never reach it. | |
| 586 | - | if !rc.invalidateForLeaseAtResizeEpoch(ac, lease, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) { | |
| 888 | + | if !d.publishResizeOwnerInvalidation(result.members, sess, ac, lease, epoch, | |
| 889 | + | renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) { | |
| 587 | 890 | return false | |
| 588 | 891 | } | |
| 589 | 892 | // The resize debounce has already elapsed. Consume this sticky reset now; | |
| @@ -613,7 +916,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at | |||
| 613 | 916 | tabs := make([]*tab, 0, len(members)) | |
| 614 | 917 | seen := make(map[*tab]struct{}, len(members)) | |
| 615 | 918 | for _, member := range members { | |
| 616 | - | if member.tab == nil || member.isFloating { | |
| 919 | + | if member.tab == nil || member.isFloating || !resizeMemberOwnerCurrentLocked(&member) { | |
| 617 | 920 | continue | |
| 618 | 921 | } | |
| 619 | 922 | if _, ok := seen[member.tab]; ok { | |
| @@ -651,13 +954,13 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at | |||
| 651 | 954 | // pointers, and solved rectangles, and validates them again before any | |
| 652 | 955 | // VT/rectangle publication. It also preserves the resize gate and | |
| 653 | 956 | // degradation notice behavior for another failed external attempt. | |
| 654 | - | freshFailed, ok := d.applyTabLayoutTransaction(sess, tb, func() bool { | |
| 957 | + | result, ok := d.applyTabLayoutTransaction(sess, tb, func() bool { | |
| 655 | 958 | return rc.retryCurrentForLease(epoch, ac, lease) | |
| 656 | 959 | }) | |
| 657 | 960 | if !ok || !rc.retryCurrentForLease(epoch, ac, lease) { | |
| 658 | 961 | return | |
| 659 | 962 | } | |
| 660 | - | failed = append(failed, freshFailed...) | |
| 963 | + | failed = append(failed, result.failed...) | |
| 661 | 964 | // A retry success is a formerly failed, still-current target whose | |
| 662 | 965 | // canonical apply cleared its retry bit. A collapsed or removed target | |
| 663 | 966 | // is neither a retry completion nor a reason to publish a reset. | |
| @@ -673,7 +976,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at | |||
| 673 | 976 | tb.mu.Unlock() | |
| 674 | 977 | } | |
| 675 | 978 | for _, member := range members { | |
| 676 | - | if !member.isFloating || member.tab == nil || member.pane == nil { | |
| 979 | + | if !member.isFloating || member.tab == nil || member.pane == nil || !resizeMemberOwnerCurrentLocked(&member) { | |
| 677 | 980 | continue | |
| 678 | 981 | } | |
| 679 | 982 | if !rc.retryCurrentForLease(epoch, ac, lease) { | |
| @@ -685,7 +988,8 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at | |||
| 685 | 988 | member.tab.mu.Lock() | |
| 686 | 989 | currentSlot := member.tab.floating.state == floatingVisible && | |
| 687 | 990 | member.tab.floating.generation == member.floatingGeneration && | |
| 688 | - | member.tab.floating.pane == member.pane | |
| 991 | + | member.tab.floating.pane == member.pane && | |
| 992 | + | resizeMemberOwnerCurrentLocked(&member) | |
| 689 | 993 | retryPending := false | |
| 690 | 994 | if currentSlot { | |
| 691 | 995 | member.pane.mu.Lock() | |
| @@ -696,32 +1000,46 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at | |||
| 696 | 1000 | if !currentSlot || !retryPending { | |
| 697 | 1001 | continue | |
| 698 | 1002 | } | |
| 699 | - | freshFailed, ok := d.applyVisibleFloatingLayoutForMember(sess, member.tab, func() bool { | |
| 1003 | + | result, ok := d.applyVisibleFloatingLayoutForMember(sess, member.tab, func() bool { | |
| 700 | 1004 | return rc.retryCurrentForLease(epoch, ac, lease) | |
| 701 | 1005 | }, &member) | |
| 702 | 1006 | if !ok || !rc.retryCurrentForLease(epoch, ac, lease) { | |
| 703 | 1007 | return | |
| 704 | 1008 | } | |
| 705 | - | failed = append(failed, freshFailed...) | |
| 1009 | + | failed = append(failed, result.failed...) | |
| 706 | 1010 | member.tab.mu.Lock() | |
| 707 | 1011 | stillCurrent := member.tab.floating.state == floatingVisible && | |
| 708 | 1012 | member.tab.floating.generation == member.floatingGeneration && | |
| 709 | - | member.tab.floating.pane == member.pane | |
| 1013 | + | member.tab.floating.pane == member.pane && | |
| 1014 | + | resizeMemberOwnerCurrentLocked(&member) | |
| 710 | 1015 | member.tab.mu.Unlock() | |
| 711 | - | if stillCurrent && len(freshFailed) == 0 { | |
| 1016 | + | if stillCurrent && len(result.failed) == 0 { | |
| 712 | 1017 | member.pane.mu.Lock() | |
| 713 | 1018 | succeeded = succeeded || !member.pane.resizeRetry | |
| 714 | 1019 | member.pane.mu.Unlock() | |
| 715 | 1020 | } | |
| 716 | 1021 | } | |
| 717 | 1022 | if len(failed) != 0 { | |
| 718 | - | rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) }) | |
| 1023 | + | var retry *resizeRetryReservation | |
| 1024 | + | if !d.publishResizeOwnerPostEffect(failed, resizeOwnerPostRetrySchedule, func() { | |
| 1025 | + | retry, _ = rc.reserveResizeRetryForLease(epoch, ac, lease, func() { | |
| 1026 | + | d.retryResizeMembers(sess, ac, lease, epoch, failed) | |
| 1027 | + | }) | |
| 1028 | + | }) || retry == nil { | |
| 1029 | + | return | |
| 1030 | + | } | |
| 1031 | + | retry.finish() | |
| 719 | 1032 | } | |
| 720 | 1033 | if succeeded { | |
| 721 | 1034 | // Retry completion changes VT state after the original layout commit. | |
| 722 | 1035 | // Keep a named session's eventual snapshot generation aligned with it. | |
| 723 | - | markSnapshotDirty(sess) | |
| 724 | - | rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) | |
| 1036 | + | if !d.publishResizeOwnerPostEffect(members, resizeOwnerPostSnapshotDirty, func() { | |
| 1037 | + | markSnapshotDirty(sess) | |
| 1038 | + | }) { | |
| 1039 | + | return | |
| 1040 | + | } | |
| 1041 | + | d.publishResizeOwnerInvalidation(members, sess, ac, lease, 0, | |
| 1042 | + | renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) | |
| 725 | 1043 | } | |
| 726 | 1044 | } | |
| 727 | 1045 | ||
| @@ -747,8 +1065,13 @@ func (d *Daemon) requestTransactionalResizeForLease(sess *session, ac *attachedC | |||
| 747 | 1065 | if ac == nil { | |
| 748 | 1066 | // Headless geometry has no coordinator/transport to coalesce, but keeps | |
| 749 | 1067 | // the same prepare/apply/commit ordering. | |
| 750 | - | _, ok := d.applySessionLayout(sess, size, nil, nil) | |
| 751 | - | return ok | |
| 1068 | + | result, ok := d.applySessionLayout(sess, size, nil, nil) | |
| 1069 | + | if !ok || !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() { | |
| 1070 | + | markSnapshotDirty(sess) | |
| 1071 | + | }) { | |
| 1072 | + | return false | |
| 1073 | + | } | |
| 1074 | + | return d.publishResizeCommit(result.members, sess, nil, nil, 0, nil, size) | |
| 752 | 1075 | } | |
| 753 | 1076 | rc := sess.renderCoordinator() | |
| 754 | 1077 | if rc == nil || !rc.leaseCurrent(lease, true) { | |
| @@ -8,12 +8,22 @@ import ( | |||||
| 8 | "github.com/bnema/vev/internal/usecase/layout" | 8 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | + | type resizeOwnerPostEffect uint8 | |||
| 12 | + | ||||
| 13 | + | const ( | |||
| 14 | + | resizeOwnerPostSnapshotDirty resizeOwnerPostEffect = iota + 1 | |||
| 15 | + | resizeOwnerPostRetrySchedule | |||
| 16 | + | resizeOwnerPostRenderInvalidation | |||
| 17 | + | resizeOwnerPostCommitPublication | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 11 | // resizeMember is an unpublished layout decision. Prepare only reads guarded | 20 | // resizeMember is an unpublished layout decision. Prepare only reads guarded | ||
| 12 | // state; apply performs the external PTY call; commit is the sole publisher. | 21 | // state; apply performs the external PTY call; commit is the sole publisher. | ||
| 13 | type resizeMember struct { | 22 | type resizeMember struct { | ||
| 14 | session *session | 23 | session *session | ||
| 15 | tab *tab | 24 | tab *tab | ||
| 16 | pane *pane | 25 | pane *pane | ||
| 26 | + | owner paneEffectLease | |||
| 17 | rect domain.Rect | 27 | rect domain.Rect | ||
| 18 | floating floatingGeometry | 28 | floating floatingGeometry | ||
| 19 | isFloating bool | 29 | isFloating bool | ||
| @@ -35,6 +45,11 @@ type preparedTabLayout struct { | |||||
| 35 | members []resizeMember | 45 | members []resizeMember | ||
| 36 | } | 46 | } | ||
| 37 | 47 | ||||
| 48 | + | type resizeApplyResult struct { | |||
| 49 | + | members []resizeMember | |||
| 50 | + | failed []resizeMember | |||
| 51 | + | } | |||
| 52 | + | ||||
| 38 | func prepareTabLayoutLocked(sess *session, tb *tab) preparedTabLayout { | 53 | func prepareTabLayoutLocked(sess *session, tb *tab) preparedTabLayout { | ||
| 39 | return prepareTabLayoutForSizeLocked(sess, tb, tb.size) | 54 | return prepareTabLayoutForSizeLocked(sess, tb, tb.size) | ||
| 40 | } | 55 | } | ||
| @@ -58,7 +73,10 @@ func prepareTabLayoutForSizeLocked(sess *session, tb *tab, size domain.Size) pre | |||||
| 58 | continue | 73 | continue | ||
| 59 | } | 74 | } | ||
| 60 | if p := tb.panes[placement.ID]; p != nil { | 75 | if p := tb.panes[placement.ID]; p != nil { | ||
| 61 | - | plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: p, rect: placement.Content}) | 76 | + | p.mu.Lock() |
| 77 | + | owner := p.effectLeaseLocked() | |||
| 78 | + | p.mu.Unlock() | |||
| 79 | + | plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: p, owner: owner, rect: placement.Content}) | |||
| 62 | } | 80 | } | ||
| 63 | } | 81 | } | ||
| 64 | return plan | 82 | return plan | ||
| @@ -72,6 +90,13 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) { | |||||
| 72 | p := member.pane | 90 | p := member.pane | ||
| 73 | p.resizeMu.Lock() | 91 | p.resizeMu.Lock() | ||
| 74 | p.mu.Lock() | 92 | p.mu.Lock() | ||
| 93 | + | if !resizeMemberOwnerCurrentLocked(member) { | |||
| 94 | + | member.retry = p.owner.Load() != nil | |||
| 95 | + | p.resizeRetry = member.retry | |||
| 96 | + | p.mu.Unlock() | |||
| 97 | + | p.resizeMu.Unlock() | |||
| 98 | + | continue | |||
| 99 | + | } | |||
| 75 | old := p.rect | 100 | old := p.rect | ||
| 76 | pty := p.pty | 101 | pty := p.pty | ||
| 77 | // A rejected earlier attempt deliberately leaves its parser gate open. | 102 | // A rejected earlier attempt deliberately leaves its parser gate open. | ||
| @@ -88,36 +113,102 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) { | |||||
| 88 | } else if err := pty.Resize(rectSize(member.rect)); err != nil { | 113 | } else if err := pty.Resize(rectSize(member.rect)); err != nil { | ||
| 89 | d.log.Warn("pty resize failed", "err", err) | 114 | d.log.Warn("pty resize failed", "err", err) | ||
| 90 | p.mu.Lock() | 115 | p.mu.Lock() | ||
| 116 | + | current := resizeMemberOwnerCurrentLocked(member) | |||
| 91 | p.resizeRetry = true | 117 | p.resizeRetry = true | ||
| 92 | p.mu.Unlock() | 118 | p.mu.Unlock() | ||
| 93 | d.replayResizePending(member.session, member.tab, p, false, member.rect) | 119 | d.replayResizePending(member.session, member.tab, p, false, member.rect) | ||
| 94 | member.retry = true | 120 | member.retry = true | ||
| 95 | - | member.err = err | 121 | + | if current { |
| 122 | + | member.err = err | |||
| 123 | + | } | |||
| 96 | } else { | 124 | } else { | ||
| 97 | - | member.ok = true | 125 | + | p.mu.Lock() |
| 126 | + | current := resizeMemberOwnerCurrentLocked(member) | |||
| 127 | + | if !current && p.owner.Load() != nil { | |||
| 128 | + | p.resizeRetry = true | |||
| 129 | + | member.retry = true | |||
| 130 | + | } | |||
| 131 | + | p.mu.Unlock() | |||
| 132 | + | member.ok = current | |||
| 98 | member.screenResized = true // records that this member owns an open gate | 133 | member.screenResized = true // records that this member owns an open gate | ||
| 99 | } | 134 | } | ||
| 100 | p.resizeMu.Unlock() | 135 | p.resizeMu.Unlock() | ||
| 101 | } | 136 | } | ||
| 102 | } | 137 | } | ||
| 103 | 138 | ||||
| 139 | + | func resizeMemberOwnerCurrentLocked(member *resizeMember) bool { | |||
| 140 | + | if member == nil || member.pane == nil { | |||
| 141 | + | return false | |||
| 142 | + | } | |||
| 143 | + | // A zero lease is retained only for focused tests which construct the | |||
| 144 | + | // transactional primitive directly. Every prepared production member owns | |||
| 145 | + | // an immutable generation lease. | |||
| 146 | + | if member.owner.owner == nil { | |||
| 147 | + | return true | |||
| 148 | + | } | |||
| 149 | + | owner := member.pane.owner.Load() | |||
| 150 | + | return owner == member.owner.owner && owner.session == member.session && owner.tab == member.tab | |||
| 151 | + | } | |||
| 152 | + | ||||
| 104 | func validatePreparedTabLayoutLocked(tb *tab, plan *preparedTabLayout) bool { | 153 | func validatePreparedTabLayoutLocked(tb *tab, plan *preparedTabLayout) bool { | ||
| 105 | if tb.layoutGeneration != plan.generation || tb.size != plan.previousSize { | 154 | if tb.layoutGeneration != plan.generation || tb.size != plan.previousSize { | ||
| 106 | return false | 155 | return false | ||
| 107 | } | 156 | } | ||
| 108 | for i := range plan.members { | 157 | for i := range plan.members { | ||
| 109 | member := &plan.members[i] | 158 | member := &plan.members[i] | ||
| 110 | - | if tb.panes[member.pane.id] != member.pane { | 159 | + | if tb.panes[member.pane.id] != member.pane || !resizeMemberOwnerCurrentLocked(member) { |
| 111 | return false | 160 | return false | ||
| 112 | } | 161 | } | ||
| 113 | } | 162 | } | ||
| 114 | return true | 163 | return true | ||
| 115 | } | 164 | } | ||
| 116 | 165 | ||||
| 117 | - | func commitPreparedTabLayoutLocked(plan *preparedTabLayout) { | 166 | + | func resizeMembersOwnerCurrent(members []resizeMember) bool { |
| 167 | + | for i := range members { | |||
| 168 | + | if !resizeMemberOwnerCurrentLocked(&members[i]) { | |||
| 169 | + | return false | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | return true | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func (d *Daemon) publishResizeOwnerInvalidation(members []resizeMember, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) bool { | |||
| 176 | + | var reservation *renderInvalidationReservation | |||
| 177 | + | fallback := false | |||
| 178 | + | if !d.publishResizeOwnerPostEffect(members, resizeOwnerPostRenderInvalidation, func() { | |||
| 179 | + | if rc := sess.renderCoordinator(); rc != nil { | |||
| 180 | + | reservation, _ = rc.reserveInvalidationForLeaseAtResizeEpoch(ac, lease, epoch, inv) | |||
| 181 | + | return | |||
| 182 | + | } | |||
| 183 | + | fallback = ac != nil | |||
| 184 | + | }) { | |||
| 185 | + | return false | |||
| 186 | + | } | |||
| 187 | + | if reservation != nil { | |||
| 188 | + | reservation.finish() | |||
| 189 | + | return true | |||
| 190 | + | } | |||
| 191 | + | if fallback { | |||
| 192 | + | d.paint(sess, ac, inv.reset, nil) | |||
| 193 | + | return true | |||
| 194 | + | } | |||
| 195 | + | return ac == nil | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | func preparedTabOwnerStale(plan *preparedTabLayout) bool { | |||
| 199 | + | return plan != nil && !resizeMembersOwnerCurrent(plan.members) | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | func commitPreparedTabLayoutLocked(plan *preparedTabLayout) bool { | |||
| 118 | for i := range plan.members { | 203 | for i := range plan.members { | ||
| 119 | member := &plan.members[i] | 204 | member := &plan.members[i] | ||
| 120 | member.pane.mu.Lock() | 205 | member.pane.mu.Lock() | ||
| 206 | + | if !resizeMemberOwnerCurrentLocked(member) { | |||
| 207 | + | member.pane.resizeRetry = member.pane.owner.Load() != nil | |||
| 208 | + | member.retry = member.pane.resizeRetry | |||
| 209 | + | member.pane.mu.Unlock() | |||
| 210 | + | return false | |||
| 211 | + | } | |||
| 121 | member.pane.rect = member.rect | 212 | member.pane.rect = member.rect | ||
| 122 | if member.ok { | 213 | if member.ok { | ||
| 123 | member.pane.resizeRetry = false | 214 | member.pane.resizeRetry = false | ||
| @@ -125,6 +216,7 @@ func commitPreparedTabLayoutLocked(plan *preparedTabLayout) { | |||||
| 125 | } | 216 | } | ||
| 126 | member.pane.mu.Unlock() | 217 | member.pane.mu.Unlock() | ||
| 127 | } | 218 | } | ||
| 219 | + | return true | |||
| 128 | } | 220 | } | ||
| 129 | 221 | ||||
| 130 | // cancelStalePreparedGates releases gates for members which disappeared from | 222 | // cancelStalePreparedGates releases gates for members which disappeared from | ||
| @@ -173,22 +265,22 @@ func (d *Daemon) finishPreparedTabMembers(plan *preparedTabLayout, accepted bool | |||||
| 173 | // applyTabLayoutTransaction is the canonical tiled-layout publisher. It owns | 265 | // applyTabLayoutTransaction is the canonical tiled-layout publisher. It owns | ||
| 174 | // one per-tab single-writer loop, but never holds tab or pane state locks while | 266 | // one per-tab single-writer loop, but never holds tab or pane state locks while | ||
| 175 | // invoking PTY.Resize. | 267 | // invoking PTY.Resize. | ||
| 176 | - | func (d *Daemon) applyTabLayoutTransaction(sess *session, tb *tab, current ...func() bool) ([]resizeMember, bool) { | 268 | + | func (d *Daemon) applyTabLayoutTransaction(sess *session, tb *tab, current ...func() bool) (resizeApplyResult, bool) { |
| 177 | return d.applyTabLayoutTransactionWithNotice(sess, tb, true, current...) | 269 | return d.applyTabLayoutTransactionWithNotice(sess, tb, true, current...) | ||
| 178 | } | 270 | } | ||
| 179 | 271 | ||||
| 180 | - | func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, reportFailure bool, current ...func() bool) ([]resizeMember, bool) { | 272 | + | func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, reportFailure bool, current ...func() bool) (resizeApplyResult, bool) { |
| 181 | if tb == nil { | 273 | if tb == nil { | ||
| 182 | - | return nil, false | 274 | + | return resizeApplyResult{}, false |
| 183 | } | 275 | } | ||
| 184 | tb.layoutApplyMu.Lock() | 276 | tb.layoutApplyMu.Lock() | ||
| 185 | defer tb.layoutApplyMu.Unlock() | 277 | defer tb.layoutApplyMu.Unlock() | ||
| 186 | for { | 278 | for { | ||
| 187 | if tb.ctx != nil && tb.ctx.Err() != nil { | 279 | if tb.ctx != nil && tb.ctx.Err() != nil { | ||
| 188 | - | return nil, false | 280 | + | return resizeApplyResult{}, false |
| 189 | } | 281 | } | ||
| 190 | if len(current) != 0 && !current[0]() { | 282 | if len(current) != 0 && !current[0]() { | ||
| 191 | - | return nil, false | 283 | + | return resizeApplyResult{}, false |
| 192 | } | 284 | } | ||
| 193 | tb.mu.Lock() | 285 | tb.mu.Lock() | ||
| 194 | plan := prepareTabLayoutLocked(sess, tb) | 286 | plan := prepareTabLayoutLocked(sess, tb) | ||
| @@ -201,12 +293,16 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep | |||||
| 201 | accepted = false | 293 | accepted = false | ||
| 202 | } | 294 | } | ||
| 203 | if accepted { | 295 | if accepted { | ||
| 204 | - | commitPreparedTabLayoutLocked(&plan) | 296 | + | accepted = commitPreparedTabLayoutLocked(&plan) |
| 205 | } | 297 | } | ||
| 206 | tb.mu.Unlock() | 298 | tb.mu.Unlock() | ||
| 207 | d.finishPreparedTabMembers(&plan, accepted) | 299 | d.finishPreparedTabMembers(&plan, accepted) | ||
| 208 | if !accepted { | 300 | if !accepted { | ||
| 209 | d.cancelStalePreparedGates(sess, tb, &plan) | 301 | d.cancelStalePreparedGates(sess, tb, &plan) | ||
| 302 | + | if preparedTabOwnerStale(&plan) { | |||
| 303 | + | d.releasePreparedSessionGates([]*preparedTabLayout{&plan}) | |||
| 304 | + | return resizeApplyResult{}, false | |||
| 305 | + | } | |||
| 210 | if len(current) != 0 && !current[0]() { | 306 | if len(current) != 0 && !current[0]() { | ||
| 211 | for i := range plan.members { | 307 | for i := range plan.members { | ||
| 212 | member := &plan.members[i] | 308 | member := &plan.members[i] | ||
| @@ -217,7 +313,7 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep | |||||
| 217 | d.replayResizePending(member.session, member.tab, member.pane, false, member.rect) | 313 | d.replayResizePending(member.session, member.tab, member.pane, false, member.rect) | ||
| 218 | member.pane.resizeMu.Unlock() | 314 | member.pane.resizeMu.Unlock() | ||
| 219 | } | 315 | } | ||
| 220 | - | return nil, false | 316 | + | return resizeApplyResult{}, false |
| 221 | } | 317 | } | ||
| 222 | continue | 318 | continue | ||
| 223 | } | 319 | } | ||
| @@ -227,44 +323,104 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep | |||||
| 227 | failed = append(failed, member) | 323 | failed = append(failed, member) | ||
| 228 | } | 324 | } | ||
| 229 | } | 325 | } | ||
| 230 | - | if reportFailure && len(failed) != 0 { | 326 | + | if reportFailure && len(failed) != 0 && resizeMembersOwnerCurrent(failed) { |
| 231 | d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed, | 327 | d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed, | ||
| 232 | "pane resize failed; retrying in background", failed[len(failed)-1].err) | 328 | "pane resize failed; retrying in background", failed[len(failed)-1].err) | ||
| 233 | } | 329 | } | ||
| 234 | - | return failed, true | 330 | + | return resizeApplyResult{members: append([]resizeMember(nil), plan.members...), failed: failed}, true |
| 235 | } | 331 | } | ||
| 236 | } | 332 | } | ||
| 237 | 333 | ||||
| 238 | func (d *Daemon) applyTabLayout(sess *session, tb *tab) bool { | 334 | func (d *Daemon) applyTabLayout(sess *session, tb *tab) bool { | ||
| 239 | - | failed, ok := d.applyTabLayoutTransaction(sess, tb) | 335 | + | result, ok := d.applyTabLayoutTransaction(sess, tb) |
| 240 | if !ok { | 336 | if !ok { | ||
| 241 | return false | 337 | return false | ||
| 242 | } | 338 | } | ||
| 243 | - | markSnapshotDirty(sess) | 339 | + | if !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() { |
| 244 | - | if len(failed) != 0 { | 340 | + | markSnapshotDirty(sess) |
| 245 | - | d.scheduleAcceptedTabLayoutRetry(sess, tb) | 341 | + | }) { |
| 342 | + | return false | |||
| 343 | + | } | |||
| 344 | + | if len(result.failed) != 0 { | |||
| 345 | + | if !d.scheduleAcceptedTabLayoutRetry(sess, tb, result.failed) { | |||
| 346 | + | return false | |||
| 347 | + | } | |||
| 246 | } | 348 | } | ||
| 247 | return true | 349 | return true | ||
| 248 | } | 350 | } | ||
| 249 | 351 | ||||
| 250 | const maxAcceptedTabLayoutRetries = 3 | 352 | const maxAcceptedTabLayoutRetries = 3 | ||
| 251 | 353 | ||||
| 354 | + | // acceptedTabLayoutRetryToken binds a delayed tiled retry to the exact pane | |||
| 355 | + | // owner generations which accepted the failure. A moved pane cannot lend its | |||
| 356 | + | // old source worker authority over either the PTY or source publications. | |||
| 357 | + | type acceptedTabLayoutRetryToken struct { | |||
| 358 | + | session *session | |||
| 359 | + | tab *tab | |||
| 360 | + | members []resizeMember | |||
| 361 | + | } | |||
| 362 | + | ||||
| 363 | + | func newAcceptedTabLayoutRetryToken(sess *session, tb *tab, members []resizeMember) acceptedTabLayoutRetryToken { | |||
| 364 | + | return acceptedTabLayoutRetryToken{session: sess, tab: tb, members: append([]resizeMember(nil), members...)} | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | func (t acceptedTabLayoutRetryToken) current() bool { | |||
| 368 | + | if t.session == nil || t.tab == nil || len(t.members) == 0 { | |||
| 369 | + | return false | |||
| 370 | + | } | |||
| 371 | + | for i := range t.members { | |||
| 372 | + | member := &t.members[i] | |||
| 373 | + | if member.session != t.session || member.tab != t.tab || !resizeMemberOwnerCurrentLocked(member) { | |||
| 374 | + | return false | |||
| 375 | + | } | |||
| 376 | + | } | |||
| 377 | + | return true | |||
| 378 | + | } | |||
| 379 | + | ||||
| 252 | // scheduleAcceptedTabLayoutRetry owns one bounded retry worker per tab. The | 380 | // scheduleAcceptedTabLayoutRetry owns one bounded retry worker per tab. The | ||
| 253 | // worker is deduplicated, derives cancellation from the tab lifecycle, and | 381 | // worker is deduplicated, derives cancellation from the tab lifecycle, and | ||
| 254 | // suppresses repeat degradation notices after the accepted initial failure. | 382 | // suppresses repeat degradation notices after the accepted initial failure. | ||
| 255 | - | func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) { | 383 | + | func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab, failed ...[]resizeMember) bool { |
| 256 | if d.clock == nil || tb == nil || tb.ctx == nil { | 384 | if d.clock == nil || tb == nil || tb.ctx == nil { | ||
| 257 | - | return | 385 | + | return false |
| 258 | } | 386 | } | ||
| 259 | - | tb.layoutRetryMu.Lock() | 387 | + | var members []resizeMember |
| 260 | - | if tb.layoutRetryRunning { | 388 | + | if len(failed) != 0 { |
| 261 | - | tb.layoutRetryMu.Unlock() | 389 | + | members = failed[0] |
| 262 | - | return | 390 | + | } else { |
| 391 | + | tb.mu.Lock() | |||
| 392 | + | plan := prepareTabLayoutLocked(sess, tb) | |||
| 393 | + | tb.mu.Unlock() | |||
| 394 | + | for i := range plan.members { | |||
| 395 | + | member := plan.members[i] | |||
| 396 | + | member.pane.mu.Lock() | |||
| 397 | + | pending := member.pane.resizeRetry | |||
| 398 | + | member.pane.mu.Unlock() | |||
| 399 | + | if pending { | |||
| 400 | + | members = append(members, member) | |||
| 401 | + | } | |||
| 402 | + | } | |||
| 403 | + | } | |||
| 404 | + | token := newAcceptedTabLayoutRetryToken(sess, tb, members) | |||
| 405 | + | var ctx context.Context | |||
| 406 | + | var cancel context.CancelFunc | |||
| 407 | + | start := false | |||
| 408 | + | if !d.publishResizeOwnerPostEffect(members, resizeOwnerPostRetrySchedule, func() { | |||
| 409 | + | tb.layoutRetryMu.Lock() | |||
| 410 | + | defer tb.layoutRetryMu.Unlock() | |||
| 411 | + | if tb.layoutRetryRunning { | |||
| 412 | + | return | |||
| 413 | + | } | |||
| 414 | + | ctx, cancel = context.WithCancel(tb.ctx) | |||
| 415 | + | tb.layoutRetryRunning = true | |||
| 416 | + | tb.layoutRetryCancel = cancel | |||
| 417 | + | start = true | |||
| 418 | + | }) { | |||
| 419 | + | return false | |||
| 420 | + | } | |||
| 421 | + | if !start { | |||
| 422 | + | return true | |||
| 263 | } | 423 | } | ||
| 264 | - | ctx, cancel := context.WithCancel(tb.ctx) | |||
| 265 | - | tb.layoutRetryRunning = true | |||
| 266 | - | tb.layoutRetryCancel = cancel | |||
| 267 | - | tb.layoutRetryMu.Unlock() | |||
| 268 | 424 | ||||
| 269 | go func() { | 425 | go func() { | ||
| 270 | defer func() { | 426 | defer func() { | ||
| @@ -275,6 +431,9 @@ func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) { | |||||
| 275 | tb.layoutRetryMu.Unlock() | 431 | tb.layoutRetryMu.Unlock() | ||
| 276 | }() | 432 | }() | ||
| 277 | for range maxAcceptedTabLayoutRetries { | 433 | for range maxAcceptedTabLayoutRetries { | ||
| 434 | + | if !token.current() { | |||
| 435 | + | return | |||
| 436 | + | } | |||
| 278 | timer := d.clock.NewTimer(minOutputRenderDeadline) | 437 | timer := d.clock.NewTimer(minOutputRenderDeadline) | ||
| 279 | if timer == nil { | 438 | if timer == nil { | ||
| 280 | return | 439 | return | ||
| @@ -290,22 +449,37 @@ func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) { | |||||
| 290 | return | 449 | return | ||
| 291 | case <-timerC: | 450 | case <-timerC: | ||
| 292 | } | 451 | } | ||
| 293 | - | if ctx.Err() != nil { | 452 | + | if ctx.Err() != nil || !token.current() { |
| 294 | return | 453 | return | ||
| 295 | } | 454 | } | ||
| 296 | - | failed, ok := d.applyTabLayoutTransactionWithNotice(sess, tb, false, func() bool { return ctx.Err() == nil }) | 455 | + | result, ok := d.applyTabLayoutTransactionWithNotice(sess, tb, false, func() bool { |
| 297 | - | if !ok || len(failed) == 0 { | 456 | + | return ctx.Err() == nil && token.current() |
| 457 | + | }) | |||
| 458 | + | if !ok || !token.current() { | |||
| 459 | + | return | |||
| 460 | + | } | |||
| 461 | + | if len(result.failed) == 0 { | |||
| 462 | + | return | |||
| 463 | + | } | |||
| 464 | + | token = newAcceptedTabLayoutRetryToken(sess, tb, result.failed) | |||
| 465 | + | if !token.current() { | |||
| 466 | + | return | |||
| 467 | + | } | |||
| 468 | + | if !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() { | |||
| 469 | + | markSnapshotDirty(sess) | |||
| 470 | + | }) { | |||
| 298 | return | 471 | return | ||
| 299 | } | 472 | } | ||
| 300 | - | markSnapshotDirty(sess) | |||
| 301 | sess.mu.Lock() | 473 | sess.mu.Lock() | ||
| 302 | ac := sess.client | 474 | ac := sess.client | ||
| 303 | sess.mu.Unlock() | 475 | sess.mu.Unlock() | ||
| 304 | - | if ac != nil { | 476 | + | if ac != nil && !d.publishResizeOwnerInvalidation(result.members, sess, ac, nil, 0, |
| 305 | - | d.invalidateRender(sess, ac, true, "transactional_resize.go") | 477 | + | renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) { |
| 478 | + | return | |||
| 306 | } | 479 | } | ||
| 307 | } | 480 | } | ||
| 308 | }() | 481 | }() | ||
| 482 | + | return true | |||
| 309 | } | 483 | } | ||
| 310 | 484 | ||||
| 311 | // replayResizePending completes a pane's apply epoch. It keeps the gate set | 485 | // replayResizePending completes a pane's apply epoch. It keeps the gate set | ||
| @@ -344,22 +518,22 @@ func (d *Daemon) replayResizePending(sess *session, tb *tab, p *pane, resized bo | |||||
| 344 | // used for retryable PTY work. Floating state is not part of a tiled layout | 518 | // used for retryable PTY work. Floating state is not part of a tiled layout | ||
| 345 | // generation, so its slot generation and exact pane identity validate its | 519 | // generation, so its slot generation and exact pane identity validate its | ||
| 346 | // publication instead. | 520 | // publication instead. | ||
| 347 | - | func (d *Daemon) applyVisibleFloatingLayout(sess *session, tb *tab, current func() bool) ([]resizeMember, bool) { | 521 | + | func (d *Daemon) applyVisibleFloatingLayout(sess *session, tb *tab, current func() bool) (resizeApplyResult, bool) { |
| 348 | return d.applyVisibleFloatingLayoutForMember(sess, tb, current, nil) | 522 | return d.applyVisibleFloatingLayoutForMember(sess, tb, current, nil) | ||
| 349 | } | 523 | } | ||
| 350 | 524 | ||||
| 351 | // applyVisibleFloatingLayoutForMember optionally fences a delayed retry to the | 525 | // applyVisibleFloatingLayoutForMember optionally fences a delayed retry to the | ||
| 352 | // exact failed floating slot. Unlike tiled panes, a popup is not in tb.panes, | 526 | // exact failed floating slot. Unlike tiled panes, a popup is not in tb.panes, | ||
| 353 | // so accepting a replacement here would silently retry unrelated geometry. | 527 | // so accepting a replacement here would silently retry unrelated geometry. | ||
| 354 | - | func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, current func() bool, expected *resizeMember) ([]resizeMember, bool) { | 528 | + | func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, current func() bool, expected *resizeMember) (resizeApplyResult, bool) { |
| 355 | if tb == nil { | 529 | if tb == nil { | ||
| 356 | - | return nil, true | 530 | + | return resizeApplyResult{}, true |
| 357 | } | 531 | } | ||
| 358 | tb.mu.Lock() | 532 | tb.mu.Lock() | ||
| 359 | if tb.floating.state != floatingVisible || tb.floating.pane == nil || | 533 | if tb.floating.state != floatingVisible || tb.floating.pane == nil || | ||
| 360 | (expected != nil && (tb.floating.generation != expected.floatingGeneration || tb.floating.pane != expected.pane)) { | 534 | (expected != nil && (tb.floating.generation != expected.floatingGeneration || tb.floating.pane != expected.pane)) { | ||
| 361 | tb.mu.Unlock() | 535 | tb.mu.Unlock() | ||
| 362 | - | return nil, true | 536 | + | return resizeApplyResult{}, true |
| 363 | } | 537 | } | ||
| 364 | p := tb.floating.pane | 538 | p := tb.floating.pane | ||
| 365 | generation := tb.floating.generation | 539 | generation := tb.floating.generation | ||
| @@ -367,36 +541,44 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur | |||||
| 367 | geometry := calculateContentFloatingGeometry(size, d.currentFloatingConfig()) | 541 | geometry := calculateContentFloatingGeometry(size, d.currentFloatingConfig()) | ||
| 368 | tb.mu.Unlock() | 542 | tb.mu.Unlock() | ||
| 369 | if !geometry.valid() { | 543 | if !geometry.valid() { | ||
| 370 | - | return nil, true | 544 | + | return resizeApplyResult{}, true |
| 371 | } | 545 | } | ||
| 372 | 546 | ||||
| 373 | // This keeps successful PTY resizes gated until the | 547 | // This keeps successful PTY resizes gated until the | ||
| 374 | // floating slot and tab size are revalidated. A newer client resize may | 548 | // floating slot and tab size are revalidated. A newer client resize may | ||
| 375 | // otherwise publish this obsolete popup geometry after its PTY call returns. | 549 | // otherwise publish this obsolete popup geometry after its PTY call returns. | ||
| 550 | + | p.mu.Lock() | |||
| 551 | + | owner := p.effectLeaseLocked() | |||
| 552 | + | p.mu.Unlock() | |||
| 376 | plan := preparedTabLayout{members: []resizeMember{{ | 553 | plan := preparedTabLayout{members: []resizeMember{{ | ||
| 377 | - | session: sess, tab: tb, pane: p, rect: geometry.Inner, floating: geometry, isFloating: true, floatingGeneration: generation, | 554 | + | session: sess, tab: tb, pane: p, owner: owner, rect: geometry.Inner, floating: geometry, isFloating: true, floatingGeneration: generation, |
| 378 | }}} | 555 | }}} | ||
| 379 | d.applyPreparedTabMembers(&plan) | 556 | d.applyPreparedTabMembers(&plan) | ||
| 380 | 557 | ||||
| 381 | // The PTY may have accepted an intermediate size, but a hidden, replaced, | 558 | // The PTY may have accepted an intermediate size, but a hidden, replaced, | ||
| 382 | // relaunched, or resized slot must never receive this attempt's geometry. | 559 | // relaunched, or resized slot must never receive this attempt's geometry. | ||
| 383 | tb.mu.Lock() | 560 | tb.mu.Lock() | ||
| 384 | - | currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && tb.floating.pane == p && tb.size == size | 561 | + | currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && tb.floating.pane == p && tb.size == size && |
| 562 | + | resizeMemberOwnerCurrentLocked(&plan.members[0]) | |||
| 385 | if current != nil && !current() { | 563 | if current != nil && !current() { | ||
| 386 | currentSlot = false | 564 | currentSlot = false | ||
| 387 | } | 565 | } | ||
| 388 | if currentSlot { | 566 | if currentSlot { | ||
| 389 | p.mu.Lock() | 567 | p.mu.Lock() | ||
| 390 | - | p.rect = geometry.Inner | 568 | + | currentSlot = resizeMemberOwnerCurrentLocked(&plan.members[0]) |
| 391 | - | p.popupGeometry = geometry | 569 | + | if currentSlot { |
| 392 | - | if plan.members[0].ok { | 570 | + | p.rect = geometry.Inner |
| 393 | - | p.resizeRetry = false | 571 | + | p.popupGeometry = geometry |
| 394 | - | p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height) | 572 | + | if plan.members[0].ok { |
| 573 | + | p.resizeRetry = false | |||
| 574 | + | p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height) | |||
| 575 | + | } | |||
| 395 | } | 576 | } | ||
| 396 | p.mu.Unlock() | 577 | p.mu.Unlock() | ||
| 397 | } | 578 | } | ||
| 398 | tb.mu.Unlock() | 579 | tb.mu.Unlock() | ||
| 399 | if !currentSlot { | 580 | if !currentSlot { | ||
| 581 | + | ownerCurrent := resizeMemberOwnerCurrentLocked(&plan.members[0]) | |||
| 400 | // This attempt has no tiled transaction loop to retain the gate for, so | 582 | // This attempt has no tiled transaction loop to retain the gate for, so | ||
| 401 | // discard its buffered bytes at the old screen size before returning. | 583 | // discard its buffered bytes at the old screen size before returning. | ||
| 402 | for i := range plan.members { | 584 | for i := range plan.members { | ||
| @@ -408,16 +590,17 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur | |||||
| 408 | d.replayResizePending(member.session, member.tab, member.pane, false, member.rect) | 590 | d.replayResizePending(member.session, member.tab, member.pane, false, member.rect) | ||
| 409 | member.pane.resizeMu.Unlock() | 591 | member.pane.resizeMu.Unlock() | ||
| 410 | } | 592 | } | ||
| 411 | - | if current != nil && !current() { | 593 | + | if !ownerCurrent || (current != nil && !current()) { |
| 412 | - | return nil, false | 594 | + | return resizeApplyResult{}, false |
| 413 | } | 595 | } | ||
| 414 | - | return nil, true | 596 | + | return resizeApplyResult{}, true |
| 415 | } | 597 | } | ||
| 416 | if plan.members[0].ok { | 598 | if plan.members[0].ok { | ||
| 417 | d.finishPreparedTabMembers(&plan, true) | 599 | d.finishPreparedTabMembers(&plan, true) | ||
| 418 | - | return nil, true | 600 | + | return resizeApplyResult{members: append([]resizeMember(nil), plan.members...)}, true |
| 419 | } | 601 | } | ||
| 420 | - | return plan.members, true | 602 | + | members := append([]resizeMember(nil), plan.members...) |
| 603 | + | return resizeApplyResult{members: members, failed: members}, true | |||
| 421 | } | 604 | } | ||
| 422 | 605 | ||||
| 423 | // releasePreparedSessionGates abandons a session attempt which cannot be | 606 | // releasePreparedSessionGates abandons a session attempt which cannot be | ||
| @@ -446,9 +629,9 @@ func (d *Daemon) releasePreparedSessionGates(plans []*preparedTabLayout) { | |||||
| 446 | // transaction: all PTYs are applied and plans validated first, then the | 629 | // transaction: all PTYs are applied and plans validated first, then the | ||
| 447 | // coordinator admits the epoch before any tab size, rectangle, VT screen, | 630 | // coordinator admits the epoch before any tab size, rectangle, VT screen, | ||
| 448 | // snapshot dirtiness, or resize telemetry becomes visible. | 631 | // snapshot dirtiness, or resize telemetry becomes visible. | ||
| 449 | - | func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, admit func() bool) ([]resizeMember, bool) { | 632 | + | func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, admit func() bool) (resizeApplyResult, bool) { |
| 450 | if sess == nil { | 633 | if sess == nil { | ||
| 451 | - | return nil, false | 634 | + | return resizeApplyResult{}, false |
| 452 | } | 635 | } | ||
| 453 | sess.layoutApplyMu.Lock() | 636 | sess.layoutApplyMu.Lock() | ||
| 454 | defer sess.layoutApplyMu.Unlock() | 637 | defer sess.layoutApplyMu.Unlock() | ||
| @@ -456,10 +639,10 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||||
| 456 | target := tabSize(size) | 639 | target := tabSize(size) | ||
| 457 | for { | 640 | for { | ||
| 458 | if sess.ctx != nil && sess.ctx.Err() != nil { | 641 | if sess.ctx != nil && sess.ctx.Err() != nil { | ||
| 459 | - | return nil, false | 642 | + | return resizeApplyResult{}, false |
| 460 | } | 643 | } | ||
| 461 | if current != nil && !current() { | 644 | if current != nil && !current() { | ||
| 462 | - | return nil, false | 645 | + | return resizeApplyResult{}, false |
| 463 | } | 646 | } | ||
| 464 | sess.mu.Lock() | 647 | sess.mu.Lock() | ||
| 465 | tabs := append([]*tab(nil), sess.tabs...) | 648 | tabs := append([]*tab(nil), sess.tabs...) | ||
| @@ -506,8 +689,15 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||||
| 506 | } | 689 | } | ||
| 507 | if valid { | 690 | if valid { | ||
| 508 | for _, plan := range plans { | 691 | for _, plan := range plans { | ||
| 509 | - | plan.tab.size = plan.size | 692 | + | if !commitPreparedTabLayoutLocked(plan) { |
| 510 | - | commitPreparedTabLayoutLocked(plan) | 693 | + | valid = false |
| 694 | + | break | |||
| 695 | + | } | |||
| 696 | + | } | |||
| 697 | + | if valid { | |||
| 698 | + | for _, plan := range plans { | |||
| 699 | + | plan.tab.size = plan.size | |||
| 700 | + | } | |||
| 511 | } | 701 | } | ||
| 512 | } | 702 | } | ||
| 513 | for i := len(tabs) - 1; i >= 0; i-- { | 703 | for i := len(tabs) - 1; i >= 0; i-- { | ||
| @@ -515,20 +705,27 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||||
| 515 | } | 705 | } | ||
| 516 | if !valid { | 706 | if !valid { | ||
| 517 | d.releasePreparedSessionGates(plans) | 707 | d.releasePreparedSessionGates(plans) | ||
| 708 | + | for _, plan := range plans { | |||
| 709 | + | if preparedTabOwnerStale(plan) { | |||
| 710 | + | return resizeApplyResult{}, false | |||
| 711 | + | } | |||
| 712 | + | } | |||
| 518 | // Headless requests have no attachment epoch. A live tab mutation | 713 | // Headless requests have no attachment epoch. A live tab mutation | ||
| 519 | // still invalidates their plan, but must trigger a fresh prepare rather | 714 | // still invalidates their plan, but must trigger a fresh prepare rather | ||
| 520 | // than being mistaken for a canceled request. | 715 | // than being mistaken for a canceled request. | ||
| 521 | if current != nil && !current() { | 716 | if current != nil && !current() { | ||
| 522 | - | return nil, false | 717 | + | return resizeApplyResult{}, false |
| 523 | } | 718 | } | ||
| 524 | // A layout mutation invalidated the plans while this epoch remains | 719 | // A layout mutation invalidated the plans while this epoch remains | ||
| 525 | // current. Reapply the fresh session geometry before admitting it. | 720 | // current. Reapply the fresh session geometry before admitting it. | ||
| 526 | continue | 721 | continue | ||
| 527 | } | 722 | } | ||
| 528 | 723 | ||||
| 724 | + | members := make([]resizeMember, 0) | |||
| 529 | failed := make([]resizeMember, 0) | 725 | failed := make([]resizeMember, 0) | ||
| 530 | for _, plan := range plans { | 726 | for _, plan := range plans { | ||
| 531 | d.finishPreparedTabMembers(plan, true) | 727 | d.finishPreparedTabMembers(plan, true) | ||
| 728 | + | members = append(members, plan.members...) | |||
| 532 | for _, member := range plan.members { | 729 | for _, member := range plan.members { | ||
| 533 | if !member.ok { | 730 | if !member.ok { | ||
| 534 | failed = append(failed, member) | 731 | failed = append(failed, member) | ||
| @@ -536,20 +733,114 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||||
| 536 | } | 733 | } | ||
| 537 | } | 734 | } | ||
| 538 | if active >= 0 && active < len(tabs) { | 735 | if active >= 0 && active < len(tabs) { | ||
| 539 | - | floatingFailed, ok := d.applyVisibleFloatingLayout(sess, tabs[active], current) | 736 | + | floatingResult, ok := d.applyVisibleFloatingLayout(sess, tabs[active], current) |
| 540 | if !ok { | 737 | if !ok { | ||
| 541 | - | return nil, false | 738 | + | return resizeApplyResult{}, false |
| 542 | } | 739 | } | ||
| 543 | - | failed = append(failed, floatingFailed...) | 740 | + | members = append(members, floatingResult.members...) |
| 741 | + | failed = append(failed, floatingResult.failed...) | |||
| 544 | } | 742 | } | ||
| 545 | - | if len(failed) != 0 { | 743 | + | if len(failed) != 0 && resizeMembersOwnerCurrent(failed) { |
| 546 | d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed, | 744 | d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed, | ||
| 547 | "pane resize failed; retrying in background", failed[len(failed)-1].err) | 745 | "pane resize failed; retrying in background", failed[len(failed)-1].err) | ||
| 548 | } | 746 | } | ||
| 549 | - | markSnapshotDirty(sess) | 747 | + | if len(failed) != 0 && !resizeMembersOwnerCurrent(failed) { |
| 550 | - | d.observeRuntime(ports.RuntimeResizeCommitted, 0, true) | 748 | + | return resizeApplyResult{}, false |
| 551 | - | return failed, true | 749 | + | } |
| 750 | + | return resizeApplyResult{members: members, failed: failed}, true | |||
| 751 | + | } | |||
| 752 | + | } | |||
| 753 | + | ||||
| 754 | + | type resizeCommitPublication struct { | |||
| 755 | + | members []resizeMember | |||
| 756 | + | session *session | |||
| 757 | + | attachment *attachedClient | |||
| 758 | + | lease *attachmentLease | |||
| 759 | + | epoch uint64 | |||
| 760 | + | role attachmentRoleToken | |||
| 761 | + | coordinator *renderCoordinator | |||
| 762 | + | observer ports.RuntimeObserver | |||
| 763 | + | mark ports.RuntimeMark | |||
| 764 | + | } | |||
| 765 | + | ||||
| 766 | + | func (p resizeCommitPublication) current() bool { | |||
| 767 | + | if !resizeMembersOwnerCurrent(p.members) { | |||
| 768 | + | return false | |||
| 552 | } | 769 | } | ||
| 770 | + | if p.attachment == nil { | |||
| 771 | + | return true | |||
| 772 | + | } | |||
| 773 | + | if p.role.effect == nil || p.role.effect.ended.Load() || p.role.sess != p.session || | |||
| 774 | + | p.role.ac != p.attachment || p.role.role != attachmentActive || p.role.lease != p.lease || | |||
| 775 | + | p.role.generation != p.attachment.roleGeneration.Load() || | |||
| 776 | + | !p.attachment.transportSnapshotCurrent(p.role.transport) || | |||
| 777 | + | p.attachment.currentSession() != p.session || p.coordinator == nil { | |||
| 778 | + | return false | |||
| 779 | + | } | |||
| 780 | + | p.session.mu.Lock() | |||
| 781 | + | active := p.session.attachmentRoleLocked(p.attachment) == attachmentActive | |||
| 782 | + | p.session.mu.Unlock() | |||
| 783 | + | return active && p.coordinator.resizeCurrentForLease(p.epoch, p.attachment, p.lease, false) | |||
| 784 | + | } | |||
| 785 | + | ||||
| 786 | + | // emit performs observer work only after every resize-owner fence and | |||
| 787 | + | // attachment sendMu are released. The generation-bound publication is checked | |||
| 788 | + | // once more so a move that wins after geometry publication but before observer | |||
| 789 | + | // admission suppresses stale source telemetry. | |||
| 790 | + | func (p resizeCommitPublication) emit() bool { | |||
| 791 | + | if !p.current() { | |||
| 792 | + | return false | |||
| 793 | + | } | |||
| 794 | + | if p.observer != nil { | |||
| 795 | + | p.observer.ObserveRuntime(p.mark) | |||
| 796 | + | } | |||
| 797 | + | return true | |||
| 798 | + | } | |||
| 799 | + | ||||
| 800 | + | // publishResizeCommit linearizes attachment geometry and an immutable telemetry | |||
| 801 | + | // reservation with pane ownership. sendMu is acquired before resize fences, so | |||
| 802 | + | // the canonical rule never acquires an attachment lock while a pane fence is | |||
| 803 | + | // held. Observer callbacks run only after both lock families are released. | |||
| 804 | + | func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, ticket *roleEffectTicket, size domain.Size) bool { | |||
| 805 | + | d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication) | |||
| 806 | + | if ac != nil { | |||
| 807 | + | ac.sendMu.Lock() | |||
| 808 | + | } | |||
| 809 | + | fences := acquireResizeOwnerPostEffectFences(members) | |||
| 810 | + | if fences == nil { | |||
| 811 | + | if ac != nil { | |||
| 812 | + | ac.sendMu.Unlock() | |||
| 813 | + | } | |||
| 814 | + | return false | |||
| 815 | + | } | |||
| 816 | + | publication := resizeCommitPublication{ | |||
| 817 | + | members: append([]resizeMember(nil), members...), | |||
| 818 | + | session: sess, | |||
| 819 | + | attachment: ac, | |||
| 820 | + | lease: lease, | |||
| 821 | + | epoch: epoch, | |||
| 822 | + | observer: d.runtimeObserver, | |||
| 823 | + | mark: ports.NewRuntimeMark("daemon", ports.RuntimeResizeCommitted, 0, true), | |||
| 824 | + | } | |||
| 825 | + | if ticket != nil { | |||
| 826 | + | publication.role = ticket.roleToken() | |||
| 827 | + | publication.coordinator = sess.renderCoordinator() | |||
| 828 | + | } | |||
| 829 | + | if !publication.current() { | |||
| 830 | + | fences.Release() | |||
| 831 | + | if ac != nil { | |||
| 832 | + | ac.sendMu.Unlock() | |||
| 833 | + | } | |||
| 834 | + | return false | |||
| 835 | + | } | |||
| 836 | + | if ac != nil { | |||
| 837 | + | ac.size = size | |||
| 838 | + | } | |||
| 839 | + | fences.Release() | |||
| 840 | + | if ac != nil { | |||
| 841 | + | ac.sendMu.Unlock() | |||
| 842 | + | } | |||
| 843 | + | return publication.emit() | |||
| 553 | } | 844 | } | ||
| 554 | 845 | ||||
| 555 | func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool { | 846 | func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool { | ||
| @@ -568,22 +859,34 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease * | |||||
| 568 | return false | 859 | return false | ||
| 569 | } | 860 | } | ||
| 570 | d.exitCopyMode(ac) | 861 | d.exitCopyMode(ac) | ||
| 571 | - | failed, ok := d.applySessionLayout(sess, snap.size, current, func() bool { | 862 | + | result, ok := d.applySessionLayout(sess, snap.size, current, func() bool { |
| 572 | return rc.resizeCurrentForLease(epoch, ac, lease, true) | 863 | return rc.resizeCurrentForLease(epoch, ac, lease, true) | ||
| 573 | }) | 864 | }) | ||
| 574 | if !ok { | 865 | if !ok { | ||
| 575 | return false | 866 | return false | ||
| 576 | } | 867 | } | ||
| 577 | - | ac.sendMu.Lock() | 868 | + | if !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() { |
| 578 | - | ac.size = snap.size | 869 | + | markSnapshotDirty(sess) |
| 579 | - | ac.sendMu.Unlock() | 870 | + | }) { |
| 580 | - | if len(failed) != 0 { | 871 | + | return false |
| 581 | - | rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) }) | 872 | + | } |
| 873 | + | if !d.publishResizeCommit(result.members, sess, ac, lease, epoch, ticket, snap.size) { | |||
| 874 | + | return false | |||
| 875 | + | } | |||
| 876 | + | if len(result.failed) != 0 { | |||
| 877 | + | var retry *resizeRetryReservation | |||
| 878 | + | if !d.publishResizeOwnerPostEffect(result.failed, resizeOwnerPostRetrySchedule, func() { | |||
| 879 | + | retry, _ = rc.reserveResizeRetryForLease(epoch, ac, lease, func() { | |||
| 880 | + | d.retryResizeMembers(sess, ac, lease, epoch, result.failed) | |||
| 881 | + | }) | |||
| 882 | + | }) || retry == nil { | |||
| 883 | + | return false | |||
| 884 | + | } | |||
| 885 | + | retry.finish() | |||
| 582 | } | 886 | } | ||
| 583 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | 887 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | ||
| 584 | - | // A successful transaction publishes exactly one full S2 frame. The | 888 | + | if !d.publishResizeOwnerInvalidation(result.members, sess, ac, lease, epoch, |
| 585 | - | // coordinator is the only emission route and stale epochs never reach it. | 889 | + | renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) { |
| 586 | - | if !rc.invalidateForLeaseAtResizeEpoch(ac, lease, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) { | |||
| 587 | return false | 890 | return false | ||
| 588 | } | 891 | } | ||
| 589 | // The resize debounce has already elapsed. Consume this sticky reset now; | 892 | // The resize debounce has already elapsed. Consume this sticky reset now; | ||
| @@ -613,7 +916,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at | |||||
| 613 | tabs := make([]*tab, 0, len(members)) | 916 | tabs := make([]*tab, 0, len(members)) | ||
| 614 | seen := make(map[*tab]struct{}, len(members)) | 917 | seen := make(map[*tab]struct{}, len(members)) | ||
| 615 | for _, member := range members { | 918 | for _, member := range members { | ||
| 616 | - | if member.tab == nil || member.isFloating { | 919 | + | if member.tab == nil || member.isFloating || !resizeMemberOwnerCurrentLocked(&member) { |
| 617 | continue | 920 | continue | ||
| 618 | } | 921 | } | ||
| 619 | if _, ok := seen[member.tab]; ok { | 922 | if _, ok := seen[member.tab]; ok { | ||
| @@ -651,13 +954,13 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at | |||||
| 651 | // pointers, and solved rectangles, and validates them again before any | 954 | // pointers, and solved rectangles, and validates them again before any | ||
| 652 | // VT/rectangle publication. It also preserves the resize gate and | 955 | // VT/rectangle publication. It also preserves the resize gate and | ||
| 653 | // degradation notice behavior for another failed external attempt. | 956 | // degradation notice behavior for another failed external attempt. | ||
| 654 | - | freshFailed, ok := d.applyTabLayoutTransaction(sess, tb, func() bool { | 957 | + | result, ok := d.applyTabLayoutTransaction(sess, tb, func() bool { |
| 655 | return rc.retryCurrentForLease(epoch, ac, lease) | 958 | return rc.retryCurrentForLease(epoch, ac, lease) | ||
| 656 | }) | 959 | }) | ||
| 657 | if !ok || !rc.retryCurrentForLease(epoch, ac, lease) { | 960 | if !ok || !rc.retryCurrentForLease(epoch, ac, lease) { | ||
| 658 | return | 961 | return | ||
| 659 | } | 962 | } | ||
| 660 | - | failed = append(failed, freshFailed...) | 963 | + | failed = append(failed, result.failed...) |
| 661 | // A retry success is a formerly failed, still-current target whose | 964 | // A retry success is a formerly failed, still-current target whose | ||
| 662 | // canonical apply cleared its retry bit. A collapsed or removed target | 965 | // canonical apply cleared its retry bit. A collapsed or removed target | ||
| 663 | // is neither a retry completion nor a reason to publish a reset. | 966 | // is neither a retry completion nor a reason to publish a reset. | ||
| @@ -673,7 +976,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at | |||||
| 673 | tb.mu.Unlock() | 976 | tb.mu.Unlock() | ||
| 674 | } | 977 | } | ||
| 675 | for _, member := range members { | 978 | for _, member := range members { | ||
| 676 | - | if !member.isFloating || member.tab == nil || member.pane == nil { | 979 | + | if !member.isFloating || member.tab == nil || member.pane == nil || !resizeMemberOwnerCurrentLocked(&member) { |
| 677 | continue | 980 | continue | ||
| 678 | } | 981 | } | ||
| 679 | if !rc.retryCurrentForLease(epoch, ac, lease) { | 982 | if !rc.retryCurrentForLease(epoch, ac, lease) { | ||
| @@ -685,7 +988,8 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at | |||||
| 685 | member.tab.mu.Lock() | 988 | member.tab.mu.Lock() | ||
| 686 | currentSlot := member.tab.floating.state == floatingVisible && | 989 | currentSlot := member.tab.floating.state == floatingVisible && | ||
| 687 | member.tab.floating.generation == member.floatingGeneration && | 990 | member.tab.floating.generation == member.floatingGeneration && | ||
| 688 | - | member.tab.floating.pane == member.pane | 991 | + | member.tab.floating.pane == member.pane && |
| 992 | + | resizeMemberOwnerCurrentLocked(&member) | |||
| 689 | retryPending := false | 993 | retryPending := false | ||
| 690 | if currentSlot { | 994 | if currentSlot { | ||
| 691 | member.pane.mu.Lock() | 995 | member.pane.mu.Lock() | ||
| @@ -696,32 +1000,46 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at | |||||
| 696 | if !currentSlot || !retryPending { | 1000 | if !currentSlot || !retryPending { | ||
| 697 | continue | 1001 | continue | ||
| 698 | } | 1002 | } | ||
| 699 | - | freshFailed, ok := d.applyVisibleFloatingLayoutForMember(sess, member.tab, func() bool { | 1003 | + | result, ok := d.applyVisibleFloatingLayoutForMember(sess, member.tab, func() bool { |
| 700 | return rc.retryCurrentForLease(epoch, ac, lease) | 1004 | return rc.retryCurrentForLease(epoch, ac, lease) | ||
| 701 | }, &member) | 1005 | }, &member) | ||
| 702 | if !ok || !rc.retryCurrentForLease(epoch, ac, lease) { | 1006 | if !ok || !rc.retryCurrentForLease(epoch, ac, lease) { | ||
| 703 | return | 1007 | return | ||
| 704 | } | 1008 | } | ||
| 705 | - | failed = append(failed, freshFailed...) | 1009 | + | failed = append(failed, result.failed...) |
| 706 | member.tab.mu.Lock() | 1010 | member.tab.mu.Lock() | ||
| 707 | stillCurrent := member.tab.floating.state == floatingVisible && | 1011 | stillCurrent := member.tab.floating.state == floatingVisible && | ||
| 708 | member.tab.floating.generation == member.floatingGeneration && | 1012 | member.tab.floating.generation == member.floatingGeneration && | ||
| 709 | - | member.tab.floating.pane == member.pane | 1013 | + | member.tab.floating.pane == member.pane && |
| 1014 | + | resizeMemberOwnerCurrentLocked(&member) | |||
| 710 | member.tab.mu.Unlock() | 1015 | member.tab.mu.Unlock() | ||
| 711 | - | if stillCurrent && len(freshFailed) == 0 { | 1016 | + | if stillCurrent && len(result.failed) == 0 { |
| 712 | member.pane.mu.Lock() | 1017 | member.pane.mu.Lock() | ||
| 713 | succeeded = succeeded || !member.pane.resizeRetry | 1018 | succeeded = succeeded || !member.pane.resizeRetry | ||
| 714 | member.pane.mu.Unlock() | 1019 | member.pane.mu.Unlock() | ||
| 715 | } | 1020 | } | ||
| 716 | } | 1021 | } | ||
| 717 | if len(failed) != 0 { | 1022 | if len(failed) != 0 { | ||
| 718 | - | rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) }) | 1023 | + | var retry *resizeRetryReservation |
| 1024 | + | if !d.publishResizeOwnerPostEffect(failed, resizeOwnerPostRetrySchedule, func() { | |||
| 1025 | + | retry, _ = rc.reserveResizeRetryForLease(epoch, ac, lease, func() { | |||
| 1026 | + | d.retryResizeMembers(sess, ac, lease, epoch, failed) | |||
| 1027 | + | }) | |||
| 1028 | + | }) || retry == nil { | |||
| 1029 | + | return | |||
| 1030 | + | } | |||
| 1031 | + | retry.finish() | |||
| 719 | } | 1032 | } | ||
| 720 | if succeeded { | 1033 | if succeeded { | ||
| 721 | // Retry completion changes VT state after the original layout commit. | 1034 | // Retry completion changes VT state after the original layout commit. | ||
| 722 | // Keep a named session's eventual snapshot generation aligned with it. | 1035 | // Keep a named session's eventual snapshot generation aligned with it. | ||
| 723 | - | markSnapshotDirty(sess) | 1036 | + | if !d.publishResizeOwnerPostEffect(members, resizeOwnerPostSnapshotDirty, func() { |
| 724 | - | rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) | 1037 | + | markSnapshotDirty(sess) |
| 1038 | + | }) { | |||
| 1039 | + | return | |||
| 1040 | + | } | |||
| 1041 | + | d.publishResizeOwnerInvalidation(members, sess, ac, lease, 0, | |||
| 1042 | + | renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) | |||
| 725 | } | 1043 | } | ||
| 726 | } | 1044 | } | ||
| 727 | 1045 | ||||
| @@ -747,8 +1065,13 @@ func (d *Daemon) requestTransactionalResizeForLease(sess *session, ac *attachedC | |||||
| 747 | if ac == nil { | 1065 | if ac == nil { | ||
| 748 | // Headless geometry has no coordinator/transport to coalesce, but keeps | 1066 | // Headless geometry has no coordinator/transport to coalesce, but keeps | ||
| 749 | // the same prepare/apply/commit ordering. | 1067 | // the same prepare/apply/commit ordering. | ||
| 750 | - | _, ok := d.applySessionLayout(sess, size, nil, nil) | 1068 | + | result, ok := d.applySessionLayout(sess, size, nil, nil) |
| 751 | - | return ok | 1069 | + | if !ok || !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() { |
| 1070 | + | markSnapshotDirty(sess) | |||
| 1071 | + | }) { | |||
| 1072 | + | return false | |||
| 1073 | + | } | |||
| 1074 | + | return d.publishResizeCommit(result.members, sess, nil, nil, 0, nil, size) | |||
| 752 | } | 1075 | } | ||
| 753 | rc := sess.renderCoordinator() | 1076 | rc := sess.renderCoordinator() | ||
| 754 | if rc == nil || !rc.leaseCurrent(lease, true) { | 1077 | if rc == nil || !rc.leaseCurrent(lease, true) { | ||
M internal/usecase/daemon/transactional_resize_test.go +438 -2
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "io" | |
| 6 | 6 | "strings" | |
| 7 | 7 | "sync" | |
| 8 | + | "sync/atomic" | |
| 8 | 9 | "testing" | |
| 9 | 10 | "time" | |
| 10 | 11 | ||
| @@ -271,10 +272,10 @@ func TestApplySessionLayoutReportsResizeFailuresOnce(t *testing.T) { | |||
| 271 | 272 | } | |
| 272 | 273 | d, sess, _, _ := newManualSessionWithPTYs(t, first, second) | |
| 273 | 274 | ||
| 274 | - | failed, ok := d.applySessionLayout(sess, domain.Size{Cols: 100, Rows: 30}, nil, nil) | |
| 275 | + | result, ok := d.applySessionLayout(sess, domain.Size{Cols: 100, Rows: 30}, nil, nil) | |
| 275 | 276 | ||
| 276 | 277 | require.Equal(t, tc.wantOK, ok) | |
| 277 | - | require.Len(t, failed, tc.wantFailed) | |
| 278 | + | require.Len(t, result.failed, tc.wantFailed) | |
| 278 | 279 | count := 0 | |
| 279 | 280 | for _, notification := range d.notices.history() { | |
| 280 | 281 | if notification.Code == domain.NoticeResizeFailed { | |
| @@ -406,6 +407,402 @@ func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) { | |||
| 406 | 407 | // stopped. Only the newest prepared epoch may apply, commit, and emit one full | |
| 407 | 408 | // frame. coordinatorMockClock is a generated MockClock/MockTimer harness; its | |
| 408 | 409 | // channels make callback order explicit and do not use wall-clock waits. | |
| 410 | + | func publishResizeOwnerMoveUnderFence(t *testing.T, source, destination *session, tb *tab, p *pane) { | |
| 411 | + | t.Helper() | |
| 412 | + | fences := newMovePaneResizeFences(source, destination, tb, tb, p) | |
| 413 | + | require.True(t, fences.acquire(func() bool { | |
| 414 | + | p.mu.Lock() | |
| 415 | + | p.publishOwnerLocked(destination, tb, 0) | |
| 416 | + | p.mu.Unlock() | |
| 417 | + | return true | |
| 418 | + | })) | |
| 419 | + | fences.Release() | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | func pauseResizeOwnerPostEffect(d *Daemon, effect resizeOwnerPostEffect) (<-chan struct{}, func()) { | |
| 423 | + | entered := make(chan struct{}) | |
| 424 | + | release := make(chan struct{}) | |
| 425 | + | var once sync.Once | |
| 426 | + | d.beforeResizeOwnerPostEffect = func(got resizeOwnerPostEffect) { | |
| 427 | + | if got != effect { | |
| 428 | + | return | |
| 429 | + | } | |
| 430 | + | once.Do(func() { close(entered) }) | |
| 431 | + | <-release | |
| 432 | + | } | |
| 433 | + | return entered, func() { close(release) } | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | func TestResizeOwnerMoveImmediatelyBeforeSnapshotDirtyDropsStaleSource(t *testing.T) { | |
| 437 | + | d, source, _, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 438 | + | source.snapEligible.Store(true) | |
| 439 | + | tb := source.activeTab() | |
| 440 | + | p := tb.focusedPane() | |
| 441 | + | tb.mu.Lock() | |
| 442 | + | tb.size = domain.Size{Cols: 100, Rows: 20} | |
| 443 | + | tb.bumpLayoutGenerationLocked() | |
| 444 | + | tb.mu.Unlock() | |
| 445 | + | destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}} | |
| 446 | + | entered, release := pauseResizeOwnerPostEffect(d, resizeOwnerPostSnapshotDirty) | |
| 447 | + | ||
| 448 | + | result := make(chan bool, 1) | |
| 449 | + | go func() { result <- d.applyTabLayout(source, tb) }() | |
| 450 | + | awaitTestCompletion(t, entered, "resize did not pause before snapshot dirtiness") | |
| 451 | + | publishResizeOwnerMoveUnderFence(t, source, destination, tb, p) | |
| 452 | + | release() | |
| 453 | + | ||
| 454 | + | require.False(t, awaitTestValue(t, result, "stale source layout did not return")) | |
| 455 | + | require.False(t, source.snapDirty.Load(), "stale source dirtied its snapshot after the move won the fence") | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | func TestRetryOwnerMoveImmediatelyBeforeScheduleContinuesAtDestination(t *testing.T) { | |
| 459 | + | resizeErr := errors.New("scripted resize failure") | |
| 460 | + | pty := &transactionalResizePTY{errs: []error{resizeErr, resizeErr}} | |
| 461 | + | d, source, _, _ := newManualSessionWithPTYs(t, pty) | |
| 462 | + | clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 8)} | |
| 463 | + | d.clock = clock | |
| 464 | + | tb := source.activeTab() | |
| 465 | + | p := tb.focusedPane() | |
| 466 | + | tb.mu.Lock() | |
| 467 | + | tb.size = domain.Size{Cols: 100, Rows: 20} | |
| 468 | + | tb.bumpLayoutGenerationLocked() | |
| 469 | + | tb.mu.Unlock() | |
| 470 | + | destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}} | |
| 471 | + | entered, release := pauseResizeOwnerPostEffect(d, resizeOwnerPostRetrySchedule) | |
| 472 | + | ||
| 473 | + | result := make(chan bool, 1) | |
| 474 | + | go func() { result <- d.applyTabLayout(source, tb) }() | |
| 475 | + | awaitTestCompletion(t, entered, "resize did not pause before retry scheduling") | |
| 476 | + | publishResizeOwnerMoveUnderFence(t, source, destination, tb, p) | |
| 477 | + | release() | |
| 478 | + | ||
| 479 | + | require.False(t, awaitTestValue(t, result, "stale source retry publication did not return")) | |
| 480 | + | tb.layoutRetryMu.Lock() | |
| 481 | + | require.False(t, tb.layoutRetryRunning, "stale source published a retry worker") | |
| 482 | + | tb.layoutRetryMu.Unlock() | |
| 483 | + | for { | |
| 484 | + | select { | |
| 485 | + | case timer := <-clock.timers: | |
| 486 | + | require.NotEqual(t, minOutputRenderDeadline, timer.delay, "stale source armed a retry timer") | |
| 487 | + | default: | |
| 488 | + | goto timersDrained | |
| 489 | + | } | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | timersDrained: | |
| 493 | + | d.beforeResizeOwnerPostEffect = nil | |
| 494 | + | require.True(t, d.applyTabLayout(destination, tb), "destination must recreate retry from pane metadata") | |
| 495 | + | for { | |
| 496 | + | timer := awaitTestValue(t, clock.timers, "destination did not continue the moved retry") | |
| 497 | + | if timer.delay == minOutputRenderDeadline { | |
| 498 | + | break | |
| 499 | + | } | |
| 500 | + | } | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | type resizeFenceCheckingClock struct { | |
| 504 | + | locks []*sync.Mutex | |
| 505 | + | calls atomic.Int32 | |
| 506 | + | locksFree atomic.Bool | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | func (c *resizeFenceCheckingClock) check() { | |
| 510 | + | c.calls.Add(1) | |
| 511 | + | locked := make([]*sync.Mutex, 0, len(c.locks)) | |
| 512 | + | for _, lock := range c.locks { | |
| 513 | + | if !lock.TryLock() { | |
| 514 | + | for i := len(locked) - 1; i >= 0; i-- { | |
| 515 | + | locked[i].Unlock() | |
| 516 | + | } | |
| 517 | + | c.locksFree.Store(false) | |
| 518 | + | return | |
| 519 | + | } | |
| 520 | + | locked = append(locked, lock) | |
| 521 | + | } | |
| 522 | + | for i := len(locked) - 1; i >= 0; i-- { | |
| 523 | + | locked[i].Unlock() | |
| 524 | + | } | |
| 525 | + | } | |
| 526 | + | ||
| 527 | + | func (c *resizeFenceCheckingClock) Now() time.Time { | |
| 528 | + | c.check() | |
| 529 | + | return time.Time{} | |
| 530 | + | } | |
| 531 | + | ||
| 532 | + | func (c *resizeFenceCheckingClock) NewTimer(time.Duration) ports.Timer { | |
| 533 | + | c.check() | |
| 534 | + | return stubTimer{} | |
| 535 | + | } | |
| 536 | + | ||
| 537 | + | type blockingResizeCommitObserver struct { | |
| 538 | + | entered chan struct{} | |
| 539 | + | release chan struct{} | |
| 540 | + | once sync.Once | |
| 541 | + | } | |
| 542 | + | ||
| 543 | + | func (o *blockingResizeCommitObserver) ObserveRuntime(mark ports.RuntimeMark) { | |
| 544 | + | if mark.Kind != ports.RuntimeResizeCommitted { | |
| 545 | + | return | |
| 546 | + | } | |
| 547 | + | o.once.Do(func() { close(o.entered) }) | |
| 548 | + | <-o.release | |
| 549 | + | } | |
| 550 | + | ||
| 551 | + | func TestResizeCommitObserverBlocksWithoutHoldingOwnerOrAttachmentFences(t *testing.T) { | |
| 552 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 553 | + | observer := &blockingResizeCommitObserver{entered: make(chan struct{}), release: make(chan struct{})} | |
| 554 | + | d.runtimeObserver = observer | |
| 555 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 556 | + | lease := rc.attachmentLease(ac) | |
| 557 | + | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease) | |
| 558 | + | tb := sess.activeTab() | |
| 559 | + | p := tb.focusedPane() | |
| 560 | + | ||
| 561 | + | result := make(chan bool, 1) | |
| 562 | + | go func() { result <- d.runResizeTransaction(sess, ac, lease, epoch) }() | |
| 563 | + | awaitTestCompletion(t, observer.entered, "resize commit observer did not block") | |
| 564 | + | for _, lock := range []struct { | |
| 565 | + | name string | |
| 566 | + | mu *sync.Mutex | |
| 567 | + | }{ | |
| 568 | + | {name: "attachment send", mu: &ac.sendMu}, | |
| 569 | + | {name: "session resize", mu: &sess.layoutApplyMu}, | |
| 570 | + | {name: "tab resize", mu: &tb.layoutApplyMu}, | |
| 571 | + | {name: "pane resize", mu: &p.resizeMu}, | |
| 572 | + | } { | |
| 573 | + | require.True(t, lock.mu.TryLock(), "%s fence remained held across observer callback", lock.name) | |
| 574 | + | lock.mu.Unlock() | |
| 575 | + | } | |
| 576 | + | close(observer.release) | |
| 577 | + | require.True(t, awaitTestValue(t, result, "resize did not finish after observer release")) | |
| 578 | + | } | |
| 579 | + | ||
| 580 | + | func TestResizeOwnerPostEffectsReleaseFencesBeforeTimerAndRenderCallbacks(t *testing.T) { | |
| 581 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 582 | + | tb := sess.activeTab() | |
| 583 | + | p := tb.focusedPane() | |
| 584 | + | clock := &resizeFenceCheckingClock{locks: []*sync.Mutex{&sess.layoutApplyMu, &tb.layoutApplyMu, &p.resizeMu}} | |
| 585 | + | clock.locksFree.Store(true) | |
| 586 | + | d.clock = clock | |
| 587 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 588 | + | callbackLocksFree := atomic.Bool{} | |
| 589 | + | callbackLocksFree.Store(true) | |
| 590 | + | rc.opts.onInvalidate = func(renderInvalidation) { | |
| 591 | + | clock.check() | |
| 592 | + | callbackLocksFree.Store(clock.locksFree.Load()) | |
| 593 | + | } | |
| 594 | + | lease := rc.attachmentLease(ac) | |
| 595 | + | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease) | |
| 596 | + | ||
| 597 | + | require.True(t, d.runResizeTransaction(sess, ac, lease, epoch)) | |
| 598 | + | require.Positive(t, clock.calls.Load(), "clock boundary was not exercised") | |
| 599 | + | require.True(t, clock.locksFree.Load(), "timer or clock work ran under a resize ownership fence") | |
| 600 | + | require.True(t, callbackLocksFree.Load(), "render callback ran under a resize ownership fence") | |
| 601 | + | } | |
| 602 | + | ||
| 603 | + | func TestResizeOwnerMoveBeforeClientGeometryAndCommitTelemetryDropsStaleSource(t *testing.T) { | |
| 604 | + | first := &transactionalResizePTY{} | |
| 605 | + | second := &transactionalResizePTY{} | |
| 606 | + | d, source, ac, _ := newManualSessionWithPTYs(t, first, second) | |
| 607 | + | observer := &daemonRuntimeObserver{} | |
| 608 | + | d.runtimeObserver = observer | |
| 609 | + | rc := d.attachCoordinator(source, nil, ac, true) | |
| 610 | + | lease := rc.attachmentLease(ac) | |
| 611 | + | target := domain.Size{Cols: 100, Rows: 30} | |
| 612 | + | epoch := rc.recordResizeRequestForLease(target, ac, lease) | |
| 613 | + | require.NotZero(t, epoch) | |
| 614 | + | movedTab := source.tabs[1] | |
| 615 | + | movedPane := movedTab.focusedPane() | |
| 616 | + | destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{movedTab}} | |
| 617 | + | ||
| 618 | + | entered, release := pauseResizeOwnerPostEffect(d, resizeOwnerPostCommitPublication) | |
| 619 | + | result := make(chan bool, 1) | |
| 620 | + | go func() { result <- d.runResizeTransaction(source, ac, lease, epoch) }() | |
| 621 | + | select { | |
| 622 | + | case <-entered: | |
| 623 | + | publishResizeOwnerMoveUnderFence(t, source, destination, movedTab, movedPane) | |
| 624 | + | release() | |
| 625 | + | case accepted := <-result: | |
| 626 | + | ac.sendMu.Lock() | |
| 627 | + | publishedSize := ac.size | |
| 628 | + | ac.sendMu.Unlock() | |
| 629 | + | t.Fatalf("resize completed before geometry/telemetry owner fence: accepted=%v size=%+v marks=%+v", accepted, publishedSize, observer.marks) | |
| 630 | + | case <-time.After(time.Second): | |
| 631 | + | t.Fatal("resize neither reached geometry/telemetry publication nor completed") | |
| 632 | + | } | |
| 633 | + | ||
| 634 | + | require.False(t, awaitTestValue(t, result, "stale source geometry publication did not return")) | |
| 635 | + | ac.sendMu.Lock() | |
| 636 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 24}, ac.size, "stale source published attachment geometry after ownership commit") | |
| 637 | + | ac.sendMu.Unlock() | |
| 638 | + | for _, mark := range observer.marks { | |
| 639 | + | require.NotEqual(t, ports.RuntimeResizeCommitted, mark.Kind, "stale source published resize-commit telemetry after ownership commit") | |
| 640 | + | } | |
| 641 | + | ||
| 642 | + | // The destination's finalizer is the only accepted geometry/telemetry owner. | |
| 643 | + | d.beforeResizeOwnerPostEffect = nil | |
| 644 | + | destinationSize := domain.Size{Cols: 90, Rows: 28} | |
| 645 | + | require.True(t, d.requestTransactionalResizeForLease(destination, nil, nil, destinationSize, true)) | |
| 646 | + | movedPane.mu.Lock() | |
| 647 | + | require.Equal(t, domain.Size{Cols: 90, Rows: 26}, domain.Size{Cols: movedPane.screen.Frame.Width, Rows: movedPane.screen.Frame.Height}) | |
| 648 | + | movedPane.mu.Unlock() | |
| 649 | + | commits := 0 | |
| 650 | + | for _, mark := range observer.marks { | |
| 651 | + | if mark.Kind == ports.RuntimeResizeCommitted { | |
| 652 | + | commits++ | |
| 653 | + | } | |
| 654 | + | } | |
| 655 | + | require.Equal(t, 1, commits, "only the destination finalizer may publish resize-commit telemetry") | |
| 656 | + | } | |
| 657 | + | ||
| 658 | + | func TestSameOwnerResizePublishesClientGeometryAndCommitTelemetryOnce(t *testing.T) { | |
| 659 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}, &transactionalResizePTY{}) | |
| 660 | + | observer := &daemonRuntimeObserver{} | |
| 661 | + | d.runtimeObserver = observer | |
| 662 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 663 | + | lease := rc.attachmentLease(ac) | |
| 664 | + | target := domain.Size{Cols: 100, Rows: 30} | |
| 665 | + | epoch := rc.recordResizeRequestForLease(target, ac, lease) | |
| 666 | + | ||
| 667 | + | require.True(t, d.runResizeTransaction(sess, ac, lease, epoch)) | |
| 668 | + | ac.sendMu.Lock() | |
| 669 | + | require.Equal(t, target, ac.size) | |
| 670 | + | ac.sendMu.Unlock() | |
| 671 | + | commits := 0 | |
| 672 | + | for _, mark := range observer.marks { | |
| 673 | + | if mark.Kind == ports.RuntimeResizeCommitted { | |
| 674 | + | commits++ | |
| 675 | + | } | |
| 676 | + | } | |
| 677 | + | require.Equal(t, 1, commits) | |
| 678 | + | } | |
| 679 | + | ||
| 680 | + | func TestResizeOwnerMoveImmediatelyBeforeRenderInvalidationDropsStaleSource(t *testing.T) { | |
| 681 | + | d, source, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 682 | + | rc := d.attachCoordinator(source, nil, ac, true) | |
| 683 | + | lease := rc.attachmentLease(ac) | |
| 684 | + | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease) | |
| 685 | + | var invalidations atomic.Int32 | |
| 686 | + | rc.opts.onInvalidate = func(renderInvalidation) { invalidations.Add(1) } | |
| 687 | + | tb := source.activeTab() | |
| 688 | + | p := tb.focusedPane() | |
| 689 | + | destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}} | |
| 690 | + | entered, release := pauseResizeOwnerPostEffect(d, resizeOwnerPostRenderInvalidation) | |
| 691 | + | ||
| 692 | + | result := make(chan bool, 1) | |
| 693 | + | go func() { result <- d.runResizeTransaction(source, ac, lease, epoch) }() | |
| 694 | + | awaitTestCompletion(t, entered, "resize did not pause before render invalidation") | |
| 695 | + | publishResizeOwnerMoveUnderFence(t, source, destination, tb, p) | |
| 696 | + | release() | |
| 697 | + | ||
| 698 | + | require.False(t, awaitTestValue(t, result, "stale source invalidation did not return")) | |
| 699 | + | require.Zero(t, invalidations.Load(), "stale source invalidated its renderer after the move won the fence") | |
| 700 | + | } | |
| 701 | + | ||
| 702 | + | func TestRetryOwnerAcceptedTabStopsWhenFailedPaneMoves(t *testing.T) { | |
| 703 | + | resizeErr := errors.New("scripted resize failure") | |
| 704 | + | pty := &transactionalResizePTY{errs: []error{resizeErr}} | |
| 705 | + | d, source, _, _ := newManualSessionWithPTYs(t, pty) | |
| 706 | + | clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 8)} | |
| 707 | + | d.clock = clock | |
| 708 | + | tb := source.activeTab() | |
| 709 | + | p := tb.focusedPane() | |
| 710 | + | tb.mu.Lock() | |
| 711 | + | tb.size = domain.Size{Cols: 100, Rows: 20} | |
| 712 | + | tb.bumpLayoutGenerationLocked() | |
| 713 | + | tb.mu.Unlock() | |
| 714 | + | ||
| 715 | + | require.True(t, d.applyTabLayout(source, tb)) | |
| 716 | + | var timer *layoutRetryTimer | |
| 717 | + | for timer == nil { | |
| 718 | + | candidate := awaitTestValue(t, clock.timers, "accepted failure did not schedule its retry") | |
| 719 | + | if candidate.delay == minOutputRenderDeadline { | |
| 720 | + | timer = candidate | |
| 721 | + | } | |
| 722 | + | } | |
| 723 | + | source.snapshotMu.Lock() | |
| 724 | + | sourceGeneration := source.snapshotGeneration | |
| 725 | + | source.snapshotMu.Unlock() | |
| 726 | + | notices := len(d.notices.history()) | |
| 727 | + | ||
| 728 | + | destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}} | |
| 729 | + | publishPaneOwner(p, destination, tb, 0) | |
| 730 | + | timer.fire() | |
| 731 | + | ||
| 732 | + | require.Eventually(t, func() bool { | |
| 733 | + | tb.layoutRetryMu.Lock() | |
| 734 | + | defer tb.layoutRetryMu.Unlock() | |
| 735 | + | return !tb.layoutRetryRunning | |
| 736 | + | }, time.Second, time.Millisecond, "stale source retry did not retire") | |
| 737 | + | require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}}, pty.requested(), "stale source retry reached the moved pane PTY") | |
| 738 | + | source.snapshotMu.Lock() | |
| 739 | + | require.Equal(t, sourceGeneration, source.snapshotGeneration, "stale source retry dirtied its former session") | |
| 740 | + | source.snapshotMu.Unlock() | |
| 741 | + | require.Len(t, d.notices.history(), notices, "stale source retry published a notice") | |
| 742 | + | } | |
| 743 | + | ||
| 744 | + | func TestTransactionalResizeOwnerMoveAfterExternalApplyPublishesOnlyDestinationGeometry(t *testing.T) { | |
| 745 | + | entered := make(chan struct{}) | |
| 746 | + | release := make(chan struct{}) | |
| 747 | + | pty := &transactionalResizePTY{} | |
| 748 | + | pty.onResize = func() { | |
| 749 | + | if len(pty.requested()) != 1 { | |
| 750 | + | return | |
| 751 | + | } | |
| 752 | + | close(entered) | |
| 753 | + | <-release | |
| 754 | + | } | |
| 755 | + | d, source, ac, _ := newManualSessionWithPTYs(t, pty) | |
| 756 | + | observer := &daemonRuntimeObserver{} | |
| 757 | + | d.runtimeObserver = observer | |
| 758 | + | rc := d.attachCoordinator(source, nil, ac, true) | |
| 759 | + | var invalidations atomic.Int32 | |
| 760 | + | rc.opts.onInvalidate = func(renderInvalidation) { invalidations.Add(1) } | |
| 761 | + | lease := rc.attachmentLease(ac) | |
| 762 | + | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease) | |
| 763 | + | require.NotZero(t, epoch) | |
| 764 | + | tb := source.activeTab() | |
| 765 | + | p := tb.focusedPane() | |
| 766 | + | destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}} | |
| 767 | + | ||
| 768 | + | result := make(chan bool, 1) | |
| 769 | + | go func() { | |
| 770 | + | result <- d.runResizeTransaction(source, ac, lease, epoch) | |
| 771 | + | }() | |
| 772 | + | awaitTestCompletion(t, entered, "source resize did not reach the external PTY") | |
| 773 | + | ||
| 774 | + | source.mu.Lock() | |
| 775 | + | source.tabs = nil | |
| 776 | + | source.active = -1 | |
| 777 | + | source.mu.Unlock() | |
| 778 | + | publishPaneOwner(p, destination, tb, 0) | |
| 779 | + | close(release) | |
| 780 | + | ||
| 781 | + | require.False(t, awaitTestValue(t, result, "source resize did not finish after the owner move")) | |
| 782 | + | require.False(t, source.snapDirty.Load(), "retired source owner dirtied its snapshot") | |
| 783 | + | require.Zero(t, invalidations.Load(), "retired source owner invalidated its renderer") | |
| 784 | + | require.Empty(t, d.notices.history(), "retired source owner published a resize notice") | |
| 785 | + | for _, mark := range observer.marks { | |
| 786 | + | require.NotEqual(t, ports.RuntimeResizeCommitted, mark.Kind, "retired source owner published resize telemetry") | |
| 787 | + | } | |
| 788 | + | ac.sendMu.Lock() | |
| 789 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 24}, ac.size, "retired source owner published client geometry") | |
| 790 | + | ac.sendMu.Unlock() | |
| 791 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size, "retired source owner published the moved tab size") | |
| 792 | + | p.mu.Lock() | |
| 793 | + | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "retired source owner published pane geometry") | |
| 794 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "retired source owner published VT geometry") | |
| 795 | + | require.True(t, p.resizeRetry, "the destination owner needs metadata for the externally applied stale size") | |
| 796 | + | p.mu.Unlock() | |
| 797 | + | ||
| 798 | + | require.True(t, d.applyTabLayout(destination, tb)) | |
| 799 | + | require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 80, Rows: 23}}, pty.requested(), "destination layout must restore its owned geometry") | |
| 800 | + | p.mu.Lock() | |
| 801 | + | require.False(t, p.resizeRetry) | |
| 802 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}) | |
| 803 | + | p.mu.Unlock() | |
| 804 | + | } | |
| 805 | + | ||
| 409 | 806 | func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing.T) { | |
| 410 | 807 | first, second := &transactionalResizePTY{}, &transactionalResizePTY{} | |
| 411 | 808 | d, sess, ac, _ := newManualSessionWithPTYs(t, first, second) | |
| @@ -505,6 +902,45 @@ func TestHeadlessResizeRetriesInvalidatedPlan(t *testing.T) { | |||
| 505 | 902 | tb.mu.Unlock() | |
| 506 | 903 | } | |
| 507 | 904 | ||
| 905 | + | func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) { | |
| 906 | + | popupPTY := &transactionalResizePTY{errs: []error{errors.New("initial popup failure")}} | |
| 907 | + | d, source, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 908 | + | tb := source.activeTab() | |
| 909 | + | popup := newPane("popup", popupPTY, domain.Size{Cols: 80, Rows: 23}) | |
| 910 | + | tb.mu.Lock() | |
| 911 | + | tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7} | |
| 912 | + | tb.mu.Unlock() | |
| 913 | + | owner := publishPaneOwner(popup, source, tb, 7) | |
| 914 | + | ||
| 915 | + | rc := d.attachCoordinator(source, nil, ac, true) | |
| 916 | + | lease := rc.attachmentLease(ac) | |
| 917 | + | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease) | |
| 918 | + | require.True(t, d.runResizeTransaction(source, ac, lease, epoch)) | |
| 919 | + | ||
| 920 | + | destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}} | |
| 921 | + | publishPaneOwner(popup, destination, tb, 7) | |
| 922 | + | destinationRect := domain.Rect{X: 9, Y: 4, Width: 31, Height: 9} | |
| 923 | + | popup.mu.Lock() | |
| 924 | + | popup.rect = destinationRect | |
| 925 | + | popup.popupGeometry = floatingGeometry{Inner: destinationRect} | |
| 926 | + | popup.mu.Unlock() | |
| 927 | + | source.snapshotMu.Lock() | |
| 928 | + | sourceGeneration := source.snapshotGeneration | |
| 929 | + | source.snapshotMu.Unlock() | |
| 930 | + | requested := popupPTY.requested() | |
| 931 | + | ||
| 932 | + | d.retryResizeMembers(source, ac, lease, epoch, []resizeMember{{session: source, tab: tb, pane: popup, owner: owner, isFloating: true, floatingGeneration: 7}}) | |
| 933 | + | ||
| 934 | + | require.Equal(t, requested, popupPTY.requested(), "stale floating retry reached the moved PTY") | |
| 935 | + | popup.mu.Lock() | |
| 936 | + | require.Equal(t, destinationRect, popup.rect, "stale floating retry published source geometry") | |
| 937 | + | require.Equal(t, destinationRect, popup.popupGeometry.Inner, "stale floating retry published source popup geometry") | |
| 938 | + | popup.mu.Unlock() | |
| 939 | + | source.snapshotMu.Lock() | |
| 940 | + | require.Equal(t, sourceGeneration, source.snapshotGeneration, "stale floating retry dirtied its former session") | |
| 941 | + | source.snapshotMu.Unlock() | |
| 942 | + | } | |
| 943 | + | ||
| 508 | 944 | func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) { | |
| 509 | 945 | popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}} | |
| 510 | 946 | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "io" | 5 | "io" | ||
| 6 | "strings" | 6 | "strings" | ||
| 7 | "sync" | 7 | "sync" | ||
| 8 | + | "sync/atomic" | |||
| 8 | "testing" | 9 | "testing" | ||
| 9 | "time" | 10 | "time" | ||
| 10 | 11 | ||||
| @@ -271,10 +272,10 @@ func TestApplySessionLayoutReportsResizeFailuresOnce(t *testing.T) { | |||||
| 271 | } | 272 | } | ||
| 272 | d, sess, _, _ := newManualSessionWithPTYs(t, first, second) | 273 | d, sess, _, _ := newManualSessionWithPTYs(t, first, second) | ||
| 273 | 274 | ||||
| 274 | - | failed, ok := d.applySessionLayout(sess, domain.Size{Cols: 100, Rows: 30}, nil, nil) | 275 | + | result, ok := d.applySessionLayout(sess, domain.Size{Cols: 100, Rows: 30}, nil, nil) |
| 275 | 276 | ||||
| 276 | require.Equal(t, tc.wantOK, ok) | 277 | require.Equal(t, tc.wantOK, ok) | ||
| 277 | - | require.Len(t, failed, tc.wantFailed) | 278 | + | require.Len(t, result.failed, tc.wantFailed) |
| 278 | count := 0 | 279 | count := 0 | ||
| 279 | for _, notification := range d.notices.history() { | 280 | for _, notification := range d.notices.history() { | ||
| 280 | if notification.Code == domain.NoticeResizeFailed { | 281 | if notification.Code == domain.NoticeResizeFailed { | ||
| @@ -406,6 +407,402 @@ func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) { | |||||
| 406 | // stopped. Only the newest prepared epoch may apply, commit, and emit one full | 407 | // stopped. Only the newest prepared epoch may apply, commit, and emit one full | ||
| 407 | // frame. coordinatorMockClock is a generated MockClock/MockTimer harness; its | 408 | // frame. coordinatorMockClock is a generated MockClock/MockTimer harness; its | ||
| 408 | // channels make callback order explicit and do not use wall-clock waits. | 409 | // channels make callback order explicit and do not use wall-clock waits. | ||
| 410 | + | func publishResizeOwnerMoveUnderFence(t *testing.T, source, destination *session, tb *tab, p *pane) { | |||
| 411 | + | t.Helper() | |||
| 412 | + | fences := newMovePaneResizeFences(source, destination, tb, tb, p) | |||
| 413 | + | require.True(t, fences.acquire(func() bool { | |||
| 414 | + | p.mu.Lock() | |||
| 415 | + | p.publishOwnerLocked(destination, tb, 0) | |||
| 416 | + | p.mu.Unlock() | |||
| 417 | + | return true | |||
| 418 | + | })) | |||
| 419 | + | fences.Release() | |||
| 420 | + | } | |||
| 421 | + | ||||
| 422 | + | func pauseResizeOwnerPostEffect(d *Daemon, effect resizeOwnerPostEffect) (<-chan struct{}, func()) { | |||
| 423 | + | entered := make(chan struct{}) | |||
| 424 | + | release := make(chan struct{}) | |||
| 425 | + | var once sync.Once | |||
| 426 | + | d.beforeResizeOwnerPostEffect = func(got resizeOwnerPostEffect) { | |||
| 427 | + | if got != effect { | |||
| 428 | + | return | |||
| 429 | + | } | |||
| 430 | + | once.Do(func() { close(entered) }) | |||
| 431 | + | <-release | |||
| 432 | + | } | |||
| 433 | + | return entered, func() { close(release) } | |||
| 434 | + | } | |||
| 435 | + | ||||
| 436 | + | func TestResizeOwnerMoveImmediatelyBeforeSnapshotDirtyDropsStaleSource(t *testing.T) { | |||
| 437 | + | d, source, _, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |||
| 438 | + | source.snapEligible.Store(true) | |||
| 439 | + | tb := source.activeTab() | |||
| 440 | + | p := tb.focusedPane() | |||
| 441 | + | tb.mu.Lock() | |||
| 442 | + | tb.size = domain.Size{Cols: 100, Rows: 20} | |||
| 443 | + | tb.bumpLayoutGenerationLocked() | |||
| 444 | + | tb.mu.Unlock() | |||
| 445 | + | destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}} | |||
| 446 | + | entered, release := pauseResizeOwnerPostEffect(d, resizeOwnerPostSnapshotDirty) | |||
| 447 | + | ||||
| 448 | + | result := make(chan bool, 1) | |||
| 449 | + | go func() { result <- d.applyTabLayout(source, tb) }() | |||
| 450 | + | awaitTestCompletion(t, entered, "resize did not pause before snapshot dirtiness") | |||
| 451 | + | publishResizeOwnerMoveUnderFence(t, source, destination, tb, p) | |||
| 452 | + | release() | |||
| 453 | + | ||||
| 454 | + | require.False(t, awaitTestValue(t, result, "stale source layout did not return")) | |||
| 455 | + | require.False(t, source.snapDirty.Load(), "stale source dirtied its snapshot after the move won the fence") | |||
| 456 | + | } | |||
| 457 | + | ||||
| 458 | + | func TestRetryOwnerMoveImmediatelyBeforeScheduleContinuesAtDestination(t *testing.T) { | |||
| 459 | + | resizeErr := errors.New("scripted resize failure") | |||
| 460 | + | pty := &transactionalResizePTY{errs: []error{resizeErr, resizeErr}} | |||
| 461 | + | d, source, _, _ := newManualSessionWithPTYs(t, pty) | |||
| 462 | + | clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 8)} | |||
| 463 | + | d.clock = clock | |||
| 464 | + | tb := source.activeTab() | |||
| 465 | + | p := tb.focusedPane() | |||
| 466 | + | tb.mu.Lock() | |||
| 467 | + | tb.size = domain.Size{Cols: 100, Rows: 20} | |||
| 468 | + | tb.bumpLayoutGenerationLocked() | |||
| 469 | + | tb.mu.Unlock() | |||
| 470 | + | destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}} | |||
| 471 | + | entered, release := pauseResizeOwnerPostEffect(d, resizeOwnerPostRetrySchedule) | |||
| 472 | + | ||||
| 473 | + | result := make(chan bool, 1) | |||
| 474 | + | go func() { result <- d.applyTabLayout(source, tb) }() | |||
| 475 | + | awaitTestCompletion(t, entered, "resize did not pause before retry scheduling") | |||
| 476 | + | publishResizeOwnerMoveUnderFence(t, source, destination, tb, p) | |||
| 477 | + | release() | |||
| 478 | + | ||||
| 479 | + | require.False(t, awaitTestValue(t, result, "stale source retry publication did not return")) | |||
| 480 | + | tb.layoutRetryMu.Lock() | |||
| 481 | + | require.False(t, tb.layoutRetryRunning, "stale source published a retry worker") | |||
| 482 | + | tb.layoutRetryMu.Unlock() | |||
| 483 | + | for { | |||
| 484 | + | select { | |||
| 485 | + | case timer := <-clock.timers: | |||
| 486 | + | require.NotEqual(t, minOutputRenderDeadline, timer.delay, "stale source armed a retry timer") | |||
| 487 | + | default: | |||
| 488 | + | goto timersDrained | |||
| 489 | + | } | |||
| 490 | + | } | |||
| 491 | + | ||||
| 492 | + | timersDrained: | |||
| 493 | + | d.beforeResizeOwnerPostEffect = nil | |||
| 494 | + | require.True(t, d.applyTabLayout(destination, tb), "destination must recreate retry from pane metadata") | |||
| 495 | + | for { | |||
| 496 | + | timer := awaitTestValue(t, clock.timers, "destination did not continue the moved retry") | |||
| 497 | + | if timer.delay == minOutputRenderDeadline { | |||
| 498 | + | break | |||
| 499 | + | } | |||
| 500 | + | } | |||
| 501 | + | } | |||
| 502 | + | ||||
| 503 | + | type resizeFenceCheckingClock struct { | |||
| 504 | + | locks []*sync.Mutex | |||
| 505 | + | calls atomic.Int32 | |||
| 506 | + | locksFree atomic.Bool | |||
| 507 | + | } | |||
| 508 | + | ||||
| 509 | + | func (c *resizeFenceCheckingClock) check() { | |||
| 510 | + | c.calls.Add(1) | |||
| 511 | + | locked := make([]*sync.Mutex, 0, len(c.locks)) | |||
| 512 | + | for _, lock := range c.locks { | |||
| 513 | + | if !lock.TryLock() { | |||
| 514 | + | for i := len(locked) - 1; i >= 0; i-- { | |||
| 515 | + | locked[i].Unlock() | |||
| 516 | + | } | |||
| 517 | + | c.locksFree.Store(false) | |||
| 518 | + | return | |||
| 519 | + | } | |||
| 520 | + | locked = append(locked, lock) | |||
| 521 | + | } | |||
| 522 | + | for i := len(locked) - 1; i >= 0; i-- { | |||
| 523 | + | locked[i].Unlock() | |||
| 524 | + | } | |||
| 525 | + | } | |||
| 526 | + | ||||
| 527 | + | func (c *resizeFenceCheckingClock) Now() time.Time { | |||
| 528 | + | c.check() | |||
| 529 | + | return time.Time{} | |||
| 530 | + | } | |||
| 531 | + | ||||
| 532 | + | func (c *resizeFenceCheckingClock) NewTimer(time.Duration) ports.Timer { | |||
| 533 | + | c.check() | |||
| 534 | + | return stubTimer{} | |||
| 535 | + | } | |||
| 536 | + | ||||
| 537 | + | type blockingResizeCommitObserver struct { | |||
| 538 | + | entered chan struct{} | |||
| 539 | + | release chan struct{} | |||
| 540 | + | once sync.Once | |||
| 541 | + | } | |||
| 542 | + | ||||
| 543 | + | func (o *blockingResizeCommitObserver) ObserveRuntime(mark ports.RuntimeMark) { | |||
| 544 | + | if mark.Kind != ports.RuntimeResizeCommitted { | |||
| 545 | + | return | |||
| 546 | + | } | |||
| 547 | + | o.once.Do(func() { close(o.entered) }) | |||
| 548 | + | <-o.release | |||
| 549 | + | } | |||
| 550 | + | ||||
| 551 | + | func TestResizeCommitObserverBlocksWithoutHoldingOwnerOrAttachmentFences(t *testing.T) { | |||
| 552 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |||
| 553 | + | observer := &blockingResizeCommitObserver{entered: make(chan struct{}), release: make(chan struct{})} | |||
| 554 | + | d.runtimeObserver = observer | |||
| 555 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |||
| 556 | + | lease := rc.attachmentLease(ac) | |||
| 557 | + | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease) | |||
| 558 | + | tb := sess.activeTab() | |||
| 559 | + | p := tb.focusedPane() | |||
| 560 | + | ||||
| 561 | + | result := make(chan bool, 1) | |||
| 562 | + | go func() { result <- d.runResizeTransaction(sess, ac, lease, epoch) }() | |||
| 563 | + | awaitTestCompletion(t, observer.entered, "resize commit observer did not block") | |||
| 564 | + | for _, lock := range []struct { | |||
| 565 | + | name string | |||
| 566 | + | mu *sync.Mutex | |||
| 567 | + | }{ | |||
| 568 | + | {name: "attachment send", mu: &ac.sendMu}, | |||
| 569 | + | {name: "session resize", mu: &sess.layoutApplyMu}, | |||
| 570 | + | {name: "tab resize", mu: &tb.layoutApplyMu}, | |||
| 571 | + | {name: "pane resize", mu: &p.resizeMu}, | |||
| 572 | + | } { | |||
| 573 | + | require.True(t, lock.mu.TryLock(), "%s fence remained held across observer callback", lock.name) | |||
| 574 | + | lock.mu.Unlock() | |||
| 575 | + | } | |||
| 576 | + | close(observer.release) | |||
| 577 | + | require.True(t, awaitTestValue(t, result, "resize did not finish after observer release")) | |||
| 578 | + | } | |||
| 579 | + | ||||
| 580 | + | func TestResizeOwnerPostEffectsReleaseFencesBeforeTimerAndRenderCallbacks(t *testing.T) { | |||
| 581 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |||
| 582 | + | tb := sess.activeTab() | |||
| 583 | + | p := tb.focusedPane() | |||
| 584 | + | clock := &resizeFenceCheckingClock{locks: []*sync.Mutex{&sess.layoutApplyMu, &tb.layoutApplyMu, &p.resizeMu}} | |||
| 585 | + | clock.locksFree.Store(true) | |||
| 586 | + | d.clock = clock | |||
| 587 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |||
| 588 | + | callbackLocksFree := atomic.Bool{} | |||
| 589 | + | callbackLocksFree.Store(true) | |||
| 590 | + | rc.opts.onInvalidate = func(renderInvalidation) { | |||
| 591 | + | clock.check() | |||
| 592 | + | callbackLocksFree.Store(clock.locksFree.Load()) | |||
| 593 | + | } | |||
| 594 | + | lease := rc.attachmentLease(ac) | |||
| 595 | + | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease) | |||
| 596 | + | ||||
| 597 | + | require.True(t, d.runResizeTransaction(sess, ac, lease, epoch)) | |||
| 598 | + | require.Positive(t, clock.calls.Load(), "clock boundary was not exercised") | |||
| 599 | + | require.True(t, clock.locksFree.Load(), "timer or clock work ran under a resize ownership fence") | |||
| 600 | + | require.True(t, callbackLocksFree.Load(), "render callback ran under a resize ownership fence") | |||
| 601 | + | } | |||
| 602 | + | ||||
| 603 | + | func TestResizeOwnerMoveBeforeClientGeometryAndCommitTelemetryDropsStaleSource(t *testing.T) { | |||
| 604 | + | first := &transactionalResizePTY{} | |||
| 605 | + | second := &transactionalResizePTY{} | |||
| 606 | + | d, source, ac, _ := newManualSessionWithPTYs(t, first, second) | |||
| 607 | + | observer := &daemonRuntimeObserver{} | |||
| 608 | + | d.runtimeObserver = observer | |||
| 609 | + | rc := d.attachCoordinator(source, nil, ac, true) | |||
| 610 | + | lease := rc.attachmentLease(ac) | |||
| 611 | + | target := domain.Size{Cols: 100, Rows: 30} | |||
| 612 | + | epoch := rc.recordResizeRequestForLease(target, ac, lease) | |||
| 613 | + | require.NotZero(t, epoch) | |||
| 614 | + | movedTab := source.tabs[1] | |||
| 615 | + | movedPane := movedTab.focusedPane() | |||
| 616 | + | destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{movedTab}} | |||
| 617 | + | ||||
| 618 | + | entered, release := pauseResizeOwnerPostEffect(d, resizeOwnerPostCommitPublication) | |||
| 619 | + | result := make(chan bool, 1) | |||
| 620 | + | go func() { result <- d.runResizeTransaction(source, ac, lease, epoch) }() | |||
| 621 | + | select { | |||
| 622 | + | case <-entered: | |||
| 623 | + | publishResizeOwnerMoveUnderFence(t, source, destination, movedTab, movedPane) | |||
| 624 | + | release() | |||
| 625 | + | case accepted := <-result: | |||
| 626 | + | ac.sendMu.Lock() | |||
| 627 | + | publishedSize := ac.size | |||
| 628 | + | ac.sendMu.Unlock() | |||
| 629 | + | t.Fatalf("resize completed before geometry/telemetry owner fence: accepted=%v size=%+v marks=%+v", accepted, publishedSize, observer.marks) | |||
| 630 | + | case <-time.After(time.Second): | |||
| 631 | + | t.Fatal("resize neither reached geometry/telemetry publication nor completed") | |||
| 632 | + | } | |||
| 633 | + | ||||
| 634 | + | require.False(t, awaitTestValue(t, result, "stale source geometry publication did not return")) | |||
| 635 | + | ac.sendMu.Lock() | |||
| 636 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 24}, ac.size, "stale source published attachment geometry after ownership commit") | |||
| 637 | + | ac.sendMu.Unlock() | |||
| 638 | + | for _, mark := range observer.marks { | |||
| 639 | + | require.NotEqual(t, ports.RuntimeResizeCommitted, mark.Kind, "stale source published resize-commit telemetry after ownership commit") | |||
| 640 | + | } | |||
| 641 | + | ||||
| 642 | + | // The destination's finalizer is the only accepted geometry/telemetry owner. | |||
| 643 | + | d.beforeResizeOwnerPostEffect = nil | |||
| 644 | + | destinationSize := domain.Size{Cols: 90, Rows: 28} | |||
| 645 | + | require.True(t, d.requestTransactionalResizeForLease(destination, nil, nil, destinationSize, true)) | |||
| 646 | + | movedPane.mu.Lock() | |||
| 647 | + | require.Equal(t, domain.Size{Cols: 90, Rows: 26}, domain.Size{Cols: movedPane.screen.Frame.Width, Rows: movedPane.screen.Frame.Height}) | |||
| 648 | + | movedPane.mu.Unlock() | |||
| 649 | + | commits := 0 | |||
| 650 | + | for _, mark := range observer.marks { | |||
| 651 | + | if mark.Kind == ports.RuntimeResizeCommitted { | |||
| 652 | + | commits++ | |||
| 653 | + | } | |||
| 654 | + | } | |||
| 655 | + | require.Equal(t, 1, commits, "only the destination finalizer may publish resize-commit telemetry") | |||
| 656 | + | } | |||
| 657 | + | ||||
| 658 | + | func TestSameOwnerResizePublishesClientGeometryAndCommitTelemetryOnce(t *testing.T) { | |||
| 659 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}, &transactionalResizePTY{}) | |||
| 660 | + | observer := &daemonRuntimeObserver{} | |||
| 661 | + | d.runtimeObserver = observer | |||
| 662 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |||
| 663 | + | lease := rc.attachmentLease(ac) | |||
| 664 | + | target := domain.Size{Cols: 100, Rows: 30} | |||
| 665 | + | epoch := rc.recordResizeRequestForLease(target, ac, lease) | |||
| 666 | + | ||||
| 667 | + | require.True(t, d.runResizeTransaction(sess, ac, lease, epoch)) | |||
| 668 | + | ac.sendMu.Lock() | |||
| 669 | + | require.Equal(t, target, ac.size) | |||
| 670 | + | ac.sendMu.Unlock() | |||
| 671 | + | commits := 0 | |||
| 672 | + | for _, mark := range observer.marks { | |||
| 673 | + | if mark.Kind == ports.RuntimeResizeCommitted { | |||
| 674 | + | commits++ | |||
| 675 | + | } | |||
| 676 | + | } | |||
| 677 | + | require.Equal(t, 1, commits) | |||
| 678 | + | } | |||
| 679 | + | ||||
| 680 | + | func TestResizeOwnerMoveImmediatelyBeforeRenderInvalidationDropsStaleSource(t *testing.T) { | |||
| 681 | + | d, source, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |||
| 682 | + | rc := d.attachCoordinator(source, nil, ac, true) | |||
| 683 | + | lease := rc.attachmentLease(ac) | |||
| 684 | + | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease) | |||
| 685 | + | var invalidations atomic.Int32 | |||
| 686 | + | rc.opts.onInvalidate = func(renderInvalidation) { invalidations.Add(1) } | |||
| 687 | + | tb := source.activeTab() | |||
| 688 | + | p := tb.focusedPane() | |||
| 689 | + | destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}} | |||
| 690 | + | entered, release := pauseResizeOwnerPostEffect(d, resizeOwnerPostRenderInvalidation) | |||
| 691 | + | ||||
| 692 | + | result := make(chan bool, 1) | |||
| 693 | + | go func() { result <- d.runResizeTransaction(source, ac, lease, epoch) }() | |||
| 694 | + | awaitTestCompletion(t, entered, "resize did not pause before render invalidation") | |||
| 695 | + | publishResizeOwnerMoveUnderFence(t, source, destination, tb, p) | |||
| 696 | + | release() | |||
| 697 | + | ||||
| 698 | + | require.False(t, awaitTestValue(t, result, "stale source invalidation did not return")) | |||
| 699 | + | require.Zero(t, invalidations.Load(), "stale source invalidated its renderer after the move won the fence") | |||
| 700 | + | } | |||
| 701 | + | ||||
| 702 | + | func TestRetryOwnerAcceptedTabStopsWhenFailedPaneMoves(t *testing.T) { | |||
| 703 | + | resizeErr := errors.New("scripted resize failure") | |||
| 704 | + | pty := &transactionalResizePTY{errs: []error{resizeErr}} | |||
| 705 | + | d, source, _, _ := newManualSessionWithPTYs(t, pty) | |||
| 706 | + | clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 8)} | |||
| 707 | + | d.clock = clock | |||
| 708 | + | tb := source.activeTab() | |||
| 709 | + | p := tb.focusedPane() | |||
| 710 | + | tb.mu.Lock() | |||
| 711 | + | tb.size = domain.Size{Cols: 100, Rows: 20} | |||
| 712 | + | tb.bumpLayoutGenerationLocked() | |||
| 713 | + | tb.mu.Unlock() | |||
| 714 | + | ||||
| 715 | + | require.True(t, d.applyTabLayout(source, tb)) | |||
| 716 | + | var timer *layoutRetryTimer | |||
| 717 | + | for timer == nil { | |||
| 718 | + | candidate := awaitTestValue(t, clock.timers, "accepted failure did not schedule its retry") | |||
| 719 | + | if candidate.delay == minOutputRenderDeadline { | |||
| 720 | + | timer = candidate | |||
| 721 | + | } | |||
| 722 | + | } | |||
| 723 | + | source.snapshotMu.Lock() | |||
| 724 | + | sourceGeneration := source.snapshotGeneration | |||
| 725 | + | source.snapshotMu.Unlock() | |||
| 726 | + | notices := len(d.notices.history()) | |||
| 727 | + | ||||
| 728 | + | destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}} | |||
| 729 | + | publishPaneOwner(p, destination, tb, 0) | |||
| 730 | + | timer.fire() | |||
| 731 | + | ||||
| 732 | + | require.Eventually(t, func() bool { | |||
| 733 | + | tb.layoutRetryMu.Lock() | |||
| 734 | + | defer tb.layoutRetryMu.Unlock() | |||
| 735 | + | return !tb.layoutRetryRunning | |||
| 736 | + | }, time.Second, time.Millisecond, "stale source retry did not retire") | |||
| 737 | + | require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}}, pty.requested(), "stale source retry reached the moved pane PTY") | |||
| 738 | + | source.snapshotMu.Lock() | |||
| 739 | + | require.Equal(t, sourceGeneration, source.snapshotGeneration, "stale source retry dirtied its former session") | |||
| 740 | + | source.snapshotMu.Unlock() | |||
| 741 | + | require.Len(t, d.notices.history(), notices, "stale source retry published a notice") | |||
| 742 | + | } | |||
| 743 | + | ||||
| 744 | + | func TestTransactionalResizeOwnerMoveAfterExternalApplyPublishesOnlyDestinationGeometry(t *testing.T) { | |||
| 745 | + | entered := make(chan struct{}) | |||
| 746 | + | release := make(chan struct{}) | |||
| 747 | + | pty := &transactionalResizePTY{} | |||
| 748 | + | pty.onResize = func() { | |||
| 749 | + | if len(pty.requested()) != 1 { | |||
| 750 | + | return | |||
| 751 | + | } | |||
| 752 | + | close(entered) | |||
| 753 | + | <-release | |||
| 754 | + | } | |||
| 755 | + | d, source, ac, _ := newManualSessionWithPTYs(t, pty) | |||
| 756 | + | observer := &daemonRuntimeObserver{} | |||
| 757 | + | d.runtimeObserver = observer | |||
| 758 | + | rc := d.attachCoordinator(source, nil, ac, true) | |||
| 759 | + | var invalidations atomic.Int32 | |||
| 760 | + | rc.opts.onInvalidate = func(renderInvalidation) { invalidations.Add(1) } | |||
| 761 | + | lease := rc.attachmentLease(ac) | |||
| 762 | + | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease) | |||
| 763 | + | require.NotZero(t, epoch) | |||
| 764 | + | tb := source.activeTab() | |||
| 765 | + | p := tb.focusedPane() | |||
| 766 | + | destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}} | |||
| 767 | + | ||||
| 768 | + | result := make(chan bool, 1) | |||
| 769 | + | go func() { | |||
| 770 | + | result <- d.runResizeTransaction(source, ac, lease, epoch) | |||
| 771 | + | }() | |||
| 772 | + | awaitTestCompletion(t, entered, "source resize did not reach the external PTY") | |||
| 773 | + | ||||
| 774 | + | source.mu.Lock() | |||
| 775 | + | source.tabs = nil | |||
| 776 | + | source.active = -1 | |||
| 777 | + | source.mu.Unlock() | |||
| 778 | + | publishPaneOwner(p, destination, tb, 0) | |||
| 779 | + | close(release) | |||
| 780 | + | ||||
| 781 | + | require.False(t, awaitTestValue(t, result, "source resize did not finish after the owner move")) | |||
| 782 | + | require.False(t, source.snapDirty.Load(), "retired source owner dirtied its snapshot") | |||
| 783 | + | require.Zero(t, invalidations.Load(), "retired source owner invalidated its renderer") | |||
| 784 | + | require.Empty(t, d.notices.history(), "retired source owner published a resize notice") | |||
| 785 | + | for _, mark := range observer.marks { | |||
| 786 | + | require.NotEqual(t, ports.RuntimeResizeCommitted, mark.Kind, "retired source owner published resize telemetry") | |||
| 787 | + | } | |||
| 788 | + | ac.sendMu.Lock() | |||
| 789 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 24}, ac.size, "retired source owner published client geometry") | |||
| 790 | + | ac.sendMu.Unlock() | |||
| 791 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size, "retired source owner published the moved tab size") | |||
| 792 | + | p.mu.Lock() | |||
| 793 | + | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "retired source owner published pane geometry") | |||
| 794 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "retired source owner published VT geometry") | |||
| 795 | + | require.True(t, p.resizeRetry, "the destination owner needs metadata for the externally applied stale size") | |||
| 796 | + | p.mu.Unlock() | |||
| 797 | + | ||||
| 798 | + | require.True(t, d.applyTabLayout(destination, tb)) | |||
| 799 | + | require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 80, Rows: 23}}, pty.requested(), "destination layout must restore its owned geometry") | |||
| 800 | + | p.mu.Lock() | |||
| 801 | + | require.False(t, p.resizeRetry) | |||
| 802 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}) | |||
| 803 | + | p.mu.Unlock() | |||
| 804 | + | } | |||
| 805 | + | ||||
| 409 | func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing.T) { | 806 | func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing.T) { | ||
| 410 | first, second := &transactionalResizePTY{}, &transactionalResizePTY{} | 807 | first, second := &transactionalResizePTY{}, &transactionalResizePTY{} | ||
| 411 | d, sess, ac, _ := newManualSessionWithPTYs(t, first, second) | 808 | d, sess, ac, _ := newManualSessionWithPTYs(t, first, second) | ||
| @@ -505,6 +902,45 @@ func TestHeadlessResizeRetriesInvalidatedPlan(t *testing.T) { | |||||
| 505 | tb.mu.Unlock() | 902 | tb.mu.Unlock() | ||
| 506 | } | 903 | } | ||
| 507 | 904 | ||||
| 905 | + | func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) { | |||
| 906 | + | popupPTY := &transactionalResizePTY{errs: []error{errors.New("initial popup failure")}} | |||
| 907 | + | d, source, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |||
| 908 | + | tb := source.activeTab() | |||
| 909 | + | popup := newPane("popup", popupPTY, domain.Size{Cols: 80, Rows: 23}) | |||
| 910 | + | tb.mu.Lock() | |||
| 911 | + | tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7} | |||
| 912 | + | tb.mu.Unlock() | |||
| 913 | + | owner := publishPaneOwner(popup, source, tb, 7) | |||
| 914 | + | ||||
| 915 | + | rc := d.attachCoordinator(source, nil, ac, true) | |||
| 916 | + | lease := rc.attachmentLease(ac) | |||
| 917 | + | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease) | |||
| 918 | + | require.True(t, d.runResizeTransaction(source, ac, lease, epoch)) | |||
| 919 | + | ||||
| 920 | + | destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}} | |||
| 921 | + | publishPaneOwner(popup, destination, tb, 7) | |||
| 922 | + | destinationRect := domain.Rect{X: 9, Y: 4, Width: 31, Height: 9} | |||
| 923 | + | popup.mu.Lock() | |||
| 924 | + | popup.rect = destinationRect | |||
| 925 | + | popup.popupGeometry = floatingGeometry{Inner: destinationRect} | |||
| 926 | + | popup.mu.Unlock() | |||
| 927 | + | source.snapshotMu.Lock() | |||
| 928 | + | sourceGeneration := source.snapshotGeneration | |||
| 929 | + | source.snapshotMu.Unlock() | |||
| 930 | + | requested := popupPTY.requested() | |||
| 931 | + | ||||
| 932 | + | d.retryResizeMembers(source, ac, lease, epoch, []resizeMember{{session: source, tab: tb, pane: popup, owner: owner, isFloating: true, floatingGeneration: 7}}) | |||
| 933 | + | ||||
| 934 | + | require.Equal(t, requested, popupPTY.requested(), "stale floating retry reached the moved PTY") | |||
| 935 | + | popup.mu.Lock() | |||
| 936 | + | require.Equal(t, destinationRect, popup.rect, "stale floating retry published source geometry") | |||
| 937 | + | require.Equal(t, destinationRect, popup.popupGeometry.Inner, "stale floating retry published source popup geometry") | |||
| 938 | + | popup.mu.Unlock() | |||
| 939 | + | source.snapshotMu.Lock() | |||
| 940 | + | require.Equal(t, sourceGeneration, source.snapshotGeneration, "stale floating retry dirtied its former session") | |||
| 941 | + | source.snapshotMu.Unlock() | |||
| 942 | + | } | |||
| 943 | + | ||||
| 508 | func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) { | 944 | func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) { | ||
| 509 | popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}} | 945 | popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}} | ||
| 510 | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | 946 | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | ||