fix(daemon): admit resize epochs before publication
- commit
- 50d230aa74ce64c580451415cf9e437f683fd28a
- author
- brice · · 2026-07-25 05:40 UTC
- parent
- c11de37
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +303 -54
M internal/usecase/daemon/daemon.go +14 -10
| @@ -105,16 +105,20 @@ type Daemon struct { | |||
| 105 | 105 | // beforeCopyMouseMap is a deterministic seam after an immutable copy-input | |
| 106 | 106 | // snapshot is captured and before its mapped position is applied. | |
| 107 | 107 | beforeCopyMouseMap func() | |
| 108 | - | ptys ports.PTYFactory | |
| 109 | - | clock ports.Clock | |
| 110 | - | log *slog.Logger | |
| 111 | - | runtimeObserver ports.RuntimeObserver | |
| 112 | - | baseEnv []string | |
| 113 | - | shell string | |
| 114 | - | shellArgs []string | |
| 115 | - | shellOverride bool | |
| 116 | - | persist *persist.Persister | |
| 117 | - | persistEnabled bool | |
| 108 | + | // beforeSessionResizePublication runs after final external PTY validation | |
| 109 | + | // and before coordinator epoch admission. It is a deterministic regression | |
| 110 | + | // seam for stale resize publication. | |
| 111 | + | beforeSessionResizePublication func() | |
| 112 | + | ptys ports.PTYFactory | |
| 113 | + | clock ports.Clock | |
| 114 | + | log *slog.Logger | |
| 115 | + | runtimeObserver ports.RuntimeObserver | |
| 116 | + | baseEnv []string | |
| 117 | + | shell string | |
| 118 | + | shellArgs []string | |
| 119 | + | shellOverride bool | |
| 120 | + | persist *persist.Persister | |
| 121 | + | persistEnabled bool | |
| 118 | 122 | // snapshotRepository is the sole checkpoint contract. legacySnapshots is | |
| 119 | 123 | // read-only migration input and is never used for new writes. | |
| 120 | 124 | snapshotRepository ports.SnapshotRepository | |
| @@ -105,16 +105,20 @@ type Daemon struct { | |||||
| 105 | // beforeCopyMouseMap is a deterministic seam after an immutable copy-input | 105 | // beforeCopyMouseMap is a deterministic seam after an immutable copy-input | ||
| 106 | // snapshot is captured and before its mapped position is applied. | 106 | // snapshot is captured and before its mapped position is applied. | ||
| 107 | beforeCopyMouseMap func() | 107 | beforeCopyMouseMap func() | ||
| 108 | - | ptys ports.PTYFactory | 108 | + | // beforeSessionResizePublication runs after final external PTY validation |
| 109 | - | clock ports.Clock | 109 | + | // and before coordinator epoch admission. It is a deterministic regression |
| 110 | - | log *slog.Logger | 110 | + | // seam for stale resize publication. |
| 111 | - | runtimeObserver ports.RuntimeObserver | 111 | + | beforeSessionResizePublication func() |
| 112 | - | baseEnv []string | 112 | + | ptys ports.PTYFactory |
| 113 | - | shell string | 113 | + | clock ports.Clock |
| 114 | - | shellArgs []string | 114 | + | log *slog.Logger |
| 115 | - | shellOverride bool | 115 | + | runtimeObserver ports.RuntimeObserver |
| 116 | - | persist *persist.Persister | 116 | + | baseEnv []string |
| 117 | - | persistEnabled bool | 117 | + | shell string |
| 118 | + | shellArgs []string | |||
| 119 | + | shellOverride bool | |||
| 120 | + | persist *persist.Persister | |||
| 121 | + | persistEnabled bool | |||
| 118 | // snapshotRepository is the sole checkpoint contract. legacySnapshots is | 122 | // snapshotRepository is the sole checkpoint contract. legacySnapshots is | ||
| 119 | // read-only migration input and is never used for new writes. | 123 | // read-only migration input and is never used for new writes. | ||
| 120 | snapshotRepository ports.SnapshotRepository | 124 | snapshotRepository ports.SnapshotRepository | ||
M internal/usecase/daemon/mouse_panes.go +58 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "math" | |
| 4 | 5 | "strconv" | |
| 5 | 6 | "strings" | |
| 6 | 7 | ||
| @@ -66,10 +67,49 @@ func solveTabLayoutLocked(tb *tab) tabLayoutSnapshot { | |||
| 66 | 67 | ||
| 67 | 68 | func layoutFingerprint(root *layout.Node) string { | |
| 68 | 69 | var b strings.Builder | |
| 70 | + | b.Grow(layoutFingerprintLength(root)) | |
| 69 | 71 | writeLayoutFingerprint(&b, root) | |
| 70 | 72 | return b.String() | |
| 71 | 73 | } | |
| 72 | 74 | ||
| 75 | + | func layoutFingerprintLength(n *layout.Node) int { | |
| 76 | + | if n == nil { | |
| 77 | + | return 1 | |
| 78 | + | } | |
| 79 | + | length := 2 + weightFingerprintLength(n.Weight) + 1 + paneIDFingerprintLength(n.Leaf) + 1 + paneIDFingerprintLength(n.Expanded) + 2 | |
| 80 | + | for _, child := range n.Children { | |
| 81 | + | length += layoutFingerprintLength(child) + 1 | |
| 82 | + | } | |
| 83 | + | return length | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func paneIDFingerprintLength(id layout.PaneID) int { | |
| 87 | + | length := len(id) | |
| 88 | + | return decimalDigits(length) + 1 + length | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func decimalDigits(value int) int { | |
| 92 | + | if value == 0 { | |
| 93 | + | return 1 | |
| 94 | + | } | |
| 95 | + | digits := 0 | |
| 96 | + | for value > 0 { | |
| 97 | + | value /= 10 | |
| 98 | + | digits++ | |
| 99 | + | } | |
| 100 | + | return digits | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func weightFingerprintLength(weight float64) int { | |
| 104 | + | bits := math.Float64bits(weight) | |
| 105 | + | length := 1 | |
| 106 | + | for bits >= 16 { | |
| 107 | + | bits >>= 4 | |
| 108 | + | length++ | |
| 109 | + | } | |
| 110 | + | return length | |
| 111 | + | } | |
| 112 | + | ||
| 73 | 113 | func writeLayoutFingerprint(b *strings.Builder, n *layout.Node) { | |
| 74 | 114 | if n == nil { | |
| 75 | 115 | b.WriteByte('0') | |
| @@ -77,6 +117,8 @@ func writeLayoutFingerprint(b *strings.Builder, n *layout.Node) { | |||
| 77 | 117 | } | |
| 78 | 118 | b.WriteByte(byte('0' + n.Kind)) | |
| 79 | 119 | b.WriteByte(byte('0' + n.Dir)) | |
| 120 | + | writeWeightFingerprint(b, n.Weight) | |
| 121 | + | b.WriteByte('|') | |
| 80 | 122 | writePaneIDFingerprint(b, n.Leaf) | |
| 81 | 123 | b.WriteByte('|') | |
| 82 | 124 | writePaneIDFingerprint(b, n.Expanded) | |
| @@ -88,6 +130,22 @@ func writeLayoutFingerprint(b *strings.Builder, n *layout.Node) { | |||
| 88 | 130 | b.WriteByte(']') | |
| 89 | 131 | } | |
| 90 | 132 | ||
| 133 | + | func writeWeightFingerprint(b *strings.Builder, weight float64) { | |
| 134 | + | const hex = "0123456789abcdef" | |
| 135 | + | bits := math.Float64bits(weight) | |
| 136 | + | started := false | |
| 137 | + | for shift := uint(60); ; shift -= 4 { | |
| 138 | + | digit := byte(bits >> shift & 0xf) | |
| 139 | + | if digit != 0 || started || shift == 0 { | |
| 140 | + | b.WriteByte(hex[digit]) | |
| 141 | + | started = true | |
| 142 | + | } | |
| 143 | + | if shift == 0 { | |
| 144 | + | return | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 91 | 149 | func writePaneIDFingerprint(b *strings.Builder, id layout.PaneID) { | |
| 92 | 150 | s := string(id) | |
| 93 | 151 | b.WriteString(strconv.Itoa(len(s))) | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "math" | |||
| 4 | "strconv" | 5 | "strconv" | ||
| 5 | "strings" | 6 | "strings" | ||
| 6 | 7 | ||||
| @@ -66,10 +67,49 @@ func solveTabLayoutLocked(tb *tab) tabLayoutSnapshot { | |||||
| 66 | 67 | ||||
| 67 | func layoutFingerprint(root *layout.Node) string { | 68 | func layoutFingerprint(root *layout.Node) string { | ||
| 68 | var b strings.Builder | 69 | var b strings.Builder | ||
| 70 | + | b.Grow(layoutFingerprintLength(root)) | |||
| 69 | writeLayoutFingerprint(&b, root) | 71 | writeLayoutFingerprint(&b, root) | ||
| 70 | return b.String() | 72 | return b.String() | ||
| 71 | } | 73 | } | ||
| 72 | 74 | ||||
| 75 | + | func layoutFingerprintLength(n *layout.Node) int { | |||
| 76 | + | if n == nil { | |||
| 77 | + | return 1 | |||
| 78 | + | } | |||
| 79 | + | length := 2 + weightFingerprintLength(n.Weight) + 1 + paneIDFingerprintLength(n.Leaf) + 1 + paneIDFingerprintLength(n.Expanded) + 2 | |||
| 80 | + | for _, child := range n.Children { | |||
| 81 | + | length += layoutFingerprintLength(child) + 1 | |||
| 82 | + | } | |||
| 83 | + | return length | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func paneIDFingerprintLength(id layout.PaneID) int { | |||
| 87 | + | length := len(id) | |||
| 88 | + | return decimalDigits(length) + 1 + length | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func decimalDigits(value int) int { | |||
| 92 | + | if value == 0 { | |||
| 93 | + | return 1 | |||
| 94 | + | } | |||
| 95 | + | digits := 0 | |||
| 96 | + | for value > 0 { | |||
| 97 | + | value /= 10 | |||
| 98 | + | digits++ | |||
| 99 | + | } | |||
| 100 | + | return digits | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func weightFingerprintLength(weight float64) int { | |||
| 104 | + | bits := math.Float64bits(weight) | |||
| 105 | + | length := 1 | |||
| 106 | + | for bits >= 16 { | |||
| 107 | + | bits >>= 4 | |||
| 108 | + | length++ | |||
| 109 | + | } | |||
| 110 | + | return length | |||
| 111 | + | } | |||
| 112 | + | ||||
| 73 | func writeLayoutFingerprint(b *strings.Builder, n *layout.Node) { | 113 | func writeLayoutFingerprint(b *strings.Builder, n *layout.Node) { | ||
| 74 | if n == nil { | 114 | if n == nil { | ||
| 75 | b.WriteByte('0') | 115 | b.WriteByte('0') | ||
| @@ -77,6 +117,8 @@ func writeLayoutFingerprint(b *strings.Builder, n *layout.Node) { | |||||
| 77 | } | 117 | } | ||
| 78 | b.WriteByte(byte('0' + n.Kind)) | 118 | b.WriteByte(byte('0' + n.Kind)) | ||
| 79 | b.WriteByte(byte('0' + n.Dir)) | 119 | b.WriteByte(byte('0' + n.Dir)) | ||
| 120 | + | writeWeightFingerprint(b, n.Weight) | |||
| 121 | + | b.WriteByte('|') | |||
| 80 | writePaneIDFingerprint(b, n.Leaf) | 122 | writePaneIDFingerprint(b, n.Leaf) | ||
| 81 | b.WriteByte('|') | 123 | b.WriteByte('|') | ||
| 82 | writePaneIDFingerprint(b, n.Expanded) | 124 | writePaneIDFingerprint(b, n.Expanded) | ||
| @@ -88,6 +130,22 @@ func writeLayoutFingerprint(b *strings.Builder, n *layout.Node) { | |||||
| 88 | b.WriteByte(']') | 130 | b.WriteByte(']') | ||
| 89 | } | 131 | } | ||
| 90 | 132 | ||||
| 133 | + | func writeWeightFingerprint(b *strings.Builder, weight float64) { | |||
| 134 | + | const hex = "0123456789abcdef" | |||
| 135 | + | bits := math.Float64bits(weight) | |||
| 136 | + | started := false | |||
| 137 | + | for shift := uint(60); ; shift -= 4 { | |||
| 138 | + | digit := byte(bits >> shift & 0xf) | |||
| 139 | + | if digit != 0 || started || shift == 0 { | |||
| 140 | + | b.WriteByte(hex[digit]) | |||
| 141 | + | started = true | |||
| 142 | + | } | |||
| 143 | + | if shift == 0 { | |||
| 144 | + | return | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | ||||
| 91 | func writePaneIDFingerprint(b *strings.Builder, id layout.PaneID) { | 149 | func writePaneIDFingerprint(b *strings.Builder, id layout.PaneID) { | ||
| 92 | s := string(id) | 150 | s := string(id) | ||
| 93 | b.WriteString(strconv.Itoa(len(s))) | 151 | b.WriteString(strconv.Itoa(len(s))) | ||
M internal/usecase/daemon/pane_actions_test.go +6 -5
| @@ -339,11 +339,12 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) { | |||
| 339 | 339 | awaitSignal(t, entered, "old size apply did not block") | |
| 340 | 340 | resizeDone := make(chan bool, 1) | |
| 341 | 341 | go func() { resizeDone <- d.requestTransactionalResize(sess, nil, domain.Size{Cols: 100, Rows: 30}, true) }() | |
| 342 | - | require.Eventually(t, func() bool { | |
| 343 | - | tb.mu.Lock() | |
| 344 | - | defer tb.mu.Unlock() | |
| 345 | - | return tb.size == (domain.Size{Cols: 100, Rows: 28}) && tb.layoutGeneration > 0 | |
| 346 | - | }, time.Second, time.Millisecond) | |
| 342 | + | // Session resize now stages the target privately until every external | |
| 343 | + | // apply validates and the transaction is admitted, so neither the size | |
| 344 | + | // nor pane geometry is visible while the old PTY call is blocked. | |
| 345 | + | tb.mu.Lock() | |
| 346 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size) | |
| 347 | + | tb.mu.Unlock() | |
| 347 | 348 | require.Equal(t, domain.Rect{Width: 20, Height: 10}, p.rect, "stale size published before release") | |
| 348 | 349 | close(release) | |
| 349 | 350 | awaitSignal(t, oldDone, "old size apply did not finish") | |
| @@ -339,11 +339,12 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) { | |||||
| 339 | awaitSignal(t, entered, "old size apply did not block") | 339 | awaitSignal(t, entered, "old size apply did not block") | ||
| 340 | resizeDone := make(chan bool, 1) | 340 | resizeDone := make(chan bool, 1) | ||
| 341 | go func() { resizeDone <- d.requestTransactionalResize(sess, nil, domain.Size{Cols: 100, Rows: 30}, true) }() | 341 | go func() { resizeDone <- d.requestTransactionalResize(sess, nil, domain.Size{Cols: 100, Rows: 30}, true) }() | ||
| 342 | - | require.Eventually(t, func() bool { | 342 | + | // Session resize now stages the target privately until every external |
| 343 | - | tb.mu.Lock() | 343 | + | // apply validates and the transaction is admitted, so neither the size |
| 344 | - | defer tb.mu.Unlock() | 344 | + | // nor pane geometry is visible while the old PTY call is blocked. |
| 345 | - | return tb.size == (domain.Size{Cols: 100, Rows: 28}) && tb.layoutGeneration > 0 | 345 | + | tb.mu.Lock() |
| 346 | - | }, time.Second, time.Millisecond) | 346 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size) |
| 347 | + | tb.mu.Unlock() | |||
| 347 | require.Equal(t, domain.Rect{Width: 20, Height: 10}, p.rect, "stale size published before release") | 348 | require.Equal(t, domain.Rect{Width: 20, Height: 10}, p.rect, "stale size published before release") | ||
| 348 | close(release) | 349 | close(release) | ||
| 349 | awaitSignal(t, oldDone, "old size apply did not finish") | 350 | awaitSignal(t, oldDone, "old size apply did not finish") | ||
M internal/usecase/daemon/render_pipeline_cache_test.go +39 -0
| @@ -125,6 +125,45 @@ func TestComposeFrameCacheSkipsUndamagedBlitsAndInvalidatesFocusAndLayout(t *tes | |||
| 125 | 125 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage) | |
| 126 | 126 | } | |
| 127 | 127 | ||
| 128 | + | func TestLayoutFingerprintWeightChangeInvalidatesGeometryCache(t *testing.T) { | |
| 129 | + | root := &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |
| 130 | + | {Kind: layout.Leaf, Leaf: "left", Weight: 1}, | |
| 131 | + | {Kind: layout.Leaf, Leaf: "right", Weight: 1}, | |
| 132 | + | }} | |
| 133 | + | stateFor := func(reset bool, damage []renderer.Damage) capturedRenderState { | |
| 134 | + | placements, ok := layout.Solve(root, domain.Rect{Width: 61, Height: 5}) | |
| 135 | + | require.True(t, ok) | |
| 136 | + | panes := make([]capturedPaneRenderState, 0, len(placements)) | |
| 137 | + | for _, placement := range placements { | |
| 138 | + | frame := renderer.NewFrame(placement.Content.Width, placement.Content.Height) | |
| 139 | + | for y := 0; y < frame.Height; y++ { | |
| 140 | + | for x := 0; x < frame.Width; x++ { | |
| 141 | + | frame.Set(x, y, renderer.Cell{Rune: rune(placement.ID[0]), Style: renderer.DefaultStyle()}) | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | panes = append(panes, capturedPaneRenderState{ | |
| 145 | + | id: placement.ID, frame: frame, placement: placement, focused: placement.ID == "left", damage: damage, | |
| 146 | + | }) | |
| 147 | + | } | |
| 148 | + | return capturedRenderState{ | |
| 149 | + | reset: reset, | |
| 150 | + | layout: capturedTabLayout{root: root, area: domain.Rect{Width: 61, Height: 5}, focus: "left", placements: placements, fingerprint: layoutFingerprint(root), valid: true}, | |
| 151 | + | panes: panes, styles: resolveStyles(nil), | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | initial := stateFor(true, []renderer.Damage{renderer.FullRedraw()}) | |
| 156 | + | committed := composeFrame(initial, composeCacheInput{}) | |
| 157 | + | root.Children[0].Weight = 2 | |
| 158 | + | next := stateFor(false, nil) | |
| 159 | + | require.NotEqual(t, initial.layout.fingerprint, next.layout.fingerprint) | |
| 160 | + | require.Equal(t, 41, next.layout.placements[1].Content.X) | |
| 161 | + | out := composeFrame(next, committed.cache, composeCacheInput{}) | |
| 162 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage) | |
| 163 | + | require.Equal(t, 'l', out.frame.At(31, 1).Rune, "the enlarged pane must replace its old divider/cache footprint") | |
| 164 | + | require.Equal(t, 'r', out.frame.At(41, 1).Rune, "weight-derived pane geometry must be reblitted at its new position") | |
| 165 | + | } | |
| 166 | + | ||
| 128 | 167 | func TestComposeFrameUsesCachedNeutralStructuralBorder(t *testing.T) { | |
| 129 | 168 | theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 210, B: 200}, Background: renderer.RGB{R: 20, G: 30, B: 40}} | |
| 130 | 169 | neutralBorder := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 170, G: 80, B: 30}} | |
| @@ -125,6 +125,45 @@ func TestComposeFrameCacheSkipsUndamagedBlitsAndInvalidatesFocusAndLayout(t *tes | |||||
| 125 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage) | 125 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage) | ||
| 126 | } | 126 | } | ||
| 127 | 127 | ||||
| 128 | + | func TestLayoutFingerprintWeightChangeInvalidatesGeometryCache(t *testing.T) { | |||
| 129 | + | root := &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |||
| 130 | + | {Kind: layout.Leaf, Leaf: "left", Weight: 1}, | |||
| 131 | + | {Kind: layout.Leaf, Leaf: "right", Weight: 1}, | |||
| 132 | + | }} | |||
| 133 | + | stateFor := func(reset bool, damage []renderer.Damage) capturedRenderState { | |||
| 134 | + | placements, ok := layout.Solve(root, domain.Rect{Width: 61, Height: 5}) | |||
| 135 | + | require.True(t, ok) | |||
| 136 | + | panes := make([]capturedPaneRenderState, 0, len(placements)) | |||
| 137 | + | for _, placement := range placements { | |||
| 138 | + | frame := renderer.NewFrame(placement.Content.Width, placement.Content.Height) | |||
| 139 | + | for y := 0; y < frame.Height; y++ { | |||
| 140 | + | for x := 0; x < frame.Width; x++ { | |||
| 141 | + | frame.Set(x, y, renderer.Cell{Rune: rune(placement.ID[0]), Style: renderer.DefaultStyle()}) | |||
| 142 | + | } | |||
| 143 | + | } | |||
| 144 | + | panes = append(panes, capturedPaneRenderState{ | |||
| 145 | + | id: placement.ID, frame: frame, placement: placement, focused: placement.ID == "left", damage: damage, | |||
| 146 | + | }) | |||
| 147 | + | } | |||
| 148 | + | return capturedRenderState{ | |||
| 149 | + | reset: reset, | |||
| 150 | + | layout: capturedTabLayout{root: root, area: domain.Rect{Width: 61, Height: 5}, focus: "left", placements: placements, fingerprint: layoutFingerprint(root), valid: true}, | |||
| 151 | + | panes: panes, styles: resolveStyles(nil), | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | initial := stateFor(true, []renderer.Damage{renderer.FullRedraw()}) | |||
| 156 | + | committed := composeFrame(initial, composeCacheInput{}) | |||
| 157 | + | root.Children[0].Weight = 2 | |||
| 158 | + | next := stateFor(false, nil) | |||
| 159 | + | require.NotEqual(t, initial.layout.fingerprint, next.layout.fingerprint) | |||
| 160 | + | require.Equal(t, 41, next.layout.placements[1].Content.X) | |||
| 161 | + | out := composeFrame(next, committed.cache, composeCacheInput{}) | |||
| 162 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage) | |||
| 163 | + | require.Equal(t, 'l', out.frame.At(31, 1).Rune, "the enlarged pane must replace its old divider/cache footprint") | |||
| 164 | + | require.Equal(t, 'r', out.frame.At(41, 1).Rune, "weight-derived pane geometry must be reblitted at its new position") | |||
| 165 | + | } | |||
| 166 | + | ||||
| 128 | func TestComposeFrameUsesCachedNeutralStructuralBorder(t *testing.T) { | 167 | func TestComposeFrameUsesCachedNeutralStructuralBorder(t *testing.T) { | ||
| 129 | theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 210, B: 200}, Background: renderer.RGB{R: 20, G: 30, B: 40}} | 168 | theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 210, B: 200}, Background: renderer.RGB{R: 20, G: 30, B: 40}} | ||
| 130 | neutralBorder := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 170, G: 80, B: 30}} | 169 | neutralBorder := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 170, G: 80, B: 30}} | ||
M internal/usecase/daemon/session.go +4 -0
| @@ -105,6 +105,10 @@ type session struct { | |||
| 105 | 105 | syncGen atomic.Uint64 | |
| 106 | 106 | // coordinator fans in this session's producer render invalidations. | |
| 107 | 107 | coordinator atomic.Pointer[renderCoordinator] | |
| 108 | + | // layoutApplyMu serializes whole-session resize prepare/apply/admit/publish | |
| 109 | + | // transactions. It is deliberately not an architecture lock and may span | |
| 110 | + | // external PTY.Resize calls. | |
| 111 | + | layoutApplyMu sync.Mutex | |
| 108 | 112 | // clipFiles records clipboard-image-transfer temp file paths (see | |
| 109 | 113 | // clipboard.go) written for this session, removed best-effort in | |
| 110 | 114 | // killSession. | |
| @@ -105,6 +105,10 @@ type session struct { | |||||
| 105 | syncGen atomic.Uint64 | 105 | syncGen atomic.Uint64 | ||
| 106 | // coordinator fans in this session's producer render invalidations. | 106 | // coordinator fans in this session's producer render invalidations. | ||
| 107 | coordinator atomic.Pointer[renderCoordinator] | 107 | coordinator atomic.Pointer[renderCoordinator] | ||
| 108 | + | // layoutApplyMu serializes whole-session resize prepare/apply/admit/publish | |||
| 109 | + | // transactions. It is deliberately not an architecture lock and may span | |||
| 110 | + | // external PTY.Resize calls. | |||
| 111 | + | layoutApplyMu sync.Mutex | |||
| 108 | // clipFiles records clipboard-image-transfer temp file paths (see | 112 | // clipFiles records clipboard-image-transfer temp file paths (see | ||
| 109 | // clipboard.go) written for this session, removed best-effort in | 113 | // clipboard.go) written for this session, removed best-effort in | ||
| 110 | // killSession. | 114 | // killSession. | ||
M internal/usecase/daemon/transactional_resize.go +127 -39
| @@ -28,17 +28,23 @@ type resizePlan struct { | |||
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | 30 | type preparedTabLayout struct { | |
| 31 | - | generation uint64 | |
| 32 | - | size domain.Size | |
| 33 | - | members []resizeMember | |
| 31 | + | tab *tab | |
| 32 | + | generation uint64 | |
| 33 | + | size domain.Size | |
| 34 | + | previousSize domain.Size | |
| 35 | + | members []resizeMember | |
| 34 | 36 | } | |
| 35 | 37 | ||
| 36 | 38 | 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 prepareTabLayoutForSizeLocked(sess, tb, tb.size) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func prepareTabLayoutForSizeLocked(sess *session, tb *tab, size domain.Size) preparedTabLayout { | |
| 43 | + | plan := preparedTabLayout{tab: tb, generation: tb.layoutGeneration, size: size, previousSize: tb.size} | |
| 44 | + | if tb.tree == nil || tb.tree.Root == nil || !size.Valid() { | |
| 39 | 45 | return plan | |
| 40 | 46 | } | |
| 41 | - | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | |
| 47 | + | area := domain.Rect{Width: size.Cols, Height: size.Rows} | |
| 42 | 48 | placements, ok := layout.Solve(tb.tree.Root, area) | |
| 43 | 49 | if !ok && tb.tree.Root.Kind == layout.Leaf { | |
| 44 | 50 | placements = []layout.Placement{{ID: tb.tree.Root.Leaf, Content: area}} | |
| @@ -96,7 +102,7 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) { | |||
| 96 | 102 | } | |
| 97 | 103 | ||
| 98 | 104 | func validatePreparedTabLayoutLocked(tb *tab, plan *preparedTabLayout) bool { | |
| 99 | - | if tb.layoutGeneration != plan.generation || tb.size != plan.size { | |
| 105 | + | if tb.layoutGeneration != plan.generation || tb.size != plan.previousSize { | |
| 100 | 106 | return false | |
| 101 | 107 | } | |
| 102 | 108 | for i := range plan.members { | |
| @@ -474,47 +480,127 @@ func (d *Daemon) applyVisibleFloatingLayout(sess *session, tb *tab, current func | |||
| 474 | 480 | return plan.members, true | |
| 475 | 481 | } | |
| 476 | 482 | ||
| 477 | - | func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current func() bool) ([]resizeMember, bool) { | |
| 478 | - | sess.mu.Lock() | |
| 479 | - | tabs := append([]*tab(nil), sess.tabs...) | |
| 480 | - | active := sess.active | |
| 481 | - | sess.mu.Unlock() | |
| 483 | + | // releasePreparedSessionGates abandons a session attempt which cannot be | |
| 484 | + | // retried here (for example because a newer coordinator epoch won). Unlike a | |
| 485 | + | // stale per-tab retry, no succeeding attempt is owned by this call, so every | |
| 486 | + | // successful external resize must reopen its parser gate at the old screen | |
| 487 | + | // size. | |
| 488 | + | func (d *Daemon) releasePreparedSessionGates(plans []*preparedTabLayout) { | |
| 489 | + | for _, plan := range plans { | |
| 490 | + | if plan == nil { | |
| 491 | + | continue | |
| 492 | + | } | |
| 493 | + | for i := range plan.members { | |
| 494 | + | member := &plan.members[i] | |
| 495 | + | if !member.screenResized { | |
| 496 | + | continue | |
| 497 | + | } | |
| 498 | + | member.pane.resizeMu.Lock() | |
| 499 | + | d.replayResizePending(member.session, member.tab, member.pane, false, member.rect) | |
| 500 | + | member.pane.resizeMu.Unlock() | |
| 501 | + | } | |
| 502 | + | } | |
| 503 | + | } | |
| 504 | + | ||
| 505 | + | // applySessionLayout keeps client resize publication as a two-phase session | |
| 506 | + | // transaction: all PTYs are applied and plans validated first, then the | |
| 507 | + | // coordinator admits the epoch before any tab size, rectangle, VT screen, | |
| 508 | + | // snapshot dirtiness, or resize telemetry becomes visible. | |
| 509 | + | func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, admit func() bool) ([]resizeMember, bool) { | |
| 510 | + | if sess == nil { | |
| 511 | + | return nil, false | |
| 512 | + | } | |
| 513 | + | sess.layoutApplyMu.Lock() | |
| 514 | + | defer sess.layoutApplyMu.Unlock() | |
| 515 | + | ||
| 482 | 516 | target := tabSize(size) | |
| 483 | - | for _, tb := range tabs { | |
| 517 | + | for { | |
| 484 | 518 | if current != nil && !current() { | |
| 485 | 519 | return nil, false | |
| 486 | 520 | } | |
| 487 | - | tb.mu.Lock() | |
| 488 | - | if tb.size != target { | |
| 489 | - | tb.size = target | |
| 490 | - | tb.bumpLayoutGenerationLocked() | |
| 521 | + | sess.mu.Lock() | |
| 522 | + | tabs := append([]*tab(nil), sess.tabs...) | |
| 523 | + | active := sess.active | |
| 524 | + | sess.mu.Unlock() | |
| 525 | + | plans := make([]*preparedTabLayout, 0, len(tabs)) | |
| 526 | + | for _, tb := range tabs { | |
| 527 | + | tb.mu.Lock() | |
| 528 | + | plan := prepareTabLayoutForSizeLocked(sess, tb, target) | |
| 529 | + | tb.mu.Unlock() | |
| 530 | + | plans = append(plans, &plan) | |
| 491 | 531 | } | |
| 492 | - | tb.mu.Unlock() | |
| 493 | - | } | |
| 494 | - | failed := make([]resizeMember, 0) | |
| 495 | - | for i, tb := range tabs { | |
| 496 | - | var members []resizeMember | |
| 497 | - | var ok bool | |
| 498 | - | if current == nil { | |
| 499 | - | members, ok = d.applyTabLayoutTransaction(sess, tb) | |
| 500 | - | } else { | |
| 501 | - | members, ok = d.applyTabLayoutTransaction(sess, tb, current) | |
| 532 | + | for _, plan := range plans { | |
| 533 | + | d.applyPreparedTabMembers(plan) | |
| 502 | 534 | } | |
| 503 | - | if !ok { | |
| 504 | - | return nil, false | |
| 535 | + | ||
| 536 | + | // Hold every tab lock across final validation, epoch admission, and all | |
| 537 | + | // publication. This makes the session commit indivisible to layout | |
| 538 | + | // mutators while still keeping PTY.Resize outside every state lock. | |
| 539 | + | for _, tb := range tabs { | |
| 540 | + | tb.mu.Lock() | |
| 541 | + | } | |
| 542 | + | valid := true | |
| 543 | + | for _, plan := range plans { | |
| 544 | + | if !validatePreparedTabLayoutLocked(plan.tab, plan) { | |
| 545 | + | valid = false | |
| 546 | + | break | |
| 547 | + | } | |
| 548 | + | } | |
| 549 | + | // This is the final external-apply validation boundary. Tests use the | |
| 550 | + | // seam to install a newer epoch here; that epoch must reject admission | |
| 551 | + | // before this attempt publishes any session geometry. | |
| 552 | + | if valid && d.beforeSessionResizePublication != nil { | |
| 553 | + | d.beforeSessionResizePublication() | |
| 554 | + | } | |
| 555 | + | if valid && current != nil && !current() { | |
| 556 | + | valid = false | |
| 557 | + | } | |
| 558 | + | if valid && admit != nil && !admit() { | |
| 559 | + | valid = false | |
| 560 | + | } | |
| 561 | + | if valid { | |
| 562 | + | for _, plan := range plans { | |
| 563 | + | plan.tab.size = plan.size | |
| 564 | + | commitPreparedTabLayoutLocked(plan) | |
| 565 | + | } | |
| 566 | + | } | |
| 567 | + | for i := len(tabs) - 1; i >= 0; i-- { | |
| 568 | + | tabs[i].mu.Unlock() | |
| 569 | + | } | |
| 570 | + | if !valid { | |
| 571 | + | d.releasePreparedSessionGates(plans) | |
| 572 | + | if current == nil || !current() { | |
| 573 | + | return nil, false | |
| 574 | + | } | |
| 575 | + | // A layout mutation invalidated the plans while this epoch remains | |
| 576 | + | // current. Reapply the fresh session geometry before admitting it. | |
| 577 | + | continue | |
| 505 | 578 | } | |
| 506 | - | failed = append(failed, members...) | |
| 507 | - | if i == active { | |
| 508 | - | members, ok = d.applyVisibleFloatingLayout(sess, tb, current) | |
| 579 | + | ||
| 580 | + | failed := make([]resizeMember, 0) | |
| 581 | + | for _, plan := range plans { | |
| 582 | + | d.finishPreparedTabMembers(plan, true) | |
| 583 | + | for _, member := range plan.members { | |
| 584 | + | if !member.ok { | |
| 585 | + | failed = append(failed, member) | |
| 586 | + | } | |
| 587 | + | } | |
| 588 | + | } | |
| 589 | + | if active >= 0 && active < len(tabs) { | |
| 590 | + | floatingFailed, ok := d.applyVisibleFloatingLayout(sess, tabs[active], current) | |
| 509 | 591 | if !ok { | |
| 510 | 592 | return nil, false | |
| 511 | 593 | } | |
| 512 | - | failed = append(failed, members...) | |
| 594 | + | failed = append(failed, floatingFailed...) | |
| 513 | 595 | } | |
| 596 | + | if len(failed) != 0 { | |
| 597 | + | d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed, | |
| 598 | + | "pane resize failed; retrying in background", failed[len(failed)-1].err) | |
| 599 | + | } | |
| 600 | + | markSnapshotDirty(sess) | |
| 601 | + | d.observeRuntime(ports.RuntimeResizeCommitted, 0, true) | |
| 602 | + | return failed, true | |
| 514 | 603 | } | |
| 515 | - | markSnapshotDirty(sess) | |
| 516 | - | d.observeRuntime(ports.RuntimeResizeCommitted, 0, true) | |
| 517 | - | return failed, true | |
| 518 | 604 | } | |
| 519 | 605 | ||
| 520 | 606 | func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool { | |
| @@ -528,8 +614,10 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease * | |||
| 528 | 614 | return false | |
| 529 | 615 | } | |
| 530 | 616 | d.exitCopyMode(ac) | |
| 531 | - | failed, ok := d.applySessionLayout(sess, snap.size, current) | |
| 532 | - | if !ok || !rc.resizeCurrentForLease(epoch, ac, lease, true) { | |
| 617 | + | failed, ok := d.applySessionLayout(sess, snap.size, current, func() bool { | |
| 618 | + | return rc.resizeCurrentForLease(epoch, ac, lease, true) | |
| 619 | + | }) | |
| 620 | + | if !ok { | |
| 533 | 621 | return false | |
| 534 | 622 | } | |
| 535 | 623 | ac.sendMu.Lock() | |
| @@ -658,7 +746,7 @@ func (d *Daemon) requestTransactionalResizeForLease(sess *session, ac *attachedC | |||
| 658 | 746 | if ac == nil { | |
| 659 | 747 | // Headless geometry has no coordinator/transport to coalesce, but keeps | |
| 660 | 748 | // the same prepare/apply/commit ordering. | |
| 661 | - | _, ok := d.applySessionLayout(sess, size, nil) | |
| 749 | + | _, ok := d.applySessionLayout(sess, size, nil, nil) | |
| 662 | 750 | return ok | |
| 663 | 751 | } | |
| 664 | 752 | rc := sess.renderCoordinator() | |
| @@ -28,17 +28,23 @@ type resizePlan struct { | |||||
| 28 | } | 28 | } | ||
| 29 | 29 | ||||
| 30 | type preparedTabLayout struct { | 30 | type preparedTabLayout struct { | ||
| 31 | - | generation uint64 | 31 | + | tab *tab |
| 32 | - | size domain.Size | 32 | + | generation uint64 |
| 33 | - | members []resizeMember | 33 | + | size domain.Size |
| 34 | + | previousSize domain.Size | |||
| 35 | + | members []resizeMember | |||
| 34 | } | 36 | } | ||
| 35 | 37 | ||||
| 36 | func prepareTabLayoutLocked(sess *session, tb *tab) preparedTabLayout { | 38 | func prepareTabLayoutLocked(sess *session, tb *tab) preparedTabLayout { | ||
| 37 | - | plan := preparedTabLayout{generation: tb.layoutGeneration, size: tb.size} | 39 | + | return prepareTabLayoutForSizeLocked(sess, tb, tb.size) |
| 38 | - | if tb.tree == nil || tb.tree.Root == nil || !tb.size.Valid() { | 40 | + | } |
| 41 | + | ||||
| 42 | + | func prepareTabLayoutForSizeLocked(sess *session, tb *tab, size domain.Size) preparedTabLayout { | |||
| 43 | + | plan := preparedTabLayout{tab: tb, generation: tb.layoutGeneration, size: size, previousSize: tb.size} | |||
| 44 | + | if tb.tree == nil || tb.tree.Root == nil || !size.Valid() { | |||
| 39 | return plan | 45 | return plan | ||
| 40 | } | 46 | } | ||
| 41 | - | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | 47 | + | area := domain.Rect{Width: size.Cols, Height: size.Rows} |
| 42 | placements, ok := layout.Solve(tb.tree.Root, area) | 48 | placements, ok := layout.Solve(tb.tree.Root, area) | ||
| 43 | if !ok && tb.tree.Root.Kind == layout.Leaf { | 49 | if !ok && tb.tree.Root.Kind == layout.Leaf { | ||
| 44 | placements = []layout.Placement{{ID: tb.tree.Root.Leaf, Content: area}} | 50 | placements = []layout.Placement{{ID: tb.tree.Root.Leaf, Content: area}} | ||
| @@ -96,7 +102,7 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) { | |||||
| 96 | } | 102 | } | ||
| 97 | 103 | ||||
| 98 | func validatePreparedTabLayoutLocked(tb *tab, plan *preparedTabLayout) bool { | 104 | func validatePreparedTabLayoutLocked(tb *tab, plan *preparedTabLayout) bool { | ||
| 99 | - | if tb.layoutGeneration != plan.generation || tb.size != plan.size { | 105 | + | if tb.layoutGeneration != plan.generation || tb.size != plan.previousSize { |
| 100 | return false | 106 | return false | ||
| 101 | } | 107 | } | ||
| 102 | for i := range plan.members { | 108 | for i := range plan.members { | ||
| @@ -474,47 +480,127 @@ func (d *Daemon) applyVisibleFloatingLayout(sess *session, tb *tab, current func | |||||
| 474 | return plan.members, true | 480 | return plan.members, true | ||
| 475 | } | 481 | } | ||
| 476 | 482 | ||||
| 477 | - | func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current func() bool) ([]resizeMember, bool) { | 483 | + | // releasePreparedSessionGates abandons a session attempt which cannot be |
| 478 | - | sess.mu.Lock() | 484 | + | // retried here (for example because a newer coordinator epoch won). Unlike a |
| 479 | - | tabs := append([]*tab(nil), sess.tabs...) | 485 | + | // stale per-tab retry, no succeeding attempt is owned by this call, so every |
| 480 | - | active := sess.active | 486 | + | // successful external resize must reopen its parser gate at the old screen |
| 481 | - | sess.mu.Unlock() | 487 | + | // size. |
| 488 | + | func (d *Daemon) releasePreparedSessionGates(plans []*preparedTabLayout) { | |||
| 489 | + | for _, plan := range plans { | |||
| 490 | + | if plan == nil { | |||
| 491 | + | continue | |||
| 492 | + | } | |||
| 493 | + | for i := range plan.members { | |||
| 494 | + | member := &plan.members[i] | |||
| 495 | + | if !member.screenResized { | |||
| 496 | + | continue | |||
| 497 | + | } | |||
| 498 | + | member.pane.resizeMu.Lock() | |||
| 499 | + | d.replayResizePending(member.session, member.tab, member.pane, false, member.rect) | |||
| 500 | + | member.pane.resizeMu.Unlock() | |||
| 501 | + | } | |||
| 502 | + | } | |||
| 503 | + | } | |||
| 504 | + | ||||
| 505 | + | // applySessionLayout keeps client resize publication as a two-phase session | |||
| 506 | + | // transaction: all PTYs are applied and plans validated first, then the | |||
| 507 | + | // coordinator admits the epoch before any tab size, rectangle, VT screen, | |||
| 508 | + | // snapshot dirtiness, or resize telemetry becomes visible. | |||
| 509 | + | func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, admit func() bool) ([]resizeMember, bool) { | |||
| 510 | + | if sess == nil { | |||
| 511 | + | return nil, false | |||
| 512 | + | } | |||
| 513 | + | sess.layoutApplyMu.Lock() | |||
| 514 | + | defer sess.layoutApplyMu.Unlock() | |||
| 515 | + | ||||
| 482 | target := tabSize(size) | 516 | target := tabSize(size) | ||
| 483 | - | for _, tb := range tabs { | 517 | + | for { |
| 484 | if current != nil && !current() { | 518 | if current != nil && !current() { | ||
| 485 | return nil, false | 519 | return nil, false | ||
| 486 | } | 520 | } | ||
| 487 | - | tb.mu.Lock() | 521 | + | sess.mu.Lock() |
| 488 | - | if tb.size != target { | 522 | + | tabs := append([]*tab(nil), sess.tabs...) |
| 489 | - | tb.size = target | 523 | + | active := sess.active |
| 490 | - | tb.bumpLayoutGenerationLocked() | 524 | + | sess.mu.Unlock() |
| 525 | + | plans := make([]*preparedTabLayout, 0, len(tabs)) | |||
| 526 | + | for _, tb := range tabs { | |||
| 527 | + | tb.mu.Lock() | |||
| 528 | + | plan := prepareTabLayoutForSizeLocked(sess, tb, target) | |||
| 529 | + | tb.mu.Unlock() | |||
| 530 | + | plans = append(plans, &plan) | |||
| 491 | } | 531 | } | ||
| 492 | - | tb.mu.Unlock() | 532 | + | for _, plan := range plans { |
| 493 | - | } | 533 | + | d.applyPreparedTabMembers(plan) |
| 494 | - | failed := make([]resizeMember, 0) | |||
| 495 | - | for i, tb := range tabs { | |||
| 496 | - | var members []resizeMember | |||
| 497 | - | var ok bool | |||
| 498 | - | if current == nil { | |||
| 499 | - | members, ok = d.applyTabLayoutTransaction(sess, tb) | |||
| 500 | - | } else { | |||
| 501 | - | members, ok = d.applyTabLayoutTransaction(sess, tb, current) | |||
| 502 | } | 534 | } | ||
| 503 | - | if !ok { | 535 | + | |
| 504 | - | return nil, false | 536 | + | // Hold every tab lock across final validation, epoch admission, and all |
| 537 | + | // publication. This makes the session commit indivisible to layout | |||
| 538 | + | // mutators while still keeping PTY.Resize outside every state lock. | |||
| 539 | + | for _, tb := range tabs { | |||
| 540 | + | tb.mu.Lock() | |||
| 541 | + | } | |||
| 542 | + | valid := true | |||
| 543 | + | for _, plan := range plans { | |||
| 544 | + | if !validatePreparedTabLayoutLocked(plan.tab, plan) { | |||
| 545 | + | valid = false | |||
| 546 | + | break | |||
| 547 | + | } | |||
| 548 | + | } | |||
| 549 | + | // This is the final external-apply validation boundary. Tests use the | |||
| 550 | + | // seam to install a newer epoch here; that epoch must reject admission | |||
| 551 | + | // before this attempt publishes any session geometry. | |||
| 552 | + | if valid && d.beforeSessionResizePublication != nil { | |||
| 553 | + | d.beforeSessionResizePublication() | |||
| 554 | + | } | |||
| 555 | + | if valid && current != nil && !current() { | |||
| 556 | + | valid = false | |||
| 557 | + | } | |||
| 558 | + | if valid && admit != nil && !admit() { | |||
| 559 | + | valid = false | |||
| 560 | + | } | |||
| 561 | + | if valid { | |||
| 562 | + | for _, plan := range plans { | |||
| 563 | + | plan.tab.size = plan.size | |||
| 564 | + | commitPreparedTabLayoutLocked(plan) | |||
| 565 | + | } | |||
| 566 | + | } | |||
| 567 | + | for i := len(tabs) - 1; i >= 0; i-- { | |||
| 568 | + | tabs[i].mu.Unlock() | |||
| 569 | + | } | |||
| 570 | + | if !valid { | |||
| 571 | + | d.releasePreparedSessionGates(plans) | |||
| 572 | + | if current == nil || !current() { | |||
| 573 | + | return nil, false | |||
| 574 | + | } | |||
| 575 | + | // A layout mutation invalidated the plans while this epoch remains | |||
| 576 | + | // current. Reapply the fresh session geometry before admitting it. | |||
| 577 | + | continue | |||
| 505 | } | 578 | } | ||
| 506 | - | failed = append(failed, members...) | 579 | + | |
| 507 | - | if i == active { | 580 | + | failed := make([]resizeMember, 0) |
| 508 | - | members, ok = d.applyVisibleFloatingLayout(sess, tb, current) | 581 | + | for _, plan := range plans { |
| 582 | + | d.finishPreparedTabMembers(plan, true) | |||
| 583 | + | for _, member := range plan.members { | |||
| 584 | + | if !member.ok { | |||
| 585 | + | failed = append(failed, member) | |||
| 586 | + | } | |||
| 587 | + | } | |||
| 588 | + | } | |||
| 589 | + | if active >= 0 && active < len(tabs) { | |||
| 590 | + | floatingFailed, ok := d.applyVisibleFloatingLayout(sess, tabs[active], current) | |||
| 509 | if !ok { | 591 | if !ok { | ||
| 510 | return nil, false | 592 | return nil, false | ||
| 511 | } | 593 | } | ||
| 512 | - | failed = append(failed, members...) | 594 | + | failed = append(failed, floatingFailed...) |
| 513 | } | 595 | } | ||
| 596 | + | if len(failed) != 0 { | |||
| 597 | + | d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed, | |||
| 598 | + | "pane resize failed; retrying in background", failed[len(failed)-1].err) | |||
| 599 | + | } | |||
| 600 | + | markSnapshotDirty(sess) | |||
| 601 | + | d.observeRuntime(ports.RuntimeResizeCommitted, 0, true) | |||
| 602 | + | return failed, true | |||
| 514 | } | 603 | } | ||
| 515 | - | markSnapshotDirty(sess) | |||
| 516 | - | d.observeRuntime(ports.RuntimeResizeCommitted, 0, true) | |||
| 517 | - | return failed, true | |||
| 518 | } | 604 | } | ||
| 519 | 605 | ||||
| 520 | func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool { | 606 | func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool { | ||
| @@ -528,8 +614,10 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease * | |||||
| 528 | return false | 614 | return false | ||
| 529 | } | 615 | } | ||
| 530 | d.exitCopyMode(ac) | 616 | d.exitCopyMode(ac) | ||
| 531 | - | failed, ok := d.applySessionLayout(sess, snap.size, current) | 617 | + | failed, ok := d.applySessionLayout(sess, snap.size, current, func() bool { |
| 532 | - | if !ok || !rc.resizeCurrentForLease(epoch, ac, lease, true) { | 618 | + | return rc.resizeCurrentForLease(epoch, ac, lease, true) |
| 619 | + | }) | |||
| 620 | + | if !ok { | |||
| 533 | return false | 621 | return false | ||
| 534 | } | 622 | } | ||
| 535 | ac.sendMu.Lock() | 623 | ac.sendMu.Lock() | ||
| @@ -658,7 +746,7 @@ func (d *Daemon) requestTransactionalResizeForLease(sess *session, ac *attachedC | |||||
| 658 | if ac == nil { | 746 | if ac == nil { | ||
| 659 | // Headless geometry has no coordinator/transport to coalesce, but keeps | 747 | // Headless geometry has no coordinator/transport to coalesce, but keeps | ||
| 660 | // the same prepare/apply/commit ordering. | 748 | // the same prepare/apply/commit ordering. | ||
| 661 | - | _, ok := d.applySessionLayout(sess, size, nil) | 749 | + | _, ok := d.applySessionLayout(sess, size, nil, nil) |
| 662 | return ok | 750 | return ok | ||
| 663 | } | 751 | } | ||
| 664 | rc := sess.renderCoordinator() | 752 | rc := sess.renderCoordinator() | ||
M internal/usecase/daemon/transactional_resize_test.go +55 -0
| @@ -423,6 +423,61 @@ func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) { | |||
| 423 | 423 | // stopped. Only the newest prepared epoch may apply, commit, and emit one full | |
| 424 | 424 | // frame. coordinatorMockClock is a generated MockClock/MockTimer harness; its | |
| 425 | 425 | // channels make callback order explicit and do not use wall-clock waits. | |
| 426 | + | func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing.T) { | |
| 427 | + | first, second := &transactionalResizePTY{}, &transactionalResizePTY{} | |
| 428 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, first, second) | |
| 429 | + | observer := &daemonRuntimeObserver{} | |
| 430 | + | d.runtimeObserver = observer | |
| 431 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 432 | + | lease := rc.attachmentLease(ac) | |
| 433 | + | var newer uint64 | |
| 434 | + | d.beforeSessionResizePublication = func() { | |
| 435 | + | // Final external PTY validation is complete, but epoch admission and | |
| 436 | + | // every session-visible publication must still be pending. | |
| 437 | + | for _, tb := range sess.tabs { | |
| 438 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size) | |
| 439 | + | p := tb.focusedPane() | |
| 440 | + | p.mu.Lock() | |
| 441 | + | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect) | |
| 442 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}) | |
| 443 | + | p.mu.Unlock() | |
| 444 | + | } | |
| 445 | + | require.False(t, sess.snapDirty.Load()) | |
| 446 | + | for _, mark := range observer.marks { | |
| 447 | + | require.NotEqual(t, ports.RuntimeResizeCommitted, mark.Kind) | |
| 448 | + | } | |
| 449 | + | newer = rc.recordResizeRequestForLease(domain.Size{Cols: 120, Rows: 34}, ac, lease) | |
| 450 | + | require.NotZero(t, newer) | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | require.False(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 100, Rows: 30}, true)) | |
| 454 | + | d.beforeSessionResizePublication = nil | |
| 455 | + | require.False(t, sess.snapDirty.Load(), "rejected epoch must not dirty the snapshot") | |
| 456 | + | for _, tb := range sess.tabs { | |
| 457 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size, "rejected epoch published a tab size") | |
| 458 | + | p := tb.focusedPane() | |
| 459 | + | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "rejected epoch published a pane rectangle") | |
| 460 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "rejected epoch published a VT size") | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | require.True(t, d.runResizeTransaction(sess, ac, lease, newer)) | |
| 464 | + | for _, tb := range sess.tabs { | |
| 465 | + | require.Equal(t, domain.Size{Cols: 120, Rows: 32}, tb.size) | |
| 466 | + | p := tb.focusedPane() | |
| 467 | + | require.Equal(t, domain.Rect{Width: 120, Height: 32}, p.rect) | |
| 468 | + | require.Equal(t, domain.Size{Cols: 120, Rows: 32}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}) | |
| 469 | + | } | |
| 470 | + | committed := 0 | |
| 471 | + | for _, mark := range observer.marks { | |
| 472 | + | if mark.Kind == ports.RuntimeResizeCommitted { | |
| 473 | + | committed++ | |
| 474 | + | } | |
| 475 | + | } | |
| 476 | + | require.Equal(t, 1, committed, "only the current epoch emits commit telemetry") | |
| 477 | + | require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}}, first.requested()) | |
| 478 | + | require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}}, second.requested()) | |
| 479 | + | } | |
| 480 | + | ||
| 426 | 481 | func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testing.T) { | |
| 427 | 482 | clock := newCoordinatorMockClock(t, 8) | |
| 428 | 483 | pty := &transactionalResizePTY{} | |
| @@ -423,6 +423,61 @@ func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) { | |||||
| 423 | // stopped. Only the newest prepared epoch may apply, commit, and emit one full | 423 | // stopped. Only the newest prepared epoch may apply, commit, and emit one full | ||
| 424 | // frame. coordinatorMockClock is a generated MockClock/MockTimer harness; its | 424 | // frame. coordinatorMockClock is a generated MockClock/MockTimer harness; its | ||
| 425 | // channels make callback order explicit and do not use wall-clock waits. | 425 | // channels make callback order explicit and do not use wall-clock waits. | ||
| 426 | + | func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing.T) { | |||
| 427 | + | first, second := &transactionalResizePTY{}, &transactionalResizePTY{} | |||
| 428 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, first, second) | |||
| 429 | + | observer := &daemonRuntimeObserver{} | |||
| 430 | + | d.runtimeObserver = observer | |||
| 431 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |||
| 432 | + | lease := rc.attachmentLease(ac) | |||
| 433 | + | var newer uint64 | |||
| 434 | + | d.beforeSessionResizePublication = func() { | |||
| 435 | + | // Final external PTY validation is complete, but epoch admission and | |||
| 436 | + | // every session-visible publication must still be pending. | |||
| 437 | + | for _, tb := range sess.tabs { | |||
| 438 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size) | |||
| 439 | + | p := tb.focusedPane() | |||
| 440 | + | p.mu.Lock() | |||
| 441 | + | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect) | |||
| 442 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}) | |||
| 443 | + | p.mu.Unlock() | |||
| 444 | + | } | |||
| 445 | + | require.False(t, sess.snapDirty.Load()) | |||
| 446 | + | for _, mark := range observer.marks { | |||
| 447 | + | require.NotEqual(t, ports.RuntimeResizeCommitted, mark.Kind) | |||
| 448 | + | } | |||
| 449 | + | newer = rc.recordResizeRequestForLease(domain.Size{Cols: 120, Rows: 34}, ac, lease) | |||
| 450 | + | require.NotZero(t, newer) | |||
| 451 | + | } | |||
| 452 | + | ||||
| 453 | + | require.False(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 100, Rows: 30}, true)) | |||
| 454 | + | d.beforeSessionResizePublication = nil | |||
| 455 | + | require.False(t, sess.snapDirty.Load(), "rejected epoch must not dirty the snapshot") | |||
| 456 | + | for _, tb := range sess.tabs { | |||
| 457 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size, "rejected epoch published a tab size") | |||
| 458 | + | p := tb.focusedPane() | |||
| 459 | + | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "rejected epoch published a pane rectangle") | |||
| 460 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "rejected epoch published a VT size") | |||
| 461 | + | } | |||
| 462 | + | ||||
| 463 | + | require.True(t, d.runResizeTransaction(sess, ac, lease, newer)) | |||
| 464 | + | for _, tb := range sess.tabs { | |||
| 465 | + | require.Equal(t, domain.Size{Cols: 120, Rows: 32}, tb.size) | |||
| 466 | + | p := tb.focusedPane() | |||
| 467 | + | require.Equal(t, domain.Rect{Width: 120, Height: 32}, p.rect) | |||
| 468 | + | require.Equal(t, domain.Size{Cols: 120, Rows: 32}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}) | |||
| 469 | + | } | |||
| 470 | + | committed := 0 | |||
| 471 | + | for _, mark := range observer.marks { | |||
| 472 | + | if mark.Kind == ports.RuntimeResizeCommitted { | |||
| 473 | + | committed++ | |||
| 474 | + | } | |||
| 475 | + | } | |||
| 476 | + | require.Equal(t, 1, committed, "only the current epoch emits commit telemetry") | |||
| 477 | + | require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}}, first.requested()) | |||
| 478 | + | require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}}, second.requested()) | |||
| 479 | + | } | |||
| 480 | + | ||||
| 426 | func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testing.T) { | 481 | func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testing.T) { | ||
| 427 | clock := newCoordinatorMockClock(t, 8) | 482 | clock := newCoordinatorMockClock(t, 8) | ||
| 428 | pty := &transactionalResizePTY{} | 483 | pty := &transactionalResizePTY{} | ||