detached 6400ab4

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
268268 tb.mu.Lock()
269269 geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, snapshot.pane)
270270 otherPane := false
271+ layoutChanged := false
271272 if ev.Type == mouse.Press {
272273 // A new press is allowed to change focus, but never the document owned
273274 // 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
275276 if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane {
276277 otherPane = true
277278 if tb.tree != nil {
278- focusPlacementLocked(tb, hit.pane.id)
279- d.applyLayoutLocked(tb)
279+ layoutChanged = focusPlacementLocked(tb, hit.pane.id)
280280 }
281281 }
282282 }
283283 tb.mu.Unlock()
284+ if layoutChanged {
285+ d.applyTabLayout(sess, tb)
286+ }
284287 if otherPane {
285288 d.exitCopyMode(ac)
286289 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 := false270 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 owned273 // 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 takes274 // 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 = true277 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 return289 return
M internal/usecase/daemon/input.go +9 -4
@@ -89,9 +89,11 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
8989 return
9090 }
9191 oldFocus := focusedID
92- focusPlacementLocked(tb, pl.ID)
93- d.applyLayoutLocked(tb)
92+ layoutChanged := focusPlacementLocked(tb, pl.ID)
9493 tb.mu.Unlock()
94+ if layoutChanged {
95+ d.applyTabLayout(sess, tb)
96+ }
9597 // A title bar never routes to terminal content. Clear any pre-existing
9698 // left-button candidate before handling the focus result, including when
9799 // this press leaves the same pane focused.
@@ -108,13 +110,16 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
108110 hoveredFocused := true
109111 if hit && !pl.Collapsed && pointInRect(ev.Col, contentRow, pl.Content) {
110112 oldFocus := focusedID
113+ layoutChanged := false
111114 if isMouseFocusPress(ev) {
112- focusPlacementLocked(tb, pl.ID)
113- d.applyLayoutLocked(tb)
115+ layoutChanged = focusPlacementLocked(tb, pl.ID)
114116 }
115117 p = tb.panes[pl.ID]
116118 hoveredFocused = pl.ID == oldFocus
117119 tb.mu.Unlock()
120+ if layoutChanged {
121+ d.applyTabLayout(sess, tb)
122+ }
118123 if p == nil {
119124 invalidateRejectedLeftPointer(rt, ev)
120125 return
@@ -89,9 +89,11 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
89 return89 return
90 }90 }
91 oldFocus := focusedID91 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-existing97 // A title bar never routes to terminal content. Clear any pre-existing
96 // left-button candidate before handling the focus result, including when98 // 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 := true110 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 := focusedID112 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 == oldFocus118 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 return125 return
M internal/usecase/daemon/mouse_panes.go +9 -2
@@ -99,12 +99,19 @@ func pointInRect(col, row int, r domain.Rect) bool {
9999 return r.Width > 0 && r.Height > 0 && col >= r.X && col < r.X+r.Width && row >= r.Y && row < r.Y+r.Height
100100 }
101101
102-func focusPlacementLocked(tb *tab, id layout.PaneID) {
102+func focusPlacementLocked(tb *tab, id layout.PaneID) bool {
103103 if tb == nil || tb.tree == nil {
104- return
104+ return false
105105 }
106+ oldFocus := tb.tree.Focus
107+ oldLayout := layoutFingerprint(tb.tree.Root)
106108 tb.tree.Focus = id
107109 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
108115 }
109116
110117 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.Height99 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- return104+ return false
105 }105 }
106+ oldFocus := tb.tree.Focus
107+ oldLayout := layoutFingerprint(tb.tree.Root)
106 tb.tree.Focus = id108 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) {
2323 second := newTab(nil, domain.Size{Cols: 41, Rows: 10})
2424 sess.tabs = []*tab{first, second}
2525
26+ first.mu.Lock()
27+ first.size = domain.Size{Cols: 20, Rows: 5}
28+ first.bumpLayoutGenerationLocked()
29+ first.mu.Unlock()
2630 require.NotPanics(t, func() {
27- d.applyPaneResize(first.focusedPane(), domain.Rect{Width: 20, Height: 5})
31+ d.applyTabLayout(sess, first)
2832 })
2933 require.Equal(t, 20, first.focusedPane().screen.Frame.Width)
3034 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(
5656 }
5757 newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID))
5858 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 {
6061 tb.mu.Unlock()
6162 if errors.Is(err, layout.ErrTooSmall) {
6263 return domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", err)
@@ -64,33 +65,24 @@ func (d *Daemon) spawnPaneOpAt(
6465 return err
6566 }
6667 tb.nextPaneID++
67- placements, ok := layout.Solve(tb.tree.Root, area)
68+ placements, ok := layout.Solve(candidate.Root, area)
6869 if !ok {
69- _ = tb.tree.Close(newID)
70- tb.tree.Focus = oldFocus
7170 tb.mu.Unlock()
7271 return domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", layout.ErrTooSmall)
7372 }
7473 newRect := placementContent(placements, newID)
7574 tabStableID := tb.stableID
75+ generation := tb.layoutGeneration
7676 tb.mu.Unlock()
7777
7878 paneStableID, err := newStableID("p")
7979 if err != nil {
80- tb.mu.Lock()
81- _ = tb.tree.Close(newID)
82- tb.tree.Focus = oldFocus
83- tb.mu.Unlock()
8480 return fmt.Errorf("daemon: generating pane identity: %w", err)
8581 }
8682 command, args := d.ptyCommand(env)
8783 pty, err := d.ptys.Open(sess.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, rectSize(newRect))
8884 if err != nil {
8985 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()
9486 return domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err)
9587 }
9688
@@ -100,65 +92,22 @@ func (d *Daemon) spawnPaneOpAt(
10092 p.rect = newRect
10193
10294 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) {
10496 tb.mu.Unlock()
10597 cancel()
10698 _ = pty.Close()
10799 return layout.ErrNotFound
108100 }
101+ tb.tree = candidate
109102 tb.panes[newID] = p
110- tb.tree.Focus = newID
111- d.applyLayoutLocked(tb)
103+ tb.bumpLayoutGenerationLocked()
112104 tb.mu.Unlock()
113105
106+ d.applyTabLayout(sess, tb)
114107 d.startPaneGoroutines(sess, tb, p)
115- markSnapshotDirty(sess)
116108 return nil
117109 }
118110
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-
162111 func placementContent(placements []layout.Placement, id layout.PaneID) domain.Rect {
163112 for _, pl := range placements {
164113 if pl.ID == id {
@@ -210,13 +159,15 @@ func (d *Daemon) toggleStackAt(sess *session, tb *tab, target *pane) error {
210159 tb.mu.Unlock()
211160 return layout.ErrNotFound
212161 }
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})
214164 if err == nil {
215- d.applyLayoutLocked(tb)
165+ tb.tree = candidate
166+ tb.bumpLayoutGenerationLocked()
216167 }
217168 tb.mu.Unlock()
218169 if err == nil {
219- markSnapshotDirty(sess)
170+ d.applyTabLayout(sess, tb)
220171 }
221172 return err
222173 }
@@ -276,8 +227,9 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
276227 return err
277228 }
278229 delete(tb.panes, id)
279- d.applyLayoutLocked(tb)
230+ tb.bumpLayoutGenerationLocked()
280231 tb.mu.Unlock()
232+ d.applyTabLayout(sess, tb)
281233
282234 if ac != nil {
283235 ac.overlays.clearCopyModeForPane(p)
@@ -294,7 +246,6 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
294246 _ = p.pty.Close()
295247 }
296248 d.log.Info("pane closed", "session", sess.name, "pane", id)
297- markSnapshotDirty(sess)
298249 if repaint {
299250 if ac != nil {
300251 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
336287 return layout.ErrNotFound
337288 }
338289 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()
344297 }
345298 tb.mu.Unlock()
346299 if err == nil && newFocus != oldFocus {
347- markSnapshotDirty(sess)
300+ d.applyTabLayout(sess, tb)
348301 }
349302 if errors.Is(err, layout.ErrNoPane) {
350303 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 err65 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.stableID74 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 = newRect92 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.ErrNotFound99 return layout.ErrNotFound
108 }100 }
101+ tb.tree = candidate
109 tb.panes[newID] = p102 tb.panes[newID] = p
110- tb.tree.Focus = newID103+ 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 nil108 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.ErrNotFound160 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 err172 return err
222 }173 }
@@ -276,8 +227,9 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
276 return err227 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.ErrNotFound287 return layout.ErrNotFound
337 }288 }
338 oldFocus := target.id289 oldFocus := target.id
339- tb.tree.Focus = oldFocus290+ 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.Focus292+ 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 errNoNeighbor303 return errNoNeighbor
M internal/usecase/daemon/pane_actions_test.go +201 -3
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "io"
77 "log/slog"
8+ "sync"
89 "testing"
910 "time"
1011
@@ -19,6 +20,205 @@ import (
1920 "github.com/bnema/vev/pkg/renderer"
2021 )
2122
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+
22222 func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) {
23223 tests := []struct {
24224 name string
@@ -123,9 +323,7 @@ func TestApplyLayoutResizesPTYsAndScreens(t *testing.T) {
123323 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
124324 newPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
125325
126- tb.mu.Lock()
127- d.applyLayoutLocked(tb)
128- tb.mu.Unlock()
326+ d.applyTabLayout(sess, tb)
129327
130328 require.Equal(t, 20, tb.panes["pane-1"].screen.Frame.Width)
131329 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 string224 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 {
11101110 func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyRows int, size domain.Size) {
11111111 f.t.Helper()
11121112 tb.mu.Lock()
1113- defer tb.mu.Unlock()
11141113 tb.size = size
11151114 left := tb.focusedPane()
11161115 for paneIndex := 1; paneIndex < paneCount; paneIndex++ {
@@ -1121,8 +1120,13 @@ func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyR
11211120 placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: size.Cols, Height: size.Rows})
11221121 require.True(f.t, ok, "performance fixture layout must be solvable")
11231122 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 {
11261130 p.mu.Lock()
11271131 width := p.screen.Frame.Width
11281132 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 = size1113 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.Width1131 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) {
372372 tb.mu.Lock()
373373 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"}
374374 tb.panes[closed.id] = closed
375- d.applyLayoutLocked(tb)
375+ tb.bumpLayoutGenerationLocked()
376376 tb.mu.Unlock()
377+ d.applyTabLayout(sess, tb)
377378 survivor.screen.Write([]byte("survivor"))
378379 closed.screen.Write([]byte("closed"))
379380 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] = closed374 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 {
122122 type tab struct {
123123 mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, and pane map membership
124124
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+
125130 stableID string
126131 name string
127132 tree *layout.Tree
@@ -435,6 +440,10 @@ func newTabWithStableID(tabStableID, paneStableID string, pty ports.PTY, sz doma
435440 }
436441 }
437442
443+func (tb *tab) bumpLayoutGenerationLocked() {
444+ tb.layoutGeneration++
445+}
446+
438447 func (tb *tab) focusedPane() *pane {
439448 if tb == nil || tb.tree == nil || tb.panes == nil {
440449 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 membership123 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 string130 stableID string
126 name string131 name string
127 tree *layout.Tree132 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 nil449 return nil
M internal/usecase/daemon/transactional_resize.go +300 -42
@@ -17,6 +17,7 @@ type resizeMember struct {
1717 isFloating bool
1818 retry bool
1919 ok bool
20+ err error
2021 screenResized bool
2122 }
2223
@@ -26,6 +27,207 @@ type resizePlan struct {
2627 members []resizeMember
2728 }
2829
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+
29231 func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan {
30232 plan := resizePlan{size: size}
31233 sess.mu.Lock()
@@ -150,36 +352,103 @@ func (d *Daemon) replayResizePending(sess *session, tb *tab, p *pane, resized bo
150352 }
151353 }
152354
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 {
160366 tb.mu.Unlock()
367+ return nil, true
161368 }
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
170420 }
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()
173425 }
174- m.pane.mu.Unlock()
426+ tb.mu.Unlock()
175427 }
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+ }
180448 }
181449 markSnapshotDirty(sess)
182450 d.observeRuntime(ports.RuntimeResizeCommitted, 0, true)
451+ return failed, true
183452 }
184453
185454 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 *
188457 return false
189458 }
190459 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() {
192462 return false
193463 }
194464 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) {
200467 return false
201468 }
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()
212472 if len(failed) != 0 {
213473 rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) })
214474 }
@@ -289,10 +549,8 @@ func (d *Daemon) requestTransactionalResizeForLease(sess *session, ac *attachedC
289549 if ac == nil {
290550 // Headless geometry has no coordinator/transport to coalesce, but keeps
291551 // 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
296554 }
297555 rc := sess.renderCoordinator()
298556 if rc == nil || !rc.leaseCurrent(lease, true) {
@@ -17,6 +17,7 @@ type resizeMember struct {
17 isFloating bool17 isFloating bool
18 retry bool18 retry bool
19 ok bool19 ok bool
20+ err error
20 screenResized bool21 screenResized bool
21 }22 }
22 23
@@ -26,6 +27,207 @@ type resizePlan struct {
26 members []resizeMember27 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 coordinator356+// lifecycle while routing their external resize through the same lock-free gate
155- // has rejected obsolete work. A later request may have applied already, but357+// 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 deliberately370+ generation := tb.floating.generation
164- // retain their old VT screen; capture clips that screen to this new rect371+ 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.rect374+ return nil, true
168- if m.isFloating {375+ }
169- m.pane.popupGeometry = m.floating376+
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.size430+ 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 false457 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 false462 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 false467 return false
201 }468 }
202- if !rc.resizeCurrentForLease(epoch, ac, lease, true) {469+ ac.sendMu.Lock()
203- return false470+ 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 keeps550 // 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) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close