detached ef6cad6

fix(render): report immediate resize completion

commit
ef6cad648fccfba019422a6659692c6d1166e610
author
brice · · 2026-07-13 07:45 UTC
parent
b901b13
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +77 -24
M internal/usecase/daemon/render_coordinator.go +15 -6
@@ -265,16 +265,24 @@ func (c *renderCoordinator) invalidate(inv renderInvalidation) {
265265 // attachment. This check shares c.mu with replacement, making a stale
266266 // attachment's timer callback unable to enqueue work for its replacement.
267267 // A nil source represents an internal pane/session invalidation.
268-func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv renderInvalidation) {
268+func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv renderInvalidation) bool {
269+ return c.invalidateForAttachmentAtResizeEpoch(source, 0, inv)
270+}
271+
272+// invalidateForAttachmentAtResizeEpoch additionally requires epoch to remain
273+// current when nonzero, so a completed resize cannot publish after a newer
274+// request supersedes it.
275+func (c *renderCoordinator) invalidateForAttachmentAtResizeEpoch(source *attachedClient, epoch uint64, inv renderInvalidation) bool {
269276 c.mu.Lock()
270277 // An unbound coordinator is the manual/headless harness state; there is no
271278 // published production attachment to protect there. A parked target may
272279 // still publish its own PTY/session mutations to picker observers, but no
273280 // attachment-owned callback may revive that target's render path.
274281 detachedPreviewOnly := c.detached && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0)
275- if c.torndown || (c.detached && !detachedPreviewOnly) || (source != nil && c.attachment != nil && c.attachment != source) {
282+ if c.torndown || (c.detached && !detachedPreviewOnly) || (source != nil && c.attachment != nil && c.attachment != source) ||
283+ (epoch != 0 && (c.resize.epoch != epoch || c.resize.source != source)) {
276284 c.mu.Unlock()
277- return
285+ return false
278286 }
279287 onInvalidate := c.opts.onInvalidate
280288 c.metrics.invalidations.Add(1)
@@ -310,7 +318,7 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv
310318 if onInvalidate != nil {
311319 onInvalidate(inv)
312320 }
313- return
321+ return true
314322 }
315323
316324 // NewTimer and C may re-enter the coordinator. Publish only if this
@@ -330,7 +338,7 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv
330338 if onInvalidate != nil {
331339 onInvalidate(inv)
332340 }
333- return
341+ return true
334342 }
335343 if timerC == nil {
336344 stopTimer(timer)
@@ -338,7 +346,7 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv
338346 if onInvalidate != nil {
339347 onInvalidate(inv)
340348 }
341- return
349+ return true
342350 }
343351 go func() {
344352 defer close(done)
@@ -356,6 +364,7 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv
356364 onInvalidate(inv)
357365 }
358366 runtime.Gosched()
367+ return true
359368 }
360369
361370 // notifyAck reports that the client acknowledged an output state, releasing
@@ -265,16 +265,24 @@ func (c *renderCoordinator) invalidate(inv renderInvalidation) {
265 // attachment. This check shares c.mu with replacement, making a stale265 // attachment. This check shares c.mu with replacement, making a stale
266 // attachment's timer callback unable to enqueue work for its replacement.266 // attachment's timer callback unable to enqueue work for its replacement.
267 // A nil source represents an internal pane/session invalidation.267 // A nil source represents an internal pane/session invalidation.
268-func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv renderInvalidation) {268+func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv renderInvalidation) bool {
269+ return c.invalidateForAttachmentAtResizeEpoch(source, 0, inv)
270+}
271+
272+// invalidateForAttachmentAtResizeEpoch additionally requires epoch to remain
273+// current when nonzero, so a completed resize cannot publish after a newer
274+// request supersedes it.
275+func (c *renderCoordinator) invalidateForAttachmentAtResizeEpoch(source *attachedClient, epoch uint64, inv renderInvalidation) bool {
269 c.mu.Lock()276 c.mu.Lock()
270 // An unbound coordinator is the manual/headless harness state; there is no277 // An unbound coordinator is the manual/headless harness state; there is no
271 // published production attachment to protect there. A parked target may278 // published production attachment to protect there. A parked target may
272 // still publish its own PTY/session mutations to picker observers, but no279 // still publish its own PTY/session mutations to picker observers, but no
273 // attachment-owned callback may revive that target's render path.280 // attachment-owned callback may revive that target's render path.
274 detachedPreviewOnly := c.detached && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0)281 detachedPreviewOnly := c.detached && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0)
275- if c.torndown || (c.detached && !detachedPreviewOnly) || (source != nil && c.attachment != nil && c.attachment != source) {282+ if c.torndown || (c.detached && !detachedPreviewOnly) || (source != nil && c.attachment != nil && c.attachment != source) ||
283+ (epoch != 0 && (c.resize.epoch != epoch || c.resize.source != source)) {
276 c.mu.Unlock()284 c.mu.Unlock()
277- return285+ return false
278 }286 }
279 onInvalidate := c.opts.onInvalidate287 onInvalidate := c.opts.onInvalidate
280 c.metrics.invalidations.Add(1)288 c.metrics.invalidations.Add(1)
@@ -310,7 +318,7 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv
310 if onInvalidate != nil {318 if onInvalidate != nil {
311 onInvalidate(inv)319 onInvalidate(inv)
312 }320 }
313- return321+ return true
314 }322 }
315 323
316 // NewTimer and C may re-enter the coordinator. Publish only if this324 // NewTimer and C may re-enter the coordinator. Publish only if this
@@ -330,7 +338,7 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv
330 if onInvalidate != nil {338 if onInvalidate != nil {
331 onInvalidate(inv)339 onInvalidate(inv)
332 }340 }
333- return341+ return true
334 }342 }
335 if timerC == nil {343 if timerC == nil {
336 stopTimer(timer)344 stopTimer(timer)
@@ -338,7 +346,7 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv
338 if onInvalidate != nil {346 if onInvalidate != nil {
339 onInvalidate(inv)347 onInvalidate(inv)
340 }348 }
341- return349+ return true
342 }350 }
343 go func() {351 go func() {
344 defer close(done)352 defer close(done)
@@ -356,6 +364,7 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv
356 onInvalidate(inv)364 onInvalidate(inv)
357 }365 }
358 runtime.Gosched()366 runtime.Gosched()
367+ return true
359 }368 }
360 369
361 // notifyAck reports that the client acknowledged an output state, releasing370 // notifyAck reports that the client acknowledged an output state, releasing
M internal/usecase/daemon/render_coordinator_test.go +48 -7
@@ -1559,14 +1559,55 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) {
15591559 requireNoCoordinatorOutputFrame(t, sends)
15601560 })
15611561
1562- t.Run("async request reports coordinator schedule acceptance", func(t *testing.T) {
1563- d, sess, ac, _, clk, _ := newResizeFixture(t)
1562+ t.Run("request reports immediate completion and async schedule acceptance", func(t *testing.T) {
1563+ t.Run("immediate reset invalidation", func(t *testing.T) {
1564+ d, sess, ac, _, _, invs := newResizeFixture(t)
15641565
1565- require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, false))
1566- awaitCoordinatorScheduledTimer(t, clk)
1566+ require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, true))
1567+ inv := awaitInvalidation(t, invs)
1568+ require.True(t, inv.reset)
1569+ })
1570+
1571+ t.Run("superseded immediate transaction", func(t *testing.T) {
1572+ resizeStarted := make(chan struct{})
1573+ releaseResize := make(chan struct{})
1574+ pty := &transactionalResizePTY{onResize: func() {
1575+ close(resizeStarted)
1576+ <-releaseResize
1577+ }}
1578+ d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
1579+ clk := newCoordinatorMockClock(t, 2)
1580+ d.clock = clk.clock
1581+ invs := make(chan renderInvalidation, 1)
1582+ rc := newRenderCoordinator(renderCoordinatorOptions{
1583+ clock: clk.clock,
1584+ wake: func(renderWake) {},
1585+ onInvalidate: func(inv renderInvalidation) { invs <- inv },
1586+ })
1587+ rc.attach(ac)
1588+ sess.installRenderCoordinator(rc)
1589+
1590+ result := make(chan bool, 1)
1591+ go func() {
1592+ result <- d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, true)
1593+ }()
1594+ <-resizeStarted
1595+ require.NotZero(t, rc.recordResizeRequest(domain.Size{Cols: 140, Rows: 30}, ac))
1596+ close(releaseResize)
1597+
1598+ require.False(t, <-result)
1599+ requireNoInvalidation(t, invs)
1600+ })
1601+
1602+ t.Run("async schedule and teardown rejection", func(t *testing.T) {
1603+ d, sess, ac, _, clk, _ := newResizeFixture(t)
15671604
1568- // A torn-down coordinator cannot accept a stale attachment's schedule.
1569- sess.renderCoordinator().noteSessionTeardown()
1570- require.False(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 140, Rows: 30}, false))
1605+ require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, false))
1606+ awaitCoordinatorScheduledTimer(t, clk)
1607+
1608+ // A torn-down coordinator cannot accept a stale attachment's schedule.
1609+ sess.renderCoordinator().noteSessionTeardown()
1610+ require.False(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 140, Rows: 30}, false))
1611+ })
15711612 })
15721613 }
@@ -1559,14 +1559,55 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) {
1559 requireNoCoordinatorOutputFrame(t, sends)1559 requireNoCoordinatorOutputFrame(t, sends)
1560 })1560 })
1561 1561
1562- t.Run("async request reports coordinator schedule acceptance", func(t *testing.T) {1562+ t.Run("request reports immediate completion and async schedule acceptance", func(t *testing.T) {
1563- d, sess, ac, _, clk, _ := newResizeFixture(t)1563+ t.Run("immediate reset invalidation", func(t *testing.T) {
1564+ d, sess, ac, _, _, invs := newResizeFixture(t)
1564 1565
1565- require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, false))1566+ require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, true))
1566- awaitCoordinatorScheduledTimer(t, clk)1567+ inv := awaitInvalidation(t, invs)
1568+ require.True(t, inv.reset)
1569+ })
1570+
1571+ t.Run("superseded immediate transaction", func(t *testing.T) {
1572+ resizeStarted := make(chan struct{})
1573+ releaseResize := make(chan struct{})
1574+ pty := &transactionalResizePTY{onResize: func() {
1575+ close(resizeStarted)
1576+ <-releaseResize
1577+ }}
1578+ d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
1579+ clk := newCoordinatorMockClock(t, 2)
1580+ d.clock = clk.clock
1581+ invs := make(chan renderInvalidation, 1)
1582+ rc := newRenderCoordinator(renderCoordinatorOptions{
1583+ clock: clk.clock,
1584+ wake: func(renderWake) {},
1585+ onInvalidate: func(inv renderInvalidation) { invs <- inv },
1586+ })
1587+ rc.attach(ac)
1588+ sess.installRenderCoordinator(rc)
1589+
1590+ result := make(chan bool, 1)
1591+ go func() {
1592+ result <- d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, true)
1593+ }()
1594+ <-resizeStarted
1595+ require.NotZero(t, rc.recordResizeRequest(domain.Size{Cols: 140, Rows: 30}, ac))
1596+ close(releaseResize)
1597+
1598+ require.False(t, <-result)
1599+ requireNoInvalidation(t, invs)
1600+ })
1601+
1602+ t.Run("async schedule and teardown rejection", func(t *testing.T) {
1603+ d, sess, ac, _, clk, _ := newResizeFixture(t)
1567 1604
1568- // A torn-down coordinator cannot accept a stale attachment's schedule.1605+ require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, false))
1569- sess.renderCoordinator().noteSessionTeardown()1606+ awaitCoordinatorScheduledTimer(t, clk)
1570- require.False(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 140, Rows: 30}, false))1607+
1608+ // A torn-down coordinator cannot accept a stale attachment's schedule.
1609+ sess.renderCoordinator().noteSessionTeardown()
1610+ require.False(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 140, Rows: 30}, false))
1611+ })
1571 })1612 })
1572 }1613 }
M internal/usecase/daemon/transactional_resize.go +14 -11
@@ -163,25 +163,25 @@ func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan
163163 markSnapshotDirty(sess)
164164 }
165165
166-func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch uint64) {
166+func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch uint64) bool {
167167 rc := sess.renderCoordinator()
168168 if rc == nil {
169- return
169+ return false
170170 }
171171 snap := rc.resizeSnapshot()
172172 if !rc.resizeCurrent(epoch, ac, false) {
173- return
173+ return false
174174 }
175175 d.exitCopyMode(ac)
176176 plan := d.prepareResize(sess, snap.size)
177177 if !rc.resizeCurrent(epoch, ac, false) {
178- return
178+ return false
179179 }
180180 if !d.applyResize(&plan, func() bool { return rc.resizeCurrent(epoch, ac, false) }) {
181- return
181+ return false
182182 }
183183 if !rc.resizeCurrent(epoch, ac, true) {
184- return
184+ return false
185185 }
186186 d.commitResize(sess, ac, plan)
187187 failed := make([]resizeMember, 0, len(plan.members))
@@ -196,11 +196,14 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch u
196196 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
197197 // A successful transaction publishes exactly one full S2 frame. The
198198 // coordinator is the only emission route and stale epochs never reach it.
199- rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})
199+ if !rc.invalidateForAttachmentAtResizeEpoch(ac, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
200+ return false
201+ }
200202 // The resize debounce has already elapsed. Consume this sticky reset now;
201203 // fire preserves ACK and synchronized-output gates rather than scheduling a
202204 // second urgent deadline.
203205 rc.fireCurrent(false)
206+ return true
204207 }
205208
206209 // retryResizeMembers retries only members which failed the committed epoch.
@@ -242,8 +245,9 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, epoch uin
242245 }
243246 }
244247
245-// requestTransactionalResize reports whether the coordinator accepted the
246-// request. Immediate requests complete synchronously before it returns.
248+// requestTransactionalResize reports reset invalidation completion for immediate
249+// attached requests, and coordinator schedule acceptance for async requests.
250+// Headless requests have no reset invalidation and report geometry completion.
247251 func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) bool {
248252 if sess == nil || !size.Valid() {
249253 return false
@@ -262,8 +266,7 @@ func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, s
262266 if epoch == 0 {
263267 return false
264268 }
265- d.runResizeTransaction(sess, ac, epoch)
266- return true
269+ return d.runResizeTransaction(sess, ac, epoch)
267270 }
268271 return rc.scheduleResize(size, ac, func(epoch uint64) { d.runResizeTransaction(sess, ac, epoch) }) != 0
269272 }
@@ -163,25 +163,25 @@ func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan
163 markSnapshotDirty(sess)163 markSnapshotDirty(sess)
164 }164 }
165 165
166-func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch uint64) {166+func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch uint64) bool {
167 rc := sess.renderCoordinator()167 rc := sess.renderCoordinator()
168 if rc == nil {168 if rc == nil {
169- return169+ return false
170 }170 }
171 snap := rc.resizeSnapshot()171 snap := rc.resizeSnapshot()
172 if !rc.resizeCurrent(epoch, ac, false) {172 if !rc.resizeCurrent(epoch, ac, false) {
173- return173+ return false
174 }174 }
175 d.exitCopyMode(ac)175 d.exitCopyMode(ac)
176 plan := d.prepareResize(sess, snap.size)176 plan := d.prepareResize(sess, snap.size)
177 if !rc.resizeCurrent(epoch, ac, false) {177 if !rc.resizeCurrent(epoch, ac, false) {
178- return178+ return false
179 }179 }
180 if !d.applyResize(&plan, func() bool { return rc.resizeCurrent(epoch, ac, false) }) {180 if !d.applyResize(&plan, func() bool { return rc.resizeCurrent(epoch, ac, false) }) {
181- return181+ return false
182 }182 }
183 if !rc.resizeCurrent(epoch, ac, true) {183 if !rc.resizeCurrent(epoch, ac, true) {
184- return184+ return false
185 }185 }
186 d.commitResize(sess, ac, plan)186 d.commitResize(sess, ac, plan)
187 failed := make([]resizeMember, 0, len(plan.members))187 failed := make([]resizeMember, 0, len(plan.members))
@@ -196,11 +196,14 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch u
196 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)196 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
197 // A successful transaction publishes exactly one full S2 frame. The197 // A successful transaction publishes exactly one full S2 frame. The
198 // coordinator is the only emission route and stale epochs never reach it.198 // coordinator is the only emission route and stale epochs never reach it.
199- rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})199+ if !rc.invalidateForAttachmentAtResizeEpoch(ac, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
200+ return false
201+ }
200 // The resize debounce has already elapsed. Consume this sticky reset now;202 // The resize debounce has already elapsed. Consume this sticky reset now;
201 // fire preserves ACK and synchronized-output gates rather than scheduling a203 // fire preserves ACK and synchronized-output gates rather than scheduling a
202 // second urgent deadline.204 // second urgent deadline.
203 rc.fireCurrent(false)205 rc.fireCurrent(false)
206+ return true
204 }207 }
205 208
206 // retryResizeMembers retries only members which failed the committed epoch.209 // retryResizeMembers retries only members which failed the committed epoch.
@@ -242,8 +245,9 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, epoch uin
242 }245 }
243 }246 }
244 247
245-// requestTransactionalResize reports whether the coordinator accepted the248+// requestTransactionalResize reports reset invalidation completion for immediate
246-// request. Immediate requests complete synchronously before it returns.249+// attached requests, and coordinator schedule acceptance for async requests.
250+// Headless requests have no reset invalidation and report geometry completion.
247 func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) bool {251 func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) bool {
248 if sess == nil || !size.Valid() {252 if sess == nil || !size.Valid() {
249 return false253 return false
@@ -262,8 +266,7 @@ func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, s
262 if epoch == 0 {266 if epoch == 0 {
263 return false267 return false
264 }268 }
265- d.runResizeTransaction(sess, ac, epoch)269+ return d.runResizeTransaction(sess, ac, epoch)
266- return true
267 }270 }
268 return rc.scheduleResize(size, ac, func(epoch uint64) { d.runResizeTransaction(sess, ac, epoch) }) != 0271 return rc.scheduleResize(size, ac, func(epoch uint64) { d.runResizeTransaction(sess, ac, epoch) }) != 0
269 }272 }

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