refactor(daemon): serialize stale-safe pane layout application
- commit
- 6400ab451346a54137a4b4ee84b6eb2685a249b3
- author
- brice · · 2026-07-25 03:41 UTC
- committer
- brice · · 2026-07-25 04:08 UTC
- parent
- babcd3a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +570 -128
M internal/usecase/daemon/copy_mouse_input.go +5 -2
| @@ -268,6 +268,7 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta | |||
| 268 | 268 | tb.mu.Lock() | |
| 269 | 269 | geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, snapshot.pane) | |
| 270 | 270 | otherPane := false | |
| 271 | + | layoutChanged := false | |
| 271 | 272 | if ev.Type == mouse.Press { | |
| 272 | 273 | // A new press is allowed to change focus, but never the document owned | |
| 273 | 274 | // by the active copy mode. Do all tab work first; exitCopyMode takes | |
| @@ -275,12 +276,14 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta | |||
| 275 | 276 | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane { | |
| 276 | 277 | otherPane = true | |
| 277 | 278 | if tb.tree != nil { | |
| 278 | - | focusPlacementLocked(tb, hit.pane.id) | |
| 279 | - | d.applyLayoutLocked(tb) | |
| 279 | + | layoutChanged = focusPlacementLocked(tb, hit.pane.id) | |
| 280 | 280 | } | |
| 281 | 281 | } | |
| 282 | 282 | } | |
| 283 | 283 | tb.mu.Unlock() | |
| 284 | + | if layoutChanged { | |
| 285 | + | d.applyTabLayout(sess, tb) | |
| 286 | + | } | |
| 284 | 287 | if otherPane { | |
| 285 | 288 | d.exitCopyMode(ac) | |
| 286 | 289 | return | |
| @@ -268,6 +268,7 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta | |||||
| 268 | tb.mu.Lock() | 268 | tb.mu.Lock() | ||
| 269 | geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, snapshot.pane) | 269 | geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, snapshot.pane) | ||
| 270 | otherPane := false | 270 | otherPane := false | ||
| 271 | + | layoutChanged := false | |||
| 271 | if ev.Type == mouse.Press { | 272 | if ev.Type == mouse.Press { | ||
| 272 | // A new press is allowed to change focus, but never the document owned | 273 | // A new press is allowed to change focus, but never the document owned | ||
| 273 | // by the active copy mode. Do all tab work first; exitCopyMode takes | 274 | // by the active copy mode. Do all tab work first; exitCopyMode takes | ||
| @@ -275,12 +276,14 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta | |||||
| 275 | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane { | 276 | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane { | ||
| 276 | otherPane = true | 277 | otherPane = true | ||
| 277 | if tb.tree != nil { | 278 | if tb.tree != nil { | ||
| 278 | - | focusPlacementLocked(tb, hit.pane.id) | 279 | + | layoutChanged = focusPlacementLocked(tb, hit.pane.id) |
| 279 | - | d.applyLayoutLocked(tb) | |||
| 280 | } | 280 | } | ||
| 281 | } | 281 | } | ||
| 282 | } | 282 | } | ||
| 283 | tb.mu.Unlock() | 283 | tb.mu.Unlock() | ||
| 284 | + | if layoutChanged { | |||
| 285 | + | d.applyTabLayout(sess, tb) | |||
| 286 | + | } | |||
| 284 | if otherPane { | 287 | if otherPane { | ||
| 285 | d.exitCopyMode(ac) | 288 | d.exitCopyMode(ac) | ||
| 286 | return | 289 | return | ||
M internal/usecase/daemon/input.go +9 -4
| @@ -89,9 +89,11 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 89 | 89 | return | |
| 90 | 90 | } | |
| 91 | 91 | oldFocus := focusedID | |
| 92 | - | focusPlacementLocked(tb, pl.ID) | |
| 93 | - | d.applyLayoutLocked(tb) | |
| 92 | + | layoutChanged := focusPlacementLocked(tb, pl.ID) | |
| 94 | 93 | tb.mu.Unlock() | |
| 94 | + | if layoutChanged { | |
| 95 | + | d.applyTabLayout(sess, tb) | |
| 96 | + | } | |
| 95 | 97 | // A title bar never routes to terminal content. Clear any pre-existing | |
| 96 | 98 | // left-button candidate before handling the focus result, including when | |
| 97 | 99 | // this press leaves the same pane focused. | |
| @@ -108,13 +110,16 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 108 | 110 | hoveredFocused := true | |
| 109 | 111 | if hit && !pl.Collapsed && pointInRect(ev.Col, contentRow, pl.Content) { | |
| 110 | 112 | oldFocus := focusedID | |
| 113 | + | layoutChanged := false | |
| 111 | 114 | if isMouseFocusPress(ev) { | |
| 112 | - | focusPlacementLocked(tb, pl.ID) | |
| 113 | - | d.applyLayoutLocked(tb) | |
| 115 | + | layoutChanged = focusPlacementLocked(tb, pl.ID) | |
| 114 | 116 | } | |
| 115 | 117 | p = tb.panes[pl.ID] | |
| 116 | 118 | hoveredFocused = pl.ID == oldFocus | |
| 117 | 119 | tb.mu.Unlock() | |
| 120 | + | if layoutChanged { | |
| 121 | + | d.applyTabLayout(sess, tb) | |
| 122 | + | } | |
| 118 | 123 | if p == nil { | |
| 119 | 124 | invalidateRejectedLeftPointer(rt, ev) | |
| 120 | 125 | return | |
| @@ -89,9 +89,11 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 89 | return | 89 | return | ||
| 90 | } | 90 | } | ||
| 91 | oldFocus := focusedID | 91 | oldFocus := focusedID | ||
| 92 | - | focusPlacementLocked(tb, pl.ID) | 92 | + | layoutChanged := focusPlacementLocked(tb, pl.ID) |
| 93 | - | d.applyLayoutLocked(tb) | |||
| 94 | tb.mu.Unlock() | 93 | tb.mu.Unlock() | ||
| 94 | + | if layoutChanged { | |||
| 95 | + | d.applyTabLayout(sess, tb) | |||
| 96 | + | } | |||
| 95 | // A title bar never routes to terminal content. Clear any pre-existing | 97 | // A title bar never routes to terminal content. Clear any pre-existing | ||
| 96 | // left-button candidate before handling the focus result, including when | 98 | // left-button candidate before handling the focus result, including when | ||
| 97 | // this press leaves the same pane focused. | 99 | // this press leaves the same pane focused. | ||
| @@ -108,13 +110,16 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 108 | hoveredFocused := true | 110 | hoveredFocused := true | ||
| 109 | if hit && !pl.Collapsed && pointInRect(ev.Col, contentRow, pl.Content) { | 111 | if hit && !pl.Collapsed && pointInRect(ev.Col, contentRow, pl.Content) { | ||
| 110 | oldFocus := focusedID | 112 | oldFocus := focusedID | ||
| 113 | + | layoutChanged := false | |||
| 111 | if isMouseFocusPress(ev) { | 114 | if isMouseFocusPress(ev) { | ||
| 112 | - | focusPlacementLocked(tb, pl.ID) | 115 | + | layoutChanged = focusPlacementLocked(tb, pl.ID) |
| 113 | - | d.applyLayoutLocked(tb) | |||
| 114 | } | 116 | } | ||
| 115 | p = tb.panes[pl.ID] | 117 | p = tb.panes[pl.ID] | ||
| 116 | hoveredFocused = pl.ID == oldFocus | 118 | hoveredFocused = pl.ID == oldFocus | ||
| 117 | tb.mu.Unlock() | 119 | tb.mu.Unlock() | ||
| 120 | + | if layoutChanged { | |||
| 121 | + | d.applyTabLayout(sess, tb) | |||
| 122 | + | } | |||
| 118 | if p == nil { | 123 | if p == nil { | ||
| 119 | invalidateRejectedLeftPointer(rt, ev) | 124 | invalidateRejectedLeftPointer(rt, ev) | ||
| 120 | return | 125 | return | ||
M internal/usecase/daemon/mouse_panes.go +9 -2
| @@ -99,12 +99,19 @@ func pointInRect(col, row int, r domain.Rect) bool { | |||
| 99 | 99 | return r.Width > 0 && r.Height > 0 && col >= r.X && col < r.X+r.Width && row >= r.Y && row < r.Y+r.Height | |
| 100 | 100 | } | |
| 101 | 101 | ||
| 102 | - | func focusPlacementLocked(tb *tab, id layout.PaneID) { | |
| 102 | + | func focusPlacementLocked(tb *tab, id layout.PaneID) bool { | |
| 103 | 103 | if tb == nil || tb.tree == nil { | |
| 104 | - | return | |
| 104 | + | return false | |
| 105 | 105 | } | |
| 106 | + | oldFocus := tb.tree.Focus | |
| 107 | + | oldLayout := layoutFingerprint(tb.tree.Root) | |
| 106 | 108 | tb.tree.Focus = id | |
| 107 | 109 | setExpandedLocked(tb.tree.Root, id) | |
| 110 | + | changed := oldFocus != tb.tree.Focus || oldLayout != layoutFingerprint(tb.tree.Root) | |
| 111 | + | if changed { | |
| 112 | + | tb.bumpLayoutGenerationLocked() | |
| 113 | + | } | |
| 114 | + | return changed | |
| 108 | 115 | } | |
| 109 | 116 | ||
| 110 | 117 | func setExpandedLocked(n *layout.Node, id layout.PaneID) bool { | |
| @@ -99,12 +99,19 @@ func pointInRect(col, row int, r domain.Rect) bool { | |||||
| 99 | return r.Width > 0 && r.Height > 0 && col >= r.X && col < r.X+r.Width && row >= r.Y && row < r.Y+r.Height | 99 | return r.Width > 0 && r.Height > 0 && col >= r.X && col < r.X+r.Width && row >= r.Y && row < r.Y+r.Height | ||
| 100 | } | 100 | } | ||
| 101 | 101 | ||||
| 102 | - | func focusPlacementLocked(tb *tab, id layout.PaneID) { | 102 | + | func focusPlacementLocked(tb *tab, id layout.PaneID) bool { |
| 103 | if tb == nil || tb.tree == nil { | 103 | if tb == nil || tb.tree == nil { | ||
| 104 | - | return | 104 | + | return false |
| 105 | } | 105 | } | ||
| 106 | + | oldFocus := tb.tree.Focus | |||
| 107 | + | oldLayout := layoutFingerprint(tb.tree.Root) | |||
| 106 | tb.tree.Focus = id | 108 | tb.tree.Focus = id | ||
| 107 | setExpandedLocked(tb.tree.Root, id) | 109 | setExpandedLocked(tb.tree.Root, id) | ||
| 110 | + | changed := oldFocus != tb.tree.Focus || oldLayout != layoutFingerprint(tb.tree.Root) | |||
| 111 | + | if changed { | |||
| 112 | + | tb.bumpLayoutGenerationLocked() | |||
| 113 | + | } | |||
| 114 | + | return changed | |||
| 108 | } | 115 | } | ||
| 109 | 116 | ||||
| 110 | func setExpandedLocked(n *layout.Node, id layout.PaneID) bool { | 117 | func setExpandedLocked(n *layout.Node, id layout.PaneID) bool { | ||
M internal/usecase/daemon/nil_pty_test.go +5 -1
| @@ -23,8 +23,12 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) { | |||
| 23 | 23 | second := newTab(nil, domain.Size{Cols: 41, Rows: 10}) | |
| 24 | 24 | sess.tabs = []*tab{first, second} | |
| 25 | 25 | ||
| 26 | + | first.mu.Lock() | |
| 27 | + | first.size = domain.Size{Cols: 20, Rows: 5} | |
| 28 | + | first.bumpLayoutGenerationLocked() | |
| 29 | + | first.mu.Unlock() | |
| 26 | 30 | require.NotPanics(t, func() { | |
| 27 | - | d.applyPaneResize(first.focusedPane(), domain.Rect{Width: 20, Height: 5}) | |
| 31 | + | d.applyTabLayout(sess, first) | |
| 28 | 32 | }) | |
| 29 | 33 | require.Equal(t, 20, first.focusedPane().screen.Frame.Width) | |
| 30 | 34 | require.Equal(t, 5, first.focusedPane().screen.Frame.Height) | |
| @@ -23,8 +23,12 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) { | |||||
| 23 | second := newTab(nil, domain.Size{Cols: 41, Rows: 10}) | 23 | second := newTab(nil, domain.Size{Cols: 41, Rows: 10}) | ||
| 24 | sess.tabs = []*tab{first, second} | 24 | sess.tabs = []*tab{first, second} | ||
| 25 | 25 | ||||
| 26 | + | first.mu.Lock() | |||
| 27 | + | first.size = domain.Size{Cols: 20, Rows: 5} | |||
| 28 | + | first.bumpLayoutGenerationLocked() | |||
| 29 | + | first.mu.Unlock() | |||
| 26 | require.NotPanics(t, func() { | 30 | require.NotPanics(t, func() { | ||
| 27 | - | d.applyPaneResize(first.focusedPane(), domain.Rect{Width: 20, Height: 5}) | 31 | + | d.applyTabLayout(sess, first) |
| 28 | }) | 32 | }) | ||
| 29 | require.Equal(t, 20, first.focusedPane().screen.Frame.Width) | 33 | require.Equal(t, 20, first.focusedPane().screen.Frame.Width) | ||
| 30 | require.Equal(t, 5, first.focusedPane().screen.Frame.Height) | 34 | require.Equal(t, 5, first.focusedPane().screen.Frame.Height) | ||
M internal/usecase/daemon/pane_actions.go +23 -70
| @@ -56,7 +56,8 @@ func (d *Daemon) spawnPaneOpAt( | |||
| 56 | 56 | } | |
| 57 | 57 | newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID)) | |
| 58 | 58 | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | |
| 59 | - | if err := mutate(tb.tree, oldFocus, newID, area); err != nil { | |
| 59 | + | candidate := tb.tree.Clone() | |
| 60 | + | if err := mutate(candidate, oldFocus, newID, area); err != nil { | |
| 60 | 61 | tb.mu.Unlock() | |
| 61 | 62 | if errors.Is(err, layout.ErrTooSmall) { | |
| 62 | 63 | return domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", err) | |
| @@ -64,33 +65,24 @@ func (d *Daemon) spawnPaneOpAt( | |||
| 64 | 65 | return err | |
| 65 | 66 | } | |
| 66 | 67 | tb.nextPaneID++ | |
| 67 | - | placements, ok := layout.Solve(tb.tree.Root, area) | |
| 68 | + | placements, ok := layout.Solve(candidate.Root, area) | |
| 68 | 69 | if !ok { | |
| 69 | - | _ = tb.tree.Close(newID) | |
| 70 | - | tb.tree.Focus = oldFocus | |
| 71 | 70 | tb.mu.Unlock() | |
| 72 | 71 | return domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", layout.ErrTooSmall) | |
| 73 | 72 | } | |
| 74 | 73 | newRect := placementContent(placements, newID) | |
| 75 | 74 | tabStableID := tb.stableID | |
| 75 | + | generation := tb.layoutGeneration | |
| 76 | 76 | tb.mu.Unlock() | |
| 77 | 77 | ||
| 78 | 78 | paneStableID, err := newStableID("p") | |
| 79 | 79 | if err != nil { | |
| 80 | - | tb.mu.Lock() | |
| 81 | - | _ = tb.tree.Close(newID) | |
| 82 | - | tb.tree.Focus = oldFocus | |
| 83 | - | tb.mu.Unlock() | |
| 84 | 80 | return fmt.Errorf("daemon: generating pane identity: %w", err) | |
| 85 | 81 | } | |
| 86 | 82 | command, args := d.ptyCommand(env) | |
| 87 | 83 | pty, err := d.ptys.Open(sess.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, rectSize(newRect)) | |
| 88 | 84 | if err != nil { | |
| 89 | 85 | d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane") | |
| 90 | - | tb.mu.Lock() | |
| 91 | - | _ = tb.tree.Close(newID) | |
| 92 | - | tb.tree.Focus = oldFocus | |
| 93 | - | tb.mu.Unlock() | |
| 94 | 86 | return domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err) | |
| 95 | 87 | } | |
| 96 | 88 | ||
| @@ -100,65 +92,22 @@ func (d *Daemon) spawnPaneOpAt( | |||
| 100 | 92 | p.rect = newRect | |
| 101 | 93 | ||
| 102 | 94 | tb.mu.Lock() | |
| 103 | - | if _, ok := tb.panes[newID]; ok || !layout.ContainsLeaf(tb.tree.Root, newID) { | |
| 95 | + | if tb.layoutGeneration != generation || tb.panes[oldFocus] != target || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, oldFocus) { | |
| 104 | 96 | tb.mu.Unlock() | |
| 105 | 97 | cancel() | |
| 106 | 98 | _ = pty.Close() | |
| 107 | 99 | return layout.ErrNotFound | |
| 108 | 100 | } | |
| 101 | + | tb.tree = candidate | |
| 109 | 102 | tb.panes[newID] = p | |
| 110 | - | tb.tree.Focus = newID | |
| 111 | - | d.applyLayoutLocked(tb) | |
| 103 | + | tb.bumpLayoutGenerationLocked() | |
| 112 | 104 | tb.mu.Unlock() | |
| 113 | 105 | ||
| 106 | + | d.applyTabLayout(sess, tb) | |
| 114 | 107 | d.startPaneGoroutines(sess, tb, p) | |
| 115 | - | markSnapshotDirty(sess) | |
| 116 | 108 | return nil | |
| 117 | 109 | } | |
| 118 | 110 | ||
| 119 | - | func (d *Daemon) applyLayoutLocked(tb *tab) { | |
| 120 | - | if tb == nil || tb.tree == nil || tb.tree.Root == nil || !tb.size.Valid() { | |
| 121 | - | return | |
| 122 | - | } | |
| 123 | - | placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |
| 124 | - | if !ok { | |
| 125 | - | for _, p := range tb.panes { | |
| 126 | - | d.applyPaneResize(p, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |
| 127 | - | } | |
| 128 | - | return | |
| 129 | - | } | |
| 130 | - | for _, pl := range placements { | |
| 131 | - | if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 { | |
| 132 | - | continue | |
| 133 | - | } | |
| 134 | - | if p := tb.panes[pl.ID]; p != nil { | |
| 135 | - | d.applyPaneResize(p, pl.Content) | |
| 136 | - | } | |
| 137 | - | } | |
| 138 | - | } | |
| 139 | - | ||
| 140 | - | func (d *Daemon) applyPaneResize(p *pane, r domain.Rect) { | |
| 141 | - | if p == nil { | |
| 142 | - | return | |
| 143 | - | } | |
| 144 | - | p.mu.Lock() | |
| 145 | - | old := p.rect | |
| 146 | - | p.mu.Unlock() | |
| 147 | - | if old == r { | |
| 148 | - | return | |
| 149 | - | } | |
| 150 | - | sz := rectSize(r) | |
| 151 | - | if p.pty != nil { | |
| 152 | - | if err := p.pty.Resize(sz); err != nil { | |
| 153 | - | d.log.Warn("pty resize failed", "err", err) | |
| 154 | - | } | |
| 155 | - | } | |
| 156 | - | p.mu.Lock() | |
| 157 | - | p.screen.Resize(sz.Cols, sz.Rows) | |
| 158 | - | p.rect = r | |
| 159 | - | p.mu.Unlock() | |
| 160 | - | } | |
| 161 | - | ||
| 162 | 111 | func placementContent(placements []layout.Placement, id layout.PaneID) domain.Rect { | |
| 163 | 112 | for _, pl := range placements { | |
| 164 | 113 | if pl.ID == id { | |
| @@ -210,13 +159,15 @@ func (d *Daemon) toggleStackAt(sess *session, tb *tab, target *pane) error { | |||
| 210 | 159 | tb.mu.Unlock() | |
| 211 | 160 | return layout.ErrNotFound | |
| 212 | 161 | } | |
| 213 | - | err := tb.tree.ToggleStack(target.id, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |
| 162 | + | candidate := tb.tree.Clone() | |
| 163 | + | err := candidate.ToggleStack(target.id, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |
| 214 | 164 | if err == nil { | |
| 215 | - | d.applyLayoutLocked(tb) | |
| 165 | + | tb.tree = candidate | |
| 166 | + | tb.bumpLayoutGenerationLocked() | |
| 216 | 167 | } | |
| 217 | 168 | tb.mu.Unlock() | |
| 218 | 169 | if err == nil { | |
| 219 | - | markSnapshotDirty(sess) | |
| 170 | + | d.applyTabLayout(sess, tb) | |
| 220 | 171 | } | |
| 221 | 172 | return err | |
| 222 | 173 | } | |
| @@ -276,8 +227,9 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||
| 276 | 227 | return err | |
| 277 | 228 | } | |
| 278 | 229 | delete(tb.panes, id) | |
| 279 | - | d.applyLayoutLocked(tb) | |
| 230 | + | tb.bumpLayoutGenerationLocked() | |
| 280 | 231 | tb.mu.Unlock() | |
| 232 | + | d.applyTabLayout(sess, tb) | |
| 281 | 233 | ||
| 282 | 234 | if ac != nil { | |
| 283 | 235 | ac.overlays.clearCopyModeForPane(p) | |
| @@ -294,7 +246,6 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||
| 294 | 246 | _ = p.pty.Close() | |
| 295 | 247 | } | |
| 296 | 248 | d.log.Info("pane closed", "session", sess.name, "pane", id) | |
| 297 | - | markSnapshotDirty(sess) | |
| 298 | 249 | if repaint { | |
| 299 | 250 | if ac != nil { | |
| 300 | 251 | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| @@ -336,15 +287,17 @@ func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Dir | |||
| 336 | 287 | return layout.ErrNotFound | |
| 337 | 288 | } | |
| 338 | 289 | oldFocus := target.id | |
| 339 | - | tb.tree.Focus = oldFocus | |
| 340 | - | err := tb.tree.FocusDir(dir, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |
| 341 | - | newFocus := tb.tree.Focus | |
| 342 | - | if err == nil { | |
| 343 | - | d.applyLayoutLocked(tb) | |
| 290 | + | candidate := tb.tree.Clone() | |
| 291 | + | candidate.Focus = oldFocus | |
| 292 | + | err := candidate.FocusDir(dir, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |
| 293 | + | newFocus := candidate.Focus | |
| 294 | + | if err == nil && newFocus != tb.tree.Focus { | |
| 295 | + | tb.tree = candidate | |
| 296 | + | tb.bumpLayoutGenerationLocked() | |
| 344 | 297 | } | |
| 345 | 298 | tb.mu.Unlock() | |
| 346 | 299 | if err == nil && newFocus != oldFocus { | |
| 347 | - | markSnapshotDirty(sess) | |
| 300 | + | d.applyTabLayout(sess, tb) | |
| 348 | 301 | } | |
| 349 | 302 | if errors.Is(err, layout.ErrNoPane) { | |
| 350 | 303 | return errNoNeighbor | |
| @@ -56,7 +56,8 @@ func (d *Daemon) spawnPaneOpAt( | |||||
| 56 | } | 56 | } | ||
| 57 | newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID)) | 57 | newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID)) | ||
| 58 | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | 58 | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | ||
| 59 | - | if err := mutate(tb.tree, oldFocus, newID, area); err != nil { | 59 | + | candidate := tb.tree.Clone() |
| 60 | + | if err := mutate(candidate, oldFocus, newID, area); err != nil { | |||
| 60 | tb.mu.Unlock() | 61 | tb.mu.Unlock() | ||
| 61 | if errors.Is(err, layout.ErrTooSmall) { | 62 | if errors.Is(err, layout.ErrTooSmall) { | ||
| 62 | return domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", err) | 63 | return domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", err) | ||
| @@ -64,33 +65,24 @@ func (d *Daemon) spawnPaneOpAt( | |||||
| 64 | return err | 65 | return err | ||
| 65 | } | 66 | } | ||
| 66 | tb.nextPaneID++ | 67 | tb.nextPaneID++ | ||
| 67 | - | placements, ok := layout.Solve(tb.tree.Root, area) | 68 | + | placements, ok := layout.Solve(candidate.Root, area) |
| 68 | if !ok { | 69 | if !ok { | ||
| 69 | - | _ = tb.tree.Close(newID) | |||
| 70 | - | tb.tree.Focus = oldFocus | |||
| 71 | tb.mu.Unlock() | 70 | tb.mu.Unlock() | ||
| 72 | return domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", layout.ErrTooSmall) | 71 | return domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", layout.ErrTooSmall) | ||
| 73 | } | 72 | } | ||
| 74 | newRect := placementContent(placements, newID) | 73 | newRect := placementContent(placements, newID) | ||
| 75 | tabStableID := tb.stableID | 74 | tabStableID := tb.stableID | ||
| 75 | + | generation := tb.layoutGeneration | |||
| 76 | tb.mu.Unlock() | 76 | tb.mu.Unlock() | ||
| 77 | 77 | ||||
| 78 | paneStableID, err := newStableID("p") | 78 | paneStableID, err := newStableID("p") | ||
| 79 | if err != nil { | 79 | if err != nil { | ||
| 80 | - | tb.mu.Lock() | |||
| 81 | - | _ = tb.tree.Close(newID) | |||
| 82 | - | tb.tree.Focus = oldFocus | |||
| 83 | - | tb.mu.Unlock() | |||
| 84 | return fmt.Errorf("daemon: generating pane identity: %w", err) | 80 | return fmt.Errorf("daemon: generating pane identity: %w", err) | ||
| 85 | } | 81 | } | ||
| 86 | command, args := d.ptyCommand(env) | 82 | command, args := d.ptyCommand(env) | ||
| 87 | pty, err := d.ptys.Open(sess.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, rectSize(newRect)) | 83 | pty, err := d.ptys.Open(sess.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, rectSize(newRect)) | ||
| 88 | if err != nil { | 84 | if err != nil { | ||
| 89 | d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane") | 85 | d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane") | ||
| 90 | - | tb.mu.Lock() | |||
| 91 | - | _ = tb.tree.Close(newID) | |||
| 92 | - | tb.tree.Focus = oldFocus | |||
| 93 | - | tb.mu.Unlock() | |||
| 94 | return domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err) | 86 | return domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err) | ||
| 95 | } | 87 | } | ||
| 96 | 88 | ||||
| @@ -100,65 +92,22 @@ func (d *Daemon) spawnPaneOpAt( | |||||
| 100 | p.rect = newRect | 92 | p.rect = newRect | ||
| 101 | 93 | ||||
| 102 | tb.mu.Lock() | 94 | tb.mu.Lock() | ||
| 103 | - | if _, ok := tb.panes[newID]; ok || !layout.ContainsLeaf(tb.tree.Root, newID) { | 95 | + | if tb.layoutGeneration != generation || tb.panes[oldFocus] != target || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, oldFocus) { |
| 104 | tb.mu.Unlock() | 96 | tb.mu.Unlock() | ||
| 105 | cancel() | 97 | cancel() | ||
| 106 | _ = pty.Close() | 98 | _ = pty.Close() | ||
| 107 | return layout.ErrNotFound | 99 | return layout.ErrNotFound | ||
| 108 | } | 100 | } | ||
| 101 | + | tb.tree = candidate | |||
| 109 | tb.panes[newID] = p | 102 | tb.panes[newID] = p | ||
| 110 | - | tb.tree.Focus = newID | 103 | + | tb.bumpLayoutGenerationLocked() |
| 111 | - | d.applyLayoutLocked(tb) | |||
| 112 | tb.mu.Unlock() | 104 | tb.mu.Unlock() | ||
| 113 | 105 | ||||
| 106 | + | d.applyTabLayout(sess, tb) | |||
| 114 | d.startPaneGoroutines(sess, tb, p) | 107 | d.startPaneGoroutines(sess, tb, p) | ||
| 115 | - | markSnapshotDirty(sess) | |||
| 116 | return nil | 108 | return nil | ||
| 117 | } | 109 | } | ||
| 118 | 110 | ||||
| 119 | - | func (d *Daemon) applyLayoutLocked(tb *tab) { | |||
| 120 | - | if tb == nil || tb.tree == nil || tb.tree.Root == nil || !tb.size.Valid() { | |||
| 121 | - | return | |||
| 122 | - | } | |||
| 123 | - | placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |||
| 124 | - | if !ok { | |||
| 125 | - | for _, p := range tb.panes { | |||
| 126 | - | d.applyPaneResize(p, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |||
| 127 | - | } | |||
| 128 | - | return | |||
| 129 | - | } | |||
| 130 | - | for _, pl := range placements { | |||
| 131 | - | if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 { | |||
| 132 | - | continue | |||
| 133 | - | } | |||
| 134 | - | if p := tb.panes[pl.ID]; p != nil { | |||
| 135 | - | d.applyPaneResize(p, pl.Content) | |||
| 136 | - | } | |||
| 137 | - | } | |||
| 138 | - | } | |||
| 139 | - | ||||
| 140 | - | func (d *Daemon) applyPaneResize(p *pane, r domain.Rect) { | |||
| 141 | - | if p == nil { | |||
| 142 | - | return | |||
| 143 | - | } | |||
| 144 | - | p.mu.Lock() | |||
| 145 | - | old := p.rect | |||
| 146 | - | p.mu.Unlock() | |||
| 147 | - | if old == r { | |||
| 148 | - | return | |||
| 149 | - | } | |||
| 150 | - | sz := rectSize(r) | |||
| 151 | - | if p.pty != nil { | |||
| 152 | - | if err := p.pty.Resize(sz); err != nil { | |||
| 153 | - | d.log.Warn("pty resize failed", "err", err) | |||
| 154 | - | } | |||
| 155 | - | } | |||
| 156 | - | p.mu.Lock() | |||
| 157 | - | p.screen.Resize(sz.Cols, sz.Rows) | |||
| 158 | - | p.rect = r | |||
| 159 | - | p.mu.Unlock() | |||
| 160 | - | } | |||
| 161 | - | ||||
| 162 | func placementContent(placements []layout.Placement, id layout.PaneID) domain.Rect { | 111 | func placementContent(placements []layout.Placement, id layout.PaneID) domain.Rect { | ||
| 163 | for _, pl := range placements { | 112 | for _, pl := range placements { | ||
| 164 | if pl.ID == id { | 113 | if pl.ID == id { | ||
| @@ -210,13 +159,15 @@ func (d *Daemon) toggleStackAt(sess *session, tb *tab, target *pane) error { | |||||
| 210 | tb.mu.Unlock() | 159 | tb.mu.Unlock() | ||
| 211 | return layout.ErrNotFound | 160 | return layout.ErrNotFound | ||
| 212 | } | 161 | } | ||
| 213 | - | err := tb.tree.ToggleStack(target.id, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | 162 | + | candidate := tb.tree.Clone() |
| 163 | + | err := candidate.ToggleStack(target.id, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |||
| 214 | if err == nil { | 164 | if err == nil { | ||
| 215 | - | d.applyLayoutLocked(tb) | 165 | + | tb.tree = candidate |
| 166 | + | tb.bumpLayoutGenerationLocked() | |||
| 216 | } | 167 | } | ||
| 217 | tb.mu.Unlock() | 168 | tb.mu.Unlock() | ||
| 218 | if err == nil { | 169 | if err == nil { | ||
| 219 | - | markSnapshotDirty(sess) | 170 | + | d.applyTabLayout(sess, tb) |
| 220 | } | 171 | } | ||
| 221 | return err | 172 | return err | ||
| 222 | } | 173 | } | ||
| @@ -276,8 +227,9 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||||
| 276 | return err | 227 | return err | ||
| 277 | } | 228 | } | ||
| 278 | delete(tb.panes, id) | 229 | delete(tb.panes, id) | ||
| 279 | - | d.applyLayoutLocked(tb) | 230 | + | tb.bumpLayoutGenerationLocked() |
| 280 | tb.mu.Unlock() | 231 | tb.mu.Unlock() | ||
| 232 | + | d.applyTabLayout(sess, tb) | |||
| 281 | 233 | ||||
| 282 | if ac != nil { | 234 | if ac != nil { | ||
| 283 | ac.overlays.clearCopyModeForPane(p) | 235 | ac.overlays.clearCopyModeForPane(p) | ||
| @@ -294,7 +246,6 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||||
| 294 | _ = p.pty.Close() | 246 | _ = p.pty.Close() | ||
| 295 | } | 247 | } | ||
| 296 | d.log.Info("pane closed", "session", sess.name, "pane", id) | 248 | d.log.Info("pane closed", "session", sess.name, "pane", id) | ||
| 297 | - | markSnapshotDirty(sess) | |||
| 298 | if repaint { | 249 | if repaint { | ||
| 299 | if ac != nil { | 250 | if ac != nil { | ||
| 300 | d.invalidateRender(sess, ac, true, "pane_actions.go") | 251 | d.invalidateRender(sess, ac, true, "pane_actions.go") | ||
| @@ -336,15 +287,17 @@ func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Dir | |||||
| 336 | return layout.ErrNotFound | 287 | return layout.ErrNotFound | ||
| 337 | } | 288 | } | ||
| 338 | oldFocus := target.id | 289 | oldFocus := target.id | ||
| 339 | - | tb.tree.Focus = oldFocus | 290 | + | candidate := tb.tree.Clone() |
| 340 | - | err := tb.tree.FocusDir(dir, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | 291 | + | candidate.Focus = oldFocus |
| 341 | - | newFocus := tb.tree.Focus | 292 | + | err := candidate.FocusDir(dir, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) |
| 342 | - | if err == nil { | 293 | + | newFocus := candidate.Focus |
| 343 | - | d.applyLayoutLocked(tb) | 294 | + | if err == nil && newFocus != tb.tree.Focus { |
| 295 | + | tb.tree = candidate | |||
| 296 | + | tb.bumpLayoutGenerationLocked() | |||
| 344 | } | 297 | } | ||
| 345 | tb.mu.Unlock() | 298 | tb.mu.Unlock() | ||
| 346 | if err == nil && newFocus != oldFocus { | 299 | if err == nil && newFocus != oldFocus { | ||
| 347 | - | markSnapshotDirty(sess) | 300 | + | d.applyTabLayout(sess, tb) |
| 348 | } | 301 | } | ||
| 349 | if errors.Is(err, layout.ErrNoPane) { | 302 | if errors.Is(err, layout.ErrNoPane) { | ||
| 350 | return errNoNeighbor | 303 | return errNoNeighbor | ||
M internal/usecase/daemon/pane_actions_test.go +201 -3
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "io" | |
| 7 | 7 | "log/slog" | |
| 8 | + | "sync" | |
| 8 | 9 | "testing" | |
| 9 | 10 | "time" | |
| 10 | 11 | ||
| @@ -19,6 +20,205 @@ import ( | |||
| 19 | 20 | "github.com/bnema/vev/pkg/renderer" | |
| 20 | 21 | ) | |
| 21 | 22 | ||
| 23 | + | func TestLayoutGenerationMutationBookkeeping(t *testing.T) { | |
| 24 | + | tb := newTab(nil, domain.Size{Cols: 41, Rows: 10}) | |
| 25 | + | ||
| 26 | + | tb.mu.Lock() | |
| 27 | + | require.Zero(t, tb.layoutGeneration) | |
| 28 | + | tb.bumpLayoutGenerationLocked() | |
| 29 | + | require.Equal(t, uint64(1), tb.layoutGeneration) | |
| 30 | + | tb.mu.Unlock() | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestLayoutApplicationDoesNotHoldTabLock(t *testing.T) { | |
| 34 | + | d, sess, pty, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 35 | + | tb := sess.activeTab() | |
| 36 | + | tb.focusedPane().rect = domain.Rect{Width: 20, Height: 10} | |
| 37 | + | entered := make(chan struct{}) | |
| 38 | + | release := make(chan struct{}) | |
| 39 | + | pty.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Run(func(domain.Size) { | |
| 40 | + | close(entered) | |
| 41 | + | <-release | |
| 42 | + | }).Return(nil).Once() | |
| 43 | + | ||
| 44 | + | done := make(chan struct{}) | |
| 45 | + | go func() { | |
| 46 | + | d.applyTabLayout(sess, tb) | |
| 47 | + | close(done) | |
| 48 | + | }() | |
| 49 | + | awaitSignal(t, entered, "PTY resize did not start") | |
| 50 | + | ||
| 51 | + | locked := make(chan struct{}) | |
| 52 | + | go func() { | |
| 53 | + | tb.mu.Lock() | |
| 54 | + | _ = tb.layoutGeneration | |
| 55 | + | tb.mu.Unlock() | |
| 56 | + | close(locked) | |
| 57 | + | }() | |
| 58 | + | awaitSignal(t, locked, "tab lock remained held across PTY resize") | |
| 59 | + | close(release) | |
| 60 | + | awaitSignal(t, done, "layout application did not finish") | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func TestLayoutApplicationRejectsStalePaneIdentity(t *testing.T) { | |
| 64 | + | d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 65 | + | tb := sess.activeTab() | |
| 66 | + | oldPane := tb.focusedPane() | |
| 67 | + | oldPane.rect = domain.Rect{Width: 20, Height: 10} | |
| 68 | + | oldPane.screen.Resize(20, 10) | |
| 69 | + | entered := make(chan struct{}) | |
| 70 | + | release := make(chan struct{}) | |
| 71 | + | oldPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Run(func(domain.Size) { | |
| 72 | + | close(entered) | |
| 73 | + | <-release | |
| 74 | + | }).Return(nil).Once() | |
| 75 | + | ||
| 76 | + | done := make(chan struct{}) | |
| 77 | + | go func() { | |
| 78 | + | d.applyTabLayout(sess, tb) | |
| 79 | + | close(done) | |
| 80 | + | }() | |
| 81 | + | awaitSignal(t, entered, "stale PTY resize did not start") | |
| 82 | + | ||
| 83 | + | newPTY := portsmocks.NewMockPTY(t) | |
| 84 | + | replacement := newPane("pane-1", newPTY, domain.Size{Cols: 20, Rows: 10}) | |
| 85 | + | newPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Return(nil).Once() | |
| 86 | + | tb.mu.Lock() | |
| 87 | + | tb.panes["pane-1"] = replacement | |
| 88 | + | tb.bumpLayoutGenerationLocked() | |
| 89 | + | tb.mu.Unlock() | |
| 90 | + | close(release) | |
| 91 | + | awaitSignal(t, done, "stale layout did not retry") | |
| 92 | + | ||
| 93 | + | require.Equal(t, domain.Rect{Width: 20, Height: 10}, oldPane.rect, "stale pane rectangle was published") | |
| 94 | + | require.Equal(t, 20, oldPane.screen.Frame.Width, "stale pane screen was published") | |
| 95 | + | require.Equal(t, domain.Rect{Width: 41, Height: 10}, replacement.rect) | |
| 96 | + | require.Equal(t, 41, replacement.screen.Frame.Width) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) { | |
| 100 | + | newBlockedPTY := func() (*transactionalResizePTY, <-chan struct{}, chan<- struct{}) { | |
| 101 | + | entered := make(chan struct{}) | |
| 102 | + | release := make(chan struct{}) | |
| 103 | + | var once sync.Once | |
| 104 | + | return &transactionalResizePTY{onResize: func() { | |
| 105 | + | once.Do(func() { | |
| 106 | + | close(entered) | |
| 107 | + | <-release | |
| 108 | + | }) | |
| 109 | + | }}, entered, release | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | t.Run("close removes a stale plan member", func(t *testing.T) { | |
| 113 | + | first, entered, release := newBlockedPTY() | |
| 114 | + | second := &transactionalResizePTY{} | |
| 115 | + | d, sess, _, _ := newManualSessionWithPTYs(t, first) | |
| 116 | + | tb := sess.activeTab() | |
| 117 | + | p1 := tb.focusedPane() | |
| 118 | + | p2 := newPane("pane-2", second, domain.Size{Cols: 80, Rows: 23}) | |
| 119 | + | tb.mu.Lock() | |
| 120 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}}, Focus: p1.id} | |
| 121 | + | tb.panes[p2.id] = p2 | |
| 122 | + | tb.bumpLayoutGenerationLocked() | |
| 123 | + | tb.mu.Unlock() | |
| 124 | + | ||
| 125 | + | done := make(chan struct{}) | |
| 126 | + | go func() { d.applyTabLayout(sess, tb); close(done) }() | |
| 127 | + | awaitSignal(t, entered, "old layout apply did not block") | |
| 128 | + | closed := make(chan error, 1) | |
| 129 | + | go func() { closed <- d.closePane(sess, tb, p2.id, nil, false) }() | |
| 130 | + | require.Eventually(t, func() bool { | |
| 131 | + | tb.mu.Lock() | |
| 132 | + | defer tb.mu.Unlock() | |
| 133 | + | return tb.panes[p2.id] == nil && tb.layoutGeneration > 1 | |
| 134 | + | }, time.Second, time.Millisecond, "close must mutate before its serialized apply waits") | |
| 135 | + | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p1.rect, "stale plan published before release") | |
| 136 | + | close(release) | |
| 137 | + | awaitSignal(t, done, "stale layout apply did not finish") | |
| 138 | + | require.NoError(t, <-closed) | |
| 139 | + | ||
| 140 | + | tb.mu.Lock() | |
| 141 | + | _, exists := tb.panes[p2.id] | |
| 142 | + | tb.mu.Unlock() | |
| 143 | + | require.False(t, exists) | |
| 144 | + | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p2.rect, "removed pane received a stale rectangle publication") | |
| 145 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p2.screen.Frame.Width, Rows: p2.screen.Frame.Height}, "removed pane received a stale screen publication") | |
| 146 | + | p2.mu.Lock() | |
| 147 | + | require.False(t, p2.resizeApplying, "removed pane gate was not cancelled") | |
| 148 | + | p2.mu.Unlock() | |
| 149 | + | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p1.rect) | |
| 150 | + | }) | |
| 151 | + | ||
| 152 | + | t.Run("focus expansion replaces a stale stack placement", func(t *testing.T) { | |
| 153 | + | first, entered, release := newBlockedPTY() | |
| 154 | + | second := &transactionalResizePTY{} | |
| 155 | + | d, sess, _, _ := newManualSessionWithPTYs(t, first) | |
| 156 | + | tb := sess.activeTab() | |
| 157 | + | p1 := tb.focusedPane() | |
| 158 | + | p2 := newPane("pane-2", second, domain.Size{Cols: 80, Rows: 23}) | |
| 159 | + | tb.mu.Lock() | |
| 160 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p1.id}, Focus: p1.id} | |
| 161 | + | tb.panes[p2.id] = p2 | |
| 162 | + | tb.bumpLayoutGenerationLocked() | |
| 163 | + | tb.mu.Unlock() | |
| 164 | + | ||
| 165 | + | done := make(chan struct{}) | |
| 166 | + | go func() { d.applyTabLayout(sess, tb); close(done) }() | |
| 167 | + | awaitSignal(t, entered, "old stack apply did not block") | |
| 168 | + | tb.mu.Lock() | |
| 169 | + | require.True(t, focusPlacementLocked(tb, p2.id)) | |
| 170 | + | tb.mu.Unlock() | |
| 171 | + | close(release) | |
| 172 | + | awaitSignal(t, done, "stale stack apply did not retry") | |
| 173 | + | ||
| 174 | + | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p1.rect, "collapsed pane received stale geometry") | |
| 175 | + | p1.mu.Lock() | |
| 176 | + | require.False(t, p1.resizeApplying, "collapsed pane gate was not cancelled") | |
| 177 | + | p1.mu.Unlock() | |
| 178 | + | tb.mu.Lock() | |
| 179 | + | placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |
| 180 | + | tb.mu.Unlock() | |
| 181 | + | require.True(t, ok) | |
| 182 | + | require.Equal(t, placementContent(placements, p2.id), p2.rect) | |
| 183 | + | require.Equal(t, []domain.Size{rectSize(p2.rect)}, second.requested()) | |
| 184 | + | }) | |
| 185 | + | ||
| 186 | + | t.Run("client size rejects and retries a blocked plan", func(t *testing.T) { | |
| 187 | + | pty, entered, release := newBlockedPTY() | |
| 188 | + | d, sess, _, _ := newManualSessionWithPTYs(t, pty) | |
| 189 | + | tb := sess.activeTab() | |
| 190 | + | p := tb.focusedPane() | |
| 191 | + | p.rect = domain.Rect{Width: 20, Height: 10} | |
| 192 | + | ||
| 193 | + | oldDone := make(chan struct{}) | |
| 194 | + | go func() { d.applyTabLayout(sess, tb); close(oldDone) }() | |
| 195 | + | awaitSignal(t, entered, "old size apply did not block") | |
| 196 | + | resizeDone := make(chan bool, 1) | |
| 197 | + | go func() { resizeDone <- d.requestTransactionalResize(sess, nil, domain.Size{Cols: 100, Rows: 30}, true) }() | |
| 198 | + | require.Eventually(t, func() bool { | |
| 199 | + | tb.mu.Lock() | |
| 200 | + | defer tb.mu.Unlock() | |
| 201 | + | return tb.size == (domain.Size{Cols: 100, Rows: 28}) && tb.layoutGeneration > 0 | |
| 202 | + | }, time.Second, time.Millisecond) | |
| 203 | + | require.Equal(t, domain.Rect{Width: 20, Height: 10}, p.rect, "stale size published before release") | |
| 204 | + | close(release) | |
| 205 | + | awaitSignal(t, oldDone, "old size apply did not finish") | |
| 206 | + | require.True(t, <-resizeDone) | |
| 207 | + | require.Equal(t, domain.Rect{Width: 100, Height: 28}, p.rect) | |
| 208 | + | require.Equal(t, domain.Size{Cols: 100, Rows: 28}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}) | |
| 209 | + | require.Equal(t, []domain.Size{{Cols: 80, Rows: 23}, {Cols: 100, Rows: 28}}, pty.requested()) | |
| 210 | + | }) | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | func awaitSignal(t *testing.T, ch <-chan struct{}, message string) { | |
| 214 | + | t.Helper() | |
| 215 | + | select { | |
| 216 | + | case <-ch: | |
| 217 | + | case <-time.After(2 * time.Second): | |
| 218 | + | t.Fatal(message) | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | ||
| 22 | 222 | func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) { | |
| 23 | 223 | tests := []struct { | |
| 24 | 224 | name string | |
| @@ -123,9 +323,7 @@ func TestApplyLayoutResizesPTYsAndScreens(t *testing.T) { | |||
| 123 | 323 | oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once() | |
| 124 | 324 | newPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once() | |
| 125 | 325 | ||
| 126 | - | tb.mu.Lock() | |
| 127 | - | d.applyLayoutLocked(tb) | |
| 128 | - | tb.mu.Unlock() | |
| 326 | + | d.applyTabLayout(sess, tb) | |
| 129 | 327 | ||
| 130 | 328 | require.Equal(t, 20, tb.panes["pane-1"].screen.Frame.Width) | |
| 131 | 329 | require.Equal(t, 20, tb.panes["pane-2"].screen.Frame.Width) | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "io" | 6 | "io" | ||
| 7 | "log/slog" | 7 | "log/slog" | ||
| 8 | + | "sync" | |||
| 8 | "testing" | 9 | "testing" | ||
| 9 | "time" | 10 | "time" | ||
| 10 | 11 | ||||
| @@ -19,6 +20,205 @@ import ( | |||||
| 19 | "github.com/bnema/vev/pkg/renderer" | 20 | "github.com/bnema/vev/pkg/renderer" | ||
| 20 | ) | 21 | ) | ||
| 21 | 22 | ||||
| 23 | + | func TestLayoutGenerationMutationBookkeeping(t *testing.T) { | |||
| 24 | + | tb := newTab(nil, domain.Size{Cols: 41, Rows: 10}) | |||
| 25 | + | ||||
| 26 | + | tb.mu.Lock() | |||
| 27 | + | require.Zero(t, tb.layoutGeneration) | |||
| 28 | + | tb.bumpLayoutGenerationLocked() | |||
| 29 | + | require.Equal(t, uint64(1), tb.layoutGeneration) | |||
| 30 | + | tb.mu.Unlock() | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestLayoutApplicationDoesNotHoldTabLock(t *testing.T) { | |||
| 34 | + | d, sess, pty, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |||
| 35 | + | tb := sess.activeTab() | |||
| 36 | + | tb.focusedPane().rect = domain.Rect{Width: 20, Height: 10} | |||
| 37 | + | entered := make(chan struct{}) | |||
| 38 | + | release := make(chan struct{}) | |||
| 39 | + | pty.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Run(func(domain.Size) { | |||
| 40 | + | close(entered) | |||
| 41 | + | <-release | |||
| 42 | + | }).Return(nil).Once() | |||
| 43 | + | ||||
| 44 | + | done := make(chan struct{}) | |||
| 45 | + | go func() { | |||
| 46 | + | d.applyTabLayout(sess, tb) | |||
| 47 | + | close(done) | |||
| 48 | + | }() | |||
| 49 | + | awaitSignal(t, entered, "PTY resize did not start") | |||
| 50 | + | ||||
| 51 | + | locked := make(chan struct{}) | |||
| 52 | + | go func() { | |||
| 53 | + | tb.mu.Lock() | |||
| 54 | + | _ = tb.layoutGeneration | |||
| 55 | + | tb.mu.Unlock() | |||
| 56 | + | close(locked) | |||
| 57 | + | }() | |||
| 58 | + | awaitSignal(t, locked, "tab lock remained held across PTY resize") | |||
| 59 | + | close(release) | |||
| 60 | + | awaitSignal(t, done, "layout application did not finish") | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func TestLayoutApplicationRejectsStalePaneIdentity(t *testing.T) { | |||
| 64 | + | d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |||
| 65 | + | tb := sess.activeTab() | |||
| 66 | + | oldPane := tb.focusedPane() | |||
| 67 | + | oldPane.rect = domain.Rect{Width: 20, Height: 10} | |||
| 68 | + | oldPane.screen.Resize(20, 10) | |||
| 69 | + | entered := make(chan struct{}) | |||
| 70 | + | release := make(chan struct{}) | |||
| 71 | + | oldPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Run(func(domain.Size) { | |||
| 72 | + | close(entered) | |||
| 73 | + | <-release | |||
| 74 | + | }).Return(nil).Once() | |||
| 75 | + | ||||
| 76 | + | done := make(chan struct{}) | |||
| 77 | + | go func() { | |||
| 78 | + | d.applyTabLayout(sess, tb) | |||
| 79 | + | close(done) | |||
| 80 | + | }() | |||
| 81 | + | awaitSignal(t, entered, "stale PTY resize did not start") | |||
| 82 | + | ||||
| 83 | + | newPTY := portsmocks.NewMockPTY(t) | |||
| 84 | + | replacement := newPane("pane-1", newPTY, domain.Size{Cols: 20, Rows: 10}) | |||
| 85 | + | newPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Return(nil).Once() | |||
| 86 | + | tb.mu.Lock() | |||
| 87 | + | tb.panes["pane-1"] = replacement | |||
| 88 | + | tb.bumpLayoutGenerationLocked() | |||
| 89 | + | tb.mu.Unlock() | |||
| 90 | + | close(release) | |||
| 91 | + | awaitSignal(t, done, "stale layout did not retry") | |||
| 92 | + | ||||
| 93 | + | require.Equal(t, domain.Rect{Width: 20, Height: 10}, oldPane.rect, "stale pane rectangle was published") | |||
| 94 | + | require.Equal(t, 20, oldPane.screen.Frame.Width, "stale pane screen was published") | |||
| 95 | + | require.Equal(t, domain.Rect{Width: 41, Height: 10}, replacement.rect) | |||
| 96 | + | require.Equal(t, 41, replacement.screen.Frame.Width) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) { | |||
| 100 | + | newBlockedPTY := func() (*transactionalResizePTY, <-chan struct{}, chan<- struct{}) { | |||
| 101 | + | entered := make(chan struct{}) | |||
| 102 | + | release := make(chan struct{}) | |||
| 103 | + | var once sync.Once | |||
| 104 | + | return &transactionalResizePTY{onResize: func() { | |||
| 105 | + | once.Do(func() { | |||
| 106 | + | close(entered) | |||
| 107 | + | <-release | |||
| 108 | + | }) | |||
| 109 | + | }}, entered, release | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | t.Run("close removes a stale plan member", func(t *testing.T) { | |||
| 113 | + | first, entered, release := newBlockedPTY() | |||
| 114 | + | second := &transactionalResizePTY{} | |||
| 115 | + | d, sess, _, _ := newManualSessionWithPTYs(t, first) | |||
| 116 | + | tb := sess.activeTab() | |||
| 117 | + | p1 := tb.focusedPane() | |||
| 118 | + | p2 := newPane("pane-2", second, domain.Size{Cols: 80, Rows: 23}) | |||
| 119 | + | tb.mu.Lock() | |||
| 120 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}}, Focus: p1.id} | |||
| 121 | + | tb.panes[p2.id] = p2 | |||
| 122 | + | tb.bumpLayoutGenerationLocked() | |||
| 123 | + | tb.mu.Unlock() | |||
| 124 | + | ||||
| 125 | + | done := make(chan struct{}) | |||
| 126 | + | go func() { d.applyTabLayout(sess, tb); close(done) }() | |||
| 127 | + | awaitSignal(t, entered, "old layout apply did not block") | |||
| 128 | + | closed := make(chan error, 1) | |||
| 129 | + | go func() { closed <- d.closePane(sess, tb, p2.id, nil, false) }() | |||
| 130 | + | require.Eventually(t, func() bool { | |||
| 131 | + | tb.mu.Lock() | |||
| 132 | + | defer tb.mu.Unlock() | |||
| 133 | + | return tb.panes[p2.id] == nil && tb.layoutGeneration > 1 | |||
| 134 | + | }, time.Second, time.Millisecond, "close must mutate before its serialized apply waits") | |||
| 135 | + | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p1.rect, "stale plan published before release") | |||
| 136 | + | close(release) | |||
| 137 | + | awaitSignal(t, done, "stale layout apply did not finish") | |||
| 138 | + | require.NoError(t, <-closed) | |||
| 139 | + | ||||
| 140 | + | tb.mu.Lock() | |||
| 141 | + | _, exists := tb.panes[p2.id] | |||
| 142 | + | tb.mu.Unlock() | |||
| 143 | + | require.False(t, exists) | |||
| 144 | + | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p2.rect, "removed pane received a stale rectangle publication") | |||
| 145 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p2.screen.Frame.Width, Rows: p2.screen.Frame.Height}, "removed pane received a stale screen publication") | |||
| 146 | + | p2.mu.Lock() | |||
| 147 | + | require.False(t, p2.resizeApplying, "removed pane gate was not cancelled") | |||
| 148 | + | p2.mu.Unlock() | |||
| 149 | + | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p1.rect) | |||
| 150 | + | }) | |||
| 151 | + | ||||
| 152 | + | t.Run("focus expansion replaces a stale stack placement", func(t *testing.T) { | |||
| 153 | + | first, entered, release := newBlockedPTY() | |||
| 154 | + | second := &transactionalResizePTY{} | |||
| 155 | + | d, sess, _, _ := newManualSessionWithPTYs(t, first) | |||
| 156 | + | tb := sess.activeTab() | |||
| 157 | + | p1 := tb.focusedPane() | |||
| 158 | + | p2 := newPane("pane-2", second, domain.Size{Cols: 80, Rows: 23}) | |||
| 159 | + | tb.mu.Lock() | |||
| 160 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p1.id}, Focus: p1.id} | |||
| 161 | + | tb.panes[p2.id] = p2 | |||
| 162 | + | tb.bumpLayoutGenerationLocked() | |||
| 163 | + | tb.mu.Unlock() | |||
| 164 | + | ||||
| 165 | + | done := make(chan struct{}) | |||
| 166 | + | go func() { d.applyTabLayout(sess, tb); close(done) }() | |||
| 167 | + | awaitSignal(t, entered, "old stack apply did not block") | |||
| 168 | + | tb.mu.Lock() | |||
| 169 | + | require.True(t, focusPlacementLocked(tb, p2.id)) | |||
| 170 | + | tb.mu.Unlock() | |||
| 171 | + | close(release) | |||
| 172 | + | awaitSignal(t, done, "stale stack apply did not retry") | |||
| 173 | + | ||||
| 174 | + | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p1.rect, "collapsed pane received stale geometry") | |||
| 175 | + | p1.mu.Lock() | |||
| 176 | + | require.False(t, p1.resizeApplying, "collapsed pane gate was not cancelled") | |||
| 177 | + | p1.mu.Unlock() | |||
| 178 | + | tb.mu.Lock() | |||
| 179 | + | placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |||
| 180 | + | tb.mu.Unlock() | |||
| 181 | + | require.True(t, ok) | |||
| 182 | + | require.Equal(t, placementContent(placements, p2.id), p2.rect) | |||
| 183 | + | require.Equal(t, []domain.Size{rectSize(p2.rect)}, second.requested()) | |||
| 184 | + | }) | |||
| 185 | + | ||||
| 186 | + | t.Run("client size rejects and retries a blocked plan", func(t *testing.T) { | |||
| 187 | + | pty, entered, release := newBlockedPTY() | |||
| 188 | + | d, sess, _, _ := newManualSessionWithPTYs(t, pty) | |||
| 189 | + | tb := sess.activeTab() | |||
| 190 | + | p := tb.focusedPane() | |||
| 191 | + | p.rect = domain.Rect{Width: 20, Height: 10} | |||
| 192 | + | ||||
| 193 | + | oldDone := make(chan struct{}) | |||
| 194 | + | go func() { d.applyTabLayout(sess, tb); close(oldDone) }() | |||
| 195 | + | awaitSignal(t, entered, "old size apply did not block") | |||
| 196 | + | resizeDone := make(chan bool, 1) | |||
| 197 | + | go func() { resizeDone <- d.requestTransactionalResize(sess, nil, domain.Size{Cols: 100, Rows: 30}, true) }() | |||
| 198 | + | require.Eventually(t, func() bool { | |||
| 199 | + | tb.mu.Lock() | |||
| 200 | + | defer tb.mu.Unlock() | |||
| 201 | + | return tb.size == (domain.Size{Cols: 100, Rows: 28}) && tb.layoutGeneration > 0 | |||
| 202 | + | }, time.Second, time.Millisecond) | |||
| 203 | + | require.Equal(t, domain.Rect{Width: 20, Height: 10}, p.rect, "stale size published before release") | |||
| 204 | + | close(release) | |||
| 205 | + | awaitSignal(t, oldDone, "old size apply did not finish") | |||
| 206 | + | require.True(t, <-resizeDone) | |||
| 207 | + | require.Equal(t, domain.Rect{Width: 100, Height: 28}, p.rect) | |||
| 208 | + | require.Equal(t, domain.Size{Cols: 100, Rows: 28}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}) | |||
| 209 | + | require.Equal(t, []domain.Size{{Cols: 80, Rows: 23}, {Cols: 100, Rows: 28}}, pty.requested()) | |||
| 210 | + | }) | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | func awaitSignal(t *testing.T, ch <-chan struct{}, message string) { | |||
| 214 | + | t.Helper() | |||
| 215 | + | select { | |||
| 216 | + | case <-ch: | |||
| 217 | + | case <-time.After(2 * time.Second): | |||
| 218 | + | t.Fatal(message) | |||
| 219 | + | } | |||
| 220 | + | } | |||
| 221 | + | ||||
| 22 | func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) { | 222 | func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) { | ||
| 23 | tests := []struct { | 223 | tests := []struct { | ||
| 24 | name string | 224 | name string | ||
| @@ -123,9 +323,7 @@ func TestApplyLayoutResizesPTYsAndScreens(t *testing.T) { | |||||
| 123 | oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once() | 323 | oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once() | ||
| 124 | newPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once() | 324 | newPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once() | ||
| 125 | 325 | ||||
| 126 | - | tb.mu.Lock() | 326 | + | d.applyTabLayout(sess, tb) |
| 127 | - | d.applyLayoutLocked(tb) | |||
| 128 | - | tb.mu.Unlock() | |||
| 129 | 327 | ||||
| 130 | require.Equal(t, 20, tb.panes["pane-1"].screen.Frame.Width) | 328 | require.Equal(t, 20, tb.panes["pane-1"].screen.Frame.Width) | ||
| 131 | require.Equal(t, 20, tb.panes["pane-2"].screen.Frame.Width) | 329 | require.Equal(t, 20, tb.panes["pane-2"].screen.Frame.Width) | ||
M internal/usecase/daemon/performance_benchmark_test.go +7 -3
| @@ -1110,7 +1110,6 @@ func (f *performanceFixture) close() error { | |||
| 1110 | 1110 | func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyRows int, size domain.Size) { | |
| 1111 | 1111 | f.t.Helper() | |
| 1112 | 1112 | tb.mu.Lock() | |
| 1113 | - | defer tb.mu.Unlock() | |
| 1114 | 1113 | tb.size = size | |
| 1115 | 1114 | left := tb.focusedPane() | |
| 1116 | 1115 | for paneIndex := 1; paneIndex < paneCount; paneIndex++ { | |
| @@ -1121,8 +1120,13 @@ func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyR | |||
| 1121 | 1120 | placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: size.Cols, Height: size.Rows}) | |
| 1122 | 1121 | require.True(f.t, ok, "performance fixture layout must be solvable") | |
| 1123 | 1122 | require.Len(f.t, placements, paneCount) | |
| 1124 | - | f.d.applyLayoutLocked(tb) | |
| 1125 | - | for _, p := range tb.panes { | |
| 1123 | + | tb.bumpLayoutGenerationLocked() | |
| 1124 | + | tb.mu.Unlock() | |
| 1125 | + | _, _ = f.d.applyTabLayoutTransaction(f.sess, tb) | |
| 1126 | + | tb.mu.Lock() | |
| 1127 | + | panes := tb.panesSnapshot() | |
| 1128 | + | tb.mu.Unlock() | |
| 1129 | + | for _, p := range panes { | |
| 1126 | 1130 | p.mu.Lock() | |
| 1127 | 1131 | width := p.screen.Frame.Width | |
| 1128 | 1132 | for row := 0; p.history.Len() < historyRows && row < historyRows+p.screen.Frame.Height; row++ { | |
| @@ -1110,7 +1110,6 @@ func (f *performanceFixture) close() error { | |||||
| 1110 | func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyRows int, size domain.Size) { | 1110 | func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyRows int, size domain.Size) { | ||
| 1111 | f.t.Helper() | 1111 | f.t.Helper() | ||
| 1112 | tb.mu.Lock() | 1112 | tb.mu.Lock() | ||
| 1113 | - | defer tb.mu.Unlock() | |||
| 1114 | tb.size = size | 1113 | tb.size = size | ||
| 1115 | left := tb.focusedPane() | 1114 | left := tb.focusedPane() | ||
| 1116 | for paneIndex := 1; paneIndex < paneCount; paneIndex++ { | 1115 | for paneIndex := 1; paneIndex < paneCount; paneIndex++ { | ||
| @@ -1121,8 +1120,13 @@ func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyR | |||||
| 1121 | placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: size.Cols, Height: size.Rows}) | 1120 | placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: size.Cols, Height: size.Rows}) | ||
| 1122 | require.True(f.t, ok, "performance fixture layout must be solvable") | 1121 | require.True(f.t, ok, "performance fixture layout must be solvable") | ||
| 1123 | require.Len(f.t, placements, paneCount) | 1122 | require.Len(f.t, placements, paneCount) | ||
| 1124 | - | f.d.applyLayoutLocked(tb) | 1123 | + | tb.bumpLayoutGenerationLocked() |
| 1125 | - | for _, p := range tb.panes { | 1124 | + | tb.mu.Unlock() |
| 1125 | + | _, _ = f.d.applyTabLayoutTransaction(f.sess, tb) | |||
| 1126 | + | tb.mu.Lock() | |||
| 1127 | + | panes := tb.panesSnapshot() | |||
| 1128 | + | tb.mu.Unlock() | |||
| 1129 | + | for _, p := range panes { | |||
| 1126 | p.mu.Lock() | 1130 | p.mu.Lock() | ||
| 1127 | width := p.screen.Frame.Width | 1131 | width := p.screen.Frame.Width | ||
| 1128 | for row := 0; p.history.Len() < historyRows && row < historyRows+p.screen.Frame.Height; row++ { | 1132 | for row := 0; p.history.Len() < historyRows && row < historyRows+p.screen.Frame.Height; row++ { | ||
M internal/usecase/daemon/resume_test.go +2 -1
| @@ -372,8 +372,9 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) { | |||
| 372 | 372 | tb.mu.Lock() | |
| 373 | 373 | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"} | |
| 374 | 374 | tb.panes[closed.id] = closed | |
| 375 | - | d.applyLayoutLocked(tb) | |
| 375 | + | tb.bumpLayoutGenerationLocked() | |
| 376 | 376 | tb.mu.Unlock() | |
| 377 | + | d.applyTabLayout(sess, tb) | |
| 377 | 378 | survivor.screen.Write([]byte("survivor")) | |
| 378 | 379 | closed.screen.Write([]byte("closed")) | |
| 379 | 380 | d.paint(sess, ac, true, nil) | |
| @@ -372,8 +372,9 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) { | |||||
| 372 | tb.mu.Lock() | 372 | tb.mu.Lock() | ||
| 373 | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"} | 373 | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"} | ||
| 374 | tb.panes[closed.id] = closed | 374 | tb.panes[closed.id] = closed | ||
| 375 | - | d.applyLayoutLocked(tb) | 375 | + | tb.bumpLayoutGenerationLocked() |
| 376 | tb.mu.Unlock() | 376 | tb.mu.Unlock() | ||
| 377 | + | d.applyTabLayout(sess, tb) | |||
| 377 | survivor.screen.Write([]byte("survivor")) | 378 | survivor.screen.Write([]byte("survivor")) | ||
| 378 | closed.screen.Write([]byte("closed")) | 379 | closed.screen.Write([]byte("closed")) | ||
| 379 | d.paint(sess, ac, true, nil) | 380 | d.paint(sess, ac, true, nil) | ||
M internal/usecase/daemon/session.go +9 -0
| @@ -122,6 +122,11 @@ type session struct { | |||
| 122 | 122 | type tab struct { | |
| 123 | 123 | mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, and pane map membership | |
| 124 | 124 | ||
| 125 | + | // layoutGeneration invalidates prepared geometry whenever live tab layout | |
| 126 | + | // state changes. layoutApplyMu serializes the lock-free PTY apply boundary. | |
| 127 | + | layoutGeneration uint64 | |
| 128 | + | layoutApplyMu sync.Mutex | |
| 129 | + | ||
| 125 | 130 | stableID string | |
| 126 | 131 | name string | |
| 127 | 132 | tree *layout.Tree | |
| @@ -435,6 +440,10 @@ func newTabWithStableID(tabStableID, paneStableID string, pty ports.PTY, sz doma | |||
| 435 | 440 | } | |
| 436 | 441 | } | |
| 437 | 442 | ||
| 443 | + | func (tb *tab) bumpLayoutGenerationLocked() { | |
| 444 | + | tb.layoutGeneration++ | |
| 445 | + | } | |
| 446 | + | ||
| 438 | 447 | func (tb *tab) focusedPane() *pane { | |
| 439 | 448 | if tb == nil || tb.tree == nil || tb.panes == nil { | |
| 440 | 449 | return nil | |
| @@ -122,6 +122,11 @@ type session struct { | |||||
| 122 | type tab struct { | 122 | type tab struct { | ||
| 123 | mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, and pane map membership | 123 | mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, and pane map membership | ||
| 124 | 124 | ||||
| 125 | + | // layoutGeneration invalidates prepared geometry whenever live tab layout | |||
| 126 | + | // state changes. layoutApplyMu serializes the lock-free PTY apply boundary. | |||
| 127 | + | layoutGeneration uint64 | |||
| 128 | + | layoutApplyMu sync.Mutex | |||
| 129 | + | ||||
| 125 | stableID string | 130 | stableID string | ||
| 126 | name string | 131 | name string | ||
| 127 | tree *layout.Tree | 132 | tree *layout.Tree | ||
| @@ -435,6 +440,10 @@ func newTabWithStableID(tabStableID, paneStableID string, pty ports.PTY, sz doma | |||||
| 435 | } | 440 | } | ||
| 436 | } | 441 | } | ||
| 437 | 442 | ||||
| 443 | + | func (tb *tab) bumpLayoutGenerationLocked() { | |||
| 444 | + | tb.layoutGeneration++ | |||
| 445 | + | } | |||
| 446 | + | ||||
| 438 | func (tb *tab) focusedPane() *pane { | 447 | func (tb *tab) focusedPane() *pane { | ||
| 439 | if tb == nil || tb.tree == nil || tb.panes == nil { | 448 | if tb == nil || tb.tree == nil || tb.panes == nil { | ||
| 440 | return nil | 449 | return nil | ||
M internal/usecase/daemon/transactional_resize.go +300 -42
| @@ -17,6 +17,7 @@ type resizeMember struct { | |||
| 17 | 17 | isFloating bool | |
| 18 | 18 | retry bool | |
| 19 | 19 | ok bool | |
| 20 | + | err error | |
| 20 | 21 | screenResized bool | |
| 21 | 22 | } | |
| 22 | 23 | ||
| @@ -26,6 +27,207 @@ type resizePlan struct { | |||
| 26 | 27 | members []resizeMember | |
| 27 | 28 | } | |
| 28 | 29 | ||
| 30 | + | type preparedTabLayout struct { | |
| 31 | + | generation uint64 | |
| 32 | + | size domain.Size | |
| 33 | + | members []resizeMember | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func prepareTabLayoutLocked(sess *session, tb *tab) preparedTabLayout { | |
| 37 | + | plan := preparedTabLayout{generation: tb.layoutGeneration, size: tb.size} | |
| 38 | + | if tb.tree == nil || tb.tree.Root == nil || !tb.size.Valid() { | |
| 39 | + | return plan | |
| 40 | + | } | |
| 41 | + | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | |
| 42 | + | placements, ok := layout.Solve(tb.tree.Root, area) | |
| 43 | + | if !ok && tb.tree.Root.Kind == layout.Leaf { | |
| 44 | + | placements = []layout.Placement{{ID: tb.tree.Root.Leaf, Content: area}} | |
| 45 | + | ok = true | |
| 46 | + | } | |
| 47 | + | if !ok { | |
| 48 | + | return plan | |
| 49 | + | } | |
| 50 | + | for _, placement := range placements { | |
| 51 | + | if placement.Collapsed || placement.Content.Width <= 0 || placement.Content.Height <= 0 { | |
| 52 | + | continue | |
| 53 | + | } | |
| 54 | + | if p := tb.panes[placement.ID]; p != nil { | |
| 55 | + | plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: p, rect: placement.Content}) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | return plan | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // applyPreparedTabMembers performs only the external PTY phase. A successful | |
| 62 | + | // resize leaves the parser gate closed until the complete plan is validated. | |
| 63 | + | func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) { | |
| 64 | + | for i := range plan.members { | |
| 65 | + | member := &plan.members[i] | |
| 66 | + | p := member.pane | |
| 67 | + | p.resizeMu.Lock() | |
| 68 | + | p.mu.Lock() | |
| 69 | + | old := p.rect | |
| 70 | + | pty := p.pty | |
| 71 | + | // A rejected earlier attempt deliberately leaves its parser gate open. | |
| 72 | + | // Reapply even if the committed rectangle already matches: the PTY may | |
| 73 | + | // still be at that rejected attempt's external size. | |
| 74 | + | needsApply := p.resizeApplying || old.Width != member.rect.Width || old.Height != member.rect.Height | |
| 75 | + | if needsApply && pty != nil { | |
| 76 | + | p.resizeApplying = true | |
| 77 | + | } | |
| 78 | + | p.mu.Unlock() | |
| 79 | + | if !needsApply || pty == nil { | |
| 80 | + | member.ok = true | |
| 81 | + | } else if err := pty.Resize(rectSize(member.rect)); err != nil { | |
| 82 | + | d.log.Warn("pty resize failed", "err", err) | |
| 83 | + | d.replayResizePending(member.session, member.tab, p, false, member.rect) | |
| 84 | + | member.retry = true | |
| 85 | + | member.err = err | |
| 86 | + | } else { | |
| 87 | + | member.ok = true | |
| 88 | + | member.screenResized = true // records that this member owns an open gate | |
| 89 | + | } | |
| 90 | + | p.resizeMu.Unlock() | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func validatePreparedTabLayoutLocked(tb *tab, plan *preparedTabLayout) bool { | |
| 95 | + | if tb.layoutGeneration != plan.generation || tb.size != plan.size { | |
| 96 | + | return false | |
| 97 | + | } | |
| 98 | + | for i := range plan.members { | |
| 99 | + | member := &plan.members[i] | |
| 100 | + | if tb.panes[member.pane.id] != member.pane { | |
| 101 | + | return false | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | return true | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func commitPreparedTabLayoutLocked(plan *preparedTabLayout) { | |
| 108 | + | for i := range plan.members { | |
| 109 | + | member := &plan.members[i] | |
| 110 | + | member.pane.mu.Lock() | |
| 111 | + | member.pane.rect = member.rect | |
| 112 | + | if member.ok { | |
| 113 | + | member.pane.screen.Resize(member.rect.Width, member.rect.Height) | |
| 114 | + | } | |
| 115 | + | member.pane.mu.Unlock() | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // cancelStalePreparedGates releases gates for members which disappeared from | |
| 120 | + | // the next solved layout (for example a stack member collapsed by a focus | |
| 121 | + | // change). Members that remain are deliberately left gated for the retry. | |
| 122 | + | func (d *Daemon) cancelStalePreparedGates(sess *session, tb *tab, plan *preparedTabLayout) { | |
| 123 | + | tb.mu.Lock() | |
| 124 | + | latest := prepareTabLayoutLocked(sess, tb) | |
| 125 | + | tb.mu.Unlock() | |
| 126 | + | current := make(map[*pane]struct{}, len(latest.members)) | |
| 127 | + | for i := range latest.members { | |
| 128 | + | current[latest.members[i].pane] = struct{}{} | |
| 129 | + | } | |
| 130 | + | for i := range plan.members { | |
| 131 | + | member := &plan.members[i] | |
| 132 | + | if !member.screenResized { | |
| 133 | + | continue | |
| 134 | + | } | |
| 135 | + | if _, ok := current[member.pane]; ok { | |
| 136 | + | continue | |
| 137 | + | } | |
| 138 | + | member.pane.resizeMu.Lock() | |
| 139 | + | d.replayResizePending(member.session, member.tab, member.pane, false, member.rect) | |
| 140 | + | member.pane.resizeMu.Unlock() | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func (d *Daemon) finishPreparedTabMembers(plan *preparedTabLayout, accepted bool) { | |
| 145 | + | for i := range plan.members { | |
| 146 | + | member := &plan.members[i] | |
| 147 | + | if !member.screenResized { | |
| 148 | + | continue | |
| 149 | + | } | |
| 150 | + | if !accepted { | |
| 151 | + | member.tab.mu.Lock() | |
| 152 | + | current := member.tab.panes[member.pane.id] == member.pane | |
| 153 | + | member.tab.mu.Unlock() | |
| 154 | + | if current { | |
| 155 | + | continue | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | member.pane.resizeMu.Lock() | |
| 159 | + | d.replayResizePending(member.session, member.tab, member.pane, false, member.rect) | |
| 160 | + | member.pane.resizeMu.Unlock() | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // applyTabLayoutTransaction is the canonical tiled-layout publisher. It owns | |
| 165 | + | // one per-tab single-writer loop, but never holds tab or pane state locks while | |
| 166 | + | // invoking PTY.Resize. | |
| 167 | + | func (d *Daemon) applyTabLayoutTransaction(sess *session, tb *tab, current ...func() bool) ([]resizeMember, bool) { | |
| 168 | + | if tb == nil { | |
| 169 | + | return nil, false | |
| 170 | + | } | |
| 171 | + | tb.layoutApplyMu.Lock() | |
| 172 | + | defer tb.layoutApplyMu.Unlock() | |
| 173 | + | for { | |
| 174 | + | if tb.ctx != nil && tb.ctx.Err() != nil { | |
| 175 | + | return nil, false | |
| 176 | + | } | |
| 177 | + | if len(current) != 0 && !current[0]() { | |
| 178 | + | return nil, false | |
| 179 | + | } | |
| 180 | + | tb.mu.Lock() | |
| 181 | + | plan := prepareTabLayoutLocked(sess, tb) | |
| 182 | + | tb.mu.Unlock() | |
| 183 | + | d.applyPreparedTabMembers(&plan) | |
| 184 | + | ||
| 185 | + | tb.mu.Lock() | |
| 186 | + | accepted := validatePreparedTabLayoutLocked(tb, &plan) | |
| 187 | + | if len(current) != 0 && !current[0]() { | |
| 188 | + | accepted = false | |
| 189 | + | } | |
| 190 | + | if accepted { | |
| 191 | + | commitPreparedTabLayoutLocked(&plan) | |
| 192 | + | } | |
| 193 | + | tb.mu.Unlock() | |
| 194 | + | d.finishPreparedTabMembers(&plan, accepted) | |
| 195 | + | if !accepted { | |
| 196 | + | d.cancelStalePreparedGates(sess, tb, &plan) | |
| 197 | + | if len(current) != 0 && !current[0]() { | |
| 198 | + | for i := range plan.members { | |
| 199 | + | member := &plan.members[i] | |
| 200 | + | if !member.screenResized { | |
| 201 | + | continue | |
| 202 | + | } | |
| 203 | + | member.pane.resizeMu.Lock() | |
| 204 | + | d.replayResizePending(member.session, member.tab, member.pane, false, member.rect) | |
| 205 | + | member.pane.resizeMu.Unlock() | |
| 206 | + | } | |
| 207 | + | return nil, false | |
| 208 | + | } | |
| 209 | + | continue | |
| 210 | + | } | |
| 211 | + | failed := make([]resizeMember, 0) | |
| 212 | + | for _, member := range plan.members { | |
| 213 | + | if !member.ok { | |
| 214 | + | failed = append(failed, member) | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | if len(failed) != 0 { | |
| 218 | + | d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed, | |
| 219 | + | "pane resize failed; retrying in background", failed[len(failed)-1].err) | |
| 220 | + | } | |
| 221 | + | return failed, true | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func (d *Daemon) applyTabLayout(sess *session, tb *tab) { | |
| 226 | + | if _, ok := d.applyTabLayoutTransaction(sess, tb); ok { | |
| 227 | + | markSnapshotDirty(sess) | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | ||
| 29 | 231 | func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan { | |
| 30 | 232 | plan := resizePlan{size: size} | |
| 31 | 233 | sess.mu.Lock() | |
| @@ -150,36 +352,103 @@ func (d *Daemon) replayResizePending(sess *session, tb *tab, p *pane, resized bo | |||
| 150 | 352 | } | |
| 151 | 353 | } | |
| 152 | 354 | ||
| 153 | - | func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan) { | |
| 154 | - | // Publish tab layout and every rectangle together only after the coordinator | |
| 155 | - | // has rejected obsolete work. A later request may have applied already, but | |
| 156 | - | // it can never publish through this epoch. | |
| 157 | - | for _, tb := range plan.tabs { | |
| 158 | - | tb.mu.Lock() | |
| 159 | - | tb.size = tabSize(plan.size) | |
| 355 | + | // applyVisibleFloatingLayout retains floating panes' existing independent | |
| 356 | + | // lifecycle while routing their external resize through the same lock-free gate | |
| 357 | + | // used for retryable PTY work. Floating state is not part of a tiled layout | |
| 358 | + | // generation, so its slot generation and exact pane identity validate its | |
| 359 | + | // publication instead. | |
| 360 | + | func (d *Daemon) applyVisibleFloatingLayout(sess *session, tb *tab, current func() bool) ([]resizeMember, bool) { | |
| 361 | + | if tb == nil { | |
| 362 | + | return nil, true | |
| 363 | + | } | |
| 364 | + | tb.mu.Lock() | |
| 365 | + | if tb.floating.state != floatingVisible || tb.floating.pane == nil { | |
| 160 | 366 | tb.mu.Unlock() | |
| 367 | + | return nil, true | |
| 161 | 368 | } | |
| 162 | - | for _, m := range plan.members { | |
| 163 | - | // Layout geometry always publishes together. Failed members deliberately | |
| 164 | - | // retain their old VT screen; capture clips that screen to this new rect | |
| 165 | - | // and pads the uncovered cells with normal blanks. | |
| 166 | - | m.pane.mu.Lock() | |
| 167 | - | m.pane.rect = m.rect | |
| 168 | - | if m.isFloating { | |
| 169 | - | m.pane.popupGeometry = m.floating | |
| 369 | + | p := tb.floating.pane | |
| 370 | + | generation := tb.floating.generation | |
| 371 | + | geometry := calculateContentFloatingGeometry(tb.size, d.currentFloatingConfig()) | |
| 372 | + | tb.mu.Unlock() | |
| 373 | + | if !geometry.valid() { | |
| 374 | + | return nil, true | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | plan := resizePlan{members: []resizeMember{{ | |
| 378 | + | session: sess, tab: tb, pane: p, rect: geometry.Inner, floating: geometry, isFloating: true, | |
| 379 | + | }}} | |
| 380 | + | if current == nil { | |
| 381 | + | if !d.applyResize(&plan) { | |
| 382 | + | return nil, false | |
| 383 | + | } | |
| 384 | + | } else if !d.applyResize(&plan, current) { | |
| 385 | + | return nil, false | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | // The PTY may have accepted an intermediate size, but a hidden, replaced, | |
| 389 | + | // or relaunched slot must never receive this attempt's geometry. | |
| 390 | + | tb.mu.Lock() | |
| 391 | + | currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && tb.floating.pane == p | |
| 392 | + | if currentSlot { | |
| 393 | + | p.mu.Lock() | |
| 394 | + | p.rect = geometry.Inner | |
| 395 | + | p.popupGeometry = geometry | |
| 396 | + | if plan.members[0].ok && !plan.members[0].screenResized { | |
| 397 | + | p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height) | |
| 398 | + | } | |
| 399 | + | p.mu.Unlock() | |
| 400 | + | } | |
| 401 | + | tb.mu.Unlock() | |
| 402 | + | if !currentSlot { | |
| 403 | + | return nil, true | |
| 404 | + | } | |
| 405 | + | if plan.members[0].ok { | |
| 406 | + | return nil, true | |
| 407 | + | } | |
| 408 | + | return plan.members, true | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current func() bool) ([]resizeMember, bool) { | |
| 412 | + | sess.mu.Lock() | |
| 413 | + | tabs := append([]*tab(nil), sess.tabs...) | |
| 414 | + | active := sess.active | |
| 415 | + | sess.mu.Unlock() | |
| 416 | + | target := tabSize(size) | |
| 417 | + | for _, tb := range tabs { | |
| 418 | + | if current != nil && !current() { | |
| 419 | + | return nil, false | |
| 170 | 420 | } | |
| 171 | - | if m.ok && !m.screenResized { | |
| 172 | - | m.pane.screen.Resize(m.rect.Width, m.rect.Height) | |
| 421 | + | tb.mu.Lock() | |
| 422 | + | if tb.size != target { | |
| 423 | + | tb.size = target | |
| 424 | + | tb.bumpLayoutGenerationLocked() | |
| 173 | 425 | } | |
| 174 | - | m.pane.mu.Unlock() | |
| 426 | + | tb.mu.Unlock() | |
| 175 | 427 | } | |
| 176 | - | if ac != nil { | |
| 177 | - | ac.sendMu.Lock() | |
| 178 | - | ac.size = plan.size | |
| 179 | - | ac.sendMu.Unlock() | |
| 428 | + | failed := make([]resizeMember, 0) | |
| 429 | + | for i, tb := range tabs { | |
| 430 | + | var members []resizeMember | |
| 431 | + | var ok bool | |
| 432 | + | if current == nil { | |
| 433 | + | members, ok = d.applyTabLayoutTransaction(sess, tb) | |
| 434 | + | } else { | |
| 435 | + | members, ok = d.applyTabLayoutTransaction(sess, tb, current) | |
| 436 | + | } | |
| 437 | + | if !ok { | |
| 438 | + | return nil, false | |
| 439 | + | } | |
| 440 | + | failed = append(failed, members...) | |
| 441 | + | if i == active { | |
| 442 | + | members, ok = d.applyVisibleFloatingLayout(sess, tb, current) | |
| 443 | + | if !ok { | |
| 444 | + | return nil, false | |
| 445 | + | } | |
| 446 | + | failed = append(failed, members...) | |
| 447 | + | } | |
| 180 | 448 | } | |
| 181 | 449 | markSnapshotDirty(sess) | |
| 182 | 450 | d.observeRuntime(ports.RuntimeResizeCommitted, 0, true) | |
| 451 | + | return failed, true | |
| 183 | 452 | } | |
| 184 | 453 | ||
| 185 | 454 | func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool { | |
| @@ -188,27 +457,18 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease * | |||
| 188 | 457 | return false | |
| 189 | 458 | } | |
| 190 | 459 | snap := rc.resizeSnapshot() | |
| 191 | - | if !rc.resizeCurrentForLease(epoch, ac, lease, false) { | |
| 460 | + | current := func() bool { return rc.resizeCurrentForLease(epoch, ac, lease, false) } | |
| 461 | + | if !current() { | |
| 192 | 462 | return false | |
| 193 | 463 | } | |
| 194 | 464 | d.exitCopyMode(ac) | |
| 195 | - | plan := d.prepareResize(sess, snap.size) | |
| 196 | - | if !rc.resizeCurrentForLease(epoch, ac, lease, false) { | |
| 197 | - | return false | |
| 198 | - | } | |
| 199 | - | if !d.applyResize(&plan, func() bool { return rc.resizeCurrentForLease(epoch, ac, lease, false) }) { | |
| 465 | + | failed, ok := d.applySessionLayout(sess, snap.size, current) | |
| 466 | + | if !ok || !rc.resizeCurrentForLease(epoch, ac, lease, true) { | |
| 200 | 467 | return false | |
| 201 | 468 | } | |
| 202 | - | if !rc.resizeCurrentForLease(epoch, ac, lease, true) { | |
| 203 | - | return false | |
| 204 | - | } | |
| 205 | - | d.commitResize(sess, ac, plan) | |
| 206 | - | failed := make([]resizeMember, 0, len(plan.members)) | |
| 207 | - | for _, member := range plan.members { | |
| 208 | - | if !member.ok { | |
| 209 | - | failed = append(failed, member) | |
| 210 | - | } | |
| 211 | - | } | |
| 469 | + | ac.sendMu.Lock() | |
| 470 | + | ac.size = snap.size | |
| 471 | + | ac.sendMu.Unlock() | |
| 212 | 472 | if len(failed) != 0 { | |
| 213 | 473 | rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) }) | |
| 214 | 474 | } | |
| @@ -289,10 +549,8 @@ func (d *Daemon) requestTransactionalResizeForLease(sess *session, ac *attachedC | |||
| 289 | 549 | if ac == nil { | |
| 290 | 550 | // Headless geometry has no coordinator/transport to coalesce, but keeps | |
| 291 | 551 | // the same prepare/apply/commit ordering. | |
| 292 | - | plan := d.prepareResize(sess, size) | |
| 293 | - | d.applyResize(&plan) | |
| 294 | - | d.commitResize(sess, nil, plan) | |
| 295 | - | return true | |
| 552 | + | _, ok := d.applySessionLayout(sess, size, nil) | |
| 553 | + | return ok | |
| 296 | 554 | } | |
| 297 | 555 | rc := sess.renderCoordinator() | |
| 298 | 556 | if rc == nil || !rc.leaseCurrent(lease, true) { | |
| @@ -17,6 +17,7 @@ type resizeMember struct { | |||||
| 17 | isFloating bool | 17 | isFloating bool | ||
| 18 | retry bool | 18 | retry bool | ||
| 19 | ok bool | 19 | ok bool | ||
| 20 | + | err error | |||
| 20 | screenResized bool | 21 | screenResized bool | ||
| 21 | } | 22 | } | ||
| 22 | 23 | ||||
| @@ -26,6 +27,207 @@ type resizePlan struct { | |||||
| 26 | members []resizeMember | 27 | members []resizeMember | ||
| 27 | } | 28 | } | ||
| 28 | 29 | ||||
| 30 | + | type preparedTabLayout struct { | |||
| 31 | + | generation uint64 | |||
| 32 | + | size domain.Size | |||
| 33 | + | members []resizeMember | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func prepareTabLayoutLocked(sess *session, tb *tab) preparedTabLayout { | |||
| 37 | + | plan := preparedTabLayout{generation: tb.layoutGeneration, size: tb.size} | |||
| 38 | + | if tb.tree == nil || tb.tree.Root == nil || !tb.size.Valid() { | |||
| 39 | + | return plan | |||
| 40 | + | } | |||
| 41 | + | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | |||
| 42 | + | placements, ok := layout.Solve(tb.tree.Root, area) | |||
| 43 | + | if !ok && tb.tree.Root.Kind == layout.Leaf { | |||
| 44 | + | placements = []layout.Placement{{ID: tb.tree.Root.Leaf, Content: area}} | |||
| 45 | + | ok = true | |||
| 46 | + | } | |||
| 47 | + | if !ok { | |||
| 48 | + | return plan | |||
| 49 | + | } | |||
| 50 | + | for _, placement := range placements { | |||
| 51 | + | if placement.Collapsed || placement.Content.Width <= 0 || placement.Content.Height <= 0 { | |||
| 52 | + | continue | |||
| 53 | + | } | |||
| 54 | + | if p := tb.panes[placement.ID]; p != nil { | |||
| 55 | + | plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: p, rect: placement.Content}) | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | return plan | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // applyPreparedTabMembers performs only the external PTY phase. A successful | |||
| 62 | + | // resize leaves the parser gate closed until the complete plan is validated. | |||
| 63 | + | func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) { | |||
| 64 | + | for i := range plan.members { | |||
| 65 | + | member := &plan.members[i] | |||
| 66 | + | p := member.pane | |||
| 67 | + | p.resizeMu.Lock() | |||
| 68 | + | p.mu.Lock() | |||
| 69 | + | old := p.rect | |||
| 70 | + | pty := p.pty | |||
| 71 | + | // A rejected earlier attempt deliberately leaves its parser gate open. | |||
| 72 | + | // Reapply even if the committed rectangle already matches: the PTY may | |||
| 73 | + | // still be at that rejected attempt's external size. | |||
| 74 | + | needsApply := p.resizeApplying || old.Width != member.rect.Width || old.Height != member.rect.Height | |||
| 75 | + | if needsApply && pty != nil { | |||
| 76 | + | p.resizeApplying = true | |||
| 77 | + | } | |||
| 78 | + | p.mu.Unlock() | |||
| 79 | + | if !needsApply || pty == nil { | |||
| 80 | + | member.ok = true | |||
| 81 | + | } else if err := pty.Resize(rectSize(member.rect)); err != nil { | |||
| 82 | + | d.log.Warn("pty resize failed", "err", err) | |||
| 83 | + | d.replayResizePending(member.session, member.tab, p, false, member.rect) | |||
| 84 | + | member.retry = true | |||
| 85 | + | member.err = err | |||
| 86 | + | } else { | |||
| 87 | + | member.ok = true | |||
| 88 | + | member.screenResized = true // records that this member owns an open gate | |||
| 89 | + | } | |||
| 90 | + | p.resizeMu.Unlock() | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func validatePreparedTabLayoutLocked(tb *tab, plan *preparedTabLayout) bool { | |||
| 95 | + | if tb.layoutGeneration != plan.generation || tb.size != plan.size { | |||
| 96 | + | return false | |||
| 97 | + | } | |||
| 98 | + | for i := range plan.members { | |||
| 99 | + | member := &plan.members[i] | |||
| 100 | + | if tb.panes[member.pane.id] != member.pane { | |||
| 101 | + | return false | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | return true | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func commitPreparedTabLayoutLocked(plan *preparedTabLayout) { | |||
| 108 | + | for i := range plan.members { | |||
| 109 | + | member := &plan.members[i] | |||
| 110 | + | member.pane.mu.Lock() | |||
| 111 | + | member.pane.rect = member.rect | |||
| 112 | + | if member.ok { | |||
| 113 | + | member.pane.screen.Resize(member.rect.Width, member.rect.Height) | |||
| 114 | + | } | |||
| 115 | + | member.pane.mu.Unlock() | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // cancelStalePreparedGates releases gates for members which disappeared from | |||
| 120 | + | // the next solved layout (for example a stack member collapsed by a focus | |||
| 121 | + | // change). Members that remain are deliberately left gated for the retry. | |||
| 122 | + | func (d *Daemon) cancelStalePreparedGates(sess *session, tb *tab, plan *preparedTabLayout) { | |||
| 123 | + | tb.mu.Lock() | |||
| 124 | + | latest := prepareTabLayoutLocked(sess, tb) | |||
| 125 | + | tb.mu.Unlock() | |||
| 126 | + | current := make(map[*pane]struct{}, len(latest.members)) | |||
| 127 | + | for i := range latest.members { | |||
| 128 | + | current[latest.members[i].pane] = struct{}{} | |||
| 129 | + | } | |||
| 130 | + | for i := range plan.members { | |||
| 131 | + | member := &plan.members[i] | |||
| 132 | + | if !member.screenResized { | |||
| 133 | + | continue | |||
| 134 | + | } | |||
| 135 | + | if _, ok := current[member.pane]; ok { | |||
| 136 | + | continue | |||
| 137 | + | } | |||
| 138 | + | member.pane.resizeMu.Lock() | |||
| 139 | + | d.replayResizePending(member.session, member.tab, member.pane, false, member.rect) | |||
| 140 | + | member.pane.resizeMu.Unlock() | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func (d *Daemon) finishPreparedTabMembers(plan *preparedTabLayout, accepted bool) { | |||
| 145 | + | for i := range plan.members { | |||
| 146 | + | member := &plan.members[i] | |||
| 147 | + | if !member.screenResized { | |||
| 148 | + | continue | |||
| 149 | + | } | |||
| 150 | + | if !accepted { | |||
| 151 | + | member.tab.mu.Lock() | |||
| 152 | + | current := member.tab.panes[member.pane.id] == member.pane | |||
| 153 | + | member.tab.mu.Unlock() | |||
| 154 | + | if current { | |||
| 155 | + | continue | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | member.pane.resizeMu.Lock() | |||
| 159 | + | d.replayResizePending(member.session, member.tab, member.pane, false, member.rect) | |||
| 160 | + | member.pane.resizeMu.Unlock() | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // applyTabLayoutTransaction is the canonical tiled-layout publisher. It owns | |||
| 165 | + | // one per-tab single-writer loop, but never holds tab or pane state locks while | |||
| 166 | + | // invoking PTY.Resize. | |||
| 167 | + | func (d *Daemon) applyTabLayoutTransaction(sess *session, tb *tab, current ...func() bool) ([]resizeMember, bool) { | |||
| 168 | + | if tb == nil { | |||
| 169 | + | return nil, false | |||
| 170 | + | } | |||
| 171 | + | tb.layoutApplyMu.Lock() | |||
| 172 | + | defer tb.layoutApplyMu.Unlock() | |||
| 173 | + | for { | |||
| 174 | + | if tb.ctx != nil && tb.ctx.Err() != nil { | |||
| 175 | + | return nil, false | |||
| 176 | + | } | |||
| 177 | + | if len(current) != 0 && !current[0]() { | |||
| 178 | + | return nil, false | |||
| 179 | + | } | |||
| 180 | + | tb.mu.Lock() | |||
| 181 | + | plan := prepareTabLayoutLocked(sess, tb) | |||
| 182 | + | tb.mu.Unlock() | |||
| 183 | + | d.applyPreparedTabMembers(&plan) | |||
| 184 | + | ||||
| 185 | + | tb.mu.Lock() | |||
| 186 | + | accepted := validatePreparedTabLayoutLocked(tb, &plan) | |||
| 187 | + | if len(current) != 0 && !current[0]() { | |||
| 188 | + | accepted = false | |||
| 189 | + | } | |||
| 190 | + | if accepted { | |||
| 191 | + | commitPreparedTabLayoutLocked(&plan) | |||
| 192 | + | } | |||
| 193 | + | tb.mu.Unlock() | |||
| 194 | + | d.finishPreparedTabMembers(&plan, accepted) | |||
| 195 | + | if !accepted { | |||
| 196 | + | d.cancelStalePreparedGates(sess, tb, &plan) | |||
| 197 | + | if len(current) != 0 && !current[0]() { | |||
| 198 | + | for i := range plan.members { | |||
| 199 | + | member := &plan.members[i] | |||
| 200 | + | if !member.screenResized { | |||
| 201 | + | continue | |||
| 202 | + | } | |||
| 203 | + | member.pane.resizeMu.Lock() | |||
| 204 | + | d.replayResizePending(member.session, member.tab, member.pane, false, member.rect) | |||
| 205 | + | member.pane.resizeMu.Unlock() | |||
| 206 | + | } | |||
| 207 | + | return nil, false | |||
| 208 | + | } | |||
| 209 | + | continue | |||
| 210 | + | } | |||
| 211 | + | failed := make([]resizeMember, 0) | |||
| 212 | + | for _, member := range plan.members { | |||
| 213 | + | if !member.ok { | |||
| 214 | + | failed = append(failed, member) | |||
| 215 | + | } | |||
| 216 | + | } | |||
| 217 | + | if len(failed) != 0 { | |||
| 218 | + | d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed, | |||
| 219 | + | "pane resize failed; retrying in background", failed[len(failed)-1].err) | |||
| 220 | + | } | |||
| 221 | + | return failed, true | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | func (d *Daemon) applyTabLayout(sess *session, tb *tab) { | |||
| 226 | + | if _, ok := d.applyTabLayoutTransaction(sess, tb); ok { | |||
| 227 | + | markSnapshotDirty(sess) | |||
| 228 | + | } | |||
| 229 | + | } | |||
| 230 | + | ||||
| 29 | func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan { | 231 | func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan { | ||
| 30 | plan := resizePlan{size: size} | 232 | plan := resizePlan{size: size} | ||
| 31 | sess.mu.Lock() | 233 | sess.mu.Lock() | ||
| @@ -150,36 +352,103 @@ func (d *Daemon) replayResizePending(sess *session, tb *tab, p *pane, resized bo | |||||
| 150 | } | 352 | } | ||
| 151 | } | 353 | } | ||
| 152 | 354 | ||||
| 153 | - | func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan) { | 355 | + | // applyVisibleFloatingLayout retains floating panes' existing independent |
| 154 | - | // Publish tab layout and every rectangle together only after the coordinator | 356 | + | // lifecycle while routing their external resize through the same lock-free gate |
| 155 | - | // has rejected obsolete work. A later request may have applied already, but | 357 | + | // used for retryable PTY work. Floating state is not part of a tiled layout |
| 156 | - | // it can never publish through this epoch. | 358 | + | // generation, so its slot generation and exact pane identity validate its |
| 157 | - | for _, tb := range plan.tabs { | 359 | + | // publication instead. |
| 158 | - | tb.mu.Lock() | 360 | + | func (d *Daemon) applyVisibleFloatingLayout(sess *session, tb *tab, current func() bool) ([]resizeMember, bool) { |
| 159 | - | tb.size = tabSize(plan.size) | 361 | + | if tb == nil { |
| 362 | + | return nil, true | |||
| 363 | + | } | |||
| 364 | + | tb.mu.Lock() | |||
| 365 | + | if tb.floating.state != floatingVisible || tb.floating.pane == nil { | |||
| 160 | tb.mu.Unlock() | 366 | tb.mu.Unlock() | ||
| 367 | + | return nil, true | |||
| 161 | } | 368 | } | ||
| 162 | - | for _, m := range plan.members { | 369 | + | p := tb.floating.pane |
| 163 | - | // Layout geometry always publishes together. Failed members deliberately | 370 | + | generation := tb.floating.generation |
| 164 | - | // retain their old VT screen; capture clips that screen to this new rect | 371 | + | geometry := calculateContentFloatingGeometry(tb.size, d.currentFloatingConfig()) |
| 165 | - | // and pads the uncovered cells with normal blanks. | 372 | + | tb.mu.Unlock() |
| 166 | - | m.pane.mu.Lock() | 373 | + | if !geometry.valid() { |
| 167 | - | m.pane.rect = m.rect | 374 | + | return nil, true |
| 168 | - | if m.isFloating { | 375 | + | } |
| 169 | - | m.pane.popupGeometry = m.floating | 376 | + | |
| 377 | + | plan := resizePlan{members: []resizeMember{{ | |||
| 378 | + | session: sess, tab: tb, pane: p, rect: geometry.Inner, floating: geometry, isFloating: true, | |||
| 379 | + | }}} | |||
| 380 | + | if current == nil { | |||
| 381 | + | if !d.applyResize(&plan) { | |||
| 382 | + | return nil, false | |||
| 383 | + | } | |||
| 384 | + | } else if !d.applyResize(&plan, current) { | |||
| 385 | + | return nil, false | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | // The PTY may have accepted an intermediate size, but a hidden, replaced, | |||
| 389 | + | // or relaunched slot must never receive this attempt's geometry. | |||
| 390 | + | tb.mu.Lock() | |||
| 391 | + | currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && tb.floating.pane == p | |||
| 392 | + | if currentSlot { | |||
| 393 | + | p.mu.Lock() | |||
| 394 | + | p.rect = geometry.Inner | |||
| 395 | + | p.popupGeometry = geometry | |||
| 396 | + | if plan.members[0].ok && !plan.members[0].screenResized { | |||
| 397 | + | p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height) | |||
| 398 | + | } | |||
| 399 | + | p.mu.Unlock() | |||
| 400 | + | } | |||
| 401 | + | tb.mu.Unlock() | |||
| 402 | + | if !currentSlot { | |||
| 403 | + | return nil, true | |||
| 404 | + | } | |||
| 405 | + | if plan.members[0].ok { | |||
| 406 | + | return nil, true | |||
| 407 | + | } | |||
| 408 | + | return plan.members, true | |||
| 409 | + | } | |||
| 410 | + | ||||
| 411 | + | func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current func() bool) ([]resizeMember, bool) { | |||
| 412 | + | sess.mu.Lock() | |||
| 413 | + | tabs := append([]*tab(nil), sess.tabs...) | |||
| 414 | + | active := sess.active | |||
| 415 | + | sess.mu.Unlock() | |||
| 416 | + | target := tabSize(size) | |||
| 417 | + | for _, tb := range tabs { | |||
| 418 | + | if current != nil && !current() { | |||
| 419 | + | return nil, false | |||
| 170 | } | 420 | } | ||
| 171 | - | if m.ok && !m.screenResized { | 421 | + | tb.mu.Lock() |
| 172 | - | m.pane.screen.Resize(m.rect.Width, m.rect.Height) | 422 | + | if tb.size != target { |
| 423 | + | tb.size = target | |||
| 424 | + | tb.bumpLayoutGenerationLocked() | |||
| 173 | } | 425 | } | ||
| 174 | - | m.pane.mu.Unlock() | 426 | + | tb.mu.Unlock() |
| 175 | } | 427 | } | ||
| 176 | - | if ac != nil { | 428 | + | failed := make([]resizeMember, 0) |
| 177 | - | ac.sendMu.Lock() | 429 | + | for i, tb := range tabs { |
| 178 | - | ac.size = plan.size | 430 | + | var members []resizeMember |
| 179 | - | ac.sendMu.Unlock() | 431 | + | var ok bool |
| 432 | + | if current == nil { | |||
| 433 | + | members, ok = d.applyTabLayoutTransaction(sess, tb) | |||
| 434 | + | } else { | |||
| 435 | + | members, ok = d.applyTabLayoutTransaction(sess, tb, current) | |||
| 436 | + | } | |||
| 437 | + | if !ok { | |||
| 438 | + | return nil, false | |||
| 439 | + | } | |||
| 440 | + | failed = append(failed, members...) | |||
| 441 | + | if i == active { | |||
| 442 | + | members, ok = d.applyVisibleFloatingLayout(sess, tb, current) | |||
| 443 | + | if !ok { | |||
| 444 | + | return nil, false | |||
| 445 | + | } | |||
| 446 | + | failed = append(failed, members...) | |||
| 447 | + | } | |||
| 180 | } | 448 | } | ||
| 181 | markSnapshotDirty(sess) | 449 | markSnapshotDirty(sess) | ||
| 182 | d.observeRuntime(ports.RuntimeResizeCommitted, 0, true) | 450 | d.observeRuntime(ports.RuntimeResizeCommitted, 0, true) | ||
| 451 | + | return failed, true | |||
| 183 | } | 452 | } | ||
| 184 | 453 | ||||
| 185 | func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool { | 454 | func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool { | ||
| @@ -188,27 +457,18 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease * | |||||
| 188 | return false | 457 | return false | ||
| 189 | } | 458 | } | ||
| 190 | snap := rc.resizeSnapshot() | 459 | snap := rc.resizeSnapshot() | ||
| 191 | - | if !rc.resizeCurrentForLease(epoch, ac, lease, false) { | 460 | + | current := func() bool { return rc.resizeCurrentForLease(epoch, ac, lease, false) } |
| 461 | + | if !current() { | |||
| 192 | return false | 462 | return false | ||
| 193 | } | 463 | } | ||
| 194 | d.exitCopyMode(ac) | 464 | d.exitCopyMode(ac) | ||
| 195 | - | plan := d.prepareResize(sess, snap.size) | 465 | + | failed, ok := d.applySessionLayout(sess, snap.size, current) |
| 196 | - | if !rc.resizeCurrentForLease(epoch, ac, lease, false) { | 466 | + | if !ok || !rc.resizeCurrentForLease(epoch, ac, lease, true) { |
| 197 | - | return false | |||
| 198 | - | } | |||
| 199 | - | if !d.applyResize(&plan, func() bool { return rc.resizeCurrentForLease(epoch, ac, lease, false) }) { | |||
| 200 | return false | 467 | return false | ||
| 201 | } | 468 | } | ||
| 202 | - | if !rc.resizeCurrentForLease(epoch, ac, lease, true) { | 469 | + | ac.sendMu.Lock() |
| 203 | - | return false | 470 | + | ac.size = snap.size |
| 204 | - | } | 471 | + | ac.sendMu.Unlock() |
| 205 | - | d.commitResize(sess, ac, plan) | |||
| 206 | - | failed := make([]resizeMember, 0, len(plan.members)) | |||
| 207 | - | for _, member := range plan.members { | |||
| 208 | - | if !member.ok { | |||
| 209 | - | failed = append(failed, member) | |||
| 210 | - | } | |||
| 211 | - | } | |||
| 212 | if len(failed) != 0 { | 472 | if len(failed) != 0 { | ||
| 213 | rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) }) | 473 | rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) }) | ||
| 214 | } | 474 | } | ||
| @@ -289,10 +549,8 @@ func (d *Daemon) requestTransactionalResizeForLease(sess *session, ac *attachedC | |||||
| 289 | if ac == nil { | 549 | if ac == nil { | ||
| 290 | // Headless geometry has no coordinator/transport to coalesce, but keeps | 550 | // Headless geometry has no coordinator/transport to coalesce, but keeps | ||
| 291 | // the same prepare/apply/commit ordering. | 551 | // the same prepare/apply/commit ordering. | ||
| 292 | - | plan := d.prepareResize(sess, size) | 552 | + | _, ok := d.applySessionLayout(sess, size, nil) |
| 293 | - | d.applyResize(&plan) | 553 | + | return ok |
| 294 | - | d.commitResize(sess, nil, plan) | |||
| 295 | - | return true | |||
| 296 | } | 554 | } | ||
| 297 | rc := sess.renderCoordinator() | 555 | rc := sess.renderCoordinator() | ||
| 298 | if rc == nil || !rc.leaseCurrent(lease, true) { | 556 | if rc == nil || !rc.leaseCurrent(lease, true) { | ||