detached 50d230a

fix(daemon): admit resize epochs before publication

commit
50d230aa74ce64c580451415cf9e437f683fd28a
author
brice · · 2026-07-25 05:40 UTC
parent
c11de37
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
105105 // beforeCopyMouseMap is a deterministic seam after an immutable copy-input
106106 // snapshot is captured and before its mapped position is applied.
107107 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
118122 // snapshotRepository is the sole checkpoint contract. legacySnapshots is
119123 // read-only migration input and is never used for new writes.
120124 snapshotRepository ports.SnapshotRepository
@@ -105,16 +105,20 @@ type Daemon struct {
105 // beforeCopyMouseMap is a deterministic seam after an immutable copy-input105 // 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.PTYFactory108+ // beforeSessionResizePublication runs after final external PTY validation
109- clock ports.Clock109+ // and before coordinator epoch admission. It is a deterministic regression
110- log *slog.Logger110+ // seam for stale resize publication.
111- runtimeObserver ports.RuntimeObserver111+ beforeSessionResizePublication func()
112- baseEnv []string112+ ptys ports.PTYFactory
113- shell string113+ clock ports.Clock
114- shellArgs []string114+ log *slog.Logger
115- shellOverride bool115+ runtimeObserver ports.RuntimeObserver
116- persist *persist.Persister116+ baseEnv []string
117- persistEnabled bool117+ shell string
118+ shellArgs []string
119+ shellOverride bool
120+ persist *persist.Persister
121+ persistEnabled bool
118 // snapshotRepository is the sole checkpoint contract. legacySnapshots is122 // 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.SnapshotRepository124 snapshotRepository ports.SnapshotRepository
M internal/usecase/daemon/mouse_panes.go +58 -0
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "math"
45 "strconv"
56 "strings"
67
@@ -66,10 +67,49 @@ func solveTabLayoutLocked(tb *tab) tabLayoutSnapshot {
6667
6768 func layoutFingerprint(root *layout.Node) string {
6869 var b strings.Builder
70+ b.Grow(layoutFingerprintLength(root))
6971 writeLayoutFingerprint(&b, root)
7072 return b.String()
7173 }
7274
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+
73113 func writeLayoutFingerprint(b *strings.Builder, n *layout.Node) {
74114 if n == nil {
75115 b.WriteByte('0')
@@ -77,6 +117,8 @@ func writeLayoutFingerprint(b *strings.Builder, n *layout.Node) {
77117 }
78118 b.WriteByte(byte('0' + n.Kind))
79119 b.WriteByte(byte('0' + n.Dir))
120+ writeWeightFingerprint(b, n.Weight)
121+ b.WriteByte('|')
80122 writePaneIDFingerprint(b, n.Leaf)
81123 b.WriteByte('|')
82124 writePaneIDFingerprint(b, n.Expanded)
@@ -88,6 +130,22 @@ func writeLayoutFingerprint(b *strings.Builder, n *layout.Node) {
88130 b.WriteByte(']')
89131 }
90132
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+
91149 func writePaneIDFingerprint(b *strings.Builder, id layout.PaneID) {
92150 s := string(id)
93151 b.WriteString(strconv.Itoa(len(s)))
@@ -1,6 +1,7 @@
1 package daemon1 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.Builder69 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) {
339339 awaitSignal(t, entered, "old size apply did not block")
340340 resizeDone := make(chan bool, 1)
341341 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()
347348 require.Equal(t, domain.Rect{Width: 20, Height: 10}, p.rect, "stale size published before release")
348349 close(release)
349350 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 > 0345+ 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
125125 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage)
126126 }
127127
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+
128167 func TestComposeFrameUsesCachedNeutralStructuralBorder(t *testing.T) {
129168 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}}
130169 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 {
105105 syncGen atomic.Uint64
106106 // coordinator fans in this session's producer render invalidations.
107107 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
108112 // clipFiles records clipboard-image-transfer temp file paths (see
109113 // clipboard.go) written for this session, removed best-effort in
110114 // killSession.
@@ -105,6 +105,10 @@ type session struct {
105 syncGen atomic.Uint64105 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 (see112 // clipFiles records clipboard-image-transfer temp file paths (see
109 // clipboard.go) written for this session, removed best-effort in113 // 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 {
2828 }
2929
3030 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
3436 }
3537
3638 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() {
3945 return plan
4046 }
41- area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
47+ area := domain.Rect{Width: size.Cols, Height: size.Rows}
4248 placements, ok := layout.Solve(tb.tree.Root, area)
4349 if !ok && tb.tree.Root.Kind == layout.Leaf {
4450 placements = []layout.Placement{{ID: tb.tree.Root.Leaf, Content: area}}
@@ -96,7 +102,7 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) {
96102 }
97103
98104 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 {
100106 return false
101107 }
102108 for i := range plan.members {
@@ -474,47 +480,127 @@ func (d *Daemon) applyVisibleFloatingLayout(sess *session, tb *tab, current func
474480 return plan.members, true
475481 }
476482
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+
482516 target := tabSize(size)
483- for _, tb := range tabs {
517+ for {
484518 if current != nil && !current() {
485519 return nil, false
486520 }
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)
491531 }
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)
502534 }
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
505578 }
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)
509591 if !ok {
510592 return nil, false
511593 }
512- failed = append(failed, members...)
594+ failed = append(failed, floatingFailed...)
513595 }
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
514603 }
515- markSnapshotDirty(sess)
516- d.observeRuntime(ports.RuntimeResizeCommitted, 0, true)
517- return failed, true
518604 }
519605
520606 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 *
528614 return false
529615 }
530616 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 {
533621 return false
534622 }
535623 ac.sendMu.Lock()
@@ -658,7 +746,7 @@ func (d *Daemon) requestTransactionalResizeForLease(sess *session, ac *attachedC
658746 if ac == nil {
659747 // Headless geometry has no coordinator/transport to coalesce, but keeps
660748 // the same prepare/apply/commit ordering.
661- _, ok := d.applySessionLayout(sess, size, nil)
749+ _, ok := d.applySessionLayout(sess, size, nil, nil)
662750 return ok
663751 }
664752 rc := sess.renderCoordinator()
@@ -28,17 +28,23 @@ type resizePlan struct {
28 }28 }
29 29
30 type preparedTabLayout struct {30 type preparedTabLayout struct {
31- generation uint6431+ tab *tab
32- size domain.Size32+ generation uint64
33- members []resizeMember33+ 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 plan45 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 false106 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, true480 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.active486+// 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, false519 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 = target523+ 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, false536+ // 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, false592 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 false614 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 false621 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 keeps747 // 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 ok750 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) {
423423 // stopped. Only the newest prepared epoch may apply, commit, and emit one full
424424 // frame. coordinatorMockClock is a generated MockClock/MockTimer harness; its
425425 // 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+
426481 func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testing.T) {
427482 clock := newCoordinatorMockClock(t, 8)
428483 pty := &transactionalResizePTY{}
@@ -423,6 +423,61 @@ func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) {
423 // stopped. Only the newest prepared epoch may apply, commit, and emit one full423 // stopped. Only the newest prepared epoch may apply, commit, and emit one full
424 // frame. coordinatorMockClock is a generated MockClock/MockTimer harness; its424 // 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{}

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