fix(daemon): replay PTY output across resize apply
- commit
- 83dcf79aa8e53b4113bcfbba509da17ed07d1f6c
- author
- brice · · 2026-07-12 09:09 UTC
- parent
- dce9117
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +160 -124
M internal/usecase/daemon/pane.go +17 -9
| @@ -16,15 +16,23 @@ import ( | |||
| 16 | 16 | // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |
| 17 | 17 | // The PTY reader takes only pane.mu, so child output never waits on client IO. | |
| 18 | 18 | 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 | |
| 28 | 36 | popupGeometry floatingGeometry // last geometry committed after a successful floating resize | |
| 29 | 37 | title paneTitleState | |
| 30 | 38 | 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.PaneID | 19 | + | id layout.PaneID |
| 20 | - | stableID string | 20 | + | stableID string |
| 21 | - | pty ports.PTY | 21 | + | pty ports.PTY |
| 22 | - | mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title | 22 | + | mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title |
| 23 | - | resizeMu sync.Mutex // serializes PTY resizes without holding mu | 23 | + | resizeMu sync.Mutex // serializes PTY resizes without holding mu |
| 24 | - | screen *vt.Screen | 24 | + | screen *vt.Screen |
| 25 | - | scrollback *scopy.Scrollback | 25 | + | scrollback *scopy.Scrollback |
| 26 | - | syncGen uint64 | 26 | + | syncGen uint64 |
| 27 | - | rect domain.Rect | 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 | |||
| 28 | popupGeometry floatingGeometry // last geometry committed after a successful floating resize | 36 | popupGeometry floatingGeometry // last geometry committed after a successful floating resize | ||
| 29 | title paneTitleState | 37 | title paneTitleState | ||
| 30 | ctx context.Context | 38 | ctx context.Context | ||
M internal/usecase/daemon/render.go +88 -93
| @@ -18,13 +18,6 @@ func completedSynchronizedUpdate(data []byte) bool { | |||
| 18 | 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 | 21 | // paneRenderable reports dynamic composition visibility. Callers must not | |
| 29 | 22 | // hold coordinator locks: this acquires daemon/session/tab/pane-adjacent locks. | |
| 30 | 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 | 85 | return | |
| 93 | 86 | } | |
| 94 | 87 | buf := make([]byte, ptyReadBufSize) | |
| 95 | - | var resp []byte | |
| 96 | - | var clipboards []string | |
| 97 | - | attentionCh := make(chan struct{}, 1) | |
| 98 | 88 | 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) } | |
| 104 | 94 | p.mu.Unlock() | |
| 105 | 95 | for { | |
| 106 | 96 | n, err := p.pty.Read(buf) | |
| 107 | 97 | 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) | |
| 186 | 99 | } | |
| 187 | 100 | if err != nil { | |
| 188 | 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 | 193 | // paint draws the composed client frame (active tab plus status bar) and | |
| 199 | 194 | // sends the resulting bytes. The renderer shadow is reset on explicit invalidations | |
| 200 | 195 | // 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 not | 21 | // 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 | return | 85 | 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) and | 193 | // paint draws the composed client frame (active tab plus status bar) and | ||
| 199 | // sends the resulting bytes. The renderer shadow is reset on explicit invalidations | 194 | // 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 { | |||
| 67 | 67 | ||
| 68 | 68 | // resizeRequestMetadata is the coordinator-owned latest requested resize | |
| 69 | 69 | // 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. | |
| 72 | 72 | type resizeRequestMetadata struct { | |
| 73 | 73 | size domain.Size | |
| 74 | 74 | source *attachedClient | |
| @@ -742,7 +742,7 @@ func (c *renderCoordinator) noteSessionTeardown() { | |||
| 742 | 742 | } | |
| 743 | 743 | ||
| 744 | 744 | // 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 | |
| 746 | 746 | // the strictly monotonically increased epoch, or 0 when the source is stale. | |
| 747 | 747 | func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 { | |
| 748 | 748 | c.mu.Lock() | |
| @@ -67,8 +67,8 @@ type renderWake struct { | |||||
| 67 | 67 | ||||
| 68 | // resizeRequestMetadata is the coordinator-owned latest requested resize | 68 | // resizeRequestMetadata is the coordinator-owned latest requested resize | ||
| 69 | // state: S3's transaction entry point reads it through resizeSnapshot. It is | 69 | // 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 | 70 | + | // 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.Size | 73 | size domain.Size | ||
| 74 | source *attachedClient | 74 | source *attachedClient | ||
| @@ -742,7 +742,7 @@ func (c *renderCoordinator) noteSessionTeardown() { | |||||
| 742 | } | 742 | } | ||
| 743 | 743 | ||||
| 744 | // recordResizeRequest records the latest requested geometry and source before | 744 | // 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 |
| 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) { | |||
| 1447 | 1447 | }) | |
| 1448 | 1448 | } | |
| 1449 | 1449 | ||
| 1450 | - | func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) { | |
| 1450 | + | func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) { | |
| 1451 | 1451 | newResizeFixture := func(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, *coordinatorMockClock, chan renderInvalidation) { | |
| 1452 | 1452 | t.Helper() | |
| 1453 | 1453 | 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 ( | |||
| 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 | |
| 11 | 12 | tab *tab | |
| 12 | 13 | pane *pane | |
| 13 | 14 | rect domain.Rect | |
| @@ -38,7 +39,7 @@ func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan { | |||
| 38 | 39 | for _, pl := range placements { | |
| 39 | 40 | if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 { | |
| 40 | 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 | 50 | if i == active && tb.floating.state == floatingVisible && tb.floating.pane != nil { | |
| 50 | 51 | g := calculateContentFloatingGeometry(tabSize(size), d.currentFloatingConfig()) | |
| 51 | 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 | 56 | tb.mu.Unlock() | |
| @@ -69,21 +70,58 @@ func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool { | |||
| 69 | 70 | m.pane.mu.Lock() | |
| 70 | 71 | old, pty := m.pane.rect, m.pane.pty | |
| 71 | 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 | 79 | 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 { | |
| 76 | 81 | m.ok = true | |
| 77 | 82 | } else if err := pty.Resize(rectSize(m.rect)); err != nil { | |
| 78 | 83 | d.log.Warn("pty resize failed", "err", err) | |
| 84 | + | d.replayResizePending(m.session, m.tab, m.pane, false, m.rect) | |
| 79 | 85 | } else { | |
| 80 | 86 | m.ok = true | |
| 87 | + | d.replayResizePending(m.session, m.tab, m.pane, true, m.rect) | |
| 81 | 88 | } | |
| 82 | 89 | m.pane.resizeMu.Unlock() | |
| 83 | 90 | } | |
| 84 | 91 | 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 | 125 | func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan) { | |
| 88 | 126 | // Publish tab layout and every rectangle together only after the coordinator | |
| 89 | 127 | // 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 | 187 | // A successful transaction publishes exactly one full S2 frame. The | |
| 150 | 188 | // coordinator is the only emission route and stale epochs never reach it. | |
| 151 | 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 | 196 | // retryResizeMembers retries only members which failed the committed epoch. | |
| @@ -8,6 +8,7 @@ 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 | tab *tab | 12 | tab *tab | ||
| 12 | pane *pane | 13 | pane *pane | ||
| 13 | rect domain.Rect | 14 | 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.pty | 71 | 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 = true | 81 | 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 = true | 86 | 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 true | 91 | 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 coordinator | 126 | // Publish tab layout and every rectangle together only after the coordinator | ||
| 89 | // has rejected obsolete work. A later request may have applied already, but | 127 | // 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. The | 187 | // 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 | |||
| 174 | 174 | for _, size := range []domain.Size{{Cols: 90, Rows: 24}, {Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}} { | |
| 175 | 175 | d.resize(sess, ac, size) | |
| 176 | 176 | } | |
| 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. | |
| 179 | 179 | require.Empty(t, pty.requested(), "obsolete requests must not resize the PTY") | |
| 180 | 180 | ||
| 181 | 181 | var timers []*coordinatorMockTimer | |
| @@ -192,17 +192,8 @@ func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testi | |||
| 192 | 192 | snapshot := sess.renderCoordinator().resizeSnapshot() | |
| 193 | 193 | require.Equal(t, uint64(3), snapshot.epoch) | |
| 194 | 194 | 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). | |
| 206 | 197 | frame := <-frames | |
| 207 | 198 | require.Equal(t, ports.MsgOutput, frame.Type, "the committed epoch emits a full frame") | |
| 208 | 199 | 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 must | 177 | + | // 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 []*coordinatorMockTimer | 181 | 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 not | 195 | + | // The resize deadline is the only debounce: commit fires its sticky reset |
| 196 | - | // bypass the S2 coordinator deadline. The transaction publishes committed | 196 | + | // 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 := <-frames | 197 | 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 { | ||