fix(daemon): preserve buffered redraws through resize
- commit
- 84b17816ab57546cec7dd981d6beea750627fee9
- author
- brice · · 2026-07-12 09:56 UTC
- parent
- 24d8746
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +174 -23
M internal/usecase/daemon/output_state_sync_test.go +28 -9
| @@ -279,30 +279,49 @@ func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) { | |||
| 279 | 279 | {name: "stream window", window: maxUnackedOutputStates}, | |
| 280 | 280 | } { | |
| 281 | 281 | t.Run(tc.name, func(t *testing.T) { | |
| 282 | - | p, releasePTY := newBlockingPTY(t) | |
| 283 | - | defer releasePTY() | |
| 282 | + | // resizeReaderPTY delivers the child's SIGWINCH redraw through the | |
| 283 | + | // actual ptyReader. Its Resize callback does not return until Read has | |
| 284 | + | // accepted the bytes, at which point resizeApplying is still true. | |
| 285 | + | p := newResizeReaderPTY([]byte("\x1b[1;81H" + strings.Repeat("B", 40))) | |
| 284 | 286 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 285 | 287 | ac.output.maxOutstanding = tc.window | |
| 286 | 288 | pane := sess.activeTab().focusedPane() | |
| 287 | - | ||
| 289 | + | p.applying = func() bool { | |
| 290 | + | pane.mu.Lock() | |
| 291 | + | defer pane.mu.Unlock() | |
| 292 | + | return pane.resizeApplying | |
| 293 | + | } | |
| 288 | 294 | pane.screen.Write([]byte(strings.Repeat("A", 79))) | |
| 289 | 295 | d.paint(sess, ac, true) | |
| 290 | 296 | client := vt.NewScreen(80, 24) | |
| 291 | 297 | initial := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |
| 292 | 298 | ac.ackOutputState(initial.NewStateNum) | |
| 293 | 299 | ||
| 294 | - | // The child can redraw the newly exposed columns while the resize | |
| 295 | - | // output is being prepared. The first visible grown frame must contain | |
| 296 | - | // that redraw, regardless of the negotiated output window. | |
| 300 | + | d.sessWg.Add(1) | |
| 301 | + | pane.onExit = func() {} | |
| 302 | + | go d.ptyReader(sess, sess.activeTab(), pane) | |
| 303 | + | defer func() { p.close(); d.sessWg.Wait() }() | |
| 304 | + | ||
| 305 | + | // The resize deadline drives coordinator prepare/apply/commit. No test | |
| 306 | + | // writes the grown columns directly. | |
| 307 | + | clock := &signalClock{timers: make(chan *signalTimer, 2)} | |
| 308 | + | d.clock = clock | |
| 297 | 309 | d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24}) | |
| 298 | - | pane.screen.Write([]byte("\x1b[1;81H" + strings.Repeat("B", 40))) | |
| 299 | - | d.paint(sess, ac, false) | |
| 310 | + | timer := <-clock.timers | |
| 311 | + | timer.ch <- time.Time{} | |
| 312 | + | awaitResizeCallback(t, sess.renderCoordinator()) | |
| 313 | + | require.True(t, p.deliveredWhileApplying(), "redraw must arrive while PTY.Resize owns the apply gate") | |
| 314 | + | require.Equal(t, 'B', pane.screen.Frame.At(100, 0).Rune, "replay must parse the redraw before commit") | |
| 300 | 315 | ||
| 301 | 316 | client.Resize(120, 24) | |
| 302 | 317 | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |
| 303 | 318 | require.Equal(t, 'B', client.Frame.At(100, 1).Rune, | |
| 304 | 319 | "first grown frame exposed stale pre-SIGWINCH pane content") | |
| 305 | - | requireNoOutputFrame(t, sends) | |
| 320 | + | select { | |
| 321 | + | case extra := <-sends: | |
| 322 | + | t.Fatalf("resize emitted more than one frame: %#v", extra) | |
| 323 | + | default: | |
| 324 | + | } | |
| 306 | 325 | }) | |
| 307 | 326 | } | |
| 308 | 327 | } | |
| @@ -279,30 +279,49 @@ func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) { | |||||
| 279 | {name: "stream window", window: maxUnackedOutputStates}, | 279 | {name: "stream window", window: maxUnackedOutputStates}, | ||
| 280 | } { | 280 | } { | ||
| 281 | t.Run(tc.name, func(t *testing.T) { | 281 | t.Run(tc.name, func(t *testing.T) { | ||
| 282 | - | p, releasePTY := newBlockingPTY(t) | 282 | + | // resizeReaderPTY delivers the child's SIGWINCH redraw through the |
| 283 | - | defer releasePTY() | 283 | + | // actual ptyReader. Its Resize callback does not return until Read has |
| 284 | + | // accepted the bytes, at which point resizeApplying is still true. | |||
| 285 | + | p := newResizeReaderPTY([]byte("\x1b[1;81H" + strings.Repeat("B", 40))) | |||
| 284 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 286 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 285 | ac.output.maxOutstanding = tc.window | 287 | ac.output.maxOutstanding = tc.window | ||
| 286 | pane := sess.activeTab().focusedPane() | 288 | pane := sess.activeTab().focusedPane() | ||
| 287 | - | 289 | + | p.applying = func() bool { | |
| 290 | + | pane.mu.Lock() | |||
| 291 | + | defer pane.mu.Unlock() | |||
| 292 | + | return pane.resizeApplying | |||
| 293 | + | } | |||
| 288 | pane.screen.Write([]byte(strings.Repeat("A", 79))) | 294 | pane.screen.Write([]byte(strings.Repeat("A", 79))) | ||
| 289 | d.paint(sess, ac, true) | 295 | d.paint(sess, ac, true) | ||
| 290 | client := vt.NewScreen(80, 24) | 296 | client := vt.NewScreen(80, 24) | ||
| 291 | initial := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | 297 | initial := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | ||
| 292 | ac.ackOutputState(initial.NewStateNum) | 298 | ac.ackOutputState(initial.NewStateNum) | ||
| 293 | 299 | ||||
| 294 | - | // The child can redraw the newly exposed columns while the resize | 300 | + | d.sessWg.Add(1) |
| 295 | - | // output is being prepared. The first visible grown frame must contain | 301 | + | pane.onExit = func() {} |
| 296 | - | // that redraw, regardless of the negotiated output window. | 302 | + | go d.ptyReader(sess, sess.activeTab(), pane) |
| 303 | + | defer func() { p.close(); d.sessWg.Wait() }() | |||
| 304 | + | ||||
| 305 | + | // The resize deadline drives coordinator prepare/apply/commit. No test | |||
| 306 | + | // writes the grown columns directly. | |||
| 307 | + | clock := &signalClock{timers: make(chan *signalTimer, 2)} | |||
| 308 | + | d.clock = clock | |||
| 297 | d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24}) | 309 | d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24}) | ||
| 298 | - | pane.screen.Write([]byte("\x1b[1;81H" + strings.Repeat("B", 40))) | 310 | + | timer := <-clock.timers |
| 299 | - | d.paint(sess, ac, false) | 311 | + | timer.ch <- time.Time{} |
| 312 | + | awaitResizeCallback(t, sess.renderCoordinator()) | |||
| 313 | + | require.True(t, p.deliveredWhileApplying(), "redraw must arrive while PTY.Resize owns the apply gate") | |||
| 314 | + | require.Equal(t, 'B', pane.screen.Frame.At(100, 0).Rune, "replay must parse the redraw before commit") | |||
| 300 | 315 | ||||
| 301 | client.Resize(120, 24) | 316 | client.Resize(120, 24) | ||
| 302 | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | 317 | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | ||
| 303 | require.Equal(t, 'B', client.Frame.At(100, 1).Rune, | 318 | require.Equal(t, 'B', client.Frame.At(100, 1).Rune, | ||
| 304 | "first grown frame exposed stale pre-SIGWINCH pane content") | 319 | "first grown frame exposed stale pre-SIGWINCH pane content") | ||
| 305 | - | requireNoOutputFrame(t, sends) | 320 | + | select { |
| 321 | + | case extra := <-sends: | |||
| 322 | + | t.Fatalf("resize emitted more than one frame: %#v", extra) | |||
| 323 | + | default: | |||
| 324 | + | } | |||
| 306 | }) | 325 | }) | ||
| 307 | } | 326 | } | ||
| 308 | } | 327 | } | ||
M internal/usecase/daemon/transactional_resize.go +17 -12
| @@ -8,14 +8,15 @@ import ( | |||
| 8 | 8 | // resizeMember is an unpublished layout decision. Prepare only reads guarded | |
| 9 | 9 | // state; apply performs the external PTY call; commit is the sole publisher. | |
| 10 | 10 | type resizeMember struct { | |
| 11 | - | session *session | |
| 12 | - | tab *tab | |
| 13 | - | pane *pane | |
| 14 | - | rect domain.Rect | |
| 15 | - | floating floatingGeometry | |
| 16 | - | isFloating bool | |
| 17 | - | retry bool | |
| 18 | - | ok bool | |
| 11 | + | session *session | |
| 12 | + | tab *tab | |
| 13 | + | pane *pane | |
| 14 | + | rect domain.Rect | |
| 15 | + | floating floatingGeometry | |
| 16 | + | isFloating bool | |
| 17 | + | retry bool | |
| 18 | + | ok bool | |
| 19 | + | screenResized bool | |
| 19 | 20 | } | |
| 20 | 21 | ||
| 21 | 22 | type resizePlan struct { | |
| @@ -93,6 +94,7 @@ func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool { | |||
| 93 | 94 | } else { | |
| 94 | 95 | m.ok = true | |
| 95 | 96 | d.replayResizePending(m.session, m.tab, m.pane, true, m.rect) | |
| 97 | + | m.screenResized = true | |
| 96 | 98 | } | |
| 97 | 99 | m.pane.resizeMu.Unlock() | |
| 98 | 100 | } | |
| @@ -148,7 +150,7 @@ func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan | |||
| 148 | 150 | if m.isFloating { | |
| 149 | 151 | m.pane.popupGeometry = m.floating | |
| 150 | 152 | } | |
| 151 | - | if m.ok { | |
| 153 | + | if m.ok && !m.screenResized { | |
| 152 | 154 | m.pane.screen.Resize(m.rect.Width, m.rect.Height) | |
| 153 | 155 | } | |
| 154 | 156 | m.pane.mu.Unlock() | |
| @@ -223,9 +225,12 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, epoch uin | |||
| 223 | 225 | continue | |
| 224 | 226 | } | |
| 225 | 227 | member.pane.mu.Lock() | |
| 226 | - | // The rect is the already committed layout target. Resize VT only now, | |
| 227 | - | // then force a reset so renderer shadow cannot retain padded cells. | |
| 228 | - | member.pane.screen.Resize(member.rect.Width, member.rect.Height) | |
| 228 | + | // The rect is the already committed layout target. A successful apply | |
| 229 | + | // already resized before replaying buffered bytes; PTY-less members still | |
| 230 | + | // need their VT resized here. Force a reset below in either case. | |
| 231 | + | if !member.screenResized { | |
| 232 | + | member.pane.screen.Resize(member.rect.Width, member.rect.Height) | |
| 233 | + | } | |
| 229 | 234 | member.pane.mu.Unlock() | |
| 230 | 235 | succeeded = true | |
| 231 | 236 | } | |
| @@ -8,14 +8,15 @@ import ( | |||||
| 8 | // resizeMember is an unpublished layout decision. Prepare only reads guarded | 8 | // resizeMember is an unpublished layout decision. Prepare only reads guarded | ||
| 9 | // state; apply performs the external PTY call; commit is the sole publisher. | 9 | // state; apply performs the external PTY call; commit is the sole publisher. | ||
| 10 | type resizeMember struct { | 10 | type resizeMember struct { | ||
| 11 | - | session *session | 11 | + | session *session |
| 12 | - | tab *tab | 12 | + | tab *tab |
| 13 | - | pane *pane | 13 | + | pane *pane |
| 14 | - | rect domain.Rect | 14 | + | rect domain.Rect |
| 15 | - | floating floatingGeometry | 15 | + | floating floatingGeometry |
| 16 | - | isFloating bool | 16 | + | isFloating bool |
| 17 | - | retry bool | 17 | + | retry bool |
| 18 | - | ok bool | 18 | + | ok bool |
| 19 | + | screenResized bool | |||
| 19 | } | 20 | } | ||
| 20 | 21 | ||||
| 21 | type resizePlan struct { | 22 | type resizePlan struct { | ||
| @@ -93,6 +94,7 @@ func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool { | |||||
| 93 | } else { | 94 | } else { | ||
| 94 | m.ok = true | 95 | m.ok = true | ||
| 95 | d.replayResizePending(m.session, m.tab, m.pane, true, m.rect) | 96 | d.replayResizePending(m.session, m.tab, m.pane, true, m.rect) | ||
| 97 | + | m.screenResized = true | |||
| 96 | } | 98 | } | ||
| 97 | m.pane.resizeMu.Unlock() | 99 | m.pane.resizeMu.Unlock() | ||
| 98 | } | 100 | } | ||
| @@ -148,7 +150,7 @@ func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan | |||||
| 148 | if m.isFloating { | 150 | if m.isFloating { | ||
| 149 | m.pane.popupGeometry = m.floating | 151 | m.pane.popupGeometry = m.floating | ||
| 150 | } | 152 | } | ||
| 151 | - | if m.ok { | 153 | + | if m.ok && !m.screenResized { |
| 152 | m.pane.screen.Resize(m.rect.Width, m.rect.Height) | 154 | m.pane.screen.Resize(m.rect.Width, m.rect.Height) | ||
| 153 | } | 155 | } | ||
| 154 | m.pane.mu.Unlock() | 156 | m.pane.mu.Unlock() | ||
| @@ -223,9 +225,12 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, epoch uin | |||||
| 223 | continue | 225 | continue | ||
| 224 | } | 226 | } | ||
| 225 | member.pane.mu.Lock() | 227 | member.pane.mu.Lock() | ||
| 226 | - | // The rect is the already committed layout target. Resize VT only now, | 228 | + | // The rect is the already committed layout target. A successful apply |
| 227 | - | // then force a reset so renderer shadow cannot retain padded cells. | 229 | + | // already resized before replaying buffered bytes; PTY-less members still |
| 228 | - | member.pane.screen.Resize(member.rect.Width, member.rect.Height) | 230 | + | // need their VT resized here. Force a reset below in either case. |
| 231 | + | if !member.screenResized { | |||
| 232 | + | member.pane.screen.Resize(member.rect.Width, member.rect.Height) | |||
| 233 | + | } | |||
| 229 | member.pane.mu.Unlock() | 234 | member.pane.mu.Unlock() | ||
| 230 | succeeded = true | 235 | succeeded = true | ||
| 231 | } | 236 | } | ||
M internal/usecase/daemon/transactional_resize_test.go +129 -2
| @@ -22,6 +22,7 @@ type transactionalResizePTY struct { | |||
| 22 | 22 | sizes []domain.Size | |
| 23 | 23 | errs []error | |
| 24 | 24 | onResize func() | |
| 25 | + | onWrite func([]byte) | |
| 25 | 26 | } | |
| 26 | 27 | ||
| 27 | 28 | func (p *transactionalResizePTY) Resize(size domain.Size) error { | |
| @@ -39,8 +40,16 @@ func (p *transactionalResizePTY) Resize(size domain.Size) error { | |||
| 39 | 40 | } | |
| 40 | 41 | return err | |
| 41 | 42 | } | |
| 42 | - | func (*transactionalResizePTY) Read([]byte) (int, error) { return 0, io.EOF } | |
| 43 | - | func (*transactionalResizePTY) Write(b []byte) (int, error) { return len(b), nil } | |
| 43 | + | func (*transactionalResizePTY) Read([]byte) (int, error) { return 0, io.EOF } | |
| 44 | + | func (p *transactionalResizePTY) Write(b []byte) (int, error) { | |
| 45 | + | p.mu.Lock() | |
| 46 | + | hook := p.onWrite | |
| 47 | + | p.mu.Unlock() | |
| 48 | + | if hook != nil { | |
| 49 | + | hook(append([]byte(nil), b...)) | |
| 50 | + | } | |
| 51 | + | return len(b), nil | |
| 52 | + | } | |
| 44 | 53 | func (*transactionalResizePTY) Close() error { return nil } | |
| 45 | 54 | func (*transactionalResizePTY) Pid() int { return 0 } | |
| 46 | 55 | func (*transactionalResizePTY) ForegroundPgid() (int, error) { return 0, nil } | |
| @@ -50,6 +59,124 @@ func (p *transactionalResizePTY) requested() []domain.Size { | |||
| 50 | 59 | return append([]domain.Size(nil), p.sizes...) | |
| 51 | 60 | } | |
| 52 | 61 | ||
| 62 | + | // resizeReaderPTY is a deterministic child: Resize releases its redraw only | |
| 63 | + | // after the daemon has installed resizeApplying, and waits until ptyReader has | |
| 64 | + | // accepted that read. It deliberately has no timing dependency. | |
| 65 | + | type resizeReaderPTY struct { | |
| 66 | + | redraw []byte | |
| 67 | + | reads chan []byte | |
| 68 | + | accepted chan struct{} | |
| 69 | + | closed chan struct{} | |
| 70 | + | closeOnce sync.Once | |
| 71 | + | mu sync.Mutex | |
| 72 | + | applying func() bool | |
| 73 | + | delivered bool | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func newResizeReaderPTY(redraw []byte) *resizeReaderPTY { | |
| 77 | + | return &resizeReaderPTY{redraw: redraw, reads: make(chan []byte), accepted: make(chan struct{}), closed: make(chan struct{})} | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func (p *resizeReaderPTY) Resize(domain.Size) error { | |
| 81 | + | p.reads <- p.redraw | |
| 82 | + | <-p.accepted | |
| 83 | + | // A second, empty read cannot begin until ptyReader has processed the | |
| 84 | + | // redraw from the first read. This is a channel rendezvous, not a timing | |
| 85 | + | // assumption, and proves the redraw entered resizePending before Resize | |
| 86 | + | // returns to replay it. | |
| 87 | + | p.reads <- nil | |
| 88 | + | <-p.accepted | |
| 89 | + | p.mu.Lock() | |
| 90 | + | p.delivered = p.applying != nil && p.applying() | |
| 91 | + | p.mu.Unlock() | |
| 92 | + | return nil | |
| 93 | + | } | |
| 94 | + | func (p *resizeReaderPTY) Read(dst []byte) (int, error) { | |
| 95 | + | select { | |
| 96 | + | case data := <-p.reads: | |
| 97 | + | n := copy(dst, data) | |
| 98 | + | p.accepted <- struct{}{} | |
| 99 | + | return n, nil | |
| 100 | + | case <-p.closed: | |
| 101 | + | return 0, io.EOF | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | func (*resizeReaderPTY) Write(b []byte) (int, error) { return len(b), nil } | |
| 105 | + | func (*resizeReaderPTY) Pid() int { return 0 } | |
| 106 | + | func (*resizeReaderPTY) ForegroundPgid() (int, error) { return 0, nil } | |
| 107 | + | func (p *resizeReaderPTY) Close() error { p.close(); return nil } | |
| 108 | + | func (p *resizeReaderPTY) close() { p.closeOnce.Do(func() { close(p.closed) }) } | |
| 109 | + | func (p *resizeReaderPTY) deliveredWhileApplying() bool { | |
| 110 | + | p.mu.Lock() | |
| 111 | + | defer p.mu.Unlock() | |
| 112 | + | return p.delivered | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func TestReplayResizePendingBuffersSuccessFailureAndBatchOrder(t *testing.T) { | |
| 116 | + | t.Run("success resizes then replays", func(t *testing.T) { | |
| 117 | + | pty := &transactionalResizePTY{} | |
| 118 | + | d, sess, _, _ := newManualSessionWithPTYs(t, pty) | |
| 119 | + | tb, p := sess.activeTab(), sess.activeTab().focusedPane() | |
| 120 | + | p.resizeApplying = true | |
| 121 | + | p.resizePending = []byte("A\x1b[1;81HB") | |
| 122 | + | d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 120, Height: 23}) | |
| 123 | + | require.Equal(t, 120, p.screen.Frame.Width) | |
| 124 | + | require.Equal(t, 'B', p.screen.Frame.At(80, 0).Rune) | |
| 125 | + | require.False(t, p.resizeApplying) | |
| 126 | + | }) | |
| 127 | + | t.Run("failure retains old parser width", func(t *testing.T) { | |
| 128 | + | pty := &transactionalResizePTY{} | |
| 129 | + | d, sess, _, _ := newManualSessionWithPTYs(t, pty) | |
| 130 | + | tb, p := sess.activeTab(), sess.activeTab().focusedPane() | |
| 131 | + | p.resizeApplying = true | |
| 132 | + | p.resizePending = []byte("\x1b[1;81HB") | |
| 133 | + | d.replayResizePending(sess, tb, p, false, domain.Rect{Width: 120, Height: 23}) | |
| 134 | + | require.Equal(t, 80, p.screen.Frame.Width) | |
| 135 | + | require.Equal(t, 'B', p.screen.Frame.At(79, 0).Rune) | |
| 136 | + | }) | |
| 137 | + | t.Run("batches retain read order", func(t *testing.T) { | |
| 138 | + | pty := &transactionalResizePTY{} | |
| 139 | + | d, sess, _, _ := newManualSessionWithPTYs(t, pty) | |
| 140 | + | tb, p := sess.activeTab(), sess.activeTab().focusedPane() | |
| 141 | + | p.screen.OnResponse = func([]byte) { p.ptyResponses = append(p.ptyResponses, 'r') } | |
| 142 | + | pty.onWrite = func([]byte) { | |
| 143 | + | p.mu.Lock() | |
| 144 | + | p.resizePending = append(p.resizePending, 'B') | |
| 145 | + | p.mu.Unlock() | |
| 146 | + | } | |
| 147 | + | p.resizeApplying = true | |
| 148 | + | p.resizePending = []byte("A\x1b[6n") | |
| 149 | + | d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 80, Height: 23}) | |
| 150 | + | require.Equal(t, "AB", screenLineText(p.screen, 0)[:2]) | |
| 151 | + | }) | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func TestProcessPTYDataRetainsCallbacksDuringResizeReplay(t *testing.T) { | |
| 155 | + | responses := make(chan []byte, 1) | |
| 156 | + | pty := &transactionalResizePTY{onWrite: func(b []byte) { responses <- b }} | |
| 157 | + | d, sess, _, sends := newManualSessionWithPTYs(t, pty) | |
| 158 | + | tb, p := sess.activeTab(), sess.activeTab().focusedPane() | |
| 159 | + | p.screen.OnResponse = func(b []byte) { p.ptyResponses = append(p.ptyResponses, b...) } | |
| 160 | + | p.screen.OnBell = func() { p.ptyAttention = true } | |
| 161 | + | p.screen.OnClipboard = func(b64 string) { p.ptyClipboards = append(p.ptyClipboards, b64) } | |
| 162 | + | p.resizeApplying = true | |
| 163 | + | p.resizePending = []byte("\x1b]2;resized\a\a\x1b]52;c;YQ==\a\x1b[6n") | |
| 164 | + | d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 80, Height: 23}) | |
| 165 | + | ||
| 166 | + | p.mu.Lock() | |
| 167 | + | title := p.title.terminalTitle | |
| 168 | + | p.mu.Unlock() | |
| 169 | + | require.Equal(t, "resized", title) | |
| 170 | + | require.True(t, tb.attention) | |
| 171 | + | require.NotEmpty(t, <-responses, "DSR response must be flushed back to the child") | |
| 172 | + | // DSR is flushed back to the child and OSC 52 is forwarded through the | |
| 173 | + | // normal asynchronous client path, proving replay uses processPTYData. | |
| 174 | + | frame := awaitFrame(t, sends, ports.MsgOutput) | |
| 175 | + | out, err := ports.UnmarshalOutput(frame.Payload) | |
| 176 | + | require.NoError(t, err) | |
| 177 | + | require.Contains(t, string(out.Data), "\x1b]52;c;YQ==\a") | |
| 178 | + | } | |
| 179 | + | ||
| 53 | 180 | // S3 acceptance: prepare may inspect layout under its locks, but apply must | |
| 54 | 181 | // invoke PTY.Resize after all session/tab/pane locks are released. This is the | |
| 55 | 182 | // deadlock boundary with PTY readers and scripts which synchronously re-enter | |
| @@ -22,6 +22,7 @@ type transactionalResizePTY struct { | |||||
| 22 | sizes []domain.Size | 22 | sizes []domain.Size | ||
| 23 | errs []error | 23 | errs []error | ||
| 24 | onResize func() | 24 | onResize func() | ||
| 25 | + | onWrite func([]byte) | |||
| 25 | } | 26 | } | ||
| 26 | 27 | ||||
| 27 | func (p *transactionalResizePTY) Resize(size domain.Size) error { | 28 | func (p *transactionalResizePTY) Resize(size domain.Size) error { | ||
| @@ -39,8 +40,16 @@ func (p *transactionalResizePTY) Resize(size domain.Size) error { | |||||
| 39 | } | 40 | } | ||
| 40 | return err | 41 | return err | ||
| 41 | } | 42 | } | ||
| 42 | - | func (*transactionalResizePTY) Read([]byte) (int, error) { return 0, io.EOF } | 43 | + | func (*transactionalResizePTY) Read([]byte) (int, error) { return 0, io.EOF } |
| 43 | - | func (*transactionalResizePTY) Write(b []byte) (int, error) { return len(b), nil } | 44 | + | func (p *transactionalResizePTY) Write(b []byte) (int, error) { |
| 45 | + | p.mu.Lock() | |||
| 46 | + | hook := p.onWrite | |||
| 47 | + | p.mu.Unlock() | |||
| 48 | + | if hook != nil { | |||
| 49 | + | hook(append([]byte(nil), b...)) | |||
| 50 | + | } | |||
| 51 | + | return len(b), nil | |||
| 52 | + | } | |||
| 44 | func (*transactionalResizePTY) Close() error { return nil } | 53 | func (*transactionalResizePTY) Close() error { return nil } | ||
| 45 | func (*transactionalResizePTY) Pid() int { return 0 } | 54 | func (*transactionalResizePTY) Pid() int { return 0 } | ||
| 46 | func (*transactionalResizePTY) ForegroundPgid() (int, error) { return 0, nil } | 55 | func (*transactionalResizePTY) ForegroundPgid() (int, error) { return 0, nil } | ||
| @@ -50,6 +59,124 @@ func (p *transactionalResizePTY) requested() []domain.Size { | |||||
| 50 | return append([]domain.Size(nil), p.sizes...) | 59 | return append([]domain.Size(nil), p.sizes...) | ||
| 51 | } | 60 | } | ||
| 52 | 61 | ||||
| 62 | + | // resizeReaderPTY is a deterministic child: Resize releases its redraw only | |||
| 63 | + | // after the daemon has installed resizeApplying, and waits until ptyReader has | |||
| 64 | + | // accepted that read. It deliberately has no timing dependency. | |||
| 65 | + | type resizeReaderPTY struct { | |||
| 66 | + | redraw []byte | |||
| 67 | + | reads chan []byte | |||
| 68 | + | accepted chan struct{} | |||
| 69 | + | closed chan struct{} | |||
| 70 | + | closeOnce sync.Once | |||
| 71 | + | mu sync.Mutex | |||
| 72 | + | applying func() bool | |||
| 73 | + | delivered bool | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func newResizeReaderPTY(redraw []byte) *resizeReaderPTY { | |||
| 77 | + | return &resizeReaderPTY{redraw: redraw, reads: make(chan []byte), accepted: make(chan struct{}), closed: make(chan struct{})} | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func (p *resizeReaderPTY) Resize(domain.Size) error { | |||
| 81 | + | p.reads <- p.redraw | |||
| 82 | + | <-p.accepted | |||
| 83 | + | // A second, empty read cannot begin until ptyReader has processed the | |||
| 84 | + | // redraw from the first read. This is a channel rendezvous, not a timing | |||
| 85 | + | // assumption, and proves the redraw entered resizePending before Resize | |||
| 86 | + | // returns to replay it. | |||
| 87 | + | p.reads <- nil | |||
| 88 | + | <-p.accepted | |||
| 89 | + | p.mu.Lock() | |||
| 90 | + | p.delivered = p.applying != nil && p.applying() | |||
| 91 | + | p.mu.Unlock() | |||
| 92 | + | return nil | |||
| 93 | + | } | |||
| 94 | + | func (p *resizeReaderPTY) Read(dst []byte) (int, error) { | |||
| 95 | + | select { | |||
| 96 | + | case data := <-p.reads: | |||
| 97 | + | n := copy(dst, data) | |||
| 98 | + | p.accepted <- struct{}{} | |||
| 99 | + | return n, nil | |||
| 100 | + | case <-p.closed: | |||
| 101 | + | return 0, io.EOF | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | func (*resizeReaderPTY) Write(b []byte) (int, error) { return len(b), nil } | |||
| 105 | + | func (*resizeReaderPTY) Pid() int { return 0 } | |||
| 106 | + | func (*resizeReaderPTY) ForegroundPgid() (int, error) { return 0, nil } | |||
| 107 | + | func (p *resizeReaderPTY) Close() error { p.close(); return nil } | |||
| 108 | + | func (p *resizeReaderPTY) close() { p.closeOnce.Do(func() { close(p.closed) }) } | |||
| 109 | + | func (p *resizeReaderPTY) deliveredWhileApplying() bool { | |||
| 110 | + | p.mu.Lock() | |||
| 111 | + | defer p.mu.Unlock() | |||
| 112 | + | return p.delivered | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func TestReplayResizePendingBuffersSuccessFailureAndBatchOrder(t *testing.T) { | |||
| 116 | + | t.Run("success resizes then replays", func(t *testing.T) { | |||
| 117 | + | pty := &transactionalResizePTY{} | |||
| 118 | + | d, sess, _, _ := newManualSessionWithPTYs(t, pty) | |||
| 119 | + | tb, p := sess.activeTab(), sess.activeTab().focusedPane() | |||
| 120 | + | p.resizeApplying = true | |||
| 121 | + | p.resizePending = []byte("A\x1b[1;81HB") | |||
| 122 | + | d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 120, Height: 23}) | |||
| 123 | + | require.Equal(t, 120, p.screen.Frame.Width) | |||
| 124 | + | require.Equal(t, 'B', p.screen.Frame.At(80, 0).Rune) | |||
| 125 | + | require.False(t, p.resizeApplying) | |||
| 126 | + | }) | |||
| 127 | + | t.Run("failure retains old parser width", func(t *testing.T) { | |||
| 128 | + | pty := &transactionalResizePTY{} | |||
| 129 | + | d, sess, _, _ := newManualSessionWithPTYs(t, pty) | |||
| 130 | + | tb, p := sess.activeTab(), sess.activeTab().focusedPane() | |||
| 131 | + | p.resizeApplying = true | |||
| 132 | + | p.resizePending = []byte("\x1b[1;81HB") | |||
| 133 | + | d.replayResizePending(sess, tb, p, false, domain.Rect{Width: 120, Height: 23}) | |||
| 134 | + | require.Equal(t, 80, p.screen.Frame.Width) | |||
| 135 | + | require.Equal(t, 'B', p.screen.Frame.At(79, 0).Rune) | |||
| 136 | + | }) | |||
| 137 | + | t.Run("batches retain read order", func(t *testing.T) { | |||
| 138 | + | pty := &transactionalResizePTY{} | |||
| 139 | + | d, sess, _, _ := newManualSessionWithPTYs(t, pty) | |||
| 140 | + | tb, p := sess.activeTab(), sess.activeTab().focusedPane() | |||
| 141 | + | p.screen.OnResponse = func([]byte) { p.ptyResponses = append(p.ptyResponses, 'r') } | |||
| 142 | + | pty.onWrite = func([]byte) { | |||
| 143 | + | p.mu.Lock() | |||
| 144 | + | p.resizePending = append(p.resizePending, 'B') | |||
| 145 | + | p.mu.Unlock() | |||
| 146 | + | } | |||
| 147 | + | p.resizeApplying = true | |||
| 148 | + | p.resizePending = []byte("A\x1b[6n") | |||
| 149 | + | d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 80, Height: 23}) | |||
| 150 | + | require.Equal(t, "AB", screenLineText(p.screen, 0)[:2]) | |||
| 151 | + | }) | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func TestProcessPTYDataRetainsCallbacksDuringResizeReplay(t *testing.T) { | |||
| 155 | + | responses := make(chan []byte, 1) | |||
| 156 | + | pty := &transactionalResizePTY{onWrite: func(b []byte) { responses <- b }} | |||
| 157 | + | d, sess, _, sends := newManualSessionWithPTYs(t, pty) | |||
| 158 | + | tb, p := sess.activeTab(), sess.activeTab().focusedPane() | |||
| 159 | + | p.screen.OnResponse = func(b []byte) { p.ptyResponses = append(p.ptyResponses, b...) } | |||
| 160 | + | p.screen.OnBell = func() { p.ptyAttention = true } | |||
| 161 | + | p.screen.OnClipboard = func(b64 string) { p.ptyClipboards = append(p.ptyClipboards, b64) } | |||
| 162 | + | p.resizeApplying = true | |||
| 163 | + | p.resizePending = []byte("\x1b]2;resized\a\a\x1b]52;c;YQ==\a\x1b[6n") | |||
| 164 | + | d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 80, Height: 23}) | |||
| 165 | + | ||||
| 166 | + | p.mu.Lock() | |||
| 167 | + | title := p.title.terminalTitle | |||
| 168 | + | p.mu.Unlock() | |||
| 169 | + | require.Equal(t, "resized", title) | |||
| 170 | + | require.True(t, tb.attention) | |||
| 171 | + | require.NotEmpty(t, <-responses, "DSR response must be flushed back to the child") | |||
| 172 | + | // DSR is flushed back to the child and OSC 52 is forwarded through the | |||
| 173 | + | // normal asynchronous client path, proving replay uses processPTYData. | |||
| 174 | + | frame := awaitFrame(t, sends, ports.MsgOutput) | |||
| 175 | + | out, err := ports.UnmarshalOutput(frame.Payload) | |||
| 176 | + | require.NoError(t, err) | |||
| 177 | + | require.Contains(t, string(out.Data), "\x1b]52;c;YQ==\a") | |||
| 178 | + | } | |||
| 179 | + | ||||
| 53 | // S3 acceptance: prepare may inspect layout under its locks, but apply must | 180 | // S3 acceptance: prepare may inspect layout under its locks, but apply must | ||
| 54 | // invoke PTY.Resize after all session/tab/pane locks are released. This is the | 181 | // invoke PTY.Resize after all session/tab/pane locks are released. This is the | ||
| 55 | // deadlock boundary with PTY readers and scripts which synchronously re-enter | 182 | // deadlock boundary with PTY readers and scripts which synchronously re-enter | ||