detached 83dcf79

fix(daemon): replay PTY output across resize apply

commit
83dcf79aa8e53b4113bcfbba509da17ed07d1f6c
author
brice · · 2026-07-12 09:09 UTC
parent
dce9117
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +160 -124
M internal/usecase/daemon/pane.go +17 -9
@@ -16,15 +16,23 @@ import (
1616 // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
1717 // The PTY reader takes only pane.mu, so child output never waits on client IO.
1818 type pane struct {
19- id layout.PaneID
20- stableID string
21- pty ports.PTY
22- mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title
23- resizeMu sync.Mutex // serializes PTY resizes without holding mu
24- screen *vt.Screen
25- scrollback *scopy.Scrollback
26- syncGen uint64
27- rect domain.Rect
19+ id layout.PaneID
20+ stableID string
21+ pty ports.PTY
22+ mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title
23+ resizeMu sync.Mutex // serializes PTY resizes without holding mu
24+ screen *vt.Screen
25+ scrollback *scopy.Scrollback
26+ syncGen uint64
27+ rect domain.Rect
28+ // resizeApplying gates VT parsing across PTY.Resize. The reader continues
29+ // draining into resizePending so output is replayed against the target (or
30+ // retained old) screen only after apply resolves.
31+ resizeApplying bool
32+ resizePending []byte
33+ ptyResponses []byte
34+ ptyClipboards []string
35+ ptyAttention bool
2836 popupGeometry floatingGeometry // last geometry committed after a successful floating resize
2937 title paneTitleState
3038 ctx context.Context
@@ -16,15 +16,23 @@ import (
16 // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.16 // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
17 // The PTY reader takes only pane.mu, so child output never waits on client IO.17 // The PTY reader takes only pane.mu, so child output never waits on client IO.
18 type pane struct {18 type pane struct {
19- id layout.PaneID19+ id layout.PaneID
20- stableID string20+ stableID string
21- pty ports.PTY21+ pty ports.PTY
22- mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title22+ mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title
23- resizeMu sync.Mutex // serializes PTY resizes without holding mu23+ resizeMu sync.Mutex // serializes PTY resizes without holding mu
24- screen *vt.Screen24+ screen *vt.Screen
25- scrollback *scopy.Scrollback25+ scrollback *scopy.Scrollback
26- syncGen uint6426+ syncGen uint64
27- rect domain.Rect27+ rect domain.Rect
28+ // resizeApplying gates VT parsing across PTY.Resize. The reader continues
29+ // draining into resizePending so output is replayed against the target (or
30+ // retained old) screen only after apply resolves.
31+ resizeApplying bool
32+ resizePending []byte
33+ ptyResponses []byte
34+ ptyClipboards []string
35+ ptyAttention bool
28 popupGeometry floatingGeometry // last geometry committed after a successful floating resize36 popupGeometry floatingGeometry // last geometry committed after a successful floating resize
29 title paneTitleState37 title paneTitleState
30 ctx context.Context38 ctx context.Context
M internal/usecase/daemon/render.go +88 -93
@@ -18,13 +18,6 @@ func completedSynchronizedUpdate(data []byte) bool {
1818 return found && bytes.Contains(tail, []byte(renderer.SyncEndCSI))
1919 }
2020
21-func signal(ch chan struct{}) {
22- select {
23- case ch <- struct{}{}:
24- default:
25- }
26-}
27-
2821 // paneRenderable reports dynamic composition visibility. Callers must not
2922 // hold coordinator locks: this acquires daemon/session/tab/pane-adjacent locks.
3023 func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool {
@@ -92,97 +85,17 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
9285 return
9386 }
9487 buf := make([]byte, ptyReadBufSize)
95- var resp []byte
96- var clipboards []string
97- attentionCh := make(chan struct{}, 1)
9888 p.mu.Lock()
99- p.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }
100- p.screen.OnBell = func() { signal(attentionCh) }
101- p.screen.OnNotify = func(string, string) { signal(attentionCh) }
102- p.screen.OnProgress = func(bool) { signal(attentionCh) }
103- p.screen.OnClipboard = func(b64 string) { clipboards = append(clipboards, b64) }
89+ p.screen.OnResponse = func(b []byte) { p.ptyResponses = append(p.ptyResponses, b...) }
90+ p.screen.OnBell = func() { p.ptyAttention = true }
91+ p.screen.OnNotify = func(string, string) { p.ptyAttention = true }
92+ p.screen.OnProgress = func(bool) { p.ptyAttention = true }
93+ p.screen.OnClipboard = func(b64 string) { p.ptyClipboards = append(p.ptyClipboards, b64) }
10494 p.mu.Unlock()
10595 for {
10696 n, err := p.pty.Read(buf)
10797 if n > 0 {
108- data := buf[:n]
109- rc := sess.renderCoordinator()
110- p.mu.Lock()
111- wasSyncing := p.screen.SyncUpdateActive()
112- p.screen.Write(data)
113- p.refreshTerminalTitleLocked()
114- isSyncing := p.screen.SyncUpdateActive()
115- completeSyncRead := !wasSyncing && !isSyncing && completedSynchronizedUpdate(data)
116- var syncGen uint64
117- syncEnded := false
118- if wasSyncing != isSyncing {
119- if isSyncing {
120- syncGen = sess.syncGen.Add(1)
121- p.syncGen = syncGen
122- // Publish the gate while the authoritative screen mutation is
123- // still protected. A concurrent deadline therefore cannot
124- // compose this partial DEC 2026 payload.
125- if rc != nil {
126- rc.noteSyncBeginWithRenderability(p, syncGen, func() bool {
127- return d.paneRenderable(sess, tb, p)
128- }, func() {
129- p.mu.Lock()
130- if p.syncGen == syncGen && p.screen.SyncUpdateActive() {
131- p.screen.ForceSyncEnd()
132- }
133- p.mu.Unlock()
134- })
135- }
136- } else {
137- syncGen = p.syncGen
138- syncEnded = rc != nil && rc.removeSyncEnd(p, syncGen, true)
139- }
140- }
141- p.mu.Unlock()
142- renderable := d.paneRenderable(sess, tb, p)
143- markSnapshotDirty(sess)
144- select {
145- case <-attentionCh:
146- d.noteAttention(sess, tb)
147- default:
148- }
149- if len(clipboards) > 0 {
150- for _, b64 := range clipboards {
151- d.forwardClipboardAsync(sess, b64)
152- }
153- clipboards = clipboards[:0]
154- }
155- if len(resp) > 0 {
156- if _, writeErr := p.pty.Write(resp); writeErr != nil {
157- d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)
158- }
159- resp = resp[:0]
160- }
161- if rc != nil {
162- if wasSyncing != isSyncing {
163- if isSyncing {
164- // The accumulated synchronized batch is the pending render only
165- // while this pane can currently reach a composition target.
166- if renderable {
167- rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
168- }
169- } else if syncEnded && renderable {
170- // State removal happened under pane.mu. Publish a new urgent
171- // completion after unlock: detach/replace may have cleared the
172- // pending batch work, so fireCurrent alone could not wake a
173- // headless picker preview or replacement attachment.
174- rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
175- }
176- }
177- if completeSyncRead && renderable {
178- // The entire batch completed in this read. Its final screen state
179- // is ready now, so bypass the bulk debounce without arming a
180- // watchdog for a batch that no longer exists.
181- rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
182- } else if renderable && !isSyncing && wasSyncing == isSyncing {
183- rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
184- }
185- }
98+ d.processPTYData(sess, tb, p, buf[:n], true)
18699 }
187100 if err != nil {
188101 if p.onExit != nil {
@@ -195,6 +108,88 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
195108 }
196109 }
197110
111+// processPTYData is the sole VT parsing path. While a resize apply owns the
112+// pane, reader calls append exact bytes and returns immediately; it never waits
113+// for PTY.Resize or loses a short read.
114+func (d *Daemon) processPTYData(sess *session, tb *tab, p *pane, data []byte, bufferDuringApply bool) {
115+ rc := sess.renderCoordinator()
116+ p.mu.Lock()
117+ if bufferDuringApply && p.resizeApplying {
118+ p.resizePending = append(p.resizePending, data...)
119+ p.mu.Unlock()
120+ return
121+ }
122+ wasSyncing := p.screen.SyncUpdateActive()
123+ p.screen.Write(data)
124+ p.refreshTerminalTitleLocked()
125+ isSyncing := p.screen.SyncUpdateActive()
126+ completeSyncRead := !wasSyncing && !isSyncing && completedSynchronizedUpdate(data)
127+ var syncGen uint64
128+ syncEnded := false
129+ if wasSyncing != isSyncing {
130+ if isSyncing {
131+ syncGen = sess.syncGen.Add(1)
132+ p.syncGen = syncGen
133+ if rc != nil {
134+ rc.noteSyncBeginWithRenderability(p, syncGen, func() bool { return d.paneRenderable(sess, tb, p) }, func() {
135+ p.mu.Lock()
136+ if p.syncGen == syncGen && p.screen.SyncUpdateActive() {
137+ p.screen.ForceSyncEnd()
138+ }
139+ p.mu.Unlock()
140+ })
141+ }
142+ } else {
143+ syncGen = p.syncGen
144+ syncEnded = rc != nil && rc.removeSyncEnd(p, syncGen, true)
145+ }
146+ }
147+ p.mu.Unlock()
148+ renderable := d.paneRenderable(sess, tb, p)
149+ markSnapshotDirty(sess)
150+ d.flushPTYEffects(sess, tb, p)
151+ if rc != nil {
152+ if wasSyncing != isSyncing {
153+ if isSyncing {
154+ if renderable {
155+ rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
156+ }
157+ } else if syncEnded && renderable {
158+ rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
159+ }
160+ }
161+ if completeSyncRead && renderable {
162+ rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
163+ } else if renderable && !isSyncing && wasSyncing == isSyncing {
164+ rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
165+ }
166+ }
167+}
168+
169+// flushPTYEffects preserves the normal response/title/attention/clipboard
170+// behavior for both direct reads and resize-buffer replay.
171+func (d *Daemon) flushPTYEffects(sess *session, tb *tab, p *pane) {
172+ p.mu.Lock()
173+ attention := p.ptyAttention
174+ p.ptyAttention = false
175+ responses := append([]byte(nil), p.ptyResponses...)
176+ p.ptyResponses = p.ptyResponses[:0]
177+ clipboards := append([]string(nil), p.ptyClipboards...)
178+ p.ptyClipboards = p.ptyClipboards[:0]
179+ p.mu.Unlock()
180+ if attention {
181+ d.noteAttention(sess, tb)
182+ }
183+ for _, b64 := range clipboards {
184+ d.forwardClipboardAsync(sess, b64)
185+ }
186+ if len(responses) > 0 {
187+ if _, err := p.pty.Write(responses); err != nil {
188+ d.log.Warn("pty response write failed", "err", err, "session", sess.name)
189+ }
190+ }
191+}
192+
198193 // paint draws the composed client frame (active tab plus status bar) and
199194 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations
200195 // such as switch/create/close/rename/resize so the repaint is complete.
@@ -18,13 +18,6 @@ func completedSynchronizedUpdate(data []byte) bool {
18 return found && bytes.Contains(tail, []byte(renderer.SyncEndCSI))18 return found && bytes.Contains(tail, []byte(renderer.SyncEndCSI))
19 }19 }
20 20
21-func signal(ch chan struct{}) {
22- select {
23- case ch <- struct{}{}:
24- default:
25- }
26-}
27-
28 // paneRenderable reports dynamic composition visibility. Callers must not21 // paneRenderable reports dynamic composition visibility. Callers must not
29 // hold coordinator locks: this acquires daemon/session/tab/pane-adjacent locks.22 // hold coordinator locks: this acquires daemon/session/tab/pane-adjacent locks.
30 func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool {23 func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool {
@@ -92,97 +85,17 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
92 return85 return
93 }86 }
94 buf := make([]byte, ptyReadBufSize)87 buf := make([]byte, ptyReadBufSize)
95- var resp []byte
96- var clipboards []string
97- attentionCh := make(chan struct{}, 1)
98 p.mu.Lock()88 p.mu.Lock()
99- p.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }89+ p.screen.OnResponse = func(b []byte) { p.ptyResponses = append(p.ptyResponses, b...) }
100- p.screen.OnBell = func() { signal(attentionCh) }90+ p.screen.OnBell = func() { p.ptyAttention = true }
101- p.screen.OnNotify = func(string, string) { signal(attentionCh) }91+ p.screen.OnNotify = func(string, string) { p.ptyAttention = true }
102- p.screen.OnProgress = func(bool) { signal(attentionCh) }92+ p.screen.OnProgress = func(bool) { p.ptyAttention = true }
103- p.screen.OnClipboard = func(b64 string) { clipboards = append(clipboards, b64) }93+ p.screen.OnClipboard = func(b64 string) { p.ptyClipboards = append(p.ptyClipboards, b64) }
104 p.mu.Unlock()94 p.mu.Unlock()
105 for {95 for {
106 n, err := p.pty.Read(buf)96 n, err := p.pty.Read(buf)
107 if n > 0 {97 if n > 0 {
108- data := buf[:n]98+ d.processPTYData(sess, tb, p, buf[:n], true)
109- rc := sess.renderCoordinator()
110- p.mu.Lock()
111- wasSyncing := p.screen.SyncUpdateActive()
112- p.screen.Write(data)
113- p.refreshTerminalTitleLocked()
114- isSyncing := p.screen.SyncUpdateActive()
115- completeSyncRead := !wasSyncing && !isSyncing && completedSynchronizedUpdate(data)
116- var syncGen uint64
117- syncEnded := false
118- if wasSyncing != isSyncing {
119- if isSyncing {
120- syncGen = sess.syncGen.Add(1)
121- p.syncGen = syncGen
122- // Publish the gate while the authoritative screen mutation is
123- // still protected. A concurrent deadline therefore cannot
124- // compose this partial DEC 2026 payload.
125- if rc != nil {
126- rc.noteSyncBeginWithRenderability(p, syncGen, func() bool {
127- return d.paneRenderable(sess, tb, p)
128- }, func() {
129- p.mu.Lock()
130- if p.syncGen == syncGen && p.screen.SyncUpdateActive() {
131- p.screen.ForceSyncEnd()
132- }
133- p.mu.Unlock()
134- })
135- }
136- } else {
137- syncGen = p.syncGen
138- syncEnded = rc != nil && rc.removeSyncEnd(p, syncGen, true)
139- }
140- }
141- p.mu.Unlock()
142- renderable := d.paneRenderable(sess, tb, p)
143- markSnapshotDirty(sess)
144- select {
145- case <-attentionCh:
146- d.noteAttention(sess, tb)
147- default:
148- }
149- if len(clipboards) > 0 {
150- for _, b64 := range clipboards {
151- d.forwardClipboardAsync(sess, b64)
152- }
153- clipboards = clipboards[:0]
154- }
155- if len(resp) > 0 {
156- if _, writeErr := p.pty.Write(resp); writeErr != nil {
157- d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)
158- }
159- resp = resp[:0]
160- }
161- if rc != nil {
162- if wasSyncing != isSyncing {
163- if isSyncing {
164- // The accumulated synchronized batch is the pending render only
165- // while this pane can currently reach a composition target.
166- if renderable {
167- rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
168- }
169- } else if syncEnded && renderable {
170- // State removal happened under pane.mu. Publish a new urgent
171- // completion after unlock: detach/replace may have cleared the
172- // pending batch work, so fireCurrent alone could not wake a
173- // headless picker preview or replacement attachment.
174- rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
175- }
176- }
177- if completeSyncRead && renderable {
178- // The entire batch completed in this read. Its final screen state
179- // is ready now, so bypass the bulk debounce without arming a
180- // watchdog for a batch that no longer exists.
181- rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
182- } else if renderable && !isSyncing && wasSyncing == isSyncing {
183- rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
184- }
185- }
186 }99 }
187 if err != nil {100 if err != nil {
188 if p.onExit != nil {101 if p.onExit != nil {
@@ -195,6 +108,88 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
195 }108 }
196 }109 }
197 110
111+// processPTYData is the sole VT parsing path. While a resize apply owns the
112+// pane, reader calls append exact bytes and returns immediately; it never waits
113+// for PTY.Resize or loses a short read.
114+func (d *Daemon) processPTYData(sess *session, tb *tab, p *pane, data []byte, bufferDuringApply bool) {
115+ rc := sess.renderCoordinator()
116+ p.mu.Lock()
117+ if bufferDuringApply && p.resizeApplying {
118+ p.resizePending = append(p.resizePending, data...)
119+ p.mu.Unlock()
120+ return
121+ }
122+ wasSyncing := p.screen.SyncUpdateActive()
123+ p.screen.Write(data)
124+ p.refreshTerminalTitleLocked()
125+ isSyncing := p.screen.SyncUpdateActive()
126+ completeSyncRead := !wasSyncing && !isSyncing && completedSynchronizedUpdate(data)
127+ var syncGen uint64
128+ syncEnded := false
129+ if wasSyncing != isSyncing {
130+ if isSyncing {
131+ syncGen = sess.syncGen.Add(1)
132+ p.syncGen = syncGen
133+ if rc != nil {
134+ rc.noteSyncBeginWithRenderability(p, syncGen, func() bool { return d.paneRenderable(sess, tb, p) }, func() {
135+ p.mu.Lock()
136+ if p.syncGen == syncGen && p.screen.SyncUpdateActive() {
137+ p.screen.ForceSyncEnd()
138+ }
139+ p.mu.Unlock()
140+ })
141+ }
142+ } else {
143+ syncGen = p.syncGen
144+ syncEnded = rc != nil && rc.removeSyncEnd(p, syncGen, true)
145+ }
146+ }
147+ p.mu.Unlock()
148+ renderable := d.paneRenderable(sess, tb, p)
149+ markSnapshotDirty(sess)
150+ d.flushPTYEffects(sess, tb, p)
151+ if rc != nil {
152+ if wasSyncing != isSyncing {
153+ if isSyncing {
154+ if renderable {
155+ rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
156+ }
157+ } else if syncEnded && renderable {
158+ rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
159+ }
160+ }
161+ if completeSyncRead && renderable {
162+ rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
163+ } else if renderable && !isSyncing && wasSyncing == isSyncing {
164+ rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
165+ }
166+ }
167+}
168+
169+// flushPTYEffects preserves the normal response/title/attention/clipboard
170+// behavior for both direct reads and resize-buffer replay.
171+func (d *Daemon) flushPTYEffects(sess *session, tb *tab, p *pane) {
172+ p.mu.Lock()
173+ attention := p.ptyAttention
174+ p.ptyAttention = false
175+ responses := append([]byte(nil), p.ptyResponses...)
176+ p.ptyResponses = p.ptyResponses[:0]
177+ clipboards := append([]string(nil), p.ptyClipboards...)
178+ p.ptyClipboards = p.ptyClipboards[:0]
179+ p.mu.Unlock()
180+ if attention {
181+ d.noteAttention(sess, tb)
182+ }
183+ for _, b64 := range clipboards {
184+ d.forwardClipboardAsync(sess, b64)
185+ }
186+ if len(responses) > 0 {
187+ if _, err := p.pty.Write(responses); err != nil {
188+ d.log.Warn("pty response write failed", "err", err, "session", sess.name)
189+ }
190+ }
191+}
192+
198 // paint draws the composed client frame (active tab plus status bar) and193 // paint draws the composed client frame (active tab plus status bar) and
199 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations194 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations
200 // such as switch/create/close/rename/resize so the repaint is complete.195 // such as switch/create/close/rename/resize so the repaint is complete.
M internal/usecase/daemon/render_coordinator.go +3 -3
@@ -67,8 +67,8 @@ type renderWake struct {
6767
6868 // resizeRequestMetadata is the coordinator-owned latest requested resize
6969 // state: S3's transaction entry point reads it through resizeSnapshot. It is
70-// metadata ownership only — S1/S2 dispatch stays with the retained PR #71
71-// attachment timer path.
70+// metadata ownership only — S1/S2 dispatch stays with the coordinator
71+// epoch timer path.
7272 type resizeRequestMetadata struct {
7373 size domain.Size
7474 source *attachedClient
@@ -742,7 +742,7 @@ func (c *renderCoordinator) noteSessionTeardown() {
742742 }
743743
744744 // recordResizeRequest records the latest requested geometry and source before
745-// the request delegates to the retained PR #71 attachment path. It returns
745+// the request delegates to the coordinator epoch path. It returns
746746 // the strictly monotonically increased epoch, or 0 when the source is stale.
747747 func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 {
748748 c.mu.Lock()
@@ -67,8 +67,8 @@ type renderWake struct {
67 67
68 // resizeRequestMetadata is the coordinator-owned latest requested resize68 // resizeRequestMetadata is the coordinator-owned latest requested resize
69 // state: S3's transaction entry point reads it through resizeSnapshot. It is69 // state: S3's transaction entry point reads it through resizeSnapshot. It is
70-// metadata ownership only — S1/S2 dispatch stays with the retained PR #7170+// metadata ownership only — S1/S2 dispatch stays with the coordinator
71-// attachment timer path.71+// epoch timer path.
72 type resizeRequestMetadata struct {72 type resizeRequestMetadata struct {
73 size domain.Size73 size domain.Size
74 source *attachedClient74 source *attachedClient
@@ -742,7 +742,7 @@ func (c *renderCoordinator) noteSessionTeardown() {
742 }742 }
743 743
744 // recordResizeRequest records the latest requested geometry and source before744 // recordResizeRequest records the latest requested geometry and source before
745-// the request delegates to the retained PR #71 attachment path. It returns745+// the request delegates to the coordinator epoch path. It returns
746 // the strictly monotonically increased epoch, or 0 when the source is stale.746 // the strictly monotonically increased epoch, or 0 when the source is stale.
747 func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 {747 func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 {
748 c.mu.Lock()748 c.mu.Lock()
M internal/usecase/daemon/render_coordinator_test.go +1 -1
@@ -1447,7 +1447,7 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
14471447 })
14481448 }
14491449
1450-func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {
1450+func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) {
14511451 newResizeFixture := func(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, *coordinatorMockClock, chan renderInvalidation) {
14521452 t.Helper()
14531453 p, releasePTY := newBlockingPTY(t)
@@ -1447,7 +1447,7 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
1447 })1447 })
1448 }1448 }
1449 1449
1450-func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {1450+func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) {
1451 newResizeFixture := func(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, *coordinatorMockClock, chan renderInvalidation) {1451 newResizeFixture := func(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, *coordinatorMockClock, chan renderInvalidation) {
1452 t.Helper()1452 t.Helper()
1453 p, releasePTY := newBlockingPTY(t)1453 p, releasePTY := newBlockingPTY(t)
M internal/usecase/daemon/transactional_resize.go +47 -5
@@ -8,6 +8,7 @@ import (
88 // resizeMember is an unpublished layout decision. Prepare only reads guarded
99 // state; apply performs the external PTY call; commit is the sole publisher.
1010 type resizeMember struct {
11+ session *session
1112 tab *tab
1213 pane *pane
1314 rect domain.Rect
@@ -38,7 +39,7 @@ func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan {
3839 for _, pl := range placements {
3940 if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 {
4041 if p := tb.panes[pl.ID]; p != nil {
41- plan.members = append(plan.members, resizeMember{tab: tb, pane: p, rect: pl.Content})
42+ plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: p, rect: pl.Content})
4243 }
4344 }
4445 }
@@ -49,7 +50,7 @@ func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan {
4950 if i == active && tb.floating.state == floatingVisible && tb.floating.pane != nil {
5051 g := calculateContentFloatingGeometry(tabSize(size), d.currentFloatingConfig())
5152 if g.valid() {
52- plan.members = append(plan.members, resizeMember{tab: tb, pane: tb.floating.pane, rect: g.Inner, floating: g, isFloating: true})
53+ plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: tb.floating.pane, rect: g.Inner, floating: g, isFloating: true})
5354 }
5455 }
5556 tb.mu.Unlock()
@@ -69,21 +70,58 @@ func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool {
6970 m.pane.mu.Lock()
7071 old, pty := m.pane.rect, m.pane.pty
7172 screenSize := domain.Size{Cols: m.pane.screen.Frame.Width, Rows: m.pane.screen.Frame.Height}
73+ needsApply := m.retry || old.Width != m.rect.Width || old.Height != m.rect.Height || screenSize != rectSize(m.rect)
74+ if needsApply && pty != nil {
75+ // Publish this gate before the external call. ptyReader keeps draining
76+ // but does not parse bytes at the stale width while Resize is in flight.
77+ m.pane.resizeApplying = true
78+ }
7279 m.pane.mu.Unlock()
73- if !m.retry && old.Width == m.rect.Width && old.Height == m.rect.Height && screenSize == rectSize(m.rect) {
74- m.ok = true
75- } else if pty == nil {
80+ if !needsApply || pty == nil {
7681 m.ok = true
7782 } else if err := pty.Resize(rectSize(m.rect)); err != nil {
7883 d.log.Warn("pty resize failed", "err", err)
84+ d.replayResizePending(m.session, m.tab, m.pane, false, m.rect)
7985 } else {
8086 m.ok = true
87+ d.replayResizePending(m.session, m.tab, m.pane, true, m.rect)
8188 }
8289 m.pane.resizeMu.Unlock()
8390 }
8491 return true
8592 }
8693
94+// replayResizePending completes a pane's apply epoch. It keeps the gate set
95+// while each buffered batch goes through the normal VT path, so reads arriving
96+// during replay join the same ordered stream. Failure deliberately omits the
97+// Screen.Resize, parsing every byte at the old dimensions.
98+func (d *Daemon) replayResizePending(sess *session, tb *tab, p *pane, resized bool, size domain.Rect) {
99+ first := true
100+ for {
101+ p.mu.Lock()
102+ if first && resized {
103+ p.screen.Resize(size.Width, size.Height)
104+ }
105+ first = false
106+ data := append([]byte(nil), p.resizePending...)
107+ p.resizePending = p.resizePending[:0]
108+ if len(data) == 0 {
109+ p.resizeApplying = false
110+ p.mu.Unlock()
111+ return
112+ }
113+ p.mu.Unlock()
114+ if sess == nil {
115+ p.mu.Lock()
116+ p.screen.Write(data)
117+ p.refreshTerminalTitleLocked()
118+ p.mu.Unlock()
119+ continue
120+ }
121+ d.processPTYData(sess, tb, p, data, false)
122+ }
123+}
124+
87125 func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan) {
88126 // Publish tab layout and every rectangle together only after the coordinator
89127 // has rejected obsolete work. A later request may have applied already, but
@@ -149,6 +187,10 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch u
149187 // A successful transaction publishes exactly one full S2 frame. The
150188 // coordinator is the only emission route and stale epochs never reach it.
151189 rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})
190+ // The resize debounce has already elapsed. Consume this sticky reset now;
191+ // fire preserves ACK and synchronized-output gates rather than scheduling a
192+ // second urgent deadline.
193+ rc.fireCurrent(false)
152194 }
153195
154196 // retryResizeMembers retries only members which failed the committed epoch.
@@ -8,6 +8,7 @@ import (
8 // resizeMember is an unpublished layout decision. Prepare only reads guarded8 // 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 tab *tab12 tab *tab
12 pane *pane13 pane *pane
13 rect domain.Rect14 rect domain.Rect
@@ -38,7 +39,7 @@ func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan {
38 for _, pl := range placements {39 for _, pl := range placements {
39 if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 {40 if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 {
40 if p := tb.panes[pl.ID]; p != nil {41 if p := tb.panes[pl.ID]; p != nil {
41- plan.members = append(plan.members, resizeMember{tab: tb, pane: p, rect: pl.Content})42+ plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: p, rect: pl.Content})
42 }43 }
43 }44 }
44 }45 }
@@ -49,7 +50,7 @@ func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan {
49 if i == active && tb.floating.state == floatingVisible && tb.floating.pane != nil {50 if i == active && tb.floating.state == floatingVisible && tb.floating.pane != nil {
50 g := calculateContentFloatingGeometry(tabSize(size), d.currentFloatingConfig())51 g := calculateContentFloatingGeometry(tabSize(size), d.currentFloatingConfig())
51 if g.valid() {52 if g.valid() {
52- plan.members = append(plan.members, resizeMember{tab: tb, pane: tb.floating.pane, rect: g.Inner, floating: g, isFloating: true})53+ plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: tb.floating.pane, rect: g.Inner, floating: g, isFloating: true})
53 }54 }
54 }55 }
55 tb.mu.Unlock()56 tb.mu.Unlock()
@@ -69,21 +70,58 @@ func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool {
69 m.pane.mu.Lock()70 m.pane.mu.Lock()
70 old, pty := m.pane.rect, m.pane.pty71 old, pty := m.pane.rect, m.pane.pty
71 screenSize := domain.Size{Cols: m.pane.screen.Frame.Width, Rows: m.pane.screen.Frame.Height}72 screenSize := domain.Size{Cols: m.pane.screen.Frame.Width, Rows: m.pane.screen.Frame.Height}
73+ needsApply := m.retry || old.Width != m.rect.Width || old.Height != m.rect.Height || screenSize != rectSize(m.rect)
74+ if needsApply && pty != nil {
75+ // Publish this gate before the external call. ptyReader keeps draining
76+ // but does not parse bytes at the stale width while Resize is in flight.
77+ m.pane.resizeApplying = true
78+ }
72 m.pane.mu.Unlock()79 m.pane.mu.Unlock()
73- if !m.retry && old.Width == m.rect.Width && old.Height == m.rect.Height && screenSize == rectSize(m.rect) {80+ if !needsApply || pty == nil {
74- m.ok = true
75- } else if pty == nil {
76 m.ok = true81 m.ok = true
77 } else if err := pty.Resize(rectSize(m.rect)); err != nil {82 } else if err := pty.Resize(rectSize(m.rect)); err != nil {
78 d.log.Warn("pty resize failed", "err", err)83 d.log.Warn("pty resize failed", "err", err)
84+ d.replayResizePending(m.session, m.tab, m.pane, false, m.rect)
79 } else {85 } else {
80 m.ok = true86 m.ok = true
87+ d.replayResizePending(m.session, m.tab, m.pane, true, m.rect)
81 }88 }
82 m.pane.resizeMu.Unlock()89 m.pane.resizeMu.Unlock()
83 }90 }
84 return true91 return true
85 }92 }
86 93
94+// replayResizePending completes a pane's apply epoch. It keeps the gate set
95+// while each buffered batch goes through the normal VT path, so reads arriving
96+// during replay join the same ordered stream. Failure deliberately omits the
97+// Screen.Resize, parsing every byte at the old dimensions.
98+func (d *Daemon) replayResizePending(sess *session, tb *tab, p *pane, resized bool, size domain.Rect) {
99+ first := true
100+ for {
101+ p.mu.Lock()
102+ if first && resized {
103+ p.screen.Resize(size.Width, size.Height)
104+ }
105+ first = false
106+ data := append([]byte(nil), p.resizePending...)
107+ p.resizePending = p.resizePending[:0]
108+ if len(data) == 0 {
109+ p.resizeApplying = false
110+ p.mu.Unlock()
111+ return
112+ }
113+ p.mu.Unlock()
114+ if sess == nil {
115+ p.mu.Lock()
116+ p.screen.Write(data)
117+ p.refreshTerminalTitleLocked()
118+ p.mu.Unlock()
119+ continue
120+ }
121+ d.processPTYData(sess, tb, p, data, false)
122+ }
123+}
124+
87 func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan) {125 func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan) {
88 // Publish tab layout and every rectangle together only after the coordinator126 // Publish tab layout and every rectangle together only after the coordinator
89 // has rejected obsolete work. A later request may have applied already, but127 // has rejected obsolete work. A later request may have applied already, but
@@ -149,6 +187,10 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch u
149 // A successful transaction publishes exactly one full S2 frame. The187 // A successful transaction publishes exactly one full S2 frame. The
150 // coordinator is the only emission route and stale epochs never reach it.188 // coordinator is the only emission route and stale epochs never reach it.
151 rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})189 rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})
190+ // The resize debounce has already elapsed. Consume this sticky reset now;
191+ // fire preserves ACK and synchronized-output gates rather than scheduling a
192+ // second urgent deadline.
193+ rc.fireCurrent(false)
152 }194 }
153 195
154 // retryResizeMembers retries only members which failed the committed epoch.196 // retryResizeMembers retries only members which failed the committed epoch.
M internal/usecase/daemon/transactional_resize_test.go +4 -13
@@ -174,8 +174,8 @@ func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testi
174174 for _, size := range []domain.Size{{Cols: 90, Rows: 24}, {Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}} {
175175 d.resize(sess, ac, size)
176176 }
177- // Current PR #71 applies each resize before its debounce callback. S3 must
178- // leave this empty until the final callback's prepare/apply/commit epoch.
177+ // Epoch scheduling leaves PTY geometry untouched until the final
178+ // prepare/apply/commit callback.
179179 require.Empty(t, pty.requested(), "obsolete requests must not resize the PTY")
180180
181181 var timers []*coordinatorMockTimer
@@ -192,17 +192,8 @@ func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testi
192192 snapshot := sess.renderCoordinator().resizeSnapshot()
193193 require.Equal(t, uint64(3), snapshot.epoch)
194194 require.Equal(t, snapshot.epoch, snapshot.committed)
195- // Flush the separately coalesced render invalidation; resize commits do not
196- // bypass the S2 coordinator deadline. The transaction publishes committed
197- // metadata immediately before enqueueing that invalidation, so observe the
198- // enqueue explicitly rather than racing the callback goroutine.
199- rc := sess.renderCoordinator()
200- require.Eventually(t, func() bool {
201- rc.mu.Lock()
202- defer rc.mu.Unlock()
203- return rc.pending
204- }, time.Second, time.Millisecond)
205- rc.fireCurrent(false)
195+ // The resize deadline is the only debounce: commit fires its sticky reset
196+ // through S2 immediately (subject to ACK/sync gates).
206197 frame := <-frames
207198 require.Equal(t, ports.MsgOutput, frame.Type, "the committed epoch emits a full frame")
208199 select {
@@ -174,8 +174,8 @@ func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testi
174 for _, size := range []domain.Size{{Cols: 90, Rows: 24}, {Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}} {174 for _, size := range []domain.Size{{Cols: 90, Rows: 24}, {Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}} {
175 d.resize(sess, ac, size)175 d.resize(sess, ac, size)
176 }176 }
177- // Current PR #71 applies each resize before its debounce callback. S3 must177+ // Epoch scheduling leaves PTY geometry untouched until the final
178- // leave this empty until the final callback's prepare/apply/commit epoch.178+ // prepare/apply/commit callback.
179 require.Empty(t, pty.requested(), "obsolete requests must not resize the PTY")179 require.Empty(t, pty.requested(), "obsolete requests must not resize the PTY")
180 180
181 var timers []*coordinatorMockTimer181 var timers []*coordinatorMockTimer
@@ -192,17 +192,8 @@ func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testi
192 snapshot := sess.renderCoordinator().resizeSnapshot()192 snapshot := sess.renderCoordinator().resizeSnapshot()
193 require.Equal(t, uint64(3), snapshot.epoch)193 require.Equal(t, uint64(3), snapshot.epoch)
194 require.Equal(t, snapshot.epoch, snapshot.committed)194 require.Equal(t, snapshot.epoch, snapshot.committed)
195- // Flush the separately coalesced render invalidation; resize commits do not195+ // The resize deadline is the only debounce: commit fires its sticky reset
196- // bypass the S2 coordinator deadline. The transaction publishes committed196+ // through S2 immediately (subject to ACK/sync gates).
197- // metadata immediately before enqueueing that invalidation, so observe the
198- // enqueue explicitly rather than racing the callback goroutine.
199- rc := sess.renderCoordinator()
200- require.Eventually(t, func() bool {
201- rc.mu.Lock()
202- defer rc.mu.Unlock()
203- return rc.pending
204- }, time.Second, time.Millisecond)
205- rc.fireCurrent(false)
206 frame := <-frames197 frame := <-frames
207 require.Equal(t, ports.MsgOutput, frame.Type, "the committed epoch emits a full frame")198 require.Equal(t, ports.MsgOutput, frame.Type, "the committed epoch emits a full frame")
208 select {199 select {

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