detached 25f341a

fix(daemon): surface pane resize failures

commit
25f341a386d81fabfdb29cce0b48fec2260b0b8a
author
brice · · 2026-07-21 05:02 UTC
parent
a2b1d86
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +66 -1
M internal/usecase/daemon/performance_benchmark_test.go +1 -1
@@ -132,7 +132,7 @@ func TestTransactionalResizeMetrics(t *testing.T) {
132132 require.Equal(t, uint64(1), metrics.resizeCommits)
133133 require.Equal(t, uint64(1), metrics.ptyFailures)
134134 require.Equal(t, uint64(1), metrics.ptyRetries)
135- require.Equal(t, uint64(2), metrics.outputFrames, "commit plus successful full-reset retry")
135+ require.Equal(t, uint64(3), metrics.outputFrames, "commit, resize-failure notice toast, and successful full-reset retry")
136136 require.Zero(t, metrics.frameGapEpochs, "every committed epoch emitted its required frame")
137137 require.Equal(t, []domain.Size{{Cols: 50, Rows: 28}, {Cols: 50, Rows: 28}}, fixture.pty.requested())
138138 require.Equal(t, domain.Size{Cols: 100, Rows: 30}, fixture.ac.size)
@@ -132,7 +132,7 @@ func TestTransactionalResizeMetrics(t *testing.T) {
132 require.Equal(t, uint64(1), metrics.resizeCommits)132 require.Equal(t, uint64(1), metrics.resizeCommits)
133 require.Equal(t, uint64(1), metrics.ptyFailures)133 require.Equal(t, uint64(1), metrics.ptyFailures)
134 require.Equal(t, uint64(1), metrics.ptyRetries)134 require.Equal(t, uint64(1), metrics.ptyRetries)
135- require.Equal(t, uint64(2), metrics.outputFrames, "commit plus successful full-reset retry")135+ require.Equal(t, uint64(3), metrics.outputFrames, "commit, resize-failure notice toast, and successful full-reset retry")
136 require.Zero(t, metrics.frameGapEpochs, "every committed epoch emitted its required frame")136 require.Zero(t, metrics.frameGapEpochs, "every committed epoch emitted its required frame")
137 require.Equal(t, []domain.Size{{Cols: 50, Rows: 28}, {Cols: 50, Rows: 28}}, fixture.pty.requested())137 require.Equal(t, []domain.Size{{Cols: 50, Rows: 28}, {Cols: 50, Rows: 28}}, fixture.pty.requested())
138 require.Equal(t, domain.Size{Cols: 100, Rows: 30}, fixture.ac.size)138 require.Equal(t, domain.Size{Cols: 100, Rows: 30}, fixture.ac.size)
M internal/usecase/daemon/transactional_resize.go +14 -0
@@ -71,6 +71,11 @@ func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan {
7171 // applyResize deliberately holds only a pane's resizeMu around its PTY call;
7272 // no daemon, session, tab, or pane lock crosses the external boundary.
7373 func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool {
74+ // Collect degradation across the loop and report once after it returns to
75+ // lock-free code: notify acquires sess.mu/d.mu and repaints, so it must never
76+ // run while a pane's resizeMu is held across the external PTY call below.
77+ failed := 0
78+ var lastErr error
7479 for i := range plan.members {
7580 if len(current) != 0 && !current[0]() {
7681 return false
@@ -91,6 +96,8 @@ func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool {
9196 m.ok = true
9297 } else if err := pty.Resize(rectSize(m.rect)); err != nil {
9398 d.log.Warn("pty resize failed", "err", err)
99+ failed++
100+ lastErr = err
94101 d.replayResizePending(m.session, m.tab, m.pane, false, m.rect)
95102 } else {
96103 m.ok = true
@@ -99,6 +106,13 @@ func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool {
99106 }
100107 m.pane.resizeMu.Unlock()
101108 }
109+ if failed > 0 {
110+ // Every resizeMu (and pane/session/daemon lock) is released here: each was
111+ // bracketed inside the loop. plan.members is non-empty because a failure
112+ // came from one of its members, so members[0] is always safe.
113+ d.notify(plan.members[0].session, domain.NoticeWarn, domain.NoticeResizeFailed,
114+ "pane resize failed; retrying in background", lastErr)
115+ }
102116 return true
103117 }
104118
@@ -71,6 +71,11 @@ func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan {
71 // applyResize deliberately holds only a pane's resizeMu around its PTY call;71 // applyResize deliberately holds only a pane's resizeMu around its PTY call;
72 // no daemon, session, tab, or pane lock crosses the external boundary.72 // no daemon, session, tab, or pane lock crosses the external boundary.
73 func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool {73 func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool {
74+ // Collect degradation across the loop and report once after it returns to
75+ // lock-free code: notify acquires sess.mu/d.mu and repaints, so it must never
76+ // run while a pane's resizeMu is held across the external PTY call below.
77+ failed := 0
78+ var lastErr error
74 for i := range plan.members {79 for i := range plan.members {
75 if len(current) != 0 && !current[0]() {80 if len(current) != 0 && !current[0]() {
76 return false81 return false
@@ -91,6 +96,8 @@ func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool {
91 m.ok = true96 m.ok = true
92 } else if err := pty.Resize(rectSize(m.rect)); err != nil {97 } else if err := pty.Resize(rectSize(m.rect)); err != nil {
93 d.log.Warn("pty resize failed", "err", err)98 d.log.Warn("pty resize failed", "err", err)
99+ failed++
100+ lastErr = err
94 d.replayResizePending(m.session, m.tab, m.pane, false, m.rect)101 d.replayResizePending(m.session, m.tab, m.pane, false, m.rect)
95 } else {102 } else {
96 m.ok = true103 m.ok = true
@@ -99,6 +106,13 @@ func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool {
99 }106 }
100 m.pane.resizeMu.Unlock()107 m.pane.resizeMu.Unlock()
101 }108 }
109+ if failed > 0 {
110+ // Every resizeMu (and pane/session/daemon lock) is released here: each was
111+ // bracketed inside the loop. plan.members is non-empty because a failure
112+ // came from one of its members, so members[0] is always safe.
113+ d.notify(plan.members[0].session, domain.NoticeWarn, domain.NoticeResizeFailed,
114+ "pane resize failed; retrying in background", lastErr)
115+ }
102 return true116 return true
103 }117 }
104 118
M internal/usecase/daemon/transactional_resize_test.go +51 -0
@@ -204,6 +204,57 @@ func TestTransactionalResizeApplyDoesNotHoldTabOrPaneLocks(t *testing.T) {
204204 require.True(t, paneUnlocked, "PTY.Resize must run outside pane.mu")
205205 }
206206
207+// P3 degradation: a failing PTY resize is surfaced as exactly one warn notice
208+// per applyResize call, collected inside the resizeMu loop and reported once
209+// after it returns to lock-free code. The count is the meaningful assertion:
210+// notify acquires sess.mu/d.mu and must never run while any resizeMu is held.
211+func TestTransactionalResizeApplyReportsFailureOncePerCall(t *testing.T) {
212+ countResizeFailed := func(d *Daemon) (int, domain.Notification) {
213+ var last domain.Notification
214+ count := 0
215+ for _, h := range d.notices.history() {
216+ if h.Code == domain.NoticeResizeFailed {
217+ count++
218+ last = h
219+ }
220+ }
221+ return count, last
222+ }
223+
224+ t.Run("one failed member yields one notice after return", func(t *testing.T) {
225+ failing := &transactionalResizePTY{errs: []error{errors.New("scripted resize failure")}}
226+ d, sess, _, _ := newManualSessionWithPTYs(t, failing)
227+ tb := sess.activeTab()
228+ p := tb.focusedPane()
229+ plan := resizePlan{members: []resizeMember{{session: sess, tab: tb, pane: p, rect: domain.Rect{Width: 100, Height: 20}, retry: true}}}
230+
231+ require.Empty(t, d.notices.history(), "no notice is recorded before applyResize")
232+ require.True(t, d.applyResize(&plan))
233+
234+ count, n := countResizeFailed(d)
235+ require.Equal(t, 1, count, "exactly one NoticeResizeFailed per applyResize call")
236+ require.Equal(t, domain.NoticeWarn, n.Severity)
237+ require.Equal(t, "pane resize failed; retrying in background", n.Message)
238+ require.False(t, plan.members[0].ok, "failed member is not marked ok")
239+ })
240+
241+ t.Run("two failed members still yield one notice per call", func(t *testing.T) {
242+ first := &transactionalResizePTY{errs: []error{errors.New("first fails")}}
243+ second := &transactionalResizePTY{errs: []error{errors.New("second fails")}}
244+ d, sess, _, _ := newManualSessionWithPTYs(t, first, second)
245+ tabs := sess.tabs
246+ plan := resizePlan{members: []resizeMember{
247+ {session: sess, tab: tabs[0], pane: tabs[0].focusedPane(), rect: domain.Rect{Width: 100, Height: 20}, retry: true},
248+ {session: sess, tab: tabs[1], pane: tabs[1].focusedPane(), rect: domain.Rect{Width: 100, Height: 20}, retry: true},
249+ }}
250+
251+ require.True(t, d.applyResize(&plan))
252+
253+ count, _ := countResizeFailed(d)
254+ require.Equal(t, 1, count, "one notice per applyResize call, not per failed member")
255+ })
256+}
257+
207258 // S3 acceptance: a failed member never publishes speculative parser/screen or
208259 // rectangle state. Successful members and the client layout commit together;
209260 // composition clips/pads the retained failed screen against that new layout.
@@ -204,6 +204,57 @@ func TestTransactionalResizeApplyDoesNotHoldTabOrPaneLocks(t *testing.T) {
204 require.True(t, paneUnlocked, "PTY.Resize must run outside pane.mu")204 require.True(t, paneUnlocked, "PTY.Resize must run outside pane.mu")
205 }205 }
206 206
207+// P3 degradation: a failing PTY resize is surfaced as exactly one warn notice
208+// per applyResize call, collected inside the resizeMu loop and reported once
209+// after it returns to lock-free code. The count is the meaningful assertion:
210+// notify acquires sess.mu/d.mu and must never run while any resizeMu is held.
211+func TestTransactionalResizeApplyReportsFailureOncePerCall(t *testing.T) {
212+ countResizeFailed := func(d *Daemon) (int, domain.Notification) {
213+ var last domain.Notification
214+ count := 0
215+ for _, h := range d.notices.history() {
216+ if h.Code == domain.NoticeResizeFailed {
217+ count++
218+ last = h
219+ }
220+ }
221+ return count, last
222+ }
223+
224+ t.Run("one failed member yields one notice after return", func(t *testing.T) {
225+ failing := &transactionalResizePTY{errs: []error{errors.New("scripted resize failure")}}
226+ d, sess, _, _ := newManualSessionWithPTYs(t, failing)
227+ tb := sess.activeTab()
228+ p := tb.focusedPane()
229+ plan := resizePlan{members: []resizeMember{{session: sess, tab: tb, pane: p, rect: domain.Rect{Width: 100, Height: 20}, retry: true}}}
230+
231+ require.Empty(t, d.notices.history(), "no notice is recorded before applyResize")
232+ require.True(t, d.applyResize(&plan))
233+
234+ count, n := countResizeFailed(d)
235+ require.Equal(t, 1, count, "exactly one NoticeResizeFailed per applyResize call")
236+ require.Equal(t, domain.NoticeWarn, n.Severity)
237+ require.Equal(t, "pane resize failed; retrying in background", n.Message)
238+ require.False(t, plan.members[0].ok, "failed member is not marked ok")
239+ })
240+
241+ t.Run("two failed members still yield one notice per call", func(t *testing.T) {
242+ first := &transactionalResizePTY{errs: []error{errors.New("first fails")}}
243+ second := &transactionalResizePTY{errs: []error{errors.New("second fails")}}
244+ d, sess, _, _ := newManualSessionWithPTYs(t, first, second)
245+ tabs := sess.tabs
246+ plan := resizePlan{members: []resizeMember{
247+ {session: sess, tab: tabs[0], pane: tabs[0].focusedPane(), rect: domain.Rect{Width: 100, Height: 20}, retry: true},
248+ {session: sess, tab: tabs[1], pane: tabs[1].focusedPane(), rect: domain.Rect{Width: 100, Height: 20}, retry: true},
249+ }}
250+
251+ require.True(t, d.applyResize(&plan))
252+
253+ count, _ := countResizeFailed(d)
254+ require.Equal(t, 1, count, "one notice per applyResize call, not per failed member")
255+ })
256+}
257+
207 // S3 acceptance: a failed member never publishes speculative parser/screen or258 // S3 acceptance: a failed member never publishes speculative parser/screen or
208 // rectangle state. Successful members and the client layout commit together;259 // rectangle state. Successful members and the client layout commit together;
209 // composition clips/pads the retained failed screen against that new layout.260 // composition clips/pads the retained failed screen against that new layout.

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